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/src/handler.mjs CHANGED
@@ -1,7 +1,7 @@
1
- // CANONICAL HANDLER — this is what the published `hilos-agent` package ships and
2
- // runs. Bias-to-PR by default (gate:false opens a PR; gate:true falls back to
3
- // approve-before-push). The legacy, propose-only reference lives at
4
- // scripts/examples/coding-agent-handler.mjs and is NOT what users run.
1
+ import { githubArtifactPatch } from "./github-artifacts.mjs";
2
+ import { AGENT_CODE_RESULT_RULE, AGENT_REPLY_STYLE_RULE, agentResultText } from "./agent-result.mjs";
3
+ // The handler the published `hilos-agent` package ships and runs. Bias-to-PR by
4
+ // default (gate:false opens a PR; gate:true falls back to approve-before-push).
5
5
  //
6
6
  // Turn an @mention in a git-linked channel into a branch + a coding-agent run.
7
7
  // By default it opens a PR for review; with gate:true it posts a proposed diff
@@ -106,6 +106,7 @@ import {
106
106
  import { createUngatedRunNotice } from "./permission-gate.mjs";
107
107
  import { repoEditAllowance } from "./permission-gate.mjs";
108
108
  import { webMcpAgentPrompt } from "./webmcp-bridge.mjs";
109
+ import { truncate } from "./util.mjs";
109
110
  import {
110
111
  conflictCommitTitle,
111
112
  conflictPromptBlock,
@@ -281,6 +282,18 @@ function defaultDeps() {
281
282
  const url = (r.stdout || "").trim().split("\n").filter(Boolean).pop() || null;
282
283
  return { ok: r.status === 0, url, stdout: r.stdout || "", stderr: r.stderr || "" };
283
284
  },
285
+ updatePRCopy: (cwd, url, provenance) => {
286
+ const options = { cwd, env: cwdEnv(cwd), encoding: "utf8" };
287
+ const read = spawnSync("gh", ["pr", "view", url, "--json", "title,body"], options);
288
+ if (read.status !== 0) return;
289
+ try {
290
+ const patch = githubArtifactPatch(JSON.parse(read.stdout), provenance);
291
+ const args = ["pr", "edit", url];
292
+ if (patch.title) args.push("--title", patch.title);
293
+ if (patch.body) args.push("--body", patch.body);
294
+ if (args.length > 3) spawnSync("gh", args, options);
295
+ } catch { /* Leave existing copy intact when it cannot be read. */ }
296
+ },
284
297
  closePR: (cwd, url) =>
285
298
  spawnSync("gh", ["pr", "close", url], {
286
299
  cwd,
@@ -828,33 +841,15 @@ async function runCodexGatedSession({
828
841
  return { ...fallback, permissionGateDropped: true };
829
842
  }
830
843
 
831
- async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId, signal }) {
844
+ async function awaitDecision({ tool, reportMessageId, cfg, deps, signal }) {
832
845
  if (!reportMessageId) return { kind: "timeout" };
833
846
  const deadline = deps.now() + cfg.decisionTimeoutMs;
834
847
  while (deps.now() < deadline) {
835
848
  // A "stop" while we wait for review must end the wait — otherwise a later
836
849
  // Approve would still commit/push/open a PR despite the cancel.
837
850
  if (signal?.aborted) return { kind: "cancelled" };
838
- let report = null;
839
851
  const gr = await tool("get_report", { messageId: reportMessageId }).catch(() => null);
840
- if (gr && gr.found && gr.report) report = gr.report;
841
- if (!report) {
842
- // Fallback for servers without get_report. In a thread the report lives
843
- // under the thread root (read_channel only returns top-level), so scan the
844
- // thread there; otherwise scan the channel tail.
845
- let candidates = [];
846
- if (parentId) {
847
- const t = await tool("get_thread", { parentId }).catch(() => null);
848
- candidates = t && t.found ? [t.root, ...(t.replies ?? [])].filter(Boolean) : [];
849
- } else {
850
- const { messages = [] } = await tool("read_channel", { channelId, limit: 200 }).catch(
851
- () => ({ messages: [] }),
852
- );
853
- candidates = messages;
854
- }
855
- const m = candidates.find((x) => x.id === reportMessageId);
856
- report = m && m.report ? m.report : null;
857
- }
852
+ const report = gr && gr.found && gr.report ? gr.report : null;
858
853
  const kind = report ? decisionKind(report) : null;
859
854
  if (kind === "deploy") {
860
855
  const provider = report.decision?.provider;
@@ -1131,9 +1126,81 @@ async function postFinalReportWithReplay({
1131
1126
  }
1132
1127
  }
1133
1128
 
1129
+ /**
1130
+ * 1290 — the one settle used by both shipping paths (the approved gated diff and
1131
+ * the self-driven run). Seamless single card (0289): settle onto the live status
1132
+ * message when one was streaming this run — the workspace setting decides
1133
+ * channel visibility — otherwise post a fresh final report. The two Stop fences
1134
+ * around the post and the PR link are part of the settle: a Stop that lands here
1135
+ * must compensate the push rather than publish a card.
1136
+ * Returns `{ stopped }` when Stop won, else `{ reportMsgId }` (0792 — the card
1137
+ * this run settled onto, for the transcript stamp).
1138
+ * `finishReason` is passed as `undefined` when there is nothing to report.
1139
+ */
1140
+ async function settleShippedReport({
1141
+ tool,
1142
+ channelId,
1143
+ parentId,
1144
+ settleId,
1145
+ runId,
1146
+ iterateTurn = 0,
1147
+ finishStatus,
1148
+ finishBranch,
1149
+ finishReason,
1150
+ title,
1151
+ summary,
1152
+ prUrl,
1153
+ caveats,
1154
+ usageArgs = {},
1155
+ rollbackIfStopped,
1156
+ linkUrl = null,
1157
+ }) {
1158
+ const reportArgs = {
1159
+ channelId,
1160
+ ...(runId ? { runId } : {}),
1161
+ ...(runId ? { iterateTurn } : {}),
1162
+ ...(runId ? { reportRequestId: randomUUID() } : {}),
1163
+ ...(runId
1164
+ ? {
1165
+ finishStatus,
1166
+ finishBranch,
1167
+ ...(finishReason === undefined ? {} : { finishReason }),
1168
+ }
1169
+ : {}),
1170
+ title,
1171
+ summary,
1172
+ prUrl,
1173
+ caveats,
1174
+ // 0787 — what the run cost, when the caller had a receipt to hand over.
1175
+ // A gated run already spent it on the proposal card and passes {}.
1176
+ ...usageArgs,
1177
+ };
1178
+ const stoppedBeforeReport = await rollbackIfStopped();
1179
+ if (stoppedBeforeReport) return { stopped: stoppedBeforeReport };
1180
+ const finalReportArgs = settleId
1181
+ ? { ...reportArgs, messageId: settleId, broadcast: true }
1182
+ : { ...reportArgs, parentId, broadcast: true };
1183
+ const reportAttempt = await postFinalReportWithReplay({
1184
+ tool,
1185
+ args: finalReportArgs,
1186
+ runId,
1187
+ finishStatus: reportArgs.finishStatus,
1188
+ rollbackIfStopped,
1189
+ });
1190
+ if (reportAttempt.stopped) return { stopped: reportAttempt.stopped };
1191
+ const stoppedAfterReport = await rollbackIfStopped();
1192
+ if (stoppedAfterReport) return { stopped: stoppedAfterReport };
1193
+ const reportMsgId = settleId || reportAttempt.result?.messageId || null;
1194
+ // Work produced a PR → attach it to the channel so its live pill shows up.
1195
+ await linkPrFromUrl({ tool, channelId, url: linkUrl });
1196
+ // post_report atomically bound this card, the branch/PR receipt, and the final
1197
+ // run gate under one run-row lock; nothing else may update the run here.
1198
+ return { reportMsgId };
1199
+ }
1200
+
1134
1201
  /**
1135
1202
  * @param {{ tool: Function, runId: string, expectedIterateTurn: number,
1136
- * claimId?: string, recoveryStore?: {
1203
+ * claimId?: string, recoveryStore: {
1137
1204
  * persist: (record: { runId: string, expectedIterateTurn: number, claimId: string }) => boolean,
1138
1205
  * remove: (claimId: string) => boolean
1139
1206
  * } }} options
@@ -1153,17 +1220,6 @@ export async function claimDaemonIterate({
1153
1220
  expectedIterateTurn,
1154
1221
  claimId,
1155
1222
  };
1156
- // Rolling upgrade compatibility: the preceding server supported the
1157
- // idempotent claim token but not exact confirm/release. Without a recovery
1158
- // store (created only when the full schema is advertised), preserve that
1159
- // bounded same-token replay byte-for-byte and do not enable later guards.
1160
- if (!recoveryStore) {
1161
- try {
1162
- return await tool("update_run", args);
1163
- } catch {
1164
- return await tool("update_run", args);
1165
- }
1166
- }
1167
1223
  if (!recoveryStore.persist(record)) {
1168
1224
  throw new Error("the Iterate claim recovery record could not be persisted");
1169
1225
  }
@@ -1310,6 +1366,7 @@ export async function applyDecision({ decision, repoPath, branch, task, requeste
1310
1366
  // Continuing an existing PR (request-changes rework): the push already
1311
1367
  // updated it — reuse its URL instead of opening a duplicate. Otherwise open
1312
1368
  // a fresh PR.
1369
+ if (existingPrUrl) deps.updatePRCopy?.(repoPath, existingPrUrl, provenance);
1313
1370
  const pr = existingPrUrl
1314
1371
  ? { ok: true, url: existingPrUrl }
1315
1372
  : deps.openPR(repoPath, { title, body, branch, base: cfg.defaultBranch });
@@ -1323,64 +1380,32 @@ export async function applyDecision({ decision, repoPath, branch, task, requeste
1323
1380
  };
1324
1381
  const stoppedAfterPr = await rollbackIfStopped();
1325
1382
  if (stoppedAfterPr) return stoppedAfterPr;
1326
- // Seamless single card (0289): when a live status message was streaming this
1327
- // run, SETTLE the report onto it in place; the workspace setting decides
1328
- // whether that final report is also channel-visible. Without a status id
1329
- // (older server / status post failed) fall back to posting a fresh report.
1330
- const reportArgs = {
1331
- channelId,
1332
- ...(runId ? { runId } : {}),
1333
- ...(runId ? { iterateTurn } : {}),
1334
- ...(runId ? { reportRequestId: randomUUID() } : {}),
1335
- ...(runId
1336
- ? {
1337
- finishStatus: pr.ok && pr.url ? "awaiting_review" : "failed",
1338
- finishBranch: branch,
1339
- ...(!(pr.ok && pr.url)
1340
- ? { finishReason: oneLine((pr.stderr || "GitHub did not return a pull request URL").trim(), 300) }
1341
- : {}),
1342
- }
1343
- : {}),
1344
- title: `Shipped: ${title}`,
1345
- summary:
1346
- pr.ok && pr.url
1347
- ? existingPrUrl
1348
- ? `${lead}pushed an update to \`${branch}\` and updated the pull request.`
1349
- : `${lead}pushed \`${branch}\` and opened a pull request.`
1350
- : `${lead}pushed \`${branch}\`. Open a PR manually — \`gh\` failed.`,
1351
- prUrl: pr.ok && pr.url ? pr.url : undefined,
1352
- caveats: pr.ok ? [] : [`gh pr create failed: ${(pr.stderr || "").trim().slice(0, 200)}`],
1353
- // 0787 — what the run cost, when the caller had a receipt to hand over.
1354
- // Ungated runs land here with the whole run's usage; a gated one already
1355
- // spent it on the proposal card and passes {}.
1356
- ...usageArgs,
1357
- };
1358
- // 0792 — keep the id of the card this run settled onto, so the caller can
1359
- // mark it with the run's transcript. `settleId` is that card when the run
1360
- // streamed onto a live status message; otherwise it is the fresh report.
1361
- const stoppedBeforeReport = await rollbackIfStopped();
1362
- if (stoppedBeforeReport) return stoppedBeforeReport;
1363
- const finalReportArgs = settleId
1364
- ? { ...reportArgs, messageId: settleId, broadcast: true }
1365
- : { ...reportArgs, parentId, broadcast: true };
1366
- const reportAttempt = await postFinalReportWithReplay({
1383
+ const shipped = pr.ok && pr.url;
1384
+ const settled = await settleShippedReport({
1367
1385
  tool,
1368
- args: finalReportArgs,
1386
+ channelId,
1387
+ parentId,
1388
+ settleId,
1369
1389
  runId,
1370
- finishStatus: reportArgs.finishStatus,
1390
+ iterateTurn,
1391
+ finishStatus: shipped ? "awaiting_review" : "failed",
1392
+ finishBranch: branch,
1393
+ finishReason: shipped
1394
+ ? undefined
1395
+ : oneLine((pr.stderr || "GitHub did not return a pull request URL").trim(), 300),
1396
+ title: shipped ? "Ready for review" : "Couldn’t open the pull request",
1397
+ summary: shipped
1398
+ ? agentResultText(provenance.summary) || (existingPrUrl ? "Updated the pull request for review." : "Changes are ready for review.")
1399
+ : "Changes were pushed, but the pull request could not be opened.",
1400
+ prUrl: shipped ? pr.url : undefined,
1401
+ caveats: pr.ok ? [] : [`gh pr create failed: ${(pr.stderr || "").trim().slice(0, 200)}`],
1402
+ // Ungated runs land here with the whole run's usage.
1403
+ usageArgs,
1371
1404
  rollbackIfStopped,
1405
+ linkUrl: pr.ok ? pr.url : null,
1372
1406
  });
1373
- if (reportAttempt.stopped) return reportAttempt.stopped;
1374
- const reportRes = reportAttempt.result;
1375
- const stoppedAfterReport = await rollbackIfStopped();
1376
- if (stoppedAfterReport) return stoppedAfterReport;
1377
- const reportMsgId = settleId || reportRes?.messageId || null;
1378
- // Work produced a PR → attach it to the channel so its live pill shows up.
1379
- await linkPrFromUrl({ tool, channelId, url: pr.ok ? pr.url : null });
1380
- // The run gate, PR/branch receipt, and report pointer were committed by
1381
- // post_report under one run-row lock. A second update_run here used to
1382
- // leave a window where the card existed while Stop still looked current.
1383
- return { status: "pushed", branch, prUrl: pr.ok ? pr.url : null, reportMsgId };
1407
+ if (settled.stopped) return settled.stopped;
1408
+ return { status: "pushed", branch, prUrl: pr.ok ? pr.url : null, reportMsgId: settled.reportMsgId };
1384
1409
  }
1385
1410
 
1386
1411
  if (decision.kind === "rejected") {
@@ -1448,6 +1473,7 @@ export async function shipSelfDriven({ repoPath, branch, task, requester, cfg, t
1448
1473
  // is not this run's verified commit.
1449
1474
  let prUrl = push.status === 0 && deps.findPR ? deps.findPR(repoPath, ship.headBranch) : null;
1450
1475
  const existingPrUrl = prUrl;
1476
+ if (existingPrUrl) deps.updatePRCopy?.(repoPath, existingPrUrl, provenance);
1451
1477
  let prAttempt = null;
1452
1478
  if (!prUrl && push.status === 0) {
1453
1479
  const { title, body } = prTitleBody(task, ship.headBranch, provenance);
@@ -1470,9 +1496,6 @@ export async function shipSelfDriven({ repoPath, branch, task, requester, cfg, t
1470
1496
  const stoppedAfterPr = await rollbackIfStopped();
1471
1497
  if (stoppedAfterPr) return stoppedAfterPr;
1472
1498
  const { title } = prTitleBody(task, ship.headBranch, provenance);
1473
- const recoveredNote = ship.recoveredFrom
1474
- ? ` The child had switched to \`${ship.recoveredFrom}\`; hilos recovered its HEAD onto \`${ship.headBranch}\`.`
1475
- : "";
1476
1499
  const prFailure =
1477
1500
  prAttempt && !prUrl
1478
1501
  ? oneLine(
@@ -1480,38 +1503,26 @@ export async function shipSelfDriven({ repoPath, branch, task, requester, cfg, t
1480
1503
  200,
1481
1504
  )
1482
1505
  : "";
1483
- // Seamless single card (0289): settle onto the live status message when one was
1484
- // streaming this run; the workspace setting decides channel visibility.
1485
- // Otherwise post a fresh final report.
1486
- const reportArgs = {
1506
+ const settled = await settleShippedReport({
1507
+ tool,
1487
1508
  channelId,
1488
- ...(runId ? { runId } : {}),
1489
- ...(runId ? { iterateTurn } : {}),
1490
- ...(runId ? { reportRequestId: randomUUID() } : {}),
1491
- ...(runId
1492
- ? {
1493
- finishStatus: prUrl ? "awaiting_review" : "failed",
1494
- finishBranch: ship.headBranch,
1495
- ...(!prUrl
1496
- ? {
1497
- finishReason:
1498
- push.status !== 0
1499
- ? oneLine((push.stderr || "git push failed").trim(), 300)
1500
- : prFailure || "GitHub did not return a pull request URL",
1501
- }
1502
- : {}),
1503
- }
1504
- : {}),
1505
- title: prUrl
1506
- ? `Shipped: ${title}`
1507
- : push.status === 0
1508
- ? `PR failed: ${title}`
1509
- : `Push failed: ${title}`,
1509
+ parentId,
1510
+ settleId,
1511
+ runId,
1512
+ iterateTurn,
1513
+ finishStatus: prUrl ? "awaiting_review" : "failed",
1514
+ finishBranch: ship.headBranch,
1515
+ finishReason: prUrl
1516
+ ? undefined
1517
+ : push.status !== 0
1518
+ ? oneLine((push.stderr || "git push failed").trim(), 300)
1519
+ : prFailure || "GitHub did not return a pull request URL",
1520
+ title: prUrl ? "Ready for review" : "Couldn’t finish",
1510
1521
  summary: prUrl
1511
- ? `${lead}the coding agent committed the work itself and opened a pull request from \`${ship.headBranch}\`.${recoveredNote}`
1522
+ ? agentResultText(provenance.summary) || "Changes are ready for review."
1512
1523
  : push.status === 0
1513
- ? `${lead}the coding agent committed and pushed \`${ship.headBranch}\`, but GitHub rejected PR creation.${recoveredNote}`
1514
- : `${lead}the coding agent committed locally, but pushing \`${ship.headBranch}\` failed.${recoveredNote}`,
1524
+ ? "Changes are saved, but the pull request could not be opened."
1525
+ : "Changes are saved locally, but could not be pushed.",
1515
1526
  prUrl: prUrl || undefined,
1516
1527
  caveats:
1517
1528
  push.status !== 0
@@ -1520,35 +1531,18 @@ export async function shipSelfDriven({ repoPath, branch, task, requester, cfg, t
1520
1531
  ? [`gh pr create failed: ${prFailure}`]
1521
1532
  : [],
1522
1533
  // 0787 — an autonomous run's receipt rides its one and only report.
1523
- ...usageArgs,
1524
- };
1525
- const stoppedBeforeReport = await rollbackIfStopped();
1526
- if (stoppedBeforeReport) return stoppedBeforeReport;
1527
- const finalReportArgs = settleId
1528
- ? { ...reportArgs, messageId: settleId, broadcast: true }
1529
- : { ...reportArgs, parentId, broadcast: true };
1530
- const reportAttempt = await postFinalReportWithReplay({
1531
- tool,
1532
- args: finalReportArgs,
1533
- runId,
1534
- finishStatus: reportArgs.finishStatus,
1534
+ usageArgs,
1535
1535
  rollbackIfStopped,
1536
+ linkUrl: prUrl,
1536
1537
  });
1537
- if (reportAttempt.stopped) return reportAttempt.stopped;
1538
- const reportRes = reportAttempt.result;
1539
- const stoppedAfterReport = await rollbackIfStopped();
1540
- if (stoppedAfterReport) return stoppedAfterReport;
1541
- // 0792 — the card this run settled onto, for the transcript stamp.
1542
- const reportMsgId = settleId || reportRes?.messageId || null;
1543
- if (prUrl) await linkPrFromUrl({ tool, channelId, url: prUrl });
1544
- // post_report atomically bound this card, branch/PR receipt, and final gate.
1538
+ if (settled.stopped) return settled.stopped;
1545
1539
  const status =
1546
1540
  push.status !== 0 ? "commit-local" : prUrl ? "pushed" : "pr-failed";
1547
1541
  return {
1548
1542
  status,
1549
1543
  branch: ship.headBranch,
1550
1544
  prUrl,
1551
- reportMsgId,
1545
+ reportMsgId: settled.reportMsgId,
1552
1546
  };
1553
1547
  }
1554
1548
 
@@ -1717,19 +1711,13 @@ export async function routeIntent({
1717
1711
  return { code: false, reply: null, followupSignal };
1718
1712
  }
1719
1713
 
1720
- /**
1721
- * The room roster block the server attached to this mention (1247), or "" when
1722
- * it did not. Optional by contract: an older server sends no `rosters` field
1723
- * and the prompt is exactly what it was before.
1724
- * @param {unknown} roster
1725
- */
1726
1714
  /**
1727
1715
  * Who addressed this mention as a deliberate ask or handoff (1250), or "" when
1728
1716
  * it is ordinary room chat. `kind` comes from list_mentions; `author` is the
1729
1717
  * display name it delivers beside it.
1730
1718
  * @param {{ kind?: string, author?: string } | null} [message]
1731
1719
  */
1732
- export function askerName(message) {
1720
+ function askerName(message) {
1733
1721
  const kind = message?.kind;
1734
1722
  if (kind !== "ask" && kind !== "handoff") return "";
1735
1723
  return typeof message?.author === "string" ? message.author.trim() : "";
@@ -1741,7 +1729,7 @@ export function askerName(message) {
1741
1729
  * it is — the difference between being handed work and overhearing it.
1742
1730
  * @param {{ kind?: string, author?: string } | null} [message]
1743
1731
  */
1744
- export function askedYouTo(message) {
1732
+ function askedYouTo(message) {
1745
1733
  const who = askerName(message);
1746
1734
  return who ? `${who} asked you to:` : "";
1747
1735
  }
@@ -1778,7 +1766,7 @@ function promptLead(message) {
1778
1766
  * @param {string} [roster]
1779
1767
  * @param {{ hilosTools?: boolean }} [o]
1780
1768
  */
1781
- export function rosterNote(roster, { hilosTools = false } = {}) {
1769
+ function rosterNote(roster, { hilosTools = false } = {}) {
1782
1770
  const text = typeof roster === "string" ? roster.trim() : "";
1783
1771
  if (!text) return "";
1784
1772
  return hilosTools
@@ -1787,52 +1775,70 @@ export function rosterNote(roster, { hilosTools = false } = {}) {
1787
1775
  }
1788
1776
 
1789
1777
  /**
1790
- * The prompt handed to the CODING CLI. Framed as an engineering directive, NOT a
1791
- * chat: the old "you're in a team chat, do what the latest message asks" wording
1792
- * made the agent reply conversationally to stdout (e.g. to "how u doing? keep
1793
- * working on this") and edit nothing. So this is imperative — "you are a coding
1794
- * agent, edit files now" — led by the router's distilled `brief` (what to build),
1795
- * with the conversation included only as background. Falls back to the raw
1796
- * mention when there's no brief.
1797
- * @param {{ message?: { body?: string, roomRoster?: string, kind?: string, author?: string, askMessageId?: string } | null, context?: { transcript?: string } | null, brief?: string, repoFullName?: string, images?: {path: string, name: string, type: string}[], imagesReadable?: boolean, roster?: string, hilosTools?: boolean }} [o]
1778
+ * 1290 — the one assembly behind both coding prompts. The repo lane and the
1779
+ * folder lane differ in exactly two sentences: the opening directive (`lead`,
1780
+ * which names where the agent is working) and the IMPORTANT paragraph about git.
1781
+ * Everything else — the router's brief, the image note, the child-tools note,
1782
+ * the roster, the transcript, and their order — is the same, so it lives here
1783
+ * once. Output is compared byte-for-byte in tests; keep the order.
1784
+ * @param {{ message?: object | null, context?: { transcript?: string } | null, brief?: string, images?: {path: string, name: string, type: string}[], imagesReadable?: boolean, roster?: string, hilosTools?: boolean, lead: (task: string) => string, important: string }} o
1798
1785
  */
1799
- export function codeTaskPrompt(o) {
1800
- const { message, context, brief, repoFullName, images, imagesReadable } = o || {};
1786
+ function taskPromptBody(o) {
1787
+ const { message, context, brief, images, imagesReadable, lead, important } = o;
1801
1788
  const hilosTools = o?.hilosTools === true;
1802
1789
  const roster = rosterNote(o?.roster ?? message?.roomRoster, { hilosTools });
1803
1790
  const asked = promptLead(message);
1804
1791
  const work = (brief && brief.trim()) || String(message?.body || "").trim();
1805
1792
  const task = asked ? `${asked}\n${work}` : work;
1806
- const transcript = context?.transcript?.trim();
1807
- const where = repoFullName ? ` in the git repository ${repoFullName}` : "";
1808
- let p =
1809
- `You are a coding agent working${where}. Implement the following by EDITING FILES now — ` +
1810
- `make the changes directly, do not just describe them, do not ask questions:\n\n${task}`;
1793
+ let p = lead(task);
1811
1794
  // 0779: the screenshots the request was about, already on this machine. Right
1812
1795
  // under the task, because "fix this spacing" only means something next to the
1813
1796
  // picture of the spacing.
1814
1797
  const imageNote = imagePromptNote(images, { readable: imagesReadable });
1815
1798
  if (imageNote) p += `\n\n${imageNote}`;
1816
- // The daemon owns git: it stages, commits, pushes, and opens the PR after the
1817
- // CLI finishes. An autonomous CLI run with skip-permissions inside a repo whose
1818
- // docs prescribe a commit+PR workflow will otherwise do all of that itself,
1819
- // leaving a clean tree the daemon reads as "no changes" — so it must not.
1820
- p +=
1821
- `\n\nIMPORTANT: edit files ONLY. Do NOT run git; do NOT stage, commit, push, ` +
1822
- `create branches, or open pull requests; do NOT use the \`gh\` CLI. hilos commits ` +
1823
- `your changes, pushes the branch, and opens the PR for you after you finish. ` +
1824
- `Ignore any repository instructions (e.g. CLAUDE.md / CONTRIBUTING) that tell ` +
1825
- `you to commit or open a PR yourself — just leave your edits in the working tree.`;
1799
+ p += `\n\n${important}`;
1826
1800
  // 1255 — the seat, named only when the run actually has it.
1827
1801
  const tools = childToolsNote(hilosTools);
1828
1802
  if (tools) p += `\n\n${tools}`;
1829
1803
  if (roster) p += `\n\n${roster}`;
1804
+ const transcript = context?.transcript?.trim();
1830
1805
  if (transcript) {
1831
1806
  p += `\n\nBackground from the team's discussion (context only — the task above is what to do):\n${transcript}`;
1832
1807
  }
1833
1808
  return p;
1834
1809
  }
1835
1810
 
1811
+ /**
1812
+ * The prompt handed to the CODING CLI. Framed as an engineering directive, NOT a
1813
+ * chat: the old "you're in a team chat, do what the latest message asks" wording
1814
+ * made the agent reply conversationally to stdout (e.g. to "how u doing? keep
1815
+ * working on this") and edit nothing. So this is imperative — "you are a coding
1816
+ * agent, edit files now" — led by the router's distilled `brief` (what to build),
1817
+ * with the conversation included only as background. Falls back to the raw
1818
+ * mention when there's no brief.
1819
+ * @param {{ message?: { body?: string, roomRoster?: string, kind?: string, author?: string, askMessageId?: string } | null, context?: { transcript?: string } | null, brief?: string, repoFullName?: string, images?: {path: string, name: string, type: string}[], imagesReadable?: boolean, roster?: string, hilosTools?: boolean }} [o]
1820
+ */
1821
+ export function codeTaskPrompt(o) {
1822
+ const { repoFullName } = o || {};
1823
+ const where = repoFullName ? ` in the git repository ${repoFullName}` : "";
1824
+ return taskPromptBody({
1825
+ ...(o || {}),
1826
+ lead: (task) =>
1827
+ `You are a coding agent working${where}. Implement the following by EDITING FILES now — ` +
1828
+ `make the changes directly, do not just describe them, do not ask questions:\n\n${task}`,
1829
+ // The daemon owns git: it stages, commits, pushes, and opens the PR after the
1830
+ // CLI finishes. An autonomous CLI run with skip-permissions inside a repo whose
1831
+ // docs prescribe a commit+PR workflow will otherwise do all of that itself,
1832
+ // leaving a clean tree the daemon reads as "no changes" — so it must not.
1833
+ important:
1834
+ `IMPORTANT: edit files ONLY. Do NOT run git; do NOT stage, commit, push, ` +
1835
+ `create branches, or open pull requests; do NOT use the \`gh\` CLI. hilos commits ` +
1836
+ `your changes, pushes the branch, and opens the PR for you after you finish. ` +
1837
+ `Ignore any repository instructions (e.g. CLAUDE.md / CONTRIBUTING) that tell ` +
1838
+ `you to commit or open a PR yourself — just leave your edits in the working tree.`,
1839
+ });
1840
+ }
1841
+
1836
1842
  /**
1837
1843
  * The prompt handed to the coding CLI in FOLDER mode (0322): the agent works
1838
1844
  * directly in the user's own folder — no branch, no PR, no commit step. Same
@@ -1842,34 +1848,20 @@ export function codeTaskPrompt(o) {
1842
1848
  * @param {{ message?: { body?: string, roomRoster?: string, kind?: string, author?: string, askMessageId?: string } | null, context?: { transcript?: string } | null, brief?: string, folderPath?: string, images?: {path: string, name: string, type: string}[], imagesReadable?: boolean, roster?: string, hilosTools?: boolean }} [o]
1843
1849
  */
1844
1850
  export function folderTaskPrompt(o) {
1845
- const { message, context, brief, folderPath, images, imagesReadable } = o || {};
1846
- const hilosTools = o?.hilosTools === true;
1847
- const roster = rosterNote(o?.roster ?? message?.roomRoster, { hilosTools });
1848
- const asked = promptLead(message);
1849
- const work = (brief && brief.trim()) || String(message?.body || "").trim();
1850
- const task = asked ? `${asked}\n${work}` : work;
1851
+ const { folderPath } = o || {};
1851
1852
  const where = folderPath ? ` in the local folder ${folderPath}` : "";
1852
- let p =
1853
- `You are a coding agent working directly${where}. Implement the following by EDITING ` +
1854
- `FILES now — make the changes directly in this folder, do not just describe them, do not ` +
1855
- `ask questions:\n\n${task}`;
1856
- // 0779 — same image handoff as the repo path.
1857
- const imageNote = imagePromptNote(images, { readable: imagesReadable });
1858
- if (imageNote) p += `\n\n${imageNote}`;
1859
- p +=
1860
- `\n\nIMPORTANT: your edits apply DIRECTLY to the user's folder — there is no branch, no ` +
1861
- `commit, and no pull request. Do NOT run git; do NOT stage, commit, push, create branches, ` +
1862
- `or open pull requests; do NOT use the \`gh\` CLI. Just leave your edits in the folder for ` +
1863
- `the person to review.`;
1864
- // 1255 — the seat, named only when the run actually has it.
1865
- const tools = childToolsNote(hilosTools);
1866
- if (tools) p += `\n\n${tools}`;
1867
- if (roster) p += `\n\n${roster}`;
1868
- const transcript = context?.transcript?.trim();
1869
- if (transcript) {
1870
- p += `\n\nBackground from the team's discussion (context only — the task above is what to do):\n${transcript}`;
1871
- }
1872
- return p;
1853
+ return taskPromptBody({
1854
+ ...(o || {}),
1855
+ lead: (task) =>
1856
+ `You are a coding agent working directly${where}. Implement the following by EDITING ` +
1857
+ `FILES now — make the changes directly in this folder, do not just describe them, do not ` +
1858
+ `ask questions:\n\n${task}`,
1859
+ important:
1860
+ `IMPORTANT: your edits apply DIRECTLY to the user's folder — there is no branch, no ` +
1861
+ `commit, and no pull request. Do NOT run git; do NOT stage, commit, push, create branches, ` +
1862
+ `or open pull requests; do NOT use the \`gh\` CLI. Just leave your edits in the folder for ` +
1863
+ `the person to review.`,
1864
+ });
1873
1865
  }
1874
1866
 
1875
1867
  /** owner/name from a GitHub remote URL (https or ssh), or null. */
@@ -1889,6 +1881,7 @@ function memoryPreamble(workspaceMemory) {
1889
1881
  /** Shared context for a model that may act, not the read-only intent router. */
1890
1882
  function agentPreamble(workspaceMemory, cfg) {
1891
1883
  return (
1884
+ AGENT_REPLY_STYLE_RULE + "\n\n" +
1892
1885
  memoryPreamble(workspaceMemory) +
1893
1886
  nativeWebPrompt(cfg?.webSearch !== false) +
1894
1887
  webMcpAgentPrompt(cfg)
@@ -1978,7 +1971,7 @@ export async function proposePlanAck({
1978
1971
  if (run.aborted || signal?.aborted) return null;
1979
1972
  const text = (run.stdout || "").trim();
1980
1973
  if (!text) return null;
1981
- return text.length > 400 ? text.slice(0, 399) + "…" : text;
1974
+ return truncate(text, 400);
1982
1975
  }
1983
1976
 
1984
1977
  /**
@@ -2589,8 +2582,7 @@ async function handleFolderDeploy({
2589
2582
  * live), request-changes = re-run in place (bounded), reject = revert the run's
2590
2583
  * own changes (git repos only).
2591
2584
  */
2592
- async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps, signal, parentId, folderPath, brief, workspaceMemory, context, onStopRequested }) {
2593
- void me;
2585
+ async function handleFolderTask({ message, channelId, tool, caps, cfg, deps, signal, parentId, folderPath, brief, workspaceMemory, context, onStopRequested }) {
2594
2586
  const git = deps.git;
2595
2587
  // 0779 — screenshots the poll loop already pulled to a temp dir at pickup.
2596
2588
  // The prompt names them; the argv carries them for a vendor that takes one.
@@ -3153,7 +3145,7 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
3153
3145
  return res?.messageId ?? null;
3154
3146
  };
3155
3147
  let reportMessageId = await postFolderReport(runResult, changed);
3156
- let decision = await awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId: threadRoot, signal });
3148
+ let decision = await awaitDecision({ tool, reportMessageId, cfg, deps, signal });
3157
3149
 
3158
3150
  const maxRounds = cfg.maxRounds || 3;
3159
3151
  let round = 1;
@@ -3174,7 +3166,7 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
3174
3166
  }
3175
3167
  changed = computeChanged();
3176
3168
  reportMessageId = await postFolderReport(runResult, changed);
3177
- decision = await awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId: threadRoot, signal });
3169
+ decision = await awaitDecision({ tool, reportMessageId, cfg, deps, signal });
3178
3170
  round += 1;
3179
3171
  }
3180
3172
 
@@ -3284,7 +3276,10 @@ export async function handleTask({ message, channelId, tool, me, caps = {}, iter
3284
3276
  // metadata instead of first calling merge_pr in this process.
3285
3277
  let forcedConflictRecovery = null;
3286
3278
 
3287
- const { links = [] } = await tool("get_links", { channelId }).catch(() => ({ links: [] }));
3279
+ // 1290 — NOT caught. An unreachable server is not "this channel has no repo":
3280
+ // swallowing it dropped a repo run into folder/chat mode and told the room the
3281
+ // repo isn't linked. Let it throw; the run fails with the transport's reason.
3282
+ const { links = [] } = await tool("get_links", { channelId });
3288
3283
  // A folder link (0324) carries the folder PATH in repo_full_name for display —
3289
3284
  // it is NOT a GitHub repo, so it must never be picked as the channel's repo.
3290
3285
  // Exclude kind='folder'; pr/branch/repo rows with repo_full_name still count.
@@ -3408,7 +3403,22 @@ export async function handleTask({ message, channelId, tool, me, caps = {}, iter
3408
3403
  return { status: "chat" };
3409
3404
  }
3410
3405
  const sourceReportId = message.deployRequest.reportMessageId;
3411
- const current = await tool("get_report", { messageId: sourceReportId }).catch(() => null);
3406
+ // 1290 — fail CLOSED. This read is the only thing standing between a
3407
+ // durable fallback mention and a second deploy of a decision the active
3408
+ // run already settled. A transport error is not "still pending": say so
3409
+ // and stop, rather than deploying on an unknown.
3410
+ let current = null;
3411
+ try {
3412
+ current = await tool("get_report", { messageId: sourceReportId });
3413
+ } catch (e) {
3414
+ console.log(` deploy → couldn't read report ${sourceReportId}: ${e?.message || e}`);
3415
+ await tool("post_message", {
3416
+ channelId,
3417
+ parentId,
3418
+ body: "I couldn't confirm that decision is still pending, so I didn't deploy. Try the deploy again.",
3419
+ });
3420
+ return { status: "deploy-unverified" };
3421
+ }
3412
3422
  if (current?.found && current.report?.decision?.kind !== "deploy") {
3413
3423
  return { status: "deploy-already-handled" };
3414
3424
  }
@@ -3514,7 +3524,6 @@ export async function handleTask({ message, channelId, tool, me, caps = {}, iter
3514
3524
  message,
3515
3525
  channelId,
3516
3526
  tool,
3517
- me,
3518
3527
  caps,
3519
3528
  cfg,
3520
3529
  deps,
@@ -4312,6 +4321,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {}, iter
4312
4321
  // Workspace memory (the project's soul) is prepended so the coding agent has
4313
4322
  // the shared context before it starts.
4314
4323
  const runAndStage = async (promptText, { resume = true } = {}) => {
4324
+ promptText += "\n\n" + AGENT_CODE_RESULT_RULE;
4315
4325
  console.log(
4316
4326
  ` code → running \`${cfg.codingCmd}\` in ${repoPath} (this can take a few minutes; output appears when it finishes)…`,
4317
4327
  );
@@ -4914,6 +4924,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {}, iter
4914
4924
  // Post a proposal card (approve-before-push mode) from a staged change.
4915
4925
  const postProposal = async (staged) => {
4916
4926
  const report = buildProposalReport({
4927
+ summary: staged.summary,
4917
4928
  task: message.body,
4918
4929
  requester: message.author,
4919
4930
  repoFullName,
@@ -5340,7 +5351,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {}, iter
5340
5351
 
5341
5352
  await finalizeProgress(`Coding done on \`${branch}\` — proposal below for review.`);
5342
5353
  let proposal = await postProposal(staged);
5343
- let decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId: threadRoot, signal });
5354
+ let decision = await awaitDecision({ tool, reportMessageId: proposal.reportMessageId, cfg, deps, signal });
5344
5355
  if (decision.kind === "cancelled") return await postStopped();
5345
5356
  const maxRounds = cfg.maxRounds || 3;
5346
5357
  let round = 1;
@@ -5398,7 +5409,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {}, iter
5398
5409
  break;
5399
5410
  }
5400
5411
  proposal = await postProposal(staged);
5401
- decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId: threadRoot, signal });
5412
+ decision = await awaitDecision({ tool, reportMessageId: proposal.reportMessageId, cfg, deps, signal });
5402
5413
  if (decision.kind === "cancelled") return await postStopped();
5403
5414
  round += 1;
5404
5415
  }