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.
@@ -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
+ }
@@ -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, everything else → unknown. Handles an
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
- 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";
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. Only claude_code
41
- * has a proven flag (`--output-format stream-json --verbose`, per lib/agent-cli.ts
42
- * + 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
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
- * 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
- * @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 = !cfg.channelId && toolNames.includes("list_mentions");
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
- if (liveCfg.queueAcks && !r.startedImmediately) {
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 out = await tool("list_mentions", cursor.value ? { since: cursor.value } : {});
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;