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/LICENSE +21 -0
- package/README.md +31 -16
- package/bin/hilos-agent.mjs +87 -14
- package/package.json +6 -4
- package/src/acp-session.mjs +16 -1
- package/src/cli.mjs +34 -1
- package/src/codex-mcp-session.mjs +36 -0
- package/src/config.mjs +39 -5
- package/src/cursor-store.mjs +43 -8
- package/src/handler.mjs +277 -37
- package/src/iterate-claim-recovery.mjs +574 -0
- package/src/mcp.mjs +411 -34
- package/src/model-resolve.mjs +3 -1
- package/src/opencode-session.mjs +22 -2
- package/src/progress-emitter.mjs +1 -1
- package/src/queue.mjs +70 -3
- package/src/run.mjs +453 -76
- package/src/webmcp-bridge.mjs +77 -4
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 || "")
|
|
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
|
-
|
|
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
|
-
|
|
1175
|
+
await releaseDaemonIterateClaim({ tool, recoveryStore, record });
|
|
1097
1176
|
} catch {
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
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({
|
|
1495
|
-
|
|
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
|
|
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({
|
|
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
|
|
1851
|
+
const parts = commandArgv(cmd);
|
|
1723
1852
|
const prompt = planAckPrompt({ task, transcript, repoFullName });
|
|
1724
|
-
const run = await
|
|
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({
|
|
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
|
-
?
|
|
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
|
-
?
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
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({
|
|
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({
|
|
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 =
|
|
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({
|
|
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
|
|
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
|
-
?
|
|
4521
|
-
:
|
|
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,
|