hilos-agent 0.5.1 → 0.7.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 +15 -3
- package/bin/hilos-agent.mjs +22 -2
- package/package.json +1 -1
- package/src/agent-events.mjs +190 -11
- package/src/cli.mjs +58 -5
- package/src/config.mjs +23 -2
- package/src/handler.mjs +270 -69
- package/src/model-resolve.mjs +99 -0
- package/src/opencode-permissions.mjs +654 -0
- package/src/opencode-session.mjs +770 -0
- package/src/progress-emitter.mjs +106 -14
- package/src/resume.mjs +88 -12
- package/src/run.mjs +5 -0
package/src/handler.mjs
CHANGED
|
@@ -26,11 +26,29 @@ import {
|
|
|
26
26
|
mentionHandle,
|
|
27
27
|
detectPrContinuation,
|
|
28
28
|
} from "./daemon.mjs";
|
|
29
|
-
import {
|
|
29
|
+
import {
|
|
30
|
+
runCli,
|
|
31
|
+
buildHeartbeat,
|
|
32
|
+
ackText,
|
|
33
|
+
oneLine,
|
|
34
|
+
fmtElapsed,
|
|
35
|
+
minimalEnv,
|
|
36
|
+
scrubHilosEnv,
|
|
37
|
+
envForCwd,
|
|
38
|
+
} from "./cli.mjs";
|
|
30
39
|
import { makeStreamParser } from "./agent-events.mjs";
|
|
31
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
detectVendor,
|
|
42
|
+
codeStreamArgs,
|
|
43
|
+
codeDirArgs,
|
|
44
|
+
codeProjectKey,
|
|
45
|
+
attachTarget,
|
|
46
|
+
createProgressEmitter,
|
|
47
|
+
fastChatCmd,
|
|
48
|
+
} from "./progress-emitter.mjs";
|
|
32
49
|
import { resolveFollowupMode, classifyFollowupCue, normalizeSignal } from "./followup.mjs";
|
|
33
|
-
import { buildResumeArgs, readStateEntry, writeState, HILOS_DIR } from "./resume.mjs";
|
|
50
|
+
import { buildResumeArgs, resumeDecision, readStateEntry, writeState, HILOS_DIR } from "./resume.mjs";
|
|
51
|
+
import { createModelArgsResolver } from "./model-resolve.mjs";
|
|
34
52
|
import {
|
|
35
53
|
buildReviewPrompt,
|
|
36
54
|
parseReviewOutput,
|
|
@@ -39,6 +57,7 @@ import {
|
|
|
39
57
|
} from "./review.mjs";
|
|
40
58
|
import { buildMemoryBlock } from "./memory.mjs";
|
|
41
59
|
import { deployFolder, resolveDeployTarget } from "./deploy.mjs";
|
|
60
|
+
import { runOpenCodeHttpSession } from "./opencode-session.mjs";
|
|
42
61
|
|
|
43
62
|
/**
|
|
44
63
|
* The environment for a coding/chat CLI run. runCli always strips HILOS_* on top
|
|
@@ -75,6 +94,10 @@ function prNumberFromUrl(url) {
|
|
|
75
94
|
// human re-triggering the review is the escape hatch past the cap.
|
|
76
95
|
const reviewRounds = new Map();
|
|
77
96
|
|
|
97
|
+
// Tier → account-verified `--model` args for the code run (0504). Memoized per
|
|
98
|
+
// (binary, tier) for the process; a failed lookup emits [] and retries later.
|
|
99
|
+
const modelArgsFor = createModelArgsResolver({ run: (opts) => runCli(opts) });
|
|
100
|
+
|
|
78
101
|
|
|
79
102
|
// Sentinel the router model emits (only when a thread already owns a run) to
|
|
80
103
|
// classify a follow-up: change | new-scope | ambiguous. Parsed out of the router
|
|
@@ -115,14 +138,20 @@ function compactRunMarker(status, branch) {
|
|
|
115
138
|
return `Failed:${b}`;
|
|
116
139
|
}
|
|
117
140
|
|
|
141
|
+
// Every helper below runs a tool in a directory we choose, so each one hands
|
|
142
|
+
// the child a PWD that matches that directory instead of the daemon's launch
|
|
143
|
+
// dir (0615). git and gh both use the real cwd, so this is hygiene rather than
|
|
144
|
+
// a fix — but a child whose env contradicts its cwd is wrong on any tool.
|
|
145
|
+
const cwdEnv = (cwd) => envForCwd(process.env, cwd);
|
|
146
|
+
|
|
118
147
|
function defaultDeps() {
|
|
119
148
|
return {
|
|
120
149
|
git: (cwd, args) =>
|
|
121
|
-
spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 50 * 1024 * 1024 }),
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
// imported runCli directly (unchanged); only folder mode goes through deps.
|
|
150
|
+
spawnSync("git", args, { cwd, env: cwdEnv(cwd), encoding: "utf8", maxBuffer: 50 * 1024 * 1024 }),
|
|
151
|
+
// Async coding runners are injectable so handler-level tests can prove the
|
|
152
|
+
// selected trust boundary without spawning a real model process.
|
|
125
153
|
runCli: (opts) => runCli(opts),
|
|
154
|
+
runOpenCodeHttpSession: (opts) => runOpenCodeHttpSession(opts),
|
|
126
155
|
// Does a path exist on disk? Injectable so folder mode's "missing folder"
|
|
127
156
|
// guard is unit-testable without touching the real filesystem.
|
|
128
157
|
pathExists: (p) => existsSync(p),
|
|
@@ -132,7 +161,7 @@ function defaultDeps() {
|
|
|
132
161
|
const r = spawnSync(
|
|
133
162
|
"gh",
|
|
134
163
|
["pr", "create", "--title", title, "--body", body, "--head", branch, "--base", base],
|
|
135
|
-
{ cwd, encoding: "utf8" },
|
|
164
|
+
{ cwd, env: cwdEnv(cwd), encoding: "utf8" },
|
|
136
165
|
);
|
|
137
166
|
const url = (r.stdout || "").trim().split("\n").filter(Boolean).pop() || null;
|
|
138
167
|
return { ok: r.status === 0, url, stderr: r.stderr || "" };
|
|
@@ -143,7 +172,7 @@ function defaultDeps() {
|
|
|
143
172
|
const r = spawnSync(
|
|
144
173
|
"gh",
|
|
145
174
|
["pr", "list", "--head", branch, "--state", "open", "--json", "url", "--jq", ".[0].url // empty"],
|
|
146
|
-
{ cwd, encoding: "utf8" },
|
|
175
|
+
{ cwd, env: cwdEnv(cwd), encoding: "utf8" },
|
|
147
176
|
);
|
|
148
177
|
const url = (r.stdout || "").trim();
|
|
149
178
|
return r.status === 0 && url ? url : null;
|
|
@@ -155,7 +184,7 @@ function defaultDeps() {
|
|
|
155
184
|
const r = spawnSync(
|
|
156
185
|
"gh",
|
|
157
186
|
["pr", "view", String(ref), "--json", "headRefName", "--jq", ".headRefName // empty"],
|
|
158
|
-
{ cwd, encoding: "utf8" },
|
|
187
|
+
{ cwd, env: cwdEnv(cwd), encoding: "utf8" },
|
|
159
188
|
);
|
|
160
189
|
const out = (r.stdout || "").trim();
|
|
161
190
|
return r.status === 0 && out ? out : null;
|
|
@@ -165,6 +194,80 @@ function defaultDeps() {
|
|
|
165
194
|
};
|
|
166
195
|
}
|
|
167
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Bind one OpenCode HTTP session to hilos's vendor-neutral permission tools.
|
|
199
|
+
* Both repo and direct-folder runs use this exact callback contract so neither
|
|
200
|
+
* path can accidentally become the ungated exception.
|
|
201
|
+
*/
|
|
202
|
+
function openCodePermissionCallbacks({ tool, channelId, threadRoot, runId = null }) {
|
|
203
|
+
return {
|
|
204
|
+
requestPermission: async (request) => {
|
|
205
|
+
const detail =
|
|
206
|
+
request.metadata && typeof request.metadata === "object"
|
|
207
|
+
? request.metadata
|
|
208
|
+
: {};
|
|
209
|
+
const title = [
|
|
210
|
+
detail.title,
|
|
211
|
+
detail.description,
|
|
212
|
+
detail.command,
|
|
213
|
+
request.resources?.[0],
|
|
214
|
+
].find((value) => typeof value === "string" && value.trim());
|
|
215
|
+
return tool("request_permission", {
|
|
216
|
+
channelId,
|
|
217
|
+
threadRootId: threadRoot,
|
|
218
|
+
...(runId ? { runId } : {}),
|
|
219
|
+
provider: "opencode",
|
|
220
|
+
vendorSessionId: request.sessionId,
|
|
221
|
+
vendorRequestId: request.vendorRequestId,
|
|
222
|
+
action: request.action,
|
|
223
|
+
title: title || `${request.action} permission`,
|
|
224
|
+
resources: request.resources,
|
|
225
|
+
suggestedSave: request.suggestedSave,
|
|
226
|
+
metadata: detail,
|
|
227
|
+
...(request.source ? { source: request.source } : {}),
|
|
228
|
+
});
|
|
229
|
+
},
|
|
230
|
+
getPermissionDecision: async (handle, { request, failClosed = false }) => {
|
|
231
|
+
const requestId =
|
|
232
|
+
handle &&
|
|
233
|
+
typeof handle === "object" &&
|
|
234
|
+
typeof handle.requestId === "string"
|
|
235
|
+
? handle.requestId
|
|
236
|
+
: null;
|
|
237
|
+
if (!requestId) {
|
|
238
|
+
throw new Error(
|
|
239
|
+
"hilos returned a pending permission without a request id",
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
return tool("get_permission_decision", {
|
|
243
|
+
requestId,
|
|
244
|
+
provider: "opencode",
|
|
245
|
+
vendorSessionId: request.sessionId,
|
|
246
|
+
vendorRequestId: request.vendorRequestId,
|
|
247
|
+
...(failClosed ? { failClosed: true } : {}),
|
|
248
|
+
});
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The local HTTP bridge can own only a local OpenCode server. An explicit
|
|
254
|
+
* `--attach` remains on OpenCode's CLI responder, which rejects unanswered asks
|
|
255
|
+
* fail closed; taking over a remote server requires a separate authenticated
|
|
256
|
+
* transport contract. Shared by repo and direct-folder runs. */
|
|
257
|
+
export function shouldUseRuntimePermissionBridge({
|
|
258
|
+
vendor,
|
|
259
|
+
runtimePermissions,
|
|
260
|
+
codeArgs,
|
|
261
|
+
codingCmd,
|
|
262
|
+
}) {
|
|
263
|
+
return (
|
|
264
|
+
vendor === "opencode" &&
|
|
265
|
+
runtimePermissions === true &&
|
|
266
|
+
!codeArgs.includes("--auto") &&
|
|
267
|
+
attachTarget(codingCmd) === null
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
168
271
|
async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId, signal }) {
|
|
169
272
|
if (!reportMessageId) return { kind: "timeout" };
|
|
170
273
|
const deadline = deps.now() + cfg.decisionTimeoutMs;
|
|
@@ -1272,34 +1375,74 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
|
|
|
1272
1375
|
};
|
|
1273
1376
|
}
|
|
1274
1377
|
let run;
|
|
1378
|
+
// Model preset (0504): same run-time resolution as the repo path.
|
|
1379
|
+
const modelArgs = await modelArgsFor(cfg, vendor);
|
|
1380
|
+
// Project pin (0608): opencode reads its project from PWD, so without this
|
|
1381
|
+
// a folder run could edit the daemon's launch directory instead of the
|
|
1382
|
+
// folder the channel is linked to. [] for every other vendor.
|
|
1383
|
+
const dirArgs = codeDirArgs(vendor, folderPath, cfg.codingCmd);
|
|
1384
|
+
const codeArgs = [
|
|
1385
|
+
...parts.slice(1),
|
|
1386
|
+
...modelArgs,
|
|
1387
|
+
...dirArgs,
|
|
1388
|
+
...streamArgs,
|
|
1389
|
+
];
|
|
1390
|
+
const handleCliData = (c) => {
|
|
1391
|
+
const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
1392
|
+
if (lines.length) lastLine = lines[lines.length - 1];
|
|
1393
|
+
if (resultParser) {
|
|
1394
|
+
try {
|
|
1395
|
+
foldResultEvents(resultParser.push(String(c)));
|
|
1396
|
+
} catch {
|
|
1397
|
+
/* result extraction must never break the run */
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
if (emitter) {
|
|
1401
|
+
try {
|
|
1402
|
+
emitter.feed(c);
|
|
1403
|
+
} catch {
|
|
1404
|
+
/* a progress fold must never break the run */
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
};
|
|
1275
1408
|
try {
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
label: "coding",
|
|
1282
|
-
signal,
|
|
1283
|
-
env: codingChildEnv(cfg),
|
|
1284
|
-
onData: (c) => {
|
|
1285
|
-
const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
1286
|
-
if (lines.length) lastLine = lines[lines.length - 1];
|
|
1287
|
-
if (resultParser) {
|
|
1288
|
-
try {
|
|
1289
|
-
foldResultEvents(resultParser.push(String(c)));
|
|
1290
|
-
} catch {
|
|
1291
|
-
/* result extraction must never break the run */
|
|
1292
|
-
}
|
|
1293
|
-
}
|
|
1294
|
-
if (emitter) {
|
|
1295
|
-
try {
|
|
1296
|
-
emitter.feed(c);
|
|
1297
|
-
} catch {
|
|
1298
|
-
/* a progress fold must never break the run */
|
|
1299
|
-
}
|
|
1300
|
-
}
|
|
1301
|
-
},
|
|
1409
|
+
const useRuntimePermissionBridge = shouldUseRuntimePermissionBridge({
|
|
1410
|
+
vendor,
|
|
1411
|
+
runtimePermissions: caps.runtimePermissions,
|
|
1412
|
+
codeArgs,
|
|
1413
|
+
codingCmd: cfg.codingCmd,
|
|
1302
1414
|
});
|
|
1415
|
+
if (useRuntimePermissionBridge) {
|
|
1416
|
+
run = await deps.runOpenCodeHttpSession({
|
|
1417
|
+
cmd: parts[0],
|
|
1418
|
+
args: codeArgs,
|
|
1419
|
+
cwd: folderPath,
|
|
1420
|
+
prompt: memoryPreamble(workspaceMemory) + promptText,
|
|
1421
|
+
timeoutMs: cfg.runTimeoutMs,
|
|
1422
|
+
signal,
|
|
1423
|
+
env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
|
|
1424
|
+
onData: handleCliData,
|
|
1425
|
+
...openCodePermissionCallbacks({
|
|
1426
|
+
tool,
|
|
1427
|
+
channelId,
|
|
1428
|
+
threadRoot,
|
|
1429
|
+
}),
|
|
1430
|
+
});
|
|
1431
|
+
} else {
|
|
1432
|
+
run = await deps.runCli({
|
|
1433
|
+
cmd: parts[0],
|
|
1434
|
+
args: [
|
|
1435
|
+
...codeArgs,
|
|
1436
|
+
memoryPreamble(workspaceMemory) + promptText,
|
|
1437
|
+
],
|
|
1438
|
+
cwd: folderPath,
|
|
1439
|
+
timeoutMs: cfg.runTimeoutMs,
|
|
1440
|
+
label: "coding",
|
|
1441
|
+
signal,
|
|
1442
|
+
env: codingChildEnv(cfg),
|
|
1443
|
+
onData: handleCliData,
|
|
1444
|
+
});
|
|
1445
|
+
}
|
|
1303
1446
|
} finally {
|
|
1304
1447
|
stopHeartbeat();
|
|
1305
1448
|
if (emitter) {
|
|
@@ -2030,7 +2173,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2030
2173
|
branch,
|
|
2031
2174
|
// Map an unrecognized command to null rather than the off-vocabulary
|
|
2032
2175
|
// "unknown" — provider is documented as
|
|
2033
|
-
// claude_code|codex|cursor|antigravity|hermes|hilos.
|
|
2176
|
+
// claude_code|codex|cursor|opencode|antigravity|hermes|hilos.
|
|
2034
2177
|
provider: (() => {
|
|
2035
2178
|
const v = detectVendor(cfg.codingCmd);
|
|
2036
2179
|
return v === "unknown" ? null : v;
|
|
@@ -2078,9 +2221,13 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2078
2221
|
// (get_active_run) with no local match means the session likely lives on another
|
|
2079
2222
|
// machine/instance — a bad `--resume` id makes claude error → empty diff → a failed
|
|
2080
2223
|
// run, so we DON'T resume and degrade to today's branch+feedback (never worse).
|
|
2081
|
-
//
|
|
2224
|
+
// claude_code + cursor + opencode have proven resume flags (0282/0573/0608);
|
|
2225
|
+
// codex/unknown → []. The gate itself is pure (resumeDecision in resume.mjs):
|
|
2226
|
+
// vendor, machine, project and server agreement all have to line up, and any
|
|
2227
|
+
// "no" degrades to the branch+feedback iterate rather than risking a bad id.
|
|
2228
|
+
const projectKey = codeProjectKey(vendor, repoPath, cfg.codingCmd);
|
|
2082
2229
|
let resumeSessionId = null;
|
|
2083
|
-
if (effectiveMode === "iterate"
|
|
2230
|
+
if (effectiveMode === "iterate") {
|
|
2084
2231
|
const local = (() => {
|
|
2085
2232
|
try {
|
|
2086
2233
|
return readStateEntry(HILOS_DIR, threadRoot);
|
|
@@ -2089,14 +2236,18 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2089
2236
|
}
|
|
2090
2237
|
})();
|
|
2091
2238
|
const serverSid = activeRun?.providerSessionId || null;
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
}
|
|
2099
|
-
|
|
2239
|
+
const decision = resumeDecision({
|
|
2240
|
+
vendor,
|
|
2241
|
+
entry: local,
|
|
2242
|
+
serverSessionId: serverSid,
|
|
2243
|
+
machine,
|
|
2244
|
+
projectKey,
|
|
2245
|
+
});
|
|
2246
|
+
if (decision.sessionId) {
|
|
2247
|
+
resumeSessionId = decision.sessionId;
|
|
2248
|
+
console.log(` code → resuming session ${decision.sessionId} for this iterate (local + machine match)`);
|
|
2249
|
+
} else if (local?.sessionId || serverSid) {
|
|
2250
|
+
console.log(` code → not resuming (${decision.reason}); using branch + feedback`);
|
|
2100
2251
|
}
|
|
2101
2252
|
}
|
|
2102
2253
|
|
|
@@ -2209,34 +2360,79 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2209
2360
|
// fallback) suppresses it. buildResumeArgs is [] unless vendor+session make
|
|
2210
2361
|
// resume safe, so a non-resume run is byte-identical to the pre-0282 ARGV.
|
|
2211
2362
|
const resumeArgs = resume ? buildResumeArgs(vendor, resumeSessionId) : [];
|
|
2363
|
+
// Model preset (0504): resolved at run time against the CLI's own model
|
|
2364
|
+
// list (cursor only today) — [] when unset/unresolvable, so the tool's
|
|
2365
|
+
// default stands. Inserted before resume/stream flags, after the base.
|
|
2366
|
+
const modelArgs = await modelArgsFor(cfg, vendor);
|
|
2367
|
+
// Project pin (0608, opencode only): the CLI resolves its project from PWD,
|
|
2368
|
+
// not the spawn cwd, and its sessions are per project — `--dir` makes both
|
|
2369
|
+
// deterministic. [] for every other vendor (and for an `--attach`ed run,
|
|
2370
|
+
// where the project lives on the remote server), so their ARGV is unchanged.
|
|
2371
|
+
// The spawned PWD now matches the cwd too (0615); `--dir` stays as the
|
|
2372
|
+
// CLI's own explicit contract, and to keep attach runs off our local path.
|
|
2373
|
+
const dirArgs = codeDirArgs(vendor, repoPath, cfg.codingCmd);
|
|
2212
2374
|
const codeArgs = streamOn
|
|
2213
|
-
? [...parts.slice(1), ...resumeArgs, ...streamArgs]
|
|
2214
|
-
: [...parts.slice(1), ...resumeArgs];
|
|
2375
|
+
? [...parts.slice(1), ...modelArgs, ...dirArgs, ...resumeArgs, ...streamArgs]
|
|
2376
|
+
: [...parts.slice(1), ...modelArgs, ...dirArgs, ...resumeArgs];
|
|
2377
|
+
const handleCliData = (c) => {
|
|
2378
|
+
// Keep tracking lastLine as a fallback (legacy heartbeat / honesty).
|
|
2379
|
+
const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
2380
|
+
if (lines.length) lastLine = lines[lines.length - 1];
|
|
2381
|
+
if (emitter) {
|
|
2382
|
+
try {
|
|
2383
|
+
emitter.feed(c);
|
|
2384
|
+
} catch {
|
|
2385
|
+
/* a progress fold must never break the run */
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
};
|
|
2215
2389
|
let run;
|
|
2216
2390
|
try {
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
// Keep tracking lastLine as a fallback (legacy heartbeat / honesty).
|
|
2227
|
-
const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
2228
|
-
if (lines.length) lastLine = lines[lines.length - 1];
|
|
2229
|
-
if (emitter) {
|
|
2230
|
-
try {
|
|
2231
|
-
emitter.feed(c);
|
|
2232
|
-
} catch {
|
|
2233
|
-
/* a progress fold must never break the run */
|
|
2234
|
-
}
|
|
2235
|
-
}
|
|
2236
|
-
},
|
|
2391
|
+
// OpenCode's own non-interactive CLI auto-rejects every permission ask
|
|
2392
|
+
// unless --auto is present. For the gated tiers, bypass that responder
|
|
2393
|
+
// and own the authenticated HTTP session + SSE stream directly so hilos
|
|
2394
|
+
// is the sole authority answering the paused tool call (0593).
|
|
2395
|
+
const useRuntimePermissionBridge = shouldUseRuntimePermissionBridge({
|
|
2396
|
+
vendor,
|
|
2397
|
+
runtimePermissions: caps.runtimePermissions,
|
|
2398
|
+
codeArgs,
|
|
2399
|
+
codingCmd: cfg.codingCmd,
|
|
2237
2400
|
});
|
|
2401
|
+
if (useRuntimePermissionBridge) {
|
|
2402
|
+
run = await deps.runOpenCodeHttpSession({
|
|
2403
|
+
cmd: parts[0],
|
|
2404
|
+
args: codeArgs,
|
|
2405
|
+
cwd: repoPath,
|
|
2406
|
+
prompt: memoryPreamble(workspaceMemory) + promptText,
|
|
2407
|
+
timeoutMs: cfg.runTimeoutMs,
|
|
2408
|
+
signal,
|
|
2409
|
+
// A model running inside the server must never inherit the daemon's
|
|
2410
|
+
// hilos bearer token. The bridge's random loopback password is added
|
|
2411
|
+
// after this scrub and dies with the process group.
|
|
2412
|
+
env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
|
|
2413
|
+
onData: handleCliData,
|
|
2414
|
+
...openCodePermissionCallbacks({
|
|
2415
|
+
tool,
|
|
2416
|
+
channelId,
|
|
2417
|
+
threadRoot,
|
|
2418
|
+
runId,
|
|
2419
|
+
}),
|
|
2420
|
+
});
|
|
2421
|
+
} else {
|
|
2422
|
+
run = await runCli({
|
|
2423
|
+
cmd: parts[0],
|
|
2424
|
+
args: [...codeArgs, memoryPreamble(workspaceMemory) + promptText],
|
|
2425
|
+
cwd: repoPath,
|
|
2426
|
+
timeoutMs: cfg.runTimeoutMs,
|
|
2427
|
+
label: "coding",
|
|
2428
|
+
signal,
|
|
2429
|
+
env: codingChildEnv(cfg),
|
|
2430
|
+
onData: handleCliData,
|
|
2431
|
+
});
|
|
2432
|
+
}
|
|
2238
2433
|
} finally {
|
|
2239
2434
|
stopHeartbeat();
|
|
2435
|
+
if (run?.sessionId) runSessionId = run.sessionId;
|
|
2240
2436
|
if (emitter) {
|
|
2241
2437
|
// Terminal state: flip the status card off "working" (state 'done'/'error')
|
|
2242
2438
|
// so it stops claiming the agent is alive. For a run that produced work,
|
|
@@ -2344,6 +2540,11 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
2344
2540
|
prUrl: prUrl || continuingPrUrl || null,
|
|
2345
2541
|
sessionId: sid,
|
|
2346
2542
|
machine,
|
|
2543
|
+
// The CLI that created the session (a `ses_…` is meaningless to
|
|
2544
|
+
// claude's `--resume`) and the project it belongs to — opencode
|
|
2545
|
+
// scopes sessions per project. The gate above requires both (0608).
|
|
2546
|
+
vendor,
|
|
2547
|
+
cwd: projectKey,
|
|
2347
2548
|
updatedAt: new Date().toISOString(),
|
|
2348
2549
|
});
|
|
2349
2550
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Runtime model-preset resolution (0504). The connect UI offers capability
|
|
2
|
+
// TIERS (Most capable / Balanced / Fastest), but Cursor's model ids are
|
|
3
|
+
// account- and plan-specific and churn weekly — a baked id that works for one
|
|
4
|
+
// user 404s for another, which is why VENDOR_CLI.cursor.model stayed empty for
|
|
5
|
+
// months. So the tier resolves HERE, at run time, against the account's OWN
|
|
6
|
+
// `cursor-agent --list-models` output: we only ever emit an id the CLI itself
|
|
7
|
+
// just listed, and when nothing matches we emit no flag at all (the tool's
|
|
8
|
+
// default — `auto` routing — stands). Never a guessed id, never a wrong flag.
|
|
9
|
+
//
|
|
10
|
+
// Design rules (mirror the .mjs siblings): the parse/resolve transforms are
|
|
11
|
+
// PURE + node-builtins-only; the resolver takes an injected `run` (runCli) so
|
|
12
|
+
// tests drive it with no CLI; a resolution failure NEVER breaks a run ([]).
|
|
13
|
+
//
|
|
14
|
+
// codex stays out: its CLI has no verified model-list command (0504 notes).
|
|
15
|
+
|
|
16
|
+
/** Strip ANSI SGR color codes (`--list-models` output is colorized). */
|
|
17
|
+
export function stripAnsi(s) {
|
|
18
|
+
// eslint-disable-next-line no-control-regex
|
|
19
|
+
return String(s || "").replace(/\x1b\[[0-9;]*m/g, "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse `cursor-agent --list-models` stdout → model ids, in list order.
|
|
24
|
+
* Wire shape captured live on 2026.07.23: a header line, then one
|
|
25
|
+
* `<id> - <Label>` line per model, colorized. Anything that doesn't match the
|
|
26
|
+
* `id - label` shape (headers, blanks) is skipped — a format drift degrades to
|
|
27
|
+
* [] and the preset silently falls back to the tool default.
|
|
28
|
+
* @param {string} stdout
|
|
29
|
+
* @returns {string[]}
|
|
30
|
+
*/
|
|
31
|
+
export function parseCursorModels(stdout) {
|
|
32
|
+
const ids = [];
|
|
33
|
+
for (const raw of String(stdout || "").split("\n")) {
|
|
34
|
+
const m = stripAnsi(raw).trim().match(/^(\S+)\s+-\s+\S/);
|
|
35
|
+
if (m) ids.push(m[1]);
|
|
36
|
+
}
|
|
37
|
+
return ids;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Ranked preferences per tier, scanned in order — the first pattern with any
|
|
41
|
+
// match wins, then the FIRST id in the account's list-order that matches it.
|
|
42
|
+
// opus = the strongest reasoning family; sonnet ("Balanced") = Cursor's own
|
|
43
|
+
// composer flagship (its default agent model) before a Claude sonnet;
|
|
44
|
+
// haiku ("Fastest") = the -fast variants, composer first. `auto` never
|
|
45
|
+
// matches (tier "default" emits no flag long before this table is consulted).
|
|
46
|
+
const TIER_PREFS = {
|
|
47
|
+
opus: [/^claude-opus[\w.-]*thinking(?!.*fast)/, /^claude-opus(?!.*fast)/, /opus(?!.*fast)/],
|
|
48
|
+
sonnet: [/^composer(?!.*fast)/, /^claude-sonnet(?!.*fast)/, /sonnet(?!.*fast)/],
|
|
49
|
+
haiku: [/^composer.*fast/, /-fast$/],
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Pick the account's model id for a tier, or null when nothing fits.
|
|
54
|
+
* @param {'opus'|'sonnet'|'haiku'|string} tier
|
|
55
|
+
* @param {string[]} ids
|
|
56
|
+
* @returns {string|null}
|
|
57
|
+
*/
|
|
58
|
+
export function resolveCursorModel(tier, ids) {
|
|
59
|
+
const prefs = TIER_PREFS[tier];
|
|
60
|
+
if (!prefs || !Array.isArray(ids)) return null;
|
|
61
|
+
for (const re of prefs) {
|
|
62
|
+
const hit = ids.find((id) => typeof id === "string" && re.test(id));
|
|
63
|
+
if (hit) return hit;
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Build a memoized `modelArgsFor(cfg, vendor)` → `["--model", id]` or [].
|
|
70
|
+
* Emits [] (tool default) when: the vendor isn't cursor, no/default tier is
|
|
71
|
+
* configured, the user already pinned `--model` by hand in codingCmd, the
|
|
72
|
+
* list command fails, or nothing matches. Successful lookups are cached per
|
|
73
|
+
* (binary, tier) for the process lifetime; failures are NOT cached so a
|
|
74
|
+
* transient hiccup (offline, auth) retries on the next run.
|
|
75
|
+
*
|
|
76
|
+
* @param {{ run: (opts: object) => Promise<{status: number|null, stdout: string}> }} o
|
|
77
|
+
*/
|
|
78
|
+
export function createModelArgsResolver({ run } = {}) {
|
|
79
|
+
const cache = new Map();
|
|
80
|
+
return async function modelArgsFor(cfg, vendor) {
|
|
81
|
+
try {
|
|
82
|
+
const tier = String(cfg?.codingModel || "").trim();
|
|
83
|
+
if (vendor !== "cursor" || !tier || tier === "default") return [];
|
|
84
|
+
const cmd = String(cfg?.codingCmd || "");
|
|
85
|
+
if (/(^|\s)--model(\s|=)/.test(cmd + " ")) return []; // hand-pinned wins
|
|
86
|
+
const bin = cmd.trim().split(/\s+/)[0] || "cursor-agent";
|
|
87
|
+
const key = `${bin} ${tier}`;
|
|
88
|
+
if (cache.has(key)) return cache.get(key);
|
|
89
|
+
const r = await run({ cmd: bin, args: ["--list-models"], timeoutMs: 30000, heartbeatMs: 0 });
|
|
90
|
+
if (!r || r.status !== 0) return [];
|
|
91
|
+
const id = resolveCursorModel(tier, parseCursorModels(r.stdout));
|
|
92
|
+
const args = id ? ["--model", id] : [];
|
|
93
|
+
cache.set(key, args);
|
|
94
|
+
return args;
|
|
95
|
+
} catch {
|
|
96
|
+
return []; // resolution must never break a run
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|