hilos-agent 0.10.1 → 0.11.2

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
@@ -8,7 +8,7 @@
8
8
  // and pushes only after approval. Your code + credentials stay local.
9
9
 
10
10
  import { spawnSync } from "node:child_process";
11
- import { randomBytes } from "node:crypto";
11
+ import { randomBytes, randomUUID } from "node:crypto";
12
12
  import { rmSync, mkdtempSync, existsSync } from "node:fs";
13
13
  import { hostname, tmpdir } from "node:os";
14
14
  import { join } from "node:path";
@@ -37,7 +37,15 @@ import {
37
37
  scrubHilosEnv,
38
38
  envForCwd,
39
39
  } from "./cli.mjs";
40
+ import { commandArgv } from "./argv.mjs";
40
41
  import { makeStreamParser, createUsageFold } from "./agent-events.mjs";
42
+ import {
43
+ createDirectionInbox,
44
+ directionTurnPrompt,
45
+ directionTurnRan,
46
+ MAX_DIRECTION_TURNS,
47
+ readPendingInputs,
48
+ } from "./run-inputs.mjs";
41
49
  import {
42
50
  detectVendor,
43
51
  chatWebArgs,
@@ -54,6 +62,8 @@ import {
54
62
  webToolEnv,
55
63
  } from "./progress-emitter.mjs";
56
64
  import { createTranscriptTap } from "./transcript.mjs";
65
+ import { createLocalHarnessEmitter } from "./local-harness-transport.mjs";
66
+ import { releaseDaemonIterateClaim } from "./iterate-claim-recovery.mjs";
57
67
  import { imagePromptNote, renderAttachmentLine } from "./attachments.mjs";
58
68
  import { resolveFollowupMode, classifyFollowupCue, normalizeSignal } from "./followup.mjs";
59
69
  import {
@@ -87,6 +97,15 @@ import {
87
97
  } from "./codex-mcp-session.mjs";
88
98
  import { createUngatedRunNotice } from "./permission-gate.mjs";
89
99
  import { webMcpAgentPrompt } from "./webmcp-bridge.mjs";
100
+ import {
101
+ conflictCommitTitle,
102
+ conflictPromptBlock,
103
+ conflictRepairBrief,
104
+ remainingConflictFiles,
105
+ startConflictMerge,
106
+ validateConflictMergeState,
107
+ validConflictRecovery,
108
+ } from "./conflicts.mjs";
90
109
 
91
110
  /**
92
111
  * The environment for a coding/chat CLI run. runCli always strips HILOS_* on top
@@ -250,6 +269,12 @@ function defaultDeps() {
250
269
  const url = (r.stdout || "").trim().split("\n").filter(Boolean).pop() || null;
251
270
  return { ok: r.status === 0, url, stdout: r.stdout || "", stderr: r.stderr || "" };
252
271
  },
272
+ closePR: (cwd, url) =>
273
+ spawnSync("gh", ["pr", "close", url], {
274
+ cwd,
275
+ env: cwdEnv(cwd),
276
+ encoding: "utf8",
277
+ }),
253
278
  // The open PR for a branch, if one already exists — so when the CLI opened a
254
279
  // PR itself we report THAT instead of opening a duplicate.
255
280
  findPR: (cwd, branch) => {
@@ -392,13 +417,31 @@ function openCodePermissionCallbacks({
392
417
  * `onStopRequested` is called at most once per sender and must never throw into
393
418
  * the run — it is the daemon's cue to tear the process group down.
394
419
  *
420
+ * `onPendingInputs` (1182) receives the room directions a reply lists for a
421
+ * run-bound sender; it must never throw into the run either.
422
+ *
395
423
  * @param {{ tool: Function, statusId: string, runId?: string|null,
396
- * onStopRequested?: (info: {by: string|null}) => any }} o
424
+ * onStopRequested?: (info: {by: string|null}) => any,
425
+ * onPendingInputs?: (list: Array<{inputId: string, ordinal: number, body: string}>) => any }} o
397
426
  */
398
- export function createProgressSender({ tool, statusId, runId = null, onStopRequested }) {
427
+ export function createProgressSender({ tool, statusId, runId = null, onStopRequested, onPendingInputs }) {
399
428
  let inflight = Promise.resolve();
400
429
  let stopSeen = false;
430
+ // 1182 — a room direction rides home on the same beat as the stop. Handed
431
+ // over on every reply that carries one (the inbox deduplicates), and never
432
+ // allowed to throw into the run.
433
+ const takeInputs = (res) => {
434
+ if (!onPendingInputs || !runId) return;
435
+ const list = readPendingInputs(res);
436
+ if (list.length === 0) return;
437
+ try {
438
+ onPendingInputs(list);
439
+ } catch {
440
+ /* a direction hand-off must never break the run */
441
+ }
442
+ };
401
443
  const takeStop = (res) => {
444
+ takeInputs(res);
402
445
  if (!res || res.stopRequested !== true) return false;
403
446
  if (stopSeen || !onStopRequested) return true;
404
447
  stopSeen = true;
@@ -606,6 +649,7 @@ async function runClaudeGatedCli({
606
649
  permissionCallbacks,
607
650
  sessionId = "",
608
651
  resolveSessionId,
652
+ beforeSpawn,
609
653
  log = console,
610
654
  }) {
611
655
  const server = await deps.startClaudePermissionServer({
@@ -630,6 +674,7 @@ async function runClaudeGatedCli({
630
674
  signal,
631
675
  env: claudeGateEnv(cfg),
632
676
  onData,
677
+ beforeSpawn,
633
678
  // 0785 — a CLI that rejects the gate flags is retried without them. This
634
679
  // fires BEFORE that ungated retry is spawned, so the room hears about the
635
680
  // downgrade first rather than after the fact.
@@ -657,7 +702,7 @@ function codexRunIsGated(cfg, caps) {
657
702
  return shouldGateCodexPermissions({
658
703
  vendor: detectVendor(cfg?.codingCmd),
659
704
  runtimePermissions: caps?.runtimePermissions,
660
- codeArgs: String(cfg?.codingCmd || "").split(" ").filter(Boolean).slice(1),
705
+ codeArgs: commandArgv(String(cfg?.codingCmd || "")).slice(1),
661
706
  });
662
707
  }
663
708
 
@@ -695,8 +740,19 @@ async function runCodexGatedSession({
695
740
  signal,
696
741
  onData,
697
742
  onEvent,
743
+ beforeSpawn,
698
744
  permissionCallbacks,
699
745
  }) {
746
+ if (typeof beforeSpawn === "function" && (await beforeSpawn()) === false) {
747
+ return {
748
+ status: null,
749
+ stdout: "",
750
+ stderr: "",
751
+ aborted: true,
752
+ authorityLost: true,
753
+ error: new Error("execution authority lost before spawn"),
754
+ };
755
+ }
700
756
  const run = await deps.runCodexMcpSession({
701
757
  cmd,
702
758
  cwd,
@@ -713,6 +769,7 @@ async function runCodexGatedSession({
713
769
  env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
714
770
  onData,
715
771
  onEvent,
772
+ beforeSpawn,
716
773
  ...permissionCallbacks,
717
774
  });
718
775
  if (!codexMcpTransportUnavailable(run)) return run;
@@ -736,6 +793,7 @@ async function runCodexGatedSession({
736
793
  signal,
737
794
  env: codingChildEnv(cfg),
738
795
  onData,
796
+ beforeSpawn,
739
797
  });
740
798
  return { ...fallback, permissionGateDropped: true };
741
799
  }
@@ -820,7 +878,339 @@ function githubProvenance({ cfg, me, message, channelId, runId, summary }) {
820
878
  };
821
879
  }
822
880
 
823
- async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, existingPrUrl, settleId, runId, provenance = {}, usageArgs = {} }) {
881
+ /**
882
+ * Capture the exact local commit and remote branch value that a daemon push is
883
+ * allowed to replace. A failed probe is not "branch absent": without a known
884
+ * before-value there is no safe force-with-lease compensation, so callers must
885
+ * refuse the push.
886
+ */
887
+ export function captureRemoteRefLease(git, repoPath, branch) {
888
+ const ref = `refs/heads/${branch}`;
889
+ const local = git(repoPath, ["rev-parse", "--verify", "HEAD"]);
890
+ const headSha = (local.stdout || "").trim();
891
+ if (local.status !== 0 || !headSha || /\s/.test(headSha)) {
892
+ return { ok: false, error: "could not resolve the exact local HEAD" };
893
+ }
894
+
895
+ const remote = git(repoPath, ["ls-remote", "--heads", "origin", ref]);
896
+ if (remote.status !== 0) {
897
+ return {
898
+ ok: false,
899
+ error: `could not read ${ref} from origin: ${(remote.stderr || "git ls-remote failed").trim()}`,
900
+ };
901
+ }
902
+ const lines = (remote.stdout || "")
903
+ .split("\n")
904
+ .map((line) => line.trim())
905
+ .filter(Boolean);
906
+ if (lines.length === 0) {
907
+ return { ok: true, ref, headSha, priorSha: null };
908
+ }
909
+ if (lines.length !== 1) {
910
+ return { ok: false, error: `origin returned an ambiguous value for ${ref}` };
911
+ }
912
+ const [priorSha, remoteRef, ...extra] = lines[0].split(/\s+/);
913
+ if (!priorSha || remoteRef !== ref || extra.length > 0) {
914
+ return { ok: false, error: `origin returned an invalid value for ${ref}` };
915
+ }
916
+ // `ls-remote` proves the ref value but does not guarantee its object exists
917
+ // locally. Fetch the advertised ref without moving a local branch so a later
918
+ // restore can name the exact prior commit.
919
+ const fetched = git(repoPath, ["fetch", "--no-tags", "origin", ref]);
920
+ if (fetched.status !== 0) {
921
+ return { ok: false, error: `could not fetch the exact prior value of ${ref}` };
922
+ }
923
+ const priorObject = git(repoPath, ["cat-file", "-e", `${priorSha}^{commit}`]);
924
+ if (priorObject.status !== 0) {
925
+ return { ok: false, error: `the exact prior value of ${ref} is unavailable locally` };
926
+ }
927
+ // An exact lease must not become permission to rewrite somebody else's
928
+ // branch. Only a fast-forward from the value we observed may ship.
929
+ const fastForward = git(repoPath, ["merge-base", "--is-ancestor", priorSha, headSha]);
930
+ if (fastForward.status !== 0) {
931
+ return { ok: false, error: `local HEAD is not a descendant of the exact ${ref} value` };
932
+ }
933
+ return { ok: true, ref, headSha, priorSha };
934
+ }
935
+
936
+ function pushWithRemoteRefLease(git, repoPath, args, lease) {
937
+ const exactLease = `--force-with-lease=${lease.ref}:${lease.priorSha || ""}`;
938
+ return git(repoPath, [args[0], exactLease, ...args.slice(1)]);
939
+ }
940
+
941
+ /**
942
+ * Undo only the ref value written by this run. If another writer has advanced
943
+ * the branch, the lease fails and their work is left untouched.
944
+ */
945
+ export function compensateRemoteRef(git, repoPath, lease) {
946
+ const restore = lease.priorSha ? `${lease.priorSha}:${lease.ref}` : `:${lease.ref}`;
947
+ let lastPush = null;
948
+ let lastProbe = null;
949
+ let repairable = true;
950
+ for (let attempt = 0; attempt < 2; attempt += 1) {
951
+ lastPush = git(repoPath, [
952
+ "push",
953
+ `--force-with-lease=${lease.ref}:${lease.headSha}`,
954
+ "origin",
955
+ restore,
956
+ ]);
957
+ if (lastPush.status === 0) {
958
+ return { ...lastPush, confirmed: true, repairable: false };
959
+ }
960
+
961
+ // A transport failure can lose the successful response. Read the exact ref
962
+ // before deciding that rollback failed; repeating the same exact lease is
963
+ // safe, but a failed response by itself is not proof that Stop completed.
964
+ lastProbe = git(repoPath, ["ls-remote", "--heads", "origin", lease.ref]);
965
+ if (lastProbe.status === 0) {
966
+ const refs = (lastProbe.stdout || "")
967
+ .split("\n")
968
+ .map((line) => line.trim())
969
+ .filter(Boolean);
970
+ const [remoteSha, remoteRef, ...extra] = refs.length === 1
971
+ ? refs[0].split(/\s+/)
972
+ : [];
973
+ const isExpected = lease.priorSha
974
+ ? remoteSha === lease.priorSha && remoteRef === lease.ref && extra.length === 0
975
+ : refs.length === 0;
976
+ if (isExpected) {
977
+ return {
978
+ ...lastPush,
979
+ status: 0,
980
+ confirmed: true,
981
+ repairable: false,
982
+ verifiedAfterAmbiguousPush: true,
983
+ };
984
+ }
985
+ // Another writer won after our push. The exact lease protects their ref,
986
+ // so do not keep retrying a restoration that can no longer be ours.
987
+ if (
988
+ refs.length !== 1 ||
989
+ remoteSha !== lease.headSha ||
990
+ remoteRef !== lease.ref ||
991
+ extra.length > 0
992
+ ) {
993
+ repairable = false;
994
+ break;
995
+ }
996
+ }
997
+ }
998
+ return {
999
+ ...(lastPush || { status: 1, stdout: "", stderr: "compensation did not run" }),
1000
+ confirmed: false,
1001
+ // Replaying this same exact lease can never overwrite another writer. The
1002
+ // receipt below gives the daemon (and its operator) everything needed to
1003
+ // retry or repair without guessing which ref this run owned.
1004
+ repairable,
1005
+ receipt: {
1006
+ ref: lease.ref,
1007
+ headSha: lease.headSha,
1008
+ priorSha: lease.priorSha,
1009
+ },
1010
+ probeError:
1011
+ lastProbe?.status === 0
1012
+ ? null
1013
+ : (lastProbe?.stderr || "could not verify the remote ref after compensation").trim(),
1014
+ };
1015
+ }
1016
+
1017
+ function stopAfterRemoteRefCompensation(
1018
+ git,
1019
+ repoPath,
1020
+ branch,
1021
+ lease,
1022
+ { closePR, prUrl } = {},
1023
+ ) {
1024
+ const compensation = compensateRemoteRef(git, repoPath, lease);
1025
+ if (!compensation.confirmed) {
1026
+ return {
1027
+ status: "stop-compensation-pending",
1028
+ branch,
1029
+ compensation: {
1030
+ ...compensation,
1031
+ receipt: { ...compensation.receipt, ...(prUrl ? { prUrl } : {}) },
1032
+ },
1033
+ };
1034
+ }
1035
+ if (prUrl) {
1036
+ let closure = null;
1037
+ if (typeof closePR === "function") {
1038
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1039
+ try {
1040
+ closure = closePR(repoPath, prUrl);
1041
+ } catch (error) {
1042
+ closure = {
1043
+ status: null,
1044
+ stderr: error instanceof Error ? error.message : String(error),
1045
+ };
1046
+ }
1047
+ if (closure?.status === 0) return { status: "stopped", branch };
1048
+ }
1049
+ }
1050
+ return {
1051
+ status: "stop-compensation-pending",
1052
+ branch,
1053
+ compensation: {
1054
+ confirmed: false,
1055
+ branchRestored: true,
1056
+ prClosed: false,
1057
+ repairable: true,
1058
+ stderr: closure?.stderr || "pull request closure could not be confirmed",
1059
+ receipt: {
1060
+ ref: lease.ref,
1061
+ headSha: lease.headSha,
1062
+ priorSha: lease.priorSha,
1063
+ prUrl,
1064
+ },
1065
+ },
1066
+ };
1067
+ }
1068
+ return { status: "stopped", branch };
1069
+ }
1070
+
1071
+ async function postFinalReportWithReplay({
1072
+ tool,
1073
+ args,
1074
+ runId,
1075
+ finishStatus,
1076
+ rollbackIfStopped,
1077
+ }) {
1078
+ try {
1079
+ return { result: await tool("post_report", args) };
1080
+ } catch (error) {
1081
+ // A transport can lose the successful response after the server atomically
1082
+ // commits a run-bound final report. Re-read Stop before doing anything:
1083
+ // once the person has stopped the run, compensate shipping rather than
1084
+ // replaying or publishing another report.
1085
+ const stopped = await rollbackIfStopped();
1086
+ if (stopped) return { stopped };
1087
+
1088
+ // Run-bound final reports are exact-replay safe in the server RPC. Reuse
1089
+ // the very same object so run id, finish status, receipt, and card identity
1090
+ // cannot drift between the uncertain attempt and its one replay. Legacy
1091
+ // reports have no atomic run settlement and remain single-attempt.
1092
+ if (!runId || !finishStatus) throw error;
1093
+
1094
+ try {
1095
+ return { result: await tool("post_report", args) };
1096
+ } catch (retryError) {
1097
+ const stoppedAfterRetry = await rollbackIfStopped();
1098
+ if (stoppedAfterRetry) return { stopped: stoppedAfterRetry };
1099
+ throw retryError;
1100
+ }
1101
+ }
1102
+ }
1103
+
1104
+ /**
1105
+ * @param {{ tool: Function, runId: string, expectedIterateTurn: number,
1106
+ * claimId?: string, recoveryStore?: {
1107
+ * persist: (record: { runId: string, expectedIterateTurn: number, claimId: string }) => boolean,
1108
+ * remove: (claimId: string) => boolean
1109
+ * } }} options
1110
+ */
1111
+ export async function claimDaemonIterate({
1112
+ tool,
1113
+ runId,
1114
+ expectedIterateTurn,
1115
+ claimId = randomUUID(),
1116
+ recoveryStore,
1117
+ }) {
1118
+ const record = { runId, expectedIterateTurn, claimId };
1119
+ const args = {
1120
+ runId,
1121
+ status: "running",
1122
+ claimIterate: true,
1123
+ expectedIterateTurn,
1124
+ claimId,
1125
+ };
1126
+ // Rolling upgrade compatibility: the preceding server supported the
1127
+ // idempotent claim token but not exact confirm/release. Without a recovery
1128
+ // store (created only when the full schema is advertised), preserve that
1129
+ // bounded same-token replay byte-for-byte and do not enable later guards.
1130
+ if (!recoveryStore) {
1131
+ try {
1132
+ return await tool("update_run", args);
1133
+ } catch {
1134
+ return await tool("update_run", args);
1135
+ }
1136
+ }
1137
+ if (!recoveryStore.persist(record)) {
1138
+ throw new Error("the Iterate claim recovery record could not be persisted");
1139
+ }
1140
+ let lastError;
1141
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1142
+ try {
1143
+ const result = await tool("update_run", args);
1144
+ if (
1145
+ Number.isSafeInteger(result?.iterateTurn) &&
1146
+ Number(result.iterateTurn) === expectedIterateTurn + 1
1147
+ ) {
1148
+ const claimedTurn = Number(result.iterateTurn);
1149
+ const confirmed = await confirmDaemonIterateClaim({
1150
+ tool,
1151
+ runId,
1152
+ iterateTurn: claimedTurn,
1153
+ claimId,
1154
+ });
1155
+ if (!confirmed) {
1156
+ lastError = new Error("the Iterate claim could not be confirmed");
1157
+ break;
1158
+ }
1159
+ // Keep the exact receipt durable for the whole queue-job lifecycle.
1160
+ // The poller defers reconciliation while this job is active, then
1161
+ // exact-releases (or observes final settlement) before accepting more
1162
+ // work. A crash anywhere before/during the child leaves the receipt for
1163
+ // the next daemon instead of stranding a running generation.
1164
+ return { ...result, claimId };
1165
+ }
1166
+ lastError = new Error("the server did not return the claimed Iterate generation");
1167
+ } catch (error) {
1168
+ lastError = error;
1169
+ }
1170
+ }
1171
+
1172
+ // Neither bounded response proved execution authority. Hand only this exact
1173
+ // token back; a lost release response leaves the durable record for startup.
1174
+ try {
1175
+ await releaseDaemonIterateClaim({ tool, recoveryStore, record });
1176
+ } catch {
1177
+ /* the durable record is the retry */
1178
+ }
1179
+ throw lastError;
1180
+ }
1181
+
1182
+ /**
1183
+ * Prove that this worker still owns one exact Iterate generation. Stop,
1184
+ * release, a final report, or a newer generation all make the replay-safe
1185
+ * confirmation return `not-owned` without changing the run.
1186
+ */
1187
+ export async function confirmDaemonIterateClaim({ tool, runId, iterateTurn, claimId }) {
1188
+ const args = {
1189
+ runId,
1190
+ confirmIterateClaim: true,
1191
+ expectedIterateTurn: iterateTurn,
1192
+ claimId,
1193
+ };
1194
+ for (let attempt = 0; attempt < 2; attempt += 1) {
1195
+ try {
1196
+ const result = await tool("update_run", args);
1197
+ if (
1198
+ result?.iterateClaimKind === "confirmed" &&
1199
+ Number.isSafeInteger(result?.iterateTurn) &&
1200
+ Number(result.iterateTurn) === iterateTurn
1201
+ ) {
1202
+ return true;
1203
+ }
1204
+ return false;
1205
+ } catch {
1206
+ // Read-only and exact-token fenced: one replay is safe. Two missing
1207
+ // responses mean execution authority is unproven, so fail closed.
1208
+ }
1209
+ }
1210
+ return false;
1211
+ }
1212
+
1213
+ export async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, existingPrUrl, settleId, runId, iterateTurn = 0, provenance = {}, usageArgs = {}, shouldContinue }) {
824
1214
  const tag = requesterTag(requester);
825
1215
  const lead = tag ? `${tag} — ` : "";
826
1216
  // `parentId` here is the run's thread root. Terminal outcomes ask the server
@@ -837,6 +1227,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
837
1227
  });
838
1228
  return { status: "nothing-staged", branch };
839
1229
  }
1230
+ if (shouldContinue && !(await shouldContinue())) return { status: "stopped", branch };
840
1231
  const commit = deps.git(repoPath, ["commit", "-m", commitMessage(task, provenance)]);
841
1232
  if (commit.status !== 0) {
842
1233
  await tool("post_message", {
@@ -847,7 +1238,28 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
847
1238
  });
848
1239
  return { status: "commit-failed", branch };
849
1240
  }
850
- const push = deps.git(repoPath, ["push", "-u", "origin", branch]);
1241
+ // Re-check at the irreversible external boundary. The final progress poll
1242
+ // is not enough: Stop can land while the local commit is being created.
1243
+ if (shouldContinue && !(await shouldContinue())) return { status: "stopped", branch };
1244
+ const pushLease = captureRemoteRefLease(deps.git, repoPath, branch);
1245
+ if (!pushLease.ok) {
1246
+ await tool("post_message", {
1247
+ channelId,
1248
+ parentId,
1249
+ broadcast: true,
1250
+ body: `Approved, but hilos refused to push because branch ownership could not be established: ${pushLease.error}`,
1251
+ });
1252
+ return { status: "push-ownership-unknown", branch };
1253
+ }
1254
+ // The remote probe may itself take long enough for Stop to arrive. Do not
1255
+ // let a lease captured before that Stop authorize a later push.
1256
+ if (shouldContinue && !(await shouldContinue())) return { status: "stopped", branch };
1257
+ const push = pushWithRemoteRefLease(
1258
+ deps.git,
1259
+ repoPath,
1260
+ ["push", "-u", "origin", branch],
1261
+ pushLease,
1262
+ );
851
1263
  if (push.status !== 0) {
852
1264
  await tool("post_message", {
853
1265
  channelId,
@@ -857,6 +1269,13 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
857
1269
  });
858
1270
  return { status: "push-failed", branch };
859
1271
  }
1272
+ // A remote ref update is atomic outside hilos, so Stop cannot interrupt a
1273
+ // push already in flight. Fence the next external effect immediately: a
1274
+ // Stop that landed during push must never go on to create/reuse a PR or
1275
+ // publish a completion report.
1276
+ if (shouldContinue && !(await shouldContinue())) {
1277
+ return stopAfterRemoteRefCompensation(deps.git, repoPath, branch, pushLease);
1278
+ }
860
1279
  const { title, body } = prTitleBody(task, branch, provenance);
861
1280
  // Continuing an existing PR (request-changes rework): the push already
862
1281
  // updated it — reuse its URL instead of opening a duplicate. Otherwise open
@@ -864,12 +1283,34 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
864
1283
  const pr = existingPrUrl
865
1284
  ? { ok: true, url: existingPrUrl }
866
1285
  : deps.openPR(repoPath, { title, body, branch, base: cfg.defaultBranch });
1286
+ const rollbackIfStopped = async () => {
1287
+ if (!shouldContinue || (await shouldContinue())) return null;
1288
+ return stopAfterRemoteRefCompensation(deps.git, repoPath, branch, pushLease, {
1289
+ ...(!existingPrUrl && pr.ok && pr.url
1290
+ ? { closePR: deps.closePR, prUrl: pr.url }
1291
+ : {}),
1292
+ });
1293
+ };
1294
+ const stoppedAfterPr = await rollbackIfStopped();
1295
+ if (stoppedAfterPr) return stoppedAfterPr;
867
1296
  // Seamless single card (0289): when a live status message was streaming this
868
1297
  // run, SETTLE the report onto it in place; the workspace setting decides
869
1298
  // whether that final report is also channel-visible. Without a status id
870
1299
  // (older server / status post failed) fall back to posting a fresh report.
871
1300
  const reportArgs = {
872
1301
  channelId,
1302
+ ...(runId ? { runId } : {}),
1303
+ ...(runId ? { iterateTurn } : {}),
1304
+ ...(runId ? { reportRequestId: randomUUID() } : {}),
1305
+ ...(runId
1306
+ ? {
1307
+ finishStatus: pr.ok && pr.url ? "awaiting_review" : "failed",
1308
+ finishBranch: branch,
1309
+ ...(!(pr.ok && pr.url)
1310
+ ? { finishReason: oneLine((pr.stderr || "GitHub did not return a pull request URL").trim(), 300) }
1311
+ : {}),
1312
+ }
1313
+ : {}),
873
1314
  title: `Shipped: ${title}`,
874
1315
  summary:
875
1316
  pr.ok && pr.url
@@ -887,28 +1328,28 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
887
1328
  // 0792 — keep the id of the card this run settled onto, so the caller can
888
1329
  // mark it with the run's transcript. `settleId` is that card when the run
889
1330
  // streamed onto a live status message; otherwise it is the fresh report.
890
- const reportRes = await tool(
891
- "post_report",
892
- settleId
893
- ? { ...reportArgs, messageId: settleId, broadcast: true }
894
- : { ...reportArgs, parentId, broadcast: true },
895
- );
1331
+ const stoppedBeforeReport = await rollbackIfStopped();
1332
+ if (stoppedBeforeReport) return stoppedBeforeReport;
1333
+ const finalReportArgs = settleId
1334
+ ? { ...reportArgs, messageId: settleId, broadcast: true }
1335
+ : { ...reportArgs, parentId, broadcast: true };
1336
+ const reportAttempt = await postFinalReportWithReplay({
1337
+ tool,
1338
+ args: finalReportArgs,
1339
+ runId,
1340
+ finishStatus: reportArgs.finishStatus,
1341
+ rollbackIfStopped,
1342
+ });
1343
+ if (reportAttempt.stopped) return reportAttempt.stopped;
1344
+ const reportRes = reportAttempt.result;
1345
+ const stoppedAfterReport = await rollbackIfStopped();
1346
+ if (stoppedAfterReport) return stoppedAfterReport;
896
1347
  const reportMsgId = settleId || reportRes?.messageId || null;
897
1348
  // Work produced a PR → attach it to the channel so its live pill shows up.
898
1349
  await linkPrFromUrl({ tool, channelId, url: pr.ok ? pr.url : null });
899
- // Bind the PR to the thread's run (0279/0280) so a follow-up continues it.
900
- // Best-effort; only when a run was recorded (caps.runs on this server).
901
- if (runId) {
902
- await tool("update_run", {
903
- runId,
904
- ...(pr.ok && pr.url ? { prUrl: pr.url } : {}),
905
- status: "awaiting_review",
906
- // 0792: record the card even when it was a fresh report rather than a
907
- // settle — it is the run's card either way, and the transcript upload
908
- // falls back to this exact field when it is not handed an id.
909
- ...(reportMsgId ? { reportMsgId } : {}),
910
- }).catch(() => {});
911
- }
1350
+ // The run gate, PR/branch receipt, and report pointer were committed by
1351
+ // post_report under one run-row lock. A second update_run here used to
1352
+ // leave a window where the card existed while Stop still looked current.
912
1353
  return { status: "pushed", branch, prUrl: pr.ok ? pr.url : null, reportMsgId };
913
1354
  }
914
1355
 
@@ -949,14 +1390,34 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
949
1390
  * switched to `main` would otherwise make `gh` try to open main → main. Used
950
1391
  * only when NOT gated (bias-to-action).
951
1392
  */
952
- async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, settleId, runId, provenance = {}, usageArgs = {} }) {
1393
+ export async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, settleId, runId, iterateTurn = 0, provenance = {}, usageArgs = {}, shouldContinue }) {
953
1394
  const tag = requesterTag(requester);
954
1395
  const lead = tag ? `${tag} — ` : "";
955
1396
  const currentBranch =
956
1397
  (deps.git(repoPath, ["symbolic-ref", "--quiet", "--short", "HEAD"]).stdout || "").trim();
957
1398
  const ship = selfDrivenShipPlan(currentBranch, branch);
958
- const push = deps.git(repoPath, ship.pushArgs);
959
- let prUrl = deps.findPR ? deps.findPR(repoPath, ship.headBranch) : null;
1399
+ if (shouldContinue && !(await shouldContinue())) return { status: "stopped", branch };
1400
+ const pushLease = captureRemoteRefLease(deps.git, repoPath, ship.headBranch);
1401
+ if (pushLease.ok && shouldContinue && !(await shouldContinue())) {
1402
+ return { status: "stopped", branch };
1403
+ }
1404
+ const push = pushLease.ok
1405
+ ? pushWithRemoteRefLease(deps.git, repoPath, ship.pushArgs, pushLease)
1406
+ : {
1407
+ status: 1,
1408
+ stdout: "",
1409
+ stderr: `hilos refused to push because branch ownership could not be established: ${pushLease.error}`,
1410
+ };
1411
+ if (shouldContinue && !(await shouldContinue())) {
1412
+ if (push.status === 0 && pushLease.ok) {
1413
+ return stopAfterRemoteRefCompensation(deps.git, repoPath, branch, pushLease);
1414
+ }
1415
+ return { status: "stopped", branch };
1416
+ }
1417
+ // A failed/declined push cannot ship an existing PR either: its remote head
1418
+ // is not this run's verified commit.
1419
+ let prUrl = push.status === 0 && deps.findPR ? deps.findPR(repoPath, ship.headBranch) : null;
1420
+ const existingPrUrl = prUrl;
960
1421
  let prAttempt = null;
961
1422
  if (!prUrl && push.status === 0) {
962
1423
  const { title, body } = prTitleBody(task, ship.headBranch, provenance);
@@ -968,6 +1429,16 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
968
1429
  });
969
1430
  prUrl = prAttempt.ok && prAttempt.url ? prAttempt.url : null;
970
1431
  }
1432
+ const rollbackIfStopped = async () => {
1433
+ if (!shouldContinue || (await shouldContinue())) return null;
1434
+ return push.status === 0 && pushLease.ok
1435
+ ? stopAfterRemoteRefCompensation(deps.git, repoPath, branch, pushLease, {
1436
+ ...(!existingPrUrl && prUrl ? { closePR: deps.closePR, prUrl } : {}),
1437
+ })
1438
+ : { status: "stopped", branch };
1439
+ };
1440
+ const stoppedAfterPr = await rollbackIfStopped();
1441
+ if (stoppedAfterPr) return stoppedAfterPr;
971
1442
  const { title } = prTitleBody(task, ship.headBranch, provenance);
972
1443
  const recoveredNote = ship.recoveredFrom
973
1444
  ? ` The child had switched to \`${ship.recoveredFrom}\`; hilos recovered its HEAD onto \`${ship.headBranch}\`.`
@@ -984,6 +1455,23 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
984
1455
  // Otherwise post a fresh final report.
985
1456
  const reportArgs = {
986
1457
  channelId,
1458
+ ...(runId ? { runId } : {}),
1459
+ ...(runId ? { iterateTurn } : {}),
1460
+ ...(runId ? { reportRequestId: randomUUID() } : {}),
1461
+ ...(runId
1462
+ ? {
1463
+ finishStatus: prUrl ? "awaiting_review" : "failed",
1464
+ finishBranch: ship.headBranch,
1465
+ ...(!prUrl
1466
+ ? {
1467
+ finishReason:
1468
+ push.status !== 0
1469
+ ? oneLine((push.stderr || "git push failed").trim(), 300)
1470
+ : prFailure || "GitHub did not return a pull request URL",
1471
+ }
1472
+ : {}),
1473
+ }
1474
+ : {}),
987
1475
  title: prUrl
988
1476
  ? `Shipped: ${title}`
989
1477
  : push.status === 0
@@ -1004,32 +1492,26 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
1004
1492
  // 0787 — an autonomous run's receipt rides its one and only report.
1005
1493
  ...usageArgs,
1006
1494
  };
1007
- const reportRes = await tool(
1008
- "post_report",
1009
- settleId
1010
- ? { ...reportArgs, messageId: settleId, broadcast: true }
1011
- : { ...reportArgs, parentId, broadcast: true },
1012
- );
1495
+ const stoppedBeforeReport = await rollbackIfStopped();
1496
+ if (stoppedBeforeReport) return stoppedBeforeReport;
1497
+ const finalReportArgs = settleId
1498
+ ? { ...reportArgs, messageId: settleId, broadcast: true }
1499
+ : { ...reportArgs, parentId, broadcast: true };
1500
+ const reportAttempt = await postFinalReportWithReplay({
1501
+ tool,
1502
+ args: finalReportArgs,
1503
+ runId,
1504
+ finishStatus: reportArgs.finishStatus,
1505
+ rollbackIfStopped,
1506
+ });
1507
+ if (reportAttempt.stopped) return reportAttempt.stopped;
1508
+ const reportRes = reportAttempt.result;
1509
+ const stoppedAfterReport = await rollbackIfStopped();
1510
+ if (stoppedAfterReport) return stoppedAfterReport;
1013
1511
  // 0792 — the card this run settled onto, for the transcript stamp.
1014
1512
  const reportMsgId = settleId || reportRes?.messageId || null;
1015
1513
  if (prUrl) await linkPrFromUrl({ tool, channelId, url: prUrl });
1016
- // Bind the PR to the thread's run (0279/0280). Best-effort; only when recorded.
1017
- if (runId) {
1018
- await tool("update_run", {
1019
- runId,
1020
- ...(prUrl ? { prUrl } : {}),
1021
- status: prUrl ? "awaiting_review" : "failed",
1022
- ...(!prUrl
1023
- ? {
1024
- reason:
1025
- push.status !== 0
1026
- ? oneLine((push.stderr || "git push failed").trim(), 300)
1027
- : prFailure || "GitHub did not return a pull request URL",
1028
- }
1029
- : {}),
1030
- ...(reportMsgId ? { reportMsgId } : {}),
1031
- }).catch(() => {});
1032
- }
1514
+ // post_report atomically bound this card, branch/PR receipt, and final gate.
1033
1515
  const status =
1034
1516
  push.status !== 0 ? "commit-local" : prUrl ? "pushed" : "pr-failed";
1035
1517
  return {
@@ -1117,10 +1599,21 @@ export function dmJudgmentBlock(implicitDm) {
1117
1599
  * `error` is set when the model produced nothing (so the caller can be honest
1118
1600
  * about a timeout vs a missing binary instead of inventing a reply).
1119
1601
  */
1120
- export async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal, hasActiveRun = false, runCliFn, implicitDm = false }) {
1121
- const doRun = runCliFn || runCli; // folder mode injects deps.runCli; repo flow uses the import
1602
+ export async function routeIntent({
1603
+ name,
1604
+ repoFullName,
1605
+ transcript,
1606
+ workspaceMemory,
1607
+ cfg,
1608
+ signal,
1609
+ hasActiveRun = false,
1610
+ runCliFn,
1611
+ implicitDm = false,
1612
+ cwd = /** @type {string | undefined} */ (undefined),
1613
+ }) {
1614
+ const doRun = runCliFn || runCli;
1122
1615
  const cmd = chatCmdFor(cfg);
1123
- const parts = cmd.split(" ").filter(Boolean);
1616
+ const parts = commandArgv(cmd);
1124
1617
  // When this thread already owns an OPEN pull request (a follow-up reply), the
1125
1618
  // router ALSO reads whether the latest message continues that PR, wants a
1126
1619
  // separate one, or is unclear — the primary signal for same-PR continuation
@@ -1160,6 +1653,7 @@ export async function routeIntent({ name, repoFullName, transcript, workspaceMem
1160
1653
  timeoutMs: cfg.chatTimeoutMs || cfg.runTimeoutMs,
1161
1654
  label: "thinking",
1162
1655
  signal,
1656
+ cwd,
1163
1657
  });
1164
1658
  if (run.aborted || signal?.aborted) return { aborted: true };
1165
1659
  const raw = (run.stdout || "").trim();
@@ -1342,18 +1836,29 @@ export function planAckPrompt(o) {
1342
1836
  * CLI. Bounded (≤45s) + best-effort: returns trimmed text, or null on
1343
1837
  * empty/timeout/error so the run keeps the instant template and never stalls.
1344
1838
  */
1345
- async function proposePlanAck({ task, transcript, repoFullName, cfg, signal }) {
1839
+ export async function proposePlanAck({
1840
+ task,
1841
+ transcript,
1842
+ repoFullName,
1843
+ cfg,
1844
+ signal,
1845
+ cwd = /** @type {string | undefined} */ (undefined),
1846
+ beforeSpawn = /** @type {(() => boolean | Promise<boolean>) | undefined} */ (undefined),
1847
+ runCliFn = runCli,
1848
+ }) {
1346
1849
  const cmd = chatCmdFor(cfg);
1347
1850
  if (!cmd) return null;
1348
- const parts = cmd.split(" ").filter(Boolean);
1851
+ const parts = commandArgv(cmd);
1349
1852
  const prompt = planAckPrompt({ task, transcript, repoFullName });
1350
- const run = await runCli({
1853
+ const run = await runCliFn({
1351
1854
  cmd: parts[0],
1352
1855
  args: [...parts.slice(1), prompt],
1353
1856
  timeoutMs: Math.min(cfg.chatTimeoutMs || 90000, 45000),
1354
1857
  label: "thinking",
1355
1858
  signal,
1859
+ cwd,
1356
1860
  env: codingChildEnv(cfg),
1861
+ beforeSpawn,
1357
1862
  });
1358
1863
  if (run.aborted || signal?.aborted) return null;
1359
1864
  const text = (run.stdout || "").trim();
@@ -1372,7 +1877,21 @@ async function reactQuietly({ tool, caps, message, emoji }) {
1372
1877
  }
1373
1878
 
1374
1879
  /** Run the FAST chat CLI to produce a reply, using recent channel context. */
1375
- async function respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps = {} }) {
1880
+ async function respondConversationally({
1881
+ message,
1882
+ channelId,
1883
+ tool,
1884
+ me,
1885
+ cfg,
1886
+ repoLink,
1887
+ parentId,
1888
+ workspaceMemory,
1889
+ signal,
1890
+ context,
1891
+ caps = {},
1892
+ runCliFn = runCli,
1893
+ pathExistsFn = existsSync,
1894
+ }) {
1376
1895
  const implicitDm = message?.implicit === "dm";
1377
1896
  const { transcript } = context || (await fetchContext({ channelId, tool, parentId }));
1378
1897
  const name = me?.agentName || "an assistant";
@@ -1381,10 +1900,20 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
1381
1900
  // "repository" line never claims a folder path is a repo. When there's no repo
1382
1901
  // but this channel maps to a local folder on THIS machine, say that instead.
1383
1902
  const folderPath = !repoLink ? resolveFolderPath(cfg, channelId) : null;
1903
+ const repoPath = repoLink ? resolveRepoPath(cfg, repoLink.repo_full_name) : null;
1904
+ const mappedProjectPath = repoPath || folderPath || null;
1905
+ const projectCwd =
1906
+ mappedProjectPath && pathExistsFn(mappedProjectPath)
1907
+ ? mappedProjectPath
1908
+ : undefined;
1384
1909
  const repoLine = repoLink
1385
- ? `This channel is connected to the repository ${repoLink.repo_full_name}; assume that repo for any code work — don't ask which one.`
1910
+ ? repoPath && !projectCwd
1911
+ ? `This channel is connected to the repository ${repoLink.repo_full_name}, but its configured local checkout ${repoPath} is unavailable on this machine. Answer from the conversation and don't claim you inspected it.`
1912
+ : `This channel is connected to the repository ${repoLink.repo_full_name}; assume that repo for any code work — don't ask which one.`
1386
1913
  : folderPath
1387
- ? `This channel is connected to the local folder ${folderPath} on this machine; assume that folder for any file work — don't ask which one.`
1914
+ ? projectCwd
1915
+ ? `This channel is connected to the local folder ${folderPath} on this machine; assume that folder for any file work — don't ask which one.`
1916
+ : `This channel is mapped to the local folder ${folderPath}, but that folder is unavailable on this machine. Answer from the conversation and don't claim you inspected it.`
1388
1917
  : `If asked to change code, note that a repo isn't linked to this channel yet.`;
1389
1918
  const prompt =
1390
1919
  `You are ${name}, a teammate in a team chat (hilos). Reply to the latest message ` +
@@ -1398,7 +1927,7 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
1398
1927
  // chatCmd is unset) bounded by chatTimeoutMs, so a casual reply lands in seconds
1399
1928
  // and a stalled model can't dead-air the channel for the full coding timeout.
1400
1929
  const cmd = chatCmdFor(cfg);
1401
- const parts = cmd.split(" ").filter(Boolean);
1930
+ const parts = commandArgv(cmd);
1402
1931
  const chatVendor = detectVendor(cmd);
1403
1932
  const webArgs = chatWebArgs(chatVendor, parts.slice(1), {
1404
1933
  enabled: cfg.webSearch !== false,
@@ -1431,12 +1960,13 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
1431
1960
 
1432
1961
  let run;
1433
1962
  try {
1434
- run = await runCli({
1963
+ run = await runCliFn({
1435
1964
  cmd: parts[0],
1436
1965
  args: [...parts.slice(1), ...webArgs, prompt],
1437
1966
  timeoutMs: cfg.chatTimeoutMs || cfg.runTimeoutMs,
1438
1967
  label: "thinking",
1439
1968
  signal,
1969
+ cwd: projectCwd,
1440
1970
  env: codingChildWebEnv(cfg, chatVendor),
1441
1971
  });
1442
1972
  } finally {
@@ -1625,7 +2155,7 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
1625
2155
  // stdout and never stages/commits anything. Review uses the fast chat command
1626
2156
  // when set (no acceptEdits → can't write), falling back to the coding command.
1627
2157
  const cmd = cfg.reviewCmd || chatCmdFor(cfg);
1628
- const parts = String(cmd).split(" ").filter(Boolean);
2158
+ const parts = commandArgv(String(cmd));
1629
2159
  let sandboxDir = null;
1630
2160
  try {
1631
2161
  sandboxDir = mkdtempSync(join(tmpdir(), "hilos-review-"));
@@ -2022,7 +2552,7 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
2022
2552
  // Run the coding CLI in the folder, streaming progress + honoring timeouts and
2023
2553
  // the abort signal (reuses runCli via deps + the progress-emitter machinery).
2024
2554
  const runFolderCli = async (promptText) => {
2025
- const parts = cfg.codingCmd.split(" ").filter(Boolean);
2555
+ const parts = commandArgv(cfg.codingCmd);
2026
2556
  const vendor = detectVendor(cfg.codingCmd);
2027
2557
  const streamOn = Boolean(caps.postProgress);
2028
2558
  const streamArgs = streamOn ? codeStreamArgs(vendor) : [];
@@ -2556,7 +3086,7 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
2556
3086
 
2557
3087
  /** Handle one task. cfg/deps injectable for tests. `opts.signal` (AbortSignal)
2558
3088
  * cancels an in-flight run — the queue fires it when a human says "stop". */
2559
- export async function handleTask({ message, channelId, tool, me, caps = {} }, cfg, depsOverride, opts = {}) {
3089
+ export async function handleTask({ message, channelId, tool, me, caps = {}, iterateClaimRecoveryStore = null }, cfg, depsOverride, opts = {}) {
2560
3090
  if (message.dispatch?.briefMarkdown) {
2561
3091
  message = { ...message, body: `${message.body}\n\n${message.dispatch.briefMarkdown}` };
2562
3092
  }
@@ -2572,12 +3102,32 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2572
3102
  // 0860 — an untagged DM wake: addressed by the room, not by an @-handle. The
2573
3103
  // one context where the router/chat prompts offer the quiet option.
2574
3104
  const implicitDm = message.implicit === "dm";
3105
+ // Set only when merge_pr returned the typed 0991 recovery hand-off. It turns
3106
+ // the same human merge instruction into an existing-PR coding turn — no
3107
+ // second prompt and no semantic router are allowed between those two facts.
3108
+ // A report-card click reaches the same slot through signed list_mentions
3109
+ // metadata instead of first calling merge_pr in this process.
3110
+ let forcedConflictRecovery = null;
2575
3111
 
2576
3112
  const { links = [] } = await tool("get_links", { channelId }).catch(() => ({ links: [] }));
2577
3113
  // A folder link (0324) carries the folder PATH in repo_full_name for display —
2578
3114
  // it is NOT a GitHub repo, so it must never be picked as the channel's repo.
2579
3115
  // Exclude kind='folder'; pr/branch/repo rows with repo_full_name still count.
2580
3116
  const repoLink = links.find((l) => l.kind !== "folder" && l.repo_full_name);
3117
+ if (message.conflictRecovery) {
3118
+ forcedConflictRecovery = validConflictRecovery(
3119
+ message.conflictRecovery,
3120
+ repoLink?.repo_full_name,
3121
+ );
3122
+ if (!forcedConflictRecovery) {
3123
+ await tool("post_message", {
3124
+ channelId,
3125
+ parentId,
3126
+ body: "I couldn't verify the conflict-repair target, so I left the repository unchanged.",
3127
+ }).catch(() => {});
3128
+ return { status: "conflict-target-refused" };
3129
+ }
3130
+ }
2581
3131
  // Workspace memory ("soul") — shared project context the agent should know.
2582
3132
  const { memory: workspaceMemory = null } = await tool("get_workspace_memory", {}).catch(() => ({
2583
3133
  memory: null,
@@ -2634,7 +3184,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2634
3184
  mode,
2635
3185
  // A manager-routed dispatch is a brief to implement, not an instruction to
2636
3186
  // land something — its body is assembled text, never a person's sentence.
2637
- canRelay: Boolean(caps.prActions) && !message.dispatch,
3187
+ canRelay: Boolean(caps.prActions) && !message.dispatch && !forcedConflictRecovery,
2638
3188
  });
2639
3189
  if (relay.relay) {
2640
3190
  const verb = relay.action;
@@ -2650,7 +3200,15 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2650
3200
  if (reply.post) {
2651
3201
  await tool("post_message", { channelId, parentId, body: reply.post }).catch(() => {});
2652
3202
  }
2653
- return { status: reply.ok ? `pr-${verb}d` : "pr-action-refused", action: verb };
3203
+ const recovery = validConflictRecovery(reply.conflict, repoLink?.repo_full_name);
3204
+ if (verb === "merge" && recovery) {
3205
+ forcedConflictRecovery = recovery;
3206
+ console.log(
3207
+ ` merge → PR #${recovery.prNumber} conflicts with ${recovery.baseBranch}; continuing as a repair run`,
3208
+ );
3209
+ } else {
3210
+ return { status: reply.ok ? `pr-${verb}d` : "pr-action-refused", action: verb };
3211
+ }
2654
3212
  }
2655
3213
  }
2656
3214
 
@@ -2660,6 +3218,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2660
3218
  if (!repoLink) {
2661
3219
  const folderPath = resolveFolderPath(cfg, channelId);
2662
3220
  if (folderPath) {
3221
+ const folderCwd = deps.pathExists(folderPath) ? folderPath : undefined;
2663
3222
  // Durable report-card fallback: list_mentions projects a still-pending
2664
3223
  // in-place decision into the queue. Deploy only while that decision
2665
3224
  // remains pending; a settled report is a silent no-op, which prevents a
@@ -2756,15 +3315,16 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2756
3315
  // CLI router. There is still no keyword/regex intent list.
2757
3316
  if (!routed) {
2758
3317
  routed = await routeIntent({
2759
- name: me?.agentName || "an assistant",
2760
- repoFullName: folderPath,
2761
- transcript: context.transcript,
2762
- workspaceMemory,
2763
- cfg,
2764
- signal,
2765
- runCliFn: deps.runCli,
2766
- implicitDm,
2767
- });
3318
+ name: me?.agentName || "an assistant",
3319
+ repoFullName: folderPath,
3320
+ transcript: context.transcript,
3321
+ workspaceMemory,
3322
+ cfg,
3323
+ signal,
3324
+ runCliFn: deps.runCli,
3325
+ implicitDm,
3326
+ cwd: folderCwd,
3327
+ });
2768
3328
  }
2769
3329
  if (routed.aborted || signal?.aborted) {
2770
3330
  await tool("post_message", { channelId, parentId, body: "Stopped." });
@@ -2795,13 +3355,46 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2795
3355
  // Not a coding task → the classifier is finished. The separate responder
2796
3356
  // below owns the answer and keeps its normal tools.
2797
3357
  }
2798
- await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps });
3358
+ await respondConversationally({
3359
+ message,
3360
+ channelId,
3361
+ tool,
3362
+ me,
3363
+ cfg,
3364
+ repoLink,
3365
+ parentId,
3366
+ workspaceMemory,
3367
+ signal,
3368
+ context,
3369
+ caps,
3370
+ runCliFn: deps.runCli,
3371
+ pathExistsFn: deps.pathExists,
3372
+ });
2799
3373
  return { status: "chat" };
2800
3374
  }
2801
3375
  const repoFullName = repoLink.repo_full_name;
3376
+ const mappedRepoPath = resolveRepoPath(cfg, repoFullName);
3377
+ const mappedRepoCwd =
3378
+ mappedRepoPath && deps.pathExists(mappedRepoPath)
3379
+ ? mappedRepoPath
3380
+ : undefined;
2802
3381
 
2803
- if (mode === "ask") {
2804
- await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps });
3382
+ if (mode === "ask" && !forcedConflictRecovery) {
3383
+ await respondConversationally({
3384
+ message,
3385
+ channelId,
3386
+ tool,
3387
+ me,
3388
+ cfg,
3389
+ repoLink,
3390
+ parentId,
3391
+ workspaceMemory,
3392
+ signal,
3393
+ context,
3394
+ caps,
3395
+ runCliFn: deps.runCli,
3396
+ pathExistsFn: deps.pathExists,
3397
+ });
2805
3398
  return { status: "chat" };
2806
3399
  }
2807
3400
 
@@ -2832,7 +3425,13 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2832
3425
  // otherwise), so telling the router the thread "owns an open PR" when the run is
2833
3426
  // merged/closed would just mislead the model and waste a classification.
2834
3427
  const routed =
2835
- mode === "ship"
3428
+ forcedConflictRecovery
3429
+ ? {
3430
+ code: true,
3431
+ task: conflictRepairBrief(forcedConflictRecovery),
3432
+ followupSignal: "change",
3433
+ }
3434
+ : mode === "ship"
2836
3435
  ? { code: true, task: message.body, followupSignal: null }
2837
3436
  : await routeIntent({
2838
3437
  name: me?.agentName || "an assistant",
@@ -2842,7 +3441,9 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2842
3441
  cfg,
2843
3442
  signal,
2844
3443
  hasActiveRun: activeRunOpen,
3444
+ runCliFn: deps.runCli,
2845
3445
  implicitDm,
3446
+ cwd: mappedRepoCwd,
2846
3447
  });
2847
3448
  if (routed.aborted || signal?.aborted) {
2848
3449
  await tool("post_message", { channelId, parentId, body: "Stopped." });
@@ -2865,6 +3466,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2865
3466
  signal,
2866
3467
  context,
2867
3468
  caps,
3469
+ runCliFn: deps.runCli,
3470
+ pathExistsFn: deps.pathExists,
2868
3471
  });
2869
3472
  return { status: "chat" };
2870
3473
  }
@@ -2884,7 +3487,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2884
3487
  signal: followupSignal,
2885
3488
  });
2886
3489
 
2887
- let repoPath = resolveRepoPath(cfg, repoFullName);
3490
+ let repoPath = mappedRepoPath;
2888
3491
  if (!repoPath) {
2889
3492
  // Zero-config path: if the daemon is running INSIDE a checkout of this repo
2890
3493
  // (its origin matches), just use the current directory — no repos map needed.
@@ -2958,6 +3561,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2958
3561
  }
2959
3562
  };
2960
3563
 
3564
+ let activeConflictMerge = null;
2961
3565
  try {
2962
3566
  git(repoPath, ["fetch", "origin", cfg.defaultBranch]);
2963
3567
 
@@ -2974,7 +3578,9 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2974
3578
  // settled, still amends THAT PR. Same shape as the server's own anchor
2975
3579
  // fallback, and it never overrides 1 or 2.
2976
3580
  // Otherwise, a fresh branch off the default (today's behavior).
2977
- const cont = detectPrContinuation(message.body, repoFullName);
3581
+ const cont = forcedConflictRecovery
3582
+ ? null
3583
+ : detectPrContinuation(message.body, repoFullName);
2978
3584
  const runIterateUrl = (followupMode === "iterate" && activeRun?.prUrl) || null;
2979
3585
  // The anchor is a HYPOTHESIS (its recorded state may be stale or unknown);
2980
3586
  // `gh` is the proof. No proof, no adoption — the run cuts a fresh branch and
@@ -3011,7 +3617,10 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3011
3617
  anchorNote = staleAnchorNote({ reason: decision.reason, anchor: decision.anchor });
3012
3618
  }
3013
3619
  }
3014
- const iterateUrl = cont?.url || runIterateUrl || anchorAdopt?.prUrl || null;
3620
+ // A typed conflict target is stronger than heuristic text/run/anchor
3621
+ // evidence: GitHub just returned this exact live PR head to merge_pr.
3622
+ const iterateUrl =
3623
+ forcedConflictRecovery?.prUrl || cont?.url || runIterateUrl || anchorAdopt?.prUrl || null;
3015
3624
  let branch = null;
3016
3625
  let continuingPrUrl = null;
3017
3626
  if (iterateUrl && (deps.prHeadRef || anchorAdopt)) {
@@ -3019,6 +3628,9 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3019
3628
  // run's recorded branch is a fallback if gh can't view the PR. An adopted
3020
3629
  // anchor already has its head branch straight from the live PR.
3021
3630
  const headRef =
3631
+ (forcedConflictRecovery?.prUrl === iterateUrl
3632
+ ? forcedConflictRecovery.branch
3633
+ : null) ||
3022
3634
  (anchorAdopt?.prUrl === iterateUrl ? anchorAdopt.branch : null) ||
3023
3635
  deps.prHeadRef?.(repoPath, iterateUrl) ||
3024
3636
  (followupMode === "iterate" ? activeRun?.branch || null : null);
@@ -3047,6 +3659,14 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3047
3659
  }
3048
3660
  }
3049
3661
  }
3662
+ if (forcedConflictRecovery && !continuingPrUrl) {
3663
+ await tool("post_message", {
3664
+ channelId,
3665
+ parentId,
3666
+ body: `PR #${forcedConflictRecovery.prNumber} needs conflict repair, but I couldn't check out its existing branch \`${forcedConflictRecovery.branch}\`. I left the pull request unchanged.`,
3667
+ });
3668
+ return { status: "conflict-checkout-failed" };
3669
+ }
3050
3670
  // An explicit PR token (detectPrContinuation) that names a DIFFERENT PR than the
3051
3671
  // thread's active run must NOT reuse/update that run for the terminal
3052
3672
  // update_run/report: we push to the TOKEN's PR (via existingPrUrl below), so
@@ -3079,6 +3699,41 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3079
3699
  // self-committed".
3080
3700
  const baseRef = (git(repoPath, ["rev-parse", "HEAD"]).stdout || "").trim() || (startRef.ref || cfg.defaultBranch);
3081
3701
 
3702
+ // 0991 — own the Git transition OUTSIDE the model. A merge conflict is a
3703
+ // prepared worktree state, not prose in a prompt. Only after this succeeds do
3704
+ // we post an ack or launch the coding CLI.
3705
+ if (forcedConflictRecovery) {
3706
+ const started = startConflictMerge({
3707
+ git,
3708
+ cwd: repoPath,
3709
+ conflict: forcedConflictRecovery,
3710
+ });
3711
+ if (!started.ok) {
3712
+ await tool("post_message", {
3713
+ channelId,
3714
+ parentId,
3715
+ body: `PR #${forcedConflictRecovery.prNumber} conflicts with \`${forcedConflictRecovery.baseBranch}\`, but I couldn't prepare the repair checkout: ${started.reason} The pull request branch is unchanged.`,
3716
+ });
3717
+ return { status: "conflict-setup-failed" };
3718
+ }
3719
+ if (started.state === "up-to-date") {
3720
+ // The synthetic repair row is durable execution authority, but it is not
3721
+ // a second merge approval. Never cite its compact display body to
3722
+ // `merge_pr`; ask for a fresh click after GitHub recomputes mergeability.
3723
+ await tool("post_message", {
3724
+ channelId,
3725
+ parentId,
3726
+ body: `PR #${forcedConflictRecovery.prNumber} already contains \`${forcedConflictRecovery.baseBranch}\`. GitHub had still refused the merge, so I left it unchanged. Review its current status and approve it again if it is ready.`,
3727
+ }).catch(() => {});
3728
+ return { status: "conflict-already-current", action: "merge" };
3729
+ }
3730
+ activeConflictMerge = {
3731
+ baseBranch: forcedConflictRecovery.baseBranch,
3732
+ files: started.files,
3733
+ prNumber: forcedConflictRecovery.prNumber,
3734
+ };
3735
+ }
3736
+
3082
3737
  // Instant acknowledgement: post a template in <1s so the channel shows life
3083
3738
  // before any model call. If the server supports edit_message and a fast chatCmd
3084
3739
  // is set, a bounded plan-ack edits in a sentence of specifics ("I'll do X…").
@@ -3089,7 +3744,9 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3089
3744
  // routing before any code lands. This is a key safety mitigation for a wrong
3090
3745
  // 'iterate'. A plain new run keeps the standard template.
3091
3746
  let ackBody = ackText(repoFullName);
3092
- if (continuingPrUrl) {
3747
+ if (activeConflictMerge) {
3748
+ ackBody = `PR #${activeConflictMerge.prNumber} conflicts with \`${activeConflictMerge.baseBranch}\`. I started the merge on \`${branch}\` and I'm resolving it now; the updated PR will come back for review.`;
3749
+ } else if (continuingPrUrl) {
3093
3750
  const n = prNumberFromUrl(continuingPrUrl);
3094
3751
  ackBody = `Continuing ${n ? `PR #${n}` : "the existing pull request"} on \`${branch}\` — I'll push this change there and update the report.`;
3095
3752
  } else if (effectiveMode === "redirect") {
@@ -3124,6 +3781,16 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3124
3781
  // Best-effort throughout — a bookkeeping failure (or an older server without the
3125
3782
  // runs tools) must NEVER break the run, so it proceeds without a runId.
3126
3783
  let runId = message.dispatch?.runId ?? (effectiveMode === "iterate" ? activeRun?.runId ?? null : null);
3784
+ // Immutable generation for every report attempt in this daemon turn. A reused
3785
+ // run claims the next value atomically below; fresh and dispatch-adopted rows
3786
+ // begin on the server's current value (normally zero).
3787
+ let iterateTurn = Number.isSafeInteger(message.dispatch?.iterateTurn)
3788
+ ? Number(message.dispatch.iterateTurn)
3789
+ : Number.isSafeInteger(activeRun?.iterateTurn)
3790
+ ? Number(activeRun.iterateTurn)
3791
+ : 0;
3792
+ let iterateClaimId = null;
3793
+ let iterateAuthorityLost = false;
3127
3794
  if (message.dispatch?.runId && caps.runs) {
3128
3795
  // Adopt the dispatch's canonical run. If the server already SETTLED it (the
3129
3796
  // reaper/outbox closed a dispatch we were slow to pick up), leave it alone
@@ -3131,17 +3798,51 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3131
3798
  // waiting on. Any OTHER failure (network, an older server without the guard)
3132
3799
  // keeps today's best-effort behavior: proceed with the run.
3133
3800
  try {
3134
- await tool("update_run", { runId: message.dispatch.runId, status: "running" });
3801
+ const adopted = await tool("update_run", { runId: message.dispatch.runId, status: "running" });
3802
+ if (Number.isSafeInteger(adopted?.iterateTurn)) iterateTurn = Number(adopted.iterateTurn);
3135
3803
  } catch (e) {
3136
- if (String(e?.message || "").includes("already settled")) {
3137
- await tool("post_message", {
3138
- channelId,
3139
- parentId,
3140
- body: "This dispatch was already closed on the server, so I left it alone. Approve it again if it's still wanted.",
3141
- }).catch(() => {});
3142
- return { status: "dispatch-settled" };
3804
+ const alreadySettled = String(e?.message || "").includes("already settled");
3805
+ await tool("post_message", {
3806
+ channelId,
3807
+ parentId,
3808
+ body: alreadySettled
3809
+ ? "This dispatch was already closed on the server, so I left it alone. Approve it again if it's still wanted."
3810
+ : "I could not confirm this dispatch's current run generation, so I did not start or ship it. Try the dispatch again.",
3811
+ }).catch(() => {});
3812
+ // A dispatch already names the canonical run. Continuing after an
3813
+ // uncertain adoption would report with a guessed turn (usually zero)
3814
+ // and can push before the exact-generation fence refuses the card.
3815
+ return {
3816
+ status: alreadySettled ? "dispatch-settled" : "dispatch-adoption-uncertain",
3817
+ runId: message.dispatch.runId,
3818
+ };
3819
+ }
3820
+ }
3821
+ if (caps.runs && effectiveMode === "iterate" && runId && !message.dispatch?.runId) {
3822
+ try {
3823
+ const claimed = await claimDaemonIterate({
3824
+ tool,
3825
+ runId,
3826
+ expectedIterateTurn: iterateTurn,
3827
+ recoveryStore: iterateClaimRecoveryStore,
3828
+ });
3829
+ const claimedTurn = claimed?.iterateTurn;
3830
+ if (!Number.isSafeInteger(claimedTurn) || Number(claimedTurn) !== iterateTurn + 1) {
3831
+ throw new Error("the server did not return the claimed Iterate generation");
3143
3832
  }
3144
- /* older server or a transient error — proceed best-effort */
3833
+ iterateTurn = Number(claimedTurn);
3834
+ iterateClaimId = claimed.claimId;
3835
+ } catch {
3836
+ // Unlike optional bookkeeping, the generation claim is execution
3837
+ // authority. Continuing after losing it would let two daemons ship one
3838
+ // reused PR and let an old report settle the winner's newer work.
3839
+ await tool("post_message", {
3840
+ channelId,
3841
+ parentId: threadRoot,
3842
+ broadcast: true,
3843
+ body: "This Iterate turn was already claimed or left its review gate, so I did not run or ship it. Re-mention me from the current report if the change is still needed.",
3844
+ }).catch(() => {});
3845
+ return { status: "iterate-claim-lost", runId };
3145
3846
  }
3146
3847
  }
3147
3848
  if (caps.runs && threadRoot && effectiveMode !== "iterate" && !message.dispatch?.runId) {
@@ -3166,12 +3867,48 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3166
3867
  const v = detectVendor(cfg.codingCmd);
3167
3868
  return v === "unknown" ? null : v;
3168
3869
  })(),
3870
+ // 1182 — this daemon takes a room direction at its next turn: it reads
3871
+ // `pendingInputs` off its heartbeat and runs one more CLI turn with
3872
+ // them after the coding turn. Declared only when the server can take
3873
+ // the receipt, so the room never offers a composer nothing will read.
3874
+ ...(caps.runInputs ? { inputTransport: "next_turn" } : {}),
3169
3875
  });
3170
3876
  runId = r?.runId ?? null;
3877
+ if (Number.isSafeInteger(r?.iterateTurn)) iterateTurn = Number(r.iterateTurn);
3171
3878
  } catch {
3172
3879
  /* never break the run on a run-record failure */
3173
3880
  }
3174
3881
  }
3882
+ // 1182 — the run's direction inbox, fed by every heartbeat reply. Only a run
3883
+ // the server knows (start_run answered) and only when it can take the
3884
+ // receipt; otherwise there is no door and nothing is ever listed.
3885
+ const directions =
3886
+ caps.runInputs && runId
3887
+ ? createDirectionInbox({ tool, runId, log: (line) => console.log(line) })
3888
+ : null;
3889
+ // All subprocesses launched after an Iterate claim — including the fast plan
3890
+ // acknowledgement — share this exact authority fence. Non-Iterate runs take
3891
+ // the true fast path and preserve their existing behavior.
3892
+ const beforeCodingSpawn = async () => {
3893
+ if (signal?.aborted) return false;
3894
+ if (!iterateClaimId) return true;
3895
+ const ownsClaim = await confirmDaemonIterateClaim({
3896
+ tool,
3897
+ runId,
3898
+ iterateTurn,
3899
+ claimId: iterateClaimId,
3900
+ });
3901
+ if (!ownsClaim) iterateAuthorityLost = true;
3902
+ return ownsClaim && !signal?.aborted;
3903
+ };
3904
+ const settleConflictFailure = async (reason) => {
3905
+ if (!activeConflictMerge || !runId || !caps.runs) return;
3906
+ await tool("update_run", {
3907
+ runId,
3908
+ status: effectiveMode === "iterate" ? "awaiting_review" : "failed",
3909
+ reason,
3910
+ }).catch(() => {});
3911
+ };
3175
3912
  // Keep the inspectable continuation/redirect statement (don't overwrite it with
3176
3913
  // an LLM plan) so a human can correct the routing before code lands.
3177
3914
  if (
@@ -3190,6 +3927,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3190
3927
  repoFullName,
3191
3928
  cfg,
3192
3929
  signal,
3930
+ cwd: repoPath,
3931
+ beforeSpawn: beforeCodingSpawn,
3193
3932
  }).catch(() => null);
3194
3933
  if (plan && !signal?.aborted) await tool("edit_message", { messageId: ackId, body: plan }).catch(() => {});
3195
3934
  }
@@ -3321,11 +4060,32 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3321
4060
  * heartbeat does (the room hears who stopped it; the job's signal aborts).
3322
4061
  */
3323
4062
  const stoppedBeforeShip = async (state = "done") => {
4063
+ if (iterateClaimId) {
4064
+ const ownsClaim = await confirmDaemonIterateClaim({
4065
+ tool,
4066
+ runId,
4067
+ iterateTurn,
4068
+ claimId: iterateClaimId,
4069
+ });
4070
+ if (!ownsClaim) {
4071
+ iterateAuthorityLost = true;
4072
+ return true;
4073
+ }
4074
+ }
3324
4075
  // Only the streaming card carries progress metadata. The legacy heartbeat's
3325
4076
  // message is plain text, and stamping a lifecycle onto it would turn a
3326
4077
  // status line into a run card — so that path simply has no pre-ship check.
3327
4078
  if (!streamOn || !progressId) return false;
3328
- const sender = createProgressSender({ tool, statusId: progressId, runId, onStopRequested });
4079
+ // 1182 — this is the one beat between the last turn and the ship, so it
4080
+ // is also where a direction typed during the review wait is harvested;
4081
+ // the inbox then answers it at close, before the report.
4082
+ const sender = createProgressSender({
4083
+ tool,
4084
+ statusId: progressId,
4085
+ runId,
4086
+ onStopRequested,
4087
+ ...(directions ? { onPendingInputs: (list) => directions.absorb(list) } : {}),
4088
+ });
3329
4089
  return await sender.poll(state);
3330
4090
  };
3331
4091
  // Once the run ends, retire the "still working…" progress reply so it doesn't
@@ -3348,15 +4108,25 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3348
4108
  const settleRunUsage = async () => {
3349
4109
  const args = reportUsageArgs(runUsage, { vendor, modelId: resolvedModelId, runId });
3350
4110
  if (!args.usage || !progressId) return;
3351
- await tool("post_progress", {
4111
+ // 1182 — a run-bound beat like any other: a direction it lists is handed
4112
+ // to the inbox (which answers it at close, right after this on the exits
4113
+ // that call it), so no beat throws a listing away.
4114
+ const res = await tool("post_progress", {
3352
4115
  messageId: progressId,
3353
4116
  ...(runId ? { runId } : {}),
3354
4117
  progress: { state: lastTerminalState },
3355
4118
  usage: args.usage,
3356
- }).catch(() => {});
4119
+ }).catch(() => null);
4120
+ if (directions && res) {
4121
+ try {
4122
+ directions.absorb(readPendingInputs(res));
4123
+ } catch {
4124
+ /* never break the settlement over a listing */
4125
+ }
4126
+ }
3357
4127
  };
3358
4128
 
3359
- const parts = cfg.codingCmd.split(" ").filter(Boolean);
4129
+ const parts = commandArgv(cfg.codingCmd);
3360
4130
  // Run the CLI and stage everything it changed; return the diff stats (no post).
3361
4131
  // Workspace memory (the project's soul) is prepended so the coding agent has
3362
4132
  // the shared context before it starts.
@@ -3402,7 +4172,13 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3402
4172
  // the sender also carries the stop signal home on this same beat, bound
3403
4173
  // to THIS run's id so a stop can't be read off a neighbouring thread.
3404
4174
  const sender = statusId
3405
- ? createProgressSender({ tool, statusId, runId, onStopRequested })
4175
+ ? createProgressSender({
4176
+ tool,
4177
+ statusId,
4178
+ runId,
4179
+ onStopRequested,
4180
+ ...(directions ? { onPendingInputs: (list) => directions.absorb(list) } : {}),
4181
+ })
3406
4182
  : null;
3407
4183
  // The emitter is built whether or not the status card posted (0787). The
3408
4184
  // stream flags are already on the argv either way, so the CLI is speaking
@@ -3466,7 +4242,24 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3466
4242
  const codeArgs = streamOn
3467
4243
  ? [...parts.slice(1), ...webArgs, ...modelArgs, ...dirArgs, ...imageArgs, ...resumeArgs, ...streamArgs]
3468
4244
  : [...parts.slice(1), ...webArgs, ...modelArgs, ...dirArgs, ...imageArgs, ...resumeArgs];
4245
+ // One HEP stream per spawned coding-runtime process. This projection is
4246
+ // automatic but deliberately best-effort: it is bounded, secret-redacted
4247
+ // again on the server, and can never interrupt the coding process.
4248
+ const harnessEmitter =
4249
+ caps.hepEvents &&
4250
+ runId &&
4251
+ me?.agentId &&
4252
+ ["claude_code", "codex", "cursor", "opencode"].includes(vendor)
4253
+ ? createLocalHarnessEmitter({
4254
+ tool,
4255
+ runId,
4256
+ runtime: vendor,
4257
+ streamId: randomUUID(),
4258
+ actorId: me.agentId,
4259
+ })
4260
+ : null;
3469
4261
  const handleCliData = (c) => {
4262
+ harnessEmitter?.push(c);
3470
4263
  // Keep tracking lastLine as a fallback (legacy heartbeat / honesty).
3471
4264
  const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
3472
4265
  if (lines.length) lastLine = lines[lines.length - 1];
@@ -3494,6 +4287,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3494
4287
  // live card has always read this; the transcript has to as well, or a gated
3495
4288
  // run uploads a page of narration with no execution anywhere in it.
3496
4289
  const handleCliEvent = (ev) => {
4290
+ harnessEmitter?.pushEvent(ev);
3497
4291
  if (transcriptTap) {
3498
4292
  try {
3499
4293
  transcriptTap.pushEvent(ev);
@@ -3566,6 +4360,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3566
4360
  env: scrubHilosEnv(codingChildWebEnv(cfg, vendor) || process.env),
3567
4361
  onData: handleCliData,
3568
4362
  onEvent: handleCliEvent,
4363
+ beforeSpawn: beforeCodingSpawn,
3569
4364
  ...openCodePermissionCallbacks({
3570
4365
  tool,
3571
4366
  channelId,
@@ -3588,6 +4383,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3588
4383
  // after this scrub and dies with the process group.
3589
4384
  env: scrubHilosEnv(codingChildWebEnv(cfg, vendor) || process.env),
3590
4385
  onData: handleCliData,
4386
+ beforeSpawn: beforeCodingSpawn,
3591
4387
  ...openCodePermissionCallbacks({
3592
4388
  tool,
3593
4389
  channelId,
@@ -3612,6 +4408,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3612
4408
  signal,
3613
4409
  onData: handleCliData,
3614
4410
  onEvent: handleCliEvent,
4411
+ beforeSpawn: beforeCodingSpawn,
3615
4412
  permissionCallbacks: openCodePermissionCallbacks({
3616
4413
  tool,
3617
4414
  channelId,
@@ -3634,6 +4431,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3634
4431
  cwd: repoPath,
3635
4432
  signal,
3636
4433
  onData: handleCliData,
4434
+ beforeSpawn: beforeCodingSpawn,
3637
4435
  sessionId: resume ? resumeSessionId ?? "" : "",
3638
4436
  resolveSessionId: () => emitter?.snapshot()?.sessionId ?? null,
3639
4437
  permissionCallbacks: openCodePermissionCallbacks({
@@ -3659,6 +4457,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3659
4457
  signal,
3660
4458
  env: codingChildWebEnv(cfg, vendor),
3661
4459
  onData: handleCliData,
4460
+ beforeSpawn: beforeCodingSpawn,
3662
4461
  });
3663
4462
  }
3664
4463
  // 0785 — the gate was expected here and the CLI couldn't hold it. Say so
@@ -3668,6 +4467,11 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3668
4467
  stopHeartbeat();
3669
4468
  stopPoller?.stop();
3670
4469
  if (run?.sessionId) runSessionId = run.sessionId;
4470
+ try {
4471
+ await harnessEmitter?.drain();
4472
+ } catch {
4473
+ /* runtime exhaust must never break the run */
4474
+ }
3671
4475
  if (emitter) {
3672
4476
  // Terminal state: flip the status card off "working" (state 'done'/'error')
3673
4477
  // so it stops claiming the agent is alive. For a run that produced work,
@@ -3735,7 +4539,53 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3735
4539
  return { aborted: true };
3736
4540
  }
3737
4541
  if (run.error) console.log(` ! ${parts[0]} failed to start: ${run.error.message}`);
3738
- git(repoPath, ["add", "-A"]);
4542
+ if (activeConflictMerge) {
4543
+ const mergeState = validateConflictMergeState({
4544
+ git,
4545
+ cwd: repoPath,
4546
+ baseBranch: activeConflictMerge.baseBranch,
4547
+ });
4548
+ if (!mergeState.ok) {
4549
+ console.log(` code → ${mergeState.reason}; refusing to stage`);
4550
+ return {
4551
+ conflicted: true,
4552
+ mergeStateInvalid: true,
4553
+ files: [mergeState.reason],
4554
+ failed: true,
4555
+ };
4556
+ }
4557
+ const unresolved = remainingConflictFiles({
4558
+ git,
4559
+ cwd: repoPath,
4560
+ knownFiles: activeConflictMerge.files,
4561
+ });
4562
+ if (unresolved.length) {
4563
+ console.log(` code → ${unresolved.length} merge conflict(s) remain; refusing to stage`);
4564
+ return {
4565
+ conflicted: true,
4566
+ files: unresolved,
4567
+ failed: Boolean(run.error) || run.status !== 0,
4568
+ };
4569
+ }
4570
+ }
4571
+ const add = git(repoPath, ["add", "-A"]);
4572
+ if (activeConflictMerge) {
4573
+ const unmergedAfterAdd = String(
4574
+ git(repoPath, ["diff", "--name-only", "--diff-filter=U"]).stdout || "",
4575
+ )
4576
+ .split("\n")
4577
+ .map((file) => file.trim())
4578
+ .filter(Boolean);
4579
+ if (add.status !== 0 || unmergedAfterAdd.length) {
4580
+ return {
4581
+ conflicted: true,
4582
+ files: unmergedAfterAdd.length
4583
+ ? unmergedAfterAdd
4584
+ : ["Git could not stage the resolved merge"],
4585
+ failed: true,
4586
+ };
4587
+ }
4588
+ }
3739
4589
  const diff = git(repoPath, ["diff", "--cached"]).stdout || "";
3740
4590
  if (!diff.trim()) {
3741
4591
  // An empty diff after a FAILED run (timeout / non-zero exit / spawn error)
@@ -3903,9 +4753,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3903
4753
  // Post the right cancel message: honest about whether cleanup actually worked.
3904
4754
  const postStopped = async () => {
3905
4755
  const clean = discardBranch();
4756
+ const lead = iterateAuthorityLost
4757
+ ? "This Iterate turn no longer owns its run, so I did not start or ship it"
4758
+ : "Stopped";
3906
4759
  const body = clean
3907
- ? `Stopped — discarded \`${branch}\`.`
3908
- : `Stopped, but couldn't fully clean \`${branch}\` — run \`git reset --hard && git switch ${startRef.ref || cfg.defaultBranch}\` in ${repoPath}.`;
4760
+ ? `${lead} — discarded \`${branch}\`.`
4761
+ : `${lead}, but couldn't fully clean \`${branch}\` — run \`git reset --hard && git switch ${startRef.ref || cfg.defaultBranch}\` in ${repoPath}.`;
3909
4762
  await tool("post_message", {
3910
4763
  channelId,
3911
4764
  parentId: threadRoot,
@@ -3919,6 +4772,30 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3919
4772
  return { status: "cancelled", branch };
3920
4773
  };
3921
4774
 
4775
+ const postStopCompensationPending = async (result) => {
4776
+ const receipt = result?.compensation?.receipt;
4777
+ const ref = receipt?.ref || `refs/heads/${branch}`;
4778
+ const reason = result?.compensation?.probeError
4779
+ || result?.compensation?.stderr
4780
+ || "the remote ref could not be verified";
4781
+ const repairGuidance = result?.compensation?.repairable
4782
+ ? "The exact-lease repair is safe to retry"
4783
+ : "The remote ref changed again, so it needs manual reconciliation";
4784
+ await tool("post_message", {
4785
+ channelId,
4786
+ parentId: threadRoot,
4787
+ broadcast: true,
4788
+ body:
4789
+ `Stop was received, but hilos could not confirm restoration of \`${ref}\`: ` +
4790
+ `${oneLine(String(reason), 200)}. ${repairGuidance}; ` +
4791
+ `the local branch \`${branch}\` was kept for recovery.`,
4792
+ });
4793
+ await finalizeProgress(`Stop needs remote branch repair on \`${branch}\` — see the note below.`);
4794
+ await updateChannelMarker(compactRunMarker("run-failed", branch));
4795
+ await settleRunUsage();
4796
+ return result;
4797
+ };
4798
+
3922
4799
  // Team memory recall (0297): best-effort fetch accumulated learnings for this
3923
4800
  // channel before the coding run so the coding agent has the team's conventions
3924
4801
  // and gotchas from the start. Capability-gated: older servers without the
@@ -3944,21 +4821,119 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3944
4821
 
3945
4822
  const codePrompt =
3946
4823
  (teamMemoryBlock ? teamMemoryBlock + "\n\n" : "") +
3947
- codeTaskPrompt({ message, context, brief: routed.task, repoFullName, ...codeImagePromptArgs });
4824
+ codeTaskPrompt({ message, context, brief: routed.task, repoFullName, ...codeImagePromptArgs }) +
4825
+ (activeConflictMerge
4826
+ ? `\n\n${conflictPromptBlock(activeConflictMerge)}`
4827
+ : "");
3948
4828
  let staged = await runAndStage(codePrompt);
3949
4829
  if (staged.aborted) return await postStopped();
4830
+ // One focused retry, in the same prepared merge, with the remaining paths
4831
+ // named explicitly. This is recovery owned by the run — never another human
4832
+ // prompt. A second failure stops safely and pushes nothing.
4833
+ if (activeConflictMerge && staged.conflicted) {
4834
+ if (staged.mergeStateInvalid) {
4835
+ await tool("post_message", {
4836
+ channelId,
4837
+ parentId: threadRoot,
4838
+ broadcast: true,
4839
+ body:
4840
+ `I couldn't safely finish the conflict repair on PR #${activeConflictMerge.prNumber}. ` +
4841
+ `${staged.files[0]}. I left its remote branch unchanged.`,
4842
+ });
4843
+ await finalizeProgress(`Conflict repair stopped on \`${branch}\` — nothing was pushed.`);
4844
+ await updateChannelMarker(compactRunMarker("run-failed", branch));
4845
+ await settleConflictFailure("conflict merge state was aborted or replaced");
4846
+ await settleRunUsage();
4847
+ return { status: "conflict-state-invalid", branch };
4848
+ }
4849
+ const retryPrompt =
4850
+ `${codePrompt}\n\nYour first pass left unresolved merge conflicts. ` +
4851
+ `Resolve every remaining file now and leave no conflict marker behind:\n` +
4852
+ staged.files.map((file) => `- ${file}`).join("\n");
4853
+ staged = await runAndStage(retryPrompt, { resume: false });
4854
+ if (staged.aborted) return await postStopped();
4855
+ if (staged.conflicted) {
4856
+ await tool("post_message", {
4857
+ channelId,
4858
+ parentId: threadRoot,
4859
+ broadcast: true,
4860
+ body:
4861
+ `I couldn't safely finish the conflict repair on PR #${activeConflictMerge.prNumber}. ` +
4862
+ `I left its remote branch unchanged; unresolved files were:\n` +
4863
+ staged.files.map((file) => `- ${file}`).join("\n"),
4864
+ });
4865
+ await finalizeProgress(`Conflict repair stopped on \`${branch}\` — nothing was pushed.`);
4866
+ await updateChannelMarker(compactRunMarker("run-failed", branch));
4867
+ await settleConflictFailure(`unresolved conflicts: ${staged.files.join(", ")}`);
4868
+ await settleRunUsage();
4869
+ return { status: "conflicts-unresolved", branch, files: staged.files };
4870
+ }
4871
+ }
3950
4872
  // Never-worse-than-today (0282): if we RESUMED a session and that run FAILED (a
3951
4873
  // stale/foreign `--resume` id makes claude error out → empty diff → failed), retry
3952
4874
  // ONCE without --resume — the exact branch+feedback iterate we'd have run before
3953
4875
  // 0282. A dead session can therefore never dead-end an iteration. Gated on
3954
4876
  // staged.failed (the reliable resume-error signal) so a legitimate no-op run isn't
3955
4877
  // re-run. Adopt the retry regardless — it IS the pre-0282 baseline.
3956
- if (resumeSessionId && staged.empty && staged.failed) {
4878
+ if (resumeSessionId && staged.empty && staged.failed && !activeConflictMerge) {
3957
4879
  console.log(" code → resumed run failed; retrying once without --resume (branch + feedback)");
3958
4880
  const retry = await runAndStage(codePrompt, { resume: false });
3959
4881
  if (retry.aborted) return await postStopped();
3960
4882
  staged = retry;
3961
4883
  }
4884
+ // 1182 — directions a person added from the card while the CLI worked. The
4885
+ // coding turn is over; each batch gets one more turn with the same task and
4886
+ // the directions, on the same working tree (the shape the gate's feedback
4887
+ // rounds use), then its receipt: the turn's own command id and the pass it
4888
+ // went into. Bounded, so a room that keeps typing waits for the next pass.
4889
+ // The inbox stays open through the gate's review rounds (each folds what
4890
+ // arrived into its prompt) and closes only when no further turn can run.
4891
+ const settleDirectionTurn = async (batch, applied, commandId) => {
4892
+ if (!directionTurnRan(applied)) {
4893
+ // No runtime ever saw the prompt (the command did not spawn): no
4894
+ // receipt. The server keeps listing; the run's end tells the room.
4895
+ console.log(` direction → the turn did not start; ${batch.length} direction(s) left unsettled`);
4896
+ return;
4897
+ }
4898
+ // The runtime took the directions into a command: delivered, whatever
4899
+ // the turn then produced (its outcome is the report's to tell).
4900
+ const delivered = await directions.acknowledge(batch, { ackId: commandId, turn: iterateTurn });
4901
+ console.log(` direction → ${delivered}/${batch.length} receipt(s) recorded`);
4902
+ };
4903
+ // Runs while directions are held and turns remain; called after the coding
4904
+ // turn and after each review round (a direction listed mid-round arrives
4905
+ // after that round's prompt was built). Never before a ship: the exits are
4906
+ // derived from the tree the last turn left, and a turn would change it.
4907
+ // Reports `stopped` when a person stopped the run mid-turn.
4908
+ let directionTurns = 0;
4909
+ const runDirectionTurns = async () => {
4910
+ const outcome = { stopped: false, advanced: false };
4911
+ // A conflict repair owns the tree until it settles; directions wait and
4912
+ // are answered at close.
4913
+ if (!directions || activeConflictMerge) return outcome;
4914
+ while (!signal?.aborted && directions.size() > 0 && directionTurns < MAX_DIRECTION_TURNS) {
4915
+ const batch = directions.take();
4916
+ directionTurns += 1;
4917
+ console.log(` direction → running one more turn with ${batch.length} direction(s) from the room`);
4918
+ const commandId = `direction-turn:${randomUUID()}`;
4919
+ const applied = await runAndStage(directionTurnPrompt(codePrompt, batch), { resume: false });
4920
+ if (applied.aborted) {
4921
+ outcome.stopped = true;
4922
+ return outcome;
4923
+ }
4924
+ await settleDirectionTurn(batch, applied, commandId);
4925
+ // `staged` always describes the repo after the last turn that ran, so
4926
+ // every exit below (self-committed, no changes, proposal, ship) is
4927
+ // derived from what the tree holds now, never from a stale snapshot.
4928
+ if (directionTurnRan(applied)) {
4929
+ staged = applied;
4930
+ outcome.advanced = true;
4931
+ }
4932
+ }
4933
+ return outcome;
4934
+ };
4935
+ const afterCoding = await runDirectionTurns();
4936
+ if (afterCoding.stopped) return await postStopped();
3962
4937
  // Persist this run's session (best-effort) so the NEXT iterate can resume it and
3963
4938
  // machine-match. prUrl is filled in again at ship time.
3964
4939
  await recordSession(continuingPrUrl);
@@ -3968,30 +4943,38 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3968
4943
  resumeSessionId = null;
3969
4944
  // The CLI committed on its own (clean tree, but commits ahead of base). Don't
3970
4945
  // report "no changes" or delete the branch — surface the real work.
4946
+ // Approve-before-push: a self-pushing CLI already bypassed the gate; be
4947
+ // honest and let the human review instead of auto-shipping. Leave the
4948
+ // branch. A closure, because a direction turn inside a review round can
4949
+ // end the same way (1182).
4950
+ const finishSelfCommittedGated = async () => {
4951
+ await tool("post_message", {
4952
+ channelId,
4953
+ parentId: threadRoot,
4954
+ broadcast: true,
4955
+ body:
4956
+ `The coding agent committed on \`${branch}\` itself on its last turn (I asked it to only edit files). ` +
4957
+ `Under approve-before-push I won't auto-push it — review \`${branch}\` locally, then ` +
4958
+ `re-mention me to ship or discard.`,
4959
+ });
4960
+ await finalizeProgress(`\`${branch}\` has the agent's own commits — needs review.`);
4961
+ await updateChannelMarker(compactRunMarker("changes", branch));
4962
+ await settleRunUsage(); // no report on this exit (0787)
4963
+ // 1182 — no turn is left in this pass: what is held is answered now.
4964
+ if (directions) await directions.close();
4965
+ return { status: "self-committed-gated", branch };
4966
+ };
3971
4967
  if (staged.empty && !staged.failed && staged.ahead > 0) {
3972
- if (cfg.gate) {
3973
- // Approve-before-push: a self-pushing CLI already bypassed the gate; be
3974
- // honest and let the human review instead of auto-shipping. Leave the branch.
3975
- await tool("post_message", {
3976
- channelId,
3977
- parentId: threadRoot,
3978
- broadcast: true,
3979
- body:
3980
- `The coding agent committed on \`${branch}\` itself (I asked it to only edit files). ` +
3981
- `Under approve-before-push I won't auto-push it — review \`${branch}\` locally, then ` +
3982
- `re-mention me to ship or discard.`,
3983
- });
3984
- await finalizeProgress(`\`${branch}\` has the agent's own commits — needs review.`);
3985
- await updateChannelMarker(compactRunMarker("changes", branch));
3986
- await settleRunUsage(); // no report on this exit (0787)
3987
- return { status: "self-committed-gated", branch };
3988
- }
4968
+ if (cfg.gate && !activeConflictMerge) return await finishSelfCommittedGated();
3989
4969
  // When a live status card was streaming, settle the report onto it (0289) —
3990
4970
  // the card cross-fades run → report. Skip finalizeProgress then (it edits the
3991
4971
  // card's body, which the settle overwrites with the report anyway).
3992
4972
  if (signal?.aborted || (await stoppedBeforeShip())) return await postStopped();
3993
4973
  const selfSettleId = streamOn && progressId ? progressId : null;
3994
4974
  if (!selfSettleId) await finalizeProgress(`Agent shipped \`${branch}\` itself — report below.`);
4975
+ // 1182 — closed before the report ends the pass on the server (see the
4976
+ // ungated ship below for why).
4977
+ if (directions) await directions.close();
3995
4978
  const selfResult = await shipSelfDriven({
3996
4979
  repoPath,
3997
4980
  branch,
@@ -4004,15 +4987,24 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
4004
4987
  parentId: threadRoot,
4005
4988
  settleId: selfSettleId,
4006
4989
  runId,
4990
+ iterateTurn,
4007
4991
  provenance: githubProvenance({ cfg, me, message, channelId, runId, summary: staged.summary }),
4008
4992
  usageArgs: reportUsageArgs(runUsage, { vendor, modelId: resolvedModelId, runId }),
4993
+ shouldContinue: async () => !signal?.aborted && !(await stoppedBeforeShip()),
4009
4994
  });
4995
+ if (selfResult.status === "stopped") return await postStopped();
4996
+ if (selfResult.status === "stop-compensation-pending") {
4997
+ return await postStopCompensationPending(selfResult);
4998
+ }
4010
4999
  await recordSession(selfResult.prUrl);
4011
5000
  await uploadTranscript(selfResult.reportMsgId);
4012
5001
  await updateChannelMarker(compactRunMarker(selfResult.status, selfResult.branch));
4013
5002
  return selfResult;
4014
5003
  }
4015
- if (staged.empty) {
5004
+ // The tree holds nothing to propose: report why and leave or clean the
5005
+ // branch. A closure, because a direction turn inside a review round can
5006
+ // leave the tree this way too (1182); it always reads the current `staged`.
5007
+ const finishEmptyTree = async ({ afterDirection = false } = {}) => {
4016
5008
  // When continuing an existing PR, never delete that branch — it's the open
4017
5009
  // PR — and don't say "cleaning up". Just report and leave the PR as it was.
4018
5010
  const cleanupNote = continuingPrUrl ? "" : ` Cleaning up \`${branch}\`.`;
@@ -4030,6 +5022,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
4030
5022
  : "";
4031
5023
  const tail = staged.stderrTail ? `: ${staged.stderrTail}` : "";
4032
5024
  body = `The run didn't finish${why}${tail}.${retryTail}`;
5025
+ } else if (afterDirection) {
5026
+ // A direction turn inside a review round left nothing staged: say so,
5027
+ // since a proposal card showing a diff may still be standing.
5028
+ body = continuingPrUrl
5029
+ ? `The direction from the room left no changes — \`${branch}\` is unchanged.`
5030
+ : `The direction from the room left no changes on \`${branch}\`.${cleanupNote}`;
4033
5031
  } else {
4034
5032
  body = continuingPrUrl
4035
5033
  ? `That feedback produced no further changes — \`${branch}\` is unchanged.`
@@ -4047,13 +5045,16 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
4047
5045
  compactRunMarker(staged.failed ? "run-failed" : "no-changes", branch),
4048
5046
  );
4049
5047
  await settleRunUsage(); // "no changes" is not "no spend" (0787)
5048
+ // 1182 — no turn is left in this pass: what is held is answered now.
5049
+ if (directions) await directions.close();
4050
5050
  return { status: staged.failed ? "run-failed" : "no-changes" };
4051
- }
5051
+ };
5052
+ if (staged.empty) return await finishEmptyTree({ afterDirection: afterCoding.advanced });
4052
5053
 
4053
5054
  // Bias to action (default): commit, push, and open a PR for review now — the
4054
5055
  // report card's Approve merges it. `gate:true` keeps the older
4055
5056
  // propose-a-diff-and-wait flow for users who want approve-before-push.
4056
- if (!cfg.gate) {
5057
+ if (!cfg.gate || activeConflictMerge) {
4057
5058
  // A "stop" that lands between the run finishing and the ship must still win.
4058
5059
  // The signal covers a stop we already heard; the server check covers one that
4059
5060
  // landed while the CLI was silent and nothing was carrying it home.
@@ -4065,13 +5066,23 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
4065
5066
  // separate ticket). No status card (older server / status post failed) →
4066
5067
  // settleId is null and applyDecision posts a fresh final report instead.
4067
5068
  const settleId = streamOn && progressId ? progressId : null;
5069
+ // 1182 — the inbox closes BEFORE the report: the report ends the pass on
5070
+ // the server, and the server's run-end rule would otherwise speak first,
5071
+ // with a notice meant for a run that stopped and before a lost receipt
5072
+ // could be retried. No extra turn here: the exits above were derived
5073
+ // from the tree the last turn left, and a turn now would change it.
5074
+ if (directions) await directions.close();
4068
5075
  const result = await applyDecision({
4069
5076
  decision: { kind: "approved" },
4070
5077
  repoPath,
4071
5078
  branch,
4072
5079
  // For a continuation the raw mention is the rework ping ("please update the
4073
5080
  // PR …"); use the router's distilled brief for a clean commit/PR title.
4074
- task: continuingPrUrl ? routed.task || message.body : message.body,
5081
+ task: activeConflictMerge
5082
+ ? conflictCommitTitle(activeConflictMerge.baseBranch)
5083
+ : continuingPrUrl
5084
+ ? routed.task || message.body
5085
+ : message.body,
4075
5086
  requester: message.author,
4076
5087
  cfg,
4077
5088
  tool,
@@ -4081,9 +5092,15 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
4081
5092
  existingPrUrl: continuingPrUrl,
4082
5093
  settleId,
4083
5094
  runId,
5095
+ iterateTurn,
4084
5096
  provenance: githubProvenance({ cfg, me, message, channelId, runId, summary: staged.summary }),
4085
5097
  usageArgs: reportUsageArgs(runUsage, { vendor, modelId: resolvedModelId, runId }),
5098
+ shouldContinue: async () => !signal?.aborted && !(await stoppedBeforeShip()),
4086
5099
  });
5100
+ if (result.status === "stopped") return await postStopped();
5101
+ if (result.status === "stop-compensation-pending") {
5102
+ return await postStopCompensationPending(result);
5103
+ }
4087
5104
  // The settle already flipped the card to the report (body + metadata); editing
4088
5105
  // the body again would clobber the report summary, so only finalize when we
4089
5106
  // posted a separate report.
@@ -4108,11 +5125,45 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
4108
5125
  parentId: threadRoot,
4109
5126
  body: `Revising with your feedback${decision.note ? `: ${decision.note}` : ""} (round ${round + 1}/${maxRounds}).`,
4110
5127
  });
5128
+ // 1182 — a direction that arrived while the reviewer was deciding rides
5129
+ // this round's prompt, after the feedback, and gets this round's receipt.
5130
+ const roundDirections = directions ? directions.take() : [];
5131
+ const roundCommandId = `direction-turn:${randomUUID()}`;
5132
+ const roundPrompt = `${message.body}\n\nReviewer feedback to address: ${decision.note || "(see the channel)"}`;
4111
5133
  const refined = await runAndStage(
4112
- `${message.body}\n\nReviewer feedback to address: ${decision.note || "(see the channel)"}`,
5134
+ roundDirections.length > 0 ? directionTurnPrompt(roundPrompt, roundDirections) : roundPrompt,
4113
5135
  );
4114
5136
  if (refined.aborted) return await postStopped();
4115
- if (refined.empty) {
5137
+ if (roundDirections.length > 0) await settleDirectionTurn(roundDirections, refined, roundCommandId);
5138
+ // The round's own turn is a turn that ran: `staged` follows it (1182
5139
+ // round 5), so the invariant holds for every turn, not only direction
5140
+ // turns. Then a direction listed during this round (it arrived after the
5141
+ // prompt was built) gets its turn, and whichever tree is newest is what
5142
+ // the reviewer sees: the proposal card is the gate, and it shows what
5143
+ // will ship.
5144
+ if (!directionTurnRan(refined)) {
5145
+ // The round's command never started: nothing changed and nothing was
5146
+ // seen, so the earlier proposal stands, said plainly, never re-posted.
5147
+ await tool("post_message", {
5148
+ channelId,
5149
+ parentId: threadRoot,
5150
+ broadcast: true,
5151
+ body: `I couldn't start \`${cfg.codingCmd}\` for this round — leaving \`${branch}\` as proposed.`,
5152
+ });
5153
+ break;
5154
+ }
5155
+ staged = refined;
5156
+ const afterRound = await runDirectionTurns();
5157
+ if (afterRound.stopped) return await postStopped();
5158
+ if (staged.empty) {
5159
+ // Nothing staged after this round's turns. The CLI's own commits end
5160
+ // the pass the way they would after the coding turn (approve-before-
5161
+ // push never auto-ships them, whether a feedback round or a direction
5162
+ // turn made them); a direction turn that left the tree clean ends it as
5163
+ // no changes; a feedback round alone that produced nothing leaves the
5164
+ // earlier proposal standing, as it always has.
5165
+ if (!staged.failed && staged.ahead > 0) return await finishSelfCommittedGated();
5166
+ if (afterRound.advanced) return await finishEmptyTree({ afterDirection: true });
4116
5167
  await tool("post_message", {
4117
5168
  channelId,
4118
5169
  parentId: threadRoot,
@@ -4121,7 +5172,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
4121
5172
  });
4122
5173
  break;
4123
5174
  }
4124
- proposal = await postProposal(refined);
5175
+ proposal = await postProposal(staged);
4125
5176
  decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId: threadRoot, signal });
4126
5177
  if (decision.kind === "cancelled") return await postStopped();
4127
5178
  round += 1;
@@ -4129,6 +5180,10 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
4129
5180
 
4130
5181
  // A stop during the final wait, too — don't ship a cancelled run.
4131
5182
  if (signal?.aborted || (await stoppedBeforeShip())) return await postStopped();
5183
+ // 1182 — closed before the report ends the pass on the server (see the
5184
+ // ungated ship above for why). The reviewer approved this tree; no further
5185
+ // turn may change it.
5186
+ if (directions) await directions.close();
4132
5187
  const result = await applyDecision({
4133
5188
  decision,
4134
5189
  repoPath,
@@ -4144,11 +5199,17 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
4144
5199
  parentId: threadRoot,
4145
5200
  existingPrUrl: continuingPrUrl,
4146
5201
  runId,
5202
+ iterateTurn,
4147
5203
  provenance: githubProvenance({ cfg, me, message, channelId, runId, summary: staged.summary }),
4148
5204
  // Normally {} — a gated run already reported its spend on the proposal card
4149
5205
  // it is now shipping. Anything the CLI spent after that still comes home.
4150
5206
  usageArgs: reportUsageArgs(runUsage, { vendor, modelId: resolvedModelId, runId }),
5207
+ shouldContinue: async () => !signal?.aborted && !(await stoppedBeforeShip()),
4151
5208
  });
5209
+ if (result.status === "stopped") return await postStopped();
5210
+ if (result.status === "stop-compensation-pending") {
5211
+ return await postStopCompensationPending(result);
5212
+ }
4152
5213
  await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
4153
5214
  await recordSession(result.prUrl);
4154
5215
  // The gated lane's shipped report is a fresh message; `result.reportMsgId`
@@ -4157,6 +5218,15 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
4157
5218
  await updateChannelMarker(compactRunMarker(result.status, result.branch));
4158
5219
  return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round, sessionId: runSessionId };
4159
5220
  } finally {
5221
+ // Any early exit while Git is still mid-merge must leave the user's checkout
5222
+ // clean enough to switch back and restore their stash. A successful merge
5223
+ // commit has already removed MERGE_HEAD, so this is a no-op on the happy path.
5224
+ if (
5225
+ activeConflictMerge &&
5226
+ git(repoPath, ["rev-parse", "-q", "--verify", "MERGE_HEAD"]).status === 0
5227
+ ) {
5228
+ git(repoPath, ["merge", "--abort"]);
5229
+ }
4160
5230
  // Whatever happened, give the user their uncommitted work back.
4161
5231
  await restoreStash();
4162
5232
  }