hilos-agent 0.11.12 → 0.11.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.11.12",
3
+ "version": "0.11.13",
4
4
  "description": "Run your own coding agent (Claude Code, Codex, Cursor, OpenCode, Hermes, or any command) as a teammate in a hilos room. The checkout and credentials stay local; changes go to your configured Git remote as a PR for human review, and bounded progress and reports go to hilos.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,6 +19,7 @@
19
19
  import { spawn } from "node:child_process";
20
20
  import { createAcpEventMapper } from "./agent-events.mjs";
21
21
  import { resolveHilosPermissionReply } from "./permission-gate.mjs";
22
+ import { abortError, isObject, raceWithAbort } from "./util.mjs";
22
23
 
23
24
  const DEFAULT_TIMEOUT_MS = 30 * 60_000;
24
25
  const DEFAULT_POLL_MS = 1_000;
@@ -26,35 +27,6 @@ const SHUTDOWN_GRACE_MS = 750;
26
27
  const PROTOCOL_VERSION = 1;
27
28
  const MAX_LINE_BYTES = 4 * 1024 * 1024;
28
29
 
29
- function isObject(value) {
30
- return typeof value === "object" && value !== null && !Array.isArray(value);
31
- }
32
-
33
- function abortError(reason = "cancelled") {
34
- const error = new Error(String(reason || "cancelled"));
35
- error.name = "AbortError";
36
- return error;
37
- }
38
-
39
- function raceWithAbort(promise, signal) {
40
- if (!signal) return promise;
41
- if (signal.aborted) return Promise.reject(abortError(signal.reason));
42
- return new Promise((resolve, reject) => {
43
- const onAbort = () => reject(abortError(signal.reason));
44
- signal.addEventListener("abort", onAbort, { once: true });
45
- Promise.resolve(promise).then(
46
- (value) => {
47
- signal.removeEventListener("abort", onAbort);
48
- resolve(value);
49
- },
50
- (error) => {
51
- signal.removeEventListener("abort", onAbort);
52
- reject(error);
53
- },
54
- );
55
- });
56
- }
57
-
58
30
  /**
59
31
  * Choose the agent's option id for a hilos reply ("once"|"always"|"reject").
60
32
  * Matches on the ACP option `kind` first (allow_once / allow_always /
@@ -16,16 +16,16 @@
16
16
  // stripped, length capped) — a chatty CLI can echo a `Bearer …` header.
17
17
  //
18
18
  // Claude shapes here MATCH `lib/hosted-worklog.ts` / `lib/hosted-agent.ts`
19
- // (`formatClaudeEvent`, `parseStreamResult`) so ticket 0274 can swap the daemon's
20
- // hand-rolled `lastLine` capture (see handler.mjs onData ~:787) for this parser
21
- // with zero behavior change. NOTHING imports this yet except the test — wiring is
22
- // 0274.
19
+ // (`formatClaudeEvent`, `parseStreamResult`) so the daemon and the hosted path
20
+ // render the same run from the same shapes (0274).
21
+
22
+ import { COMMAND_LABEL_MAX, truncate } from "./util.mjs";
23
23
 
24
24
  /**
25
25
  * @typedef {object} AgentEvent
26
26
  * A normalized, render-ready step. Exactly one of these per meaningful thing the
27
27
  * coding agent did. Small on purpose — the LiveRunCard maps `t` to a label.
28
- * @property {'phase'|'edit'|'run'|'read'|'think'|'note'|'session'|'result'|'usage'|'websearch'|'webfetch'|'subagent'} t
28
+ * @property {'edit'|'run'|'read'|'think'|'note'|'session'|'result'|'usage'|'websearch'|'webfetch'|'subagent'} t
29
29
  * - 'session' → { sessionId } the CLI's resumable session id (init only)
30
30
  * - 'edit' → { path } wrote/edited a file
31
31
  * - 'read' → { path } read a file
@@ -44,9 +44,6 @@
44
44
  * reported it (0787). Never a step — the
45
45
  * ring and the activity fold skip it; it
46
46
  * rides home on the report instead.
47
- * - 'phase' → { name } reserved lifecycle marker (unused by v1
48
- * parsers; kept so 0274 can add start/end
49
- * phases without widening the type)
50
47
  * @property {string} [sessionId]
51
48
  * @property {string} [path]
52
49
  * @property {string} [cmd]
@@ -95,7 +92,7 @@ export function sanitizeText(value) {
95
92
  if (typeof value !== "string") return "";
96
93
  let out = value;
97
94
  for (const re of SECRET_PATTERNS) out = out.replace(re, REDACTED);
98
- if (out.length > MAX_LEN) out = out.slice(0, MAX_LEN - 1) + "…";
95
+ out = truncate(out, MAX_LEN);
99
96
  return out;
100
97
  }
101
98
 
@@ -269,7 +266,7 @@ function webPhrase(value) {
269
266
  // would render.
270
267
  const clean = sanitizeText(raw);
271
268
  if (!clean) return "";
272
- return clean.length > WEB_LABEL_MAX ? clean.slice(0, WEB_LABEL_MAX - 1) + "…" : clean;
269
+ return truncate(clean, WEB_LABEL_MAX);
273
270
  }
274
271
 
275
272
  /**
@@ -879,7 +876,7 @@ function normalizeVendor(vendor) {
879
876
  // --- Streaming parsers -----------------------------------------------------
880
877
 
881
878
  /**
882
- * The daemon pushes raw stdout chunks (see handler.mjs onData ~:787) that can
879
+ * The daemon pushes raw stdout chunks (handler.mjs `handleCliData`) that can
883
880
  * split a JSON object mid-line. A structured parser buffers the partial tail and
884
881
  * only parses COMPLETE lines; flush() drains a trailing unterminated line.
885
882
  * @param {(line: string) => AgentEvent[]} parseLine
@@ -913,7 +910,7 @@ function makeLineBufferedParser(parseLine) {
913
910
  * Fallback for any vendor with no structured stream (cursor graduated to a
914
911
  * real parser in 0573). There's nothing to parse, so we only remember the last
915
912
  * non-empty line seen — matching exactly what the daemon does today
916
- * (handler.mjs onData) so we NEVER regress below it — and emit at most one
913
+ * (handler.mjs `handleCliData`) so we NEVER regress below it — and emit at most one
917
914
  * sparse note on flush.
918
915
  */
919
916
  function makeTextTailParser() {
@@ -964,13 +961,13 @@ function describeRun(cmd) {
964
961
  const c = String(cmd || "").trim();
965
962
  if (!c) return "Running a command";
966
963
  if (/\b(test|tests)\b|vitest|jest|pytest|\bgo test\b/i.test(c)) return "Running tests";
967
- const short = c.length > 60 ? c.slice(0, 59) + "…" : c;
964
+ const short = truncate(c, COMMAND_LABEL_MAX);
968
965
  return `Running ${short}`;
969
966
  }
970
967
 
971
968
  /**
972
969
  * One AgentEvent → a human step label, or null for events that aren't shown as
973
- * steps (session/phase are metadata). Pure.
970
+ * steps (session events are metadata). Pure.
974
971
  * @param {AgentEvent} event
975
972
  * @returns {string | null}
976
973
  */
@@ -993,7 +990,7 @@ export function stepLabel(event) {
993
990
  case "result":
994
991
  return event.ok ? "Done" : "Finished with errors";
995
992
  default:
996
- return null; // session / phase / unknown → not a step
993
+ return null; // session / unknown → not a step
997
994
  }
998
995
  }
999
996
 
@@ -1081,7 +1078,7 @@ function activitySentence(row) {
1081
1078
  * truth a CLI stream carries, so "done" is the sequential-execution inference:
1082
1079
  * a new action starting settles the one in flight. `settle("failed")` is for a
1083
1080
  * dying run — leaving "Editing…" forever is the dishonesty this feed exists to
1084
- * avoid. session/phase events are metadata, not rows (same as stepLabel).
1081
+ * avoid. session events are metadata, not rows (same as stepLabel).
1085
1082
  * @param {number} [limit]
1086
1083
  */
1087
1084
  export function createActivityFold(limit = MAX_ACTIVITY) {
@@ -1151,7 +1148,7 @@ export function createActivityFold(limit = MAX_ACTIVITY) {
1151
1148
  });
1152
1149
  return;
1153
1150
  default:
1154
- return; // session / phase / unknown — metadata, not activity
1151
+ return; // session / unknown — metadata, not activity
1155
1152
  }
1156
1153
  }
1157
1154
 
@@ -0,0 +1,54 @@
1
+ // 1298 — one writing contract and final-result envelope for hosted and local runs.
2
+ export const AGENT_REPLY_STYLE_RULE = [
3
+ "How to write the reply:",
4
+ "- Lead with the answer or outcome. Never restate the request.",
5
+ "- Default to 1-3 short sentences, or at most three short bullets for separate outcomes.",
6
+ "- Include useful links once. No preamble, headings, sign-off, process narration, or routine test/file lists.",
7
+ "- Keep blockers, material limitations, and a needed next step visible. Never claim checks, deployment, or merging without evidence.",
8
+ "- Give requested documents, plans, code, research, and explanations the detail they need; keep their introduction short. Put code and commands in fenced blocks.",
9
+ "The room's voice may shape tone, but only a person's request for detail expands this default. Clarity beats a word limit.",
10
+ ].join("\n");
11
+
12
+ export const AGENT_CODE_RESULT_RULE = [
13
+ AGENT_REPLY_STYLE_RULE,
14
+ "When the coding turn finishes, return one fenced hilos-result JSON object with three string fields:",
15
+ '{"summary":"The short result for the teammate.","prTitle":"A short title describing the actual problem fixed","prDescription":"The problem, resulting behavior, and relevant verification."}',
16
+ "Write summary for someone who may never open the code. Do not repeat the PR title, request, branch, file counts, or tool history. Include material limitations.",
17
+ "Write prTitle and prDescription as the pull request itself for a reviewer opening it cold. Title: under 60 characters, no trailing period or request echo. Description: usually 1-2 short paragraphs; only actual checks. No transcript, preamble, boilerplate, or attribution footer; hilos adds credit.",
18
+ "If you create or update the PR yourself, use those same title and description fields on GitHub. Describe the entire final PR change, including prior work on an iteration. Preserve text a person edited.",
19
+ "Return empty PR fields when there is no code change. A VISUAL_PREVIEW: /route line may follow the fence.",
20
+ ].join("\n");
21
+
22
+ /** Keep complete artifacts, never half a sentence, Markdown link, or JSON object. */
23
+ export function retainAgentResult(value) {
24
+ return typeof value === "string" && value.length <= 32_000 ? value.trim() : "";
25
+ }
26
+
27
+ /** @returns {{ summary: string, prTitle: string, prDescription: string } | null} */
28
+ export function readAgentResult(value) {
29
+ const text = retainAgentResult(value);
30
+ const fenced = /```hilos-result\s*\n([\s\S]*?)\n```/.exec(text);
31
+ const candidate = fenced?.[1] ?? (text.startsWith("{") ? text : null);
32
+ if (!candidate) return null;
33
+ try {
34
+ const result = JSON.parse(candidate);
35
+ if (!result || typeof result !== "object" || typeof result.summary !== "string" ||
36
+ typeof result.prTitle !== "string" || typeof result.prDescription !== "string") return null;
37
+ return {
38
+ summary: result.summary.trim(),
39
+ prTitle: result.prTitle.trim(),
40
+ prDescription: result.prDescription.trim(),
41
+ };
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /** Legacy prose remains readable; an invalid machine envelope never enters chat. */
48
+ export function agentResultText(value) {
49
+ const text = retainAgentResult(value);
50
+ const result = readAgentResult(text);
51
+ if (result) return result.summary;
52
+ if (text.includes("```hilos-result") || /^\s*\{\s*"(?:summary|prTitle|prDescription)"/.test(text)) return "";
53
+ return text.replace(/^\s*VISUAL_PREVIEW:.*$/gm, "").trim();
54
+ }
package/src/child-mcp.mjs CHANGED
@@ -147,7 +147,7 @@ export function childMcpPlanned({ cfg, vendor, bindingClaim, env } = {}) {
147
147
  }
148
148
 
149
149
  /** The `mcpServers` fragment for a claude `--mcp-config` JSON. */
150
- export function childMcpServers(url) {
150
+ function childMcpServers(url) {
151
151
  return { [CHILD_MCP_SERVER]: { type: "http", url } };
152
152
  }
153
153
 
@@ -159,7 +159,7 @@ export function childMcpServers(url) {
159
159
  * config file instead — claude takes `--mcp-config` more than once, but one
160
160
  * file the run owns end-to-end is one file to delete.
161
161
  */
162
- export function writeChildMcpConfig(url, { dir = os.tmpdir() } = {}) {
162
+ function writeChildMcpConfig(url, { dir = os.tmpdir() } = {}) {
163
163
  const configPath = path.join(
164
164
  dir,
165
165
  `hilos-child-mcp-${process.pid}-${randomBytes(6).toString("hex")}.json`,
@@ -38,6 +38,7 @@ import {
38
38
  DEFAULT_PERMISSION_TIMEOUT_MS,
39
39
  resolveHilosPermissionReply,
40
40
  } from "./permission-gate.mjs";
41
+ import { isObject } from "./util.mjs";
41
42
 
42
43
  /** The MCP server key and tool name the daemon serves. Together they form the
43
44
  * `mcp__<server>__<tool>` identifier claude's flag takes. */
@@ -54,10 +55,6 @@ const DEFAULT_SESSION_ID_WAIT_MS = 10_000;
54
55
  /** Tool names whose input names a file rather than a command. */
55
56
  const FILE_INPUT_KEYS = ["file_path", "path", "notebook_path"];
56
57
 
57
- function isObject(value) {
58
- return typeof value === "object" && value !== null && !Array.isArray(value);
59
- }
60
-
61
58
  /**
62
59
  * Shape claude's permission-prompt payload into the SAME vendor-neutral request
63
60
  * the opencode/ACP card flow already produces, so the card, its audit row, and
@@ -184,9 +181,6 @@ export function claudePermissionArgs({
184
181
  ];
185
182
  }
186
183
 
187
- /** The flags to strip when a CLI turns out not to know them (compat retry). */
188
- export const CLAUDE_PERMISSION_FLAGS = ["--mcp-config", "--permission-prompt-tool"];
189
-
190
184
  /**
191
185
  * Serve the permission tool on loopback for the life of one run.
192
186
  *
@@ -209,8 +203,7 @@ export const CLAUDE_PERMISSION_FLAGS = ["--mcp-config", "--permission-prompt-too
209
203
  * allowedTools?: string,
210
204
  * }} options
211
205
  * @returns {Promise<{ configPath: string, toolId: string, url: string, port: number,
212
- * args: string[], setSessionId: (value: string) => void, pending: () => number,
213
- * close: () => Promise<void> }>}
206
+ * args: string[], close: () => Promise<void> }>}
214
207
  */
215
208
  export async function startClaudePermissionServer({
216
209
  requestPermission,
@@ -235,7 +228,6 @@ export async function startClaudePermissionServer({
235
228
  }
236
229
  const token = randomBytes(24).toString("hex");
237
230
  let seq = 0;
238
- let inflight = 0;
239
231
  let currentSessionId = String(sessionId || "");
240
232
  // hilos's server REJECTS a permission request with an empty vendorSessionId,
241
233
  // so a fresh run (no session to resume) must not ask with one — every card
@@ -373,33 +365,25 @@ export async function startClaudePermissionServer({
373
365
  sessionId: await sessionIdForAsk(),
374
366
  fallbackId: String(++seq),
375
367
  });
376
- inflight++;
377
- let outcome = "transport-error";
378
- // Set only when a workspace RULE refused this ask (0813); a person's
379
- // rejection leaves these alone and keeps the human wording. Provenance is
380
- // tracked apart from the reason because a rule may carry no reason.
381
- let byPolicy = false;
382
- let policyReason = null;
383
- let decisionReply = "reject";
384
- try {
385
- const resolved = await resolveHilosPermissionReply({
386
- request,
387
- requestPermission,
388
- getPermissionDecision,
389
- signal,
390
- timeoutMs,
391
- pollIntervalMs,
392
- log,
393
- label: "claude permission",
394
- localAllow,
395
- });
396
- decisionReply = resolved.reply;
397
- outcome = resolved.outcome;
398
- byPolicy = resolved.byPolicy === true;
399
- policyReason = resolved.policyReason ?? null;
400
- } finally {
401
- inflight--;
402
- }
368
+ const resolved = await resolveHilosPermissionReply({
369
+ request,
370
+ requestPermission,
371
+ getPermissionDecision,
372
+ signal,
373
+ timeoutMs,
374
+ pollIntervalMs,
375
+ log,
376
+ label: "claude permission",
377
+ localAllow,
378
+ });
379
+ const decisionReply = resolved.reply;
380
+ const outcome = resolved.outcome;
381
+ // byPolicy is set only when a workspace RULE refused this ask (0813); a
382
+ // person's rejection leaves it false and keeps the human wording.
383
+ // Provenance is tracked apart from the reason because a rule may carry no
384
+ // reason.
385
+ const byPolicy = resolved.byPolicy === true;
386
+ const policyReason = resolved.policyReason ?? null;
403
387
  // A rejection is never an MCP error: an isError result makes claude retry
404
388
  // or improvise around the block. A plain deny result is the refusal the
405
389
  // CLI is built to respect.
@@ -452,11 +436,6 @@ export async function startClaudePermissionServer({
452
436
  url,
453
437
  port,
454
438
  args: claudePermissionArgs({ configPath, allowedTools }),
455
- /** Let the caller stamp the CLI's real session id onto later cards. */
456
- setSessionId(value) {
457
- if (typeof value === "string" && value) currentSessionId = value;
458
- },
459
- pending: () => inflight,
460
439
  async close() {
461
440
  try {
462
441
  fs.unlinkSync(configPath);
package/src/cli.mjs CHANGED
@@ -6,6 +6,7 @@
6
6
  // logs a periodic "still working…" heartbeat so the run visibly stays alive.
7
7
 
8
8
  import { spawn } from "node:child_process";
9
+ import { truncate } from "./util.mjs";
9
10
 
10
11
  /**
11
12
  * Let the daemon finish its existing abort/cleanup path before exiting.
@@ -150,8 +151,7 @@ export function fmtElapsed(ms) {
150
151
 
151
152
  /** Collapse to one trimmed, length-capped line (for a heartbeat's "Latest:" tail). */
152
153
  export function oneLine(s, max = 140) {
153
- const t = String(s || "").replace(/\s+/g, " ").trim();
154
- return t.length > max ? t.slice(0, max - 1) + "…" : t;
154
+ return truncate(String(s || "").replace(/\s+/g, " ").trim(), max);
155
155
  }
156
156
 
157
157
  /**
@@ -58,6 +58,7 @@ import {
58
58
  DEFAULT_PERMISSION_TIMEOUT_MS,
59
59
  resolveHilosPermissionReply,
60
60
  } from "./permission-gate.mjs";
61
+ import { isObject } from "./util.mjs";
61
62
 
62
63
  // Codex 0.153.4 accepts on-request/never on its MCP tool. `untrusted`
63
64
  // is rejected before a turn starts. Keep the workspace sandbox and route its
@@ -65,16 +66,6 @@ import {
65
66
  const DEFAULT_TIMEOUT_MS = 30 * 60_000;
66
67
  const SHUTDOWN_GRACE_MS = 750;
67
68
 
68
- function isObject(value) {
69
- return typeof value === "object" && value !== null && !Array.isArray(value);
70
- }
71
-
72
- function abortError(reason = "cancelled") {
73
- const error = new Error(String(reason || "cancelled"));
74
- error.name = "AbortError";
75
- return error;
76
- }
77
-
78
69
  /**
79
70
  * Render codex's argv-array command as the one line a human decides about.
80
71
  * Codex wraps shell work as ["/bin/zsh","-lc","<the real command>"]; showing
package/src/config.mjs CHANGED
@@ -40,7 +40,7 @@ function readJson(path) {
40
40
  }
41
41
 
42
42
  /** First config file that exists: explicit path → ./hilos-agent.json → ~/.hilos/agent.json. */
43
- export function findConfigPath(explicit) {
43
+ function findConfigPath(explicit) {
44
44
  if (explicit) return explicit;
45
45
  if (existsSync(LOCAL_CONFIG)) return LOCAL_CONFIG;
46
46
  if (existsSync(GLOBAL_CONFIG)) return GLOBAL_CONFIG;
@@ -180,10 +180,9 @@ const DEFAULTS = {
180
180
  // Cap a chat reply / plan-ack so a stalled model can't dead-air the channel;
181
181
  // on timeout we post an honest "taking longer than expected" line.
182
182
  chatTimeoutMs: 90000,
183
- // Work queue: run mentions one at a time (concurrency 1 — parallel CLI runs on
184
- // one checkout would collide on git state). queueAcks posts "queued behind the
185
- // current task" when a mention lands during a run; set false to keep it quiet.
186
- queueConcurrency: 1,
183
+ // Work queue: mentions run one at a time — parallel CLI runs on one checkout
184
+ // would collide on git state. queueAcks posts "queued behind the current task"
185
+ // when a mention lands during a run; set false to keep it quiet.
187
186
  queueAcks: true,
188
187
  runTimeoutMs: 600000,
189
188
  decisionTimeoutMs: 1800000,
@@ -303,8 +302,7 @@ const LIVE_FIELDS = [
303
302
  "heartbeatMs",
304
303
  "progressMs",
305
304
  "chatTimeoutMs",
306
- // NOT queueConcurrency: the queue is built once at startup, so it can't change
307
- // live. queueAcks IS live (intake() reads it per-mention from liveCfg).
305
+ // Live: intake() reads it per-mention from liveCfg.
308
306
  "queueAcks",
309
307
  // folders is an object map; it's listed here for documentation, but the actual
310
308
  // live merge (with DEFAULTS) happens in the special-cased block below, exactly
package/src/daemon.mjs CHANGED
@@ -1,5 +1,5 @@
1
- // Pure daemon helpers — no I/O. (Mirror of the app's scripts/lib/daemon.mjs so
2
- // the package stands alone; keep them equivalent.)
1
+ // Pure daemon helpers — no I/O.
2
+ import { agentResultText } from "./agent-result.mjs";
3
3
 
4
4
  import {
5
5
  agentCoauthorTrailer,
@@ -133,7 +133,7 @@ export function commitMessage(task, provenance = {}) {
133
133
  const title = selectGithubArtifactTitle({
134
134
  summary: provenance.summary,
135
135
  originalTask: task,
136
- fallback: "hilos change",
136
+ fallback: "Update project files",
137
137
  });
138
138
  const trailer = agentCoauthorTrailer(provenance.agentName, provenance.agentId);
139
139
  // The agent's own account of the change becomes the commit body, so the log
@@ -152,28 +152,23 @@ export function prTitleBody(task, branch, provenance = {}) {
152
152
  const title = selectGithubArtifactTitle({
153
153
  summary: provenance.summary,
154
154
  originalTask: task,
155
- fallback: branch,
155
+ fallback: "Update project files",
156
156
  });
157
- return { title, body: githubArtifactBody(provenance) };
157
+ return { title, body: githubArtifactBody({ ...provenance, title }) };
158
158
  }
159
159
 
160
160
  /** Build the post_report payload that serves as the approval card. */
161
161
  export function buildProposalReport(o) {
162
- const firstLine = String(o.task || "").split("\n")[0].slice(0, 72) || "task";
163
- const statLine = `${o.stat.files} file(s), +${o.stat.insertions}/-${o.stat.deletions} on \`${o.branch}\` in ${o.repoFullName}`;
164
162
  const diffBlock =
165
163
  "```diff\n" +
166
164
  o.diffText +
167
165
  (o.truncated ? `\n… (+${o.omittedLines} more lines)` : "") +
168
166
  "\n```";
169
- // Tag whoever asked so the proposal lands in their notifications, not just
170
- // the channel. Handle matches the server's @-mention format (kebab of name).
171
- const handle = mentionHandle(o.requester);
172
- const lead = handle ? `@${handle} — proposed changes for: ${firstLine}` : `Proposed changes for: ${firstLine}`;
173
- const summary = `${lead}\n\n${statLine}\n\n${diffBlock}`;
167
+ // Before a PR exists, the diff is the reviewable deliverable.
168
+ const summary = `${agentResultText(o.summary) || "Changes are ready for review."}\n\n${diffBlock}`;
174
169
  const caveats = ["Not pushed yet — approve to push + open a PR, or reject to discard."];
175
170
  if (o.runFailed) caveats.push("The coding agent exited non-zero; review the diff carefully.");
176
- return { title: `Proposal: ${firstLine}`, summary, caveats, todos: [] };
171
+ return { title: "Ready for review", summary, caveats, todos: [] };
177
172
  }
178
173
 
179
174
  /** kebab handle from a display name (matches the server's mention handle). */
package/src/deploy.mjs CHANGED
@@ -16,7 +16,7 @@ function isProvider(value) {
16
16
  return DEPLOY_PROVIDERS.includes(value);
17
17
  }
18
18
 
19
- export function findDeployCli(provider, env = process.env) {
19
+ function findDeployCli(provider, env = process.env) {
20
20
  if (!isProvider(provider)) return null;
21
21
  for (const entry of String(env.PATH || "").split(delimiter)) {
22
22
  if (!entry) continue;
@@ -1,14 +1,17 @@
1
+ import { readAgentResult, agentResultText } from "./agent-result.mjs";
1
2
  const TITLE_MAX = 72;
2
3
  // A pull-request description is a reviewer's first read, not an essay: enough
3
4
  // room for the agent's own account of the change, bounded so a runaway summary
4
5
  // can never become the PR body. The commit log gets a shorter form still.
5
- const DESCRIPTION_MAX = 1400;
6
+ const DESCRIPTION_MAX = 6000;
6
7
  const COMMIT_BODY_MAX = 600;
7
8
 
8
9
  const REJECTED_TITLE_LINES = [
9
10
  /^(?:sure|okay|ok|yes|got it|sounds good|absolutely|certainly|done|working on it)\b/i,
10
11
  /^(?:i(?:'ll| will| have|’ll)|let me|here(?:'s| is))\b/i,
11
12
  /^(?:address|apply|handle|incorporate|respond to|update)\b.{0,36}\b(?:feedback|review|comments?|pr)\b/i,
13
+ /^(?:summary|changes|what changed|verification|testing|result|done|update|implementation)[:\s]*$/i,
14
+ /^(?:can|could|would|please|hey|hi)\b/i,
12
15
  /^@\S+/,
13
16
  /https?:\/\//i,
14
17
  /^```/,
@@ -57,13 +60,16 @@ function truncateTitle(title) {
57
60
  /**
58
61
  * The title the agent wrote for its OWN change, or null when its summary never
59
62
  * offered a usable one. Callers that must always end up with something use
60
- * `selectGithubArtifactTitle` (which falls back to the request text); callers
63
+ * `selectGithubArtifactTitle` (which uses a neutral fallback); callers
61
64
  * that need to know whether the agent actually authored a headline — an
62
65
  * iteration commit deciding between the agent's words and a canned subject —
63
66
  * use this.
64
67
  */
65
68
  /** @param {unknown} summary */
66
69
  export function agentAuthoredTitle(summary) {
70
+ const result = readAgentResult(summary);
71
+ if (result) return usableTitle(cleanTitleLine(result.prTitle)) ? truncateTitle(cleanTitleLine(result.prTitle)) : null;
72
+ if (!agentResultText(summary)) return null;
67
73
  const line = candidateLines(summary).find(usableTitle);
68
74
  return line ? truncateTitle(line) : null;
69
75
  }
@@ -75,12 +81,11 @@ export function agentAuthoredTitle(summary) {
75
81
  * callers keep their existing PR title and use this only for the new commit.
76
82
  */
77
83
  /** @param {{ summary?: unknown, originalTask?: unknown, fallback?: string }} [input] */
78
- export function selectGithubArtifactTitle({ summary, originalTask, fallback = "Update" } = {}) {
84
+ export function selectGithubArtifactTitle({ summary, originalTask, fallback = "Update project files" } = {}) {
79
85
  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");
86
+ const taskText = candidateLines(originalTask).join(" ").toLowerCase();
87
+ const echoed = summaryTitle && cleanTitleLine(summaryTitle).toLowerCase() === taskText;
88
+ return truncateTitle((!echoed && summaryTitle) || cleanTitleLine(fallback) || "Update project files");
84
89
  }
85
90
 
86
91
  /**
@@ -142,11 +147,14 @@ function clampLines(lines, max) {
142
147
  */
143
148
  /** @param {unknown} summary @param {{ max?: number }} [options] */
144
149
  export function agentChangeDescription(summary, { max = DESCRIPTION_MAX } = {}) {
145
- const lines = String(summary ?? "")
146
- .replace(/\r\n?/g, "\n")
147
- .split("\n");
150
+ const result = readAgentResult(summary);
151
+ if (result) return clampLines(tidyLines((result.prDescription || result.summary).split("\n").map(defuseMarkup)), max);
152
+ const text = agentResultText(summary);
153
+ const lines = text.replace(/\r\n?/g, "\n").split("\n");
148
154
  const headline = lines.findIndex((line) => usableTitle(cleanTitleLine(line)));
149
- const body = (headline === -1 ? lines : lines.slice(headline + 1))
155
+ // A single sentence is the complete description, not a headline to discard.
156
+ const hasBody = headline >= 0 && lines.slice(headline + 1).some((line) => line.trim() && !/^\s*VISUAL_PREVIEW:/i.test(line));
157
+ const body = (hasBody ? lines.slice(headline + 1) : lines)
150
158
  .filter((line) => !/^\s*visual_?preview\s*:/i.test(line))
151
159
  .map(defuseMarkup);
152
160
  return clampLines(tidyLines(body), max);
@@ -155,6 +163,8 @@ export function agentChangeDescription(summary, { max = DESCRIPTION_MAX } = {})
155
163
  /** The same account of the change, shortened and de-fenced for a commit body. */
156
164
  /** @param {unknown} summary */
157
165
  export function agentCommitBody(summary) {
166
+ const result = readAgentResult(summary);
167
+ if (result) return clampLines(tidyLines(stripFences(result.prDescription || result.summary).split("\n").map(defuseMarkup)), COMMIT_BODY_MAX);
158
168
  return agentChangeDescription(stripFences(summary), { max: COMMIT_BODY_MAX });
159
169
  }
160
170
 
@@ -185,10 +195,6 @@ function safeHilosUrl(value) {
185
195
  }
186
196
  }
187
197
 
188
- function linkLine(label, url, fallback) {
189
- return url ? `- ${label}: [Open in hilos](${url})` : `- ${label}: ${fallback}`;
190
- }
191
-
192
198
  /**
193
199
  * A bounded PR body: the agent's own description of the change on top, then the
194
200
  * provenance block. It still deliberately never accepts task text — the raw
@@ -199,7 +205,7 @@ function linkLine(label, url, fallback) {
199
205
  /**
200
206
  * @param {{ agentName?: unknown, personName?: unknown, roomName?: unknown,
201
207
  * roomUrl?: unknown, messageId?: unknown, messageUrl?: unknown, runId?: unknown,
202
- * reportId?: unknown, partial?: boolean, summary?: unknown }} [input]
208
+ * reportId?: unknown, partial?: boolean, summary?: unknown, title?: string }} [input]
203
209
  */
204
210
  export function githubArtifactBody(input = {}) {
205
211
  const agent = safeLabel(input.agentName, "hilos agent");
@@ -223,25 +229,14 @@ export function githubArtifactBody(input = {}) {
223
229
  ? "\nThis is a partial run. Mention the agent in the room to continue."
224
230
  : "";
225
231
 
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();
232
+ const description = agentChangeDescription(input.summary) || "Changes are available in the diff. Verification was not reported.";
233
+ const context = messageUrl || roomUrl;
234
+ const credit = `Requested by **${person}** · Agent: **${agent}**${context ? ` · [Context](${context})` : ""}`;
235
+ const visible = [description, partialNote.trim(), credit].filter(Boolean).join("\n\n");
236
+ const title = input.title || selectGithubArtifactTitle({ summary: input.summary });
237
+ const marker = { ...machine, generatedTitle: `${partial ? "[partial] " : ""}${title}`, generatedBody: visible };
238
+ return `${visible}\n\n<!-- hilos-provenance ${JSON.stringify(marker).replace(/--/g, "\\u002d\\u002d")} -->`;
239
+
245
240
  }
246
241
 
247
242
  /** A stable Git co-author trailer for the named agent, without user input. */
@@ -251,3 +246,22 @@ export function agentCoauthorTrailer(agentName, agentId) {
251
246
  const id = safeId(agentId)?.replace(/[^a-zA-Z0-9]/g, "").slice(0, 48) || "agent";
252
247
  return `Co-authored-by: ${name} <agent+${id}@hilos.sh>`;
253
248
  }
249
+
250
+ /** Update only text still identical to what hilos last wrote (1298).
251
+ * @returns {{title?: string, body?: string}}
252
+ */
253
+ export function githubArtifactPatch(existing, input) {
254
+ const match = /<!-- hilos-provenance (\{[^\n]*\}) -->/.exec(existing.body || "");
255
+ if (!match) return {};
256
+ let previous;
257
+ try { previous = JSON.parse(match[1]); } catch { return {}; }
258
+ const patch = {};
259
+ const title = input.title || selectGithubArtifactTitle({ summary: input.summary });
260
+ if (typeof previous.generatedTitle === "string" && existing.title === previous.generatedTitle) {
261
+ patch.title = `${input.partial ? "[partial] " : ""}${title}`;
262
+ }
263
+ if (typeof previous.generatedBody === "string" && (existing.body || "").slice(0, match.index).trim() === previous.generatedBody) {
264
+ patch.body = githubArtifactBody(input) + (existing.body || "").slice(match.index + match[0].length);
265
+ }
266
+ return patch;
267
+ }