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 +1 -1
- package/src/acp-session.mjs +1 -29
- package/src/agent-events.mjs +14 -17
- package/src/agent-result.mjs +54 -0
- package/src/child-mcp.mjs +2 -2
- package/src/claude-permissions.mjs +21 -42
- package/src/cli.mjs +2 -2
- package/src/codex-mcp-session.mjs +1 -10
- package/src/config.mjs +5 -7
- package/src/daemon.mjs +8 -13
- package/src/deploy.mjs +1 -1
- package/src/github-artifacts.mjs +49 -35
- package/src/handler.mjs +225 -214
- package/src/hook.mjs +15 -47
- package/src/local-harness-events.mjs +0 -11
- package/src/mcp.mjs +17 -29
- package/src/opencode-permissions.mjs +2 -34
- package/src/opencode-session.mjs +1 -10
- package/src/permission-gate.mjs +1 -25
- package/src/progress-emitter.mjs +1 -6
- package/src/queue.mjs +1 -5
- package/src/reply-bridge.mjs +13 -11
- package/src/run.mjs +6 -27
- package/src/util.mjs +58 -0
package/src/handler.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
1327
|
-
|
|
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
|
-
|
|
1386
|
+
channelId,
|
|
1387
|
+
parentId,
|
|
1388
|
+
settleId,
|
|
1369
1389
|
runId,
|
|
1370
|
-
|
|
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 (
|
|
1374
|
-
|
|
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
|
-
|
|
1484
|
-
|
|
1485
|
-
// Otherwise post a fresh final report.
|
|
1486
|
-
const reportArgs = {
|
|
1506
|
+
const settled = await settleShippedReport({
|
|
1507
|
+
tool,
|
|
1487
1508
|
channelId,
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
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
|
-
?
|
|
1522
|
+
? agentResultText(provenance.summary) || "Changes are ready for review."
|
|
1512
1523
|
: push.status === 0
|
|
1513
|
-
?
|
|
1514
|
-
:
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1791
|
-
*
|
|
1792
|
-
*
|
|
1793
|
-
*
|
|
1794
|
-
*
|
|
1795
|
-
*
|
|
1796
|
-
*
|
|
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
|
-
|
|
1800
|
-
const { message, context, brief,
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
}
|