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/src/handler.mjs CHANGED
@@ -26,11 +26,29 @@ import {
26
26
  mentionHandle,
27
27
  detectPrContinuation,
28
28
  } from "./daemon.mjs";
29
- import { runCli, buildHeartbeat, ackText, oneLine, fmtElapsed, minimalEnv } from "./cli.mjs";
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 { detectVendor, codeStreamArgs, createProgressEmitter, fastChatCmd } from "./progress-emitter.mjs";
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
- // The async CLI runner, injectable so folder mode (and tests) can drive the
123
- // coding run without spawning a real process. The repo flow still uses the
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
- run = await deps.runCli({
1277
- cmd: parts[0],
1278
- args: [...parts.slice(1), ...streamArgs, memoryPreamble(workspaceMemory) + promptText],
1279
- cwd: folderPath,
1280
- timeoutMs: cfg.runTimeoutMs,
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
- // Only claude_code has a proven resume flag; codex/cursor/unknown → [] anyway.
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" && vendor === "claude_code") {
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
- // Confidence = a local record, for THIS machine, with a session id. If the server
2093
- // also has one it must AGREE (a mismatch means the run moved — don't resume a
2094
- // stale/foreign id). A server-only id (no local record) is never resumed.
2095
- if (local && local.machine === machine && local.sessionId && (!serverSid || serverSid === local.sessionId)) {
2096
- resumeSessionId = local.sessionId;
2097
- console.log(` code → resuming session ${local.sessionId} for this iterate (local + machine match)`);
2098
- } else if (serverSid) {
2099
- console.log(" code → not resuming (session recorded on another machine or unverified locally); using branch + feedback");
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
- run = await runCli({
2218
- cmd: parts[0],
2219
- args: [...codeArgs, memoryPreamble(workspaceMemory) + promptText],
2220
- cwd: repoPath,
2221
- timeoutMs: cfg.runTimeoutMs,
2222
- label: "coding",
2223
- signal,
2224
- env: codingChildEnv(cfg),
2225
- onData: (c) => {
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
+ }