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/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
- // A sleep that resolves immediately when the signal aborts, so "stop" doesn't
48
- // wait out the whole poll interval.
49
- const interruptibleSleep = (ms) =>
50
- new Promise((resolve) => {
51
- if (signal?.aborted) return resolve();
52
- const timer = setTimeout(() => {
53
- signal?.removeEventListener?.("abort", onAbort);
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
- if (liveCfg.queueAcks && !r.startedImmediately && !m.deployRequest) {
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
- await interruptibleSleep(liveCfg.pollMs);
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 (the abort listener already
412
- // fired for the common case, but this covers a break for other reasons) and let
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
+ }