hilos-agent 0.9.1 → 0.9.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -31
- package/bin/hilos-agent.mjs +9 -5
- package/package.json +1 -1
- package/src/argv.mjs +61 -0
- package/src/config.mjs +4 -3
- package/src/handler.mjs +102 -4
- package/src/hook.mjs +429 -61
- package/src/mcp-loopback.mjs +142 -0
- package/src/mcp.mjs +2 -2
- package/src/model-resolve.mjs +34 -18
- package/src/progress-emitter.mjs +6 -3
- package/src/reply-bridge.mjs +412 -49
- package/src/run.mjs +67 -21
- package/src/wake.mjs +288 -0
package/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,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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
|
294
|
-
|
|
295
|
-
hilos
|
|
296
|
-
|
|
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.
|
package/bin/hilos-agent.mjs
CHANGED
|
@@ -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:
|
|
79
|
-
|
|
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 (
|
|
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.
|
|
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). "
|
|
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 =
|