aicodeman 1.30.0 → 1.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/dist/config/cli-registry/schema.d.ts +8 -0
  2. package/dist/config/cli-registry/schema.d.ts.map +1 -1
  3. package/dist/config/cli-registry/schema.js +29 -0
  4. package/dist/config/cli-registry/schema.js.map +1 -1
  5. package/dist/config/cli-registry/stock.d.ts.map +1 -1
  6. package/dist/config/cli-registry/stock.js +62 -7
  7. package/dist/config/cli-registry/stock.js.map +1 -1
  8. package/dist/config/cli-registry/types.d.ts +62 -0
  9. package/dist/config/cli-registry/types.d.ts.map +1 -1
  10. package/dist/config/remote-wake-limits.d.ts +23 -0
  11. package/dist/config/remote-wake-limits.d.ts.map +1 -0
  12. package/dist/config/remote-wake-limits.js +23 -0
  13. package/dist/config/remote-wake-limits.js.map +1 -0
  14. package/dist/custom-model-hosts.d.ts +32 -0
  15. package/dist/custom-model-hosts.d.ts.map +1 -1
  16. package/dist/custom-model-hosts.js.map +1 -1
  17. package/dist/custom-model-injection-apply.d.ts +29 -1
  18. package/dist/custom-model-injection-apply.d.ts.map +1 -1
  19. package/dist/custom-model-injection-apply.js +191 -5
  20. package/dist/custom-model-injection-apply.js.map +1 -1
  21. package/dist/custom-model-injection.d.ts +35 -9
  22. package/dist/custom-model-injection.d.ts.map +1 -1
  23. package/dist/custom-model-injection.js +37 -16
  24. package/dist/custom-model-injection.js.map +1 -1
  25. package/dist/mux-interface.d.ts +12 -0
  26. package/dist/mux-interface.d.ts.map +1 -1
  27. package/dist/remote-hosts.d.ts +20 -0
  28. package/dist/remote-hosts.d.ts.map +1 -1
  29. package/dist/remote-hosts.js +33 -0
  30. package/dist/remote-hosts.js.map +1 -1
  31. package/dist/remote-wake.d.ts +446 -0
  32. package/dist/remote-wake.d.ts.map +1 -0
  33. package/dist/remote-wake.js +880 -0
  34. package/dist/remote-wake.js.map +1 -0
  35. package/dist/session-env-clamp.d.ts +12 -7
  36. package/dist/session-env-clamp.d.ts.map +1 -1
  37. package/dist/session-env-clamp.js +12 -7
  38. package/dist/session-env-clamp.js.map +1 -1
  39. package/dist/session-submit-verifier.d.ts +44 -0
  40. package/dist/session-submit-verifier.d.ts.map +1 -0
  41. package/dist/session-submit-verifier.js +122 -0
  42. package/dist/session-submit-verifier.js.map +1 -0
  43. package/dist/session.d.ts +9 -0
  44. package/dist/session.d.ts.map +1 -1
  45. package/dist/session.js +42 -1
  46. package/dist/session.js.map +1 -1
  47. package/dist/tmux-manager.d.ts.map +1 -1
  48. package/dist/tmux-manager.js +9 -0
  49. package/dist/tmux-manager.js.map +1 -1
  50. package/dist/types/session.d.ts +26 -0
  51. package/dist/types/session.d.ts.map +1 -1
  52. package/dist/types/session.js.map +1 -1
  53. package/dist/web/public/admin-ui.js.gz +0 -0
  54. package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
  55. package/dist/web/public/app.8df358cd.js +38 -0
  56. package/dist/web/public/app.8df358cd.js.br +0 -0
  57. package/dist/web/public/app.8df358cd.js.gz +0 -0
  58. package/dist/web/public/approvals-ui.js.gz +0 -0
  59. package/dist/web/public/{constants.258b140f.js → constants.85ed12f3.js} +74 -0
  60. package/dist/web/public/constants.85ed12f3.js.br +0 -0
  61. package/dist/web/public/constants.85ed12f3.js.gz +0 -0
  62. package/dist/web/public/cron-ui.js.gz +0 -0
  63. package/dist/web/public/entrance-animations.js.gz +0 -0
  64. package/dist/web/public/home-sessions.js.gz +0 -0
  65. package/dist/web/public/host-wake-ui.js +440 -0
  66. package/dist/web/public/host-wake-ui.js.br +0 -0
  67. package/dist/web/public/host-wake-ui.js.gz +0 -0
  68. package/dist/web/public/i18n.83924614.js +1 -0
  69. package/dist/web/public/i18n.83924614.js.br +0 -0
  70. package/dist/web/public/i18n.83924614.js.gz +0 -0
  71. package/dist/web/public/image-input.cd4b97c4.js.gz +0 -0
  72. package/dist/web/public/index.html +186 -10
  73. package/dist/web/public/index.html.br +0 -0
  74. package/dist/web/public/index.html.gz +0 -0
  75. package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
  76. package/dist/web/public/keyboard-accessory.2cf04f17.js.gz +0 -0
  77. package/dist/web/public/mobile-handlers.6f354a87.js.gz +0 -0
  78. package/dist/web/public/mobile-overview.js.gz +0 -0
  79. package/dist/web/public/mobile.6caaa28a.css.gz +0 -0
  80. package/dist/web/public/notification-manager.36ea4624.js.gz +0 -0
  81. package/dist/web/public/orchestrator-panel.js.gz +0 -0
  82. package/dist/web/public/{panels-ui.5b07ad14.js → panels-ui.28df5053.js} +5 -5
  83. package/dist/web/public/panels-ui.28df5053.js.br +0 -0
  84. package/dist/web/public/panels-ui.28df5053.js.gz +0 -0
  85. package/dist/web/public/ralph-panel.6de2d0f8.js.gz +0 -0
  86. package/dist/web/public/ralph-wizard.13a1831e.js.gz +0 -0
  87. package/dist/web/public/readmymind-ui.js.gz +0 -0
  88. package/dist/web/public/reboot-restore-ui.js.gz +0 -0
  89. package/dist/web/public/respawn-ui.ff0dae4c.js.gz +0 -0
  90. package/dist/web/public/sanitize-html.bc7078d6.js.gz +0 -0
  91. package/dist/web/public/session-lineage.js.gz +0 -0
  92. package/dist/web/public/session-ui.d4aea71a.js +88 -0
  93. package/dist/web/public/session-ui.d4aea71a.js.br +0 -0
  94. package/dist/web/public/session-ui.d4aea71a.js.gz +0 -0
  95. package/dist/web/public/settings-ui.4762f480.js +78 -0
  96. package/dist/web/public/settings-ui.4762f480.js.br +0 -0
  97. package/dist/web/public/settings-ui.4762f480.js.gz +0 -0
  98. package/dist/web/public/styles.e34fea04.css +1 -0
  99. package/dist/web/public/styles.e34fea04.css.br +0 -0
  100. package/dist/web/public/styles.e34fea04.css.gz +0 -0
  101. package/dist/web/public/subagent-windows.e6ca799f.js.gz +0 -0
  102. package/dist/web/public/sw.js.gz +0 -0
  103. package/dist/web/public/tab-rail-resize.42c24949.js.gz +0 -0
  104. package/dist/web/public/terminal-keycode229-recovery.fb91b25b.js.gz +0 -0
  105. package/dist/web/public/terminal-ui.35812aa2.js +2 -0
  106. package/dist/web/public/terminal-ui.35812aa2.js.br +0 -0
  107. package/dist/web/public/terminal-ui.35812aa2.js.gz +0 -0
  108. package/dist/web/public/ultracode-panel.js.gz +0 -0
  109. package/dist/web/public/ultracode-windows.js.gz +0 -0
  110. package/dist/web/public/upload.html.gz +0 -0
  111. package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
  112. package/dist/web/public/vendor/marked.min.js.gz +0 -0
  113. package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
  114. package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
  115. package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
  116. package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
  117. package/dist/web/public/vendor/xterm-predictive-echo.bd6882b8.js.gz +0 -0
  118. package/dist/web/public/vendor/xterm-zerolag-input.6fee72f2.js.gz +0 -0
  119. package/dist/web/public/vendor/xterm.css.gz +0 -0
  120. package/dist/web/public/vendor/xterm.min.js.gz +0 -0
  121. package/dist/web/public/voice-input.c4b51eb6.js.gz +0 -0
  122. package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
  123. package/dist/web/public/webview-tabs.js.gz +0 -0
  124. package/dist/web/routes/custom-model-routes.d.ts +173 -0
  125. package/dist/web/routes/custom-model-routes.d.ts.map +1 -1
  126. package/dist/web/routes/custom-model-routes.js +628 -11
  127. package/dist/web/routes/custom-model-routes.js.map +1 -1
  128. package/dist/web/routes/index.d.ts +1 -1
  129. package/dist/web/routes/index.d.ts.map +1 -1
  130. package/dist/web/routes/index.js +1 -1
  131. package/dist/web/routes/index.js.map +1 -1
  132. package/dist/web/routes/session-routes.d.ts +6 -1
  133. package/dist/web/routes/session-routes.d.ts.map +1 -1
  134. package/dist/web/routes/session-routes.js +448 -17
  135. package/dist/web/routes/session-routes.js.map +1 -1
  136. package/dist/web/schemas.d.ts +15 -0
  137. package/dist/web/schemas.d.ts.map +1 -1
  138. package/dist/web/schemas.js +76 -0
  139. package/dist/web/schemas.js.map +1 -1
  140. package/dist/web/server.d.ts +21 -0
  141. package/dist/web/server.d.ts.map +1 -1
  142. package/dist/web/server.js +130 -5
  143. package/dist/web/server.js.map +1 -1
  144. package/dist/web/sse-events.d.ts +25 -2
  145. package/dist/web/sse-events.d.ts.map +1 -1
  146. package/dist/web/sse-events.js +30 -3
  147. package/dist/web/sse-events.js.map +1 -1
  148. package/package.json +1 -1
  149. package/skills/codeman/SKILL.md +75 -26
  150. package/skills/codeman/preamble.sh +66 -19
  151. package/skills/codeman/reference/recipes.md +1 -1
  152. package/dist/web/public/app.6d2dc4e8.js +0 -38
  153. package/dist/web/public/app.6d2dc4e8.js.br +0 -0
  154. package/dist/web/public/app.6d2dc4e8.js.gz +0 -0
  155. package/dist/web/public/constants.258b140f.js.br +0 -0
  156. package/dist/web/public/constants.258b140f.js.gz +0 -0
  157. package/dist/web/public/i18n.5f897ed5.js +0 -1
  158. package/dist/web/public/i18n.5f897ed5.js.br +0 -0
  159. package/dist/web/public/i18n.5f897ed5.js.gz +0 -0
  160. package/dist/web/public/panels-ui.5b07ad14.js.br +0 -0
  161. package/dist/web/public/panels-ui.5b07ad14.js.gz +0 -0
  162. package/dist/web/public/session-ui.42b81477.js +0 -77
  163. package/dist/web/public/session-ui.42b81477.js.br +0 -0
  164. package/dist/web/public/session-ui.42b81477.js.gz +0 -0
  165. package/dist/web/public/settings-ui.58f1d756.js +0 -67
  166. package/dist/web/public/settings-ui.58f1d756.js.br +0 -0
  167. package/dist/web/public/settings-ui.58f1d756.js.gz +0 -0
  168. package/dist/web/public/styles.e6edfb0b.css +0 -1
  169. package/dist/web/public/styles.e6edfb0b.css.br +0 -0
  170. package/dist/web/public/styles.e6edfb0b.css.gz +0 -0
  171. package/dist/web/public/terminal-ui.4f8d5820.js +0 -2
  172. package/dist/web/public/terminal-ui.4f8d5820.js.br +0 -0
  173. package/dist/web/public/terminal-ui.4f8d5820.js.gz +0 -0
@@ -0,0 +1,880 @@
1
+ /**
2
+ * @fileoverview Wake a SLEEPING remote host from user input (user-triggered Wake-on-LAN).
3
+ *
4
+ * A durable remote session survives SSH drops (COD-104) and auto-reconnects
5
+ * (COD-108), but nothing brings the HOST back: if the remote machine suspended,
6
+ * the local tmux pane's `ssh` child stalls silently. `tmux send-keys` then
7
+ * SUCCEEDS against a pane that will never deliver the bytes, so typed input is
8
+ * lost with no error anywhere — the failure this module exists to close.
9
+ *
10
+ * Design (deliberately narrow, see docs/remote-sessions.md §Wake-on-LAN):
11
+ * - An EXPLICIT request wakes a host, and nothing else: user input on an
12
+ * established session (`handleInput`), the wake button (`ensureAwake`), or the
13
+ * user's own session create/attach request (`ensureHostAwake`, wired in the HTTP
14
+ * routes). Everything that runs on a TIMER — the auto-reconnect watcher, boot
15
+ * recovery, the reachability probe, session discovery — must never wake one, or
16
+ * a host would be re-woken ~45 s after each suspend and could never stay asleep
17
+ * (the "keepalive pings a sleeping host" failure already solved for a different
18
+ * consumer by `hufflepuff-mcp-lazy`). The create path is deliberately wired in
19
+ * `session-routes.ts` and NOT in the shared session service, because
20
+ * `cron-service.ts` builds sessions there without a user waiting on the answer.
21
+ * - Detection is a cheap TCP connect to the SSH port (no auth, no ssh client,
22
+ * a few hundred bytes — below any meaningful activity threshold), throttled
23
+ * per session. No SSH keepalive is added to the launch command: keepalives
24
+ * would move bytes into an otherwise idle connection every interval, which is
25
+ * exactly the "an open pipe keeps the host awake" bug the remote-side idle
26
+ * detector was rewritten to avoid.
27
+ * - While a wake is in flight, input is BUFFERED and flushed in order once the
28
+ * pane is reattached, so the user's first characters after a long pause are
29
+ * not the ones that get eaten.
30
+ *
31
+ * The pure decisions and the IO are separated so the decision table can be
32
+ * unit-tested without tmux, ssh, or a real host.
33
+ *
34
+ * @module remote-wake
35
+ */
36
+ import { spawn } from 'node:child_process';
37
+ import dgram from 'node:dgram';
38
+ import net from 'node:net';
39
+ import { MAX_WAKE_MACS } from './config/remote-wake-limits.js';
40
+ /** Minimum spacing between two reachability probes for the same session. */
41
+ export const REMOTE_WAKE_PROBE_MIN_INTERVAL_MS = 30_000;
42
+ /** TCP-connect timeout for a reachability probe (host awake ≈ a few ms). */
43
+ export const REMOTE_WAKE_PROBE_TIMEOUT_MS = 1_500;
44
+ /** Poll spacing while waiting for a woken host to accept SSH again. */
45
+ export const REMOTE_WAKE_READY_INTERVAL_MS = 1_500;
46
+ /** Bounded wait for the host to come back after the wake command ran. */
47
+ export const REMOTE_WAKE_READY_TIMEOUT_MS = 90_000;
48
+ /**
49
+ * Budget for a wake that an HTTP REQUEST is waiting on (session create/attach).
50
+ * Deliberately shorter than {@link REMOTE_WAKE_READY_TIMEOUT_MS}: the dashboard is
51
+ * served through a reverse proxy whose default `proxy_read_timeout` is 60 s, so a
52
+ * 90 s wait would be cut off AT THE PROXY while the session was still being built —
53
+ * the browser reports a failure for a session that exists. The budget has to cover
54
+ * the WHOLE request, not just the wait: 40 s here + the 1.5 s reachability probe +
55
+ * the tmux prereq probe's own 15 s timeout = 56.5 s worst case, still under 60 s.
56
+ * ⚠ The wake ITSELF counts against this, which the original arithmetic omitted: a
57
+ * `command` target can spend REMOTE_WAKE_COMMAND_TIMEOUT_MS before the readiness poll
58
+ * begins, which would have made the real worst case ~68 s. `_wakeAndWait` therefore
59
+ * subtracts the wake's measured elapsed time from this budget rather than adding to it.
60
+ * A warm S3 resume measures ~12 s, so 40 s is >3× the observed wake.
61
+ */
62
+ export const REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS = 40_000;
63
+ /** The wake command itself must not hang the wake flow. */
64
+ export const REMOTE_WAKE_COMMAND_TIMEOUT_MS = 10_000;
65
+ /**
66
+ * Settle time between respawning the ssh pane and flushing buffered input: the
67
+ * respawned `ssh` needs a moment to run `tmux -L codeman-remote … -A` and attach,
68
+ * and bytes written into a still-connecting pane land in nothing.
69
+ */
70
+ export const REMOTE_WAKE_ATTACH_SETTLE_MS = 1_500;
71
+ /**
72
+ * Cap on buffered input per session while a host is being woken. 4 KB is a lot
73
+ * of typing for a ~10 s wake; beyond it the OLDEST bytes are dropped (keeping the
74
+ * tail preserves what the user just typed, and a silently unbounded buffer would
75
+ * be a memory leak keyed on user input).
76
+ */
77
+ export const REMOTE_WAKE_PENDING_MAX_BYTES = 4096;
78
+ /** Default SSH port used when the host config has no explicit `port`. */
79
+ export const DEFAULT_SSH_PORT = 22;
80
+ /**
81
+ * Decide what to do with an input chunk on an input route. Mirrors
82
+ * {@link RemoteWakeRegistry.handleInput} so the throttle table has exactly ONE
83
+ * definition and is unit-testable:
84
+ *
85
+ * - a wake already in flight → buffer (the flush owns delivery),
86
+ * - no wake command configured → deliver (feature off, today's behavior),
87
+ * - the last probe said "down" → buffer (no second probe; re-probing a known
88
+ * sleeping host on every keystroke would add seconds of latency per character),
89
+ * - never probed / throttle window elapsed → probe,
90
+ * - probed "up" inside the window → deliver.
91
+ *
92
+ * Pure — no clock, no IO.
93
+ */
94
+ export function decideRemoteInputAction(args) {
95
+ if (args.waking)
96
+ return 'buffer';
97
+ if (!args.hasWakeTarget)
98
+ return 'deliver';
99
+ if (args.lastReachable === false)
100
+ return 'buffer';
101
+ const interval = args.minProbeIntervalMs ?? REMOTE_WAKE_PROBE_MIN_INTERVAL_MS;
102
+ if (args.probeAgeMs >= interval)
103
+ return 'probe';
104
+ return 'deliver';
105
+ }
106
+ /**
107
+ * Append `data` to the pending buffer, dropping the OLDEST whole chunks when the cap
108
+ * is exceeded. Returns the resulting buffer — the SAME array reference when the chunk
109
+ * was rejected, so the caller can tell the two apart. Pure.
110
+ *
111
+ * A chunk LARGER than the cap is dropped outright rather than trimmed: one paste is
112
+ * one `input` value, and it was never typed character by character, so delivering its
113
+ * tail would execute a fragment of it (with the trailing carriage return, if the paste
114
+ * had one) — a partial command the user never sent. Keeping the tail is right for
115
+ * typing, where the newest bytes are the ones the user just produced, and wrong for a
116
+ * chunk that arrived whole.
117
+ */
118
+ export function appendBoundedPending(pending, data, maxBytes = REMOTE_WAKE_PENDING_MAX_BYTES) {
119
+ if (Buffer.byteLength(data) > maxBytes)
120
+ return pending;
121
+ const next = [...pending, data];
122
+ let total = next.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
123
+ while (next.length > 1 && total > maxBytes) {
124
+ total -= Buffer.byteLength(next[0]);
125
+ next.shift();
126
+ }
127
+ return next;
128
+ }
129
+ /**
130
+ * Whether the bare TCP probe can answer for this host at all. Pure.
131
+ *
132
+ * The probe connects straight to `host:port`. A host behind a jump host or a SOCKS
133
+ * proxy (the cloudflared case) is reachable ONLY through that proxy, so the direct
134
+ * connect fails while ssh works — and every consumer of the verdict would then act on
135
+ * a "sleeping" host that is fine: a permanent banner, a create-path gate that hides the
136
+ * real ssh error, and (with a wake target) input buffered for the life of the session
137
+ * because the readiness poll can never succeed. Such a host is reachability-UNKNOWN:
138
+ * the registry never buffers for it, never gates on it, and reports `null` rather than
139
+ * `false`. A wake target can still be fired for it, blind.
140
+ */
141
+ export function isProbeable(remote) {
142
+ if (remote.jumpHost || remote.socksProxy)
143
+ return false;
144
+ return !(remote.extraSshOptions ?? []).some((option) => /^\s*proxy(command|jump)\s*=/i.test(option));
145
+ }
146
+ /**
147
+ * Resolve the wake target from host config. Pure.
148
+ *
149
+ * A malformed `wakeMac` resolves to `null` rather than throwing: the schema
150
+ * already rejects one at config time, so this can only be reached with a config
151
+ * written by hand, and a broken MAC must not break the input route.
152
+ */
153
+ export function resolveWakeTarget(remote) {
154
+ if (!remote)
155
+ return null;
156
+ if (remote.wakeCommand)
157
+ return { kind: 'command', command: remote.wakeCommand };
158
+ if (remote.wakeMac) {
159
+ const macs = parseMacList(remote.wakeMac);
160
+ if (macs && macs.length > 0)
161
+ return { kind: 'mac', macs };
162
+ }
163
+ return null;
164
+ }
165
+ /**
166
+ * Parse a comma-separated MAC list into byte arrays. Pure; returns null when any
167
+ * entry is malformed (all-or-nothing, so a typo cannot half-arm a host).
168
+ */
169
+ export function parseMacList(value, maxMacs = MAX_WAKE_MACS) {
170
+ const parts = value
171
+ .split(',')
172
+ .map((part) => part.trim())
173
+ .filter((part) => part.length > 0);
174
+ if (parts.length === 0 || parts.length > maxMacs)
175
+ return null;
176
+ const macs = [];
177
+ for (const part of parts) {
178
+ 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);
179
+ if (!match)
180
+ return null;
181
+ macs.push(match.slice(1).map((hex) => Number.parseInt(hex, 16)));
182
+ }
183
+ return macs;
184
+ }
185
+ /**
186
+ * Build a Wake-on-LAN "magic packet": six `0xFF` bytes then the MAC repeated 16
187
+ * times. Pure — the shape is asserted byte-for-byte in the tests because a packet
188
+ * that is off by one byte simply never wakes anything.
189
+ */
190
+ export function buildMagicPacket(mac) {
191
+ const packet = Buffer.alloc(6 + 16 * 6, 0xff);
192
+ for (let repeat = 0; repeat < 16; repeat++) {
193
+ Buffer.from(mac).copy(packet, 6 + repeat * 6);
194
+ }
195
+ return packet;
196
+ }
197
+ /** Probe freshness for the UI's reachability check (a tab switch is not a hammer). */
198
+ export const REMOTE_WAKE_REACHABILITY_TTL_MS = 5_000;
199
+ /** How long a resolved host config is trusted before asking the resolver again. */
200
+ export const REMOTE_WAKE_RESOLVE_TTL_MS = 30_000;
201
+ /** Which wake path a host config provides (mirrors {@link resolveWakeTarget}). Pure. */
202
+ export function wakeConfigured(remote) {
203
+ const target = resolveWakeTarget(remote);
204
+ if (!target)
205
+ return 'none';
206
+ return target.kind;
207
+ }
208
+ /**
209
+ * State key for a host-scoped wake. Prefixed so it can never collide with a session
210
+ * id, and keyed on the HOST rather than the case: two cases on one host share a
211
+ * single in-flight wake and one probe verdict. Such an entry is tiny (no input
212
+ * buffer) and bounded by the number of configured hosts, so it is never dropped.
213
+ */
214
+ function hostWakeKey(hostId) {
215
+ return `host:${hostId}`;
216
+ }
217
+ /**
218
+ * Per-session wake state + single-flight wake flow.
219
+ *
220
+ * One instance per web server (module singleton in the routes file, like the
221
+ * signal-wait registry). State is keyed by session id and dropped with the
222
+ * session.
223
+ */
224
+ export class RemoteWakeRegistry {
225
+ deps;
226
+ states = new Map();
227
+ /**
228
+ * Aborted by {@link stop} on shutdown. Every in-flight readiness poll is holding an
229
+ * HTTP request open (the wake route blocks on it), and Fastify's `close()` waits for
230
+ * in-flight requests — so without this a restart during a wake sits out the full 90 s
231
+ * budget. The same problem `sessionWaits.cancelEverything()` exists for.
232
+ */
233
+ shutdown = new AbortController();
234
+ stopped = false;
235
+ constructor(deps) {
236
+ this.deps = deps;
237
+ }
238
+ /** Drop a session's state (session closed/killed). The pending buffer goes with it. */
239
+ drop(sessionId) {
240
+ this.states.delete(sessionId);
241
+ }
242
+ /**
243
+ * Resolve every in-flight wake as failed and refuse new ones (server shutdown).
244
+ *
245
+ * Called from `WebServer.stop()`: an in-flight wake is awaited by a request, and the
246
+ * server's own `app.close()` does not abort in-flight requests, so shutdown would wait
247
+ * out the poll. Nothing is lost by failing them — the state flush happens earlier in
248
+ * `stop()`, and the process is going away.
249
+ */
250
+ stop() {
251
+ this.stopped = true;
252
+ this.shutdown.abort();
253
+ }
254
+ /** Whether a wake is currently in flight (diagnostics/tests). */
255
+ isWaking(sessionId) {
256
+ return this.states.get(sessionId)?.waking != null;
257
+ }
258
+ /** Buffered input bytes for a session (diagnostics/tests). */
259
+ pendingBytes(sessionId) {
260
+ const state = this.states.get(sessionId);
261
+ if (!state)
262
+ return 0;
263
+ return state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
264
+ }
265
+ /**
266
+ * Number of keys with wake state (diagnostics/tests). Pins that a LOCAL session never
267
+ * gets an entry: the input gate runs on every keystroke, so an entry per local session
268
+ * would be a map the size of the session list, swept only on cleanup.
269
+ */
270
+ stateCount() {
271
+ return this.states.size;
272
+ }
273
+ /** Whether this session's host has any wake path configured at all. */
274
+ async hasWakeTarget(session) {
275
+ return resolveWakeTarget(await this._effectiveRemote(session)) !== null;
276
+ }
277
+ /** Which wake path is configured (`'none'` when the UI should offer configuration). */
278
+ async wakeConfigured(session) {
279
+ return wakeConfigured(await this._effectiveRemote(session));
280
+ }
281
+ /**
282
+ * Reachability for the UI: probe unless a recent result is still fresh. `null` for a
283
+ * host the probe cannot reach (see {@link isProbeable}): unknown is not unreachable.
284
+ *
285
+ * Shares the per-session probe state with the input path on purpose — a fresh
286
+ * answer is exactly what the input ladder wants, and an `unreachable` verdict here
287
+ * makes the next keystroke buffer + wake instead of vanishing into a stalled pane.
288
+ */
289
+ async checkReachable(session, opts = {}) {
290
+ const remote = await this._effectiveRemote(session);
291
+ if (!remote)
292
+ return true;
293
+ // `null`, never `false`: the UI keys the banner on a PROVEN unreachable host.
294
+ if (!isProbeable(remote))
295
+ return null;
296
+ const state = this._state(session.id);
297
+ const ttl = opts.force ? 0 : (opts.ttlMs ?? REMOTE_WAKE_REACHABILITY_TTL_MS);
298
+ if (Date.now() - state.probedAt >= ttl) {
299
+ state.probedAt = Date.now();
300
+ state.reachable = await this.deps.probe(remote);
301
+ }
302
+ return state.reachable === true;
303
+ }
304
+ /**
305
+ * Decide + act for one input chunk.
306
+ *
307
+ * `'deliver'` means the caller writes it as usual (today's path, zero added
308
+ * cost). `'buffered'` means the registry took ownership of the bytes: it either
309
+ * queued them behind an in-flight wake or started a wake, and will flush them
310
+ * in order once the pane is reattached.
311
+ */
312
+ async handleInput(session, data) {
313
+ const remote = await this._effectiveRemote(session);
314
+ // A proxied host can never pass the readiness poll, so buffering for it would hold
315
+ // the bytes for the life of the session (reproduced upstream: three inputs, nothing
316
+ // written, no reattach). Deliver, as if the feature were off.
317
+ if (remote && !isProbeable(remote))
318
+ return 'deliver';
319
+ const state = this._state(session.id);
320
+ const target = resolveWakeTarget(remote);
321
+ const action = decideRemoteInputAction({
322
+ hasWakeTarget: target !== null,
323
+ waking: state.waking != null,
324
+ probeAgeMs: Date.now() - state.probedAt,
325
+ lastReachable: state.reachable,
326
+ });
327
+ if (action === 'deliver')
328
+ return 'deliver';
329
+ if (action === 'buffer') {
330
+ const queued = this._enqueue(session.id, data);
331
+ // A buffered verdict with no wake in flight still has to DRIVE a wake (the
332
+ // previous one failed and reset the probe state, or the ladder landed here
333
+ // directly) — otherwise the bytes would sit in the buffer forever.
334
+ if (state.waking == null && target)
335
+ void this.wake(session);
336
+ return queued;
337
+ }
338
+ // action === 'probe' — the throttle window elapsed, so one TCP connect is owed.
339
+ state.probedAt = Date.now();
340
+ state.reachable = remote ? await this.deps.probe(remote) : true;
341
+ if (state.reachable)
342
+ return 'deliver';
343
+ const queued = this._enqueue(session.id, data);
344
+ void this.wake(session);
345
+ return queued;
346
+ }
347
+ /**
348
+ * Block until the host is reachable and the pane is reattached — the
349
+ * send-and-wait path, where the HTTP response stays open anyway and buffering
350
+ * would break the wait contract.
351
+ */
352
+ async ensureAwake(session, opts = {}) {
353
+ if (this.stopped)
354
+ return false;
355
+ const remote = await this._effectiveRemote(session);
356
+ const target = resolveWakeTarget(remote);
357
+ if (!remote || !target)
358
+ return true;
359
+ // A proxied host: the send-and-wait path has nothing to gate on (unknown is not
360
+ // asleep), so it delivers; the manual button still wakes, blind (see `wake`).
361
+ if (!isProbeable(remote))
362
+ return opts.force ? this.wake(session, opts) : true;
363
+ const state = this._state(session.id);
364
+ // `force` is the manual path (a user pressed "wake"): a cached "reachable" from
365
+ // seconds ago must not talk the button out of waking a host that just slept.
366
+ if (opts.force || (state.reachable !== false && Date.now() - state.probedAt >= REMOTE_WAKE_PROBE_MIN_INTERVAL_MS)) {
367
+ state.probedAt = Date.now();
368
+ state.reachable = await this.deps.probe(remote);
369
+ }
370
+ if (state.reachable)
371
+ return true;
372
+ // The manual button is pressed from the SAME dashboard the create/attach paths are,
373
+ // so it holds its request open under the same reverse proxy — it needs the request
374
+ // budget, not the 90 s session default (see REMOTE_WAKE_REQUEST_READY_TIMEOUT_MS).
375
+ return this.wake(session, { timeoutMs: opts.timeoutMs });
376
+ }
377
+ /**
378
+ * Host-scoped reachability, for a caller that has no session yet (create/attach).
379
+ * Shares the per-HOST probe state with {@link ensureHostAwake}, so the probe the
380
+ * wake flow just paid for also answers "was that ssh failure really a sleeping
381
+ * machine?". Never wakes anything — it is a question, not an action. `null` when the
382
+ * question cannot be answered (see {@link isProbeable}).
383
+ */
384
+ async checkHostReachable(remote, opts = {}) {
385
+ // `null` for a proxied host: callers gate on `=== false` (proven unreachable), so an
386
+ // unknown verdict leaves their ordinary error path — "needs tmux" — intact.
387
+ if (!isProbeable(remote))
388
+ return null;
389
+ const state = this._state(hostWakeKey(remote.hostId));
390
+ const ttl = opts.force ? 0 : (opts.ttlMs ?? REMOTE_WAKE_REACHABILITY_TTL_MS);
391
+ if (Date.now() - state.probedAt >= ttl) {
392
+ state.probedAt = Date.now();
393
+ state.reachable = await this.deps.probe(remote);
394
+ }
395
+ return state.reachable === true;
396
+ }
397
+ /**
398
+ * Wake a host for a REQUEST that is waiting on it — the session create/attach
399
+ * routes, where there is no session to reattach and no input to buffer yet.
400
+ *
401
+ * `'no-target'` returns without probing, so a host without WoL config costs
402
+ * nothing and behaves exactly as before. Single-flight per host, so a double click
403
+ * (or two cases on the same host) sends one packet and shares one readiness poll.
404
+ */
405
+ async ensureHostAwake(remote, opts = {}) {
406
+ if (!resolveWakeTarget(remote))
407
+ return 'no-target';
408
+ // The probe cannot tell a proxied host asleep from awake, and a wake that cannot
409
+ // verify readiness would only delay the request by its whole budget. Not gated.
410
+ if (!isProbeable(remote))
411
+ return 'unprobeable';
412
+ if (this.stopped)
413
+ return 'failed';
414
+ const state = this._state(hostWakeKey(remote.hostId));
415
+ if (state.waking)
416
+ return (await state.waking) ? 'ready' : 'failed';
417
+ state.probedAt = Date.now();
418
+ state.reachable = await this.deps.probe(remote);
419
+ if (state.reachable)
420
+ return 'ready';
421
+ this.deps.log?.(`[RemoteWake] ${remote.label} (${remote.host}) is unreachable — waking it for a new session`);
422
+ return (await this.wakeHost(remote, opts)) ? 'ready' : 'failed';
423
+ }
424
+ /**
425
+ * Single-flight wake for a host with no session (see {@link ensureHostAwake}). Uses the
426
+ * same single-flight `waking` slot the session flow uses — but a DIFFERENT key
427
+ * (`host:<id>` vs the session id), so a session wake and a create-path wake for the same
428
+ * host are two independent flows rather than one shared poll. Harmless (both are
429
+ * user-initiated and the host only wakes once), and keying them together would mean a
430
+ * create request joining an unrelated session's wake and inheriting its budget.
431
+ */
432
+ async wakeHost(remote, opts) {
433
+ const state = this._state(hostWakeKey(remote.hostId));
434
+ if (state.waking)
435
+ return state.waking;
436
+ state.waking = (async () => {
437
+ try {
438
+ return await this._wakeAndWait(remote, state, {
439
+ timeoutMs: opts.timeoutMs,
440
+ forNewSession: true,
441
+ requestedBy: opts.requestedBy,
442
+ });
443
+ }
444
+ catch (err) {
445
+ // Injected IO is documented not to throw, but a rejected promise here would
446
+ // surface as an unhandled rejection AND take the route down with it (the
447
+ // session path catches for exactly this reason). A broken wake target must
448
+ // fail the wake, never the create route beyond its own error response.
449
+ this.deps.log?.(`[RemoteWake] unexpected failure: ${err instanceof Error ? err.message : String(err)}`);
450
+ return false;
451
+ }
452
+ finally {
453
+ state.waking = null;
454
+ }
455
+ })();
456
+ return state.waking;
457
+ }
458
+ /**
459
+ * Single-flight wake: probe-free (the caller already knows the host is down),
460
+ * run the wake command, poll for readiness, reattach the pane, flush the buffer.
461
+ */
462
+ async wake(session, opts = {}) {
463
+ if (this.stopped)
464
+ return false;
465
+ const remote = await this._effectiveRemote(session);
466
+ const target = resolveWakeTarget(remote);
467
+ if (!remote || !target)
468
+ return true;
469
+ if (!isProbeable(remote))
470
+ return this._wakeBlind(remote, target);
471
+ const state = this._state(session.id);
472
+ if (state.waking)
473
+ return state.waking;
474
+ state.waking = (async () => {
475
+ const id = session.id;
476
+ try {
477
+ const ready = await this._wakeAndWait(remote, state, { sessionId: id, timeoutMs: opts.timeoutMs });
478
+ if (!ready)
479
+ return false;
480
+ const reattached = await session.reattachRemote();
481
+ if (!reattached) {
482
+ this.deps.log?.(`[RemoteWake] ${remote.label} is up but the pane could not be reattached`);
483
+ return false;
484
+ }
485
+ // The reset also clears an EXHAUSTED COD-108 backoff, which otherwise
486
+ // never fires again for this session (see remote-reconnect.ts).
487
+ this.deps.noteReconnected?.(id, true);
488
+ this.deps.broadcast?.('remote:sessionReconnected', { sessionId: id });
489
+ this.deps.log?.(`[RemoteWake] ${remote.label} reattached for session ${id}`);
490
+ await this.deps.delay(REMOTE_WAKE_ATTACH_SETTLE_MS);
491
+ await this._flush(state, session);
492
+ return true;
493
+ }
494
+ catch (err) {
495
+ this.deps.log?.(`[RemoteWake] unexpected failure: ${err instanceof Error ? err.message : String(err)}`);
496
+ return false;
497
+ }
498
+ finally {
499
+ state.waking = null;
500
+ }
501
+ })();
502
+ return state.waking;
503
+ }
504
+ /**
505
+ * Fire the wake target for a host whose readiness cannot be verified (see
506
+ * {@link isProbeable}): no readiness poll (it could never succeed), no reattach (the
507
+ * COD-108 watcher owns the pane once ssh works again), no `hostWaking` broadcast (its
508
+ * toast promises a wait that does not happen). The caller learns only whether the
509
+ * packet/command went out — and a wake IO that throws is a failed wake, never a
510
+ * rejected route.
511
+ */
512
+ async _wakeBlind(remote, target) {
513
+ this.deps.log?.(`[RemoteWake] waking ${remote.label} (${remote.host}) via ${target.kind}, blind: proxied host`);
514
+ try {
515
+ return await this.deps.wake(target);
516
+ }
517
+ catch (err) {
518
+ this.deps.log?.(`[RemoteWake] unexpected failure: ${err instanceof Error ? err.message : String(err)}`);
519
+ return false;
520
+ }
521
+ }
522
+ /**
523
+ * Broadcast + run the wake target + wait for SSH. Shared by the session flow (which
524
+ * then reattaches and flushes the buffer) and the create/attach flow (which has no
525
+ * pane yet). On failure the probe state is reset so the NEXT attempt probes and
526
+ * retries instead of trusting a stale "down" verdict forever.
527
+ */
528
+ async _wakeAndWait(remote, state, opts = {}) {
529
+ const target = resolveWakeTarget(remote);
530
+ if (!target)
531
+ return true;
532
+ const forWhat = opts.sessionId ? `for session ${opts.sessionId}` : 'for a new session';
533
+ // Routing for multi-user mode (server.ts `deriveSseHint`): a session-scoped event
534
+ // reaches its owner, and the create/attach wake has no session yet — so it names
535
+ // the requesting user instead, or it would reach admins only. The payload carries
536
+ // `hostId`/`label`, which non-admins are not shown elsewhere, so it must not go global.
537
+ const scope = opts.sessionId
538
+ ? { sessionId: opts.sessionId }
539
+ : { forNewSession: true, ...(opts.requestedBy ? { username: opts.requestedBy } : {}) };
540
+ // No `sessionId` for a create-path wake: the toast handler is then the only one
541
+ // that acts (a banner for a session that does not exist yet would have no target),
542
+ // which is exactly the `forNewSession` distinction the UI renders.
543
+ //
544
+ // `queuedInput` is true only on the typing path, where bytes are actually held for
545
+ // this session. The wake BUTTON and the send-and-wait path hold nothing, so a UI
546
+ // that keyed "input is queued until it is back" off "a wake is running" would promise
547
+ // something the user can disprove by typing (browser keystrokes go over the
548
+ // WebSocket, which never passes through this registry).
549
+ this.deps.broadcast?.('remote:hostWaking', {
550
+ ...scope,
551
+ hostId: remote.hostId,
552
+ label: remote.label,
553
+ queuedInput: state.pending.length > 0,
554
+ });
555
+ this.deps.log?.(`[RemoteWake] waking ${remote.label} (${remote.host}) via ${target.kind} ${forWhat}`);
556
+ const wakeStartedAt = Date.now();
557
+ const woke = await this.deps.wake(target);
558
+ if (!woke) {
559
+ this.deps.log?.(`[RemoteWake] wake failed for ${remote.label}: ${target.kind === 'command' ? target.command : 'magic packet'}`);
560
+ }
561
+ // The request-scoped budget has to cover the WHOLE request, and the wake is part
562
+ // of it. A `command` target is bounded by REMOTE_WAKE_COMMAND_TIMEOUT_MS, so a slow
563
+ // one burned 10 s before the readiness poll even started and pushed a wakeCommand
564
+ // host's worst case to ~68 s, past the 60 s proxy_read_timeout this budget exists to
565
+ // stay under. A magic packet is effectively instant, so this subtracts nothing there.
566
+ // Floored at one poll interval so a wake that ate the whole budget still gets one
567
+ // probe rather than being declared unreachable without asking.
568
+ const wakeElapsedMs = Date.now() - wakeStartedAt;
569
+ const readyTimeoutMs = opts.timeoutMs === undefined
570
+ ? undefined
571
+ : Math.max(REMOTE_WAKE_READY_INTERVAL_MS, opts.timeoutMs - wakeElapsedMs);
572
+ const ready = await this.deps.waitUntilReady(remote, {
573
+ timeoutMs: readyTimeoutMs,
574
+ signal: this.shutdown.signal,
575
+ });
576
+ if (!ready) {
577
+ this.deps.log?.(`[RemoteWake] ${remote.label} did not come back — ${opts.forNewSession ? 'the session was not started' : 'input stays buffered'}`);
578
+ this.deps.broadcast?.('remote:hostWakeFailed', {
579
+ ...scope,
580
+ hostId: remote.hostId,
581
+ label: remote.label,
582
+ queuedInput: state.pending.length > 0,
583
+ });
584
+ state.probedAt = 0;
585
+ state.reachable = undefined;
586
+ return false;
587
+ }
588
+ state.reachable = true;
589
+ state.probedAt = Date.now();
590
+ return true;
591
+ }
592
+ _state(sessionId) {
593
+ let state = this.states.get(sessionId);
594
+ if (!state) {
595
+ state = { probedAt: 0, reachable: undefined, waking: null, pending: [], resolvedAt: 0 };
596
+ this.states.set(sessionId, state);
597
+ }
598
+ return state;
599
+ }
600
+ /**
601
+ * The host config to act on: the session's own `remote` when it is fresh enough, else a
602
+ * freshly resolved one.
603
+ *
604
+ * The persisted `remote` snapshot is taken at launch, so a wake target configured AFTER
605
+ * the session started (e.g. through the banner's config dialog, or by adding `wakeMac`
606
+ * to `remote-hosts.json`) is invisible to it. Recovery rehydration (server.ts) covers
607
+ * restarts; this covers the live session, and it is why saving the dialog takes effect
608
+ * without restarting anything.
609
+ *
610
+ * ⚠️ The host config wins in BOTH directions, so the resolver is consulted on the TTL
611
+ * regardless of whether the session already carries a target. Preferring the snapshot
612
+ * whenever it HAD one meant removing a MAC/command in the config (or the dialog) never
613
+ * took effect for a running session — the feature stayed on with a target nobody could
614
+ * see in the config any more, which is exactly the "host config is authoritative"
615
+ * promise failing in the one direction a user can observe.
616
+ */
617
+ async _effectiveRemote(session) {
618
+ // The local-session return comes FIRST, before `_state`: this runs on every input
619
+ // chunk (`hasWakeTarget` gates the route), so allocating state here would put an
620
+ // entry in the map for every local session the user types in — sessions the feature
621
+ // can never apply to, and whose pending buffers would then have to be swept.
622
+ if (!session.remote)
623
+ return undefined;
624
+ const state = this._state(session.id);
625
+ if (!this.deps.resolveRemote)
626
+ return state.resolvedRemote ?? session.remote;
627
+ if (state.resolvedAt !== 0 && Date.now() - state.resolvedAt < REMOTE_WAKE_RESOLVE_TTL_MS) {
628
+ return state.resolvedRemote ?? session.remote;
629
+ }
630
+ state.resolvedAt = Date.now();
631
+ try {
632
+ const resolved = await this.deps.resolveRemote(session);
633
+ if (resolved)
634
+ state.resolvedRemote = resolved;
635
+ }
636
+ catch (err) {
637
+ this.deps.log?.(`[RemoteWake] host config lookup failed for session ${session.id}: ${err instanceof Error ? err.message : String(err)}`);
638
+ }
639
+ return state.resolvedRemote ?? session.remote;
640
+ }
641
+ /** Queue a chunk; `'dropped'` when it was over the cap and never entered the buffer. */
642
+ _enqueue(sessionId, data) {
643
+ const state = this._state(sessionId);
644
+ const next = appendBoundedPending(state.pending, data);
645
+ if (next === state.pending) {
646
+ // Oversized chunk: dropped whole (see `appendBoundedPending`), so the buffer is
647
+ // untouched and nothing is delivered as a fragment. Logged, and reported to the
648
+ // route, which answers `dropped:true` — the user's paste is gone and a bare 200
649
+ // could not say so.
650
+ this.deps.log?.(`[RemoteWake] dropped a ${Buffer.byteLength(data)}-byte input chunk for session ${sessionId} — over the ${REMOTE_WAKE_PENDING_MAX_BYTES}-byte wake buffer, and a truncated paste must not be delivered as a fragment`);
651
+ return 'dropped';
652
+ }
653
+ const before = state.pending.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
654
+ const after = next.reduce((sum, chunk) => sum + Buffer.byteLength(chunk), 0);
655
+ if (before + Buffer.byteLength(data) > after) {
656
+ this.deps.log?.(`[RemoteWake] pending buffer cap reached for session ${sessionId} — oldest input dropped`);
657
+ }
658
+ state.pending = next;
659
+ return 'buffered';
660
+ }
661
+ async _flush(state, session) {
662
+ while (state.pending.length > 0) {
663
+ const chunk = state.pending[0];
664
+ // Take the chunk OUT before awaiting the write. Input arriving during the await is
665
+ // enqueued by `handleInput` (a wake is still in flight, so it takes the buffer
666
+ // path), and `appendBoundedPending` may then drop the OLDEST chunk to stay under
667
+ // the cap — which would be this one, already on its way to the pane. Shifting
668
+ // afterwards removed the NEXT chunk instead, so the drop-oldest bookkeeping lost a
669
+ // chunk that was never written while the log line blamed the one that was.
670
+ state.pending = state.pending.slice(1);
671
+ // `fromUser`: these bytes came through the input route as a person's prompt, so
672
+ // they may name the tab — without it a session whose FIRST prompt was buffered
673
+ // through a wake could never be auto-named.
674
+ const ok = await session.writeViaMux(chunk, { fromUser: true }).catch(() => false);
675
+ if (!ok) {
676
+ // Drop the rest, and say so. Retaining it looked safer but was worse: the wake
677
+ // still resolves and marks the host reachable, so the NEXT input takes the
678
+ // deliver path while the old chunks sit here — to be replayed by the next wake,
679
+ // possibly hours later, after everything typed since, and maybe ending in a
680
+ // carriage return. Same policy as the oversized paste: gone, with a log line.
681
+ const dropped = state.pending.length + 1;
682
+ state.pending = [];
683
+ this.deps.log?.(`[RemoteWake] flush failed for session ${session.id} — ${dropped} buffered chunk(s) dropped rather than replayed on a later wake`);
684
+ return;
685
+ }
686
+ }
687
+ }
688
+ }
689
+ // ========== Default IO ==========
690
+ /**
691
+ * Under vitest none of this may do real IO (a TCP connect, a child process, a UDP
692
+ * broadcast) — mirrors `remote-files.ts`. Every consumer injects its deps
693
+ * (`RemoteWakeDeps`, the socket factory); this is what makes that seam non-optional
694
+ * instead of a convention the next test can forget.
695
+ */
696
+ function assertNotUnderTest(what) {
697
+ if (process.env.VITEST) {
698
+ throw new Error(`remote-wake: ${what} is disabled under test — inject a fake (RemoteWakeDeps / WakeSocketFactory)`);
699
+ }
700
+ }
701
+ /**
702
+ * Cheap reachability probe: a bare TCP connect to the SSH port. Only meaningful for a
703
+ * host the registry deems probeable (see {@link isProbeable}); the registry never asks
704
+ * it about a proxied host.
705
+ *
706
+ * Deliberately NOT an `ssh … true` probe: that opens a full session (auth,
707
+ * remote log, process) every throttle window for a question a SYN already
708
+ * answers. Any byte count it does move is a few hundred bytes per probe, far
709
+ * below the remote idle detector's traffic threshold, so probing cannot keep a
710
+ * host awake.
711
+ */
712
+ export function probeRemoteHostReachable(remote, timeoutMs = REMOTE_WAKE_PROBE_TIMEOUT_MS) {
713
+ assertNotUnderTest('the TCP probe');
714
+ const port = remote.port ?? DEFAULT_SSH_PORT;
715
+ return new Promise((resolve) => {
716
+ let settled = false;
717
+ const finish = (value) => {
718
+ if (settled)
719
+ return;
720
+ settled = true;
721
+ socket.destroy();
722
+ resolve(value);
723
+ };
724
+ const socket = net.connect({ host: remote.host, port });
725
+ socket.setTimeout(timeoutMs, () => finish(false));
726
+ socket.once('connect', () => finish(true));
727
+ socket.once('error', () => finish(false));
728
+ });
729
+ }
730
+ /**
731
+ * Run a host's wake command (e.g. a Wake-on-LAN wrapper script). No shell — the
732
+ * value is a single executable path, so nothing in it can be interpreted.
733
+ * Resolves false on any failure (missing binary, non-zero exit, timeout) rather
734
+ * than throwing: a broken wake command must not break the input route.
735
+ */
736
+ export function runRemoteWakeCommand(command, timeoutMs = REMOTE_WAKE_COMMAND_TIMEOUT_MS) {
737
+ assertNotUnderTest('the wake command');
738
+ return new Promise((resolve) => {
739
+ let settled = false;
740
+ const finish = (value) => {
741
+ if (settled)
742
+ return;
743
+ settled = true;
744
+ resolve(value);
745
+ };
746
+ let child;
747
+ try {
748
+ child = spawn(command, [], { stdio: 'ignore' });
749
+ }
750
+ catch {
751
+ finish(false);
752
+ return;
753
+ }
754
+ const timer = setTimeout(() => {
755
+ child.kill('SIGKILL');
756
+ finish(false);
757
+ }, timeoutMs);
758
+ child.once('error', () => {
759
+ clearTimeout(timer);
760
+ finish(false);
761
+ });
762
+ child.once('exit', (code) => {
763
+ clearTimeout(timer);
764
+ finish(code === 0);
765
+ });
766
+ });
767
+ }
768
+ /**
769
+ * Send Wake-on-LAN magic packets for every MAC, over UDP to the broadcast address.
770
+ *
771
+ * This is the whole reason `wakeMac` exists: the common case needs no external
772
+ * script. Broadcast on 255.255.255.255 is what the CLI `wakeonlan` does and what the
773
+ * NICs here answer to; the socket is closed as soon as the packets are queued, so a
774
+ * sleeping host cannot leave a handle behind. Resolves false on any failure (no
775
+ * interface to broadcast on, permission) rather than throwing — a broken network
776
+ * must not break the wake flow, which reports the failure itself.
777
+ */
778
+ export function sendWakePackets(addresses, port = 9, createSocket = () => {
779
+ assertNotUnderTest('the UDP broadcast');
780
+ return dgram.createSocket('udp4');
781
+ }) {
782
+ if (addresses.length === 0)
783
+ return Promise.resolve(false);
784
+ return new Promise((resolve) => {
785
+ const socket = createSocket();
786
+ let settled = false;
787
+ const finish = (value) => {
788
+ if (settled)
789
+ return;
790
+ settled = true;
791
+ try {
792
+ socket.close();
793
+ }
794
+ catch {
795
+ /* already closed */
796
+ }
797
+ resolve(value);
798
+ };
799
+ socket.once('error', () => finish(false));
800
+ // ⚠️ `setBroadcast` BEFORE the socket is bound fails with EBADF on Linux, and the
801
+ // send that follows fails with EACCES — i.e. the packet silently never leaves the
802
+ // machine. So the broadcast flag is set in the bind callback, always. (Found by
803
+ // the live test: macOS/BSD tolerate the wrong order, Linux does not.)
804
+ socket.bind(() => {
805
+ try {
806
+ socket.setBroadcast(true);
807
+ }
808
+ catch {
809
+ finish(false);
810
+ return;
811
+ }
812
+ let pending = addresses.length;
813
+ let failed = false;
814
+ for (const mac of addresses) {
815
+ socket.send(buildMagicPacket(mac), port, '255.255.255.255', (err) => {
816
+ if (err)
817
+ failed = true;
818
+ pending--;
819
+ if (pending === 0)
820
+ finish(!failed);
821
+ });
822
+ }
823
+ });
824
+ });
825
+ }
826
+ /** Poll the host until it accepts connections again, or the bound is hit. */
827
+ export async function waitUntilRemoteReady(remote, opts = {}) {
828
+ const intervalMs = opts.intervalMs ?? REMOTE_WAKE_READY_INTERVAL_MS;
829
+ const timeoutMs = opts.timeoutMs ?? REMOTE_WAKE_READY_TIMEOUT_MS;
830
+ const probe = opts.probe ?? probeRemoteHostReachable;
831
+ const deadline = Date.now() + timeoutMs;
832
+ // Probe immediately: WoL from a warm S3 is fast (~7.5 s measured on this setup),
833
+ // and the first poll is what turns "just woke" into a sub-interval response.
834
+ for (;;) {
835
+ if (opts.signal?.aborted)
836
+ return false;
837
+ if (await probe(remote))
838
+ return true;
839
+ if (Date.now() + intervalMs > deadline)
840
+ return false;
841
+ // Abortable sleep, so a shutdown does not wait out the current interval either.
842
+ await delayOrAbort(intervalMs, opts.signal);
843
+ }
844
+ }
845
+ /** `delay`, but it also ends the moment `signal` aborts (so cancellation is immediate). */
846
+ function delayOrAbort(ms, signal) {
847
+ if (!signal)
848
+ return delay(ms);
849
+ if (signal.aborted)
850
+ return Promise.resolve();
851
+ return new Promise((resolve) => {
852
+ const done = () => {
853
+ clearTimeout(timer);
854
+ signal.removeEventListener('abort', done);
855
+ resolve();
856
+ };
857
+ const timer = setTimeout(done, ms);
858
+ signal.addEventListener('abort', done, { once: true });
859
+ });
860
+ }
861
+ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
862
+ /**
863
+ * Production wiring: all IO defaults, overridable for tests.
864
+ *
865
+ * The readiness poll uses the SAME probe as the rest of the deps, overridden or not.
866
+ * Wiring it to the module default instead let a caller that injected `probe` still
867
+ * poll the real host during the wait — under vitest, a TCP connect to a production
868
+ * address on every shutdown test (which the vitest guard is what finally caught).
869
+ */
870
+ export function createDefaultRemoteWakeDeps(overrides = {}) {
871
+ const probe = overrides.probe ?? probeRemoteHostReachable;
872
+ return {
873
+ probe,
874
+ wake: (target) => (target.kind === 'command' ? runRemoteWakeCommand(target.command) : sendWakePackets(target.macs)),
875
+ waitUntilReady: (remote, opts) => waitUntilRemoteReady(remote, { ...opts, probe }),
876
+ delay,
877
+ ...overrides,
878
+ };
879
+ }
880
+ //# sourceMappingURL=remote-wake.js.map