hilos-agent 0.6.0 → 0.9.0
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/README.md +29 -3
- package/bin/hilos-agent.mjs +16 -1
- package/package.json +1 -1
- package/src/acp-session.mjs +538 -0
- package/src/agent-events.mjs +232 -2
- package/src/cli.mjs +33 -4
- package/src/config.mjs +8 -0
- package/src/daemon.mjs +23 -0
- package/src/handler.mjs +558 -95
- package/src/opencode-permissions.mjs +654 -0
- package/src/opencode-session.mjs +770 -0
- package/src/progress-emitter.mjs +103 -9
- package/src/resume.mjs +85 -12
- package/src/run.mjs +10 -0
- package/src/thread-pr.mjs +305 -0
package/src/handler.mjs
CHANGED
|
@@ -25,12 +25,37 @@ import {
|
|
|
25
25
|
prTitleBody,
|
|
26
26
|
mentionHandle,
|
|
27
27
|
detectPrContinuation,
|
|
28
|
+
selfDrivenShipPlan,
|
|
28
29
|
} from "./daemon.mjs";
|
|
29
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
runCli,
|
|
32
|
+
buildHeartbeat,
|
|
33
|
+
ackText,
|
|
34
|
+
oneLine,
|
|
35
|
+
fmtElapsed,
|
|
36
|
+
minimalEnv,
|
|
37
|
+
scrubHilosEnv,
|
|
38
|
+
envForCwd,
|
|
39
|
+
} from "./cli.mjs";
|
|
30
40
|
import { makeStreamParser } from "./agent-events.mjs";
|
|
31
|
-
import {
|
|
41
|
+
import {
|
|
42
|
+
detectVendor,
|
|
43
|
+
codeStreamArgs,
|
|
44
|
+
codeDirArgs,
|
|
45
|
+
codeProjectKey,
|
|
46
|
+
attachTarget,
|
|
47
|
+
createProgressEmitter,
|
|
48
|
+
fastChatCmd,
|
|
49
|
+
} from "./progress-emitter.mjs";
|
|
32
50
|
import { resolveFollowupMode, classifyFollowupCue, normalizeSignal } from "./followup.mjs";
|
|
33
|
-
import {
|
|
51
|
+
import {
|
|
52
|
+
anchorIterateDecision,
|
|
53
|
+
confirmAnchorOpen,
|
|
54
|
+
prActionRelayDecision,
|
|
55
|
+
prActionReply,
|
|
56
|
+
staleAnchorNote,
|
|
57
|
+
} from "./thread-pr.mjs";
|
|
58
|
+
import { buildResumeArgs, resumeDecision, readStateEntry, writeState, HILOS_DIR } from "./resume.mjs";
|
|
34
59
|
import { createModelArgsResolver } from "./model-resolve.mjs";
|
|
35
60
|
import {
|
|
36
61
|
buildReviewPrompt,
|
|
@@ -40,6 +65,8 @@ import {
|
|
|
40
65
|
} from "./review.mjs";
|
|
41
66
|
import { buildMemoryBlock } from "./memory.mjs";
|
|
42
67
|
import { deployFolder, resolveDeployTarget } from "./deploy.mjs";
|
|
68
|
+
import { runOpenCodeHttpSession } from "./opencode-session.mjs";
|
|
69
|
+
import { runAcpSession } from "./acp-session.mjs";
|
|
43
70
|
|
|
44
71
|
/**
|
|
45
72
|
* The environment for a coding/chat CLI run. runCli always strips HILOS_* on top
|
|
@@ -120,14 +147,21 @@ function compactRunMarker(status, branch) {
|
|
|
120
147
|
return `Failed:${b}`;
|
|
121
148
|
}
|
|
122
149
|
|
|
150
|
+
// Every helper below runs a tool in a directory we choose, so each one hands
|
|
151
|
+
// the child a PWD that matches that directory instead of the daemon's launch
|
|
152
|
+
// dir (0615). git and gh both use the real cwd, so this is hygiene rather than
|
|
153
|
+
// a fix — but a child whose env contradicts its cwd is wrong on any tool.
|
|
154
|
+
const cwdEnv = (cwd) => envForCwd(process.env, cwd);
|
|
155
|
+
|
|
123
156
|
function defaultDeps() {
|
|
124
157
|
return {
|
|
125
158
|
git: (cwd, args) =>
|
|
126
|
-
spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 50 * 1024 * 1024 }),
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
// imported runCli directly (unchanged); only folder mode goes through deps.
|
|
159
|
+
spawnSync("git", args, { cwd, env: cwdEnv(cwd), encoding: "utf8", maxBuffer: 50 * 1024 * 1024 }),
|
|
160
|
+
// Async coding runners are injectable so handler-level tests can prove the
|
|
161
|
+
// selected trust boundary without spawning a real model process.
|
|
130
162
|
runCli: (opts) => runCli(opts),
|
|
163
|
+
runOpenCodeHttpSession: (opts) => runOpenCodeHttpSession(opts),
|
|
164
|
+
runAcpSession: (opts) => runAcpSession(opts),
|
|
131
165
|
// Does a path exist on disk? Injectable so folder mode's "missing folder"
|
|
132
166
|
// guard is unit-testable without touching the real filesystem.
|
|
133
167
|
pathExists: (p) => existsSync(p),
|
|
@@ -137,10 +171,10 @@ function defaultDeps() {
|
|
|
137
171
|
const r = spawnSync(
|
|
138
172
|
"gh",
|
|
139
173
|
["pr", "create", "--title", title, "--body", body, "--head", branch, "--base", base],
|
|
140
|
-
{ cwd, encoding: "utf8" },
|
|
174
|
+
{ cwd, env: cwdEnv(cwd), encoding: "utf8" },
|
|
141
175
|
);
|
|
142
176
|
const url = (r.stdout || "").trim().split("\n").filter(Boolean).pop() || null;
|
|
143
|
-
return { ok: r.status === 0, url, stderr: r.stderr || "" };
|
|
177
|
+
return { ok: r.status === 0, url, stdout: r.stdout || "", stderr: r.stderr || "" };
|
|
144
178
|
},
|
|
145
179
|
// The open PR for a branch, if one already exists — so when the CLI opened a
|
|
146
180
|
// PR itself we report THAT instead of opening a duplicate.
|
|
@@ -148,7 +182,7 @@ function defaultDeps() {
|
|
|
148
182
|
const r = spawnSync(
|
|
149
183
|
"gh",
|
|
150
184
|
["pr", "list", "--head", branch, "--state", "open", "--json", "url", "--jq", ".[0].url // empty"],
|
|
151
|
-
{ cwd, encoding: "utf8" },
|
|
185
|
+
{ cwd, env: cwdEnv(cwd), encoding: "utf8" },
|
|
152
186
|
);
|
|
153
187
|
const url = (r.stdout || "").trim();
|
|
154
188
|
return r.status === 0 && url ? url : null;
|
|
@@ -160,16 +194,160 @@ function defaultDeps() {
|
|
|
160
194
|
const r = spawnSync(
|
|
161
195
|
"gh",
|
|
162
196
|
["pr", "view", String(ref), "--json", "headRefName", "--jq", ".headRefName // empty"],
|
|
163
|
-
{ cwd, encoding: "utf8" },
|
|
197
|
+
{ cwd, env: cwdEnv(cwd), encoding: "utf8" },
|
|
164
198
|
);
|
|
165
199
|
const out = (r.stdout || "").trim();
|
|
166
200
|
return r.status === 0 && out ? out : null;
|
|
167
201
|
},
|
|
202
|
+
// The live state of a PR (0704): is it still OPEN, what is its head branch,
|
|
203
|
+
// and does that head live in the base repo? The staleness guard for adopting
|
|
204
|
+
// the thread's pull request — a merged/closed anchor must never take a push,
|
|
205
|
+
// and a fork head is another repository's branch. Returns null when `gh`
|
|
206
|
+
// can't answer, which the caller reads as "no proof, no adoption".
|
|
207
|
+
prState: (cwd, ref) => {
|
|
208
|
+
const r = spawnSync(
|
|
209
|
+
"gh",
|
|
210
|
+
[
|
|
211
|
+
"pr",
|
|
212
|
+
"view",
|
|
213
|
+
String(ref),
|
|
214
|
+
"--json",
|
|
215
|
+
"state,headRefName,headRepository,headRepositoryOwner",
|
|
216
|
+
],
|
|
217
|
+
{ cwd, env: cwdEnv(cwd), encoding: "utf8" },
|
|
218
|
+
);
|
|
219
|
+
if (r.status !== 0) return null;
|
|
220
|
+
try {
|
|
221
|
+
const j = JSON.parse(r.stdout || "{}");
|
|
222
|
+
const owner = j.headRepositoryOwner?.login || j.headRepositoryOwner?.name || null;
|
|
223
|
+
const repo = j.headRepository?.name || null;
|
|
224
|
+
return {
|
|
225
|
+
state: typeof j.state === "string" ? j.state : "",
|
|
226
|
+
headRefName: typeof j.headRefName === "string" ? j.headRefName : null,
|
|
227
|
+
headRepoFullName: owner && repo ? `${owner}/${repo}` : null,
|
|
228
|
+
};
|
|
229
|
+
} catch {
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
},
|
|
168
233
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
169
234
|
now: () => Date.now(),
|
|
170
235
|
};
|
|
171
236
|
}
|
|
172
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Bind one OpenCode HTTP session to hilos's vendor-neutral permission tools.
|
|
240
|
+
* Both repo and direct-folder runs use this exact callback contract so neither
|
|
241
|
+
* path can accidentally become the ungated exception.
|
|
242
|
+
*/
|
|
243
|
+
function openCodePermissionCallbacks({
|
|
244
|
+
tool,
|
|
245
|
+
channelId,
|
|
246
|
+
threadRoot,
|
|
247
|
+
runId = null,
|
|
248
|
+
provider = "opencode",
|
|
249
|
+
}) {
|
|
250
|
+
return {
|
|
251
|
+
requestPermission: async (request) => {
|
|
252
|
+
const detail =
|
|
253
|
+
request.metadata && typeof request.metadata === "object"
|
|
254
|
+
? request.metadata
|
|
255
|
+
: {};
|
|
256
|
+
const title = [
|
|
257
|
+
detail.title,
|
|
258
|
+
detail.description,
|
|
259
|
+
detail.command,
|
|
260
|
+
request.resources?.[0],
|
|
261
|
+
].find((value) => typeof value === "string" && value.trim());
|
|
262
|
+
return tool("request_permission", {
|
|
263
|
+
channelId,
|
|
264
|
+
threadRootId: threadRoot,
|
|
265
|
+
...(runId ? { runId } : {}),
|
|
266
|
+
provider,
|
|
267
|
+
vendorSessionId: request.sessionId,
|
|
268
|
+
vendorRequestId: request.vendorRequestId,
|
|
269
|
+
action: request.action,
|
|
270
|
+
title: title || `${request.action} permission`,
|
|
271
|
+
resources: request.resources,
|
|
272
|
+
suggestedSave: request.suggestedSave,
|
|
273
|
+
metadata: detail,
|
|
274
|
+
...(request.source ? { source: request.source } : {}),
|
|
275
|
+
});
|
|
276
|
+
},
|
|
277
|
+
getPermissionDecision: async (handle, { request, failClosed = false }) => {
|
|
278
|
+
const requestId =
|
|
279
|
+
handle &&
|
|
280
|
+
typeof handle === "object" &&
|
|
281
|
+
typeof handle.requestId === "string"
|
|
282
|
+
? handle.requestId
|
|
283
|
+
: null;
|
|
284
|
+
if (!requestId) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
"hilos returned a pending permission without a request id",
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
return tool("get_permission_decision", {
|
|
290
|
+
requestId,
|
|
291
|
+
provider,
|
|
292
|
+
vendorSessionId: request.sessionId,
|
|
293
|
+
vendorRequestId: request.vendorRequestId,
|
|
294
|
+
...(failClosed ? { failClosed: true } : {}),
|
|
295
|
+
});
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** The local HTTP bridge can own only a local OpenCode server. An explicit
|
|
301
|
+
* `--attach` remains on OpenCode's CLI responder, which rejects unanswered asks
|
|
302
|
+
* fail closed; taking over a remote server requires a separate authenticated
|
|
303
|
+
* transport contract. Shared by repo and direct-folder runs. */
|
|
304
|
+
export function shouldUseRuntimePermissionBridge({
|
|
305
|
+
vendor,
|
|
306
|
+
runtimePermissions,
|
|
307
|
+
codeArgs,
|
|
308
|
+
codingCmd,
|
|
309
|
+
}) {
|
|
310
|
+
return (
|
|
311
|
+
vendor === "opencode" &&
|
|
312
|
+
runtimePermissions === true &&
|
|
313
|
+
!codeArgs.includes("--auto") &&
|
|
314
|
+
attachTarget(codingCmd) === null
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** ACP transport opt-in (0759; slice 1 opencode, slice 2 cursor). Only runs
|
|
319
|
+
* the workspace already gates with runtime permissions qualify, PLUS the
|
|
320
|
+
* operator's explicit acpTransport flag, and only when the vendor's own
|
|
321
|
+
* auto-allow escape hatches are absent — over ACP, hilos must be the one
|
|
322
|
+
* answering asks, so a run configured to never ask has nothing to gate here.
|
|
323
|
+
* Resumed runs stay on the existing paths: their session continuity contract
|
|
324
|
+
* is argv/HTTP-shaped and the ACP path does not carry it yet. */
|
|
325
|
+
export function shouldUseAcpTransport({
|
|
326
|
+
vendor,
|
|
327
|
+
acpTransport,
|
|
328
|
+
runtimePermissions,
|
|
329
|
+
codeArgs,
|
|
330
|
+
codingCmd,
|
|
331
|
+
resumeSessionId = /** @type {string | null} */ (null),
|
|
332
|
+
}) {
|
|
333
|
+
if (acpTransport !== true || resumeSessionId != null) return false;
|
|
334
|
+
if (runtimePermissions !== true) return false;
|
|
335
|
+
if (vendor === "opencode") {
|
|
336
|
+
return shouldUseRuntimePermissionBridge({
|
|
337
|
+
vendor,
|
|
338
|
+
runtimePermissions,
|
|
339
|
+
codeArgs,
|
|
340
|
+
codingCmd,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
if (vendor === "cursor") {
|
|
344
|
+
// -f/--force is cursor's "allow everything" switch — the exact analog of
|
|
345
|
+
// opencode's --auto.
|
|
346
|
+
return !codeArgs.includes("-f") && !codeArgs.includes("--force");
|
|
347
|
+
}
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
|
|
173
351
|
async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId, signal }) {
|
|
174
352
|
if (!reportMessageId) return { kind: "timeout" };
|
|
175
353
|
const deadline = deps.now() + cfg.decisionTimeoutMs;
|
|
@@ -333,37 +511,63 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
333
511
|
* The coding CLI committed the work itself (autonomous run with skip-permissions
|
|
334
512
|
* in a repo whose docs prescribe a commit+PR flow), so the working tree is clean
|
|
335
513
|
* and the daemon's diff is empty. Don't claim "no changes": adopt what it did —
|
|
336
|
-
* push
|
|
337
|
-
* open one, and report it.
|
|
514
|
+
* push its HEAD under the daemon-owned task branch, reuse the PR it opened or
|
|
515
|
+
* open one, and report it. Keeping the intended head matters: a child that
|
|
516
|
+
* switched to `main` would otherwise make `gh` try to open main → main. Used
|
|
517
|
+
* only when NOT gated (bias-to-action).
|
|
338
518
|
*/
|
|
339
519
|
async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId, settleId, runId }) {
|
|
340
520
|
const tag = requesterTag(requester);
|
|
341
521
|
const lead = tag ? `${tag} — ` : "";
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
let
|
|
522
|
+
const currentBranch =
|
|
523
|
+
(deps.git(repoPath, ["symbolic-ref", "--quiet", "--short", "HEAD"]).stdout || "").trim();
|
|
524
|
+
const ship = selfDrivenShipPlan(currentBranch, branch);
|
|
525
|
+
const push = deps.git(repoPath, ship.pushArgs);
|
|
526
|
+
let prUrl = deps.findPR ? deps.findPR(repoPath, ship.headBranch) : null;
|
|
527
|
+
let prAttempt = null;
|
|
348
528
|
if (!prUrl && push.status === 0) {
|
|
349
|
-
const { title, body } = prTitleBody(task,
|
|
350
|
-
|
|
351
|
-
|
|
529
|
+
const { title, body } = prTitleBody(task, ship.headBranch);
|
|
530
|
+
prAttempt = deps.openPR(repoPath, {
|
|
531
|
+
title,
|
|
532
|
+
body,
|
|
533
|
+
branch: ship.headBranch,
|
|
534
|
+
base: cfg.defaultBranch,
|
|
535
|
+
});
|
|
536
|
+
prUrl = prAttempt.ok && prAttempt.url ? prAttempt.url : null;
|
|
352
537
|
}
|
|
353
|
-
const { title } = prTitleBody(task,
|
|
538
|
+
const { title } = prTitleBody(task, ship.headBranch);
|
|
539
|
+
const recoveredNote = ship.recoveredFrom
|
|
540
|
+
? ` The child had switched to \`${ship.recoveredFrom}\`; hilos recovered its HEAD onto \`${ship.headBranch}\`.`
|
|
541
|
+
: "";
|
|
542
|
+
const prFailure =
|
|
543
|
+
prAttempt && !prUrl
|
|
544
|
+
? oneLine(
|
|
545
|
+
(prAttempt.stderr || prAttempt.stdout || "gh did not return a pull request URL").trim(),
|
|
546
|
+
200,
|
|
547
|
+
)
|
|
548
|
+
: "";
|
|
354
549
|
// Seamless single card (0289): settle onto the live status message when one was
|
|
355
550
|
// streaming this run; the workspace setting decides channel visibility.
|
|
356
551
|
// Otherwise post a fresh final report.
|
|
357
552
|
const reportArgs = {
|
|
358
553
|
channelId,
|
|
359
|
-
title:
|
|
554
|
+
title: prUrl
|
|
555
|
+
? `Shipped: ${title}`
|
|
556
|
+
: push.status === 0
|
|
557
|
+
? `PR failed: ${title}`
|
|
558
|
+
: `Push failed: ${title}`,
|
|
360
559
|
summary: prUrl
|
|
361
|
-
? `${lead}the coding agent committed
|
|
560
|
+
? `${lead}the coding agent committed the work itself and opened a pull request from \`${ship.headBranch}\`.${recoveredNote}`
|
|
362
561
|
: push.status === 0
|
|
363
|
-
? `${lead}the coding agent committed and pushed \`${
|
|
364
|
-
: `${lead}the coding agent committed
|
|
562
|
+
? `${lead}the coding agent committed and pushed \`${ship.headBranch}\`, but GitHub rejected PR creation.${recoveredNote}`
|
|
563
|
+
: `${lead}the coding agent committed locally, but pushing \`${ship.headBranch}\` failed.${recoveredNote}`,
|
|
365
564
|
prUrl: prUrl || undefined,
|
|
366
|
-
caveats:
|
|
565
|
+
caveats:
|
|
566
|
+
push.status !== 0
|
|
567
|
+
? [`git push failed: ${(push.stderr || "").trim().slice(0, 200)}`]
|
|
568
|
+
: prFailure
|
|
569
|
+
? [`gh pr create failed: ${prFailure}`]
|
|
570
|
+
: [],
|
|
367
571
|
};
|
|
368
572
|
await tool(
|
|
369
573
|
"post_report",
|
|
@@ -377,11 +581,25 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
|
|
|
377
581
|
await tool("update_run", {
|
|
378
582
|
runId,
|
|
379
583
|
...(prUrl ? { prUrl } : {}),
|
|
380
|
-
status: "awaiting_review",
|
|
584
|
+
status: prUrl ? "awaiting_review" : "failed",
|
|
585
|
+
...(!prUrl
|
|
586
|
+
? {
|
|
587
|
+
reason:
|
|
588
|
+
push.status !== 0
|
|
589
|
+
? oneLine((push.stderr || "git push failed").trim(), 300)
|
|
590
|
+
: prFailure || "GitHub did not return a pull request URL",
|
|
591
|
+
}
|
|
592
|
+
: {}),
|
|
381
593
|
...(settleId ? { reportMsgId: settleId } : {}),
|
|
382
594
|
}).catch(() => {});
|
|
383
595
|
}
|
|
384
|
-
|
|
596
|
+
const status =
|
|
597
|
+
push.status !== 0 ? "commit-local" : prUrl ? "pushed" : "pr-failed";
|
|
598
|
+
return {
|
|
599
|
+
status,
|
|
600
|
+
branch: ship.headBranch,
|
|
601
|
+
prUrl,
|
|
602
|
+
};
|
|
385
603
|
}
|
|
386
604
|
|
|
387
605
|
// Sentinel the router model emits when the latest message is a request to change
|
|
@@ -1279,34 +1497,98 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
|
|
|
1279
1497
|
let run;
|
|
1280
1498
|
// Model preset (0504): same run-time resolution as the repo path.
|
|
1281
1499
|
const modelArgs = await modelArgsFor(cfg, vendor);
|
|
1500
|
+
// Project pin (0608): opencode reads its project from PWD, so without this
|
|
1501
|
+
// a folder run could edit the daemon's launch directory instead of the
|
|
1502
|
+
// folder the channel is linked to. [] for every other vendor.
|
|
1503
|
+
const dirArgs = codeDirArgs(vendor, folderPath, cfg.codingCmd);
|
|
1504
|
+
const codeArgs = [
|
|
1505
|
+
...parts.slice(1),
|
|
1506
|
+
...modelArgs,
|
|
1507
|
+
...dirArgs,
|
|
1508
|
+
...streamArgs,
|
|
1509
|
+
];
|
|
1510
|
+
const handleCliData = (c) => {
|
|
1511
|
+
const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
1512
|
+
if (lines.length) lastLine = lines[lines.length - 1];
|
|
1513
|
+
if (resultParser) {
|
|
1514
|
+
try {
|
|
1515
|
+
foldResultEvents(resultParser.push(String(c)));
|
|
1516
|
+
} catch {
|
|
1517
|
+
/* result extraction must never break the run */
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
if (emitter) {
|
|
1521
|
+
try {
|
|
1522
|
+
emitter.feed(c);
|
|
1523
|
+
} catch {
|
|
1524
|
+
/* a progress fold must never break the run */
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
};
|
|
1282
1528
|
try {
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
signal,
|
|
1290
|
-
env: codingChildEnv(cfg),
|
|
1291
|
-
onData: (c) => {
|
|
1292
|
-
const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
1293
|
-
if (lines.length) lastLine = lines[lines.length - 1];
|
|
1294
|
-
if (resultParser) {
|
|
1295
|
-
try {
|
|
1296
|
-
foldResultEvents(resultParser.push(String(c)));
|
|
1297
|
-
} catch {
|
|
1298
|
-
/* result extraction must never break the run */
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
|
-
if (emitter) {
|
|
1302
|
-
try {
|
|
1303
|
-
emitter.feed(c);
|
|
1304
|
-
} catch {
|
|
1305
|
-
/* a progress fold must never break the run */
|
|
1306
|
-
}
|
|
1307
|
-
}
|
|
1308
|
-
},
|
|
1529
|
+
const useAcpTransport = shouldUseAcpTransport({
|
|
1530
|
+
vendor,
|
|
1531
|
+
acpTransport: cfg.acpTransport,
|
|
1532
|
+
runtimePermissions: caps.runtimePermissions,
|
|
1533
|
+
codeArgs,
|
|
1534
|
+
codingCmd: cfg.codingCmd,
|
|
1309
1535
|
});
|
|
1536
|
+
const useRuntimePermissionBridge =
|
|
1537
|
+
!useAcpTransport &&
|
|
1538
|
+
shouldUseRuntimePermissionBridge({
|
|
1539
|
+
vendor,
|
|
1540
|
+
runtimePermissions: caps.runtimePermissions,
|
|
1541
|
+
codeArgs,
|
|
1542
|
+
codingCmd: cfg.codingCmd,
|
|
1543
|
+
});
|
|
1544
|
+
if (useAcpTransport) {
|
|
1545
|
+
run = await deps.runAcpSession({
|
|
1546
|
+
cmd: parts[0],
|
|
1547
|
+
vendor,
|
|
1548
|
+
cwd: folderPath,
|
|
1549
|
+
prompt: memoryPreamble(workspaceMemory) + promptText,
|
|
1550
|
+
timeoutMs: cfg.runTimeoutMs,
|
|
1551
|
+
signal,
|
|
1552
|
+
env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
|
|
1553
|
+
onData: handleCliData,
|
|
1554
|
+
...openCodePermissionCallbacks({
|
|
1555
|
+
tool,
|
|
1556
|
+
channelId,
|
|
1557
|
+
threadRoot,
|
|
1558
|
+
provider: vendor,
|
|
1559
|
+
}),
|
|
1560
|
+
});
|
|
1561
|
+
} else if (useRuntimePermissionBridge) {
|
|
1562
|
+
run = await deps.runOpenCodeHttpSession({
|
|
1563
|
+
cmd: parts[0],
|
|
1564
|
+
args: codeArgs,
|
|
1565
|
+
cwd: folderPath,
|
|
1566
|
+
prompt: memoryPreamble(workspaceMemory) + promptText,
|
|
1567
|
+
timeoutMs: cfg.runTimeoutMs,
|
|
1568
|
+
signal,
|
|
1569
|
+
env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
|
|
1570
|
+
onData: handleCliData,
|
|
1571
|
+
...openCodePermissionCallbacks({
|
|
1572
|
+
tool,
|
|
1573
|
+
channelId,
|
|
1574
|
+
threadRoot,
|
|
1575
|
+
}),
|
|
1576
|
+
});
|
|
1577
|
+
} else {
|
|
1578
|
+
run = await deps.runCli({
|
|
1579
|
+
cmd: parts[0],
|
|
1580
|
+
args: [
|
|
1581
|
+
...codeArgs,
|
|
1582
|
+
memoryPreamble(workspaceMemory) + promptText,
|
|
1583
|
+
],
|
|
1584
|
+
cwd: folderPath,
|
|
1585
|
+
timeoutMs: cfg.runTimeoutMs,
|
|
1586
|
+
label: "coding",
|
|
1587
|
+
signal,
|
|
1588
|
+
env: codingChildEnv(cfg),
|
|
1589
|
+
onData: handleCliData,
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1310
1592
|
} finally {
|
|
1311
1593
|
stopHeartbeat();
|
|
1312
1594
|
if (emitter) {
|
|
@@ -1602,6 +1884,43 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
1602
1884
|
}
|
|
1603
1885
|
}
|
|
1604
1886
|
|
|
1887
|
+
// MERGE / CLOSE RELAY (0704): "merge it" in a PR thread used to get an answer
|
|
1888
|
+
// describing the button a human should press. Now it is executed — through the
|
|
1889
|
+
// server, which is the authority: merge_pr / close_pr take the id of the human
|
|
1890
|
+
// message that asked, re-classify that text with a forced-tool model call, and
|
|
1891
|
+
// check the author's workspace role before spending the App's write token. The
|
|
1892
|
+
// daemon carries no new trust; it only stops swallowing the request.
|
|
1893
|
+
//
|
|
1894
|
+
// Runs BEFORE the chat/code split for the same reason the server runs it before
|
|
1895
|
+
// the coding gates: a merge instruction must never be read as a request to
|
|
1896
|
+
// start a new run. Every "no" here falls through to today's behavior.
|
|
1897
|
+
{
|
|
1898
|
+
const relay = prActionRelayDecision({
|
|
1899
|
+
body: message.body,
|
|
1900
|
+
threadPr: message.threadPr,
|
|
1901
|
+
mode,
|
|
1902
|
+
// A manager-routed dispatch is a brief to implement, not an instruction to
|
|
1903
|
+
// land something — its body is assembled text, never a person's sentence.
|
|
1904
|
+
canRelay: Boolean(caps.prActions) && !message.dispatch,
|
|
1905
|
+
});
|
|
1906
|
+
if (relay.relay) {
|
|
1907
|
+
const verb = relay.action;
|
|
1908
|
+
console.log(` ${verb} → relaying PR #${relay.anchor.prNumber} to the server (it decides)`);
|
|
1909
|
+
const result = await tool(verb === "merge" ? "merge_pr" : "close_pr", {
|
|
1910
|
+
channelId,
|
|
1911
|
+
// The mention itself is the instruction on record. The server verifies
|
|
1912
|
+
// it is a human's message, in this channel, from an owner or admin, and
|
|
1913
|
+
// that it really asks for THIS action — citing anything else fails.
|
|
1914
|
+
instructionMessageId: message.id,
|
|
1915
|
+
}).catch((e) => ({ ok: false, error: `I couldn't reach hilos to ${verb} that pull request: ${e?.message || e}` }));
|
|
1916
|
+
const reply = prActionReply(result);
|
|
1917
|
+
if (reply.post) {
|
|
1918
|
+
await tool("post_message", { channelId, parentId, body: reply.post }).catch(() => {});
|
|
1919
|
+
}
|
|
1920
|
+
return { status: reply.ok ? `pr-${verb}d` : "pr-action-refused", action: verb };
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1605
1924
|
// No repo linked → either FOLDER mode (0322: a channel mapped to a plain local
|
|
1606
1925
|
// folder can still get coding work done) or, failing that, just reply. A linked
|
|
1607
1926
|
// repo always wins — the folder map is only consulted when there's no repo link.
|
|
@@ -1898,22 +2217,65 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
1898
2217
|
git(repoPath, ["fetch", "origin", cfg.defaultBranch]);
|
|
1899
2218
|
|
|
1900
2219
|
// Same-branch iteration: continue an existing PR on its own branch and push
|
|
1901
|
-
// onto it, so the PR updates in place instead of a duplicate opening.
|
|
2220
|
+
// onto it, so the PR updates in place instead of a duplicate opening. Three ways
|
|
1902
2221
|
// in, and an explicit token WINS when both apply (they agree — same PR):
|
|
1903
2222
|
// 1. detectPrContinuation — an explicit "PR <url>" in the text (the server's
|
|
1904
2223
|
// "request changes" rework ping, or a person naming a PR). The fallback.
|
|
1905
2224
|
// 2. the run resolver's 'iterate' (0281) — a plain follow-up reply in a thread
|
|
1906
2225
|
// whose run already owns an OPEN PR. The PRIMARY path.
|
|
2226
|
+
// 3. the THREAD ANCHOR (0704) — no active run owns this thread, but the server
|
|
2227
|
+
// says (on list_mentions, as `threadPr`) that the thread is about a pull
|
|
2228
|
+
// request: a second agent asked here, or a reply long after the first run
|
|
2229
|
+
// settled, still amends THAT PR. Same shape as the server's own anchor
|
|
2230
|
+
// fallback, and it never overrides 1 or 2.
|
|
1907
2231
|
// Otherwise, a fresh branch off the default (today's behavior).
|
|
1908
2232
|
const cont = detectPrContinuation(message.body, repoFullName);
|
|
1909
|
-
const
|
|
2233
|
+
const runIterateUrl = (followupMode === "iterate" && activeRun?.prUrl) || null;
|
|
2234
|
+
// The anchor is a HYPOTHESIS (its recorded state may be stale or unknown);
|
|
2235
|
+
// `gh` is the proof. No proof, no adoption — the run cuts a fresh branch and
|
|
2236
|
+
// says so on the status card rather than pushing onto a merged PR.
|
|
2237
|
+
let anchorAdopt = null;
|
|
2238
|
+
let anchorNote = "";
|
|
2239
|
+
if (!cont?.url && !runIterateUrl) {
|
|
2240
|
+
const decision = anchorIterateDecision({
|
|
2241
|
+
threadPr: message.threadPr,
|
|
2242
|
+
repoFullName,
|
|
2243
|
+
signal: followupSignal,
|
|
2244
|
+
});
|
|
2245
|
+
if (decision.adopt && deps.prState) {
|
|
2246
|
+
const live = deps.prState(repoPath, decision.anchor.prUrl);
|
|
2247
|
+
const confirmed = confirmAnchorOpen({ live, anchor: decision.anchor });
|
|
2248
|
+
if (confirmed.ok) {
|
|
2249
|
+
anchorAdopt = {
|
|
2250
|
+
prUrl: decision.anchor.prUrl,
|
|
2251
|
+
prNumber: decision.anchor.prNumber,
|
|
2252
|
+
branch: confirmed.branch,
|
|
2253
|
+
};
|
|
2254
|
+
console.log(
|
|
2255
|
+
` code → thread anchor: PR #${decision.anchor.prNumber} is open on \`${confirmed.branch}\` — adopting it`,
|
|
2256
|
+
);
|
|
2257
|
+
} else {
|
|
2258
|
+
anchorNote = staleAnchorNote({ reason: confirmed.reason, anchor: decision.anchor });
|
|
2259
|
+
console.log(
|
|
2260
|
+
` ! thread anchor PR #${decision.anchor.prNumber} not adopted (${confirmed.reason}) — new branch`,
|
|
2261
|
+
);
|
|
2262
|
+
}
|
|
2263
|
+
} else if (decision.adopt) {
|
|
2264
|
+
anchorNote = staleAnchorNote({ reason: "unverified", anchor: decision.anchor });
|
|
2265
|
+
} else if (decision.reason === "anchor-merged" || decision.reason === "anchor-closed") {
|
|
2266
|
+
anchorNote = staleAnchorNote({ reason: decision.reason, anchor: decision.anchor });
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
const iterateUrl = cont?.url || runIterateUrl || anchorAdopt?.prUrl || null;
|
|
1910
2270
|
let branch = null;
|
|
1911
2271
|
let continuingPrUrl = null;
|
|
1912
|
-
if (iterateUrl && deps.prHeadRef) {
|
|
2272
|
+
if (iterateUrl && (deps.prHeadRef || anchorAdopt)) {
|
|
1913
2273
|
// Resolve the PR's head branch (source of truth); for a run-based iterate the
|
|
1914
|
-
// run's recorded branch is a fallback if gh can't view the PR.
|
|
2274
|
+
// run's recorded branch is a fallback if gh can't view the PR. An adopted
|
|
2275
|
+
// anchor already has its head branch straight from the live PR.
|
|
1915
2276
|
const headRef =
|
|
1916
|
-
|
|
2277
|
+
(anchorAdopt?.prUrl === iterateUrl ? anchorAdopt.branch : null) ||
|
|
2278
|
+
deps.prHeadRef?.(repoPath, iterateUrl) ||
|
|
1917
2279
|
(followupMode === "iterate" ? activeRun?.branch || null : null);
|
|
1918
2280
|
if (headRef) {
|
|
1919
2281
|
// Fetch the PR branch, then point a local branch at its tip via FETCH_HEAD
|
|
@@ -1931,6 +2293,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
1931
2293
|
console.log(
|
|
1932
2294
|
` ! couldn't check out PR branch \`${headRef}\` (falling back to a new branch): ${(sw.stderr || "").trim().slice(0, 200)}`,
|
|
1933
2295
|
);
|
|
2296
|
+
// An adopted anchor that can't be checked out is the same broken promise
|
|
2297
|
+
// as a stale one — the thread asked about a PR and gets a new branch, so
|
|
2298
|
+
// the status card has to say it.
|
|
2299
|
+
if (anchorAdopt?.prUrl === iterateUrl) {
|
|
2300
|
+
anchorNote = staleAnchorNote({ reason: "checkout-failed", anchor: anchorAdopt });
|
|
2301
|
+
}
|
|
1934
2302
|
}
|
|
1935
2303
|
}
|
|
1936
2304
|
}
|
|
@@ -1982,6 +2350,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
1982
2350
|
} else if (effectiveMode === "redirect") {
|
|
1983
2351
|
const n = activeRun?.prNumber || prNumberFromUrl(activeRun?.prUrl);
|
|
1984
2352
|
ackBody = `Starting a new PR${n ? ` (separate from #${n})` : ""} on \`${branch}\`.`;
|
|
2353
|
+
} else if (anchorNote) {
|
|
2354
|
+
// The thread is about a pull request this run could NOT adopt (0704). Say it
|
|
2355
|
+
// on the status card itself — silence here would read as "pushing to my PR"
|
|
2356
|
+
// while a fresh branch is being cut. Still one card, per the run-output
|
|
2357
|
+
// contract: no extra chat message.
|
|
2358
|
+
ackBody = `${anchorNote} ${ackBody}`;
|
|
1985
2359
|
}
|
|
1986
2360
|
const ack = await tool("post_message", { channelId, parentId, body: ackBody });
|
|
1987
2361
|
const ackId = ack?.messageId ?? null;
|
|
@@ -2037,7 +2411,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2037
2411
|
branch,
|
|
2038
2412
|
// Map an unrecognized command to null rather than the off-vocabulary
|
|
2039
2413
|
// "unknown" — provider is documented as
|
|
2040
|
-
// claude_code|codex|cursor|antigravity|hermes|hilos.
|
|
2414
|
+
// claude_code|codex|cursor|opencode|antigravity|hermes|hilos.
|
|
2041
2415
|
provider: (() => {
|
|
2042
2416
|
const v = detectVendor(cfg.codingCmd);
|
|
2043
2417
|
return v === "unknown" ? null : v;
|
|
@@ -2050,7 +2424,14 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2050
2424
|
}
|
|
2051
2425
|
// Keep the inspectable continuation/redirect statement (don't overwrite it with
|
|
2052
2426
|
// an LLM plan) so a human can correct the routing before code lands.
|
|
2053
|
-
if (
|
|
2427
|
+
if (
|
|
2428
|
+
ackId &&
|
|
2429
|
+
caps.editMessage &&
|
|
2430
|
+
chatCmdFor(cfg) &&
|
|
2431
|
+
!continuingPrUrl &&
|
|
2432
|
+
!anchorNote &&
|
|
2433
|
+
effectiveMode !== "redirect"
|
|
2434
|
+
) {
|
|
2054
2435
|
// Feed the ack the router's distilled brief AND the conversation — not the raw
|
|
2055
2436
|
// mention — so it states a real plan instead of "what's the task?".
|
|
2056
2437
|
const plan = await proposePlanAck({
|
|
@@ -2085,9 +2466,13 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2085
2466
|
// (get_active_run) with no local match means the session likely lives on another
|
|
2086
2467
|
// machine/instance — a bad `--resume` id makes claude error → empty diff → a failed
|
|
2087
2468
|
// run, so we DON'T resume and degrade to today's branch+feedback (never worse).
|
|
2088
|
-
// claude_code + cursor have proven resume flags (0282/0573);
|
|
2469
|
+
// claude_code + cursor + opencode have proven resume flags (0282/0573/0608);
|
|
2470
|
+
// codex/unknown → []. The gate itself is pure (resumeDecision in resume.mjs):
|
|
2471
|
+
// vendor, machine, project and server agreement all have to line up, and any
|
|
2472
|
+
// "no" degrades to the branch+feedback iterate rather than risking a bad id.
|
|
2473
|
+
const projectKey = codeProjectKey(vendor, repoPath, cfg.codingCmd);
|
|
2089
2474
|
let resumeSessionId = null;
|
|
2090
|
-
if (effectiveMode === "iterate"
|
|
2475
|
+
if (effectiveMode === "iterate") {
|
|
2091
2476
|
const local = (() => {
|
|
2092
2477
|
try {
|
|
2093
2478
|
return readStateEntry(HILOS_DIR, threadRoot);
|
|
@@ -2096,14 +2481,18 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2096
2481
|
}
|
|
2097
2482
|
})();
|
|
2098
2483
|
const serverSid = activeRun?.providerSessionId || null;
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
}
|
|
2106
|
-
|
|
2484
|
+
const decision = resumeDecision({
|
|
2485
|
+
vendor,
|
|
2486
|
+
entry: local,
|
|
2487
|
+
serverSessionId: serverSid,
|
|
2488
|
+
machine,
|
|
2489
|
+
projectKey,
|
|
2490
|
+
});
|
|
2491
|
+
if (decision.sessionId) {
|
|
2492
|
+
resumeSessionId = decision.sessionId;
|
|
2493
|
+
console.log(` code → resuming session ${decision.sessionId} for this iterate (local + machine match)`);
|
|
2494
|
+
} else if (local?.sessionId || serverSid) {
|
|
2495
|
+
console.log(` code → not resuming (${decision.reason}); using branch + feedback`);
|
|
2107
2496
|
}
|
|
2108
2497
|
}
|
|
2109
2498
|
|
|
@@ -2220,34 +2609,103 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2220
2609
|
// list (cursor only today) — [] when unset/unresolvable, so the tool's
|
|
2221
2610
|
// default stands. Inserted before resume/stream flags, after the base.
|
|
2222
2611
|
const modelArgs = await modelArgsFor(cfg, vendor);
|
|
2612
|
+
// Project pin (0608, opencode only): the CLI resolves its project from PWD,
|
|
2613
|
+
// not the spawn cwd, and its sessions are per project — `--dir` makes both
|
|
2614
|
+
// deterministic. [] for every other vendor (and for an `--attach`ed run,
|
|
2615
|
+
// where the project lives on the remote server), so their ARGV is unchanged.
|
|
2616
|
+
// The spawned PWD now matches the cwd too (0615); `--dir` stays as the
|
|
2617
|
+
// CLI's own explicit contract, and to keep attach runs off our local path.
|
|
2618
|
+
const dirArgs = codeDirArgs(vendor, repoPath, cfg.codingCmd);
|
|
2223
2619
|
const codeArgs = streamOn
|
|
2224
|
-
? [...parts.slice(1), ...modelArgs, ...resumeArgs, ...streamArgs]
|
|
2225
|
-
: [...parts.slice(1), ...modelArgs, ...resumeArgs];
|
|
2620
|
+
? [...parts.slice(1), ...modelArgs, ...dirArgs, ...resumeArgs, ...streamArgs]
|
|
2621
|
+
: [...parts.slice(1), ...modelArgs, ...dirArgs, ...resumeArgs];
|
|
2622
|
+
const handleCliData = (c) => {
|
|
2623
|
+
// Keep tracking lastLine as a fallback (legacy heartbeat / honesty).
|
|
2624
|
+
const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
2625
|
+
if (lines.length) lastLine = lines[lines.length - 1];
|
|
2626
|
+
if (emitter) {
|
|
2627
|
+
try {
|
|
2628
|
+
emitter.feed(c);
|
|
2629
|
+
} catch {
|
|
2630
|
+
/* a progress fold must never break the run */
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
};
|
|
2226
2634
|
let run;
|
|
2227
2635
|
try {
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
if (lines.length) lastLine = lines[lines.length - 1];
|
|
2240
|
-
if (emitter) {
|
|
2241
|
-
try {
|
|
2242
|
-
emitter.feed(c);
|
|
2243
|
-
} catch {
|
|
2244
|
-
/* a progress fold must never break the run */
|
|
2245
|
-
}
|
|
2246
|
-
}
|
|
2247
|
-
},
|
|
2636
|
+
// OpenCode's own non-interactive CLI auto-rejects every permission ask
|
|
2637
|
+
// unless --auto is present. For the gated tiers, bypass that responder
|
|
2638
|
+
// and own the authenticated HTTP session + SSE stream directly so hilos
|
|
2639
|
+
// is the sole authority answering the paused tool call (0593).
|
|
2640
|
+
const useAcpTransport = shouldUseAcpTransport({
|
|
2641
|
+
vendor,
|
|
2642
|
+
acpTransport: cfg.acpTransport,
|
|
2643
|
+
runtimePermissions: caps.runtimePermissions,
|
|
2644
|
+
codeArgs,
|
|
2645
|
+
codingCmd: cfg.codingCmd,
|
|
2646
|
+
resumeSessionId: resume ? resumeSessionId : null,
|
|
2248
2647
|
});
|
|
2648
|
+
const useRuntimePermissionBridge =
|
|
2649
|
+
!useAcpTransport &&
|
|
2650
|
+
shouldUseRuntimePermissionBridge({
|
|
2651
|
+
vendor,
|
|
2652
|
+
runtimePermissions: caps.runtimePermissions,
|
|
2653
|
+
codeArgs,
|
|
2654
|
+
codingCmd: cfg.codingCmd,
|
|
2655
|
+
});
|
|
2656
|
+
if (useAcpTransport) {
|
|
2657
|
+
run = await deps.runAcpSession({
|
|
2658
|
+
cmd: parts[0],
|
|
2659
|
+
vendor,
|
|
2660
|
+
cwd: repoPath,
|
|
2661
|
+
prompt: memoryPreamble(workspaceMemory) + promptText,
|
|
2662
|
+
timeoutMs: cfg.runTimeoutMs,
|
|
2663
|
+
signal,
|
|
2664
|
+
env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
|
|
2665
|
+
onData: handleCliData,
|
|
2666
|
+
...openCodePermissionCallbacks({
|
|
2667
|
+
tool,
|
|
2668
|
+
channelId,
|
|
2669
|
+
threadRoot,
|
|
2670
|
+
runId,
|
|
2671
|
+
provider: vendor,
|
|
2672
|
+
}),
|
|
2673
|
+
});
|
|
2674
|
+
} else if (useRuntimePermissionBridge) {
|
|
2675
|
+
run = await deps.runOpenCodeHttpSession({
|
|
2676
|
+
cmd: parts[0],
|
|
2677
|
+
args: codeArgs,
|
|
2678
|
+
cwd: repoPath,
|
|
2679
|
+
prompt: memoryPreamble(workspaceMemory) + promptText,
|
|
2680
|
+
timeoutMs: cfg.runTimeoutMs,
|
|
2681
|
+
signal,
|
|
2682
|
+
// A model running inside the server must never inherit the daemon's
|
|
2683
|
+
// hilos bearer token. The bridge's random loopback password is added
|
|
2684
|
+
// after this scrub and dies with the process group.
|
|
2685
|
+
env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
|
|
2686
|
+
onData: handleCliData,
|
|
2687
|
+
...openCodePermissionCallbacks({
|
|
2688
|
+
tool,
|
|
2689
|
+
channelId,
|
|
2690
|
+
threadRoot,
|
|
2691
|
+
runId,
|
|
2692
|
+
}),
|
|
2693
|
+
});
|
|
2694
|
+
} else {
|
|
2695
|
+
run = await runCli({
|
|
2696
|
+
cmd: parts[0],
|
|
2697
|
+
args: [...codeArgs, memoryPreamble(workspaceMemory) + promptText],
|
|
2698
|
+
cwd: repoPath,
|
|
2699
|
+
timeoutMs: cfg.runTimeoutMs,
|
|
2700
|
+
label: "coding",
|
|
2701
|
+
signal,
|
|
2702
|
+
env: codingChildEnv(cfg),
|
|
2703
|
+
onData: handleCliData,
|
|
2704
|
+
});
|
|
2705
|
+
}
|
|
2249
2706
|
} finally {
|
|
2250
2707
|
stopHeartbeat();
|
|
2708
|
+
if (run?.sessionId) runSessionId = run.sessionId;
|
|
2251
2709
|
if (emitter) {
|
|
2252
2710
|
// Terminal state: flip the status card off "working" (state 'done'/'error')
|
|
2253
2711
|
// so it stops claiming the agent is alive. For a run that produced work,
|
|
@@ -2355,6 +2813,11 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2355
2813
|
prUrl: prUrl || continuingPrUrl || null,
|
|
2356
2814
|
sessionId: sid,
|
|
2357
2815
|
machine,
|
|
2816
|
+
// The CLI that created the session (a `ses_…` is meaningless to
|
|
2817
|
+
// claude's `--resume`) and the project it belongs to — opencode
|
|
2818
|
+
// scopes sessions per project. The gate above requires both (0608).
|
|
2819
|
+
vendor,
|
|
2820
|
+
cwd: projectKey,
|
|
2358
2821
|
updatedAt: new Date().toISOString(),
|
|
2359
2822
|
});
|
|
2360
2823
|
}
|