hilos-agent 0.11.9 → 0.11.11

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.
@@ -30,6 +30,7 @@ import { hookMain, hooksMain } from "../src/hook.mjs";
30
30
  import { runWebMcpCommand } from "../src/webmcp-bridge.mjs";
31
31
  import { detectVendor, fastChatCmd, webCapability } from "../src/progress-emitter.mjs";
32
32
  import { commandArgv } from "../src/argv.mjs";
33
+ import { runWithTerminalSignals } from "../src/cli.mjs";
33
34
 
34
35
  // 1251 — the plugin writes `hook --claude --personal`, so the client flags name
35
36
  // the vendor for `hook` the same way they choose a target for `hooks install`.
@@ -318,7 +319,7 @@ async function main() {
318
319
  delete cliFlags.joinStdin;
319
320
  delete cliFlags.help;
320
321
  const cfg = resolveConfig({ flags: cliFlags, join: joinPayload });
321
- await run(cfg);
322
+ await runWithTerminalSignals((signal) => run(cfg, { signal }));
322
323
  }
323
324
 
324
325
  main().catch((e) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.11.9",
3
+ "version": "0.11.11",
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": {
package/src/cli.mjs CHANGED
@@ -7,6 +7,30 @@
7
7
 
8
8
  import { spawn } from "node:child_process";
9
9
 
10
+ /**
11
+ * Let the daemon finish its existing abort/cleanup path before exiting.
12
+ * @param {(signal: AbortSignal) => Promise<any>} task
13
+ * @param {import("node:events").EventEmitter & { exitCode?: number | string }} signalSource
14
+ */
15
+ export async function runWithTerminalSignals(task, signalSource = process) {
16
+ const controller = new AbortController();
17
+ const interrupt = () => stop("SIGINT", 130);
18
+ const terminate = () => stop("SIGTERM", 143);
19
+ const stop = (name, code) => {
20
+ if (controller.signal.aborted) return;
21
+ signalSource.exitCode = code;
22
+ controller.abort(new Error(`Daemon received ${name}`));
23
+ };
24
+ signalSource.once("SIGINT", interrupt);
25
+ signalSource.once("SIGTERM", terminate);
26
+ try {
27
+ return await task(controller.signal);
28
+ } finally {
29
+ signalSource.removeListener("SIGINT", interrupt);
30
+ signalSource.removeListener("SIGTERM", terminate);
31
+ }
32
+ }
33
+
10
34
  // ── Environment isolation ────────────────────────────────────────────────────
11
35
  // The coding/chat CLI we spawn (`claude -p`, `codex`, …) is a model with tool
12
36
  // use: it can run `env`/`printenv` and echo whatever it sees into the channel.
package/src/handler.mjs CHANGED
@@ -4975,6 +4975,11 @@ export async function handleTask({ message, channelId, tool, me, caps = {}, iter
4975
4975
  body,
4976
4976
  });
4977
4977
  await updateChannelMarker(compactRunMarker("cancelled", branch));
4978
+ // 1270 — a process interruption has no server-side Stop action to settle
4979
+ // the row. Iterate claims keep their fenced recovery path in run().
4980
+ if (runId && caps.runs && !iterateClaimId) {
4981
+ await tool("update_run", { runId, status: "failed", reason: "daemon-stopped" }).catch(() => {});
4982
+ }
4978
4983
  // Every repo-lane cancel funnels through here, so one settle covers them
4979
4984
  // all: a stopped run still spent tokens (0787).
4980
4985
  await settleRunUsage();
@@ -5254,8 +5259,19 @@ export async function handleTask({ message, channelId, tool, me, caps = {}, iter
5254
5259
  compactRunMarker(staged.failed ? "run-failed" : "no-changes", branch),
5255
5260
  );
5256
5261
  await settleRunUsage(); // "no changes" is not "no spend" (0787)
5257
- // 1182 — no turn is left in this pass: what is held is answered now.
5262
+ // 1182 — retry direction receipts before terminal settlement drops any
5263
+ // still-unacknowledged input. No turn is left in this pass.
5258
5264
  if (directions) await directions.close();
5265
+ // A terminal progress card is presentation; it does not settle this row.
5266
+ // Keep an existing PR at its review gate when feedback produces no diff.
5267
+ if (runId && caps.runs && !iterateClaimId) {
5268
+ await tool("update_run", {
5269
+ runId,
5270
+ status: continuingPrUrl ? "awaiting_review" : staged.failed ? "failed" : "succeeded",
5271
+ ...(continuingPrUrl ? { prUrl: continuingPrUrl } : {}),
5272
+ ...(!continuingPrUrl ? { reason: staged.failed ? "coding-tool-failed" : "no-changes" } : {}),
5273
+ });
5274
+ }
5259
5275
  return { status: staged.failed ? "run-failed" : "no-changes" };
5260
5276
  };
5261
5277
  if (staged.empty) return await finishEmptyTree({ afterDirection: afterCoding.advanced });
package/src/hook.mjs CHANGED
@@ -26,6 +26,7 @@ import { join, dirname } from "node:path";
26
26
  import { fileURLToPath } from "node:url";
27
27
  import { sanitizeText, webTarget } from "./agent-events.mjs";
28
28
  import { resolveConfig } from "./config.mjs";
29
+ import { normalizeSeatRepository } from "./seat-repository.mjs";
29
30
 
30
31
  export const HOOK_STATE_DIR = join(homedir(), ".hilos", "hook-state");
31
32
  export const CODEX_HOOK_SCOPE_FILE = join(homedir(), ".hilos", "codex-hook-scope.json");
@@ -402,24 +403,25 @@ export function seatLabel(value) {
402
403
  }
403
404
 
404
405
  /**
405
- * The label for this session's seat: the repo from `git remote`, else the
406
- * folder name. `git` is asked once per session (SessionStart) with a short
407
- * timeout, and a failure just falls back — a hook never blocks the CLI.
406
+ * The canonical origin for this session. Git is asked once at attach with a
407
+ * short timeout. A missing or unsupported origin leaves the seat unbound.
408
408
  */
409
- export function readSeatLabel(cwd, { spawn = spawnSync } = {}) {
410
- const folder = seatLabel(cwd);
409
+ export function readSeatRepository(cwd, { spawn = spawnSync } = {}) {
411
410
  try {
412
411
  const out = spawn("git", ["-C", String(cwd || "."), "remote", "get-url", "origin"], {
413
412
  encoding: "utf8",
414
413
  timeout: 1500,
415
414
  });
416
- const remote = out?.status === 0 ? seatLabel(out.stdout) : "";
417
- return remote || folder;
415
+ return out?.status === 0 ? normalizeSeatRepository(out.stdout) : null;
418
416
  } catch {
419
- return folder;
417
+ return null;
420
418
  }
421
419
  }
422
420
 
421
+ export function readSeatLabel(cwd, options) {
422
+ return seatLabel(readSeatRepository(cwd, options)) || seatLabel(cwd);
423
+ }
424
+
423
425
  /** One bounded personal `tools/call`. Returns the parsed payload, or null. */
424
426
  export async function callPersonalTool({ url, token, client, name, args }) {
425
427
  const controller = new AbortController();
@@ -463,7 +465,8 @@ export async function runPersonalSeat({
463
465
  now = Date.now,
464
466
  note = "",
465
467
  call = callPersonalTool,
466
- label = readSeatLabel,
468
+ label,
469
+ repository = readSeatRepository,
467
470
  }) {
468
471
  if (!personal || !ev) return state;
469
472
  const seat = state.seat && typeof state.seat === "object" ? state.seat : {};
@@ -479,12 +482,14 @@ export async function runPersonalSeat({
479
482
  ev.event === "PostToolUse";
480
483
  if (!seat.id && !wantsSeat) return state;
481
484
  if (ev.event === "SessionStart" || !seat.id) {
485
+ const repositoryUrl = repository(ev.cwd);
482
486
  const attached = await call({
483
487
  ...base,
484
488
  name: "attach_session",
485
489
  args: {
486
490
  client: personal.client,
487
- label: label(ev.cwd),
491
+ label: label ? label(ev.cwd) : seatLabel(repositoryUrl) || seatLabel(ev.cwd),
492
+ ...(repositoryUrl ? { repositoryUrl } : {}),
488
493
  sessionKey: ev.sessionId,
489
494
  },
490
495
  });
@@ -0,0 +1 @@
1
+ export function normalizeSeatRepository(value: unknown): string | null;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * A GitHub origin as a credential-free HTTPS URL. Room links currently name
3
+ * GitHub repositories, so another host (even with the same owner/repo), local
4
+ * paths, SSH aliases, and malformed remotes cannot select a room.
5
+ */
6
+ export function normalizeSeatRepository(value) {
7
+ if (typeof value !== "string") return null;
8
+ const text = value.trim();
9
+ if (!text || /[\\\s]/.test(text)) return null;
10
+ let url;
11
+ try {
12
+ const scp = text.match(/^git@github\.com:([^?#]+)$/i);
13
+ url = new URL(scp ? `https://github.com/${scp[1]}` : text);
14
+ } catch {
15
+ return null;
16
+ }
17
+ if (!["https:", "http:", "ssh:", "git:"].includes(url.protocol) ||
18
+ url.hostname.toLowerCase() !== "github.com" || url.port) return null;
19
+ const path = url.pathname.replace(/\/$/, "").replace(/\.git$/i, "");
20
+ if (!/^\/[a-z0-9-]+\/[a-z0-9_.-]+$/i.test(path)) return null;
21
+ const [, owner, repo] = path.split("/");
22
+ if (repo === "." || repo === "..") return null;
23
+ return `https://github.com/${owner.toLowerCase()}/${repo.toLowerCase()}`;
24
+ }