hilos-agent 0.9.1 → 0.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md 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,32 +256,43 @@ 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
- This writes lifecycle entries into `.codex/hooks.json`, `.claude/settings.json`,
261
- and `.cursor/hooks.json` (or their home-directory equivalents with `--global`),
262
- preserving any hooks you already have. Codex asks you to review new project
263
- hooks once; open `/hooks` and trust the hilos entries. Preview all three blocks
264
- without writing anything:
270
+ Claude Code and Cursor write lifecycle entries into this repo. Codex needs one
271
+ entry in `~/.codex/hooks.json` because `codex exec` does not run repository
272
+ hooks. hilos keeps that entry project-scoped with a 0600 allowlist at
273
+ `~/.hilos/codex-hook-scope.json`, so other repos stay untouched unless you use
274
+ `--global`. Launching Codex below the opted-in repository root still works, but
275
+ a nested repository needs its own opt-in. If the allowlist is lost or malformed,
276
+ new installs fail closed instead of reporting from every repo.
277
+
278
+ Any hooks you already have are preserved. A pre-existing project-level hilos
279
+ Codex entry is moved out, with a `.bak` beside the changed file. Codex asks you
280
+ to review the home-level hilos hook once; open `/hooks` and trust the entry.
281
+ The installer copies the dependency-free hook code into a private, versioned
282
+ `~/.hilos/hook-runtime` and records its absolute entrypoint. A one-off npx run
283
+ therefore remains usable offline; no global install or PATH change is required.
284
+ Preview all three blocks without writing anything:
265
285
 
266
286
  ```sh
267
- npx hilos-agent hooks print
287
+ npx hilos-agent@latest hooks print
268
288
  ```
269
289
 
270
290
  **Requirements:**
271
- - `npm i -g hilos-agent` so the `hilos-agent hook` command resolves at hook time.
272
- - A `~/.hilos/agent.json` with `url` and the owned agent's `token`; `channelId`
273
- is needed only for the fallback live card. Generate these in the agent's
274
- **Connect via MCP** panel in hilos (same panel as `--join`).
291
+ - For live progress from every tool event, a `~/.hilos/agent.json` with `url`
292
+ and the owned agent's `token`; `channelId` is needed only for the fallback
293
+ live card. The normal one-line `--join` flow does not need that file for
294
+ replies: its token stays in daemon memory, and Hilos signs each session
295
+ binding so the daemon can verify it before resuming local code.
275
296
  - Keep `hilos-agent` running to pick replies up. Hooks alone still stream live
276
297
  steps, but MCP has no server-push channel that can wake an idle local client.
277
298
 
@@ -281,7 +302,14 @@ npx hilos-agent hooks print
281
302
  - A successful hilos post binds its returned message to this provider session.
282
303
  A person's later reply to that exact thread resumes the same session while
283
304
  `hilos-agent` is running. Ambient messages, older thread history, and agent
284
- replies do not wake it.
305
+ replies do not wake it. Current workspace roles are checked at pickup: guests,
306
+ removed people, and unknown authors remain advisory and cannot start code.
307
+ - The resumed turn receives bounded thread, room, and member context. Codex also
308
+ gets a random loopback-only Hilos MCP URL for that turn, so it can search any
309
+ room its linked owner can access when that owner minted the current key,
310
+ without receiving the bearer token. Regenerate an older key from Connect to
311
+ enable that inheritance. Admin-issued keys and other people's private rooms
312
+ stay outside the context.
285
313
  - The room's normal execution gate still applies; a chat-only guest room cannot
286
314
  resume local code.
287
315
  - Steps are coalesced into ~2s batches to keep traffic light.
@@ -290,10 +318,12 @@ npx hilos-agent hooks print
290
318
  - Up to 20 unique files touched are surfaced so reviewers can glance at the scope
291
319
  before the report card arrives.
292
320
 
293
- **Privacy:** project-level by default (only repos you opt into stream). Hook
294
- state stays in `~/.hilos/hook-state` and contains session ids, checkout paths,
295
- hilos message ids, progress steps, and any completed result waiting for a
296
- delivery retry never the coding transcript. Kill
321
+ **Privacy:** project-level by default (only repos you opt into stream, including
322
+ Codex through its local allowlist). Hook state stays in
323
+ `~/.hilos/hook-state` and contains session ids, checkout paths,
324
+ hilos message ids, an opaque server-signed binding proof, a one-way credential
325
+ scope after verification, progress steps, and any completed result waiting for
326
+ a delivery retry — never the bearer token or coding transcript. Kill
297
327
  switches: `HILOS_HOOKS=off` pauses streaming and new bindings;
298
328
  `HILOS_REPLY_BRIDGE=off` pauses inbound replies. The hook always exits 0 — it
299
329
  will never interrupt or break your CLI session.
@@ -50,6 +50,8 @@ function parseArgs(argv) {
50
50
  else if (a === "--codex") flags.hookClient = "codex";
51
51
  else if (a === "--cursor") flags.hookClient = "cursor";
52
52
  else if (a === "--vendor") flags.vendor = argv[++i];
53
+ else if (a === "--scope-managed") flags.scopeManaged = true;
54
+ else if (a === "--managed-runtime") flags.managedRuntime = true;
53
55
  else if (a === "-h" || a === "--help") flags.help = true;
54
56
  else if (a === "-v" || a === "--version") flags.version = true;
55
57
  else positional.push(a);
@@ -73,12 +75,14 @@ Options:
73
75
  --channel <id> watch only one channel (per-channel override)
74
76
  --config <path> use a specific config file
75
77
  --coding-cmd <cmd> the coding agent to run — claude -p, codex exec,
76
- cursor-agent -p --trust, opencode run, agy -p, hermes,
78
+ cursor-agent -p --trust, opencode run, agy -p, hermes -z,
77
79
  or any command
78
- that takes a prompt as its last arg (default: "claude -p")
79
- --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
80
84
  run time against the CLI's own model list — never a baked
81
- id (Cursor only today; default: the tool's own model)
85
+ id (Codex only today; default: the tool's own model)
82
86
  --chat-cmd <cmd> fast command for chat replies + the plan-ack (default:
83
87
  derived from the coding command, so a Codex or Cursor
84
88
  daemon chats with its own tool)
@@ -106,7 +110,7 @@ async function main() {
106
110
  // fast, silent, and always exit 0, so it short-circuits before any daemon
107
111
  // machinery.
108
112
  if (cmd === "hook") {
109
- await hookMain({ vendor: flags.vendor || "unknown" });
113
+ await hookMain({ vendor: flags.vendor || "unknown", scopeManaged: Boolean(flags.scopeManaged) });
110
114
  return;
111
115
  }
112
116
  if (cmd === "hooks") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.9.1",
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/argv.mjs ADDED
@@ -0,0 +1,61 @@
1
+ // Small shell-word parser for configured CLI commands. hilos never runs these
2
+ // strings through a shell; this preserves quoted paths/values while keeping the
3
+ // spawn boundary injection-safe. It intentionally supports words, single and
4
+ // double quotes, and backslash-escaped whitespace/quotes—not expansions, pipes,
5
+ // or redirects. Other backslashes stay literal so Windows and UNC paths work.
6
+
7
+ export function commandArgv(command) {
8
+ const input = String(command || "");
9
+ const out = [];
10
+ let word = "";
11
+ let quote = "";
12
+ let started = false;
13
+ for (let index = 0; index < input.length; index += 1) {
14
+ const char = input[index];
15
+ if (char === "\\" && quote !== "'") {
16
+ const next = input[index + 1];
17
+ const escapesQuote = next === '"' || (!quote && next === "'");
18
+ const escapesWhitespace = !quote && typeof next === "string" && /\s/.test(next);
19
+ if (escapesQuote || escapesWhitespace) {
20
+ word += next;
21
+ index += 1;
22
+ } else {
23
+ word += "\\";
24
+ }
25
+ started = true;
26
+ continue;
27
+ }
28
+ if (quote) {
29
+ if (char === quote) quote = "";
30
+ else word += char;
31
+ started = true;
32
+ continue;
33
+ }
34
+ if (char === "'" || char === '"') {
35
+ quote = char;
36
+ started = true;
37
+ continue;
38
+ }
39
+ if (/\s/.test(char)) {
40
+ if (started) {
41
+ out.push(word);
42
+ word = "";
43
+ started = false;
44
+ }
45
+ continue;
46
+ }
47
+ word += char;
48
+ started = true;
49
+ }
50
+ if (started) out.push(word);
51
+ return out;
52
+ }
53
+
54
+ /**
55
+ * Encode one literal argument for `commandArgv`'s small command grammar.
56
+ * Single-quoted spans preserve whitespace and backslashes verbatim; an
57
+ * embedded apostrophe is represented as an adjacent double-quoted span.
58
+ */
59
+ export function quoteCommandArg(value) {
60
+ return `'${String(value).replaceAll("'", `'"'"'`)}'`;
61
+ }
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 =