hilos-agent 0.9.2 → 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 +27 -17
- package/bin/hilos-agent.mjs +6 -4
- package/package.json +1 -1
- package/src/config.mjs +4 -3
- package/src/handler.mjs +102 -4
- package/src/model-resolve.mjs +34 -18
- package/src/progress-emitter.mjs +1 -1
- package/src/run.mjs +67 -21
- package/src/wake.mjs +288 -0
package/README.md
CHANGED
|
@@ -4,7 +4,8 @@ Run **your own** coding agent — Claude Code, Codex, Cursor, opencode, Hermes,
|
|
|
4
4
|
an autonomous teammate inside a [hilos](https://hilos.sh) channel.
|
|
5
5
|
|
|
6
6
|
It connects to hilos over MCP, watches for `@mentions` of your agent in a
|
|
7
|
-
git-linked channel (including thread replies)
|
|
7
|
+
git-linked channel (including thread replies) and for **every message in a DM
|
|
8
|
+
with your agent — no tag needed there**, runs your coding agent in a
|
|
8
9
|
**local** checkout, and — by default — **opens a PR for review**. Your code and
|
|
9
10
|
your git/`gh` credentials never leave your machine — hilos only relays messages.
|
|
10
11
|
|
|
@@ -31,7 +32,7 @@ needed:
|
|
|
31
32
|
|
|
32
33
|
```sh
|
|
33
34
|
cd ~/code/your-repo
|
|
34
|
-
npx hilos-agent --join <blob>
|
|
35
|
+
npx hilos-agent@latest --join <blob> # token + endpoint from the link; repo auto-detected from cwd
|
|
35
36
|
```
|
|
36
37
|
|
|
37
38
|
Running from elsewhere, or want to map several repos explicitly? Use a config:
|
|
@@ -43,7 +44,7 @@ Running from elsewhere, or want to map several repos explicitly? Use a config:
|
|
|
43
44
|
"token": "mgo_…",
|
|
44
45
|
"repos": { "your-org/your-repo": "/Users/you/code/your-repo" },
|
|
45
46
|
"codingCmd": "claude -p --permission-mode acceptEdits", // safe default; see Permissions / autonomy. or "codex exec", "cursor-agent -p --output-format text --trust", "opencode run", "agy -p", any command
|
|
46
|
-
"codingModel": "", //
|
|
47
|
+
"codingModel": "", // Codex tier ("most-capable" | "balanced" | "fastest"), resolved against this account's own model list; "" = the tool's default
|
|
47
48
|
"chatCmd": "", // FAST command for chat replies + the plan-ack. Empty = derived from codingCmd's tool (codex daemons chat with codex, etc.); set to override
|
|
48
49
|
"defaultBranch": "main",
|
|
49
50
|
"gate": false, // default: open a PR directly. true = approve-before-push
|
|
@@ -73,7 +74,14 @@ hilos-agent --channel <id> # scope to one channel
|
|
|
73
74
|
|
|
74
75
|
## How it works
|
|
75
76
|
|
|
76
|
-
- **Trigger** — an `@mention` of your agent in a channel that's linked to a repo
|
|
77
|
+
- **Trigger** — an `@mention` of your agent in a channel that's linked to a repo,
|
|
78
|
+
or any message in a DM with your agent (a DM is the address — no tag needed;
|
|
79
|
+
there, and only there, the agent may also answer with an emoji reaction or
|
|
80
|
+
stay quiet when a message truly needs nothing).
|
|
81
|
+
Mentions are picked up by polling; on servers that expose `get_wake_channel`,
|
|
82
|
+
a realtime doorbell (a content-free broadcast on every new message) wakes the
|
|
83
|
+
poll instantly, so pickup takes about a second instead of a poll interval
|
|
84
|
+
(needs Node >= 22 for the built-in WebSocket; otherwise it just keeps polling).
|
|
77
85
|
- **Chat or code?** — the agent reads the conversation and decides with the model
|
|
78
86
|
(via `chatCmd`), not a keyword list: a question/greeting/"let's just discuss" →
|
|
79
87
|
a chat reply; anything asking for a change — including "just code it", "finish
|
|
@@ -189,13 +197,15 @@ backward compatible — omit them and the CLI behaves exactly as before.
|
|
|
189
197
|
|
|
190
198
|
## Model & permissions
|
|
191
199
|
|
|
192
|
-
You don't have to hand-write `codingCmd`: the agent's
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
200
|
+
You don't have to hand-write `codingCmd`: the agent's connection panel emits
|
|
201
|
+
the selected provider's real executable and verified flags. Claude Code uses
|
|
202
|
+
its stable `opus` / `sonnet` / `haiku` aliases. Codex carries a neutral
|
|
203
|
+
`most-capable` / `balanced` / `fastest` tier that resolves against that
|
|
204
|
+
account's own live catalog. Cursor, opencode, Antigravity, and Hermes keep their
|
|
205
|
+
own model setting because no universal tier maps honestly onto their catalogs.
|
|
206
|
+
Unsupported permission choices are omitted from the UI. Change the command
|
|
207
|
+
later in `hilos-agent.json`; the daemon re-reads it between polls without
|
|
208
|
+
touching the token.
|
|
199
209
|
|
|
200
210
|
## Permissions / autonomy
|
|
201
211
|
|
|
@@ -246,15 +256,15 @@ becomes the next turn in the same local session and checkout.
|
|
|
246
256
|
|
|
247
257
|
```sh
|
|
248
258
|
# Inside the repo you want to stream:
|
|
249
|
-
npx hilos-agent hooks install
|
|
259
|
+
npx hilos-agent@latest hooks install
|
|
250
260
|
|
|
251
261
|
# Install only one provider, if you prefer:
|
|
252
|
-
npx hilos-agent hooks install --codex
|
|
253
|
-
npx hilos-agent hooks install --claude
|
|
254
|
-
npx hilos-agent hooks install --cursor
|
|
262
|
+
npx hilos-agent@latest hooks install --codex
|
|
263
|
+
npx hilos-agent@latest hooks install --claude
|
|
264
|
+
npx hilos-agent@latest hooks install --cursor
|
|
255
265
|
|
|
256
266
|
# Or opt in every repo on this machine:
|
|
257
|
-
npx hilos-agent hooks install --global
|
|
267
|
+
npx hilos-agent@latest hooks install --global
|
|
258
268
|
```
|
|
259
269
|
|
|
260
270
|
Claude Code and Cursor write lifecycle entries into this repo. Codex needs one
|
|
@@ -274,7 +284,7 @@ therefore remains usable offline; no global install or PATH change is required.
|
|
|
274
284
|
Preview all three blocks without writing anything:
|
|
275
285
|
|
|
276
286
|
```sh
|
|
277
|
-
npx hilos-agent hooks print
|
|
287
|
+
npx hilos-agent@latest hooks print
|
|
278
288
|
```
|
|
279
289
|
|
|
280
290
|
**Requirements:**
|
package/bin/hilos-agent.mjs
CHANGED
|
@@ -75,12 +75,14 @@ Options:
|
|
|
75
75
|
--channel <id> watch only one channel (per-channel override)
|
|
76
76
|
--config <path> use a specific config file
|
|
77
77
|
--coding-cmd <cmd> the coding agent to run — claude -p, codex exec,
|
|
78
|
-
cursor-agent -p --trust, opencode run, agy -p, hermes,
|
|
78
|
+
cursor-agent -p --trust, opencode run, agy -p, hermes -z,
|
|
79
79
|
or any command
|
|
80
|
-
that takes a prompt as its last arg (default:
|
|
81
|
-
|
|
80
|
+
that takes a prompt as its last arg (default:
|
|
81
|
+
"claude -p --permission-mode acceptEdits")
|
|
82
|
+
--coding-model <tier> model tier (default | most-capable | balanced | fastest)
|
|
83
|
+
resolved at
|
|
82
84
|
run time against the CLI's own model list — never a baked
|
|
83
|
-
id (
|
|
85
|
+
id (Codex only today; default: the tool's own model)
|
|
84
86
|
--chat-cmd <cmd> fast command for chat replies + the plan-ack (default:
|
|
85
87
|
derived from the coding command, so a Codex or Cursor
|
|
86
88
|
daemon chats with its own tool)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.3",
|
|
4
4
|
"description": "Run your own coding agent (Claude Code / Codex / Cursor) as an autonomous teammate in a hilos channel. Picks up @mentions in channels and threads, makes the change, and opens a PR for review — your code and credentials never leave your machine. (Approve-before-push is available via gate:true.)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/config.mjs
CHANGED
|
@@ -103,9 +103,10 @@ const DEFAULTS = {
|
|
|
103
103
|
codingEnv: "inherit",
|
|
104
104
|
codingEnvAllow: [],
|
|
105
105
|
// Model preset TIER for the coding run ("" / "default" = the tool's own
|
|
106
|
-
// default). "
|
|
107
|
-
//
|
|
108
|
-
//
|
|
106
|
+
// default). "most-capable" | "balanced" | "fastest" resolve at RUN time
|
|
107
|
+
// against Codex's own model list — never a baked id that could 404 on
|
|
108
|
+
// another account. The original opus/sonnet/haiku and Cursor values remain
|
|
109
|
+
// accepted as backward-compatible read aliases (0864).
|
|
109
110
|
codingModel: "",
|
|
110
111
|
// Chat replies + the code-task plan-ack use a FAST one-shot command so a casual
|
|
111
112
|
// reply (or "I see it, here's my plan") comes back in seconds, not minutes.
|
package/src/handler.mjs
CHANGED
|
@@ -992,6 +992,58 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
|
|
|
992
992
|
// code. Unusual on purpose so it can't be confused with a real chat reply.
|
|
993
993
|
const CODE_SIGNAL = "__CODE__";
|
|
994
994
|
|
|
995
|
+
// 0860 — the quiet option, offered ONLY on untagged DM wakes (message.implicit
|
|
996
|
+
// === "dm"). In a DM every message reaches the agent without a tag, so some of
|
|
997
|
+
// what arrives genuinely needs nothing: a closing "thanks", an "ok", an aside.
|
|
998
|
+
// The model may answer those with this token instead of words — optionally
|
|
999
|
+
// followed by one emoji to react with. An explicit @-mention NEVER gets this
|
|
1000
|
+
// option: a summons deserves a reply, and silence there is the exact
|
|
1001
|
+
// "agent ignores me" failure this ticket exists to end.
|
|
1002
|
+
export const NO_REPLY_SIGNAL = "__NO_REPLY__";
|
|
1003
|
+
|
|
1004
|
+
/**
|
|
1005
|
+
* Read the quiet-option sentinel off a chat model's output. Counts only when
|
|
1006
|
+
* the FIRST non-empty line is the token alone or the token plus one emoji-ish
|
|
1007
|
+
* word (no letters or digits — "__NO_REPLY__ ok" is a malformed reply, not a
|
|
1008
|
+
* reaction, and posts as text so nothing a person needs gets eaten silently).
|
|
1009
|
+
* @param {string} text
|
|
1010
|
+
* @returns {{ noReply: boolean, emoji: string | null }}
|
|
1011
|
+
*/
|
|
1012
|
+
export function parseNoReply(text) {
|
|
1013
|
+
const firstLine = String(text || "")
|
|
1014
|
+
.split("\n")
|
|
1015
|
+
.map((l) => l.trim())
|
|
1016
|
+
.find((l) => l.length > 0);
|
|
1017
|
+
if (!firstLine || !firstLine.startsWith(NO_REPLY_SIGNAL)) {
|
|
1018
|
+
return { noReply: false, emoji: null };
|
|
1019
|
+
}
|
|
1020
|
+
const rest = firstLine.slice(NO_REPLY_SIGNAL.length).trim();
|
|
1021
|
+
if (!rest) return { noReply: true, emoji: null };
|
|
1022
|
+
const tokens = rest.split(/\s+/);
|
|
1023
|
+
const [emoji] = tokens;
|
|
1024
|
+
if (tokens.length === 1 && emoji.length <= 16 && !/[A-Za-z0-9]/.test(emoji)) {
|
|
1025
|
+
return { noReply: true, emoji };
|
|
1026
|
+
}
|
|
1027
|
+
return { noReply: false, emoji: null };
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* The prompt paragraph that grants the quiet option. Empty unless this wake is
|
|
1032
|
+
* an untagged DM message — the ONLY context where not answering is ever right.
|
|
1033
|
+
*/
|
|
1034
|
+
export function dmJudgmentBlock(implicitDm) {
|
|
1035
|
+
if (!implicitDm) return "";
|
|
1036
|
+
return (
|
|
1037
|
+
`\n\nThis is a direct-message conversation with you: the latest message reached you ` +
|
|
1038
|
+
`because every DM message does — nobody typed your @-handle. Almost every message still ` +
|
|
1039
|
+
`deserves your reply, and when in doubt, reply. But if the latest message truly needs ` +
|
|
1040
|
+
`nothing from you — a closing "thanks" or "ok", a nicety, or something clearly said to ` +
|
|
1041
|
+
`another person in this conversation — output ONLY the token ${NO_REPLY_SIGNAL}, ` +
|
|
1042
|
+
`optionally followed by a single emoji to react with instead (e.g. ${NO_REPLY_SIGNAL} 👍). ` +
|
|
1043
|
+
`Never use ${NO_REPLY_SIGNAL} on a question, a request, or anything that expects an answer.`
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
|
|
995
1047
|
/**
|
|
996
1048
|
* Decide — with the LLM, not a word list — whether the latest message wants a
|
|
997
1049
|
* code change or a conversational reply, and produce the payload in the SAME
|
|
@@ -1012,7 +1064,7 @@ const CODE_SIGNAL = "__CODE__";
|
|
|
1012
1064
|
* `error` is set when the model produced nothing (so the caller can be honest
|
|
1013
1065
|
* about a timeout vs a missing binary instead of inventing a reply).
|
|
1014
1066
|
*/
|
|
1015
|
-
async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal, hasActiveRun = false, runCliFn }) {
|
|
1067
|
+
async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal, hasActiveRun = false, runCliFn, implicitDm = false }) {
|
|
1016
1068
|
const doRun = runCliFn || runCli; // folder mode injects deps.runCli; repo flow uses the import
|
|
1017
1069
|
const cmd = chatCmdFor(cfg);
|
|
1018
1070
|
const parts = cmd.split(" ").filter(Boolean);
|
|
@@ -1046,7 +1098,8 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
|
|
|
1046
1098
|
`action language.` +
|
|
1047
1099
|
`${followupBlock}\n\n` +
|
|
1048
1100
|
`You are only routing here — output ONLY your text response. Do NOT use any tools, do NOT ` +
|
|
1049
|
-
`edit files, do NOT run commands; a separate step does the actual coding
|
|
1101
|
+
`edit files, do NOT run commands; a separate step does the actual coding.` +
|
|
1102
|
+
`${dmJudgmentBlock(implicitDm)}\n\n` +
|
|
1050
1103
|
`${memoryPreamble(workspaceMemory)}Conversation so far:\n${transcript}`;
|
|
1051
1104
|
const run = await doRun({
|
|
1052
1105
|
cmd: parts[0],
|
|
@@ -1075,6 +1128,12 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
|
|
|
1075
1128
|
const nl = after.indexOf("\n");
|
|
1076
1129
|
return { code: true, task: (nl >= 0 ? after.slice(nl + 1) : after).trim(), followupSignal };
|
|
1077
1130
|
}
|
|
1131
|
+
// The quiet option (0860) — only ever offered (and only ever honored) on an
|
|
1132
|
+
// untagged DM wake, so a tagged summons can never be answered with silence.
|
|
1133
|
+
if (implicitDm) {
|
|
1134
|
+
const quiet = parseNoReply(out);
|
|
1135
|
+
if (quiet.noReply) return { code: false, noReply: true, emoji: quiet.emoji, followupSignal };
|
|
1136
|
+
}
|
|
1078
1137
|
return { code: false, reply: out, followupSignal };
|
|
1079
1138
|
}
|
|
1080
1139
|
|
|
@@ -1237,9 +1296,19 @@ async function proposePlanAck({ task, transcript, repoFullName, cfg, signal }) {
|
|
|
1237
1296
|
return text.length > 400 ? text.slice(0, 399) + "…" : text;
|
|
1238
1297
|
}
|
|
1239
1298
|
|
|
1299
|
+
/**
|
|
1300
|
+
* React to the wake's message instead of replying (0860). Best-effort and
|
|
1301
|
+
* capability-gated: on an older server (no add_reaction) or any failure the
|
|
1302
|
+
* agent simply stays quiet, which is what the model chose anyway.
|
|
1303
|
+
*/
|
|
1304
|
+
async function reactQuietly({ tool, caps, message, emoji }) {
|
|
1305
|
+
if (!emoji || !caps?.react || !message?.id) return;
|
|
1306
|
+
await tool("add_reaction", { messageId: message.id, emoji }).catch(() => {});
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1240
1309
|
/** Run the FAST chat CLI to produce a reply, using recent channel context. */
|
|
1241
1310
|
async function respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps = {} }) {
|
|
1242
|
-
|
|
1311
|
+
const implicitDm = message?.implicit === "dm";
|
|
1243
1312
|
const { transcript } = context || (await fetchContext({ channelId, tool, parentId }));
|
|
1244
1313
|
const name = me?.agentName || "an assistant";
|
|
1245
1314
|
// Tell the agent what the room is connected to so it doesn't ask "which repo?".
|
|
@@ -1255,7 +1324,8 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
|
|
|
1255
1324
|
const prompt =
|
|
1256
1325
|
`You are ${name}, a teammate in a team chat (hilos). Reply to the latest message ` +
|
|
1257
1326
|
`concisely and directly as a single chat message — no preamble, no headings. ` +
|
|
1258
|
-
`${repoLine}
|
|
1327
|
+
`${repoLine}` +
|
|
1328
|
+
`${dmJudgmentBlock(implicitDm)}\n\n` +
|
|
1259
1329
|
`${memoryPreamble(workspaceMemory)}` +
|
|
1260
1330
|
`Conversation so far:\n${transcript}`;
|
|
1261
1331
|
|
|
@@ -1320,6 +1390,21 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
|
|
|
1320
1390
|
}
|
|
1321
1391
|
const reply = (run.stdout || "").trim();
|
|
1322
1392
|
if (run.error) console.log(` ! ${parts[0]}: ${run.error.message}`);
|
|
1393
|
+
// The quiet option (0860), honored only on an untagged DM wake. If a slow
|
|
1394
|
+
// decision already posted a "Still thinking…" ping, silence would strand it
|
|
1395
|
+
// — settle the ping with the reaction emoji (or a plain nod) instead.
|
|
1396
|
+
if (implicitDm) {
|
|
1397
|
+
const quiet = parseNoReply(reply);
|
|
1398
|
+
if (quiet.noReply) {
|
|
1399
|
+
console.log(` chat → quiet${quiet.emoji ? ` (reacting ${quiet.emoji})` : " (no reply needed)"}`);
|
|
1400
|
+
if (thinkingId) {
|
|
1401
|
+
await deliver(quiet.emoji || "👍");
|
|
1402
|
+
} else {
|
|
1403
|
+
await reactQuietly({ tool, caps, message, emoji: quiet.emoji });
|
|
1404
|
+
}
|
|
1405
|
+
return;
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1323
1408
|
console.log(` chat → ${reply ? `replied (${reply.length} chars)` : "no output"}; posting`);
|
|
1324
1409
|
// Honest fallback: a timeout is NOT a missing binary. Only a real spawn failure
|
|
1325
1410
|
// (ENOENT) means the command isn't on PATH.
|
|
@@ -2407,6 +2492,9 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2407
2492
|
const onStopRequested = opts.onStopRequested;
|
|
2408
2493
|
// When the mention was a thread reply, keep the whole exchange in that thread.
|
|
2409
2494
|
const parentId = message.parentId ?? null;
|
|
2495
|
+
// 0860 — an untagged DM wake: addressed by the room, not by an @-handle. The
|
|
2496
|
+
// one context where the router/chat prompts offer the quiet option.
|
|
2497
|
+
const implicitDm = message.implicit === "dm";
|
|
2410
2498
|
|
|
2411
2499
|
const { links = [] } = await tool("get_links", { channelId }).catch(() => ({ links: [] }));
|
|
2412
2500
|
// A folder link (0324) carries the folder PATH in repo_full_name for display —
|
|
@@ -2598,12 +2686,17 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2598
2686
|
cfg,
|
|
2599
2687
|
signal,
|
|
2600
2688
|
runCliFn: deps.runCli,
|
|
2689
|
+
implicitDm,
|
|
2601
2690
|
});
|
|
2602
2691
|
}
|
|
2603
2692
|
if (routed.aborted || signal?.aborted) {
|
|
2604
2693
|
await tool("post_message", { channelId, parentId, body: "Stopped." });
|
|
2605
2694
|
return { status: "chat" };
|
|
2606
2695
|
}
|
|
2696
|
+
if (routed.noReply) {
|
|
2697
|
+
await reactQuietly({ tool, caps, message, emoji: routed.emoji });
|
|
2698
|
+
return { status: "quiet" };
|
|
2699
|
+
}
|
|
2607
2700
|
if (routed.code) {
|
|
2608
2701
|
return await handleFolderTask({
|
|
2609
2702
|
message,
|
|
@@ -2676,12 +2769,17 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2676
2769
|
cfg,
|
|
2677
2770
|
signal,
|
|
2678
2771
|
hasActiveRun: activeRunOpen,
|
|
2772
|
+
implicitDm,
|
|
2679
2773
|
});
|
|
2680
2774
|
if (routed.aborted || signal?.aborted) {
|
|
2681
2775
|
await tool("post_message", { channelId, parentId, body: "Stopped." });
|
|
2682
2776
|
return { status: "chat" };
|
|
2683
2777
|
}
|
|
2684
2778
|
if (!routed.code) {
|
|
2779
|
+
if (routed.noReply) {
|
|
2780
|
+
await reactQuietly({ tool, caps, message, emoji: routed.emoji });
|
|
2781
|
+
return { status: "quiet" };
|
|
2782
|
+
}
|
|
2685
2783
|
let body = routed.reply;
|
|
2686
2784
|
if (!body) {
|
|
2687
2785
|
body =
|
package/src/model-resolve.mjs
CHANGED
|
@@ -1,23 +1,20 @@
|
|
|
1
|
-
// Runtime model-preset resolution (0504).
|
|
2
|
-
//
|
|
3
|
-
// account- and plan-specific and churn weekly — a baked id that works for one
|
|
4
|
-
// user 404s for another, which is why VENDOR_CLI.cursor.model stayed empty for
|
|
5
|
-
// months. So the tier resolves HERE, at run time, against the account's OWN
|
|
6
|
-
// `cursor-agent --list-models` output: we only ever emit an id the CLI itself
|
|
7
|
-
// just listed, and when nothing matches we emit no flag at all (the tool's
|
|
8
|
-
// default — `auto` routing — stands). Never a guessed id, never a wrong flag.
|
|
9
|
-
//
|
|
10
|
-
// Design rules (mirror the .mjs siblings): the parse/resolve transforms are
|
|
11
|
-
// PURE + node-builtins-only; the resolver takes an injected `run` (runCli) so
|
|
12
|
-
// tests drive it with no CLI; a resolution failure NEVER breaks a run ([]).
|
|
13
|
-
//
|
|
14
|
-
// codex joined in 0783 on the SAME terms: `codex debug models` ("Render the raw
|
|
1
|
+
// Runtime model-preset resolution (0504). Codex gets provider-neutral tiers in
|
|
2
|
+
// new commands: `codex debug models` ("Render the raw
|
|
15
3
|
// model catalog as JSON") is its `--list-models`, so its tier also resolves at
|
|
16
4
|
// run time against the account's own catalog and never against a baked slug.
|
|
17
5
|
// That matters more for codex than for anyone: its slugs are codenames that rev
|
|
18
6
|
// (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` in the live catalog) and are
|
|
19
7
|
// plan-gated — a run on a ChatGPT account rejects `gpt-5` outright with "The
|
|
20
8
|
// 'gpt-5' model is not supported when using Codex with a ChatGPT account".
|
|
9
|
+
//
|
|
10
|
+
// Cursor remains below only as a compatibility reader for existing 0.9.2
|
|
11
|
+
// configs that stored the old Anthropic-shaped tier names. New Hilos commands
|
|
12
|
+
// do not set a Cursor tier: Cursor's catalog mixes providers and account-only
|
|
13
|
+
// ids, so its own `auto` model choice is the only honest default.
|
|
14
|
+
//
|
|
15
|
+
// Design rules (mirror the .mjs siblings): the parse/resolve transforms are
|
|
16
|
+
// PURE + node-builtins-only; the resolver takes an injected `run` (runCli) so
|
|
17
|
+
// tests drive it with no CLI; a resolution failure NEVER breaks a run ([]).
|
|
21
18
|
|
|
22
19
|
/** Strip ANSI SGR color codes (`--list-models` output is colorized). */
|
|
23
20
|
export function stripAnsi(s) {
|
|
@@ -55,6 +52,24 @@ const TIER_PREFS = {
|
|
|
55
52
|
haiku: [/^composer.*fast/, /-fast$/],
|
|
56
53
|
};
|
|
57
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Public tier names are provider-neutral. Keep the original Anthropic-shaped
|
|
57
|
+
* values as read aliases so existing hilos-agent.json files and already-copied
|
|
58
|
+
* commands continue to work after 0.9.3.
|
|
59
|
+
*/
|
|
60
|
+
export function normalizeModelTier(tier) {
|
|
61
|
+
const value = String(tier || "").trim().toLowerCase();
|
|
62
|
+
return ({
|
|
63
|
+
"most-capable": "opus",
|
|
64
|
+
balanced: "sonnet",
|
|
65
|
+
fastest: "haiku",
|
|
66
|
+
opus: "opus",
|
|
67
|
+
sonnet: "sonnet",
|
|
68
|
+
haiku: "haiku",
|
|
69
|
+
default: "default",
|
|
70
|
+
})[value] || "";
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
/**
|
|
59
74
|
* Pick the account's model id for a tier, or null when nothing fits.
|
|
60
75
|
* @param {'opus'|'sonnet'|'haiku'|string} tier
|
|
@@ -62,7 +77,7 @@ const TIER_PREFS = {
|
|
|
62
77
|
* @returns {string|null}
|
|
63
78
|
*/
|
|
64
79
|
export function resolveCursorModel(tier, ids) {
|
|
65
|
-
const prefs = TIER_PREFS[tier];
|
|
80
|
+
const prefs = TIER_PREFS[normalizeModelTier(tier)];
|
|
66
81
|
if (!prefs || !Array.isArray(ids)) return null;
|
|
67
82
|
for (const re of prefs) {
|
|
68
83
|
const hit = ids.find((id) => typeof id === "string" && re.test(id));
|
|
@@ -181,13 +196,14 @@ const CODEX_TIER_PREFS = {
|
|
|
181
196
|
* @returns {string|null}
|
|
182
197
|
*/
|
|
183
198
|
export function resolveCodexModel(tier, models) {
|
|
184
|
-
const
|
|
199
|
+
const normalized = normalizeModelTier(tier);
|
|
200
|
+
const prefs = CODEX_TIER_PREFS[normalized];
|
|
185
201
|
if (!prefs || !Array.isArray(models) || models.length === 0) return null;
|
|
186
202
|
for (const re of prefs) {
|
|
187
203
|
const hit = models.find((m) => m && typeof m.text === "string" && re.test(m.text));
|
|
188
204
|
if (hit) return hit.slug;
|
|
189
205
|
}
|
|
190
|
-
if (
|
|
206
|
+
if (normalized === "haiku") return models[models.length - 1].slug;
|
|
191
207
|
return models[0].slug;
|
|
192
208
|
}
|
|
193
209
|
|
|
@@ -231,7 +247,7 @@ export function createModelArgsResolver({ run } = {}) {
|
|
|
231
247
|
return async function modelArgsFor(cfg, vendor) {
|
|
232
248
|
try {
|
|
233
249
|
const spec = LIST_MODELS[vendor];
|
|
234
|
-
const tier =
|
|
250
|
+
const tier = normalizeModelTier(cfg?.codingModel);
|
|
235
251
|
if (!spec || !tier || tier === "default") return [];
|
|
236
252
|
const cmd = String(cfg?.codingCmd || "");
|
|
237
253
|
// Hand-pinned wins — including codex's short spelling (`-m gpt-5.6-sol`),
|
package/src/progress-emitter.mjs
CHANGED
|
@@ -69,7 +69,7 @@ export function detectVendor(codingCmd) {
|
|
|
69
69
|
* @returns {string}
|
|
70
70
|
*/
|
|
71
71
|
export function fastChatCmd(vendor) {
|
|
72
|
-
if (vendor === "claude_code") return "claude -p --model
|
|
72
|
+
if (vendor === "claude_code") return "claude -p --model haiku";
|
|
73
73
|
if (vendor === "codex") return "codex exec --skip-git-repo-check";
|
|
74
74
|
if (vendor === "cursor") return "cursor-agent -p --output-format text --trust";
|
|
75
75
|
if (vendor === "opencode") return "opencode run";
|
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
|
+
}
|