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 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), runs your coding agent in a
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> # token + endpoint from the link; repo auto-detected from cwd
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": "", // model preset tier ("opus" | "sonnet" | "haiku") resolved at run time against the CLI's own model list (Cursor only today); "" = the tool's default
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 **Connect via MCP** panel in
193
- hilos has **Model** (Vendor default / Opus / Sonnet / Haiku) and **Permissions**
194
- (Ask before edits / Auto-approve edits / Skip all prompts) pickers that bake your
195
- choice into the generated `--coding-cmd`. Change it later by editing `codingCmd` in
196
- `hilos-agent.json` the daemon re-reads the file between polls and applies it
197
- without a restart (your `url`/`token` are never affected). The next section
198
- explains what each permission level means.
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:**
@@ -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: "claude -p")
81
- --coding-model <tier> model preset tier (opus | sonnet | haiku) resolved at
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 (Cursor only today; default: the tool's own model)
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.2",
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). "opus" | "sonnet" | "haiku" resolve at RUN time against the
107
- // CLI's own model list — never a baked id that could 404 on another account
108
- // (0504; cursor only today, see model-resolve.mjs).
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.\n\n` +
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
- void message;
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}\n\n` +
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 =
@@ -1,23 +1,20 @@
1
- // Runtime model-preset resolution (0504). The connect UI offers capability
2
- // TIERS (Most capable / Balanced / Fastest), but Cursor's model ids are
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 prefs = CODEX_TIER_PREFS[tier];
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 (tier === "haiku") return models[models.length - 1].slug;
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 = String(cfg?.codingModel || "").trim();
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`),
@@ -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 claude-haiku-4-5";
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
- // 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
+ }