hilos-agent 0.9.4 → 0.10.0

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
@@ -76,6 +82,45 @@ hilos-agent # watch every channel the agent is in
76
82
  hilos-agent --channel <id> # scope to one channel
77
83
  ```
78
84
 
85
+ ## WebMCP site tools
86
+
87
+ The local daemon can give its coding and chat agents a narrow bridge to
88
+ third-party WebMCP sites. It is off until you name exact origins and exact read
89
+ tools in the machine's config:
90
+
91
+ ```jsonc
92
+ {
93
+ "webMcp": {
94
+ "origins": {
95
+ "https://docs.example.com": {
96
+ "readTools": ["search_docs", "read_reference"]
97
+ }
98
+ }
99
+ }
100
+ }
101
+ ```
102
+
103
+ The site does not authorize itself: `readOnlyHint` is informational, while this
104
+ person-owned list decides what may run. Site descriptions are omitted, schema
105
+ prose is stripped, results are bounded and labeled untrusted, and cookies stay
106
+ inside a separate browser profile. The bundled browser bridge currently needs
107
+ Node.js 24 or newer; the rest of the daemon keeps its existing Node.js support.
108
+
109
+ ```sh
110
+ hilos-agent webmcp doctor
111
+ hilos-agent webmcp login https://docs.example.com # person signs in in the opened browser
112
+ hilos-agent webmcp open https://docs.example.com/reference
113
+ hilos-agent webmcp tools
114
+ hilos-agent webmcp call search_docs '{"query":"WebMCP"}'
115
+ hilos-agent webmcp close
116
+ ```
117
+
118
+ The daemon adds this capability and its citation rules to agent prompts only
119
+ when the config is valid. Unlisted tools — including writes — are refused with
120
+ `human_approval_required`; there is no approval flag the agent can set. WebMCP
121
+ can never approve or merge hilos work. See the complete contract in
122
+ [WebMCP in hilos](https://hilos.sh/docs/webmcp).
123
+
79
124
  ## How it works
80
125
 
81
126
  - **Trigger** — an `@mention` of your agent in a channel that's linked to a repo,
@@ -7,6 +7,7 @@
7
7
  // hilos-agent --join <blob> connect with a copy-paste link from hilos
8
8
  // hilos-agent --join-stdin read a private link without exposing it in argv
9
9
  // hilos-agent init write a starter config (~/.hilos/agent.json)
10
+ // hilos-agent webmcp doctor verify the local site-tool bridge
10
11
  // hilos-agent run with the resolved config (default)
11
12
  // hilos-agent run same as above, explicit
12
13
  //
@@ -24,6 +25,7 @@ import { resolveConfig, decodeJoin, writeStarterConfig, GLOBAL_CONFIG } from "..
24
25
  import { readPrivateJoin } from "../src/join-input.mjs";
25
26
  import { run } from "../src/run.mjs";
26
27
  import { hookMain, hooksMain } from "../src/hook.mjs";
28
+ import { runWebMcpCommand } from "../src/webmcp-bridge.mjs";
27
29
 
28
30
  function packageVersion() {
29
31
  const pkgPath = fileURLToPath(new URL("../package.json", import.meta.url));
@@ -67,6 +69,12 @@ const HELP = `hilos-agent — your coding agent as a teammate in hilos
67
69
  hilos-agent --join <blob> connect using a link copied from hilos
68
70
  hilos-agent --join-stdin paste the private link at a no-echo prompt
69
71
  hilos-agent init write a starter config to ~/.hilos/agent.json
72
+ hilos-agent webmcp doctor verify the local WebMCP browser bridge
73
+ hilos-agent webmcp login <url> open the isolated profile for person sign-in
74
+ hilos-agent webmcp open <url> open a person-allowlisted site for an agent
75
+ hilos-agent webmcp tools list registered, person-allowlisted read tools
76
+ hilos-agent webmcp call <name> '<json object>'
77
+ hilos-agent webmcp close close the isolated browser session
70
78
  hilos-agent run the daemon (watch @mentions, propose diffs)
71
79
  hilos-agent hooks install stream this repo's Codex, Claude, and Cursor
72
80
  sessions to hilos and continue replies in the same
@@ -150,6 +158,17 @@ async function main() {
150
158
  return;
151
159
  }
152
160
 
161
+ if (cmd === "webmcp") {
162
+ const cliFlags = { ...flags };
163
+ delete cliFlags.help;
164
+ const cfg = resolveConfig({ flags: cliFlags });
165
+ const operation = positional[1] || "doctor";
166
+ const result = await runWebMcpCommand(cfg, operation, positional.slice(2));
167
+ console.log(JSON.stringify(result, null, 2));
168
+ if (!result.ok) process.exitCode = 1;
169
+ return;
170
+ }
171
+
153
172
  // run (default) — when --join is passed without init, connect straight away.
154
173
  const cliFlags = { ...flags };
155
174
  delete cliFlags.join;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.9.4",
3
+ "version": "0.10.0",
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": {
@@ -32,5 +32,8 @@
32
32
  "cursor",
33
33
  "coding-agent"
34
34
  ],
35
- "license": "MIT"
35
+ "license": "MIT",
36
+ "dependencies": {
37
+ "agent-browser": "^0.35.0"
38
+ }
36
39
  }
@@ -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
@@ -53,6 +53,12 @@ const DEFAULTS = {
53
53
  // and preview (default) vs production. Shape:
54
54
  // { "<channelId>": { provider: "vercel" | "netlify", prod: false } }.
55
55
  deploy: {},
56
+ // Local WebMCP consumer (0959). Disabled until the person names exact site
57
+ // origins and exact read-only tools. A site's readOnlyHint is never an
58
+ // authorization signal. Shape:
59
+ // { origins: { "https://example.com": { readTools: ["search_docs"] } },
60
+ // profile?: "/absolute/private/browser/profile" }
61
+ webMcp: null,
56
62
  // acceptEdits lets the CLI make file edits without prompting (bias to action);
57
63
  // it still won't run arbitrary commands. Override in hilos-agent.json if you
58
64
  // want a stricter (or `--dangerously-skip-permissions`) command.
@@ -121,6 +127,17 @@ const DEFAULTS = {
121
127
  gate: false,
122
128
  maxRounds: 3,
123
129
  pollMs: 5000,
130
+ // Long-poll (0866): when the server supports it, list_mentions is asked to
131
+ // HOLD this many ms and answer the moment a mention lands — pickup latency
132
+ // stops being pollMs. 0 disables and falls back to plain pollMs polling.
133
+ // The server caps a hold at 25s regardless of what is asked for here.
134
+ longPollMs: 20000,
135
+ // How far back a restart may replay missed mentions from the persisted
136
+ // cursor (0866). A daemon that was off for a week should not open a week of
137
+ // stale branches; a day covers the overnight-laptop case the cursor exists
138
+ // for. 0 disables catch-up entirely (restart starts at "now", the old
139
+ // behavior).
140
+ catchupMs: 24 * 60 * 60 * 1000,
124
141
  // On a long code run, post ONE thread progress reply at the first beat then
125
142
  // edit it on later beats, so a human sees the agent is alive without thread
126
143
  // spam. 0 disables. Clamped to >=15s so a misconfig can't spam realtime
@@ -224,6 +241,8 @@ const LIVE_FIELDS = [
224
241
  "gate",
225
242
  "maxRounds",
226
243
  "pollMs",
244
+ "longPollMs",
245
+ "catchupMs",
227
246
  "runTimeoutMs",
228
247
  "decisionTimeoutMs",
229
248
  "decisionPollMs",
@@ -238,6 +257,7 @@ const LIVE_FIELDS = [
238
257
  // like `repos`, so a partial edit doesn't drop the defaults.
239
258
  "folders",
240
259
  "deploy",
260
+ "webMcp",
241
261
  ];
242
262
 
243
263
  /**
@@ -280,6 +300,10 @@ export function reloadConfig(prev) {
280
300
  // the normal on state. An explicit launch flag remains pinned when the file
281
301
  // was never changed, preserving the 0576 precedence rule above.
282
302
  if (changed("replyBridge")) next.replyBridge = file.replyBridge !== false;
303
+ // WebMCP is an authorization allowlist. Removing it must revoke the browser
304
+ // bridge on the next poll rather than preserving stale origins/tools until a
305
+ // restart. An explicit null and a deleted edited key both mean disabled.
306
+ if (changed("webMcp")) next.webMcp = file.webMcp ?? null;
283
307
  // The environment stays the operator's override on reload, in both
284
308
  // directions — a machine that opted out with =0 must not be opted back in by
285
309
  // a file edit (0792).
@@ -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
+ }