hilos-agent 0.9.4 → 0.9.5

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
@@ -53,7 +53,9 @@ Running from elsewhere, or want to map several repos explicitly? Use a config:
53
53
  "defaultBranch": "main",
54
54
  "gate": false, // default: open a PR directly. true = approve-before-push
55
55
  "heartbeatMs": 180000, // long runs post one "still working…" thread reply this often (0 = off, min 15s)
56
- "chatTimeoutMs": 90000 // cap a chat reply / plan-ack so a stalled model can't go silent
56
+ "chatTimeoutMs": 90000, // cap a chat reply / plan-ack so a stalled model can't go silent
57
+ "longPollMs": 20000, // ask the server to HOLD the mention poll and answer the moment work lands (0 = plain 5s polling; older servers fall back automatically)
58
+ "catchupMs": 86400000 // how far back a restart replays mentions from the persisted cursor (default 24h; 0 = restart at "now", the pre-0866 behavior)
57
59
  }
58
60
  ```
59
61
 
@@ -68,7 +70,11 @@ to a short "done" line. A run that **times out or errors** says so honestly (wit
68
70
  a stderr tail) instead of claiming "no changes". Chat replies use the faster
69
71
  `chatCmd` (when unset, derived from `codingCmd`'s tool — a Claude daemon chats
70
72
  with Haiku, a Codex daemon with `codex exec`, and so on) bounded by
71
- `chatTimeoutMs`. The responsive surface needs a hilos server new enough to expose
73
+ `chatTimeoutMs`. The chat-vs-code pass only classifies; the separate chat
74
+ responder keeps the CLI's normal tools. Generated Codex chat and code commands
75
+ explicitly enable its built-in web search (an explicit operator override still
76
+ wins), while other vendors keep their own tool configuration. The responsive
77
+ surface needs a hilos server new enough to expose
72
78
  `edit_message`; older servers just skip the live edits.
73
79
 
74
80
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.9.4",
3
+ "version": "0.9.5",
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": {
@@ -461,6 +461,10 @@ export async function runCodexMcpSession({
461
461
  cwd,
462
462
  "approval-policy": approvalPolicy,
463
463
  sandbox,
464
+ // The gated transport cannot inherit `codex exec` argv. Carry the
465
+ // same public web-search capability through the MCP tool's config;
466
+ // permission policy and sandboxing remain unchanged.
467
+ config: { tools: { web_search: true } },
464
468
  ...(model ? { model } : {}),
465
469
  },
466
470
  };
package/src/config.mjs CHANGED
@@ -121,6 +121,17 @@ const DEFAULTS = {
121
121
  gate: false,
122
122
  maxRounds: 3,
123
123
  pollMs: 5000,
124
+ // Long-poll (0866): when the server supports it, list_mentions is asked to
125
+ // HOLD this many ms and answer the moment a mention lands — pickup latency
126
+ // stops being pollMs. 0 disables and falls back to plain pollMs polling.
127
+ // The server caps a hold at 25s regardless of what is asked for here.
128
+ longPollMs: 20000,
129
+ // How far back a restart may replay missed mentions from the persisted
130
+ // cursor (0866). A daemon that was off for a week should not open a week of
131
+ // stale branches; a day covers the overnight-laptop case the cursor exists
132
+ // for. 0 disables catch-up entirely (restart starts at "now", the old
133
+ // behavior).
134
+ catchupMs: 24 * 60 * 60 * 1000,
124
135
  // On a long code run, post ONE thread progress reply at the first beat then
125
136
  // edit it on later beats, so a human sees the agent is alive without thread
126
137
  // spam. 0 disables. Clamped to >=15s so a misconfig can't spam realtime
@@ -224,6 +235,8 @@ const LIVE_FIELDS = [
224
235
  "gate",
225
236
  "maxRounds",
226
237
  "pollMs",
238
+ "longPollMs",
239
+ "catchupMs",
227
240
  "runTimeoutMs",
228
241
  "decisionTimeoutMs",
229
242
  "decisionPollMs",
@@ -0,0 +1,76 @@
1
+ // Durable mention cursor (0866).
2
+ //
3
+ // The poll loop's cursor and dedupe set lived only in memory, so a daemon
4
+ // restart began at "now" and every mention that arrived while it was down was
5
+ // silently skipped (backfill:false, the default). This file is the fix's whole
6
+ // mechanism: the cursor is persisted per agent after each delivery, and a
7
+ // restart resumes from it — bounded by `catchupMs`, because replaying a week of
8
+ // stale asks after a vacation would be worse than skipping them.
9
+ //
10
+ // Telegram semantics, deliberately: persisting the cursor acknowledges
11
+ // DELIVERY into the daemon's queue, not completion of the work. A crash after
12
+ // enqueue can still lose an in-flight task (exactly as today); what can no
13
+ // longer happen is a mention arriving into a dead daemon and never being seen.
14
+ //
15
+ // Dependency-free and injectable-dir like hook.mjs's state store, so tests run
16
+ // against a temp dir and never touch a real ~/.hilos.
17
+
18
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { homedir } from "node:os";
20
+ import { join } from "node:path";
21
+
22
+ /** Same parent as ~/.hilos/agent.json (config.mjs) and state.json (resume.mjs). */
23
+ export const CURSOR_STATE_DIR = join(homedir(), ".hilos", "mention-cursors");
24
+
25
+ /** One file per agent — two agents on one machine must never share a cursor. */
26
+ export function cursorPath(agentId, dir = CURSOR_STATE_DIR) {
27
+ return join(dir, `${String(agentId).replace(/[^a-zA-Z0-9-]/g, "")}.json`);
28
+ }
29
+
30
+ /**
31
+ * The cursor a restart should resume from, or null when there is nothing
32
+ * usable — no file, unreadable JSON, a malformed timestamp, or a cursor older
33
+ * than `catchupMs` allows (clamped to the window's edge rather than dropped,
34
+ * so a long-dead daemon still catches the most recent day, not nothing).
35
+ *
36
+ * @param {string} agentId
37
+ * @param {{ catchupMs?: number, dir?: string, now?: () => number }} [opts]
38
+ * @returns {string|null} ISO timestamp
39
+ */
40
+ export function loadMentionCursor(agentId, { catchupMs = 0, dir = CURSOR_STATE_DIR, now = Date.now } = {}) {
41
+ if (!agentId || !(catchupMs > 0)) return null;
42
+ let parsed;
43
+ try {
44
+ parsed = JSON.parse(readFileSync(cursorPath(agentId, dir), "utf8"));
45
+ } catch {
46
+ return null;
47
+ }
48
+ const value = parsed && typeof parsed.mentionCursor === "string" ? parsed.mentionCursor : null;
49
+ if (!value) return null;
50
+ const at = new Date(value).getTime();
51
+ if (Number.isNaN(at)) return null;
52
+ const floor = now() - catchupMs;
53
+ return at < floor ? new Date(floor).toISOString() : value;
54
+ }
55
+
56
+ /**
57
+ * Persist the cursor after a delivery. Best-effort by design: a full disk or
58
+ * read-only home must never take the poll loop down — the daemon just degrades
59
+ * to today's restart-at-now behavior.
60
+ *
61
+ * @param {string} agentId
62
+ * @param {string} isoTimestamp
63
+ * @param {{ dir?: string, log?: { error?: (m: string) => void } }} [opts]
64
+ * @returns {boolean} whether the write landed
65
+ */
66
+ export function saveMentionCursor(agentId, isoTimestamp, { dir = CURSOR_STATE_DIR, log } = {}) {
67
+ if (!agentId || typeof isoTimestamp !== "string" || !isoTimestamp) return false;
68
+ try {
69
+ mkdirSync(dir, { recursive: true });
70
+ writeFileSync(cursorPath(agentId, dir), JSON.stringify({ mentionCursor: isoTimestamp }));
71
+ return true;
72
+ } catch (e) {
73
+ log?.error?.(`mention cursor not persisted: ${e?.message || e}`);
74
+ return false;
75
+ }
76
+ }
package/src/daemon.mjs CHANGED
@@ -1,6 +1,13 @@
1
1
  // Pure daemon helpers — no I/O. (Mirror of the app's scripts/lib/daemon.mjs so
2
2
  // the package stands alone; keep them equivalent.)
3
3
 
4
+ import {
5
+ agentCoauthorTrailer,
6
+ agentCommitBody,
7
+ githubArtifactBody,
8
+ selectGithubArtifactTitle,
9
+ } from "./github-artifacts.mjs";
10
+
4
11
  /** Branch name from a task: `hilos/<kebab-first-words>-<suffix>`. */
5
12
  export function branchSlug(text, suffix) {
6
13
  const base =
@@ -121,18 +128,33 @@ export function decisionKind(report) {
121
128
  }
122
129
 
123
130
  /** Commit message for an approved proposal. */
124
- export function commitMessage(task) {
125
- const first = String(task || "").split("\n")[0].slice(0, 72) || "hilos change";
126
- return `${first}\n\nProposed via hilos and approved by a human reviewer.`;
131
+ /** @param {unknown} task @param {Record<string, any>} [provenance] */
132
+ export function commitMessage(task, provenance = {}) {
133
+ const title = selectGithubArtifactTitle({
134
+ summary: provenance.summary,
135
+ originalTask: task,
136
+ fallback: "hilos change",
137
+ });
138
+ const trailer = agentCoauthorTrailer(provenance.agentName, provenance.agentId);
139
+ // The agent's own account of the change becomes the commit body, so the log
140
+ // says what shipped and not only that a person approved it.
141
+ const body = agentCommitBody(provenance.summary);
142
+ return [title, body, "Proposed via hilos and approved by a person.", trailer]
143
+ .filter(Boolean)
144
+ .join("\n\n");
127
145
  }
128
146
 
129
- /** PR title + body for an approved proposal. */
130
- export function prTitleBody(task, branch) {
131
- const title = String(task || "").split("\n")[0].slice(0, 72) || branch;
132
- const body =
133
- "Proposed by a hilos agent from a channel request, approved by a human reviewer.\n\n" +
134
- `Task: ${String(task || "").trim()}`;
135
- return { title, body };
147
+ /** PR title + body for an approved proposal. The body carries the agent's own
148
+ * description of the change (from `provenance.summary`) above the provenance
149
+ * block, so a reviewer on GitHub reads what shipped before who proposed it. */
150
+ /** @param {unknown} task @param {string} branch @param {Record<string, any>} [provenance] */
151
+ export function prTitleBody(task, branch, provenance = {}) {
152
+ const title = selectGithubArtifactTitle({
153
+ summary: provenance.summary,
154
+ originalTask: task,
155
+ fallback: branch,
156
+ });
157
+ return { title, body: githubArtifactBody(provenance) };
136
158
  }
137
159
 
138
160
  /** Build the post_report payload that serves as the approval card. */
@@ -0,0 +1,253 @@
1
+ const TITLE_MAX = 72;
2
+ // A pull-request description is a reviewer's first read, not an essay: enough
3
+ // room for the agent's own account of the change, bounded so a runaway summary
4
+ // can never become the PR body. The commit log gets a shorter form still.
5
+ const DESCRIPTION_MAX = 1400;
6
+ const COMMIT_BODY_MAX = 600;
7
+
8
+ const REJECTED_TITLE_LINES = [
9
+ /^(?:sure|okay|ok|yes|got it|sounds good|absolutely|certainly|done|working on it)\b/i,
10
+ /^(?:i(?:'ll| will| have|’ll)|let me|here(?:'s| is))\b/i,
11
+ /^(?:address|apply|handle|incorporate|respond to|update)\b.{0,36}\b(?:feedback|review|comments?|pr)\b/i,
12
+ /^@\S+/,
13
+ /https?:\/\//i,
14
+ /^```/,
15
+ /^visual_?preview\s*:/i,
16
+ ];
17
+
18
+ function oneLine(value, max = 160) {
19
+ return String(value ?? "")
20
+ .replace(/[\r\n\t]+/g, " ")
21
+ .replace(/\s+/g, " ")
22
+ .trim()
23
+ .slice(0, max);
24
+ }
25
+
26
+ function cleanTitleLine(value) {
27
+ return oneLine(value)
28
+ .replace(/^#{1,6}\s+/, "")
29
+ .replace(/^[-*+]\s+/, "")
30
+ .replace(/^\d+[.)]\s+/, "")
31
+ .replace(/^(?:(?:pr|pull request|commit)\s+)?title\s*[:\-–—]\s*/i, "")
32
+ .replace(/`([^`]+)`/g, "$1")
33
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
34
+ .replace(/[*_~]/g, "")
35
+ .replace(/^@[a-z0-9][a-z0-9-]*\s*[:,–—-]?\s*/i, "")
36
+ .replace(/[.\s]+$/, "")
37
+ .trim();
38
+ }
39
+
40
+ function candidateLines(value) {
41
+ return String(value ?? "")
42
+ .split(/\r?\n/)
43
+ .map(cleanTitleLine)
44
+ .filter(Boolean);
45
+ }
46
+
47
+ function usableTitle(line) {
48
+ if (line.length < 4) return false;
49
+ return !REJECTED_TITLE_LINES.some((pattern) => pattern.test(line));
50
+ }
51
+
52
+ function truncateTitle(title) {
53
+ if (title.length <= TITLE_MAX) return title;
54
+ return `${title.slice(0, TITLE_MAX - 1).trimEnd()}…`;
55
+ }
56
+
57
+ /**
58
+ * The title the agent wrote for its OWN change, or null when its summary never
59
+ * offered a usable one. Callers that must always end up with something use
60
+ * `selectGithubArtifactTitle` (which falls back to the request text); callers
61
+ * that need to know whether the agent actually authored a headline — an
62
+ * iteration commit deciding between the agent's words and a canned subject —
63
+ * use this.
64
+ */
65
+ /** @param {unknown} summary */
66
+ export function agentAuthoredTitle(summary) {
67
+ const line = candidateLines(summary).find(usableTitle);
68
+ return line ? truncateTitle(line) : null;
69
+ }
70
+
71
+ /**
72
+ * One title selector for hosted and local-daemon GitHub output. Agent summaries
73
+ * win only when they actually describe a change; acknowledgements, mentions,
74
+ * Markdown wrappers, links, and review-process chatter are rejected. Iteration
75
+ * callers keep their existing PR title and use this only for the new commit.
76
+ */
77
+ /** @param {{ summary?: unknown, originalTask?: unknown, fallback?: string }} [input] */
78
+ export function selectGithubArtifactTitle({ summary, originalTask, fallback = "Update" } = {}) {
79
+ const summaryTitle = agentAuthoredTitle(summary);
80
+ const taskTitle = candidateLines(originalTask)
81
+ .map((line) => line.replace(/@[a-z0-9][a-z0-9-]*/gi, "").trim())
82
+ .find(usableTitle);
83
+ return truncateTitle(summaryTitle || taskTitle || cleanTitleLine(fallback) || "Update");
84
+ }
85
+
86
+ /**
87
+ * Neutralize the two things in agent prose that would ACT on GitHub instead of
88
+ * describing the change: HTML comment delimiters (which could otherwise forge
89
+ * or break the provenance marker this body ends with) and @handles (which ping
90
+ * whatever unrelated GitHub account happens to own that name). Everything else
91
+ * — including code fences — is the agent's writing and survives intact.
92
+ */
93
+ function defuseMarkup(line) {
94
+ return line
95
+ .replace(/<!--+/g, "")
96
+ .replace(/--+>/g, "")
97
+ .replace(/(^|[\s(\[])@([a-z0-9][a-z0-9-]{0,38})/gi, "$1$2");
98
+ }
99
+
100
+ /** Drop fenced code blocks — a commit log is not the place for a snippet. */
101
+ function stripFences(text) {
102
+ return String(text ?? "").replace(/^\s*```[\s\S]*?^\s*```\s*$/gm, "");
103
+ }
104
+
105
+ /** Trim leading/trailing blank lines and collapse blank runs to one. */
106
+ function tidyLines(lines) {
107
+ const out = [];
108
+ for (const line of lines) {
109
+ const value = line.replace(/\s+$/, "");
110
+ if (!value.trim() && (!out.length || !out[out.length - 1].trim())) continue;
111
+ out.push(value);
112
+ }
113
+ while (out.length && !out[out.length - 1].trim()) out.pop();
114
+ return out;
115
+ }
116
+
117
+ /** Keep whole lines up to `max` characters, marking a cut rather than hiding it. */
118
+ function clampLines(lines, max) {
119
+ const kept = [];
120
+ let used = 0;
121
+ for (const line of lines) {
122
+ const next = used + line.length + 1;
123
+ if (next > max) {
124
+ kept.push("…");
125
+ break;
126
+ }
127
+ kept.push(line);
128
+ used = next;
129
+ }
130
+ return tidyLines(kept).join("\n").trim();
131
+ }
132
+
133
+ /**
134
+ * The agent's own account of the change, ready to be a pull-request
135
+ * description: its summary MINUS the headline it leads with (that becomes the
136
+ * title, and a PR should not open by repeating its own title) and minus the
137
+ * machine directives it ends with (`VISUAL_PREVIEW:`), sanitized and bounded on
138
+ * whole lines.
139
+ *
140
+ * Returns "" when the agent wrote nothing past a title — the caller then ships
141
+ * provenance alone rather than inventing prose it cannot stand behind.
142
+ */
143
+ /** @param {unknown} summary @param {{ max?: number }} [options] */
144
+ export function agentChangeDescription(summary, { max = DESCRIPTION_MAX } = {}) {
145
+ const lines = String(summary ?? "")
146
+ .replace(/\r\n?/g, "\n")
147
+ .split("\n");
148
+ const headline = lines.findIndex((line) => usableTitle(cleanTitleLine(line)));
149
+ const body = (headline === -1 ? lines : lines.slice(headline + 1))
150
+ .filter((line) => !/^\s*visual_?preview\s*:/i.test(line))
151
+ .map(defuseMarkup);
152
+ return clampLines(tidyLines(body), max);
153
+ }
154
+
155
+ /** The same account of the change, shortened and de-fenced for a commit body. */
156
+ /** @param {unknown} summary */
157
+ export function agentCommitBody(summary) {
158
+ return agentChangeDescription(stripFences(summary), { max: COMMIT_BODY_MAX });
159
+ }
160
+
161
+ function safeLabel(value, fallback) {
162
+ const label = oneLine(value, 80)
163
+ .replace(/[<>`[\]{}*_]/g, "")
164
+ .replace(/https?:\/\/\S+/gi, "")
165
+ .trim();
166
+ return label || fallback;
167
+ }
168
+
169
+ function safeId(value) {
170
+ const id = oneLine(value, 100);
171
+ return /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,99}$/.test(id) ? id : null;
172
+ }
173
+
174
+ function safeHilosUrl(value) {
175
+ try {
176
+ const url = new URL(String(value ?? ""));
177
+ if (url.protocol !== "https:" && url.protocol !== "http:") return null;
178
+ url.username = "";
179
+ url.password = "";
180
+ // Room and message links use their query/hash to identify the exact place
181
+ // a request came from. Keep that stable context after removing credentials.
182
+ return url.toString();
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ function linkLine(label, url, fallback) {
189
+ return url ? `- ${label}: [Open in hilos](${url})` : `- ${label}: ${fallback}`;
190
+ }
191
+
192
+ /**
193
+ * A bounded PR body: the agent's own description of the change on top, then the
194
+ * provenance block. It still deliberately never accepts task text — the raw
195
+ * chat message is not a description of what shipped — but it does accept the
196
+ * agent's `summary`, which is the one piece of writing on this path that was
197
+ * actually authored about the diff.
198
+ */
199
+ /**
200
+ * @param {{ agentName?: unknown, personName?: unknown, roomName?: unknown,
201
+ * roomUrl?: unknown, messageId?: unknown, messageUrl?: unknown, runId?: unknown,
202
+ * reportId?: unknown, partial?: boolean, summary?: unknown }} [input]
203
+ */
204
+ export function githubArtifactBody(input = {}) {
205
+ const agent = safeLabel(input.agentName, "hilos agent");
206
+ const person = safeLabel(input.personName, "a team member");
207
+ const room = safeLabel(input.roomName, "project room");
208
+ const roomUrl = safeHilosUrl(input.roomUrl);
209
+ const messageUrl = safeHilosUrl(input.messageUrl);
210
+ const runId = safeId(input.runId);
211
+ const reportId = safeId(input.reportId);
212
+ const partial = input.partial === true;
213
+ const machine = {
214
+ version: 1,
215
+ agent,
216
+ person,
217
+ room,
218
+ messageId: safeId(input.messageId),
219
+ runId,
220
+ reportId,
221
+ };
222
+ const partialNote = partial
223
+ ? "\nThis is a partial run. Mention the agent in the room to continue."
224
+ : "";
225
+
226
+ // The agent's account of the change leads, because that is what a reviewer
227
+ // opens the pull request to read. Provenance follows it under a rule.
228
+ const description = agentChangeDescription(input.summary);
229
+
230
+ return [
231
+ ...(description ? [description, "", "---", ""] : []),
232
+ `Proposed by **${agent}** for **${person}** in **${room}**. A person remains the creator of record and decides whether to merge.`,
233
+ "",
234
+ linkLine("Room", roomUrl, room),
235
+ linkLine("Request", messageUrl, machine.messageId ? `message \`${machine.messageId}\`` : "room request"),
236
+ `- Run: ${runId ? `\`${runId}\`` : "not recorded"}`,
237
+ `- Report: ${reportId ? `\`${reportId}\`` : "posted in the room after this pull request opens"}`,
238
+ partialNote,
239
+ "",
240
+ `<!-- hilos-provenance ${JSON.stringify(machine).replace(/--/g, "—")} -->`,
241
+ ]
242
+ .filter((line, index, all) => line !== "" || index === 1 || all[index - 1] !== "")
243
+ .join("\n")
244
+ .trim();
245
+ }
246
+
247
+ /** A stable Git co-author trailer for the named agent, without user input. */
248
+ /** @param {unknown} agentName @param {unknown} agentId */
249
+ export function agentCoauthorTrailer(agentName, agentId) {
250
+ const name = safeLabel(agentName, "hilos agent");
251
+ const id = safeId(agentId)?.replace(/[^a-zA-Z0-9]/g, "").slice(0, 48) || "agent";
252
+ return `Co-authored-by: ${name} <agent+${id}@hilos.sh>`;
253
+ }
package/src/handler.mjs CHANGED
@@ -41,6 +41,7 @@ import { makeStreamParser, createUsageFold } from "./agent-events.mjs";
41
41
  import {
42
42
  detectVendor,
43
43
  codeStreamArgs,
44
+ codeWebArgs,
44
45
  codeDirArgs,
45
46
  codeImageArgs,
46
47
  codeProjectKey,
@@ -306,6 +307,11 @@ function openCodePermissionCallbacks({
306
307
  threadRoot,
307
308
  runId = null,
308
309
  provider = "opencode",
310
+ // 0866 — when the server's get_permission_decision supports a long-poll
311
+ // hold, each read blocks up to this many ms and answers the moment a human
312
+ // decides, instead of the gate discovering the decision on its next 1s poll.
313
+ // 0 (older servers) keeps the plain immediate read.
314
+ decisionWaitMs = 0,
309
315
  }) {
310
316
  return {
311
317
  requestPermission: async (request) => {
@@ -352,6 +358,8 @@ function openCodePermissionCallbacks({
352
358
  vendorSessionId: request.sessionId,
353
359
  vendorRequestId: request.vendorRequestId,
354
360
  ...(failClosed ? { failClosed: true } : {}),
361
+ // Never on a failClosed settlement — that call exists to act NOW.
362
+ ...(decisionWaitMs > 0 && !failClosed ? { waitMs: decisionWaitMs } : {}),
355
363
  });
356
364
  },
357
365
  };
@@ -768,7 +776,37 @@ async function linkPrFromUrl({ tool, channelId, url }) {
768
776
  await tool("link_pr", { channelId, repoFullName: m[1], prNumber: Number(m[2]) }).catch(() => {});
769
777
  }
770
778
 
771
- async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, existingPrUrl, settleId, runId, usageArgs = {} }) {
779
+ function githubProvenance({ cfg, me, message, channelId, runId, summary }) {
780
+ let siteUrl = "https://hilos.sh";
781
+ try {
782
+ const parsed = new URL(cfg.url);
783
+ parsed.pathname = parsed.pathname.replace(/\/api\/mcp\/?$/, "");
784
+ parsed.search = "";
785
+ parsed.hash = "";
786
+ siteUrl = parsed.toString().replace(/\/$/, "");
787
+ } catch {
788
+ /* the body helper will keep links absent if configuration is malformed */
789
+ }
790
+ const roomUrl = `${siteUrl}/w/${me?.workspaceId}/c/${channelId}`;
791
+ const messageUrl = message?.id
792
+ ? message.parentId
793
+ ? `${roomUrl}?thread=${message.parentId}&reply=${message.id}`
794
+ : `${roomUrl}#m-${message.id}`
795
+ : undefined;
796
+ return {
797
+ agentName: me?.agentName,
798
+ agentId: me?.agentId,
799
+ personName: message?.author,
800
+ roomName: message?.channel || "project room",
801
+ roomUrl,
802
+ messageId: message?.id,
803
+ messageUrl,
804
+ runId,
805
+ summary,
806
+ };
807
+ }
808
+
809
+ async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, existingPrUrl, settleId, runId, provenance = {}, usageArgs = {} }) {
772
810
  const tag = requesterTag(requester);
773
811
  const lead = tag ? `${tag} — ` : "";
774
812
  // `parentId` here is the run's thread root. Terminal outcomes ask the server
@@ -785,7 +823,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
785
823
  });
786
824
  return { status: "nothing-staged", branch };
787
825
  }
788
- const commit = deps.git(repoPath, ["commit", "-m", commitMessage(task)]);
826
+ const commit = deps.git(repoPath, ["commit", "-m", commitMessage(task, provenance)]);
789
827
  if (commit.status !== 0) {
790
828
  await tool("post_message", {
791
829
  channelId,
@@ -805,7 +843,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
805
843
  });
806
844
  return { status: "push-failed", branch };
807
845
  }
808
- const { title, body } = prTitleBody(task, branch);
846
+ const { title, body } = prTitleBody(task, branch, provenance);
809
847
  // Continuing an existing PR (request-changes rework): the push already
810
848
  // updated it — reuse its URL instead of opening a duplicate. Otherwise open
811
849
  // a fresh PR.
@@ -897,7 +935,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
897
935
  * switched to `main` would otherwise make `gh` try to open main → main. Used
898
936
  * only when NOT gated (bias-to-action).
899
937
  */
900
- async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, settleId, runId, usageArgs = {} }) {
938
+ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, settleId, runId, provenance = {}, usageArgs = {} }) {
901
939
  const tag = requesterTag(requester);
902
940
  const lead = tag ? `${tag} — ` : "";
903
941
  const currentBranch =
@@ -907,7 +945,7 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
907
945
  let prUrl = deps.findPR ? deps.findPR(repoPath, ship.headBranch) : null;
908
946
  let prAttempt = null;
909
947
  if (!prUrl && push.status === 0) {
910
- const { title, body } = prTitleBody(task, ship.headBranch);
948
+ const { title, body } = prTitleBody(task, ship.headBranch, provenance);
911
949
  prAttempt = deps.openPR(repoPath, {
912
950
  title,
913
951
  body,
@@ -916,7 +954,7 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
916
954
  });
917
955
  prUrl = prAttempt.ok && prAttempt.url ? prAttempt.url : null;
918
956
  }
919
- const { title } = prTitleBody(task, ship.headBranch);
957
+ const { title } = prTitleBody(task, ship.headBranch, provenance);
920
958
  const recoveredNote = ship.recoveredFrom
921
959
  ? ` The child had switched to \`${ship.recoveredFrom}\`; hilos recovered its HEAD onto \`${ship.headBranch}\`.`
922
960
  : "";
@@ -991,6 +1029,7 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
991
1029
  // Sentinel the router model emits when the latest message is a request to change
992
1030
  // code. Unusual on purpose so it can't be confused with a real chat reply.
993
1031
  const CODE_SIGNAL = "__CODE__";
1032
+ const CHAT_SIGNAL = "__CHAT__";
994
1033
 
995
1034
  // 0860 — the quiet option, offered ONLY on untagged DM wakes (message.implicit
996
1035
  // === "dm"). In a DM every message reaches the agent without a tag, so some of
@@ -1046,8 +1085,8 @@ export function dmJudgmentBlock(implicitDm) {
1046
1085
 
1047
1086
  /**
1048
1087
  * Decide — with the LLM, not a word list — whether the latest message wants a
1049
- * code change or a conversational reply, and produce the payload in the SAME
1050
- * call. The model reads the whole conversation, so it judges by intent and
1088
+ * code change or a conversational reply. The model reads the whole
1089
+ * conversation, so it judges by intent and
1051
1090
  * context, in any language: "just code it", "dale, hazlo", "yeah go for it" after
1052
1091
  * a request → code; "how does this work?", "thoughts?", "thanks" → chat.
1053
1092
  *
@@ -1059,12 +1098,12 @@ export function dmJudgmentBlock(implicitDm) {
1059
1098
  * Returns one of:
1060
1099
  * { aborted: true }
1061
1100
  * { code: true, task } → run the coding flow with `task` as the spec
1062
- * { code: false, reply, error } post `reply` as a chat message
1101
+ * { code: false, error } run the separate conversational responder
1063
1102
  *
1064
1103
  * `error` is set when the model produced nothing (so the caller can be honest
1065
1104
  * about a timeout vs a missing binary instead of inventing a reply).
1066
1105
  */
1067
- async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal, hasActiveRun = false, runCliFn, implicitDm = false }) {
1106
+ export async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal, hasActiveRun = false, runCliFn, implicitDm = false }) {
1068
1107
  const doRun = runCliFn || runCli; // folder mode injects deps.runCli; repo flow uses the import
1069
1108
  const cmd = chatCmdFor(cfg);
1070
1109
  const parts = cmd.split(" ").filter(Boolean);
@@ -1093,12 +1132,12 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
1093
1132
  `using the conversation. Example:\n${CODE_SIGNAL}\nAdd a hover popover to message reactions ` +
1094
1133
  `that lists who reacted with each emoji.\n\n` +
1095
1134
  `Otherwise — a question, a greeting, general discussion, or they explicitly don't want code ` +
1096
- `yet — just reply to them concisely and directly as a single chat message (no preamble, no ` +
1097
- `headings). When unsure, stay conversational; implementation and PR flow are for clear ` +
1098
- `action language.` +
1135
+ `yet — output ONLY the token ${CHAT_SIGNAL}. When unsure, choose ${CHAT_SIGNAL}; ` +
1136
+ `implementation and PR flow are for clear action language.` +
1099
1137
  `${followupBlock}\n\n` +
1100
- `You are only routing here — output ONLY your text response. Do NOT use any tools, do NOT ` +
1101
- `edit files, do NOT run commands; a separate step does the actual coding.` +
1138
+ `You are only routing here — output ONLY the routing token (plus the imperative spec for ` +
1139
+ `${CODE_SIGNAL}). Do NOT use any tools, edit files, or run commands; separate tool-capable ` +
1140
+ `steps write the conversational answer or do the coding.` +
1102
1141
  `${dmJudgmentBlock(implicitDm)}\n\n` +
1103
1142
  `${memoryPreamble(workspaceMemory)}Conversation so far:\n${transcript}`;
1104
1143
  const run = await doRun({
@@ -1134,7 +1173,10 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
1134
1173
  const quiet = parseNoReply(out);
1135
1174
  if (quiet.noReply) return { code: false, noReply: true, emoji: quiet.emoji, followupSignal };
1136
1175
  }
1137
- return { code: false, reply: out, followupSignal };
1176
+ // A classifier never authors the final answer. Even if a model ignores the
1177
+ // token contract and emits prose, discard it and let the normal responder —
1178
+ // with the vendor's real tools — answer the room.
1179
+ return { code: false, reply: null, followupSignal };
1138
1180
  }
1139
1181
 
1140
1182
  /**
@@ -2048,8 +2090,10 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
2048
2090
  // 0779: [] for every vendor without a verified image flag — their argv is
2049
2091
  // byte-identical to before, and the prompt note still names the files.
2050
2092
  const imageArgs = codeImageArgs(vendor, localImages);
2093
+ const webArgs = codeWebArgs(vendor, parts.slice(1));
2051
2094
  const codeArgs = [
2052
2095
  ...parts.slice(1),
2096
+ ...webArgs,
2053
2097
  ...modelArgs,
2054
2098
  ...dirArgs,
2055
2099
  ...imageArgs,
@@ -2141,6 +2185,7 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
2141
2185
  tool,
2142
2186
  channelId,
2143
2187
  threadRoot,
2188
+ decisionWaitMs: caps.decisionWaitMs,
2144
2189
  provider: vendor,
2145
2190
  }),
2146
2191
  });
@@ -2158,6 +2203,7 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
2158
2203
  tool,
2159
2204
  channelId,
2160
2205
  threadRoot,
2206
+ decisionWaitMs: caps.decisionWaitMs,
2161
2207
  }),
2162
2208
  });
2163
2209
  } else if (gateCodexPermissions) {
@@ -2177,6 +2223,7 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
2177
2223
  tool,
2178
2224
  channelId,
2179
2225
  threadRoot,
2226
+ decisionWaitMs: caps.decisionWaitMs,
2180
2227
  provider: vendor,
2181
2228
  }),
2182
2229
  });
@@ -2196,6 +2243,7 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
2196
2243
  tool,
2197
2244
  channelId,
2198
2245
  threadRoot,
2246
+ decisionWaitMs: caps.decisionWaitMs,
2199
2247
  provider: vendor,
2200
2248
  }),
2201
2249
  });
@@ -2715,12 +2763,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2715
2763
  onStopRequested,
2716
2764
  });
2717
2765
  }
2718
- // Not a coding task → post the router's reply if it produced one, else fall
2719
- // through to a normal conversational reply.
2720
- if (routed.reply) {
2721
- await tool("post_message", { channelId, parentId, body: routed.reply });
2722
- return { status: "chat" };
2723
- }
2766
+ // Not a coding task → the classifier is finished. The separate responder
2767
+ // below owns the answer and keeps its normal tools.
2724
2768
  }
2725
2769
  await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps });
2726
2770
  return { status: "chat" };
@@ -2780,14 +2824,19 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2780
2824
  await reactQuietly({ tool, caps, message, emoji: routed.emoji });
2781
2825
  return { status: "quiet" };
2782
2826
  }
2783
- let body = routed.reply;
2784
- if (!body) {
2785
- body =
2786
- routed.error?.code === "ENOENT"
2787
- ? `(my chat command \`${chatCmdFor(cfg)}\` isn't installed or on PATH.)`
2788
- : `Still thinking on this — it's taking longer than usual. I'll follow up shortly.`;
2789
- }
2790
- await tool("post_message", { channelId, parentId, body });
2827
+ await respondConversationally({
2828
+ message,
2829
+ channelId,
2830
+ tool,
2831
+ me,
2832
+ cfg,
2833
+ repoLink,
2834
+ parentId,
2835
+ workspaceMemory,
2836
+ signal,
2837
+ context,
2838
+ caps,
2839
+ });
2791
2840
  return { status: "chat" };
2792
2841
  }
2793
2842
  // routed.code → fall through to the coding flow below.
@@ -3300,6 +3349,11 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3300
3349
  let drainProgress = async () => {};
3301
3350
  // 0782 — the silent-work stop poll, torn down in the same finally as the run.
3302
3351
  let stopPoller = null;
3352
+ // Structured transports expose the final assistant summary as an event;
3353
+ // argv transports fold it into the progress emitter. Keep one local value
3354
+ // so GitHub title/provenance selection never reaches for an out-of-scope
3355
+ // parser variable (0884).
3356
+ let resultText = "";
3303
3357
  if (streamOn) {
3304
3358
  if (!progressId) {
3305
3359
  try {
@@ -3377,9 +3431,10 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3377
3431
  // `exec`, so it has to sit ahead of it. [] for every vendor without a
3378
3432
  // verified flag, leaving their argv byte-identical to before.
3379
3433
  const imageArgs = codeImageArgs(vendor, localImages);
3434
+ const webArgs = codeWebArgs(vendor, parts.slice(1));
3380
3435
  const codeArgs = streamOn
3381
- ? [...parts.slice(1), ...modelArgs, ...dirArgs, ...imageArgs, ...resumeArgs, ...streamArgs]
3382
- : [...parts.slice(1), ...modelArgs, ...dirArgs, ...imageArgs, ...resumeArgs];
3436
+ ? [...parts.slice(1), ...webArgs, ...modelArgs, ...dirArgs, ...imageArgs, ...resumeArgs, ...streamArgs]
3437
+ : [...parts.slice(1), ...webArgs, ...modelArgs, ...dirArgs, ...imageArgs, ...resumeArgs];
3383
3438
  const handleCliData = (c) => {
3384
3439
  // Keep tracking lastLine as a fallback (legacy heartbeat / honesty).
3385
3440
  const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
@@ -3420,6 +3475,9 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3420
3475
  } catch {
3421
3476
  /* a progress fold must never break the run */
3422
3477
  }
3478
+ if (ev?.t === "result" && typeof ev.summary === "string") {
3479
+ resultText = ev.summary;
3480
+ }
3423
3481
  };
3424
3482
  let run;
3425
3483
  try {
@@ -3481,6 +3539,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3481
3539
  tool,
3482
3540
  channelId,
3483
3541
  threadRoot,
3542
+ decisionWaitMs: caps.decisionWaitMs,
3484
3543
  runId,
3485
3544
  provider: vendor,
3486
3545
  }),
@@ -3502,6 +3561,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3502
3561
  tool,
3503
3562
  channelId,
3504
3563
  threadRoot,
3564
+ decisionWaitMs: caps.decisionWaitMs,
3505
3565
  runId,
3506
3566
  }),
3507
3567
  });
@@ -3525,6 +3585,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3525
3585
  tool,
3526
3586
  channelId,
3527
3587
  threadRoot,
3588
+ decisionWaitMs: caps.decisionWaitMs,
3528
3589
  runId,
3529
3590
  provider: vendor,
3530
3591
  }),
@@ -3548,6 +3609,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3548
3609
  tool,
3549
3610
  channelId,
3550
3611
  threadRoot,
3612
+ decisionWaitMs: caps.decisionWaitMs,
3551
3613
  runId,
3552
3614
  provider: vendor,
3553
3615
  }),
@@ -3630,6 +3692,13 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3630
3692
  }
3631
3693
  }
3632
3694
  }
3695
+ if (!resultText && emitter) {
3696
+ try {
3697
+ resultText = emitter.snapshot()?.lastLine || "";
3698
+ } catch {
3699
+ /* title fallback still has the routed task */
3700
+ }
3701
+ }
3633
3702
  if (run.aborted || signal?.aborted) {
3634
3703
  console.log(" code → cancelled");
3635
3704
  return { aborted: true };
@@ -3661,15 +3730,33 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3661
3730
  Number((git(repoPath, ["rev-list", "--count", `${baseRef}..HEAD`]).stdout || "0").trim()) || 0;
3662
3731
  if (ahead > 0) {
3663
3732
  console.log(` code → agent self-committed (${ahead} commit(s) ahead); reconciling`);
3664
- return { empty: true, failed: false, ahead };
3733
+ return {
3734
+ empty: true,
3735
+ failed: false,
3736
+ ahead,
3737
+ summary: (resultText || (streamArgs.length ? "" : run.stdout) || "").trim(),
3738
+ };
3665
3739
  }
3666
3740
  console.log(" code → no changes produced");
3667
- return { empty: true, failed: false, ahead: 0 };
3741
+ return {
3742
+ empty: true,
3743
+ failed: false,
3744
+ ahead: 0,
3745
+ summary: (resultText || (streamArgs.length ? "" : run.stdout) || "").trim(),
3746
+ };
3668
3747
  }
3669
3748
  console.log(" code → diff captured");
3670
3749
  const stat = parseShortstat(git(repoPath, ["diff", "--cached", "--shortstat"]).stdout);
3671
3750
  const { text: diffText, truncated, omittedLines } = truncateDiff(diff);
3672
- return { empty: false, diffText, truncated, omittedLines, stat, runFailed: run.status !== 0 };
3751
+ return {
3752
+ empty: false,
3753
+ diffText,
3754
+ truncated,
3755
+ omittedLines,
3756
+ stat,
3757
+ runFailed: run.status !== 0,
3758
+ summary: (resultText || (streamArgs.length ? "" : run.stdout) || "").trim(),
3759
+ };
3673
3760
  };
3674
3761
 
3675
3762
  // Persist the run's coding-agent session (0282) so a LATER iterate can resume it.
@@ -3886,6 +3973,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3886
3973
  parentId: threadRoot,
3887
3974
  settleId: selfSettleId,
3888
3975
  runId,
3976
+ provenance: githubProvenance({ cfg, me, message, channelId, runId, summary: staged.summary }),
3889
3977
  usageArgs: reportUsageArgs(runUsage, { vendor, modelId: resolvedModelId, runId }),
3890
3978
  });
3891
3979
  await recordSession(selfResult.prUrl);
@@ -3962,6 +4050,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3962
4050
  existingPrUrl: continuingPrUrl,
3963
4051
  settleId,
3964
4052
  runId,
4053
+ provenance: githubProvenance({ cfg, me, message, channelId, runId, summary: staged.summary }),
3965
4054
  usageArgs: reportUsageArgs(runUsage, { vendor, modelId: resolvedModelId, runId }),
3966
4055
  });
3967
4056
  // The settle already flipped the card to the report (body + metadata); editing
@@ -4024,6 +4113,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
4024
4113
  parentId: threadRoot,
4025
4114
  existingPrUrl: continuingPrUrl,
4026
4115
  runId,
4116
+ provenance: githubProvenance({ cfg, me, message, channelId, runId, summary: staged.summary }),
4027
4117
  // Normally {} — a gated run already reported its spend on the proposal card
4028
4118
  // it is now shipping. Anything the CLI spent after that still comes home.
4029
4119
  usageArgs: reportUsageArgs(runUsage, { vendor, modelId: resolvedModelId, runId }),
package/src/mcp.mjs CHANGED
@@ -48,13 +48,20 @@ export function makeClient({ url, token }) {
48
48
  }
49
49
  }
50
50
 
51
- async function listToolNames() {
51
+ // Full tool objects, schemas included — capability sniffing reads these
52
+ // (0866: an ARG-level addition like waitMs shows up in a tool's inputSchema,
53
+ // not in the tool list's names).
54
+ async function listTools() {
52
55
  try {
53
- return ((await rpc("tools/list"))?.tools ?? []).map((t) => t.name);
56
+ return (await rpc("tools/list"))?.tools ?? [];
54
57
  } catch {
55
58
  return [];
56
59
  }
57
60
  }
58
61
 
59
- return { rpc, tool, listToolNames };
62
+ async function listToolNames() {
63
+ return (await listTools()).map((t) => t.name);
64
+ }
65
+
66
+ return { rpc, tool, listTools, listToolNames };
60
67
  }
@@ -57,7 +57,8 @@ export function detectVendor(codingCmd) {
57
57
  * Code installed just to answer chat (0521). Every command is the vendor's
58
58
  * verified non-interactive print mode; the daemon appends the prompt as the last
59
59
  * arg. codex carries --skip-git-repo-check because chat (and the read-only
60
- * review sandbox) can run outside a git checkout. cursor carries
60
+ * review sandbox) can run outside a git checkout, plus the explicit web-search
61
+ * config because `codex exec` otherwise leaves that tool off. cursor carries
61
62
  * --output-format text (explicit, so a CLI default change can never post raw
62
63
  * JSONL into the channel) and --trust (its Jan-2026 workspace-trust gate fails
63
64
  * headless runs at spawn in untrusted directories — 0572; pre-2026 CLIs reject
@@ -70,7 +71,9 @@ export function detectVendor(codingCmd) {
70
71
  */
71
72
  export function fastChatCmd(vendor) {
72
73
  if (vendor === "claude_code") return "claude -p --model haiku";
73
- if (vendor === "codex") return "codex exec --skip-git-repo-check";
74
+ if (vendor === "codex") {
75
+ return "codex exec --skip-git-repo-check -c tools.web_search=true";
76
+ }
74
77
  if (vendor === "cursor") return "cursor-agent -p --output-format text --trust";
75
78
  if (vendor === "opencode") return "opencode run";
76
79
  if (vendor === "antigravity") return "agy -p";
@@ -107,6 +110,31 @@ export function codeStreamArgs(vendor) {
107
110
  return [];
108
111
  }
109
112
 
113
+ /**
114
+ * Make Codex's built-in public web search available to code runs. This is a
115
+ * capability flag, not an instruction to browse; Codex decides whether the
116
+ * task needs it. An operator's explicit true/false override wins unchanged.
117
+ * Other vendors already expose their own web tools and receive no guessed
118
+ * flags.
119
+ * @param {'claude_code'|'codex'|'cursor'|'opencode'|'antigravity'|'hermes'|'unknown'} vendor
120
+ * @param {string[]} baseArgs
121
+ * @returns {string[]}
122
+ */
123
+ export function codeWebArgs(vendor, baseArgs = []) {
124
+ if (vendor !== "codex") return [];
125
+ const hasOverride = baseArgs.some((arg, index) => {
126
+ if (/^tools\.web_search=/.test(arg)) return true;
127
+ if (
128
+ (baseArgs[index - 1] === "-c" || baseArgs[index - 1] === "--config") &&
129
+ /^tools\.web_search=/.test(arg)
130
+ ) {
131
+ return true;
132
+ }
133
+ return /^--config=tools\.web_search=/.test(arg);
134
+ });
135
+ return hasOverride ? [] : ["-c", "tools.web_search=true"];
136
+ }
137
+
110
138
  /**
111
139
  * Extra args that hand the code run an IMAGE, appended to the code run's argv
112
140
  * (0779). Verified against the installed binaries, per the 0521 rule — never
package/src/run.mjs CHANGED
@@ -5,6 +5,7 @@
5
5
  import { hostname } from "node:os";
6
6
  import { basename } from "node:path";
7
7
  import { makeClient } from "./mcp.mjs";
8
+ import { loadMentionCursor, saveMentionCursor } from "./cursor-store.mjs";
8
9
  import { mentionHandle } from "./daemon.mjs";
9
10
  import { handleTask } from "./handler.mjs";
10
11
  import { createQueue, looksLikeCancel, dedupeKey } from "./queue.mjs";
@@ -59,7 +60,7 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
59
60
  return;
60
61
  }
61
62
 
62
- const { tool, listToolNames } = makeClient({ url: cfg.url, token: cfg.token });
63
+ const { tool, listTools, listToolNames } = makeClient({ url: cfg.url, token: cfg.token });
63
64
 
64
65
  const who = await tool("whoami");
65
66
  const me = { ...who, handle: mentionHandle(who.agentName) };
@@ -72,8 +73,23 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
72
73
  log.log(`repos: ${Object.keys(cfg.repos).join(", ") || "(none configured)"}`);
73
74
 
74
75
  const since = cfg.backfill ? 0 : Date.now();
75
- const toolNames = await listToolNames();
76
+ // listTools (schemas included) is the primary read; a client exposing only
77
+ // listToolNames (an embedder's minimal mock, or an older embedded client)
78
+ // still names the tools — it just never advertises arg-level capabilities.
79
+ const toolList = typeof listTools === "function" ? await listTools() : [];
80
+ const toolNames = toolList.length
81
+ ? toolList.map((t) => t.name)
82
+ : typeof listToolNames === "function"
83
+ ? await listToolNames()
84
+ : [];
76
85
  const useMentions = toolNames.includes("list_mentions");
86
+ // Arg-level capability sniffing (0866): a long-poll-capable server declares
87
+ // `waitMs` in the tool's inputSchema. Absent (older server) → the daemon
88
+ // falls back to plain interval polling, exactly as before.
89
+ const toolArgProps = (name) =>
90
+ toolList.find((t) => t.name === name)?.inputSchema?.properties ?? {};
91
+ const serverMentionWait = "waitMs" in toolArgProps("list_mentions");
92
+ const serverDecisionWait = "waitMs" in toolArgProps("get_permission_decision");
77
93
  // Capabilities of THIS server, so the handler degrades gracefully on older
78
94
  // deploys (e.g. no edit_message → no live heartbeat, rather than erroring).
79
95
  const caps = {
@@ -112,6 +128,10 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
112
128
  // that needs no words. Absent on older servers → the agent stays quiet
113
129
  // instead, exactly as if it had no hand to wave.
114
130
  react: toolNames.includes("add_reaction"),
131
+ // Long-poll hold for the permission gate (0866): each decision read blocks
132
+ // server-side until a human decides, so an Allow reaches the paused tool in
133
+ // under a second instead of on the gate's next poll. 0 on older servers.
134
+ decisionWaitMs: serverDecisionWait ? 20000 : 0,
115
135
  };
116
136
 
117
137
  // The wake doorbell (0824): capability-checked like every optional surface.
@@ -190,7 +210,16 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
190
210
  emit({ type: "status", text: useMentions ? "watching @-mentions" : `watching ${channelIds.length} channel(s)` });
191
211
 
192
212
  const seen = new Set();
193
- const cursor = { value: since ? new Date(since).toISOString() : null };
213
+ // Resume from the persisted cursor (0866) so mentions that arrived while the
214
+ // daemon was down are delivered on restart instead of silently skipped —
215
+ // bounded by catchupMs. `backfill` still means "from the beginning" and wins.
216
+ const persistedCursor = cfg.backfill
217
+ ? null
218
+ : loadMentionCursor(me.agentId, { catchupMs: cfg.catchupMs });
219
+ if (persistedCursor) log.log(`resuming mention cursor from ${persistedCursor}`);
220
+ const cursor = {
221
+ value: persistedCursor ?? (since ? new Date(since).toISOString() : null),
222
+ };
194
223
  // 0860 — a DM message inside its settle window: the server withheld it and
195
224
  // said when it matures. One shortened sleep picks it up right then, instead
196
225
  // of the person waiting out the full poll interval mid-conversation.
@@ -343,10 +372,18 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
343
372
  }
344
373
  }
345
374
 
375
+ // Long-poll (0866): ask a capable server to HOLD the request and answer the
376
+ // moment a mention lands — pickup latency stops being pollMs. The server
377
+ // caps a hold at 25s; asking for more just gets 25s.
378
+ const mentionWaitMs = () =>
379
+ serverMentionWait && liveCfg.longPollMs > 0 ? Math.floor(liveCfg.longPollMs) : 0;
380
+
346
381
  async function passViaMentions() {
382
+ const waitMs = mentionWaitMs();
347
383
  const mentionArgs = {
348
384
  ...(cursor.value ? { since: cursor.value } : {}),
349
385
  ...(cfg.channelId ? { channelId: cfg.channelId } : {}),
386
+ ...(waitMs ? { waitMs } : {}),
350
387
  };
351
388
  const out = await tool("list_mentions", mentionArgs);
352
389
  // Floor the server's settle hint so a clock skew can never hot-loop the poll.
@@ -355,6 +392,7 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
355
392
  ? Math.max(500, out.retryAfterMs)
356
393
  : null;
357
394
  const mentions = (out?.mentions ?? []).slice().reverse();
395
+ let advanced = false;
358
396
  for (const m of mentions) {
359
397
  if (seen.has(m.id)) continue;
360
398
  seen.add(m.id);
@@ -365,6 +403,7 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
365
403
  if (m.parentId && boundThreadRoots.has(m.parentId)) {
366
404
  if (!cursor.value || new Date(m.created_at).getTime() > new Date(cursor.value).getTime()) {
367
405
  cursor.value = m.created_at;
406
+ advanced = true;
368
407
  }
369
408
  continue;
370
409
  }
@@ -373,8 +412,17 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
373
412
  // so a lexicographic string compare can fail to advance the cursor.
374
413
  if (!cursor.value || new Date(m.created_at).getTime() > new Date(cursor.value).getTime()) {
375
414
  cursor.value = m.created_at;
415
+ advanced = true;
376
416
  }
377
417
  }
418
+ // Persist on advance only — an idle pass has nothing new to remember —
419
+ // and only while catch-up is enabled: with catchupMs 0 the file would
420
+ // never be read back, so writing it would be pure disk noise (this is
421
+ // also what keeps embedded/mocked runs from touching a real ~/.hilos).
422
+ // Best-effort: a failed write degrades to restart-at-now, never a crash.
423
+ if (advanced && cursor.value && liveCfg.catchupMs > 0) {
424
+ saveMentionCursor(me.agentId, cursor.value, { log });
425
+ }
378
426
  }
379
427
 
380
428
  async function passViaReplyBridge() {
@@ -432,11 +480,13 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
432
480
  } catch (e) {
433
481
  log.error(`config reload error (keeping previous): ${e.message}`);
434
482
  }
483
+ let passFailed = false;
435
484
  try {
436
485
  await passViaReplyBridge();
437
486
  if (useMentions) await passViaMentions();
438
487
  else await passViaScan();
439
488
  } catch (e) {
489
+ passFailed = true;
440
490
  log.error(`poll error: ${e.message}`);
441
491
  }
442
492
  // --once is for cron-style single passes: drain the queued work before exit.
@@ -446,11 +496,15 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
446
496
  }
447
497
  if (signal?.aborted) break;
448
498
  // Interruptible so an abort during the sleep returns promptly instead of
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
- );
499
+ // waiting out the full poll interval, and the doorbell (0824) still cuts
500
+ // any sleep short. A long-polled pass (0866) already spent its waiting
501
+ // inside the held request, so only a short breather follows it — unless
502
+ // the pass FAILED, where re-holding immediately would hammer a hiccuping
503
+ // server; an error always backs off by the full pollMs. A settling DM
504
+ // (0860) shortens one sleep to the server's hint in either mode.
505
+ const held = useMentions && mentionWaitMs() > 0 && !passFailed;
506
+ const base = held ? 250 : liveCfg.pollMs;
507
+ await interruptibleSleep(pollAgainMs != null ? Math.min(base, pollAgainMs) : base);
454
508
  } while (!signal?.aborted);
455
509
 
456
510
  // Clean stop: close the doorbell socket, make sure any active job is cancelled