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.
- package/README.md +80 -16
- package/bin/hilos-agent.mjs +16 -6
- package/package.json +1 -1
- package/src/acp-session.mjs +69 -54
- package/src/agent-events.mjs +645 -45
- package/src/argv.mjs +61 -0
- package/src/attachments.mjs +310 -0
- package/src/claude-permissions.mjs +445 -0
- package/src/cli.mjs +56 -0
- package/src/codex-mcp-session.mjs +619 -0
- package/src/config.mjs +83 -7
- package/src/handler.mjs +914 -77
- package/src/hook.mjs +793 -108
- package/src/mcp-loopback.mjs +142 -0
- package/src/mcp.mjs +3 -2
- package/src/model-resolve.mjs +180 -11
- package/src/permission-gate.mjs +269 -0
- package/src/progress-emitter.mjs +100 -5
- package/src/queue.mjs +21 -5
- package/src/redact.mjs +11 -1
- package/src/reply-bridge.mjs +847 -0
- package/src/resume.mjs +48 -11
- package/src/run.mjs +130 -4
- package/src/transcript.mjs +153 -0
package/src/progress-emitter.mjs
CHANGED
|
@@ -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 {
|
|
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 =
|
|
33
|
-
const base = (first.split(/[/\\]/).pop() || "")
|
|
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
|
|
81
|
-
*
|
|
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
|
|
22
|
-
* never spawns N branches), but two different
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
63
|
+
return `${TRANSCRIPT_TRUNCATED_LINE}\n${clean}`;
|
|
54
64
|
}
|