hilos-agent 0.5.1 → 0.6.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 CHANGED
@@ -42,7 +42,8 @@ Running from elsewhere, or want to map several repos explicitly? Use a config:
42
42
  "url": "https://hilos.sh/api/mcp",
43
43
  "token": "mgo_…",
44
44
  "repos": { "your-org/your-repo": "/Users/you/code/your-repo" },
45
- "codingCmd": "claude -p --permission-mode acceptEdits", // safe default; see Permissions / autonomy. or "codex exec", "cursor-agent -p --output-format text", "agy -p", any command
45
+ "codingCmd": "claude -p --permission-mode acceptEdits", // safe default; see Permissions / autonomy. or "codex exec", "cursor-agent -p --output-format text --trust", "agy -p", any command
46
+ "codingModel": "", // model preset tier ("opus" | "sonnet" | "haiku") resolved at run time against the CLI's own model list (Cursor only today); "" = the tool's default
46
47
  "chatCmd": "", // FAST command for chat replies + the plan-ack. Empty = derived from codingCmd's tool (codex daemons chat with codex, etc.); set to override
47
48
  "defaultBranch": "main",
48
49
  "gate": false, // default: open a PR directly. true = approve-before-push
@@ -31,6 +31,7 @@ function parseArgs(argv) {
31
31
  else if (a === "--url") flags.url = argv[++i];
32
32
  else if (a === "--token") flags.token = argv[++i];
33
33
  else if (a === "--coding-cmd") flags.codingCmd = argv[++i];
34
+ else if (a === "--coding-model") flags.codingModel = argv[++i];
34
35
  else if (a === "--chat-cmd") flags.chatCmd = argv[++i];
35
36
  else if (a === "--once") flags.once = true;
36
37
  else if (a === "--backfill") flags.backfill = true;
@@ -56,8 +57,11 @@ Options:
56
57
  --channel <id> watch only one channel (per-channel override)
57
58
  --config <path> use a specific config file
58
59
  --coding-cmd <cmd> the coding agent to run — claude -p, codex exec,
59
- cursor-agent -p, agy -p, hermes, or any command that
60
- takes a prompt as its last arg (default: "claude -p")
60
+ cursor-agent -p --trust, agy -p, hermes, or any command
61
+ that takes a prompt as its last arg (default: "claude -p")
62
+ --coding-model <tier> model preset tier (opus | sonnet | haiku) resolved at
63
+ run time against the CLI's own model list — never a baked
64
+ id (Cursor only today; default: the tool's own model)
61
65
  --chat-cmd <cmd> fast command for chat replies + the plan-ack (default:
62
66
  derived from the coding command, so a Codex or Cursor
63
67
  daemon chats with its own tool)
@@ -98,6 +102,7 @@ async function main() {
98
102
  // non-Claude connect command doesn't write the Claude default over it.
99
103
  const starter = { ...(joinPayload || {}) };
100
104
  if (flags.codingCmd) starter.codingCmd = flags.codingCmd;
105
+ if (flags.codingModel) starter.codingModel = flags.codingModel;
101
106
  const path = writeStarterConfig(joinPayload ? GLOBAL_CONFIG : flags.config, starter);
102
107
  console.log(`Wrote ${path}.`);
103
108
  console.log(joinPayload ? "Token + endpoint set from your link." : "Fill in token + repos, then run `hilos-agent`.");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Run your own coding agent (Claude Code / Codex / Cursor) as an autonomous teammate in a hilos channel. Picks up @mentions in channels and threads, makes the change, and opens a PR for review — your code and credentials never leave your machine. (Approve-before-push is available via gate:true.)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,9 +1,10 @@
1
1
  // Normalized vendor stream parser (0272). The KEYSTONE of the "Agents feel
2
2
  // alive" epic: coding CLIs each narrate their work in a different, unstable
3
3
  // wire format (Claude Code emits `--output-format stream-json` NDJSON, Codex
4
- // emits `--json` item events, Cursor emits unstructured text). This module turns
5
- // any of them into ONE small, typed `AgentEvent` stream the UI can render as a
6
- // live "what the agent is doing right now" card.
4
+ // emits `--json` item events, Cursor emits its own `--output-format
5
+ // stream-json` NDJSON — 0573). This module turns any of them into ONE small,
6
+ // typed `AgentEvent` stream the UI can render as a live "what the agent is
7
+ // doing right now" card.
7
8
  //
8
9
  // Design rules that make this safe to point at an untrusted, evolving CLI:
9
10
  // - PURE + dependency-free (node builtins only) so it stands alone and is
@@ -207,12 +208,77 @@ function parseCodexLine(line) {
207
208
  }
208
209
  }
209
210
 
211
+ /**
212
+ * Parse ONE Cursor `--output-format stream-json` NDJSON line → AgentEvent[].
213
+ * Shapes captured LIVE against cursor-agent 2026.07.23 (ticket 0572's audit):
214
+ * a `system`/`init` envelope with `session_id` (the `--resume` id — 0573),
215
+ * `assistant` turns whose message.content carries text blocks (tool calls
216
+ * arrive as separate events, unlike Claude's inline tool_use), `tool_call`
217
+ * started/completed envelopes keyed by kind (`editToolCall`/`readToolCall`/
218
+ * `shellToolCall`, each with an `args` object), and a terminal `result` with
219
+ * `is_error` + the full text in `result`. `thinking` deltas, the `user` echo,
220
+ * and `tool_call` completions are deliberately not steps.
221
+ */
222
+ function parseCursorLine(line) {
223
+ const obj = tryParse(line);
224
+ if (!obj) return [];
225
+ if (obj.type === "system" && typeof obj.session_id === "string") {
226
+ return [{ t: "session", sessionId: sanitizeText(obj.session_id) }];
227
+ }
228
+ if (obj.type === "result") {
229
+ const ok = obj.is_error !== true && obj.subtype !== "error";
230
+ const summary = typeof obj.result === "string" ? sanitizeText(obj.result) : undefined;
231
+ return [summary ? { t: "result", ok, summary } : { t: "result", ok }];
232
+ }
233
+ // Only `started` — the `completed` twin repeats the same call and would
234
+ // double every step.
235
+ if (obj.type === "tool_call" && obj.subtype === "started") {
236
+ const ev = cursorToolEvent(obj.tool_call);
237
+ return ev ? [ev] : [];
238
+ }
239
+ if (obj.type === "assistant" && obj.message && Array.isArray(obj.message.content)) {
240
+ const out = [];
241
+ for (const block of obj.message.content) {
242
+ if (block && block.type === "text" && typeof block.text === "string") {
243
+ const text = sanitizeText(block.text.replace(/\s+/g, " ").trim());
244
+ if (text) out.push({ t: "note", text });
245
+ }
246
+ }
247
+ return out;
248
+ }
249
+ return [];
250
+ }
251
+
252
+ /** One Cursor tool_call envelope → an AgentEvent, or null. The envelope holds a
253
+ * single `<kind>ToolCall` key; grep/ls/etc. carry less "alive" signal and are
254
+ * skipped, mirroring toolKind()'s v1 restraint — an unknown kind never crashes. */
255
+ function cursorToolEvent(toolCall) {
256
+ if (!toolCall || typeof toolCall !== "object") return null;
257
+ for (const [key, val] of Object.entries(toolCall)) {
258
+ if (!key.endsWith("ToolCall") || !val || typeof val !== "object") continue;
259
+ const args = val.args && typeof val.args === "object" ? val.args : {};
260
+ if (key === "shellToolCall" || key === "terminalToolCall") {
261
+ const cmd = typeof args.command === "string" ? args.command.replace(/\s+/g, " ").trim() : "";
262
+ return { t: "run", cmd: sanitizeText(cmd) };
263
+ }
264
+ if (key === "editToolCall" || key === "writeToolCall") {
265
+ return { t: "edit", path: typeof args.path === "string" ? sanitizeText(args.path) : "" };
266
+ }
267
+ if (key === "readToolCall") {
268
+ return { t: "read", path: typeof args.path === "string" ? sanitizeText(args.path) : "" };
269
+ }
270
+ return null;
271
+ }
272
+ return null;
273
+ }
274
+
210
275
  /** claude / claude_code / claude-code all mean the Claude parser. */
211
276
  function normalizeVendor(vendor) {
212
277
  const v = String(vendor || "").toLowerCase();
213
278
  if (v === "claude" || v === "claude_code" || v === "claude-code") return "claude";
214
279
  if (v === "codex") return "codex";
215
- return "cursor"; // cursor + ANY unknown vendor → lastLine fallback
280
+ if (v === "cursor") return "cursor"; // structured stream-json since 0573
281
+ return "text"; // ANY unknown vendor → lastLine text-tail fallback
216
282
  }
217
283
 
218
284
  // --- Streaming parsers -----------------------------------------------------
@@ -249,12 +315,13 @@ function makeLineBufferedParser(parseLine) {
249
315
  }
250
316
 
251
317
  /**
252
- * Fallback for Cursor and any vendor with no structured stream. There's nothing
253
- * to parse, so we only remember the last non-empty line seen — matching exactly
254
- * what the daemon does today (handler.mjs onData) so we NEVER regress below it —
255
- * and emit at most one sparse note on flush.
318
+ * Fallback for any vendor with no structured stream (cursor graduated to a
319
+ * real parser in 0573). There's nothing to parse, so we only remember the last
320
+ * non-empty line seen matching exactly what the daemon does today
321
+ * (handler.mjs onData) so we NEVER regress below it — and emit at most one
322
+ * sparse note on flush.
256
323
  */
257
- function makeCursorParser() {
324
+ function makeTextTailParser() {
258
325
  let lastLine = "";
259
326
  return {
260
327
  /** @returns {AgentEvent[]} */
@@ -285,7 +352,8 @@ export function makeStreamParser(vendor) {
285
352
  const v = normalizeVendor(vendor);
286
353
  if (v === "claude") return makeLineBufferedParser(parseClaudeLine);
287
354
  if (v === "codex") return makeLineBufferedParser(parseCodexLine);
288
- return makeCursorParser();
355
+ if (v === "cursor") return makeLineBufferedParser(parseCursorLine);
356
+ return makeTextTailParser();
289
357
  }
290
358
 
291
359
  // --- Human step labels (what the LiveRunCard shows) ------------------------
package/src/cli.mjs CHANGED
@@ -159,7 +159,7 @@ const MAX_CAPTURE_BYTES = 50 * 1024 * 1024;
159
159
  *
160
160
  * @param {RunCliOptions} opts
161
161
  */
162
- export function runCli(opts) {
162
+ function runCliOnce(opts) {
163
163
  const {
164
164
  cmd,
165
165
  args = [],
@@ -289,3 +289,27 @@ export function runCli(opts) {
289
289
  child.on("close", (code) => finish(code, null));
290
290
  });
291
291
  }
292
+
293
+ // Compat retry (0572): Cursor CLIs older than Jan 2026 predate the workspace-
294
+ // trust gate and reject the `--trust` flag we now bake into cursor commands
295
+ // (`error: unknown option '--trust'`). Those CLIs don't NEED the flag (no gate
296
+ // existed), so on that exact failure runCli retries ONCE with it stripped. The
297
+ // failed attempt writes only the commander error to stderr — stdout stays
298
+ // empty — so progress streaming (onData) never sees the false start. Lives
299
+ // inside runCli (not a wrapper) so every call site, present and future, gets it.
300
+ const UNKNOWN_TRUST_RE = /unknown option '--trust'/;
301
+
302
+ /** @param {RunCliOptions} opts */
303
+ export async function runCli(opts) {
304
+ const first = await runCliOnce(opts);
305
+ const args = Array.isArray(opts?.args) ? opts.args : [];
306
+ if (
307
+ first.status !== 0 &&
308
+ !first.aborted &&
309
+ args.includes("--trust") &&
310
+ UNKNOWN_TRUST_RE.test(first.stderr || "")
311
+ ) {
312
+ return runCliOnce({ ...opts, args: args.filter((a) => a !== "--trust") });
313
+ }
314
+ return first;
315
+ }
package/src/config.mjs CHANGED
@@ -66,6 +66,11 @@ const DEFAULTS = {
66
66
  // mode with codingEnvAllow: ["MY_VAR", …].
67
67
  codingEnv: "inherit",
68
68
  codingEnvAllow: [],
69
+ // Model preset TIER for the coding run ("" / "default" = the tool's own
70
+ // default). "opus" | "sonnet" | "haiku" resolve at RUN time against the
71
+ // CLI's own model list — never a baked id that could 404 on another account
72
+ // (0504; cursor only today, see model-resolve.mjs).
73
+ codingModel: "",
69
74
  // Chat replies + the code-task plan-ack use a FAST one-shot command so a casual
70
75
  // reply (or "I see it, here's my plan") comes back in seconds, not minutes.
71
76
  // Bounded by chatTimeoutMs with a template fallback so it can never dead-air.
@@ -112,6 +117,7 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
112
117
  token: process.env.HILOS_TOKEN,
113
118
  channelId: process.env.HILOS_CHANNEL,
114
119
  codingCmd: process.env.CODING_CMD,
120
+ codingModel: process.env.HILOS_CODING_MODEL,
115
121
  chatCmd: process.env.HILOS_CHAT_CMD,
116
122
  heartbeatMs: process.env.HILOS_HEARTBEAT_MS ? Number(process.env.HILOS_HEARTBEAT_MS) : undefined,
117
123
  progressMs: process.env.HILOS_PROGRESS_MS ? Number(process.env.HILOS_PROGRESS_MS) : undefined,
@@ -135,8 +141,12 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
135
141
  // Local-folder map, merged like `repos` (an object, not a scalar overlay).
136
142
  merged.folders = { ...DEFAULTS.folders, ...(file.folders || {}) };
137
143
  merged.deploy = { ...DEFAULTS.deploy, ...(file.deploy || {}) };
138
- // Remember where the file lives so reloadConfig can re-read it live.
144
+ // Remember where the file lives so reloadConfig can re-read it live, and
145
+ // what it said AT LAUNCH so reload only applies fields the user actually
146
+ // edited afterwards (0576) — a pre-existing file value must not claw back
147
+ // an explicit launch flag (--coding-cmd / --coding-model) on the first poll.
139
148
  merged.configPath = findConfigPath(flags.config) || null;
149
+ merged.fileSnapshot = file;
140
150
  return merged;
141
151
  }
142
152
 
@@ -145,6 +155,7 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
145
155
  // edited file can NEVER drop the daemon's connection.
146
156
  const LIVE_FIELDS = [
147
157
  "codingCmd",
158
+ "codingModel",
148
159
  "chatCmd",
149
160
  "codingEnv",
150
161
  "codingEnvAllow",
@@ -182,8 +193,17 @@ export function reloadConfig(prev) {
182
193
  const path = prev.configPath;
183
194
  const file = (path && readJson(path)) || {};
184
195
  const next = { ...prev };
185
- for (const k of LIVE_FIELDS) if (file[k] !== undefined) next[k] = file[k];
196
+ // Apply only fields that CHANGED since the launch snapshot (or the last
197
+ // reload): an EDIT to hilos-agent.json wins live, as documented, but a value
198
+ // that merely pre-existed at launch can't override the flags the user just
199
+ // pasted (`--coding-cmd`/`--coding-model` on a machine with an older config
200
+ // silently flipped back on the first poll — 0576 review finding).
201
+ const snap = prev.fileSnapshot || {};
202
+ const changed = (k) => JSON.stringify(file[k]) !== JSON.stringify(snap[k]);
203
+ for (const k of LIVE_FIELDS) if (file[k] !== undefined && changed(k)) next[k] = file[k];
204
+ next.fileSnapshot = file;
186
205
  if (process.env.CODING_CMD) next.codingCmd = process.env.CODING_CMD;
206
+ if (process.env.HILOS_CODING_MODEL) next.codingModel = process.env.HILOS_CODING_MODEL;
187
207
  if (process.env.HILOS_CHAT_CMD) next.chatCmd = process.env.HILOS_CHAT_CMD;
188
208
  if (process.env.HILOS_HEARTBEAT_MS) next.heartbeatMs = Number(process.env.HILOS_HEARTBEAT_MS);
189
209
  if (process.env.HILOS_PROGRESS_MS) next.progressMs = Number(process.env.HILOS_PROGRESS_MS);
@@ -208,6 +228,7 @@ export function writeStarterConfig(path, partial = {}) {
208
228
  channelId: partial.channelId || "",
209
229
  repos: partial.repos || { "owner/name": "/absolute/path/to/checkout" },
210
230
  codingCmd: partial.codingCmd || DEFAULTS.codingCmd,
231
+ ...(partial.codingModel ? { codingModel: partial.codingModel } : {}),
211
232
  defaultBranch: DEFAULTS.defaultBranch,
212
233
  // false = open a PR directly (bias to action); true = approve-before-push.
213
234
  gate: false,
package/src/handler.mjs CHANGED
@@ -31,6 +31,7 @@ import { makeStreamParser } from "./agent-events.mjs";
31
31
  import { detectVendor, codeStreamArgs, createProgressEmitter, fastChatCmd } from "./progress-emitter.mjs";
32
32
  import { resolveFollowupMode, classifyFollowupCue, normalizeSignal } from "./followup.mjs";
33
33
  import { buildResumeArgs, readStateEntry, writeState, HILOS_DIR } from "./resume.mjs";
34
+ import { createModelArgsResolver } from "./model-resolve.mjs";
34
35
  import {
35
36
  buildReviewPrompt,
36
37
  parseReviewOutput,
@@ -75,6 +76,10 @@ function prNumberFromUrl(url) {
75
76
  // human re-triggering the review is the escape hatch past the cap.
76
77
  const reviewRounds = new Map();
77
78
 
79
+ // Tier → account-verified `--model` args for the code run (0504). Memoized per
80
+ // (binary, tier) for the process; a failed lookup emits [] and retries later.
81
+ const modelArgsFor = createModelArgsResolver({ run: (opts) => runCli(opts) });
82
+
78
83
 
79
84
  // Sentinel the router model emits (only when a thread already owns a run) to
80
85
  // classify a follow-up: change | new-scope | ambiguous. Parsed out of the router
@@ -1272,10 +1277,12 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
1272
1277
  };
1273
1278
  }
1274
1279
  let run;
1280
+ // Model preset (0504): same run-time resolution as the repo path.
1281
+ const modelArgs = await modelArgsFor(cfg, vendor);
1275
1282
  try {
1276
1283
  run = await deps.runCli({
1277
1284
  cmd: parts[0],
1278
- args: [...parts.slice(1), ...streamArgs, memoryPreamble(workspaceMemory) + promptText],
1285
+ args: [...parts.slice(1), ...modelArgs, ...streamArgs, memoryPreamble(workspaceMemory) + promptText],
1279
1286
  cwd: folderPath,
1280
1287
  timeoutMs: cfg.runTimeoutMs,
1281
1288
  label: "coding",
@@ -2078,9 +2085,9 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2078
2085
  // (get_active_run) with no local match means the session likely lives on another
2079
2086
  // machine/instance — a bad `--resume` id makes claude error → empty diff → a failed
2080
2087
  // 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.
2088
+ // claude_code + cursor have proven resume flags (0282/0573); codex/unknown → [].
2082
2089
  let resumeSessionId = null;
2083
- if (effectiveMode === "iterate" && vendor === "claude_code") {
2090
+ if (effectiveMode === "iterate" && (vendor === "claude_code" || vendor === "cursor")) {
2084
2091
  const local = (() => {
2085
2092
  try {
2086
2093
  return readStateEntry(HILOS_DIR, threadRoot);
@@ -2209,9 +2216,13 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2209
2216
  // fallback) suppresses it. buildResumeArgs is [] unless vendor+session make
2210
2217
  // resume safe, so a non-resume run is byte-identical to the pre-0282 ARGV.
2211
2218
  const resumeArgs = resume ? buildResumeArgs(vendor, resumeSessionId) : [];
2219
+ // Model preset (0504): resolved at run time against the CLI's own model
2220
+ // list (cursor only today) — [] when unset/unresolvable, so the tool's
2221
+ // default stands. Inserted before resume/stream flags, after the base.
2222
+ const modelArgs = await modelArgsFor(cfg, vendor);
2212
2223
  const codeArgs = streamOn
2213
- ? [...parts.slice(1), ...resumeArgs, ...streamArgs]
2214
- : [...parts.slice(1), ...resumeArgs];
2224
+ ? [...parts.slice(1), ...modelArgs, ...resumeArgs, ...streamArgs]
2225
+ : [...parts.slice(1), ...modelArgs, ...resumeArgs];
2215
2226
  let run;
2216
2227
  try {
2217
2228
  run = await runCli({
@@ -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
+ }
@@ -32,7 +32,9 @@ export function detectVendor(codingCmd) {
32
32
  const base = (first.split(/[/\\]/).pop() || "").toLowerCase();
33
33
  if (base === "claude" || base === "claude-code" || base === "claude_code") return "claude_code";
34
34
  if (base === "codex") return "codex";
35
- if (base === "cursor" || base === "cursor-agent") return "cursor";
35
+ // `agent` is Cursor's canonical binary name since Jan 2026 (the installer
36
+ // symlinks both; `cursor-agent` remains an alias) — 0572.
37
+ if (base === "cursor" || base === "cursor-agent" || base === "agent") return "cursor";
36
38
  if (base === "agy" || base === "antigravity") return "antigravity";
37
39
  if (base === "hermes") return "hermes";
38
40
  return "unknown";
@@ -45,16 +47,18 @@ export function detectVendor(codingCmd) {
45
47
  * verified non-interactive print mode; the daemon appends the prompt as the last
46
48
  * arg. codex carries --skip-git-repo-check because chat (and the read-only
47
49
  * review sandbox) can run outside a git checkout. cursor carries
48
- * --output-format text because its -p default is stream-json, which would post
49
- * raw JSONL into the channel. Returns "" for unknown (caller falls back to
50
- * codingCmd).
50
+ * --output-format text (explicit, so a CLI default change can never post raw
51
+ * JSONL into the channel) and --trust (its Jan-2026 workspace-trust gate fails
52
+ * headless runs at spawn in untrusted directories — 0572; pre-2026 CLIs reject
53
+ * the flag and runCli retries without it). Returns "" for unknown (caller
54
+ * falls back to codingCmd).
51
55
  * @param {'claude_code'|'codex'|'cursor'|'antigravity'|'hermes'|'unknown'} vendor
52
56
  * @returns {string}
53
57
  */
54
58
  export function fastChatCmd(vendor) {
55
59
  if (vendor === "claude_code") return "claude -p --model claude-haiku-4-5";
56
60
  if (vendor === "codex") return "codex exec --skip-git-repo-check";
57
- if (vendor === "cursor") return "cursor-agent -p --output-format text";
61
+ if (vendor === "cursor") return "cursor-agent -p --output-format text --trust";
58
62
  if (vendor === "antigravity") return "agy -p";
59
63
  if (vendor === "hermes") return "hermes -z";
60
64
  return "";
@@ -62,15 +66,19 @@ export function fastChatCmd(vendor) {
62
66
 
63
67
  /**
64
68
  * Extra args to make the code run EMIT a structured stream, appended to the code
65
- * run's argv (NOT the display string) and ONLY for the code run. Only claude_code
66
- * has a proven flag (`--output-format stream-json --verbose`, per lib/agent-cli.ts
67
- * + scripts/verify-sandbox-mcp.mjs). codex/cursor return [] — their stream flags
69
+ * run's argv (NOT the display string) and ONLY for the code run. claude_code:
70
+ * `--output-format stream-json --verbose` (per lib/agent-cli.ts +
71
+ * scripts/verify-sandbox-mcp.mjs). cursor (0573): `--output-format stream-json`
72
+ * — appended AFTER the base's `--output-format text`, and live-verified on
73
+ * cursor-agent 2026.07.23 that the LAST occurrence wins, so the code run
74
+ * streams NDJSON while chat keeps text. codex returns [] — its stream flags
68
75
  * are deferred to 0278 rather than guessed (an unproven flag could break the run).
69
76
  * @param {'claude_code'|'codex'|'cursor'|'antigravity'|'hermes'|'unknown'} vendor
70
77
  * @returns {string[]}
71
78
  */
72
79
  export function codeStreamArgs(vendor) {
73
80
  if (vendor === "claude_code") return ["--output-format", "stream-json", "--verbose"];
81
+ if (vendor === "cursor") return ["--output-format", "stream-json"];
74
82
  return [];
75
83
  }
76
84
 
package/src/resume.mjs CHANGED
@@ -35,18 +35,21 @@ export const STATE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
35
35
  /**
36
36
  * The `--resume` flags for a coding vendor, or [] when resume isn't safe/known.
37
37
  *
38
- * ONLY claude_code has a proven, confirmed resume flag (`--resume <id>`, compatible
39
- * with `--output-format stream-json`). codex/cursor have UNCONFIRMED resume flags,
40
- * so we emit NOTHING rather than guess — a wrong flag could break the run; they
41
- * degrade to today's branch+feedback iterate (deferred to 0278/later). A falsy or
42
- * non-string sessionId also returns [] (nothing to resume).
38
+ * claude_code: `--resume <id>`, proven and compatible with
39
+ * `--output-format stream-json`. cursor (0573): `--resume <chatId>` —
40
+ * live-verified against cursor-agent 2026.07.23 that a resumed `-p` run
41
+ * answers from the prior session's context; the id is the `session_id` its
42
+ * stream-json init/result events carry. codex has an UNCONFIRMED resume flag,
43
+ * so we emit NOTHING rather than guess — a wrong flag could break the run; it
44
+ * degrades to today's branch+feedback iterate. A falsy or non-string sessionId
45
+ * also returns [] (nothing to resume).
43
46
  *
44
47
  * @param {'claude_code'|'codex'|'cursor'|'hermes'|'unknown'} vendor
45
48
  * @param {string|null|undefined} sessionId
46
49
  * @returns {string[]}
47
50
  */
48
51
  export function buildResumeArgs(vendor, sessionId) {
49
- if (vendor !== "claude_code") return [];
52
+ if (vendor !== "claude_code" && vendor !== "cursor") return [];
50
53
  if (typeof sessionId !== "string" || !sessionId.trim()) return [];
51
54
  return ["--resume", sessionId];
52
55
  }