hilos-agent 0.9.0 → 0.9.2

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.
@@ -16,7 +16,13 @@
16
16
  // - Defense-in-depth: every string is re-sanitized here (the parser already
17
17
  // redacts, the server will redact again) and arrays are clamped.
18
18
 
19
- import { createActivityFold, createStepRing, sanitizeText } from "./agent-events.mjs";
19
+ import {
20
+ createActivityFold,
21
+ createStepRing,
22
+ createUsageFold,
23
+ sanitizeText,
24
+ } from "./agent-events.mjs";
25
+ import { commandArgv } from "./argv.mjs";
20
26
 
21
27
  /**
22
28
  * Which parser/stream-flags a coding command wants, from its FIRST token.
@@ -29,8 +35,10 @@ import { createActivityFold, createStepRing, sanitizeText } from "./agent-events
29
35
  * @returns {'claude_code'|'codex'|'cursor'|'opencode'|'antigravity'|'hermes'|'unknown'}
30
36
  */
31
37
  export function detectVendor(codingCmd) {
32
- const first = String(codingCmd || "").trim().split(/\s+/)[0] || "";
33
- const base = (first.split(/[/\\]/).pop() || "").toLowerCase();
38
+ const first = commandArgv(codingCmd)[0] || "";
39
+ const base = (first.split(/[/\\]/).pop() || "")
40
+ .toLowerCase()
41
+ .replace(/\.(?:exe|cmd|bat|com)$/i, "");
34
42
  if (base === "claude" || base === "claude-code" || base === "claude_code") return "claude_code";
35
43
  if (base === "codex") return "codex";
36
44
  // `agent` is Cursor's canonical binary name since Jan 2026 (the installer
@@ -77,8 +85,12 @@ export function fastChatCmd(vendor) {
77
85
  * scripts/verify-sandbox-mcp.mjs). cursor (0573): `--output-format stream-json`
78
86
  * — appended AFTER the base's `--output-format text`, and live-verified on
79
87
  * cursor-agent 2026.07.23 that the LAST occurrence wins, so the code run
80
- * streams NDJSON while chat keeps text. codex returns [] — its stream flags
81
- * are deferred to 0278 rather than guessed (an unproven flag could break the run).
88
+ * streams NDJSON while chat keeps text. codex (0783): `--json` ("Print events
89
+ * to stdout as JSONL", `codex exec --help`) — captured live against codex-cli
90
+ * 0.144.1, where a real run emitted thread/turn envelopes plus
91
+ * command_execution + file_change + agent_message items, which is exactly what
92
+ * the parser reads. Before this the flag was [] and a Codex run had no live
93
+ * steps at all. The FAST chat path stays plain `codex exec` (prose, not JSONL).
82
94
  * opencode (0608): `--format json`. 0592 left it OFF because nothing parsed its
83
95
  * event shape and the rawTail fallback drops JSON-looking lines; the
84
96
  * agent-events parser now reads those events (steps, the final summary, and the
@@ -90,10 +102,67 @@ export function fastChatCmd(vendor) {
90
102
  export function codeStreamArgs(vendor) {
91
103
  if (vendor === "claude_code") return ["--output-format", "stream-json", "--verbose"];
92
104
  if (vendor === "cursor") return ["--output-format", "stream-json"];
105
+ if (vendor === "codex") return ["--json"];
93
106
  if (vendor === "opencode") return ["--format", "json"];
94
107
  return [];
95
108
  }
96
109
 
110
+ /**
111
+ * Extra args that hand the code run an IMAGE, appended to the code run's argv
112
+ * (0779). Verified against the installed binaries, per the 0521 rule — never
113
+ * guessed from docs:
114
+ *
115
+ * - codex: `--image=<FILE>`, one arg per file. The flag is `-i, --image
116
+ * <FILE>...` ("Optional image(s) to attach to the initial prompt", `codex
117
+ * exec --help`, codex-cli 0.144.x) — and it is VARIADIC, which is the whole
118
+ * reason for the `=` form: the daemon puts the prompt LAST in the argv, and
119
+ * a live run of `codex exec -i shot.png "<prompt>"` swallowed the prompt as
120
+ * a second image and died with "no prompt provided via stdin". `--image=`
121
+ * binds exactly one value, so the trailing prompt survives. Verified live
122
+ * against the installed binary, both ways.
123
+ * - claude_code: NOTHING. Claude Code has no local-image flag — `claude
124
+ * --help` documents `--file <specs...>` as `file_id:relative_path`, a
125
+ * REMOTE file-resource download, so passing a local path there is wrong.
126
+ * Its handoff is the file on disk plus a prompt line naming it, which its
127
+ * read tool opens as an image (see imagePromptNote's `readable` mode).
128
+ * - cursor / opencode / antigravity / hermes: no verified image flag →
129
+ * nothing. They still get the prompt note naming the local file, which is
130
+ * strictly better than the pre-0779 silence.
131
+ *
132
+ * @param {'claude_code'|'codex'|'cursor'|'opencode'|'antigravity'|'hermes'|'unknown'} vendor
133
+ * @param {{path: string}[]} files
134
+ * @returns {string[]}
135
+ */
136
+ export function codeImageArgs(vendor, files) {
137
+ const paths = (files || []).map((f) => (f && typeof f.path === "string" ? f.path : "")).filter(Boolean);
138
+ if (!paths.length) return [];
139
+ if (vendor === "codex") return paths.map((p) => `--image=${p}`);
140
+ return [];
141
+ }
142
+
143
+ /**
144
+ * True when the prompt must tell the agent to open the image file itself,
145
+ * because nothing put the bytes in front of it.
146
+ *
147
+ * Claude Code (and everyone else without a verified flag) reads images through
148
+ * its own file-read tool, so the prompt always has to say so. Codex is the one
149
+ * vendor that takes the bytes on its argv (`--image=`) — but ONLY on the exec
150
+ * transport. A GATED codex run goes through `codex mcp-server`, whose tool
151
+ * schema has no image field at all (0785, live: the server rejects one and
152
+ * lists its nine accepted fields), so the argv never happens and the exec
153
+ * wording — "they are attached to this prompt" — would be a lie. Gated codex
154
+ * therefore reads its images the way claude does, off disk, which it really
155
+ * does do: given a path in the prompt it calls its own `view_image` tool.
156
+ *
157
+ * @param {'claude_code'|'codex'|'cursor'|'opencode'|'antigravity'|'hermes'|'unknown'} vendor
158
+ * @param {{ gated?: boolean }} [opts] `gated:true` when this run takes a
159
+ * permission-gated transport rather than the plain argv one
160
+ */
161
+ export function imagesNeedReading(vendor, { gated = false } = {}) {
162
+ if (vendor !== "codex") return true;
163
+ return gated === true;
164
+ }
165
+
97
166
  /**
98
167
  * The server an `opencode run --attach <url>` command targets, or null for a
99
168
  * normal local run. Read off the TOKENIZED command (never a substring match —
@@ -228,6 +297,11 @@ export function createProgressEmitter({
228
297
  // The ring's richer sibling (0750): structured rows for the activity feed.
229
298
  // Older servers drop the unknown field; newer ones render it (0537).
230
299
  const activity = createActivityFold();
300
+ // What the run cost (0787). Deliberately NOT part of `snapshot()`: the live
301
+ // card is about what the agent is doing, and the wire payload stays exactly
302
+ // what every deployed server already accepts. The daemon reads it once the
303
+ // run settles and sends it with the report instead.
304
+ const usage = createUsageFold();
231
305
  const files = [];
232
306
  const fileSet = new Set();
233
307
  const startedAt = now();
@@ -252,6 +326,11 @@ export function createProgressEmitter({
252
326
 
253
327
  function fold(ev) {
254
328
  if (!ev || typeof ev !== "object") return;
329
+ // Accounting, not narration — it never becomes a step or a feed row.
330
+ if (ev.t === "usage") {
331
+ usage.push(ev);
332
+ return;
333
+ }
255
334
  ring.push(ev); // steps[] (coalesces same-file edits + dup labels)
256
335
  activity.push(ev); // feed rows (tense follows status; server re-validates)
257
336
  switch (ev.t) {
@@ -362,6 +441,15 @@ export function createProgressEmitter({
362
441
  for (const ev of events) fold(ev); // a clean note/summary overrides the tail
363
442
  scheduleSend();
364
443
  },
444
+ /** Fold one already-normalized AgentEvent → (throttled) send. The ACP
445
+ * transports (0759) produce structured events directly — there is no raw
446
+ * stdout stream to parse, so this is their entry into the same ring,
447
+ * activity fold, and file list that feed() populates. */
448
+ foldEvent(ev) {
449
+ if (!ev || typeof ev !== "object") return;
450
+ fold(ev);
451
+ scheduleSend();
452
+ },
365
453
  /** Force-send the current snapshot now (bypasses the throttle). */
366
454
  flush() {
367
455
  doSend();
@@ -393,5 +481,12 @@ export function createProgressEmitter({
393
481
  emit();
394
482
  },
395
483
  snapshot,
484
+ /**
485
+ * What this run cost, as the CLI itself reported it (0787), or null when it
486
+ * reported nothing. Read AFTER `done()` — the terminal usage frame usually
487
+ * arrives in the flush. Non-consuming: reading it twice returns the same
488
+ * totals, and it is the caller's fold that owns the delta contract.
489
+ */
490
+ usage: () => usage.total(),
396
491
  };
397
492
  }
package/src/queue.mjs CHANGED
@@ -17,15 +17,31 @@ export function normalizeTask(text) {
17
17
  .trim();
18
18
  }
19
19
 
20
+ /** Which attachments a mention carries, as a stable key fragment. 0779: "fix
21
+ * this" + screenshot A and "fix this" + screenshot B are DIFFERENT asks — the
22
+ * picture is half the request — and folding only the body collapsed them into
23
+ * one job, silently dropping the second image. Ids (falling back to name+type)
24
+ * are enough; the signed url changes every poll and would break dedupe the
25
+ * other way. */
26
+ function attachmentKey(message) {
27
+ const list = message && Array.isArray(message.attachments) ? message.attachments : [];
28
+ if (!list.length) return "";
29
+ return list
30
+ .map((a) => (a && (a.id || `${a.name || ""}:${a.type || ""}`)) || "")
31
+ .join(",");
32
+ }
33
+
20
34
  /** Burst-dedupe key: same requester + same thread (or channel) + same normalized
21
- * ask. A burst of identical pings from ONE person collapses to one job (one ask
22
- * never spawns N branches), but two different people asking the same thing stay
23
- * distinct. `author` is a display name (not a stable id), so same-named users can
24
- * still collide — acceptable until the mention payload carries a user id. */
35
+ * ask + the same attachments. A burst of identical pings from ONE person
36
+ * collapses to one job (one ask never spawns N branches), but two different
37
+ * people asking the same thing — or the same person asking the same words about
38
+ * a different screenshot — stay distinct. `author` is a display name (not a
39
+ * stable id), so same-named users can still collide — acceptable until the
40
+ * mention payload carries a user id. */
25
41
  export function dedupeKey(message, channelId) {
26
42
  const scope = (message && message.parentId) || channelId || "";
27
43
  const who = (message && message.author) || "";
28
- return `${scope}::${who}::${normalizeTask(message && message.body)}`;
44
+ return `${scope}::${who}::${normalizeTask(message && message.body)}::${attachmentKey(message)}`;
29
45
  }
30
46
 
31
47
  const CANCEL_LEADING =
package/src/redact.mjs CHANGED
@@ -44,11 +44,21 @@ export function redactSecrets(text) {
44
44
  });
45
45
  }
46
46
 
47
+ /**
48
+ * The record that says a transcript lost its front. It is a real NDJSON line so
49
+ * the digest can render it, and it is exported because more than one place has
50
+ * to cut a tail now — the daemon trims its buffer as a run streams, long before
51
+ * anything reaches `truncateTranscriptTail`. Two copies of this literal would
52
+ * be two chances for a marker to go missing.
53
+ */
54
+ export const TRANSCRIPT_TRUNCATED_LINE =
55
+ '{"type":"hilos_truncated","note":"earlier transcript trimmed to fit the storage cap"}';
56
+
47
57
  /** Keep the transcript tail at a whole-line boundary. */
48
58
  export function truncateTranscriptTail(text, maxChars) {
49
59
  if (text.length <= maxChars) return text;
50
60
  const tail = text.slice(text.length - maxChars);
51
61
  const newline = tail.indexOf("\n");
52
62
  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}`;
63
+ return `${TRANSCRIPT_TRUNCATED_LINE}\n${clean}`;
54
64
  }