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 +2 -1
- package/bin/hilos-agent.mjs +7 -2
- package/package.json +1 -1
- package/src/agent-events.mjs +78 -10
- package/src/cli.mjs +25 -1
- package/src/config.mjs +23 -2
- package/src/handler.mjs +16 -5
- package/src/model-resolve.mjs +99 -0
- package/src/progress-emitter.mjs +16 -8
- package/src/resume.mjs +9 -6
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
|
package/bin/hilos-agent.mjs
CHANGED
|
@@ -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
|
|
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.
|
|
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": {
|
package/src/agent-events.mjs
CHANGED
|
@@ -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
|
|
5
|
-
// any of them into ONE small,
|
|
6
|
-
// live "what the agent is
|
|
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"; //
|
|
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
|
|
253
|
-
* to parse, so we only remember the last
|
|
254
|
-
*
|
|
255
|
-
* and emit at most one
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
+
}
|
package/src/progress-emitter.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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.
|
|
66
|
-
*
|
|
67
|
-
*
|
|
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
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
}
|