hilos-agent 0.11.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
@@ -37,6 +37,7 @@ 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";
41
42
  import {
42
43
  createDirectionInbox,
@@ -62,6 +63,7 @@ import {
62
63
  } from "./progress-emitter.mjs";
63
64
  import { createTranscriptTap } from "./transcript.mjs";
64
65
  import { createLocalHarnessEmitter } from "./local-harness-transport.mjs";
66
+ import { releaseDaemonIterateClaim } from "./iterate-claim-recovery.mjs";
65
67
  import { imagePromptNote, renderAttachmentLine } from "./attachments.mjs";
66
68
  import { resolveFollowupMode, classifyFollowupCue, normalizeSignal } from "./followup.mjs";
67
69
  import {
@@ -647,6 +649,7 @@ async function runClaudeGatedCli({
647
649
  permissionCallbacks,
648
650
  sessionId = "",
649
651
  resolveSessionId,
652
+ beforeSpawn,
650
653
  log = console,
651
654
  }) {
652
655
  const server = await deps.startClaudePermissionServer({
@@ -671,6 +674,7 @@ async function runClaudeGatedCli({
671
674
  signal,
672
675
  env: claudeGateEnv(cfg),
673
676
  onData,
677
+ beforeSpawn,
674
678
  // 0785 — a CLI that rejects the gate flags is retried without them. This
675
679
  // fires BEFORE that ungated retry is spawned, so the room hears about the
676
680
  // downgrade first rather than after the fact.
@@ -698,7 +702,7 @@ function codexRunIsGated(cfg, caps) {
698
702
  return shouldGateCodexPermissions({
699
703
  vendor: detectVendor(cfg?.codingCmd),
700
704
  runtimePermissions: caps?.runtimePermissions,
701
- codeArgs: String(cfg?.codingCmd || "").split(" ").filter(Boolean).slice(1),
705
+ codeArgs: commandArgv(String(cfg?.codingCmd || "")).slice(1),
702
706
  });
703
707
  }
704
708
 
@@ -736,8 +740,19 @@ async function runCodexGatedSession({
736
740
  signal,
737
741
  onData,
738
742
  onEvent,
743
+ beforeSpawn,
739
744
  permissionCallbacks,
740
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
+ }
741
756
  const run = await deps.runCodexMcpSession({
742
757
  cmd,
743
758
  cwd,
@@ -754,6 +769,7 @@ async function runCodexGatedSession({
754
769
  env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
755
770
  onData,
756
771
  onEvent,
772
+ beforeSpawn,
757
773
  ...permissionCallbacks,
758
774
  });
759
775
  if (!codexMcpTransportUnavailable(run)) return run;
@@ -777,6 +793,7 @@ async function runCodexGatedSession({
777
793
  signal,
778
794
  env: codingChildEnv(cfg),
779
795
  onData,
796
+ beforeSpawn,
780
797
  });
781
798
  return { ...fallback, permissionGateDropped: true };
782
799
  }
@@ -1084,7 +1101,21 @@ async function postFinalReportWithReplay({
1084
1101
  }
1085
1102
  }
1086
1103
 
1087
- export async function claimDaemonIterate({ tool, runId, expectedIterateTurn, claimId = randomUUID() }) {
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 };
1088
1119
  const args = {
1089
1120
  runId,
1090
1121
  status: "running",
@@ -1092,14 +1123,91 @@ export async function claimDaemonIterate({ tool, runId, expectedIterateTurn, cla
1092
1123
  expectedIterateTurn,
1093
1124
  claimId,
1094
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.
1095
1174
  try {
1096
- return await tool("update_run", args);
1175
+ await releaseDaemonIterateClaim({ tool, recoveryStore, record });
1097
1176
  } catch {
1098
- // The RPC claim is idempotent by claimId. A lost response is recovered by
1099
- // replaying the exact object once; a competing claimant or a room Stop is
1100
- // returned as a refusal and the daemon never starts the coding process.
1101
- return await tool("update_run", args);
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
+ }
1102
1209
  }
1210
+ return false;
1103
1211
  }
1104
1212
 
1105
1213
  export async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, existingPrUrl, settleId, runId, iterateTurn = 0, provenance = {}, usageArgs = {}, shouldContinue }) {
@@ -1491,10 +1599,21 @@ export function dmJudgmentBlock(implicitDm) {
1491
1599
  * `error` is set when the model produced nothing (so the caller can be honest
1492
1600
  * about a timeout vs a missing binary instead of inventing a reply).
1493
1601
  */
1494
- export async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cfg, signal, hasActiveRun = false, runCliFn, implicitDm = false }) {
1495
- 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;
1496
1615
  const cmd = chatCmdFor(cfg);
1497
- const parts = cmd.split(" ").filter(Boolean);
1616
+ const parts = commandArgv(cmd);
1498
1617
  // When this thread already owns an OPEN pull request (a follow-up reply), the
1499
1618
  // router ALSO reads whether the latest message continues that PR, wants a
1500
1619
  // separate one, or is unclear — the primary signal for same-PR continuation
@@ -1534,6 +1653,7 @@ export async function routeIntent({ name, repoFullName, transcript, workspaceMem
1534
1653
  timeoutMs: cfg.chatTimeoutMs || cfg.runTimeoutMs,
1535
1654
  label: "thinking",
1536
1655
  signal,
1656
+ cwd,
1537
1657
  });
1538
1658
  if (run.aborted || signal?.aborted) return { aborted: true };
1539
1659
  const raw = (run.stdout || "").trim();
@@ -1716,18 +1836,29 @@ export function planAckPrompt(o) {
1716
1836
  * CLI. Bounded (≤45s) + best-effort: returns trimmed text, or null on
1717
1837
  * empty/timeout/error so the run keeps the instant template and never stalls.
1718
1838
  */
1719
- 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
+ }) {
1720
1849
  const cmd = chatCmdFor(cfg);
1721
1850
  if (!cmd) return null;
1722
- const parts = cmd.split(" ").filter(Boolean);
1851
+ const parts = commandArgv(cmd);
1723
1852
  const prompt = planAckPrompt({ task, transcript, repoFullName });
1724
- const run = await runCli({
1853
+ const run = await runCliFn({
1725
1854
  cmd: parts[0],
1726
1855
  args: [...parts.slice(1), prompt],
1727
1856
  timeoutMs: Math.min(cfg.chatTimeoutMs || 90000, 45000),
1728
1857
  label: "thinking",
1729
1858
  signal,
1859
+ cwd,
1730
1860
  env: codingChildEnv(cfg),
1861
+ beforeSpawn,
1731
1862
  });
1732
1863
  if (run.aborted || signal?.aborted) return null;
1733
1864
  const text = (run.stdout || "").trim();
@@ -1746,7 +1877,21 @@ async function reactQuietly({ tool, caps, message, emoji }) {
1746
1877
  }
1747
1878
 
1748
1879
  /** Run the FAST chat CLI to produce a reply, using recent channel context. */
1749
- 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
+ }) {
1750
1895
  const implicitDm = message?.implicit === "dm";
1751
1896
  const { transcript } = context || (await fetchContext({ channelId, tool, parentId }));
1752
1897
  const name = me?.agentName || "an assistant";
@@ -1755,10 +1900,20 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
1755
1900
  // "repository" line never claims a folder path is a repo. When there's no repo
1756
1901
  // but this channel maps to a local folder on THIS machine, say that instead.
1757
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;
1758
1909
  const repoLine = repoLink
1759
- ? `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.`
1760
1913
  : folderPath
1761
- ? `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.`
1762
1917
  : `If asked to change code, note that a repo isn't linked to this channel yet.`;
1763
1918
  const prompt =
1764
1919
  `You are ${name}, a teammate in a team chat (hilos). Reply to the latest message ` +
@@ -1772,7 +1927,7 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
1772
1927
  // chatCmd is unset) bounded by chatTimeoutMs, so a casual reply lands in seconds
1773
1928
  // and a stalled model can't dead-air the channel for the full coding timeout.
1774
1929
  const cmd = chatCmdFor(cfg);
1775
- const parts = cmd.split(" ").filter(Boolean);
1930
+ const parts = commandArgv(cmd);
1776
1931
  const chatVendor = detectVendor(cmd);
1777
1932
  const webArgs = chatWebArgs(chatVendor, parts.slice(1), {
1778
1933
  enabled: cfg.webSearch !== false,
@@ -1805,12 +1960,13 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
1805
1960
 
1806
1961
  let run;
1807
1962
  try {
1808
- run = await runCli({
1963
+ run = await runCliFn({
1809
1964
  cmd: parts[0],
1810
1965
  args: [...parts.slice(1), ...webArgs, prompt],
1811
1966
  timeoutMs: cfg.chatTimeoutMs || cfg.runTimeoutMs,
1812
1967
  label: "thinking",
1813
1968
  signal,
1969
+ cwd: projectCwd,
1814
1970
  env: codingChildWebEnv(cfg, chatVendor),
1815
1971
  });
1816
1972
  } finally {
@@ -1999,7 +2155,7 @@ async function reviewTask({ message, channelId, tool, me, cfg, workspaceMemory,
1999
2155
  // stdout and never stages/commits anything. Review uses the fast chat command
2000
2156
  // when set (no acceptEdits → can't write), falling back to the coding command.
2001
2157
  const cmd = cfg.reviewCmd || chatCmdFor(cfg);
2002
- const parts = String(cmd).split(" ").filter(Boolean);
2158
+ const parts = commandArgv(String(cmd));
2003
2159
  let sandboxDir = null;
2004
2160
  try {
2005
2161
  sandboxDir = mkdtempSync(join(tmpdir(), "hilos-review-"));
@@ -2396,7 +2552,7 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
2396
2552
  // Run the coding CLI in the folder, streaming progress + honoring timeouts and
2397
2553
  // the abort signal (reuses runCli via deps + the progress-emitter machinery).
2398
2554
  const runFolderCli = async (promptText) => {
2399
- const parts = cfg.codingCmd.split(" ").filter(Boolean);
2555
+ const parts = commandArgv(cfg.codingCmd);
2400
2556
  const vendor = detectVendor(cfg.codingCmd);
2401
2557
  const streamOn = Boolean(caps.postProgress);
2402
2558
  const streamArgs = streamOn ? codeStreamArgs(vendor) : [];
@@ -2930,7 +3086,7 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
2930
3086
 
2931
3087
  /** Handle one task. cfg/deps injectable for tests. `opts.signal` (AbortSignal)
2932
3088
  * cancels an in-flight run — the queue fires it when a human says "stop". */
2933
- 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 = {}) {
2934
3090
  if (message.dispatch?.briefMarkdown) {
2935
3091
  message = { ...message, body: `${message.body}\n\n${message.dispatch.briefMarkdown}` };
2936
3092
  }
@@ -3062,6 +3218,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3062
3218
  if (!repoLink) {
3063
3219
  const folderPath = resolveFolderPath(cfg, channelId);
3064
3220
  if (folderPath) {
3221
+ const folderCwd = deps.pathExists(folderPath) ? folderPath : undefined;
3065
3222
  // Durable report-card fallback: list_mentions projects a still-pending
3066
3223
  // in-place decision into the queue. Deploy only while that decision
3067
3224
  // remains pending; a settled report is a silent no-op, which prevents a
@@ -3158,15 +3315,16 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3158
3315
  // CLI router. There is still no keyword/regex intent list.
3159
3316
  if (!routed) {
3160
3317
  routed = await routeIntent({
3161
- name: me?.agentName || "an assistant",
3162
- repoFullName: folderPath,
3163
- transcript: context.transcript,
3164
- workspaceMemory,
3165
- cfg,
3166
- signal,
3167
- runCliFn: deps.runCli,
3168
- implicitDm,
3169
- });
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
+ });
3170
3328
  }
3171
3329
  if (routed.aborted || signal?.aborted) {
3172
3330
  await tool("post_message", { channelId, parentId, body: "Stopped." });
@@ -3197,13 +3355,46 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3197
3355
  // Not a coding task → the classifier is finished. The separate responder
3198
3356
  // below owns the answer and keeps its normal tools.
3199
3357
  }
3200
- 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
+ });
3201
3373
  return { status: "chat" };
3202
3374
  }
3203
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;
3204
3381
 
3205
3382
  if (mode === "ask" && !forcedConflictRecovery) {
3206
- await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps });
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
+ });
3207
3398
  return { status: "chat" };
3208
3399
  }
3209
3400
 
@@ -3250,7 +3441,9 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3250
3441
  cfg,
3251
3442
  signal,
3252
3443
  hasActiveRun: activeRunOpen,
3444
+ runCliFn: deps.runCli,
3253
3445
  implicitDm,
3446
+ cwd: mappedRepoCwd,
3254
3447
  });
3255
3448
  if (routed.aborted || signal?.aborted) {
3256
3449
  await tool("post_message", { channelId, parentId, body: "Stopped." });
@@ -3273,6 +3466,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3273
3466
  signal,
3274
3467
  context,
3275
3468
  caps,
3469
+ runCliFn: deps.runCli,
3470
+ pathExistsFn: deps.pathExists,
3276
3471
  });
3277
3472
  return { status: "chat" };
3278
3473
  }
@@ -3292,7 +3487,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3292
3487
  signal: followupSignal,
3293
3488
  });
3294
3489
 
3295
- let repoPath = resolveRepoPath(cfg, repoFullName);
3490
+ let repoPath = mappedRepoPath;
3296
3491
  if (!repoPath) {
3297
3492
  // Zero-config path: if the daemon is running INSIDE a checkout of this repo
3298
3493
  // (its origin matches), just use the current directory — no repos map needed.
@@ -3594,6 +3789,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3594
3789
  : Number.isSafeInteger(activeRun?.iterateTurn)
3595
3790
  ? Number(activeRun.iterateTurn)
3596
3791
  : 0;
3792
+ let iterateClaimId = null;
3793
+ let iterateAuthorityLost = false;
3597
3794
  if (message.dispatch?.runId && caps.runs) {
3598
3795
  // Adopt the dispatch's canonical run. If the server already SETTLED it (the
3599
3796
  // reaper/outbox closed a dispatch we were slow to pick up), leave it alone
@@ -3623,12 +3820,18 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3623
3820
  }
3624
3821
  if (caps.runs && effectiveMode === "iterate" && runId && !message.dispatch?.runId) {
3625
3822
  try {
3626
- const claimed = await claimDaemonIterate({ tool, runId, expectedIterateTurn: iterateTurn });
3823
+ const claimed = await claimDaemonIterate({
3824
+ tool,
3825
+ runId,
3826
+ expectedIterateTurn: iterateTurn,
3827
+ recoveryStore: iterateClaimRecoveryStore,
3828
+ });
3627
3829
  const claimedTurn = claimed?.iterateTurn;
3628
3830
  if (!Number.isSafeInteger(claimedTurn) || Number(claimedTurn) !== iterateTurn + 1) {
3629
3831
  throw new Error("the server did not return the claimed Iterate generation");
3630
3832
  }
3631
3833
  iterateTurn = Number(claimedTurn);
3834
+ iterateClaimId = claimed.claimId;
3632
3835
  } catch {
3633
3836
  // Unlike optional bookkeeping, the generation claim is execution
3634
3837
  // authority. Continuing after losing it would let two daemons ship one
@@ -3683,6 +3886,21 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3683
3886
  caps.runInputs && runId
3684
3887
  ? createDirectionInbox({ tool, runId, log: (line) => console.log(line) })
3685
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
+ };
3686
3904
  const settleConflictFailure = async (reason) => {
3687
3905
  if (!activeConflictMerge || !runId || !caps.runs) return;
3688
3906
  await tool("update_run", {
@@ -3709,6 +3927,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3709
3927
  repoFullName,
3710
3928
  cfg,
3711
3929
  signal,
3930
+ cwd: repoPath,
3931
+ beforeSpawn: beforeCodingSpawn,
3712
3932
  }).catch(() => null);
3713
3933
  if (plan && !signal?.aborted) await tool("edit_message", { messageId: ackId, body: plan }).catch(() => {});
3714
3934
  }
@@ -3840,6 +4060,18 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3840
4060
  * heartbeat does (the room hears who stopped it; the job's signal aborts).
3841
4061
  */
3842
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
+ }
3843
4075
  // Only the streaming card carries progress metadata. The legacy heartbeat's
3844
4076
  // message is plain text, and stamping a lifecycle onto it would turn a
3845
4077
  // status line into a run card — so that path simply has no pre-ship check.
@@ -3894,7 +4126,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
3894
4126
  }
3895
4127
  };
3896
4128
 
3897
- const parts = cfg.codingCmd.split(" ").filter(Boolean);
4129
+ const parts = commandArgv(cfg.codingCmd);
3898
4130
  // Run the CLI and stage everything it changed; return the diff stats (no post).
3899
4131
  // Workspace memory (the project's soul) is prepended so the coding agent has
3900
4132
  // the shared context before it starts.
@@ -4128,6 +4360,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
4128
4360
  env: scrubHilosEnv(codingChildWebEnv(cfg, vendor) || process.env),
4129
4361
  onData: handleCliData,
4130
4362
  onEvent: handleCliEvent,
4363
+ beforeSpawn: beforeCodingSpawn,
4131
4364
  ...openCodePermissionCallbacks({
4132
4365
  tool,
4133
4366
  channelId,
@@ -4150,6 +4383,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
4150
4383
  // after this scrub and dies with the process group.
4151
4384
  env: scrubHilosEnv(codingChildWebEnv(cfg, vendor) || process.env),
4152
4385
  onData: handleCliData,
4386
+ beforeSpawn: beforeCodingSpawn,
4153
4387
  ...openCodePermissionCallbacks({
4154
4388
  tool,
4155
4389
  channelId,
@@ -4174,6 +4408,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
4174
4408
  signal,
4175
4409
  onData: handleCliData,
4176
4410
  onEvent: handleCliEvent,
4411
+ beforeSpawn: beforeCodingSpawn,
4177
4412
  permissionCallbacks: openCodePermissionCallbacks({
4178
4413
  tool,
4179
4414
  channelId,
@@ -4196,6 +4431,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
4196
4431
  cwd: repoPath,
4197
4432
  signal,
4198
4433
  onData: handleCliData,
4434
+ beforeSpawn: beforeCodingSpawn,
4199
4435
  sessionId: resume ? resumeSessionId ?? "" : "",
4200
4436
  resolveSessionId: () => emitter?.snapshot()?.sessionId ?? null,
4201
4437
  permissionCallbacks: openCodePermissionCallbacks({
@@ -4221,6 +4457,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
4221
4457
  signal,
4222
4458
  env: codingChildWebEnv(cfg, vendor),
4223
4459
  onData: handleCliData,
4460
+ beforeSpawn: beforeCodingSpawn,
4224
4461
  });
4225
4462
  }
4226
4463
  // 0785 — the gate was expected here and the CLI couldn't hold it. Say so
@@ -4516,9 +4753,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
4516
4753
  // Post the right cancel message: honest about whether cleanup actually worked.
4517
4754
  const postStopped = async () => {
4518
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";
4519
4759
  const body = clean
4520
- ? `Stopped — discarded \`${branch}\`.`
4521
- : `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}.`;
4522
4762
  await tool("post_message", {
4523
4763
  channelId,
4524
4764
  parentId: threadRoot,