hilos-agent 0.6.0 → 0.7.0

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/handler.mjs CHANGED
@@ -26,11 +26,28 @@ import {
26
26
  mentionHandle,
27
27
  detectPrContinuation,
28
28
  } from "./daemon.mjs";
29
- import { runCli, buildHeartbeat, ackText, oneLine, fmtElapsed, minimalEnv } from "./cli.mjs";
29
+ import {
30
+ runCli,
31
+ buildHeartbeat,
32
+ ackText,
33
+ oneLine,
34
+ fmtElapsed,
35
+ minimalEnv,
36
+ scrubHilosEnv,
37
+ envForCwd,
38
+ } from "./cli.mjs";
30
39
  import { makeStreamParser } from "./agent-events.mjs";
31
- import { detectVendor, codeStreamArgs, createProgressEmitter, fastChatCmd } from "./progress-emitter.mjs";
40
+ import {
41
+ detectVendor,
42
+ codeStreamArgs,
43
+ codeDirArgs,
44
+ codeProjectKey,
45
+ attachTarget,
46
+ createProgressEmitter,
47
+ fastChatCmd,
48
+ } from "./progress-emitter.mjs";
32
49
  import { resolveFollowupMode, classifyFollowupCue, normalizeSignal } from "./followup.mjs";
33
- import { buildResumeArgs, readStateEntry, writeState, HILOS_DIR } from "./resume.mjs";
50
+ import { buildResumeArgs, resumeDecision, readStateEntry, writeState, HILOS_DIR } from "./resume.mjs";
34
51
  import { createModelArgsResolver } from "./model-resolve.mjs";
35
52
  import {
36
53
  buildReviewPrompt,
@@ -40,6 +57,7 @@ import {
40
57
  } from "./review.mjs";
41
58
  import { buildMemoryBlock } from "./memory.mjs";
42
59
  import { deployFolder, resolveDeployTarget } from "./deploy.mjs";
60
+ import { runOpenCodeHttpSession } from "./opencode-session.mjs";
43
61
 
44
62
  /**
45
63
  * The environment for a coding/chat CLI run. runCli always strips HILOS_* on top
@@ -120,14 +138,20 @@ function compactRunMarker(status, branch) {
120
138
  return `Failed:${b}`;
121
139
  }
122
140
 
141
+ // Every helper below runs a tool in a directory we choose, so each one hands
142
+ // the child a PWD that matches that directory instead of the daemon's launch
143
+ // dir (0615). git and gh both use the real cwd, so this is hygiene rather than
144
+ // a fix — but a child whose env contradicts its cwd is wrong on any tool.
145
+ const cwdEnv = (cwd) => envForCwd(process.env, cwd);
146
+
123
147
  function defaultDeps() {
124
148
  return {
125
149
  git: (cwd, args) =>
126
- spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 50 * 1024 * 1024 }),
127
- // The async CLI runner, injectable so folder mode (and tests) can drive the
128
- // coding run without spawning a real process. The repo flow still uses the
129
- // imported runCli directly (unchanged); only folder mode goes through deps.
150
+ spawnSync("git", args, { cwd, env: cwdEnv(cwd), encoding: "utf8", maxBuffer: 50 * 1024 * 1024 }),
151
+ // Async coding runners are injectable so handler-level tests can prove the
152
+ // selected trust boundary without spawning a real model process.
130
153
  runCli: (opts) => runCli(opts),
154
+ runOpenCodeHttpSession: (opts) => runOpenCodeHttpSession(opts),
131
155
  // Does a path exist on disk? Injectable so folder mode's "missing folder"
132
156
  // guard is unit-testable without touching the real filesystem.
133
157
  pathExists: (p) => existsSync(p),
@@ -137,7 +161,7 @@ function defaultDeps() {
137
161
  const r = spawnSync(
138
162
  "gh",
139
163
  ["pr", "create", "--title", title, "--body", body, "--head", branch, "--base", base],
140
- { cwd, encoding: "utf8" },
164
+ { cwd, env: cwdEnv(cwd), encoding: "utf8" },
141
165
  );
142
166
  const url = (r.stdout || "").trim().split("\n").filter(Boolean).pop() || null;
143
167
  return { ok: r.status === 0, url, stderr: r.stderr || "" };
@@ -148,7 +172,7 @@ function defaultDeps() {
148
172
  const r = spawnSync(
149
173
  "gh",
150
174
  ["pr", "list", "--head", branch, "--state", "open", "--json", "url", "--jq", ".[0].url // empty"],
151
- { cwd, encoding: "utf8" },
175
+ { cwd, env: cwdEnv(cwd), encoding: "utf8" },
152
176
  );
153
177
  const url = (r.stdout || "").trim();
154
178
  return r.status === 0 && url ? url : null;
@@ -160,7 +184,7 @@ function defaultDeps() {
160
184
  const r = spawnSync(
161
185
  "gh",
162
186
  ["pr", "view", String(ref), "--json", "headRefName", "--jq", ".headRefName // empty"],
163
- { cwd, encoding: "utf8" },
187
+ { cwd, env: cwdEnv(cwd), encoding: "utf8" },
164
188
  );
165
189
  const out = (r.stdout || "").trim();
166
190
  return r.status === 0 && out ? out : null;
@@ -170,6 +194,80 @@ function defaultDeps() {
170
194
  };
171
195
  }
172
196
 
197
+ /**
198
+ * Bind one OpenCode HTTP session to hilos's vendor-neutral permission tools.
199
+ * Both repo and direct-folder runs use this exact callback contract so neither
200
+ * path can accidentally become the ungated exception.
201
+ */
202
+ function openCodePermissionCallbacks({ tool, channelId, threadRoot, runId = null }) {
203
+ return {
204
+ requestPermission: async (request) => {
205
+ const detail =
206
+ request.metadata && typeof request.metadata === "object"
207
+ ? request.metadata
208
+ : {};
209
+ const title = [
210
+ detail.title,
211
+ detail.description,
212
+ detail.command,
213
+ request.resources?.[0],
214
+ ].find((value) => typeof value === "string" && value.trim());
215
+ return tool("request_permission", {
216
+ channelId,
217
+ threadRootId: threadRoot,
218
+ ...(runId ? { runId } : {}),
219
+ provider: "opencode",
220
+ vendorSessionId: request.sessionId,
221
+ vendorRequestId: request.vendorRequestId,
222
+ action: request.action,
223
+ title: title || `${request.action} permission`,
224
+ resources: request.resources,
225
+ suggestedSave: request.suggestedSave,
226
+ metadata: detail,
227
+ ...(request.source ? { source: request.source } : {}),
228
+ });
229
+ },
230
+ getPermissionDecision: async (handle, { request, failClosed = false }) => {
231
+ const requestId =
232
+ handle &&
233
+ typeof handle === "object" &&
234
+ typeof handle.requestId === "string"
235
+ ? handle.requestId
236
+ : null;
237
+ if (!requestId) {
238
+ throw new Error(
239
+ "hilos returned a pending permission without a request id",
240
+ );
241
+ }
242
+ return tool("get_permission_decision", {
243
+ requestId,
244
+ provider: "opencode",
245
+ vendorSessionId: request.sessionId,
246
+ vendorRequestId: request.vendorRequestId,
247
+ ...(failClosed ? { failClosed: true } : {}),
248
+ });
249
+ },
250
+ };
251
+ }
252
+
253
+ /** The local HTTP bridge can own only a local OpenCode server. An explicit
254
+ * `--attach` remains on OpenCode's CLI responder, which rejects unanswered asks
255
+ * fail closed; taking over a remote server requires a separate authenticated
256
+ * transport contract. Shared by repo and direct-folder runs. */
257
+ export function shouldUseRuntimePermissionBridge({
258
+ vendor,
259
+ runtimePermissions,
260
+ codeArgs,
261
+ codingCmd,
262
+ }) {
263
+ return (
264
+ vendor === "opencode" &&
265
+ runtimePermissions === true &&
266
+ !codeArgs.includes("--auto") &&
267
+ attachTarget(codingCmd) === null
268
+ );
269
+ }
270
+
173
271
  async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId, signal }) {
174
272
  if (!reportMessageId) return { kind: "timeout" };
175
273
  const deadline = deps.now() + cfg.decisionTimeoutMs;
@@ -1279,34 +1377,72 @@ async function handleFolderTask({ message, channelId, tool, me, caps, cfg, deps,
1279
1377
  let run;
1280
1378
  // Model preset (0504): same run-time resolution as the repo path.
1281
1379
  const modelArgs = await modelArgsFor(cfg, vendor);
1380
+ // Project pin (0608): opencode reads its project from PWD, so without this
1381
+ // a folder run could edit the daemon's launch directory instead of the
1382
+ // folder the channel is linked to. [] for every other vendor.
1383
+ const dirArgs = codeDirArgs(vendor, folderPath, cfg.codingCmd);
1384
+ const codeArgs = [
1385
+ ...parts.slice(1),
1386
+ ...modelArgs,
1387
+ ...dirArgs,
1388
+ ...streamArgs,
1389
+ ];
1390
+ const handleCliData = (c) => {
1391
+ const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
1392
+ if (lines.length) lastLine = lines[lines.length - 1];
1393
+ if (resultParser) {
1394
+ try {
1395
+ foldResultEvents(resultParser.push(String(c)));
1396
+ } catch {
1397
+ /* result extraction must never break the run */
1398
+ }
1399
+ }
1400
+ if (emitter) {
1401
+ try {
1402
+ emitter.feed(c);
1403
+ } catch {
1404
+ /* a progress fold must never break the run */
1405
+ }
1406
+ }
1407
+ };
1282
1408
  try {
1283
- run = await deps.runCli({
1284
- cmd: parts[0],
1285
- args: [...parts.slice(1), ...modelArgs, ...streamArgs, memoryPreamble(workspaceMemory) + promptText],
1286
- cwd: folderPath,
1287
- timeoutMs: cfg.runTimeoutMs,
1288
- label: "coding",
1289
- signal,
1290
- env: codingChildEnv(cfg),
1291
- onData: (c) => {
1292
- const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
1293
- if (lines.length) lastLine = lines[lines.length - 1];
1294
- if (resultParser) {
1295
- try {
1296
- foldResultEvents(resultParser.push(String(c)));
1297
- } catch {
1298
- /* result extraction must never break the run */
1299
- }
1300
- }
1301
- if (emitter) {
1302
- try {
1303
- emitter.feed(c);
1304
- } catch {
1305
- /* a progress fold must never break the run */
1306
- }
1307
- }
1308
- },
1409
+ const useRuntimePermissionBridge = shouldUseRuntimePermissionBridge({
1410
+ vendor,
1411
+ runtimePermissions: caps.runtimePermissions,
1412
+ codeArgs,
1413
+ codingCmd: cfg.codingCmd,
1309
1414
  });
1415
+ if (useRuntimePermissionBridge) {
1416
+ run = await deps.runOpenCodeHttpSession({
1417
+ cmd: parts[0],
1418
+ args: codeArgs,
1419
+ cwd: folderPath,
1420
+ prompt: memoryPreamble(workspaceMemory) + promptText,
1421
+ timeoutMs: cfg.runTimeoutMs,
1422
+ signal,
1423
+ env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
1424
+ onData: handleCliData,
1425
+ ...openCodePermissionCallbacks({
1426
+ tool,
1427
+ channelId,
1428
+ threadRoot,
1429
+ }),
1430
+ });
1431
+ } else {
1432
+ run = await deps.runCli({
1433
+ cmd: parts[0],
1434
+ args: [
1435
+ ...codeArgs,
1436
+ memoryPreamble(workspaceMemory) + promptText,
1437
+ ],
1438
+ cwd: folderPath,
1439
+ timeoutMs: cfg.runTimeoutMs,
1440
+ label: "coding",
1441
+ signal,
1442
+ env: codingChildEnv(cfg),
1443
+ onData: handleCliData,
1444
+ });
1445
+ }
1310
1446
  } finally {
1311
1447
  stopHeartbeat();
1312
1448
  if (emitter) {
@@ -2037,7 +2173,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2037
2173
  branch,
2038
2174
  // Map an unrecognized command to null rather than the off-vocabulary
2039
2175
  // "unknown" — provider is documented as
2040
- // claude_code|codex|cursor|antigravity|hermes|hilos.
2176
+ // claude_code|codex|cursor|opencode|antigravity|hermes|hilos.
2041
2177
  provider: (() => {
2042
2178
  const v = detectVendor(cfg.codingCmd);
2043
2179
  return v === "unknown" ? null : v;
@@ -2085,9 +2221,13 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2085
2221
  // (get_active_run) with no local match means the session likely lives on another
2086
2222
  // machine/instance — a bad `--resume` id makes claude error → empty diff → a failed
2087
2223
  // run, so we DON'T resume and degrade to today's branch+feedback (never worse).
2088
- // claude_code + cursor have proven resume flags (0282/0573); codex/unknown → [].
2224
+ // claude_code + cursor + opencode have proven resume flags (0282/0573/0608);
2225
+ // codex/unknown → []. The gate itself is pure (resumeDecision in resume.mjs):
2226
+ // vendor, machine, project and server agreement all have to line up, and any
2227
+ // "no" degrades to the branch+feedback iterate rather than risking a bad id.
2228
+ const projectKey = codeProjectKey(vendor, repoPath, cfg.codingCmd);
2089
2229
  let resumeSessionId = null;
2090
- if (effectiveMode === "iterate" && (vendor === "claude_code" || vendor === "cursor")) {
2230
+ if (effectiveMode === "iterate") {
2091
2231
  const local = (() => {
2092
2232
  try {
2093
2233
  return readStateEntry(HILOS_DIR, threadRoot);
@@ -2096,14 +2236,18 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2096
2236
  }
2097
2237
  })();
2098
2238
  const serverSid = activeRun?.providerSessionId || null;
2099
- // Confidence = a local record, for THIS machine, with a session id. If the server
2100
- // also has one it must AGREE (a mismatch means the run moved — don't resume a
2101
- // stale/foreign id). A server-only id (no local record) is never resumed.
2102
- if (local && local.machine === machine && local.sessionId && (!serverSid || serverSid === local.sessionId)) {
2103
- resumeSessionId = local.sessionId;
2104
- console.log(` code → resuming session ${local.sessionId} for this iterate (local + machine match)`);
2105
- } else if (serverSid) {
2106
- console.log(" code → not resuming (session recorded on another machine or unverified locally); using branch + feedback");
2239
+ const decision = resumeDecision({
2240
+ vendor,
2241
+ entry: local,
2242
+ serverSessionId: serverSid,
2243
+ machine,
2244
+ projectKey,
2245
+ });
2246
+ if (decision.sessionId) {
2247
+ resumeSessionId = decision.sessionId;
2248
+ console.log(` code → resuming session ${decision.sessionId} for this iterate (local + machine match)`);
2249
+ } else if (local?.sessionId || serverSid) {
2250
+ console.log(` code → not resuming (${decision.reason}); using branch + feedback`);
2107
2251
  }
2108
2252
  }
2109
2253
 
@@ -2220,34 +2364,75 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2220
2364
  // list (cursor only today) — [] when unset/unresolvable, so the tool's
2221
2365
  // default stands. Inserted before resume/stream flags, after the base.
2222
2366
  const modelArgs = await modelArgsFor(cfg, vendor);
2367
+ // Project pin (0608, opencode only): the CLI resolves its project from PWD,
2368
+ // not the spawn cwd, and its sessions are per project — `--dir` makes both
2369
+ // deterministic. [] for every other vendor (and for an `--attach`ed run,
2370
+ // where the project lives on the remote server), so their ARGV is unchanged.
2371
+ // The spawned PWD now matches the cwd too (0615); `--dir` stays as the
2372
+ // CLI's own explicit contract, and to keep attach runs off our local path.
2373
+ const dirArgs = codeDirArgs(vendor, repoPath, cfg.codingCmd);
2223
2374
  const codeArgs = streamOn
2224
- ? [...parts.slice(1), ...modelArgs, ...resumeArgs, ...streamArgs]
2225
- : [...parts.slice(1), ...modelArgs, ...resumeArgs];
2375
+ ? [...parts.slice(1), ...modelArgs, ...dirArgs, ...resumeArgs, ...streamArgs]
2376
+ : [...parts.slice(1), ...modelArgs, ...dirArgs, ...resumeArgs];
2377
+ const handleCliData = (c) => {
2378
+ // Keep tracking lastLine as a fallback (legacy heartbeat / honesty).
2379
+ const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
2380
+ if (lines.length) lastLine = lines[lines.length - 1];
2381
+ if (emitter) {
2382
+ try {
2383
+ emitter.feed(c);
2384
+ } catch {
2385
+ /* a progress fold must never break the run */
2386
+ }
2387
+ }
2388
+ };
2226
2389
  let run;
2227
2390
  try {
2228
- run = await runCli({
2229
- cmd: parts[0],
2230
- args: [...codeArgs, memoryPreamble(workspaceMemory) + promptText],
2231
- cwd: repoPath,
2232
- timeoutMs: cfg.runTimeoutMs,
2233
- label: "coding",
2234
- signal,
2235
- env: codingChildEnv(cfg),
2236
- onData: (c) => {
2237
- // Keep tracking lastLine as a fallback (legacy heartbeat / honesty).
2238
- const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
2239
- if (lines.length) lastLine = lines[lines.length - 1];
2240
- if (emitter) {
2241
- try {
2242
- emitter.feed(c);
2243
- } catch {
2244
- /* a progress fold must never break the run */
2245
- }
2246
- }
2247
- },
2391
+ // OpenCode's own non-interactive CLI auto-rejects every permission ask
2392
+ // unless --auto is present. For the gated tiers, bypass that responder
2393
+ // and own the authenticated HTTP session + SSE stream directly so hilos
2394
+ // is the sole authority answering the paused tool call (0593).
2395
+ const useRuntimePermissionBridge = shouldUseRuntimePermissionBridge({
2396
+ vendor,
2397
+ runtimePermissions: caps.runtimePermissions,
2398
+ codeArgs,
2399
+ codingCmd: cfg.codingCmd,
2248
2400
  });
2401
+ if (useRuntimePermissionBridge) {
2402
+ run = await deps.runOpenCodeHttpSession({
2403
+ cmd: parts[0],
2404
+ args: codeArgs,
2405
+ cwd: repoPath,
2406
+ prompt: memoryPreamble(workspaceMemory) + promptText,
2407
+ timeoutMs: cfg.runTimeoutMs,
2408
+ signal,
2409
+ // A model running inside the server must never inherit the daemon's
2410
+ // hilos bearer token. The bridge's random loopback password is added
2411
+ // after this scrub and dies with the process group.
2412
+ env: scrubHilosEnv(codingChildEnv(cfg) || process.env),
2413
+ onData: handleCliData,
2414
+ ...openCodePermissionCallbacks({
2415
+ tool,
2416
+ channelId,
2417
+ threadRoot,
2418
+ runId,
2419
+ }),
2420
+ });
2421
+ } else {
2422
+ run = await runCli({
2423
+ cmd: parts[0],
2424
+ args: [...codeArgs, memoryPreamble(workspaceMemory) + promptText],
2425
+ cwd: repoPath,
2426
+ timeoutMs: cfg.runTimeoutMs,
2427
+ label: "coding",
2428
+ signal,
2429
+ env: codingChildEnv(cfg),
2430
+ onData: handleCliData,
2431
+ });
2432
+ }
2249
2433
  } finally {
2250
2434
  stopHeartbeat();
2435
+ if (run?.sessionId) runSessionId = run.sessionId;
2251
2436
  if (emitter) {
2252
2437
  // Terminal state: flip the status card off "working" (state 'done'/'error')
2253
2438
  // so it stops claiming the agent is alive. For a run that produced work,
@@ -2355,6 +2540,11 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
2355
2540
  prUrl: prUrl || continuingPrUrl || null,
2356
2541
  sessionId: sid,
2357
2542
  machine,
2543
+ // The CLI that created the session (a `ses_…` is meaningless to
2544
+ // claude's `--resume`) and the project it belongs to — opencode
2545
+ // scopes sessions per project. The gate above requires both (0608).
2546
+ vendor,
2547
+ cwd: projectKey,
2358
2548
  updatedAt: new Date().toISOString(),
2359
2549
  });
2360
2550
  }