hilos-agent 0.11.13 → 0.11.14

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.11.13",
3
+ "version": "0.11.14",
4
4
  "description": "Run your own coding agent (Claude Code, Codex, Cursor, OpenCode, Hermes, or any command) as a teammate in a hilos room. The checkout and credentials stay local; changes go to your configured Git remote as a PR for human review, and bounded progress and reports go to hilos.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,10 +1,12 @@
1
1
  // 1298 — one writing contract and final-result envelope for hosted and local runs.
2
2
  export const AGENT_REPLY_STYLE_RULE = [
3
3
  "How to write the reply:",
4
- "- Lead with the answer or outcome. Never restate the request.",
4
+ "- Lead with the answer or concrete outcome, in words a teammate outside the code can use. Never restate the request.",
5
5
  "- Default to 1-3 short sentences, or at most three short bullets for separate outcomes.",
6
- "- Include useful links once. No preamble, headings, sign-off, process narration, or routine test/file lists.",
7
- "- Keep blockers, material limitations, and a needed next step visible. Never claim checks, deployment, or merging without evidence.",
6
+ "- For instructions, put the action or command first and number sequential steps. For choices, recommend one first, then give the requested alternatives with short trade-offs.",
7
+ "- Include useful links once. Omit facts that do not change the answer or next decision: routine checks, file counts, and process history. No preamble, headings on short replies, sign-off, recap, or tangents.",
8
+ "- Keep blockers, material limitations, and real uncertainty visible. State errors plainly. Never invent causes, time estimates, checks, deployment, or merging.",
9
+ "- When blocked on a person, ask for the one missing decision: 'Which inbox should receive the form?' Otherwise, do not add a question. Do authorized work yourself; do not hand it back or ask permission again. When done, stop; no follow-up offers.",
8
10
  "- Give requested documents, plans, code, research, and explanations the detail they need; keep their introduction short. Put code and commands in fenced blocks.",
9
11
  "The room's voice may shape tone, but only a person's request for detail expands this default. Clarity beats a word limit.",
10
12
  ].join("\n");
@@ -0,0 +1,440 @@
1
+ import { minimalEnv, scrubHilosEnv } from "./cli.mjs";
2
+ import { commandArgv } from "./argv.mjs";
3
+ import { webToolEnv, attachTarget, detectVendor } from "./progress-emitter.mjs";
4
+ import { shouldGateClaudePermissions } from "./claude-permissions.mjs";
5
+ import { shouldGateCodexPermissions, codexMcpTransportUnavailable, codexSandboxFromArgs } from "./codex-mcp-session.mjs";
6
+ import { childMcpArgs, codexChildMcpArgs } from "./child-mcp.mjs";
7
+ import { repoEditAllowance } from "./permission-gate.mjs";
8
+
9
+ /**
10
+ * The environment for a coding/chat CLI run. runCli always strips HILOS_* on top
11
+ * of whatever this returns; here we additionally honor `codingEnv: "minimal"`
12
+ * (strict isolation — only what the tool needs to reach its own model provider).
13
+ * Returns undefined for the default "inherit" mode so runCli falls back to the
14
+ * scrubbed process.env.
15
+ */
16
+ export function codingChildEnv(cfg) {
17
+ return cfg?.codingEnv === "minimal"
18
+ ? minimalEnv(process.env, cfg.codingEnvAllow)
19
+ : undefined;
20
+ }
21
+
22
+ /** Add only the selected CLI's public-web enablement to its already-isolated
23
+ * child environment. Today this changes OpenCode only; keeping it shared makes
24
+ * argv, ACP, HTTP, gated, and conversational runs follow one contract. */
25
+ export function codingChildWebEnv(cfg, vendor) {
26
+ return webToolEnv(vendor, codingChildEnv(cfg), {
27
+ enabled: cfg?.webSearch !== false,
28
+ });
29
+ }
30
+
31
+ /**
32
+ * Bind one OpenCode HTTP session to hilos's vendor-neutral permission tools.
33
+ * Both repo and direct-folder runs use this exact callback contract so neither
34
+ * path can accidentally become the ungated exception.
35
+ */
36
+ function openCodePermissionCallbacks({
37
+ tool,
38
+ channelId,
39
+ threadRoot,
40
+ runId = null,
41
+ provider = "opencode",
42
+ // 0866 — when the server's get_permission_decision supports a long-poll
43
+ // hold, each read blocks up to this many ms and answers the moment a human
44
+ // decides, instead of the gate discovering the decision on its next 1s poll.
45
+ // Tests may use 0 for an immediate decision fixture.
46
+ decisionWaitMs = 0,
47
+ // 1256 — the checkout this run edits; an edit under it is allowed on the
48
+ // daemon without a card (the pull request is the gate). Empty means "no
49
+ // local allowance": every ask still reaches the room.
50
+ repoRoot = "",
51
+ gateRepoEdits = false,
52
+ }) {
53
+ return {
54
+ localAllow: (request) => repoEditAllowance(request, { repoRoot, gateRepoEdits }),
55
+ requestPermission: async (request) => {
56
+ const detail =
57
+ request.metadata && typeof request.metadata === "object"
58
+ ? request.metadata
59
+ : {};
60
+ const title = [
61
+ detail.title,
62
+ detail.description,
63
+ detail.command,
64
+ request.resources?.[0],
65
+ ].find((value) => typeof value === "string" && value.trim());
66
+ return tool("request_permission", {
67
+ channelId,
68
+ threadRootId: threadRoot,
69
+ ...(runId ? { runId } : {}),
70
+ provider,
71
+ vendorSessionId: request.sessionId,
72
+ vendorRequestId: request.vendorRequestId,
73
+ action: request.action,
74
+ title: title || `${request.action} permission`,
75
+ resources: request.resources,
76
+ suggestedSave: request.suggestedSave,
77
+ metadata: detail,
78
+ ...(request.source ? { source: request.source } : {}),
79
+ });
80
+ },
81
+ getPermissionDecision: async (handle, { request, failClosed = false }) => {
82
+ const requestId =
83
+ handle &&
84
+ typeof handle === "object" &&
85
+ typeof handle.requestId === "string"
86
+ ? handle.requestId
87
+ : null;
88
+ if (!requestId) {
89
+ throw new Error(
90
+ "hilos returned a pending permission without a request id",
91
+ );
92
+ }
93
+ return tool("get_permission_decision", {
94
+ requestId,
95
+ provider,
96
+ vendorSessionId: request.sessionId,
97
+ vendorRequestId: request.vendorRequestId,
98
+ ...(failClosed ? { failClosed: true } : {}),
99
+ // Never on a failClosed settlement — that call exists to act NOW.
100
+ ...(decisionWaitMs > 0 && !failClosed ? { waitMs: decisionWaitMs } : {}),
101
+ });
102
+ },
103
+ };
104
+ }
105
+
106
+ /** The local HTTP bridge can own only a local OpenCode server. An explicit
107
+ * `--attach` remains on OpenCode's CLI responder, which rejects unanswered asks
108
+ * fail closed; taking over a remote server requires a separate authenticated
109
+ * transport contract. Shared by repo and direct-folder runs. */
110
+ export function shouldUseRuntimePermissionBridge({
111
+ vendor,
112
+ runtimePermissions,
113
+ codeArgs,
114
+ codingCmd,
115
+ }) {
116
+ return (
117
+ vendor === "opencode" &&
118
+ runtimePermissions === true &&
119
+ !codeArgs.includes("--auto") &&
120
+ attachTarget(codingCmd) === null
121
+ );
122
+ }
123
+
124
+ /** ACP transport (0759; slice 1 opencode, slice 2 cursor). Only runs the
125
+ * workspace already gates with runtime permissions qualify, and only when the
126
+ * vendor's own auto-allow escape hatches are absent — over ACP, hilos must be
127
+ * the one answering asks, so a run configured to never ask has nothing to gate
128
+ * here.
129
+ *
130
+ * 0778 removed two limits that were never about correctness. `acpTransport`
131
+ * now defaults ON (config.mjs) for the vendors whose adapter is proven, and a
132
+ * RESUMED run no longer disqualifies: both live vendors advertise ACP's
133
+ * `loadSession` capability, so acp-session.mjs resumes the session and keeps
134
+ * raising cards. Setting `acpTransport: false` still opts a workspace out. */
135
+ export function shouldUseAcpTransport({
136
+ vendor,
137
+ acpTransport,
138
+ runtimePermissions,
139
+ codeArgs,
140
+ codingCmd,
141
+ }) {
142
+ if (acpTransport !== true) return false;
143
+ if (runtimePermissions !== true) return false;
144
+ if (vendor === "opencode") {
145
+ return shouldUseRuntimePermissionBridge({
146
+ vendor,
147
+ runtimePermissions,
148
+ codeArgs,
149
+ codingCmd,
150
+ });
151
+ }
152
+ if (vendor === "cursor") {
153
+ // -f/--force is cursor's "allow everything" switch — the exact analog of
154
+ // opencode's --auto.
155
+ return !codeArgs.includes("-f") && !codeArgs.includes("--force");
156
+ }
157
+ return false;
158
+ }
159
+
160
+ /**
161
+ * The env a gated claude run gets (0777).
162
+ *
163
+ * A permission card can legitimately wait on a person for minutes — a 150s wait
164
+ * was verified live — so the CLI's own MCP tool timeout must not cut the ask
165
+ * short before hilos's run deadline does. Everything else about the env is
166
+ * unchanged (runCli still strips HILOS_* itself).
167
+ */
168
+ function claudeGateEnv(cfg) {
169
+ const base = codingChildEnv(cfg) || process.env;
170
+ const budget = Math.max(60_000, Number(cfg?.runTimeoutMs) || 0) + 60_000;
171
+ return { ...base, MCP_TOOL_TIMEOUT: String(budget) };
172
+ }
173
+
174
+ /**
175
+ * Run claude_code through its permission-prompt seam (0777).
176
+ *
177
+ * Deliberately NOT a new transport: the ordinary argv run is untouched — same
178
+ * runCli, same resume args, same streaming — and the gate is two extra flags
179
+ * plus a loopback MCP server that lives exactly as long as the run. The prompt
180
+ * stays the final argument.
181
+ */
182
+ async function runClaudeGatedCli({
183
+ deps,
184
+ cfg,
185
+ onGateDropped,
186
+ cmd,
187
+ codeArgs,
188
+ prompt,
189
+ cwd,
190
+ signal,
191
+ onData,
192
+ permissionCallbacks,
193
+ sessionId = "",
194
+ resolveSessionId,
195
+ beforeSpawn,
196
+ childMcp = null,
197
+ log = console,
198
+ }) {
199
+ const server = await deps.startClaudePermissionServer({
200
+ ...permissionCallbacks,
201
+ // 1255 — one config file for the run: the gate's permission server and the
202
+ // run's own hilos seat, plus the allow entry that keeps the agent's room
203
+ // tools off the permission card.
204
+ ...(childMcp ? { extraServers: childMcp.servers, allowedTools: childMcp.allowedTools } : {}),
205
+ sessionId,
206
+ // A FRESH run has no session id to hand over: claude reveals its own in the
207
+ // init frame of its stream, which the progress emitter is already folding.
208
+ // Pulling from that snapshot means an ask carries the CLI's real session id
209
+ // without a second parser — and hilos rejects an empty one outright.
210
+ resolveSessionId,
211
+ timeoutMs: cfg.runTimeoutMs,
212
+ signal,
213
+ log,
214
+ });
215
+ try {
216
+ return await deps.runCli({
217
+ cmd,
218
+ args: [...codeArgs, ...server.args, prompt],
219
+ cwd,
220
+ timeoutMs: cfg.runTimeoutMs,
221
+ label: "coding",
222
+ signal,
223
+ env: claudeGateEnv(cfg),
224
+ onData,
225
+ beforeSpawn,
226
+ // 0785 — a CLI that rejects the gate flags is retried without them. This
227
+ // fires BEFORE that ungated retry is spawned, so the room hears about the
228
+ // downgrade first rather than after the fact.
229
+ onPermissionGateDropped: onGateDropped,
230
+ });
231
+ } finally {
232
+ try {
233
+ await server.close();
234
+ } catch {
235
+ /* tearing the gate down must never fail a finished run */
236
+ }
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Will this codex run take the gated transport (0785)?
242
+ *
243
+ * Answerable before the run's argv exists, because the only input that can
244
+ * change the answer is the operator's OWN command — every flag the daemon
245
+ * appends later (model, dir, image, resume, stream) is ours and none of them is
246
+ * the bypass tier. Which matters because the PROMPT is written before the
247
+ * transport is chosen, and what the prompt may claim about images depends on it.
248
+ */
249
+ export function codexRunIsGated(cfg, testCapabilities) {
250
+ return shouldGateCodexPermissions({
251
+ vendor: detectVendor(cfg?.codingCmd),
252
+ runtimePermissions: testCapabilities?.runtimePermissions,
253
+ codeArgs: commandArgv(String(cfg?.codingCmd || "")).slice(1),
254
+ });
255
+ }
256
+
257
+ /**
258
+ * Run codex through its gated transport, with an honest fallback (0785).
259
+ *
260
+ * A granted workspace ALWAYS gets `codex mcp-server` — that is what the grant
261
+ * means, and it is the only codex transport that can ask (`codex exec` has no
262
+ * approval channel at any flag combination, see codex-mcp-session.mjs). The one
263
+ * thing the grant can't conjure is the subcommand itself: a codex old enough
264
+ * not to have it never answers the MCP handshake, and before this the run just
265
+ * failed. Now it degrades to the plain exec argv — the ungated run every codex
266
+ * did before 0777, never worse.
267
+ *
268
+ * Two rules make that degrade safe. It happens ONLY on positive evidence that
269
+ * the subcommand is absent (codexMcpTransportUnavailable — every other
270
+ * pre-handshake failure is returned as the failed run it is, because a wrongly
271
+ * failed run is recoverable and a wrongly ungated one is not). And the room is
272
+ * told BEFORE the ungated child is spawned, so the warning arrives while the
273
+ * run can still be stopped.
274
+ *
275
+ * The exec argv is the run's real one (model, images, resume, stream flags),
276
+ * so the fallback is the same run the ungated lane would have made.
277
+ */
278
+ async function runCodexGatedSession({
279
+ deps,
280
+ cfg,
281
+ onGateDropped,
282
+ cmd,
283
+ codeArgs,
284
+ prompt,
285
+ cwd,
286
+ model,
287
+ resumeThreadId = null,
288
+ signal,
289
+ onData,
290
+ onEvent,
291
+ beforeSpawn,
292
+ permissionCallbacks,
293
+ childMcp = null,
294
+ }) {
295
+ // 1255 — `codex mcp-server` takes the same global `-c` overrides `codex exec`
296
+ // does (verified on codex-cli 0.153.4: `codex mcp-server --help` lists
297
+ // `-c, --config <key=value>`), so the seat reaches both codex transports
298
+ // through one argv fragment.
299
+ const childMcpConfig = childMcp ? codexChildMcpArgs(childMcp.url) : [];
300
+ if (typeof beforeSpawn === "function" && (await beforeSpawn()) === false) {
301
+ return {
302
+ status: null,
303
+ stdout: "",
304
+ stderr: "",
305
+ aborted: true,
306
+ authorityLost: true,
307
+ error: new Error("execution authority lost before spawn"),
308
+ };
309
+ }
310
+ const run = await deps.runCodexMcpSession({
311
+ cmd,
312
+ ...(childMcpConfig.length ? { serverArgs: ["mcp-server", ...childMcpConfig] } : {}),
313
+ cwd,
314
+ prompt,
315
+ sandbox: codexSandboxFromArgs(codeArgs),
316
+ // 0785 — the resolved tier (0783) or a hand-pinned id. The gated transport
317
+ // took the account default before this, so a preset was silently ignored
318
+ // exactly where the operator was most likely to have set one.
319
+ model: model || null,
320
+ webSearch: cfg.webSearch !== false,
321
+ resumeThreadId,
322
+ timeoutMs: cfg.runTimeoutMs,
323
+ signal,
324
+ env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
325
+ onData,
326
+ onEvent,
327
+ beforeSpawn,
328
+ ...permissionCallbacks,
329
+ });
330
+ if (!codexMcpTransportUnavailable(run)) return run;
331
+ console.log(
332
+ " code → this codex has no `mcp-server`; running WITHOUT the hilos permission gate",
333
+ );
334
+ // Warn FIRST — before a single ungated command can run.
335
+ if (typeof onGateDropped === "function") {
336
+ try {
337
+ await onGateDropped();
338
+ } catch {
339
+ /* telling the room must never break the run */
340
+ }
341
+ }
342
+ const fallback = await deps.runCli({
343
+ cmd,
344
+ args: [...codeArgs, ...childMcpConfig, prompt],
345
+ cwd,
346
+ timeoutMs: cfg.runTimeoutMs,
347
+ label: "coding",
348
+ signal,
349
+ env: codingChildEnv(cfg),
350
+ onData,
351
+ beforeSpawn,
352
+ });
353
+ return { ...fallback, permissionGateDropped: true };
354
+ }
355
+
356
+ /**
357
+ * One turn on the repo/folder transport ladder (1290). childMcp opens the
358
+ * turn's seat after its transport is known; it closes before onSettle runs,
359
+ * including on startup failure. Resume ids and the spawn/settlement hooks are
360
+ * the repo lane's explicit additions; permissionCallbacks supplies room context.
361
+ *
362
+ * @param {{
363
+ * deps: any, cfg: any, cmd: string, model?: string | null,
364
+ * runtimePermissions?: boolean, vendor: string, codeArgs: string[],
365
+ * cwd: string, prompt: string, permissionCallbacks: any,
366
+ * childMcp: (options: { standaloneConfig: boolean }) => Promise<any>,
367
+ * onData: any, onEvent?: any, signal?: AbortSignal,
368
+ * beforeSpawn?: () => boolean | Promise<boolean>,
369
+ * resume?: { sessionId: string | null }, runId?: string | null,
370
+ * onSettle?: (run: any) => void | Promise<void>,
371
+ * onGateDropped: () => void | Promise<void>,
372
+ * resolveSessionId?: () => string | null,
373
+ * }} options
374
+ */
375
+ export async function runCodingTransport({
376
+ deps, cfg, cmd, model, runtimePermissions,
377
+ vendor, codeArgs, cwd, prompt, permissionCallbacks, childMcp,
378
+ onData, onEvent, signal, beforeSpawn, resume, runId,
379
+ onSettle, onGateDropped, resolveSessionId,
380
+ }) {
381
+ let seat = null;
382
+ let run;
383
+ try {
384
+ const useAcpTransport = shouldUseAcpTransport({
385
+ vendor, acpTransport: cfg.acpTransport, runtimePermissions,
386
+ codeArgs, codingCmd: cfg.codingCmd,
387
+ });
388
+ const useRuntimePermissionBridge = !useAcpTransport && shouldUseRuntimePermissionBridge({
389
+ vendor, runtimePermissions, codeArgs, codingCmd: cfg.codingCmd,
390
+ });
391
+ const gateCodexPermissions = !useAcpTransport && !useRuntimePermissionBridge &&
392
+ shouldGateCodexPermissions({ vendor, runtimePermissions, codeArgs });
393
+ const gateClaudePermissions = !useAcpTransport && !useRuntimePermissionBridge && !gateCodexPermissions &&
394
+ shouldGateClaudePermissions({ vendor, runtimePermissions, codeArgs });
395
+ seat = await childMcp({ standaloneConfig: vendor === "claude_code" && !gateClaudePermissions });
396
+ const callbacks = (extra = {}) => openCodePermissionCallbacks({ ...permissionCallbacks, runId, ...extra });
397
+ if (useAcpTransport) {
398
+ run = await deps.runAcpSession({
399
+ cmd, vendor, cwd, prompt,
400
+ ...(resume ? { resumeSessionId: resume.sessionId } : {}),
401
+ timeoutMs: cfg.runTimeoutMs, signal,
402
+ env: scrubHilosEnv(codingChildWebEnv(cfg, vendor) || process.env),
403
+ onData, onEvent, beforeSpawn,
404
+ ...callbacks({ provider: vendor }),
405
+ });
406
+ } else if (useRuntimePermissionBridge) {
407
+ run = await deps.runOpenCodeHttpSession({
408
+ cmd, args: codeArgs, cwd, prompt, timeoutMs: cfg.runTimeoutMs, signal,
409
+ env: scrubHilosEnv(codingChildWebEnv(cfg, vendor) || process.env),
410
+ onData, beforeSpawn, ...callbacks(),
411
+ });
412
+ } else if (gateCodexPermissions) {
413
+ run = await runCodexGatedSession({
414
+ deps, cfg, onGateDropped, cmd, codeArgs, cwd, prompt, model,
415
+ ...(resume ? { resumeThreadId: resume.sessionId } : {}),
416
+ signal, onData, onEvent, beforeSpawn, childMcp: seat,
417
+ permissionCallbacks: callbacks({ provider: vendor, repoRoot: cwd, gateRepoEdits: cfg.gateRepoEdits === true }),
418
+ });
419
+ } else if (gateClaudePermissions) {
420
+ run = await runClaudeGatedCli({
421
+ deps, cfg, onGateDropped, cmd, codeArgs, cwd, prompt,
422
+ ...(resume ? { sessionId: resume.sessionId ?? "" } : {}),
423
+ signal, onData, beforeSpawn, resolveSessionId, childMcp: seat,
424
+ permissionCallbacks: callbacks({ provider: vendor, repoRoot: cwd, gateRepoEdits: cfg.gateRepoEdits === true }),
425
+ });
426
+ } else {
427
+ run = await deps.runCli({
428
+ cmd, args: [...codeArgs, ...childMcpArgs(vendor, seat), prompt],
429
+ cwd, timeoutMs: cfg.runTimeoutMs, label: "coding", signal,
430
+ env: codingChildWebEnv(cfg, vendor), onData, beforeSpawn,
431
+ });
432
+ }
433
+ // 0785 backstop; the callback's run-scoped latch keeps this notice singular.
434
+ if (run?.permissionGateDropped) await onGateDropped();
435
+ return run;
436
+ } finally {
437
+ try { await seat?.close(); } catch { /* teardown must never fail a finished run */ }
438
+ await onSettle?.(run);
439
+ }
440
+ }