hilos-agent 0.10.1 → 0.11.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.
@@ -0,0 +1,370 @@
1
+ // Local coding-runtime stream -> Harness Event Protocol adapter.
2
+ //
3
+ // This is deliberately a side projection of agent-events.mjs. The current
4
+ // progress card keeps consuming its existing AgentEvent stream unchanged; this
5
+ // module reuses that parser to produce replayable HEP inputs without performing
6
+ // a network, storage, or transcript write. A caller must provide a streamId
7
+ // which is stable for the lifetime of one runtime process. Replaying the same
8
+ // bytes with that id produces the same idempotency keys, independent of chunk
9
+ // boundaries.
10
+
11
+ import { createHash } from "node:crypto";
12
+
13
+ import { makeStreamParser } from "./agent-events.mjs";
14
+
15
+ export const LOCAL_HARNESS_LINE_MAX_CHARS = 256 * 1024;
16
+ export const LOCAL_HARNESS_EVENTS_PER_LINE_MAX = 64;
17
+
18
+ /**
19
+ * @typedef {object} LocalHarnessContext
20
+ * @property {string} runtime Claude Code, Codex, Cursor, or OpenCode vendor id.
21
+ * @property {string} streamId Stable id for one spawned runtime process. It is
22
+ * hashed before it enters an event.
23
+ * @property {string} actorId Hilos agent UUID which owns the local run.
24
+ * @property {string} [lane]
25
+ */
26
+
27
+ /**
28
+ * The dependency-free package cannot import the app's TypeScript type, so this
29
+ * JSDoc shape intentionally mirrors `HarnessEventInput` in lib/harness-events.
30
+ * @typedef {object} LocalHarnessEventInput
31
+ * @property {string} type
32
+ * @property {number} version
33
+ * @property {string} lane
34
+ * @property {string} runtime
35
+ * @property {string} turnId
36
+ * @property {{type: 'agent', id: string}} actor
37
+ * @property {string} [summary]
38
+ * @property {'prose'|'command'} [summaryKind]
39
+ * @property {Record<string, unknown>} payload
40
+ * @property {string} idempotencyKey
41
+ */
42
+
43
+ /** Canonical HEP runtime slug for the aliases accepted by makeStreamParser. */
44
+ export function normalizeLocalHarnessRuntime(runtime) {
45
+ const value = String(runtime || "").toLowerCase();
46
+ if (value === "claude" || value === "claude-code" || value === "claude_code") {
47
+ return "claude_code";
48
+ }
49
+ if (value === "codex" || value === "cursor" || value === "opencode") return value;
50
+ return "custom";
51
+ }
52
+
53
+ function runtimeLabel(runtime) {
54
+ if (runtime === "claude_code") return "Claude Code";
55
+ if (runtime === "codex") return "Codex";
56
+ if (runtime === "cursor") return "Cursor";
57
+ if (runtime === "opencode") return "OpenCode";
58
+ return "The coding runtime";
59
+ }
60
+
61
+ function digest(value) {
62
+ return createHash("sha256").update(value).digest("hex");
63
+ }
64
+
65
+ function streamDigest(runtime, streamId) {
66
+ return digest(`local-hep-v1\0${runtime}\0${String(streamId ?? "")}`);
67
+ }
68
+
69
+ function eventKey(streamHash, sourcePosition, ordinal, type) {
70
+ return `local-stream-v1:${digest(
71
+ `${streamHash}\0${sourcePosition}\0${ordinal}\0${type}`,
72
+ )}`;
73
+ }
74
+
75
+ function eventSpec(event, runtime) {
76
+ const label = runtimeLabel(runtime);
77
+ switch (event && event.t) {
78
+ case "session":
79
+ return {
80
+ type: "run.phase.changed",
81
+ summary: `${label} session started.`,
82
+ summaryKind: "prose",
83
+ payload: {
84
+ normalizedKind: "session",
85
+ phase: "runtime_ready",
86
+ ...(event.sessionId ? { sessionId: event.sessionId } : {}),
87
+ },
88
+ };
89
+ case "phase":
90
+ return {
91
+ type: "run.phase.changed",
92
+ ...(event.name
93
+ ? { summary: `${label} entered ${event.name}.`, summaryKind: "prose" }
94
+ : {}),
95
+ payload: {
96
+ normalizedKind: "phase",
97
+ ...(event.name ? { phase: event.name } : {}),
98
+ },
99
+ };
100
+ case "edit":
101
+ return {
102
+ type: "tool.started",
103
+ summary: event.path ? `Editing ${event.path}` : "Editing files",
104
+ summaryKind: "prose",
105
+ payload: {
106
+ normalizedKind: "edit",
107
+ tool: "file_edit",
108
+ ...(event.path ? { path: event.path } : {}),
109
+ },
110
+ };
111
+ case "read":
112
+ return {
113
+ type: "tool.started",
114
+ summary: event.path ? `Reading ${event.path}` : "Reading files",
115
+ summaryKind: "prose",
116
+ payload: {
117
+ normalizedKind: "read",
118
+ tool: "file_read",
119
+ ...(event.path ? { path: event.path } : {}),
120
+ },
121
+ };
122
+ case "run":
123
+ return {
124
+ type: "tool.started",
125
+ ...(event.cmd ? { summary: event.cmd, summaryKind: "command" } : {}),
126
+ payload: {
127
+ normalizedKind: "run",
128
+ tool: "shell",
129
+ ...(event.cmd ? { command: event.cmd } : {}),
130
+ },
131
+ };
132
+ case "websearch":
133
+ return {
134
+ type: "tool.started",
135
+ summary: event.query ? `Searching the web for ${event.query}` : "Searching the web",
136
+ summaryKind: "prose",
137
+ payload: {
138
+ normalizedKind: "websearch",
139
+ tool: "web_search",
140
+ ...(event.query ? { query: event.query } : {}),
141
+ },
142
+ };
143
+ case "webfetch":
144
+ return {
145
+ type: "tool.started",
146
+ summary: event.target ? `Reading ${event.target}` : "Reading a web page",
147
+ summaryKind: "prose",
148
+ payload: {
149
+ normalizedKind: "webfetch",
150
+ tool: "web_fetch",
151
+ ...(event.target ? { target: event.target } : {}),
152
+ },
153
+ };
154
+ case "subagent":
155
+ return {
156
+ type: "child.started",
157
+ ...(event.text
158
+ ? { summary: `Started a helper: ${event.text}`, summaryKind: "prose" }
159
+ : {}),
160
+ payload: {
161
+ normalizedKind: "subagent",
162
+ ...(event.text ? { description: event.text } : {}),
163
+ },
164
+ };
165
+ case "note":
166
+ if (!event.text) return null;
167
+ return {
168
+ type: "note.recorded",
169
+ summary: event.text,
170
+ summaryKind: "prose",
171
+ payload: { normalizedKind: "note", text: event.text },
172
+ };
173
+ case "usage":
174
+ return {
175
+ type: "usage.updated",
176
+ summary: `${label} reported token usage.`,
177
+ summaryKind: "prose",
178
+ payload: {
179
+ normalizedKind: "usage",
180
+ inputTokens: event.inputTokens ?? 0,
181
+ outputTokens: event.outputTokens ?? 0,
182
+ cacheReadTokens: event.cacheReadTokens ?? 0,
183
+ cacheCreationTokens: event.cacheCreationTokens ?? 0,
184
+ ...(event.costUsd == null ? {} : { costUsd: event.costUsd }),
185
+ ...(event.model ? { model: event.model } : {}),
186
+ },
187
+ };
188
+ case "result":
189
+ return {
190
+ type: "turn.completed",
191
+ ...(event.summary
192
+ ? { summary: event.summary, summaryKind: "prose" }
193
+ : {}),
194
+ payload: {
195
+ normalizedKind: "result",
196
+ ok: event.ok !== false,
197
+ outcome: event.ok === false ? "failed" : "succeeded",
198
+ },
199
+ };
200
+ default:
201
+ return null;
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Stateful raw-stream adapter. `push` accepts arbitrary stdout chunks and
207
+ * `flush` must be called exactly once at process EOF. Neither method throws for
208
+ * malformed runtime output.
209
+ *
210
+ * @param {LocalHarnessContext} context
211
+ */
212
+ export function createLocalHarnessStreamAdapter(context) {
213
+ const runtime = normalizeLocalHarnessRuntime(context?.runtime);
214
+ const parser = makeStreamParser(runtime);
215
+ const streamHash = streamDigest(runtime, context?.streamId);
216
+ const turnId = `local_${streamHash.slice(0, 32)}`;
217
+ const lane = context?.lane || "local";
218
+ const actorId = String(context?.actorId || "");
219
+ let buffer = "";
220
+ let lineNumber = 0;
221
+ let discardedChars = 0;
222
+ let discarding = false;
223
+ let structuredEventNumber = 0;
224
+ let finished = false;
225
+
226
+ const materialize = (events, sourcePosition, sourceLine, sourcePhase = "line") => {
227
+ const specs = events.map((event) => eventSpec(event, runtime)).filter(Boolean);
228
+ let bounded = specs;
229
+ if (specs.length > LOCAL_HARNESS_EVENTS_PER_LINE_MAX) {
230
+ bounded = [
231
+ ...specs.slice(0, LOCAL_HARNESS_EVENTS_PER_LINE_MAX - 1),
232
+ {
233
+ type: "note.recorded",
234
+ summary: "Additional runtime events were withheld.",
235
+ summaryKind: "prose",
236
+ payload: {
237
+ withheld: true,
238
+ reason: "runtime_line_event_limit",
239
+ omittedEvents: specs.length - (LOCAL_HARNESS_EVENTS_PER_LINE_MAX - 1),
240
+ },
241
+ },
242
+ ];
243
+ }
244
+ return bounded.map((spec, ordinal) => ({
245
+ type: spec.type,
246
+ version: 1,
247
+ lane,
248
+ runtime,
249
+ turnId,
250
+ actor: { type: "agent", id: actorId },
251
+ ...(spec.summary === undefined ? {} : { summary: spec.summary }),
252
+ ...(spec.summaryKind === undefined ? {} : { summaryKind: spec.summaryKind }),
253
+ payload: {
254
+ source: "runtime_stream",
255
+ sourceLine,
256
+ sourcePhase,
257
+ ...spec.payload,
258
+ },
259
+ idempotencyKey: eventKey(streamHash, sourcePosition, ordinal, spec.type),
260
+ }));
261
+ };
262
+
263
+ const tooLarge = (sourceLine, originalCharacters) =>
264
+ materialize(
265
+ [
266
+ {
267
+ t: "note",
268
+ text: "A runtime event was too large to inspect.",
269
+ },
270
+ ],
271
+ `line:${sourceLine}:withheld`,
272
+ sourceLine,
273
+ ).map((event) => ({
274
+ ...event,
275
+ payload: {
276
+ ...event.payload,
277
+ withheld: true,
278
+ reason: "runtime_line_too_large",
279
+ originalCharacters,
280
+ },
281
+ }));
282
+
283
+ const consumeLine = (line) => {
284
+ lineNumber += 1;
285
+ if (line.length > LOCAL_HARNESS_LINE_MAX_CHARS) {
286
+ return tooLarge(lineNumber, line.length);
287
+ }
288
+ return materialize(parser.push(`${line}\n`), `line:${lineNumber}`, lineNumber);
289
+ };
290
+
291
+ return {
292
+ /** @param {string} chunk @returns {LocalHarnessEventInput[]} */
293
+ push(chunk) {
294
+ if (finished) return [];
295
+ const input = String(chunk ?? "");
296
+ const out = [];
297
+ let start = 0;
298
+ while (start < input.length) {
299
+ const newline = input.indexOf("\n", start);
300
+ const end = newline === -1 ? input.length : newline;
301
+ const fragment = input.slice(start, end);
302
+ if (discarding) {
303
+ discardedChars += fragment.length;
304
+ } else {
305
+ buffer += fragment;
306
+ if (buffer.length > LOCAL_HARNESS_LINE_MAX_CHARS) {
307
+ discarding = true;
308
+ discardedChars = buffer.length;
309
+ buffer = "";
310
+ }
311
+ }
312
+ if (newline === -1) break;
313
+ if (discarding) {
314
+ lineNumber += 1;
315
+ out.push(...tooLarge(lineNumber, discardedChars));
316
+ discarding = false;
317
+ discardedChars = 0;
318
+ } else {
319
+ out.push(...consumeLine(buffer));
320
+ buffer = "";
321
+ }
322
+ start = newline + 1;
323
+ }
324
+ return out;
325
+ },
326
+
327
+ /**
328
+ * Structured permission transports deliver normalized AgentEvents beside
329
+ * stdout rather than encoding tool calls into it. Keep those facts in the
330
+ * same stream with their own deterministic process-local coordinate.
331
+ * @param {Record<string, unknown>} event
332
+ * @returns {LocalHarnessEventInput[]}
333
+ */
334
+ pushEvent(event) {
335
+ if (finished) return [];
336
+ structuredEventNumber += 1;
337
+ return materialize(
338
+ [event],
339
+ `event:${structuredEventNumber}`,
340
+ lineNumber || 1,
341
+ "event",
342
+ );
343
+ },
344
+
345
+ /** @returns {LocalHarnessEventInput[]} */
346
+ flush() {
347
+ if (finished) return [];
348
+ finished = true;
349
+ const out = [];
350
+ if (discarding) {
351
+ lineNumber += 1;
352
+ out.push(...tooLarge(lineNumber, discardedChars));
353
+ } else if (buffer.length) {
354
+ out.push(...consumeLine(buffer));
355
+ }
356
+ buffer = "";
357
+ discarding = false;
358
+ discardedChars = 0;
359
+ out.push(
360
+ ...materialize(
361
+ parser.flush(),
362
+ `flush:${lineNumber}`,
363
+ lineNumber || 1,
364
+ "flush",
365
+ ),
366
+ );
367
+ return out;
368
+ },
369
+ };
370
+ }
@@ -0,0 +1,157 @@
1
+ // Best-effort local HEP batching. This module owns no MCP client; the daemon
2
+ // injects its existing `tool` function so tests stay offline and older servers
3
+ // simply leave the emitter disabled through tools/list capability detection.
4
+
5
+ import { createLocalHarnessStreamAdapter } from "./local-harness-events.mjs";
6
+
7
+ export const LOCAL_HARNESS_APPEND_BATCH_MAX = 64;
8
+ export const LOCAL_HARNESS_OUTSTANDING_MAX = 512;
9
+ export const LOCAL_HARNESS_ATTEMPT_TIMEOUT_MS = 1_000;
10
+
11
+ /**
12
+ * @param {{
13
+ * tool: (name: string, args: Record<string, unknown>) => Promise<any>|any,
14
+ * runId: string,
15
+ * runtime: string,
16
+ * streamId: string,
17
+ * actorId: string,
18
+ * lane?: string,
19
+ * attemptTimeoutMs?: number,
20
+ * setTimer?: (callback: () => void, ms: number) => any,
21
+ * clearTimer?: (timer: any) => void,
22
+ * }} options
23
+ */
24
+ export function createLocalHarnessEmitter(options) {
25
+ const adapter = createLocalHarnessStreamAdapter({
26
+ runtime: options.runtime,
27
+ streamId: options.streamId,
28
+ actorId: options.actorId,
29
+ lane: options.lane ?? "local",
30
+ });
31
+ let pending = [];
32
+ let inflight = Promise.resolve();
33
+ let scheduled = 0;
34
+ let finished = false;
35
+ const attemptTimeoutMs = Math.max(
36
+ 1,
37
+ Number(options.attemptTimeoutMs) || LOCAL_HARNESS_ATTEMPT_TIMEOUT_MS,
38
+ );
39
+ const setTimer = options.setTimer ?? setTimeout;
40
+ const clearTimer = options.clearTimer ?? clearTimeout;
41
+ const stats = {
42
+ accepted: 0,
43
+ appended: 0,
44
+ replayed: 0,
45
+ rejected: 0,
46
+ dropped: 0,
47
+ retries: 0,
48
+ };
49
+
50
+ const attempt = async (batch) => {
51
+ let timer;
52
+ try {
53
+ const request = Promise.resolve().then(() =>
54
+ options.tool("append_run_events", {
55
+ runId: options.runId,
56
+ events: batch,
57
+ }),
58
+ );
59
+ const deadline = new Promise((_, reject) => {
60
+ timer = setTimer(
61
+ () => reject(new Error("append_run_events timed out")),
62
+ attemptTimeoutMs,
63
+ );
64
+ timer?.unref?.();
65
+ });
66
+ return await Promise.race([request, deadline]);
67
+ } finally {
68
+ if (timer !== undefined) clearTimer(timer);
69
+ }
70
+ };
71
+
72
+ const send = (batch) => {
73
+ scheduled += batch.length;
74
+ inflight = inflight
75
+ .then(async () => {
76
+ let response;
77
+ try {
78
+ response = await attempt(batch);
79
+ } catch {
80
+ // One immediate lost-response retry. Stable HEP keys make a first
81
+ // successful append replay instead of duplicating when its response
82
+ // was the part that disappeared.
83
+ stats.retries += 1;
84
+ try {
85
+ response = await attempt(batch);
86
+ } catch {
87
+ stats.rejected += batch.length;
88
+ return;
89
+ }
90
+ }
91
+ if (!response || response.ok === false) {
92
+ stats.rejected += batch.length;
93
+ return;
94
+ }
95
+ stats.appended += Number(response.appended) || 0;
96
+ stats.replayed += Number(response.replayed) || 0;
97
+ stats.rejected += Number(response.rejected) || 0;
98
+ })
99
+ .catch(() => {
100
+ // No observer failure is allowed to reach the coding process.
101
+ stats.rejected += batch.length;
102
+ })
103
+ .finally(() => {
104
+ scheduled -= batch.length;
105
+ });
106
+ };
107
+
108
+ const accept = (events) => {
109
+ if (!events?.length) return;
110
+ const available = Math.max(
111
+ 0,
112
+ LOCAL_HARNESS_OUTSTANDING_MAX - scheduled - pending.length,
113
+ );
114
+ const kept = events.slice(0, available);
115
+ pending.push(...kept);
116
+ stats.accepted += kept.length;
117
+ stats.dropped += events.length - kept.length;
118
+ while (pending.length >= LOCAL_HARNESS_APPEND_BATCH_MAX) {
119
+ send(pending.splice(0, LOCAL_HARNESS_APPEND_BATCH_MAX));
120
+ }
121
+ };
122
+
123
+ return {
124
+ push(chunk) {
125
+ if (finished) return;
126
+ try {
127
+ accept(adapter.push(chunk));
128
+ } catch {
129
+ /* parser failure degrades telemetry only */
130
+ }
131
+ },
132
+ pushEvent(event) {
133
+ if (finished) return;
134
+ try {
135
+ accept(adapter.pushEvent(event));
136
+ } catch {
137
+ /* parser failure degrades telemetry only */
138
+ }
139
+ },
140
+ async drain() {
141
+ if (!finished) {
142
+ finished = true;
143
+ try {
144
+ accept(adapter.flush());
145
+ } catch {
146
+ /* parser failure degrades telemetry only */
147
+ }
148
+ if (pending.length) send(pending.splice(0));
149
+ }
150
+ await inflight.catch(() => {});
151
+ return { ...stats };
152
+ },
153
+ stats() {
154
+ return { ...stats };
155
+ },
156
+ };
157
+ }