hilos-agent 0.4.0 → 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 +26 -4
- package/bin/hilos-agent.mjs +16 -4
- package/package.json +1 -1
- package/src/agent-events.mjs +78 -10
- package/src/cli.mjs +122 -2
- package/src/config.mjs +47 -5
- package/src/deploy.mjs +234 -0
- package/src/handler.mjs +426 -28
- package/src/model-resolve.mjs +99 -0
- package/src/progress-emitter.mjs +40 -7
- package/src/redact.mjs +54 -0
- package/src/resume.mjs +10 -7
- package/src/run.mjs +13 -3
|
@@ -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
|
@@ -21,31 +21,64 @@ import { createStepRing, sanitizeText } from "./agent-events.mjs";
|
|
|
21
21
|
/**
|
|
22
22
|
* Which parser/stream-flags a coding command wants, from its FIRST token.
|
|
23
23
|
* `claude`/`claude-code` → claude_code, `codex` → codex,
|
|
24
|
-
* `cursor`/`cursor-agent` → cursor,
|
|
24
|
+
* `cursor`/`cursor-agent` → cursor, `agy`/`antigravity` → antigravity,
|
|
25
|
+
* `hermes` → hermes, everything else → unknown. Handles an
|
|
25
26
|
* absolute path (`/usr/local/bin/claude`) by taking the basename.
|
|
26
27
|
* @param {string} codingCmd
|
|
27
|
-
* @returns {'claude_code'|'codex'|'cursor'|'unknown'}
|
|
28
|
+
* @returns {'claude_code'|'codex'|'cursor'|'antigravity'|'hermes'|'unknown'}
|
|
28
29
|
*/
|
|
29
30
|
export function detectVendor(codingCmd) {
|
|
30
31
|
const first = String(codingCmd || "").trim().split(/\s+/)[0] || "";
|
|
31
32
|
const base = (first.split(/[/\\]/).pop() || "").toLowerCase();
|
|
32
33
|
if (base === "claude" || base === "claude-code" || base === "claude_code") return "claude_code";
|
|
33
34
|
if (base === "codex") return "codex";
|
|
34
|
-
|
|
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";
|
|
38
|
+
if (base === "agy" || base === "antigravity") return "antigravity";
|
|
39
|
+
if (base === "hermes") return "hermes";
|
|
35
40
|
return "unknown";
|
|
36
41
|
}
|
|
37
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The FAST one-shot chat command for a coding vendor — used when the user didn't
|
|
45
|
+
* set chatCmd explicitly, so a Codex/Cursor/Antigravity daemon never needs Claude
|
|
46
|
+
* Code installed just to answer chat (0521). Every command is the vendor's
|
|
47
|
+
* verified non-interactive print mode; the daemon appends the prompt as the last
|
|
48
|
+
* arg. codex carries --skip-git-repo-check because chat (and the read-only
|
|
49
|
+
* review sandbox) can run outside a git checkout. cursor carries
|
|
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).
|
|
55
|
+
* @param {'claude_code'|'codex'|'cursor'|'antigravity'|'hermes'|'unknown'} vendor
|
|
56
|
+
* @returns {string}
|
|
57
|
+
*/
|
|
58
|
+
export function fastChatCmd(vendor) {
|
|
59
|
+
if (vendor === "claude_code") return "claude -p --model claude-haiku-4-5";
|
|
60
|
+
if (vendor === "codex") return "codex exec --skip-git-repo-check";
|
|
61
|
+
if (vendor === "cursor") return "cursor-agent -p --output-format text --trust";
|
|
62
|
+
if (vendor === "antigravity") return "agy -p";
|
|
63
|
+
if (vendor === "hermes") return "hermes -z";
|
|
64
|
+
return "";
|
|
65
|
+
}
|
|
66
|
+
|
|
38
67
|
/**
|
|
39
68
|
* Extra args to make the code run EMIT a structured stream, appended to the code
|
|
40
|
-
* run's argv (NOT the display string) and ONLY for the code run.
|
|
41
|
-
*
|
|
42
|
-
*
|
|
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
|
|
43
75
|
* are deferred to 0278 rather than guessed (an unproven flag could break the run).
|
|
44
|
-
* @param {'claude_code'|'codex'|'cursor'|'unknown'} vendor
|
|
76
|
+
* @param {'claude_code'|'codex'|'cursor'|'antigravity'|'hermes'|'unknown'} vendor
|
|
45
77
|
* @returns {string[]}
|
|
46
78
|
*/
|
|
47
79
|
export function codeStreamArgs(vendor) {
|
|
48
80
|
if (vendor === "claude_code") return ["--output-format", "stream-json", "--verbose"];
|
|
81
|
+
if (vendor === "cursor") return ["--output-format", "stream-json"];
|
|
49
82
|
return [];
|
|
50
83
|
}
|
|
51
84
|
|
package/src/redact.mjs
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Credential redaction shared by hosted transcript persistence and local deploy
|
|
2
|
+
// output. Keep this dependency-free so the published hilos-agent package can
|
|
3
|
+
// redact CLI output before it is returned, logged, or attached to a report.
|
|
4
|
+
|
|
5
|
+
const PEM_BLOCK = /-----BEGIN [A-Z0-9 ]*?(?:PRIVATE KEY|PRIVATE KEY BLOCK)-----[\s\S]*?-----END [A-Z0-9 ]*?(?:PRIVATE KEY|PRIVATE KEY BLOCK)-----/g;
|
|
6
|
+
const JWT = /\beyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}(?:\.[A-Za-z0-9_-]{6,})?\b/g;
|
|
7
|
+
const URL_CREDENTIALS = /(\b[a-z][a-z0-9+.-]*:\/\/)([^\/\s:@"']+):([^\/\s@"']+)@/gi;
|
|
8
|
+
const PROVIDER_TOKENS = new RegExp(
|
|
9
|
+
[
|
|
10
|
+
"sk-ant-[A-Za-z0-9_-]{8,}",
|
|
11
|
+
"sk-proj-[A-Za-z0-9_-]{8,}",
|
|
12
|
+
"sk-ws-[A-Za-z0-9._-]{16,}",
|
|
13
|
+
"sk-[A-Za-z0-9]{20,}",
|
|
14
|
+
"gh[pousr]_[A-Za-z0-9]{20,}",
|
|
15
|
+
"github_pat_[A-Za-z0-9_]{20,}",
|
|
16
|
+
"xox[baprs]-[A-Za-z0-9-]{10,}",
|
|
17
|
+
"sbp_[A-Za-z0-9]{20,}",
|
|
18
|
+
"sb_secret_[A-Za-z0-9_-]{10,}",
|
|
19
|
+
"AKIA[0-9A-Z]{16}",
|
|
20
|
+
"ASIA[0-9A-Z]{16}",
|
|
21
|
+
"AIza[A-Za-z0-9_-]{30,}",
|
|
22
|
+
"hilos_live_[A-Za-z0-9_-]{8,}",
|
|
23
|
+
"vercel_[A-Za-z0-9]{20,}",
|
|
24
|
+
"re_[A-Za-z0-9]{20,}",
|
|
25
|
+
].join("|"),
|
|
26
|
+
"g",
|
|
27
|
+
);
|
|
28
|
+
const BEARER = /(\b[Bb]earer["']?[\s:=]+["']?)[A-Za-z0-9._~+/=-]{12,}/g;
|
|
29
|
+
const ASSIGNMENT =
|
|
30
|
+
/((?:api[_-]?key|access[_-]?key|secret[_-]?access[_-]?key|client[_-]?secret|private[_-]?key|service[_-]?role[_-]?key|auth[_-]?token|refresh[_-]?token|session[_-]?token|apikey|token|secret|password|passwd|credential)["']?\s*[:=]+\s*["']?)(?!\[redacted:)([A-Za-z0-9_~.+/-]{8,})/gi;
|
|
31
|
+
|
|
32
|
+
/** Replace credential-shaped content with typed markers. Idempotent. */
|
|
33
|
+
export function redactSecrets(text) {
|
|
34
|
+
if (!text) return text;
|
|
35
|
+
return text
|
|
36
|
+
.replace(PEM_BLOCK, "[redacted:pem]")
|
|
37
|
+
.replace(JWT, "[redacted:jwt]")
|
|
38
|
+
.replace(URL_CREDENTIALS, "$1[redacted:url-credentials]@")
|
|
39
|
+
.replace(PROVIDER_TOKENS, "[redacted:token]")
|
|
40
|
+
.replace(BEARER, "$1[redacted:token]")
|
|
41
|
+
.replace(ASSIGNMENT, (match, prefix, _value, offset, source) => {
|
|
42
|
+
if (source[offset + match.length] === "(") return match;
|
|
43
|
+
return `${prefix}[redacted:value]`;
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Keep the transcript tail at a whole-line boundary. */
|
|
48
|
+
export function truncateTranscriptTail(text, maxChars) {
|
|
49
|
+
if (text.length <= maxChars) return text;
|
|
50
|
+
const tail = text.slice(text.length - maxChars);
|
|
51
|
+
const newline = tail.indexOf("\n");
|
|
52
|
+
const clean = newline >= 0 ? tail.slice(newline + 1) : tail;
|
|
53
|
+
return `{"type":"hilos_truncated","note":"earlier transcript trimmed to fit the storage cap"}\n${clean}`;
|
|
54
|
+
}
|
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
|
-
* @param {'claude_code'|'codex'|'cursor'|'unknown'} vendor
|
|
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
|
}
|
package/src/run.mjs
CHANGED
|
@@ -78,7 +78,7 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
78
78
|
|
|
79
79
|
const since = cfg.backfill ? 0 : Date.now();
|
|
80
80
|
const toolNames = await listToolNames();
|
|
81
|
-
const useMentions =
|
|
81
|
+
const useMentions = toolNames.includes("list_mentions");
|
|
82
82
|
// Capabilities of THIS server, so the handler degrades gracefully on older
|
|
83
83
|
// deploys (e.g. no edit_message → no live heartbeat, rather than erroring).
|
|
84
84
|
const caps = {
|
|
@@ -96,6 +96,9 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
96
96
|
// coding agent has the workspace's conventions and gotchas from the start.
|
|
97
97
|
// Absent on older servers → silently skipped, no change in behavior.
|
|
98
98
|
recall: toolNames.includes("recall"),
|
|
99
|
+
// Semantic local-folder intent (0515): the server guarantees a forced-tool
|
|
100
|
+
// ask/ship/deploy decision. Older servers fall back to the local router.
|
|
101
|
+
agentIntent: toolNames.includes("classify_agent_intent"),
|
|
99
102
|
};
|
|
100
103
|
|
|
101
104
|
// Register local folders (0324/0325): a folder-mode daemon announces each
|
|
@@ -194,7 +197,10 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
194
197
|
log.log(" (deduped a repeat of an in-flight/queued ask)");
|
|
195
198
|
return;
|
|
196
199
|
}
|
|
197
|
-
|
|
200
|
+
// A report-card deploy decision is already visible in place. list_mentions
|
|
201
|
+
// projects the pending decision into this structured event, so don't add a
|
|
202
|
+
// misleading "queued" chat line while the active run settles it.
|
|
203
|
+
if (liveCfg.queueAcks && !r.startedImmediately && !m.deployRequest) {
|
|
198
204
|
await tool("post_message", {
|
|
199
205
|
channelId,
|
|
200
206
|
parentId: m.parentId ?? null,
|
|
@@ -204,7 +210,11 @@ export async function run(cfg, { handler = handleTask, log = console, signal, on
|
|
|
204
210
|
}
|
|
205
211
|
|
|
206
212
|
async function passViaMentions() {
|
|
207
|
-
const
|
|
213
|
+
const mentionArgs = {
|
|
214
|
+
...(cursor.value ? { since: cursor.value } : {}),
|
|
215
|
+
...(cfg.channelId ? { channelId: cfg.channelId } : {}),
|
|
216
|
+
};
|
|
217
|
+
const out = await tool("list_mentions", mentionArgs);
|
|
208
218
|
const mentions = (out?.mentions ?? []).slice().reverse();
|
|
209
219
|
for (const m of mentions) {
|
|
210
220
|
if (seen.has(m.id)) continue;
|