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.
package/src/mcp.mjs CHANGED
@@ -1,7 +1,27 @@
1
- // Minimal MCP-over-HTTP client (JSON-RPC 2.0). Dependency-free: uses global
2
- // fetch (Node >= 18). One bearer token, one endpoint.
1
+ // Small MCP-over-HTTP client for the local daemon. It speaks hilos's current
2
+ // stateless protocol when the server advertises it and falls back to the bare
3
+ // legacy request shape for older servers. No runtime dependency is required.
4
+ import { readFileSync } from "node:fs";
3
5
  import os from "node:os";
4
6
 
7
+ export const MCP_PROTOCOL_VERSION = "2026-07-28";
8
+ const META_PROTOCOL_VERSION = "io.modelcontextprotocol/protocolVersion";
9
+ const META_CLIENT_CAPABILITIES = "io.modelcontextprotocol/clientCapabilities";
10
+ const META_CLIENT_INFO = "io.modelcontextprotocol/clientInfo";
11
+ const DEFAULT_TIMEOUT_MS = 35_000;
12
+ const DISCOVERY_TIMEOUT_MS = 10_000;
13
+ const RETRY_DELAY_MS = 250;
14
+
15
+ function installedVersion() {
16
+ try {
17
+ return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
18
+ } catch {
19
+ return "unknown";
20
+ }
21
+ }
22
+
23
+ const CLIENT_INFO = { name: "hilos-agent", version: installedVersion() };
24
+
5
25
  // Best-effort host label so hilos can show "whose machine" this daemon runs on.
6
26
  export const DAEMON_CLIENT = (() => {
7
27
  try {
@@ -11,36 +31,379 @@ export const DAEMON_CLIENT = (() => {
11
31
  }
12
32
  })();
13
33
 
14
- export function makeClient({ url, token }) {
15
- let id = 0;
34
+ class McpRequestError extends Error {
35
+ constructor(message, details = {}) {
36
+ super(message);
37
+ this.name = "McpRequestError";
38
+ Object.assign(this, details);
39
+ }
40
+ }
16
41
 
17
- async function rpc(method, params) {
18
- const res = await fetch(url, {
19
- method: "POST",
20
- headers: {
21
- "content-type": "application/json",
22
- authorization: `Bearer ${token}`,
23
- "x-hilos-connection-mode": "daemon",
24
- ...(DAEMON_CLIENT ? { "x-hilos-client": DAEMON_CLIENT } : {}),
25
- },
26
- body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params }),
42
+ // An authenticated daemon can be told that its credential is permanently no
43
+ // longer usable (revoked/deleted agent, rotated token, etc.). Keep that signal
44
+ // distinct from an ordinary auth lookup failure so the poll loop can stop
45
+ // instead of retrying forever, without broadening every 401/403 into shutdown.
46
+ export class McpAuthStopError extends McpRequestError {
47
+ constructor(message, details = {}) {
48
+ super(message, details);
49
+ this.name = "McpAuthStopError";
50
+ this.stop = true;
51
+ }
52
+ }
53
+
54
+ function headerValue(value) {
55
+ const text = String(value);
56
+ return /^[\x20-\x7e]+$/.test(text)
57
+ ? text
58
+ : `=?base64?${Buffer.from(text, "utf8").toString("base64")}?=`;
59
+ }
60
+
61
+ function namedParam(method) {
62
+ if (method === "tools/call" || method === "prompts/get") return "name";
63
+ if (method === "resources/read") return "uri";
64
+ if (["tasks/get", "tasks/update", "tasks/cancel"].includes(method)) return "taskId";
65
+ return null;
66
+ }
67
+
68
+ function requestSignal(parentSignal, timeoutMs, stopSignal) {
69
+ const controller = new AbortController();
70
+ const abortFromParent = () => controller.abort(parentSignal?.reason);
71
+ const abortFromStop = () => controller.abort(stopSignal?.reason);
72
+ if (stopSignal?.aborted) abortFromStop();
73
+ else if (parentSignal?.aborted) abortFromParent();
74
+ else {
75
+ parentSignal?.addEventListener("abort", abortFromParent, { once: true });
76
+ stopSignal?.addEventListener("abort", abortFromStop, { once: true });
77
+ }
78
+ const timer = setTimeout(() => controller.abort(new Error("request timed out")), timeoutMs);
79
+ timer.unref?.();
80
+ return {
81
+ signal: controller.signal,
82
+ cleanup() {
83
+ clearTimeout(timer);
84
+ parentSignal?.removeEventListener("abort", abortFromParent);
85
+ stopSignal?.removeEventListener("abort", abortFromStop);
86
+ },
87
+ };
88
+ }
89
+
90
+ function combinedSignal(...signals) {
91
+ const active = signals.filter(Boolean);
92
+ if (active.length === 0) return { signal: undefined, cleanup() {} };
93
+ const controller = new AbortController();
94
+ const listeners = new Map();
95
+ for (const source of active) {
96
+ const abort = () => controller.abort(source.reason);
97
+ listeners.set(source, abort);
98
+ if (source.aborted) {
99
+ abort();
100
+ break;
101
+ }
102
+ source.addEventListener("abort", abort, { once: true });
103
+ }
104
+ return {
105
+ signal: controller.signal,
106
+ cleanup() {
107
+ for (const [source, abort] of listeners) source.removeEventListener("abort", abort);
108
+ },
109
+ };
110
+ }
111
+
112
+ function retryable(error) {
113
+ if (error instanceof McpAuthStopError || error?.abortedByCaller || error?.retryExhausted) {
114
+ return false;
115
+ }
116
+ if (typeof error?.status === "number") {
117
+ return error.status === 408 || error.status === 429 || error.status >= 500;
118
+ }
119
+ return error?.code !== -32600 && error?.code !== -32601 && error?.code !== -32602;
120
+ }
121
+
122
+ function callerCanceled(cause) {
123
+ return new McpRequestError("hilos request canceled", {
124
+ cause,
125
+ abortedByCaller: true,
126
+ });
127
+ }
128
+
129
+ const defaultDelay = (ms, signal) => new Promise((resolve, reject) => {
130
+ if (signal?.aborted) {
131
+ reject(callerCanceled(signal.reason));
132
+ return;
133
+ }
134
+ const onAbort = () => {
135
+ clearTimeout(timer);
136
+ reject(callerCanceled(signal.reason));
137
+ };
138
+ const timer = setTimeout(() => {
139
+ signal?.removeEventListener("abort", onAbort);
140
+ resolve();
141
+ }, ms);
142
+ timer.unref?.();
143
+ signal?.addEventListener("abort", onAbort, { once: true });
144
+ });
145
+
146
+ function retryAfterMs(response) {
147
+ const raw = response?.headers?.get?.("retry-after");
148
+ if (!raw) return 0;
149
+ const seconds = Number(raw);
150
+ if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds * 1000);
151
+ const at = Date.parse(raw);
152
+ return Number.isFinite(at) ? Math.max(0, at - Date.now()) : 0;
153
+ }
154
+
155
+ async function abortableDelay(delay, ms, signal) {
156
+ if (delay === defaultDelay) return defaultDelay(ms, signal);
157
+ if (!signal) return delay(ms);
158
+ if (signal.aborted) throw callerCanceled(signal.reason);
159
+ let onAbort;
160
+ const canceled = new Promise((_resolve, reject) => {
161
+ onAbort = () => reject(callerCanceled(signal.reason));
162
+ signal.addEventListener("abort", onAbort, { once: true });
163
+ });
164
+ try {
165
+ await Promise.race([Promise.resolve().then(() => delay(ms)), canceled]);
166
+ } finally {
167
+ signal.removeEventListener("abort", onAbort);
168
+ }
169
+ }
170
+
171
+ function assertJsonRpcResponse(json, expectedId) {
172
+ const record = json && typeof json === "object" && !Array.isArray(json) ? json : null;
173
+ const hasResult = record && Object.prototype.hasOwnProperty.call(record, "result");
174
+ const hasError = record && Object.prototype.hasOwnProperty.call(record, "error");
175
+ const error = record?.error;
176
+ if (
177
+ !record ||
178
+ record.jsonrpc !== "2.0" ||
179
+ record.id !== expectedId ||
180
+ Boolean(hasResult) === Boolean(hasError) ||
181
+ (hasError && (
182
+ !error ||
183
+ typeof error !== "object" ||
184
+ Array.isArray(error) ||
185
+ !Number.isInteger(error.code) ||
186
+ typeof error.message !== "string"
187
+ ))
188
+ ) {
189
+ throw new McpRequestError("hilos returned an invalid JSON-RPC response", {
190
+ malformedResponse: true,
27
191
  });
192
+ }
193
+ return record;
194
+ }
195
+
196
+ export function makeClient({
197
+ url,
198
+ token,
199
+ timeoutMs = DEFAULT_TIMEOUT_MS,
200
+ retryDelayMs = RETRY_DELAY_MS,
201
+ delay = defaultDelay,
202
+ fetchImpl = globalThis.fetch,
203
+ }) {
204
+ let id = 0;
205
+ let negotiated = null;
206
+ let terminalError = null;
207
+ const stopController = new AbortController();
208
+
209
+ const latchStop = (error) => {
210
+ if (!terminalError) {
211
+ terminalError = error;
212
+ stopController.abort(error);
213
+ }
214
+ return terminalError;
215
+ };
216
+
217
+ async function request(method, params, options = {}) {
218
+ if (terminalError) throw terminalError;
219
+ const modern = options.protocolVersion === MCP_PROTOCOL_VERSION;
220
+ const requestParams = modern
221
+ ? {
222
+ ...(params ?? {}),
223
+ _meta: {
224
+ [META_PROTOCOL_VERSION]: MCP_PROTOCOL_VERSION,
225
+ [META_CLIENT_CAPABILITIES]: {},
226
+ [META_CLIENT_INFO]: CLIENT_INFO,
227
+ },
228
+ }
229
+ : params;
230
+ const headers = {
231
+ "content-type": "application/json",
232
+ accept: "application/json, text/event-stream",
233
+ authorization: `Bearer ${token}`,
234
+ "x-hilos-connection-mode": "daemon",
235
+ ...(DAEMON_CLIENT ? { "x-hilos-client": DAEMON_CLIENT } : {}),
236
+ ...(modern
237
+ ? {
238
+ "mcp-protocol-version": MCP_PROTOCOL_VERSION,
239
+ "mcp-method": method,
240
+ }
241
+ : {}),
242
+ };
243
+ const nameField = modern ? namedParam(method) : null;
244
+ if (nameField && typeof requestParams?.[nameField] === "string") {
245
+ headers["mcp-name"] = headerValue(requestParams[nameField]);
246
+ }
247
+
248
+ const callerSignal = options.signal;
249
+ if (callerSignal?.aborted) throw callerCanceled(callerSignal.reason);
250
+ const bounded = requestSignal(
251
+ callerSignal,
252
+ options.timeoutMs ?? timeoutMs,
253
+ stopController.signal,
254
+ );
255
+ const requestId = ++id;
256
+ let res;
257
+ let json = null;
258
+ let transportError = null;
259
+ try {
260
+ try {
261
+ res = await fetchImpl(url, {
262
+ method: "POST",
263
+ headers,
264
+ body: JSON.stringify({ jsonrpc: "2.0", id: requestId, method, params: requestParams }),
265
+ signal: bounded.signal,
266
+ });
267
+ } catch (error) {
268
+ transportError = error;
269
+ }
270
+ if (res) {
271
+ try {
272
+ // Keep the request bound alive until the entire response body has
273
+ // arrived. Fetch can resolve as soon as headers land while the body
274
+ // remains stalled indefinitely.
275
+ json = await res.json();
276
+ } catch (error) {
277
+ transportError = error;
278
+ }
279
+ }
280
+ } finally {
281
+ bounded.cleanup();
282
+ }
283
+
284
+ if (terminalError) throw terminalError;
285
+ if (callerSignal?.aborted) throw callerCanceled(transportError || callerSignal.reason);
286
+ if (bounded.signal.aborted) {
287
+ throw new McpRequestError("hilos request failed: request timed out", {
288
+ cause: transportError || bounded.signal.reason,
289
+ });
290
+ }
291
+ if (!res) {
292
+ throw new McpRequestError(
293
+ `hilos request failed: ${transportError?.message || transportError || "no response"}`,
294
+ { cause: transportError },
295
+ );
296
+ }
28
297
  if (!res.ok) {
29
- const hint =
298
+ const authHint =
30
299
  res.status === 401 || res.status === 403
31
- ? "token rejected — generate a fresh one in hilos (Connect)"
32
- : await res.text().catch(() => "");
33
- throw new Error(`hilos ${res.status}: ${hint.slice(0, 200)}`);
300
+ ? "token rejected; generate a fresh one in hilos (Connect)"
301
+ : typeof json?.error?.message === "string"
302
+ ? json.error.message
303
+ : `request failed with HTTP ${res.status}`;
304
+ const ErrorType =
305
+ (res.status === 401 || res.status === 403) && json?.stop === true
306
+ ? McpAuthStopError
307
+ : McpRequestError;
308
+ const error = new ErrorType(`hilos ${res.status}: ${authHint.slice(0, 200)}`, {
309
+ status: res.status,
310
+ stop: json?.stop === true,
311
+ code: json?.error?.code,
312
+ retryAfterMs: retryAfterMs(res),
313
+ });
314
+ throw error instanceof McpAuthStopError ? latchStop(error) : error;
34
315
  }
35
- const json = await res.json();
36
- if (json.error) throw new Error(json.error.message || "rpc error");
37
- return json.result;
316
+ if (transportError) {
317
+ throw new McpRequestError("hilos returned an invalid JSON-RPC response", {
318
+ cause: transportError,
319
+ malformedResponse: true,
320
+ });
321
+ }
322
+ const envelope = assertJsonRpcResponse(json, requestId);
323
+ if (Object.prototype.hasOwnProperty.call(envelope, "error")) {
324
+ throw new McpRequestError(envelope.error.message, {
325
+ code: envelope.error.code,
326
+ data: envelope.error.data,
327
+ });
328
+ }
329
+ return envelope.result;
330
+ }
331
+
332
+ async function waitBeforeRetry(error, options) {
333
+ const waitMs = Math.max(retryDelayMs, Number(error?.retryAfterMs) || 0);
334
+ const linked = combinedSignal(options?.signal, stopController.signal);
335
+ try {
336
+ await abortableDelay(delay, waitMs, linked.signal);
337
+ } catch (delayError) {
338
+ if (terminalError) throw terminalError;
339
+ throw delayError;
340
+ } finally {
341
+ linked.cleanup();
342
+ }
343
+ }
344
+
345
+ async function discover(options = {}) {
346
+ let lastError;
347
+ for (let attempt = 0; attempt < 2; attempt += 1) {
348
+ try {
349
+ const result = await request("server/discover", undefined, {
350
+ signal: options.signal,
351
+ timeoutMs: Math.min(timeoutMs, DISCOVERY_TIMEOUT_MS),
352
+ protocolVersion: MCP_PROTOCOL_VERSION,
353
+ });
354
+ if (!Array.isArray(result?.supportedVersions)) {
355
+ throw new McpRequestError("hilos server/discover returned an invalid result", {
356
+ malformedResponse: true,
357
+ });
358
+ }
359
+ return result.supportedVersions.includes(MCP_PROTOCOL_VERSION) ? MCP_PROTOCOL_VERSION : null;
360
+ } catch (error) {
361
+ if (error?.code === -32601 || error?.status === 404) return null;
362
+ lastError = error;
363
+ if (attempt === 1 || !retryable(error)) {
364
+ // listTools also has one read retry. Mark a discovery error whose own
365
+ // retry is spent so that outer layer cannot accidentally negotiate
366
+ // two more times before surfacing the same failure.
367
+ if (attempt === 1 && error && typeof error === "object") error.retryExhausted = true;
368
+ throw error;
369
+ }
370
+ await waitBeforeRetry(error, options);
371
+ }
372
+ }
373
+ throw lastError;
374
+ }
375
+
376
+ async function protocol(options = {}) {
377
+ if (!negotiated) {
378
+ negotiated = discover(options).catch((error) => {
379
+ negotiated = null;
380
+ throw error;
381
+ });
382
+ }
383
+ return negotiated;
384
+ }
385
+
386
+ async function rpc(method, params, options = {}) {
387
+ if (method === "server/discover") {
388
+ return request(method, params, {
389
+ ...options,
390
+ protocolVersion: MCP_PROTOCOL_VERSION,
391
+ });
392
+ }
393
+ const protocolVersion = await protocol(options);
394
+ return request(method, params, { ...options, protocolVersion });
38
395
  }
39
396
 
40
397
  // Call a tool and parse its first text block (hilos tools return JSON text).
41
- async function tool(name, args = {}) {
42
- const r = await rpc("tools/call", { name, arguments: args });
43
- const text = r?.content?.[0]?.text;
398
+ async function tool(name, args = {}, options = {}) {
399
+ const result = await rpc("tools/call", { name, arguments: args }, options);
400
+ const first = Array.isArray(result?.content) ? result.content[0] : null;
401
+ if (!first || first.type !== "text" || typeof first.text !== "string") {
402
+ throw new McpRequestError("hilos tools/call returned an invalid result", {
403
+ malformedResponse: true,
404
+ });
405
+ }
406
+ const text = first.text;
44
407
  try {
45
408
  return JSON.parse(text);
46
409
  } catch {
@@ -48,20 +411,34 @@ export function makeClient({ url, token }) {
48
411
  }
49
412
  }
50
413
 
51
- // Full tool objects, schemas included — capability sniffing reads these
52
- // (0866: an ARG-level addition like waitMs shows up in a tool's inputSchema,
53
- // not in the tool list's names).
54
- async function listTools() {
414
+ // Full tool objects, schemas included. Never turn discovery errors into an
415
+ // empty registry: that would silently downgrade a current daemon until its
416
+ // next restart. One bounded retry handles a transient read failure.
417
+ async function listTools(options = {}) {
55
418
  try {
56
- return (await rpc("tools/list"))?.tools ?? [];
57
- } catch {
58
- return [];
419
+ const result = await rpc("tools/list", undefined, options);
420
+ if (!Array.isArray(result?.tools) || result.tools.some((tool) => !tool || typeof tool.name !== "string")) {
421
+ throw new McpRequestError("hilos tools/list returned an invalid result", {
422
+ malformedResponse: true,
423
+ });
424
+ }
425
+ return result.tools;
426
+ } catch (error) {
427
+ if (!retryable(error)) throw error;
428
+ await waitBeforeRetry(error, options);
429
+ const result = await rpc("tools/list", undefined, options);
430
+ if (!Array.isArray(result?.tools) || result.tools.some((tool) => !tool || typeof tool.name !== "string")) {
431
+ throw new McpRequestError("hilos tools/list returned an invalid result", {
432
+ malformedResponse: true,
433
+ });
434
+ }
435
+ return result.tools;
59
436
  }
60
437
  }
61
438
 
62
- async function listToolNames() {
63
- return (await listTools()).map((t) => t.name);
439
+ async function listToolNames(options = {}) {
440
+ return (await listTools(options)).map((tool) => tool.name);
64
441
  }
65
442
 
66
- return { rpc, tool, listTools, listToolNames };
443
+ return { rpc, tool, listTools, listToolNames, stopSignal: stopController.signal };
67
444
  }
@@ -16,6 +16,8 @@
16
16
  // PURE + node-builtins-only; the resolver takes an injected `run` (runCli) so
17
17
  // tests drive it with no CLI; a resolution failure NEVER breaks a run ([]).
18
18
 
19
+ import { commandArgv } from "./argv.mjs";
20
+
19
21
  /** Strip ANSI SGR color codes (`--list-models` output is colorized). */
20
22
  export function stripAnsi(s) {
21
23
  // eslint-disable-next-line no-control-regex
@@ -253,7 +255,7 @@ export function createModelArgsResolver({ run } = {}) {
253
255
  // Hand-pinned wins — including codex's short spelling (`-m gpt-5.6-sol`),
254
256
  // which is what its own help leads with.
255
257
  if (/(^|\s)(--model|-m)(\s|=)/.test(cmd + " ")) return [];
256
- const bin = cmd.trim().split(/\s+/)[0] || spec.bin;
258
+ const bin = commandArgv(cmd)[0] || spec.bin;
257
259
  const key = `${bin} ${tier}`;
258
260
  if (cache.has(key)) return cache.get(key);
259
261
  const r = await run({ cmd: bin, args: spec.args, timeoutMs: 30000, heartbeatMs: 0 });
@@ -29,6 +29,13 @@ function abortError(reason = "cancelled") {
29
29
  return error;
30
30
  }
31
31
 
32
+ function authorityLostError(reason) {
33
+ const error = new Error("execution authority lost before OpenCode spawn");
34
+ error.code = "HILOS_AUTHORITY_LOST";
35
+ if (reason !== undefined) error.cause = reason;
36
+ return error;
37
+ }
38
+
32
39
  function basicAuthorization(username, password) {
33
40
  return `Basic ${Buffer.from(`${username}:${password}`, "utf8").toString("base64")}`;
34
41
  }
@@ -220,7 +227,7 @@ function terminateProcessGroup(child, {
220
227
  * @param {any} [options]
221
228
  * @returns {Promise<any>}
222
229
  */
223
- export function startOpenCodeServer({
230
+ export async function startOpenCodeServer({
224
231
  cmd = "opencode",
225
232
  cwd,
226
233
  env,
@@ -228,12 +235,21 @@ export function startOpenCodeServer({
228
235
  password = randomBytes(24).toString("base64url"),
229
236
  username = "opencode",
230
237
  signal,
238
+ beforeSpawn,
231
239
  spawnImpl = spawn,
232
240
  kill = process.kill,
233
241
  setTimer = setTimeout,
234
242
  clearTimer = clearTimeout,
235
243
  startupTimeoutMs = STARTUP_TIMEOUT_MS,
236
244
  } = {}) {
245
+ if (typeof beforeSpawn === "function") {
246
+ try {
247
+ if ((await beforeSpawn()) === false) throw authorityLostError();
248
+ } catch (error) {
249
+ if (error?.code === "HILOS_AUTHORITY_LOST") throw error;
250
+ throw authorityLostError(error);
251
+ }
252
+ }
237
253
  return new Promise((resolve, reject) => {
238
254
  if (signal?.aborted) return reject(abortError(signal.reason));
239
255
  let child;
@@ -376,6 +392,7 @@ export async function runOpenCodeHttpSession({
376
392
  env,
377
393
  timeoutMs = 30 * 60_000,
378
394
  signal,
395
+ beforeSpawn,
379
396
  onData,
380
397
  requestPermission,
381
398
  getPermissionDecision,
@@ -470,6 +487,7 @@ export async function runOpenCodeHttpSession({
470
487
  serverArgs: parsed.serverArgs,
471
488
  password: randomPassword(),
472
489
  signal: controller.signal,
490
+ beforeSpawn,
473
491
  spawnImpl,
474
492
  kill,
475
493
  setTimer,
@@ -730,13 +748,15 @@ export async function runOpenCodeHttpSession({
730
748
  sessionId,
731
749
  };
732
750
  } catch (error) {
733
- const aborted = abortKind === "cancelled";
751
+ const authorityLost = error?.code === "HILOS_AUTHORITY_LOST";
752
+ const aborted = abortKind === "cancelled" || authorityLost;
734
753
  const timedOut = abortKind === "timeout";
735
754
  return {
736
755
  status: null,
737
756
  stdout: stdout.trimEnd(),
738
757
  stderr: stderr.trimEnd(),
739
758
  ...(aborted ? { aborted: true } : {}),
759
+ ...(authorityLost ? { authorityLost: true } : {}),
740
760
  ...(sessionId ? { sessionId } : {}),
741
761
  error:
742
762
  error instanceof Error
@@ -186,7 +186,7 @@ export function imagesNeedReading(vendor, { gated = false } = {}) {
186
186
  * @returns {string|null}
187
187
  */
188
188
  export function attachTarget(codingCmd) {
189
- const tokens = String(codingCmd || "").trim().split(/\s+/).filter(Boolean);
189
+ const tokens = commandArgv(codingCmd);
190
190
  for (let i = 0; i < tokens.length; i++) {
191
191
  if (tokens[i] === "--attach") return tokens[i + 1] && !tokens[i + 1].startsWith("-") ? tokens[i + 1] : "";
192
192
  if (tokens[i].startsWith("--attach=")) return tokens[i].slice("--attach=".length);