hilos-agent 0.9.1 → 0.9.3
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.
- package/README.md +61 -31
- package/bin/hilos-agent.mjs +9 -5
- package/package.json +1 -1
- package/src/argv.mjs +61 -0
- package/src/config.mjs +4 -3
- package/src/handler.mjs +102 -4
- package/src/hook.mjs +429 -61
- package/src/mcp-loopback.mjs +142 -0
- package/src/mcp.mjs +2 -2
- package/src/model-resolve.mjs +34 -18
- package/src/progress-emitter.mjs +6 -3
- package/src/reply-bridge.mjs +412 -49
- package/src/run.mjs +67 -21
- package/src/wake.mjs +288 -0
package/src/run.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { handleTask } from "./handler.mjs";
|
|
|
10
10
|
import { createQueue, looksLikeCancel, dedupeKey } from "./queue.mjs";
|
|
11
11
|
import { cleanupImages, fetchMentionImages } from "./attachments.mjs";
|
|
12
12
|
import { reloadConfig } from "./config.mjs";
|
|
13
|
+
import { startWake, createWakeGate } from "./wake.mjs";
|
|
13
14
|
import { scanReplyBridge, handleReplyBridgeJob } from "./reply-bridge.mjs";
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -44,21 +45,13 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
44
45
|
/* a bad event listener must never break the daemon */
|
|
45
46
|
}
|
|
46
47
|
};
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
resolve();
|
|
55
|
-
}, ms);
|
|
56
|
-
function onAbort() {
|
|
57
|
-
clearTimeout(timer);
|
|
58
|
-
resolve();
|
|
59
|
-
}
|
|
60
|
-
signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
61
|
-
});
|
|
48
|
+
// The wake doorbell (0824): a Realtime ping resolves the pending inter-poll
|
|
49
|
+
// sleep so the next list_mentions poll happens NOW. A ping that lands while a
|
|
50
|
+
// poll is still running is remembered, so the following sleep returns
|
|
51
|
+
// immediately instead of the ping being lost to the race. That race lives in
|
|
52
|
+
// createWakeGate (wake.mjs) so it can be unit-tested; the sleep it hands back
|
|
53
|
+
// also resolves on abort, so "stop" never waits out the poll interval.
|
|
54
|
+
const { ping: onWakePing, sleep: interruptibleSleep } = createWakeGate({ signal });
|
|
62
55
|
|
|
63
56
|
// Already asked to stop before we even connect → return cleanly, do nothing.
|
|
64
57
|
if (signal?.aborted) {
|
|
@@ -115,8 +108,45 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
115
108
|
// server has to offer the tool, and the operator has to have turned
|
|
116
109
|
// `uploadTranscripts` on. Older servers simply never see the call.
|
|
117
110
|
uploadTranscript: toolNames.includes("upload_run_transcript"),
|
|
111
|
+
// Emoji reactions (0860): the lightest answer to an untagged DM message
|
|
112
|
+
// that needs no words. Absent on older servers → the agent stays quiet
|
|
113
|
+
// instead, exactly as if it had no hand to wave.
|
|
114
|
+
react: toolNames.includes("add_reaction"),
|
|
118
115
|
};
|
|
119
116
|
|
|
117
|
+
// The wake doorbell (0824): capability-checked like every optional surface.
|
|
118
|
+
// On an older server the tool is absent and the daemon polls exactly as
|
|
119
|
+
// before; with it, a new message in the workspace rings a content-free
|
|
120
|
+
// Realtime broadcast that cuts mention pickup from pollMs to instant.
|
|
121
|
+
let wake = null;
|
|
122
|
+
if (toolNames.includes("get_wake_channel")) {
|
|
123
|
+
try {
|
|
124
|
+
const chan = await tool("get_wake_channel");
|
|
125
|
+
if (chan?.url && chan?.topic) {
|
|
126
|
+
// Pass a RESOLVER, not the fetched value: wake.mjs re-asks on every
|
|
127
|
+
// reconnect and on a slow refresh timer, so if the workspace rotates
|
|
128
|
+
// its doorbell topic (rotate_wake_topic, after a leak) this daemon
|
|
129
|
+
// picks the new one up on its own instead of going quietly deaf until
|
|
130
|
+
// someone restarts it. First call reuses what we already have.
|
|
131
|
+
let first = chan;
|
|
132
|
+
wake = startWake(
|
|
133
|
+
async () => {
|
|
134
|
+
if (first) {
|
|
135
|
+
const f = first;
|
|
136
|
+
first = null;
|
|
137
|
+
return f;
|
|
138
|
+
}
|
|
139
|
+
return await tool("get_wake_channel");
|
|
140
|
+
},
|
|
141
|
+
{ onWake: onWakePing, log },
|
|
142
|
+
);
|
|
143
|
+
log.log("wake: realtime doorbell enabled (polling stays as fallback)");
|
|
144
|
+
}
|
|
145
|
+
} catch (e) {
|
|
146
|
+
log.log(`wake: unavailable (${e.message}) — polling only`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
120
150
|
// Register local folders (0324/0325): a folder-mode daemon announces each
|
|
121
151
|
// channel→folder mapping to the server so the channel shows a folder chip
|
|
122
152
|
// without any manual step. Capability-gated on the server advertising
|
|
@@ -161,6 +191,10 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
161
191
|
|
|
162
192
|
const seen = new Set();
|
|
163
193
|
const cursor = { value: since ? new Date(since).toISOString() : null };
|
|
194
|
+
// 0860 — a DM message inside its settle window: the server withheld it and
|
|
195
|
+
// said when it matures. One shortened sleep picks it up right then, instead
|
|
196
|
+
// of the person waiting out the full poll interval mid-conversation.
|
|
197
|
+
let pollAgainMs = null;
|
|
164
198
|
|
|
165
199
|
// Live config: re-read between polls so model/permission/codingCmd edits to
|
|
166
200
|
// hilos-agent.json take effect without a restart. Identity stays pinned.
|
|
@@ -297,7 +331,10 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
297
331
|
// A report-card deploy decision is already visible in place. list_mentions
|
|
298
332
|
// projects the pending decision into this structured event, so don't add a
|
|
299
333
|
// misleading "queued" chat line while the active run settles it.
|
|
300
|
-
|
|
334
|
+
// An untagged DM message (0860) skips the ack too: nobody summoned the
|
|
335
|
+
// agent, so "Got it — queued" over a line that may deserve no reply at all
|
|
336
|
+
// is exactly the noise the implicit lane promises not to make.
|
|
337
|
+
if (liveCfg.queueAcks && !r.startedImmediately && !m.deployRequest && !m.implicit) {
|
|
301
338
|
await tool("post_message", {
|
|
302
339
|
channelId,
|
|
303
340
|
parentId: m.parentId ?? null,
|
|
@@ -312,6 +349,11 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
312
349
|
...(cfg.channelId ? { channelId: cfg.channelId } : {}),
|
|
313
350
|
};
|
|
314
351
|
const out = await tool("list_mentions", mentionArgs);
|
|
352
|
+
// Floor the server's settle hint so a clock skew can never hot-loop the poll.
|
|
353
|
+
pollAgainMs =
|
|
354
|
+
typeof out?.retryAfterMs === "number" && out.retryAfterMs > 0
|
|
355
|
+
? Math.max(500, out.retryAfterMs)
|
|
356
|
+
: null;
|
|
315
357
|
const mentions = (out?.mentions ?? []).slice().reverse();
|
|
316
358
|
for (const m of mentions) {
|
|
317
359
|
if (seen.has(m.id)) continue;
|
|
@@ -404,13 +446,17 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
404
446
|
}
|
|
405
447
|
if (signal?.aborted) break;
|
|
406
448
|
// Interruptible so an abort during the sleep returns promptly instead of
|
|
407
|
-
// waiting out the full poll interval.
|
|
408
|
-
|
|
449
|
+
// waiting out the full poll interval. A settling DM (0860) shortens one
|
|
450
|
+
// sleep to the server's hint; the doorbell still cuts either short.
|
|
451
|
+
await interruptibleSleep(
|
|
452
|
+
pollAgainMs != null ? Math.min(liveCfg.pollMs, pollAgainMs) : liveCfg.pollMs,
|
|
453
|
+
);
|
|
409
454
|
} while (!signal?.aborted);
|
|
410
455
|
|
|
411
|
-
// Clean stop: make sure any active job is cancelled
|
|
412
|
-
// fired for the common case, but this covers a
|
|
413
|
-
// in-flight teardown settle before returning.
|
|
456
|
+
// Clean stop: close the doorbell socket, make sure any active job is cancelled
|
|
457
|
+
// (the abort listener already fired for the common case, but this covers a
|
|
458
|
+
// break for other reasons) and let in-flight teardown settle before returning.
|
|
459
|
+
wake?.stop();
|
|
414
460
|
if (signal?.aborted) {
|
|
415
461
|
queue.cancelActive("run() aborted");
|
|
416
462
|
}
|
package/src/wake.mjs
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
// The Realtime wake doorbell (0824). Subscribes to the workspace's broadcast
|
|
2
|
+
// topic (from the get_wake_channel tool) so a new message wakes the poll loop
|
|
3
|
+
// instantly instead of waiting out pollMs. Dependency-free by design, like
|
|
4
|
+
// mcp.mjs: this speaks the small slice of the Supabase Realtime (Phoenix)
|
|
5
|
+
// protocol the doorbell needs over the global WebSocket (Node >= 22).
|
|
6
|
+
//
|
|
7
|
+
// What the credentials here are worth, so nobody mistakes one for the other:
|
|
8
|
+
// * `anonKey` is the project's PUBLIC key — the same string every page of the
|
|
9
|
+
// web app ships in its bundle. It grants nothing on its own: every table a
|
|
10
|
+
// daemon cares about is RLS-gated and the anon role has no membership, so
|
|
11
|
+
// it reads zero rows (asserted in scripts/verify-agent-wake.mjs).
|
|
12
|
+
// * `topic` is the actual secret. The broadcast is public (private=false), so
|
|
13
|
+
// the topic string is what gates the doorbell. All it buys is one repeated
|
|
14
|
+
// bit — "a message was posted somewhere in this workspace" — never content.
|
|
15
|
+
// If it leaks, an owner rotates it (rotate_wake_topic) and this client
|
|
16
|
+
// re-resolves on its own; see the `source` resolver below.
|
|
17
|
+
//
|
|
18
|
+
// Fail-soft everywhere: the doorbell is an accelerator, never a dependency.
|
|
19
|
+
// No WebSocket global, a bad URL, a rejected join, a dropped socket — all of it
|
|
20
|
+
// degrades to exactly the polling behavior the daemon has always had.
|
|
21
|
+
|
|
22
|
+
const HEARTBEAT_MS = 25_000;
|
|
23
|
+
const RECONNECT_MIN_MS = 1_000;
|
|
24
|
+
const RECONNECT_MAX_MS = 60_000;
|
|
25
|
+
// A join that never gets its ok reply is a dead doorbell that LOOKS alive.
|
|
26
|
+
// Give it a bounded window, then recycle the socket.
|
|
27
|
+
const JOIN_TIMEOUT_MS = 15_000;
|
|
28
|
+
// How often to re-ask the server for the topic when it did not tell us. Bounds
|
|
29
|
+
// how long a rotated topic can leave a daemon on plain polling.
|
|
30
|
+
const DEFAULT_REFRESH_MS = 15 * 60_000;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Open (and keep open) the doorbell subscription.
|
|
34
|
+
*
|
|
35
|
+
* @param {{ url: string, anonKey: string, topic: string, refreshMs?: number }
|
|
36
|
+
* | (() => Promise<{ url: string, anonKey: string, topic: string, refreshMs?: number }>)} source
|
|
37
|
+
* Either a fixed get_wake_channel result, or an async resolver that fetches a
|
|
38
|
+
* fresh one. Pass the resolver: it is re-run on every reconnect and on a slow
|
|
39
|
+
* refresh timer, which is what makes a topic rotation heal itself instead of
|
|
40
|
+
* silently dropping the daemon back to pollMs until someone restarts it.
|
|
41
|
+
* @param {{ onWake: () => void, log?: { log: Function, error: Function } }} opts
|
|
42
|
+
* @returns {{ stop: () => void }}
|
|
43
|
+
*/
|
|
44
|
+
export function startWake(source, { onWake, log = console }) {
|
|
45
|
+
const WS = globalThis.WebSocket;
|
|
46
|
+
if (typeof WS !== "function") {
|
|
47
|
+
log.log("wake: no WebSocket in this Node (need >= 22) — staying on polling only");
|
|
48
|
+
return { stop() {} };
|
|
49
|
+
}
|
|
50
|
+
const resolve =
|
|
51
|
+
typeof source === "function" ? source : async () => source;
|
|
52
|
+
|
|
53
|
+
let ws = null;
|
|
54
|
+
let stopped = false;
|
|
55
|
+
let heartbeat = null;
|
|
56
|
+
let joinTimer = null;
|
|
57
|
+
let reconnectTimer = null;
|
|
58
|
+
let refreshTimer = null;
|
|
59
|
+
let backoff = RECONNECT_MIN_MS;
|
|
60
|
+
let ref = 0;
|
|
61
|
+
let phxTopic = null;
|
|
62
|
+
let currentTopic = null;
|
|
63
|
+
let refreshMs = DEFAULT_REFRESH_MS;
|
|
64
|
+
let refreshArmed = false;
|
|
65
|
+
|
|
66
|
+
const send = (msg) => {
|
|
67
|
+
try {
|
|
68
|
+
if (ws?.readyState === WS.OPEN) ws.send(JSON.stringify(msg));
|
|
69
|
+
} catch {
|
|
70
|
+
/* a failed frame is the socket's problem; close/error handles it */
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const teardown = () => {
|
|
75
|
+
if (heartbeat) clearInterval(heartbeat);
|
|
76
|
+
heartbeat = null;
|
|
77
|
+
if (joinTimer) clearTimeout(joinTimer);
|
|
78
|
+
joinTimer = null;
|
|
79
|
+
if (ws) {
|
|
80
|
+
ws.onopen = ws.onmessage = ws.onclose = ws.onerror = null;
|
|
81
|
+
try {
|
|
82
|
+
ws.close();
|
|
83
|
+
} catch {
|
|
84
|
+
/* already gone */
|
|
85
|
+
}
|
|
86
|
+
ws = null;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const scheduleReconnect = () => {
|
|
91
|
+
if (stopped || reconnectTimer) return;
|
|
92
|
+
reconnectTimer = setTimeout(() => {
|
|
93
|
+
reconnectTimer = null;
|
|
94
|
+
connect();
|
|
95
|
+
}, backoff);
|
|
96
|
+
// Capped, and only ever grows between successful joins — an endpoint that
|
|
97
|
+
// is down costs one attempt a minute, not a hot retry loop.
|
|
98
|
+
backoff = Math.min(backoff * 2, RECONNECT_MAX_MS);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// Recycle the socket: used for a rejected join, a join that never answered,
|
|
102
|
+
// and a server-side channel error. Each one leaves a socket that would
|
|
103
|
+
// otherwise sit there open and permanently deaf.
|
|
104
|
+
const recycle = (why) => {
|
|
105
|
+
if (stopped) return;
|
|
106
|
+
log.log(`wake: ${why} — retrying, polling continues`);
|
|
107
|
+
teardown();
|
|
108
|
+
scheduleReconnect();
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
async function connect() {
|
|
112
|
+
if (stopped) return;
|
|
113
|
+
teardown();
|
|
114
|
+
|
|
115
|
+
// Re-resolved every attempt, so a rotated topic is picked up here.
|
|
116
|
+
let chan;
|
|
117
|
+
try {
|
|
118
|
+
chan = await resolve();
|
|
119
|
+
} catch (e) {
|
|
120
|
+
log.error(`wake: could not get the doorbell topic (${e?.message || e}) — polling continues`);
|
|
121
|
+
return scheduleReconnect();
|
|
122
|
+
}
|
|
123
|
+
if (stopped) return;
|
|
124
|
+
if (!chan?.url || !chan?.anonKey || !chan?.topic) return scheduleReconnect();
|
|
125
|
+
if (Number.isFinite(chan.refreshMs) && chan.refreshMs > 0) refreshMs = chan.refreshMs;
|
|
126
|
+
currentTopic = chan.topic;
|
|
127
|
+
// Armed here, not at startup, so the FIRST interval already uses the
|
|
128
|
+
// server's refreshMs instead of the built-in default.
|
|
129
|
+
if (!refreshArmed) {
|
|
130
|
+
refreshArmed = true;
|
|
131
|
+
scheduleRefresh();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const wsUrl =
|
|
135
|
+
chan.url.replace(/^http/, "ws").replace(/\/$/, "") +
|
|
136
|
+
"/realtime/v1/websocket?apikey=" +
|
|
137
|
+
encodeURIComponent(chan.anonKey) +
|
|
138
|
+
"&vsn=1.0.0";
|
|
139
|
+
phxTopic = "realtime:" + chan.topic;
|
|
140
|
+
|
|
141
|
+
let sock;
|
|
142
|
+
try {
|
|
143
|
+
sock = new WS(wsUrl);
|
|
144
|
+
} catch (e) {
|
|
145
|
+
log.error(`wake: connect failed (${e.message}) — polling continues`);
|
|
146
|
+
return scheduleReconnect();
|
|
147
|
+
}
|
|
148
|
+
ws = sock;
|
|
149
|
+
|
|
150
|
+
sock.onopen = () => {
|
|
151
|
+
send({
|
|
152
|
+
topic: phxTopic,
|
|
153
|
+
event: "phx_join",
|
|
154
|
+
payload: {
|
|
155
|
+
config: { broadcast: { self: false, ack: false }, presence: { key: "" }, private: false },
|
|
156
|
+
access_token: chan.anonKey,
|
|
157
|
+
},
|
|
158
|
+
ref: String(++ref),
|
|
159
|
+
});
|
|
160
|
+
joinTimer = setTimeout(() => recycle("join timed out"), JOIN_TIMEOUT_MS);
|
|
161
|
+
heartbeat = setInterval(() => {
|
|
162
|
+
send({ topic: "phoenix", event: "heartbeat", payload: {}, ref: String(++ref) });
|
|
163
|
+
}, HEARTBEAT_MS);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
sock.onmessage = (evt) => {
|
|
167
|
+
let msg;
|
|
168
|
+
try {
|
|
169
|
+
msg = JSON.parse(String(evt.data));
|
|
170
|
+
} catch {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (msg.topic !== phxTopic) return;
|
|
174
|
+
if (msg.event === "phx_reply") {
|
|
175
|
+
if (joinTimer) clearTimeout(joinTimer);
|
|
176
|
+
joinTimer = null;
|
|
177
|
+
if (msg.payload?.status === "ok") {
|
|
178
|
+
// Joined: the doorbell is live. Reset the backoff so a later drop
|
|
179
|
+
// recovers quickly.
|
|
180
|
+
backoff = RECONNECT_MIN_MS;
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
// A rejected join used to leave the socket open and silent forever.
|
|
184
|
+
return recycle(`join refused (${msg.payload?.response?.reason || msg.payload?.status || "error"})`);
|
|
185
|
+
}
|
|
186
|
+
// The channel died server-side (or was closed under us): same treatment.
|
|
187
|
+
if (msg.event === "phx_error" || msg.event === "phx_close") return recycle("channel closed");
|
|
188
|
+
if (msg.event === "broadcast" && msg.payload?.event === "wake") onWake();
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
sock.onclose = () => {
|
|
192
|
+
teardown();
|
|
193
|
+
scheduleReconnect();
|
|
194
|
+
};
|
|
195
|
+
sock.onerror = () => {
|
|
196
|
+
// onclose follows and owns the reconnect; nothing to do here.
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Slow re-check: if the workspace rotated its topic while we were happily
|
|
201
|
+
// joined to the old one, nothing drops the socket — we would just stop
|
|
202
|
+
// hearing pings. Re-resolve on a timer and reconnect only when it changed.
|
|
203
|
+
const scheduleRefresh = () => {
|
|
204
|
+
if (stopped) return;
|
|
205
|
+
refreshTimer = setTimeout(async () => {
|
|
206
|
+
refreshTimer = null;
|
|
207
|
+
if (stopped) return;
|
|
208
|
+
try {
|
|
209
|
+
const next = await resolve();
|
|
210
|
+
if (!stopped && next?.topic && next.topic !== currentTopic) {
|
|
211
|
+
log.log("wake: doorbell topic rotated — resubscribing");
|
|
212
|
+
teardown();
|
|
213
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
214
|
+
reconnectTimer = null;
|
|
215
|
+
backoff = RECONNECT_MIN_MS;
|
|
216
|
+
void connect();
|
|
217
|
+
}
|
|
218
|
+
} catch {
|
|
219
|
+
/* the doorbell is an accelerator; a failed re-check changes nothing */
|
|
220
|
+
}
|
|
221
|
+
scheduleRefresh();
|
|
222
|
+
}, refreshMs);
|
|
223
|
+
// Never hold the process open for a doorbell re-check.
|
|
224
|
+
refreshTimer?.unref?.();
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
void connect();
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
stop() {
|
|
231
|
+
stopped = true;
|
|
232
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
233
|
+
reconnectTimer = null;
|
|
234
|
+
if (refreshTimer) clearTimeout(refreshTimer);
|
|
235
|
+
refreshTimer = null;
|
|
236
|
+
teardown();
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The wake gate: the tiny bit of state that connects a doorbell ping to the
|
|
243
|
+
* poll loop's inter-poll sleep. Extracted from run.mjs so the race it exists to
|
|
244
|
+
* close is unit-testable — a ping that lands mid-poll (no sleeper armed) must
|
|
245
|
+
* be REMEMBERED, so the next sleep returns immediately instead of the ping
|
|
246
|
+
* being lost and the daemon waiting out a full pollMs anyway.
|
|
247
|
+
*
|
|
248
|
+
* @param {{ signal?: AbortSignal }} [opts]
|
|
249
|
+
* @returns {{ ping: () => void, sleep: (ms: number) => Promise<void> }}
|
|
250
|
+
*/
|
|
251
|
+
export function createWakeGate({ signal } = {}) {
|
|
252
|
+
let pending = false;
|
|
253
|
+
let sleeper = null;
|
|
254
|
+
|
|
255
|
+
const ping = () => {
|
|
256
|
+
pending = true;
|
|
257
|
+
if (sleeper) sleeper();
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
// A sleep that resolves immediately when the signal aborts (so "stop" doesn't
|
|
261
|
+
// wait out the whole poll interval) or when the wake doorbell rings.
|
|
262
|
+
const sleep = (ms) =>
|
|
263
|
+
new Promise((resolve) => {
|
|
264
|
+
if (signal?.aborted) return resolve();
|
|
265
|
+
if (pending) {
|
|
266
|
+
pending = false;
|
|
267
|
+
return resolve();
|
|
268
|
+
}
|
|
269
|
+
const done = () => {
|
|
270
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
271
|
+
sleeper = null;
|
|
272
|
+
resolve();
|
|
273
|
+
};
|
|
274
|
+
const timer = setTimeout(done, ms);
|
|
275
|
+
sleeper = () => {
|
|
276
|
+
pending = false;
|
|
277
|
+
clearTimeout(timer);
|
|
278
|
+
done();
|
|
279
|
+
};
|
|
280
|
+
function onAbort() {
|
|
281
|
+
clearTimeout(timer);
|
|
282
|
+
done();
|
|
283
|
+
}
|
|
284
|
+
signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
return { ping, sleep };
|
|
288
|
+
}
|