hilos-agent 0.9.1 → 0.9.3
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 +61 -31
- package/bin/hilos-agent.mjs +9 -5
- package/package.json +1 -1
- package/src/argv.mjs +61 -0
- package/src/config.mjs +4 -3
- package/src/handler.mjs +102 -4
- package/src/hook.mjs +429 -61
- package/src/mcp-loopback.mjs +142 -0
- package/src/mcp.mjs +2 -2
- package/src/model-resolve.mjs +34 -18
- package/src/progress-emitter.mjs +6 -3
- package/src/reply-bridge.mjs +412 -49
- package/src/run.mjs +67 -21
- package/src/wake.mjs +288 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Turn-scoped loopback transport for a resumed Codex session (0854).
|
|
2
|
+
//
|
|
3
|
+
// Codex's remote MCP auth is normally an environment variable. The daemon
|
|
4
|
+
// deliberately strips HILOS_* secrets from coding children, and handing a
|
|
5
|
+
// persistent workspace token to a model-visible shell would undo that safety.
|
|
6
|
+
// Instead, keep the token in the daemon process and expose a random loopback
|
|
7
|
+
// URL only for the lifetime of one continuation. Codex can use every tool its
|
|
8
|
+
// agent identity is authorized for; it never receives the bearer credential.
|
|
9
|
+
|
|
10
|
+
import { createServer } from "node:http";
|
|
11
|
+
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { DAEMON_CLIENT } from "./mcp.mjs";
|
|
13
|
+
|
|
14
|
+
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
15
|
+
const FORWARDED_RESPONSE_HEADERS = [
|
|
16
|
+
"content-type",
|
|
17
|
+
"mcp-session-id",
|
|
18
|
+
"mcp-protocol-version",
|
|
19
|
+
"www-authenticate",
|
|
20
|
+
];
|
|
21
|
+
const FORWARDED_REQUEST_HEADERS = [
|
|
22
|
+
"content-type",
|
|
23
|
+
"accept",
|
|
24
|
+
"mcp-session-id",
|
|
25
|
+
"mcp-protocol-version",
|
|
26
|
+
"mcp-method",
|
|
27
|
+
"mcp-name",
|
|
28
|
+
];
|
|
29
|
+
const MAX_HEADER_LENGTH = 4096;
|
|
30
|
+
|
|
31
|
+
function readBody(request) {
|
|
32
|
+
return new Promise((resolve, reject) => {
|
|
33
|
+
let size = 0;
|
|
34
|
+
const chunks = [];
|
|
35
|
+
request.on("data", (chunk) => {
|
|
36
|
+
size += chunk.length;
|
|
37
|
+
if (size > MAX_BODY_BYTES) {
|
|
38
|
+
reject(new Error("request body too large"));
|
|
39
|
+
request.destroy();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
chunks.push(chunk);
|
|
43
|
+
});
|
|
44
|
+
request.on("end", () => resolve(Buffer.concat(chunks)));
|
|
45
|
+
request.on("error", reject);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Start an unguessable, loopback-only HTTP proxy to one authenticated hilos MCP
|
|
51
|
+
* endpoint. The caller must close it in finally after the coding turn.
|
|
52
|
+
* @param {{
|
|
53
|
+
* url?: string,
|
|
54
|
+
* token?: string,
|
|
55
|
+
* channelId?: string,
|
|
56
|
+
* fetchImpl?: typeof fetch,
|
|
57
|
+
* }} [options]
|
|
58
|
+
* @returns {Promise<{url: string, close: () => Promise<void>} | null>}
|
|
59
|
+
*/
|
|
60
|
+
export async function startHilosMcpLoopback({ url, token, channelId = "", fetchImpl = fetch } = {}) {
|
|
61
|
+
if (!url || !token) return null;
|
|
62
|
+
const nonce = randomBytes(24).toString("hex");
|
|
63
|
+
const path = `/mcp/${nonce}`;
|
|
64
|
+
const upstream = new URL(url);
|
|
65
|
+
// Preserve the room that caused this turn as MCP's effective context. This
|
|
66
|
+
// does not widen or narrow token access, but it keeps guest-room memory gates
|
|
67
|
+
// and other context-sensitive server policy active even when the model calls
|
|
68
|
+
// a workspace-scoped tool without a channelId argument.
|
|
69
|
+
if (channelId) upstream.searchParams.set("channelId", channelId);
|
|
70
|
+
const upstreamRequests = new Set();
|
|
71
|
+
|
|
72
|
+
const server = createServer(async (request, response) => {
|
|
73
|
+
if (request.url !== path || !["POST", "GET", "DELETE"].includes(request.method || "")) {
|
|
74
|
+
response.writeHead(404).end();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const controller = new AbortController();
|
|
78
|
+
upstreamRequests.add(controller);
|
|
79
|
+
const abortUpstream = () => controller.abort();
|
|
80
|
+
request.once("aborted", abortUpstream);
|
|
81
|
+
response.once("close", abortUpstream);
|
|
82
|
+
try {
|
|
83
|
+
const body = request.method === "POST" ? await readBody(request) : undefined;
|
|
84
|
+
const headers = {
|
|
85
|
+
authorization: `Bearer ${token}`,
|
|
86
|
+
// This short-lived proxy is part of the persistent daemon's return
|
|
87
|
+
// path. Marking it on-demand would temporarily relabel a live daemon
|
|
88
|
+
// and could suppress the offline/wakeup behavior after the turn.
|
|
89
|
+
"x-hilos-connection-mode": "daemon",
|
|
90
|
+
...(DAEMON_CLIENT ? { "x-hilos-client": DAEMON_CLIENT } : {}),
|
|
91
|
+
};
|
|
92
|
+
for (const name of FORWARDED_REQUEST_HEADERS) {
|
|
93
|
+
const value = request.headers[name];
|
|
94
|
+
if (typeof value === "string" && value.length <= MAX_HEADER_LENGTH) {
|
|
95
|
+
headers[name] = value;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const forwarded = await fetchImpl(upstream, {
|
|
99
|
+
method: request.method,
|
|
100
|
+
headers,
|
|
101
|
+
...(body ? { body } : {}),
|
|
102
|
+
signal: controller.signal,
|
|
103
|
+
});
|
|
104
|
+
for (const name of FORWARDED_RESPONSE_HEADERS) {
|
|
105
|
+
const value = forwarded.headers.get(name);
|
|
106
|
+
if (value) response.setHeader(name, value);
|
|
107
|
+
}
|
|
108
|
+
response.writeHead(forwarded.status);
|
|
109
|
+
response.end(Buffer.from(await forwarded.arrayBuffer()));
|
|
110
|
+
} catch {
|
|
111
|
+
if (!response.destroyed && !response.writableEnded) {
|
|
112
|
+
response.writeHead(502, { "content-type": "application/json" });
|
|
113
|
+
response.end(JSON.stringify({ error: "hilos MCP loopback unavailable" }));
|
|
114
|
+
}
|
|
115
|
+
} finally {
|
|
116
|
+
upstreamRequests.delete(controller);
|
|
117
|
+
request.removeListener("aborted", abortUpstream);
|
|
118
|
+
response.removeListener("close", abortUpstream);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
await new Promise((resolve, reject) => {
|
|
123
|
+
server.once("error", reject);
|
|
124
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
125
|
+
});
|
|
126
|
+
const address = server.address();
|
|
127
|
+
if (!address || typeof address === "string") {
|
|
128
|
+
server.close();
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
let closed = false;
|
|
132
|
+
return {
|
|
133
|
+
url: `http://127.0.0.1:${address.port}${path}`,
|
|
134
|
+
async close() {
|
|
135
|
+
if (closed) return;
|
|
136
|
+
closed = true;
|
|
137
|
+
for (const controller of upstreamRequests) controller.abort();
|
|
138
|
+
server.closeAllConnections?.();
|
|
139
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
package/src/mcp.mjs
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
|
|
5
5
|
// Best-effort host label so hilos can show "whose machine" this daemon runs on.
|
|
6
|
-
const
|
|
6
|
+
export const DAEMON_CLIENT = (() => {
|
|
7
7
|
try {
|
|
8
8
|
return `${os.userInfo().username}@${os.hostname()}`;
|
|
9
9
|
} catch {
|
|
@@ -21,7 +21,7 @@ export function makeClient({ url, token }) {
|
|
|
21
21
|
"content-type": "application/json",
|
|
22
22
|
authorization: `Bearer ${token}`,
|
|
23
23
|
"x-hilos-connection-mode": "daemon",
|
|
24
|
-
...(
|
|
24
|
+
...(DAEMON_CLIENT ? { "x-hilos-client": DAEMON_CLIENT } : {}),
|
|
25
25
|
},
|
|
26
26
|
body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params }),
|
|
27
27
|
});
|
package/src/model-resolve.mjs
CHANGED
|
@@ -1,23 +1,20 @@
|
|
|
1
|
-
// Runtime model-preset resolution (0504).
|
|
2
|
-
//
|
|
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 joined in 0783 on the SAME terms: `codex debug models` ("Render the raw
|
|
1
|
+
// Runtime model-preset resolution (0504). Codex gets provider-neutral tiers in
|
|
2
|
+
// new commands: `codex debug models` ("Render the raw
|
|
15
3
|
// model catalog as JSON") is its `--list-models`, so its tier also resolves at
|
|
16
4
|
// run time against the account's own catalog and never against a baked slug.
|
|
17
5
|
// That matters more for codex than for anyone: its slugs are codenames that rev
|
|
18
6
|
// (`gpt-5.6-sol`, `gpt-5.6-terra`, `gpt-5.6-luna` in the live catalog) and are
|
|
19
7
|
// plan-gated — a run on a ChatGPT account rejects `gpt-5` outright with "The
|
|
20
8
|
// 'gpt-5' model is not supported when using Codex with a ChatGPT account".
|
|
9
|
+
//
|
|
10
|
+
// Cursor remains below only as a compatibility reader for existing 0.9.2
|
|
11
|
+
// configs that stored the old Anthropic-shaped tier names. New Hilos commands
|
|
12
|
+
// do not set a Cursor tier: Cursor's catalog mixes providers and account-only
|
|
13
|
+
// ids, so its own `auto` model choice is the only honest default.
|
|
14
|
+
//
|
|
15
|
+
// Design rules (mirror the .mjs siblings): the parse/resolve transforms are
|
|
16
|
+
// PURE + node-builtins-only; the resolver takes an injected `run` (runCli) so
|
|
17
|
+
// tests drive it with no CLI; a resolution failure NEVER breaks a run ([]).
|
|
21
18
|
|
|
22
19
|
/** Strip ANSI SGR color codes (`--list-models` output is colorized). */
|
|
23
20
|
export function stripAnsi(s) {
|
|
@@ -55,6 +52,24 @@ const TIER_PREFS = {
|
|
|
55
52
|
haiku: [/^composer.*fast/, /-fast$/],
|
|
56
53
|
};
|
|
57
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Public tier names are provider-neutral. Keep the original Anthropic-shaped
|
|
57
|
+
* values as read aliases so existing hilos-agent.json files and already-copied
|
|
58
|
+
* commands continue to work after 0.9.3.
|
|
59
|
+
*/
|
|
60
|
+
export function normalizeModelTier(tier) {
|
|
61
|
+
const value = String(tier || "").trim().toLowerCase();
|
|
62
|
+
return ({
|
|
63
|
+
"most-capable": "opus",
|
|
64
|
+
balanced: "sonnet",
|
|
65
|
+
fastest: "haiku",
|
|
66
|
+
opus: "opus",
|
|
67
|
+
sonnet: "sonnet",
|
|
68
|
+
haiku: "haiku",
|
|
69
|
+
default: "default",
|
|
70
|
+
})[value] || "";
|
|
71
|
+
}
|
|
72
|
+
|
|
58
73
|
/**
|
|
59
74
|
* Pick the account's model id for a tier, or null when nothing fits.
|
|
60
75
|
* @param {'opus'|'sonnet'|'haiku'|string} tier
|
|
@@ -62,7 +77,7 @@ const TIER_PREFS = {
|
|
|
62
77
|
* @returns {string|null}
|
|
63
78
|
*/
|
|
64
79
|
export function resolveCursorModel(tier, ids) {
|
|
65
|
-
const prefs = TIER_PREFS[tier];
|
|
80
|
+
const prefs = TIER_PREFS[normalizeModelTier(tier)];
|
|
66
81
|
if (!prefs || !Array.isArray(ids)) return null;
|
|
67
82
|
for (const re of prefs) {
|
|
68
83
|
const hit = ids.find((id) => typeof id === "string" && re.test(id));
|
|
@@ -181,13 +196,14 @@ const CODEX_TIER_PREFS = {
|
|
|
181
196
|
* @returns {string|null}
|
|
182
197
|
*/
|
|
183
198
|
export function resolveCodexModel(tier, models) {
|
|
184
|
-
const
|
|
199
|
+
const normalized = normalizeModelTier(tier);
|
|
200
|
+
const prefs = CODEX_TIER_PREFS[normalized];
|
|
185
201
|
if (!prefs || !Array.isArray(models) || models.length === 0) return null;
|
|
186
202
|
for (const re of prefs) {
|
|
187
203
|
const hit = models.find((m) => m && typeof m.text === "string" && re.test(m.text));
|
|
188
204
|
if (hit) return hit.slug;
|
|
189
205
|
}
|
|
190
|
-
if (
|
|
206
|
+
if (normalized === "haiku") return models[models.length - 1].slug;
|
|
191
207
|
return models[0].slug;
|
|
192
208
|
}
|
|
193
209
|
|
|
@@ -231,7 +247,7 @@ export function createModelArgsResolver({ run } = {}) {
|
|
|
231
247
|
return async function modelArgsFor(cfg, vendor) {
|
|
232
248
|
try {
|
|
233
249
|
const spec = LIST_MODELS[vendor];
|
|
234
|
-
const tier =
|
|
250
|
+
const tier = normalizeModelTier(cfg?.codingModel);
|
|
235
251
|
if (!spec || !tier || tier === "default") return [];
|
|
236
252
|
const cmd = String(cfg?.codingCmd || "");
|
|
237
253
|
// Hand-pinned wins — including codex's short spelling (`-m gpt-5.6-sol`),
|
package/src/progress-emitter.mjs
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
createUsageFold,
|
|
23
23
|
sanitizeText,
|
|
24
24
|
} from "./agent-events.mjs";
|
|
25
|
+
import { commandArgv } from "./argv.mjs";
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Which parser/stream-flags a coding command wants, from its FIRST token.
|
|
@@ -34,8 +35,10 @@ import {
|
|
|
34
35
|
* @returns {'claude_code'|'codex'|'cursor'|'opencode'|'antigravity'|'hermes'|'unknown'}
|
|
35
36
|
*/
|
|
36
37
|
export function detectVendor(codingCmd) {
|
|
37
|
-
const first =
|
|
38
|
-
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, "");
|
|
39
42
|
if (base === "claude" || base === "claude-code" || base === "claude_code") return "claude_code";
|
|
40
43
|
if (base === "codex") return "codex";
|
|
41
44
|
// `agent` is Cursor's canonical binary name since Jan 2026 (the installer
|
|
@@ -66,7 +69,7 @@ export function detectVendor(codingCmd) {
|
|
|
66
69
|
* @returns {string}
|
|
67
70
|
*/
|
|
68
71
|
export function fastChatCmd(vendor) {
|
|
69
|
-
if (vendor === "claude_code") return "claude -p --model
|
|
72
|
+
if (vendor === "claude_code") return "claude -p --model haiku";
|
|
70
73
|
if (vendor === "codex") return "codex exec --skip-git-repo-check";
|
|
71
74
|
if (vendor === "cursor") return "cursor-agent -p --output-format text --trust";
|
|
72
75
|
if (vendor === "opencode") return "opencode run";
|