@irythill/orq 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.
@@ -15,17 +15,21 @@ import { basename, dirname, join, resolve } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { claudeAccounts } from "../accounts.js";
17
17
  import { gateFor } from "../diagnose.js";
18
- import { argvModel, useThrowaway, agentDef, ALIASES, expandString, expandTemplate, FORMATS } from "../agents.js";
18
+ import { hasVerdict } from "../rounds.js";
19
+ import { argvModel, baseCommit, commitMessage, isCommitBase, stepIns, stepInNote, useThrowaway, agentDef, ALIASES, expandString, expandTemplate, FORMATS } from "../agents.js";
19
20
  import { getPath, loadConfig } from "../config.js";
20
21
  import { writeAtomic } from "../fsutil.js";
21
22
  import { m } from "../i18n.js";
22
23
  import { alive } from "../lock.js";
24
+ import { agyToken, humanReset, learnExhausted } from "../quota.js";
23
25
  import { stateDir } from "../paths.js";
24
26
  import { killedRc, killTree, parseSize, resolveMode, runCapped } from "../process.js";
25
27
  import { closeOwnTab, detectHost, launch, screenOpen, shq, typeInto } from "../terminal.js";
26
28
  import { ackPrefix } from "../notice.js";
27
29
  import { dropWorktree, notify } from "../testrun.js";
28
- export const USAGE = "orq agent -a <agent> -t <title> -d <dir> -o <prefix> [-g group] [-m model] [-e effort] [-c account] [-i file]... [-b base] [-r] [-R session [-F [-F]]] [-T] -- <prompt.md> run a child agent";
30
+ export const USAGE = "orq agent -a <agent> -t <title> -d <dir> -o <prefix> [-g group] [-m model] [-e effort] [-c account] [-i file]... [-b base [--base-branch <branch>]] [-r] [-R session [-F [-F]] [--reason review|verify|unblock]] [-T] -- <prompt.md> run a child agent";
31
+ /** Why a follow-up (-R) runs: only `review` (fixing a reviewer's findings) counts towards policy.max_rounds. */
32
+ export const ROUND_REASONS = ["review", "verify", "unblock"];
29
33
  const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
30
34
  const PF = ".herdr-agent-prompt.md"; // prompt_in_dir file name (kept from herdr-agent)
31
35
  const git = (dir, ...a) => {
@@ -37,8 +41,8 @@ export async function agent(argv) {
37
41
  return runJob(argv[1]);
38
42
  if (argv[0] === "--turn")
39
43
  return turn(argv[1], argv[2], argv.slice(3));
40
- let a = process.env.AGENT || "", g = "", t = "", d = "", o = "", model = "", effort = "", acc = "", resume = "", base = "";
41
- let ro = false, tui = false, force = 0, prompt;
44
+ let a = process.env.AGENT || "", g = "", t = "", d = "", o = "", model = "", effort = "", acc = "", resume = "", base = "", baseBranch = "";
45
+ let ro = false, tui = false, force = 0, prompt, reason = "review";
42
46
  const inc = [];
43
47
  for (let i = 0; i < argv.length; i++) {
44
48
  const x = argv[i], v = () => argv[++i] ?? "";
@@ -76,6 +80,10 @@ export async function agent(argv) {
76
80
  tui = true;
77
81
  else if (x === "-F")
78
82
  force++;
83
+ else if (x === "--reason")
84
+ reason = v();
85
+ else if (x === "--base-branch")
86
+ baseBranch = v();
79
87
  else if (!x.startsWith("-") && prompt === undefined)
80
88
  prompt = x;
81
89
  else {
@@ -87,6 +95,14 @@ export async function agent(argv) {
87
95
  console.error(m("give the prompt file", "informe o arquivo de prompt"));
88
96
  return 2;
89
97
  }
98
+ if (!ROUND_REASONS.includes(reason)) {
99
+ console.error(m(`--reason must be one of ${ROUND_REASONS.join("|")}`, `--reason deve ser ${ROUND_REASONS.join("|")}`));
100
+ return 2;
101
+ }
102
+ if (reason !== "review" && !resume) {
103
+ console.error(m("--reason only applies to a follow-up (-R)", "--reason só vale para um follow-up (-R)"));
104
+ return 2;
105
+ }
90
106
  for (const [f, val] of [["t", t], ["d", d], ["o", o]])
91
107
  if (!val) {
92
108
  console.error(`${m("missing", "faltou")} -${f}`);
@@ -148,7 +164,14 @@ export async function agent(argv) {
148
164
  }
149
165
  // Fix rounds: every -R on the same session counts, whatever the prefix is called. policy.max_rounds is enforced here,
150
166
  // not left to the conductor to remember (a 7-round fix loop is what burns the tokens). -F = the user said go on.
167
+ // Only --reason review counts; every follow-up is logged. Written at launch, so a later refusal costs no round.
168
+ let countRound;
151
169
  if (resume) {
170
+ const lf = join(dirname(O), `.rounds-${resume}.log`);
171
+ const logRound = () => { mkdirSync(dirname(O), { recursive: true }); appendFileSync(lf, `${new Date().toISOString()} ${reason} ${basename(O)}\n`); };
172
+ countRound = logRound;
173
+ }
174
+ if (resume && reason === "review") {
152
175
  const max = Number(getPath(cfg, "policy.max_rounds") ?? 2), cf = join(dirname(O), `.rounds-${resume}`);
153
176
  let n = 0;
154
177
  try {
@@ -168,8 +191,8 @@ export async function agent(argv) {
168
191
  return 4;
169
192
  }
170
193
  }
171
- mkdirSync(dirname(O), { recursive: true });
172
- writeFileSync(cf, String(n + 1));
194
+ const logRound = countRound;
195
+ countRound = () => { logRound(); writeFileSync(cf, String(n + 1)); };
173
196
  }
174
197
  let P = resolve(prompt);
175
198
  if (!existsSync(repo)) {
@@ -182,17 +205,32 @@ export async function agent(argv) {
182
205
  }
183
206
  mkdirSync(dirname(O), { recursive: true });
184
207
  // -b <base>: attaches `git diff origin/<base>...HEAD` (review without running git; required for Antigravity).
208
+ // A base with no origin/<base> but naming a commit (a fix round's round-<n>.sha) attaches `<base>..HEAD`: only that round's delta.
209
+ // An agent that can't review a commit base (agents.<name>.base_commit) gets the branch base instead: the whole branch.
210
+ let scopeNote = "";
211
+ if (base && !baseCommit(def) && isCommitBase(repo, base)) {
212
+ const branch = baseBranch?.replace(/^origin\//, "") || git(repo, "symbolic-ref", "--short", "refs/remotes/origin/HEAD")?.replace(/^origin\//, "");
213
+ if (!branch) {
214
+ console.error(m(`${a} can't review -b ${base} (a commit) and no branch base is known: pass --base-branch <branch>`, `${a} não revisa -b ${base} (um commit) e a base da branch é desconhecida: informe --base-branch <branch>`));
215
+ return 2;
216
+ }
217
+ scopeNote = m(`${a} can't review a commit base (agents.${a}.base_commit = false): reviewing against origin/${branch} instead. The review covers the whole branch, not just the round delta.`, `${a} não revisa uma base que é commit (agents.${a}.base_commit = false): revisando contra origin/${branch}. A revisão cobre a branch inteira, não só o delta da rodada.`);
218
+ console.log(`[orq agent] ${scopeNote}`);
219
+ base = branch;
220
+ }
185
221
  if (base) {
186
- const r = spawnSync("git", ["-C", repo, "diff", `origin/${base}...HEAD`], { encoding: "utf8", maxBuffer: 1 << 30 });
222
+ const range = isCommitBase(repo, base) ? `${base}..HEAD` : `origin/${base}...HEAD`;
223
+ const r = spawnSync("git", ["-C", repo, "diff", range], { encoding: "utf8", maxBuffer: 1 << 30 });
187
224
  if (r.status !== 0) {
188
- console.error(m(`could not build the diff against origin/${base}`, `não consegui gerar o diff contra origin/${base}`));
225
+ console.error(m(`could not build the diff ${range}`, `não consegui gerar o diff ${range}`));
189
226
  return 2;
190
227
  }
191
228
  writeFileSync(`${O}.diff`, r.stdout);
192
229
  inc.push(`${O}.diff`);
193
230
  }
194
- if (inc.length) {
195
- let text = readFileSync(P, "utf8");
231
+ const steps = stepIns(def, ro, git(repo, "rev-parse", "--git-dir") !== undefined), note = stepInNote(steps);
232
+ if (inc.length || note) {
233
+ let text = readFileSync(P, "utf8") + note;
196
234
  for (const f of inc)
197
235
  text += `\n--- ${basename(f)} ---\n${readFileSync(f, "utf8")}\n--- end of ${basename(f)} ---\n`;
198
236
  writeFileSync(`${O}.prompt.md`, text);
@@ -205,12 +243,19 @@ export async function agent(argv) {
205
243
  ackPrefix(O); // a follow-up on the issue: its failure notices were seen
206
244
  for (const s of [".done", ".out.md"])
207
245
  rmSync(O + s, { force: true });
246
+ countRound?.();
208
247
  typeInto(open, readFileSync(P, "utf8"));
209
248
  console.log(`[orq agent] ${t} → ${open} ${m("(follow-up typed into the open screen)", "(follow-up digitado na tela aberta)")}`);
210
249
  return 0;
211
250
  }
212
251
  for (const s of [".done", ".out.md", ".pid"])
213
252
  rmSync(O + s, { force: true });
253
+ // .id is the session this prefix's screen answers for (`--turn codex` drops other threads): an id left by an earlier
254
+ // run on the prefix (another agent, another session) would drop every turn of this one.
255
+ if (resume)
256
+ writeFileSync(`${O}.id`, `${resume}\n`);
257
+ else
258
+ rmSync(`${O}.id`, { force: true });
214
259
  const throwaway = useThrowaway(def, ro, Boolean(resume), git(repo, "rev-parse", "--git-dir") !== undefined);
215
260
  const dir = throwaway ? `${repo}.review-${process.pid}` : repo;
216
261
  const gd = git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir");
@@ -253,6 +298,12 @@ export async function agent(argv) {
253
298
  const cmd = expandTemplate(tpl, vars);
254
299
  if (!tpl.includes("{mode}") && !resume && !tui)
255
300
  cmd.push(...vars.mode);
301
+ // A prompt passed as an argument ({prompt_text}) can be over what the OS takes: refuse now, not with E2BIG in the tab.
302
+ const tooBig = argvTooBig(cmd);
303
+ if (tooBig) {
304
+ console.error(m(`prompt too big for ${a}: ${tooBig}. Attach less with -i (e.g. only the round's diff; the agent can run git diff itself) or use an agent that reads the prompt from stdin or a file. Nothing started.`, `prompt grande demais para ${a}: ${tooBig}. Anexe menos com -i (ex.: só o diff da rodada; o agente pode rodar git diff) ou use um agente que lê o prompt por stdin ou arquivo. Nada foi iniciado.`));
305
+ return 2;
306
+ }
256
307
  const env = {};
257
308
  for (const [k, val] of Object.entries(def.env ?? {}))
258
309
  env[k] = expandString(String(val), vars);
@@ -274,10 +325,13 @@ export async function agent(argv) {
274
325
  swap: parseSize(mach.agent_swap_max), mode: mach.mem_enforce ?? "auto",
275
326
  throwaway, promptInDir: Boolean(def.prompt_in_dir), closeAfter,
276
327
  keep: process.env.HERDR_KEEP_TAB === "1" || process.env.ORQ_KEEP_TAB === "1" || closeAfter <= 0, tui,
277
- okExit: def.ok_exit, filter: def.filter, noReview: def.no_review, ro,
328
+ okExit: def.ok_exit, filter: def.filter, noReview: def.no_review, ro, orqCommits: steps.commit, orqFetches: steps.fetch, scopeNote,
329
+ // A review (-r, prefix review*) must end with a verdict; policy.review_verdict = false turns the check off.
330
+ review: ro && /^review/i.test(basename(O)) && getPath(cfg, "policy.review_verdict") !== false,
278
331
  idleCloseMin: tui ? Number(mach.tui_idle_close_min ?? 30) : undefined,
279
332
  };
280
333
  writeAtomic(`${O}.job.json`, JSON.stringify(job, null, 1));
334
+ countRound?.();
281
335
  // Readable copy of the command (also what orq stats and herdr-board read).
282
336
  writeFileSync(`${O}.run.sh`, `#!/usr/bin/env bash\n# orq agent (generated; the run itself is \`orq agent --run ${O}.job.json\`) --unit=${unit} \n` +
283
337
  `cd ${shq(dir)}\n${Object.entries(env).map(([k, val]) => `${k}=${shq(val)} `).join("")}${cmd.map(shq).join(" ")}${job.stdin ? ` < ${shq(P)}` : ""}\n`);
@@ -342,6 +396,12 @@ function turn(file, kind, rest) {
342
396
  return 0;
343
397
  answer = String(e["last-assistant-message"] ?? "");
344
398
  sid = e["thread-id"];
399
+ // Codex also runs internal turns (e.g. generating the tab title) and notifies for them: only the main thread counts.
400
+ const known = existsSync(`${O}.id`) ? readFileSync(`${O}.id`, "utf8").trim() : "";
401
+ if (known && sid && sid !== known)
402
+ return 0;
403
+ if (isTitleOnly(answer))
404
+ return 0;
345
405
  }
346
406
  else if (kind === "claude") {
347
407
  const e = JSON.parse(readFileSync(0, "utf8"));
@@ -363,19 +423,30 @@ function turn(file, kind, rest) {
363
423
  answer = readFileSync(0, "utf8");
364
424
  }
365
425
  writeFileSync(`${O}.out.md`, answer);
426
+ appendFileSync(`${O}.turns`, `${new Date().toISOString()}\n`); // stats: work time ends at the first turn
366
427
  if (sid)
367
428
  writeFileSync(`${O}.id`, `${sid}\n`);
368
- const noReview = noReviewMatch(j.noReview, answer);
369
- const st = /\S/.test(answer) && !noReview ? "ok" : "fail";
370
- writeAtomic(`${O}.done`, `status=${st} rc=0 at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
429
+ const noReview = noReviewMatch(j.noReview, answer), noVerdict = Boolean(j.review) && /\S/.test(answer) && !hasVerdict(answer);
430
+ const st = /\S/.test(answer) && !noReview && !noVerdict ? "ok" : "fail", why = noVerdict ? " reason=no-verdict" : "";
431
+ writeAtomic(`${O}.done`, `status=${st} rc=0${why} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
371
432
  if (j.parent)
372
- notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=0 answer: ${O}.out.md`);
433
+ notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=0${why} answer: ${O}.out.md`);
373
434
  }
374
435
  catch (e) {
375
436
  console.error(`orq agent --turn: ${e.message}`);
376
437
  }
377
438
  return 0;
378
439
  }
440
+ /** The `{"title": "..."}` answer of Codex's session-title turn (a JSON object whose only key is `title`). */
441
+ function isTitleOnly(answer) {
442
+ try {
443
+ const o = JSON.parse(answer);
444
+ return typeof o === "object" && o !== null && !Array.isArray(o) && Object.keys(o).length === 1 && "title" in o;
445
+ }
446
+ catch {
447
+ return false;
448
+ }
449
+ }
379
450
  /** The last text Claude wrote, from its transcript (older Stop hooks have no last_assistant_message). */
380
451
  function lastClaudeText(transcript) {
381
452
  let text = "";
@@ -427,6 +498,29 @@ export function idleExpired(done, idleMs, now = Date.now(), busyAt = 0) {
427
498
  return false;
428
499
  }
429
500
  }
501
+ /** A -T turn ended without its hook: the screen was seen busy (`busyAt`) after the last .done (if any), and has been
502
+ * idle since `idleSince` for `graceMs`. All times epoch ms; 0 = never seen. */
503
+ export function missedTurn(done, busyAt, idleSince, graceMs, now = Date.now()) {
504
+ if (!busyAt || !idleSince || idleSince < busyAt || now - idleSince < graceMs)
505
+ return false;
506
+ try {
507
+ return statSync(done).mtimeMs < busyAt;
508
+ }
509
+ catch {
510
+ return true;
511
+ }
512
+ }
513
+ const NO_HOOK_GRACE_MS = 60_000;
514
+ /** Signals a -T turn whose end-of-turn hook never fired: .done (status=fail reason=no-hook) and the done line, with the
515
+ * screen's last lines as the answer, so the conductor isn't left waiting. A late hook still overwrites both. */
516
+ function noHook(j, pane) {
517
+ const O = j.prefix;
518
+ const r = spawnSync("herdr", ["pane", "read", pane, "--lines", "80", "--format", "text"], { encoding: "utf8", timeout: 5_000 });
519
+ writeFileSync(`${O}.out.md`, `${m("The agent's end-of-turn hook did not fire; its turn looks finished (the screen went idle). Last lines of the screen:", "O hook de fim de turno do agente não disparou; o turno parece terminado (a tela ficou ociosa). Últimas linhas da tela:")}\n\n${r.status === 0 ? r.stdout : ""}`);
520
+ writeAtomic(`${O}.done`, `status=fail rc=0 reason=no-hook at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
521
+ if (j.parent)
522
+ notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=0 reason=no-hook answer: ${O}.out.md`);
523
+ }
430
524
  /** herdr's view of the agent on a pane: working | blocked | idle | done | unknown; undefined when it can't be read. */
431
525
  function paneStatus(pane) {
432
526
  const r = spawnSync("herdr", ["pane", "get", pane], { encoding: "utf8", timeout: 5_000 });
@@ -453,6 +547,30 @@ function lines(s, each) {
453
547
  s.on("error", () => res());
454
548
  });
455
549
  }
550
+ /** The answer of a run whose command never started: what failed and what to do, for the conductor. */
551
+ export const spawnFailText = (j, e) => `orq agent: ${j.agent} could not start (${e.code ?? "error"}): ${e.message}\n` +
552
+ (e.code === "E2BIG" ? "The prompt (with its -i attachments) is too big to pass as an argument: attach less (e.g. the round's diff only) and let the agent read the rest with git.\n"
553
+ : e.code === "ENOENT" ? `Command not found: ${j.argv[0]}. Is it installed for the current PATH (and Node version)?\n` : "");
554
+ /** Largest command line the OS takes: Linux caps each argument (MAX_ARG_STRLEN, 128 KiB), Windows the whole line. */
555
+ export function argvTooBig(argv, platform = process.platform) {
556
+ const kb = (n) => `${Math.round(n / 1024)} KB`;
557
+ if (platform === "win32") {
558
+ const n = argv.reduce((s, a) => s + a.length + 3, 0);
559
+ return n > 32_000 ? m(`the command line is ${kb(n)} (Windows takes ~32 KB)`, `a linha de comando tem ${kb(n)} (o Windows aceita ~32 KB)`) : undefined;
560
+ }
561
+ const big = Math.max(0, ...argv.map((a) => Buffer.byteLength(a)));
562
+ if (platform === "linux" && big >= 128 * 1024)
563
+ return m(`one argument is ${kb(big)} (Linux takes 128 KB per argument)`, `um argumento tem ${kb(big)} (o Linux aceita 128 KB por argumento)`);
564
+ const total = argv.reduce((s, a) => s + Buffer.byteLength(a) + 1, 0);
565
+ return total > 1_000_000 ? m(`the command line is ${kb(total)} (over ~1 MB)`, `a linha de comando tem ${kb(total)} (acima de ~1 MB)`) : undefined;
566
+ }
567
+ /** Commits everything in `dir` for an agent that can't; returns the line for its answer. */
568
+ function orqCommit(dir, msg) {
569
+ const add = spawnSync("git", ["-C", dir, "add", "-A"], { encoding: "utf8" });
570
+ const c = add.status === 0 ? spawnSync("git", ["-C", dir, "commit", "-q", "-m", msg], { encoding: "utf8" }) : add;
571
+ return c.status === 0 ? `${git(dir, "rev-parse", "--short", "HEAD")} ${msg}`
572
+ : `FAILED (exit ${c.status}): ${(c.stderr || c.stdout).trim()}. The changes are still in the worktree.`;
573
+ }
456
574
  async function runJob(file) {
457
575
  const j = JSON.parse(readFileSync(file, "utf8")), O = j.prefix;
458
576
  writeFileSync(`${O}.pid`, String(process.pid));
@@ -477,6 +595,8 @@ async function runJob(file) {
477
595
  }
478
596
  catch { /* none */ }
479
597
  }
598
+ if (j.orqFetches && spawnSync("git", ["-C", j.dir, "fetch", "-q", "origin"], { stdio: "inherit" }).status !== 0)
599
+ console.error(m("[orq agent] git fetch origin failed; the agent sees the local origin/* refs", "[orq agent] git fetch origin falhou; o agente vê as refs origin/* locais"));
480
600
  if (j.promptInDir) {
481
601
  copyFileSync(j.prompt, join(j.dir, PF));
482
602
  const ex = git(j.dir, "rev-parse", "--git-path", "info/exclude");
@@ -495,15 +615,18 @@ async function runJob(file) {
495
615
  }
496
616
  return true;
497
617
  };
498
- const post = () => {
618
+ let committed = "";
619
+ const post = (answer = "") => {
499
620
  if (j.promptInDir)
500
621
  rmSync(join(j.dir, PF), { force: true });
501
622
  if (j.throwaway)
502
623
  dropWorktree(j.repo, j.dir);
624
+ else if (j.orqCommits && existsSync(j.dir) && git(j.dir, "status", "--porcelain"))
625
+ committed = orqCommit(j.dir, commitMessage(answer, j.title));
503
626
  };
504
627
  if (j.tui) {
505
628
  // The agent's own screen: the terminal is its; answers come from the end-of-turn hook (`--turn`).
506
- let ended = false;
629
+ let ended = false, tuiErr;
507
630
  const end = (code, killed = false) => {
508
631
  if (ended)
509
632
  return "fail";
@@ -518,9 +641,10 @@ async function runJob(file) {
518
641
  notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=killed rc=${code} answer: ${O}.out.md`);
519
642
  }
520
643
  else if (!existsSync(`${O}.done`)) {
521
- writeAtomic(`${O}.done`, `status=fail rc=${code} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
644
+ const why = tuiErr ? ` reason=spawn:${tuiErr.code ?? "error"}` : "";
645
+ writeAtomic(`${O}.done`, `status=fail rc=${code}${why} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
522
646
  if (j.parent)
523
- notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=${code} answer: ${O}.out.md`);
647
+ notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=${code}${why} answer: ${O}.out.md`);
524
648
  }
525
649
  rmSync(`${O}.pid`, { force: true });
526
650
  return killed ? "killed" : ok ? "ok" : "fail";
@@ -541,32 +665,40 @@ async function runJob(file) {
541
665
  for (const sig of ["SIGHUP", "SIGTERM"])
542
666
  process.on(sig, gone);
543
667
  rc = await runCapped(j.argv[0], j.argv.slice(1), { cap: j.cap, swap: j.swap, mode: j.mode, unit: j.unit, cwd: j.dir, env, quote: true, tty: true,
668
+ onError: (e) => { tuiErr = e; writeFileSync(`${O}.out.md`, spawnFailText(j, e)); },
544
669
  onSpawn: (c) => {
545
670
  child = c.pid;
546
671
  // A finished agent waits for a follow-up forever and holds its memory: after the idle timeout without a new
547
672
  // turn, kill it; the normal end below cleans up and closes the tab. "New turn" = .done removed (`-R` typed
548
673
  // into the screen removes it) or rewritten (the next end of turn), so idle = .done untouched that long.
549
- const idleMs = (j.idleCloseMin ?? 0) * 60_000;
550
- if (idleMs > 0 && !j.keep) {
551
- // On herdr the pane's agent status also catches a turn typed straight into the screen (.done only changes
552
- // when it ends). Elsewhere (tmux) only .done tells: a turn typed directly into the screen that runs longer
553
- // than the timeout can still be closed.
554
- let busyAt = 0;
555
- const pane = j.host === "herdr" ? process.env.HERDR_PANE_ID : undefined;
556
- idleT = setInterval(() => {
557
- const st = pane ? paneStatus(pane) : undefined;
558
- if (st === "working" || st === "blocked")
559
- busyAt = Date.now();
560
- if (!idleExpired(`${O}.done`, idleMs, Date.now(), busyAt))
561
- return;
562
- clearInterval(idleT);
563
- console.log(m(`[orq agent] no follow-up for ${j.idleCloseMin} min: closing the finished agent`, `[orq agent] sem follow-up há ${j.idleCloseMin} min: fechando o agente que terminou`));
564
- idleClosed = true;
565
- if (child)
566
- killTree(child, false);
567
- }, Math.min(30_000, Math.max(1_000, idleMs / 2)));
568
- idleT.unref();
569
- }
674
+ const idleMs = (j.idleCloseMin && !j.keep ? j.idleCloseMin : 0) * 60_000;
675
+ // On herdr the pane's agent status also catches a turn typed straight into the screen (.done only changes
676
+ // when it ends), and a turn whose hook never fired (busy, then idle, .done not rewritten): that one is
677
+ // signalled from here, flagged reason=no-hook, with the screen's tail as the answer. Elsewhere (tmux) only
678
+ // .done tells: a turn typed directly into the screen that runs longer than the timeout can still be closed.
679
+ const pane = j.host === "herdr" ? process.env.HERDR_PANE_ID : undefined;
680
+ if (!pane && idleMs <= 0)
681
+ return;
682
+ let busyAt = 0, idleSince = 0;
683
+ idleT = setInterval(() => {
684
+ const st = pane ? paneStatus(pane) : undefined, now = Date.now();
685
+ if (st === "working" || st === "blocked") {
686
+ busyAt = now;
687
+ idleSince = 0;
688
+ }
689
+ else if (st === "idle" || st === "done")
690
+ idleSince ||= now;
691
+ if (pane && missedTurn(`${O}.done`, busyAt, idleSince, NO_HOOK_GRACE_MS, now))
692
+ noHook(j, pane);
693
+ if (!idleExpired(`${O}.done`, idleMs, now, busyAt))
694
+ return;
695
+ clearInterval(idleT);
696
+ console.log(m(`[orq agent] no follow-up for ${j.idleCloseMin} min: closing the finished agent`, `[orq agent] sem follow-up há ${j.idleCloseMin} min: fechando o agente que terminou`));
697
+ idleClosed = true;
698
+ if (child)
699
+ killTree(child, false);
700
+ }, pane ? 15_000 : Math.min(30_000, Math.max(1_000, idleMs / 2)));
701
+ idleT.unref();
570
702
  } });
571
703
  clearInterval(idleT);
572
704
  if (idleClosed)
@@ -582,6 +714,7 @@ async function runJob(file) {
582
714
  return 0;
583
715
  }
584
716
  const log = createWriteStream(`${O}.log`);
717
+ let spawnErr;
585
718
  if (!pre())
586
719
  rc = 2;
587
720
  else {
@@ -610,6 +743,7 @@ async function runJob(file) {
610
743
  rc = await runCapped(j.argv[0], j.argv.slice(1), {
611
744
  cap: j.cap, swap: j.swap, mode: j.mode, unit: j.unit, cwd: j.dir, env, quote: true,
612
745
  stdio: [j.stdin ? openSync(j.prompt, "r") : "ignore", "pipe", "pipe"],
746
+ onError: (e) => { spawnErr = e; log.write(`[orq agent] could not start ${j.argv[0]}: ${e.message}\n`); },
613
747
  onSpawn: (c) => {
614
748
  streams = Promise.all([lines(c.stdout, (l) => onLine(l, true)), lines(c.stderr, (l) => onLine(l, false))]);
615
749
  // OpenCode sometimes doesn't exit after its final event (helpers hold the pipe): stop it after 30 s of quiet.
@@ -633,8 +767,13 @@ async function runJob(file) {
633
767
  await Promise.race([streams, sleep(5000)]);
634
768
  }
635
769
  await new Promise((r) => log.end(r));
636
- post();
637
770
  const res = fmt.finish(logText, stdout);
771
+ post(j.answer === "file" ? (existsSync(`${O}.out.md`) ? readFileSync(`${O}.out.md`, "utf8") : "") : res.answer ?? "");
772
+ if (committed)
773
+ res.answer = `${res.answer ?? ""}\n## Commit (by orq)\n${committed}\n`;
774
+ const scope = j.scopeNote ? `\n## Review scope (by orq)\n${j.scopeNote}\n` : "";
775
+ if (scope)
776
+ res.answer = `${res.answer ?? ""}${scope}`;
638
777
  // Denied commands do not sink a write run that did work: keep the diff and report ok with the denied list in the answer.
639
778
  let recovered = false;
640
779
  if (res.denied?.length && !j.ro && !j.throwaway && existsSync(j.dir)) {
@@ -647,8 +786,26 @@ async function runJob(file) {
647
786
  else
648
787
  res.answer = `${res.answer ?? ""}\nNo changes were made in the worktree.\n`;
649
788
  }
789
+ // Out of quota: not a code failure. Learn the model's bucket (orq quota / pick see it) and say why, so the conductor re-picks.
790
+ if (res.exhausted) {
791
+ const model = argvModel(j.argv);
792
+ let until = "";
793
+ if (res.exhausted.secs !== undefined) {
794
+ const at = Date.now() + res.exhausted.secs * 1000;
795
+ if (model)
796
+ learnExhausted("antigravity", model, res.exhausted.secs, agyToken()?.acct);
797
+ until = ` ${m("until", "até")} ${humanReset(Math.floor(at / 1000))} (${new Date(at).toISOString()})`;
798
+ }
799
+ res.answer = `quota exhausted: ${model ?? j.agent}${until}\n\n${res.exhausted.message}\n`;
800
+ res.rc = res.rc || 3;
801
+ }
650
802
  if (j.answer !== "file")
651
803
  writeFileSync(`${O}.out.md`, res.answer ?? "");
804
+ else if (committed || scope)
805
+ appendFileSync(`${O}.out.md`, `${committed ? `\n## Commit (by orq)\n${committed}\n` : ""}${scope}`);
806
+ // The agent never started: say why in the answer (the conductor reads it) instead of leaving it empty.
807
+ if (spawnErr)
808
+ writeFileSync(`${O}.out.md`, spawnFailText(j, spawnErr));
652
809
  const sid = j.fromLog ? new RegExp(j.fromLog, "m").exec(logText)?.[1] : res.session ?? j.session;
653
810
  if (sid)
654
811
  writeFileSync(`${O}.id`, `${sid}\n`);
@@ -674,12 +831,14 @@ async function runJob(file) {
674
831
  // fallback and the conductor must see it as not run, same as a whitespace-only answer.
675
832
  const noReview = noReviewMatch(j.noReview, answer);
676
833
  // killed (SIGKILL, memory cap...) is its own status: never ok, even if an ok_exit list or a leftover answer says otherwise.
677
- const st = killedRc(rc) ? "killed" : verdict && !noReview && /\S/.test(answer) ? "ok" : "fail"; // whitespace-only answer = failure
678
- writeAtomic(`${O}.done`, `status=${st} rc=${rc} at=${new Date().toISOString()} agent=${j.agent}\n`);
834
+ const noVerdict = !spawnErr && Boolean(j.review) && verdict && !noReview && /\S/.test(answer) && !hasVerdict(answer);
835
+ const st = spawnErr ? "fail" : killedRc(rc) ? "killed" : verdict && !noReview && !noVerdict && /\S/.test(answer) ? "ok" : "fail"; // whitespace-only answer = failure
836
+ const why = spawnErr ? ` reason=spawn:${spawnErr.code ?? "error"}` : noVerdict ? " reason=no-verdict" : "";
837
+ writeAtomic(`${O}.done`, `status=${st} rc=${rc}${why} at=${new Date().toISOString()} agent=${j.agent}\n`);
679
838
  rmSync(`${O}.pid`, { force: true });
680
839
  state({ status: st, rc, end: new Date().toISOString() });
681
840
  if (j.parent)
682
- notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=${rc} answer: ${O}.out.md`);
841
+ notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=${rc}${why} answer: ${O}.out.md`);
683
842
  console.log(`\n[${j.agent} ${m("finished", "terminou")}: exit ${rc} status=${st}] ${m("answer", "resposta")}: ${O}.out.md`);
684
843
  if (j.host !== "plain" && !j.keep) {
685
844
  await sleep(j.closeAfter * 1000);
@@ -466,7 +466,7 @@ export function render(s, theme, cols, mode = colorMode(), hideH = HIDE_H) {
466
466
  if (top.length)
467
467
  out.push(` ${c.mut}${m("role", "papel").padEnd(7)} ${m("agent", "agente").padEnd(12)} ${m("model", "modelo").padEnd(20)} ${"n".padStart(4)} ${"ok%".padStart(5)} ${m("approved%", "aprov%").padStart(10)} ${"min".padStart(6)}${c.r}`);
468
468
  for (const x of top)
469
- out.push(` ${c.ink2}${x.role.padEnd(7)}${c.r} ${c.ink}${fit(x.agent, 12)}${c.r} ${c.mut}${fit(x.model, 20)}${c.r} ${c.ink}${String(x.n).padStart(4)} ${String(x.ok).padStart(5)} ${(x.approved === null ? "-" : `${x.approved}%`).padStart(10)} ${String(x.min).padStart(6)}${c.r}`);
469
+ out.push(` ${c.ink2}${x.role.padEnd(7)}${c.r} ${c.ink}${fit(x.agent, 12)}${c.r} ${c.mut}${fit(x.model, 20)}${c.r} ${c.ink}${String(x.n).padStart(4)} ${String(x.ok).padStart(5)} ${(x.approved === null ? "-" : `${x.approved}%`).padStart(10)} ${String(x.min ?? "-").padStart(6)}${c.r}`);
470
470
  if (!top.length)
471
471
  out.push(` ${c.mut}${m("no runs yet", "nenhuma execução ainda")}${c.r}`);
472
472
  out.push("", `${c.rul}${"─".repeat(W - 2)}${c.r}`, ` ${c.mut}${m("q quit · r quota · l language · t theme", "q sai · r cota · l idioma · t tema")}${c.r}`);
@@ -15,6 +15,12 @@ import { acquire, release } from "../lock.js";
15
15
  import { handoffFile } from "../notice.js";
16
16
  export const USAGE = `orq claim [-w <workspace>] <ID> | -f <ID> | -r <ID> | -l | --register issue owner among conductors (exit 1 = someone else's)
17
17
  orq claim [-w <workspace>] --from <name> [-f] a conductor's issues by its name (part of it is enough); -f takes them all`;
18
+ /** The Claude session name of a conductor of `ws` (what ListAgents shows): conductor-<workspace folder>, plus
19
+ * :<name> (its herdr label or ORQ_CONDUCTOR_ID, slugged) so several conductors on one workspace can be told apart. */
20
+ export function sessionName(ws, name = "") {
21
+ const slug = name.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
22
+ return `conductor-${basename(ws)}${slug ? `:${slug}` : ""}`;
23
+ }
18
24
  const field = (text, k) => new RegExp(`${k}=(\\S+)`).exec(text)?.[1] ?? "";
19
25
  export const nameOf = (text) => / name=(.*)$/m.exec(text)?.[1].trim() ?? "";
20
26
  const herdrList = (args) => {
@@ -127,7 +133,8 @@ export async function claim(argv) {
127
133
  // The herdr workspace's current label while the pane is alive; the name stored at claim time otherwise
128
134
  // (the user may have renamed the workspace since, or it may not be a herdr conductor at all).
129
135
  const n = (alive && (field(t, "kind") || "herdr") === "herdr" ? names.get(field(t, "pane")) ?? "" : "") || nameOf(t);
130
- console.log(`${d} ${n ? `"${n}" ` : ""}${t.replace(/ name=.*$/, "")} (${alive ? "alive" : "dead"})`);
136
+ console.log(`${d} ${n ? `"${n}" ` : ""}${t.replace(/ name=.*$/, "")} (${alive ? "alive" : "dead"})`
137
+ + ` session=${sessionName(ws, n)}`);
131
138
  }
132
139
  return 0;
133
140
  }
@@ -2,14 +2,14 @@
2
2
  // instructions, the chosen account and model (port of setup/herdr-conductor, for any terminal and OS).
3
3
  // orq conductor [account|-] [model] [-r <session>]
4
4
  // Workspace: the current folder if it has .orquestra-conductor.md, else $CONDUCTOR_WS. $CONDUCTOR_FILE overrides the
5
- // instructions file. The session is named conductor-<workspace folder>.
5
+ // instructions file. The session is named conductor-<workspace folder>:<herdr label or ORQ_CONDUCTOR_ID> (see sessionName).
6
6
  import { spawnSync } from "node:child_process";
7
7
  import { existsSync } from "node:fs";
8
- import { basename, join, resolve } from "node:path";
8
+ import { join, resolve } from "node:path";
9
9
  import { claudeAccounts } from "../accounts.js";
10
10
  import { m } from "../i18n.js";
11
11
  import { runCapped } from "../process.js";
12
- import { claim } from "./claim.js";
12
+ import { claim, herdrName, sessionName } from "./claim.js";
13
13
  export const USAGE = `orq conductor [account|-] [model] [-r <session>] open the conductor (Claude) here: instructions, account, model
14
14
  model: sonnet | opus | haiku | a full id; "-" as account = default account; -r resumes that conversation`;
15
15
  export const MODELS = {
@@ -48,8 +48,8 @@ export function findWorkspace(cwd, env, exists = existsSync) {
48
48
  }
49
49
  export const instructionsFile = (ws, env) => env.CONDUCTOR_FILE ? resolve(env.CONDUCTOR_FILE) : join(ws, ".orquestra-conductor.md");
50
50
  /** The claude command line (without the executable). The instructions go by file, so no shell ever sees their text. */
51
- export function claudeArgs(ws, a, file) {
52
- return [...(a.resume ? ["--resume", a.resume] : []), "-n", `conductor-${basename(ws)}`,
51
+ export function claudeArgs(ws, a, file, name = "") {
52
+ return [...(a.resume ? ["--resume", a.resume] : []), "-n", sessionName(ws, name),
53
53
  ...(a.model ? ["--model", modelId(a.model)] : []), "--append-system-prompt-file", file];
54
54
  }
55
55
  export async function conductor(argv) {
@@ -100,5 +100,6 @@ export async function conductor(argv) {
100
100
  delete process.env.CLAUDE_CONFIG_DIR;
101
101
  else
102
102
  process.env.CLAUDE_CONFIG_DIR = saved;
103
- return runCapped("claude", claudeArgs(ws, a, file), { mode: "off", env, cwd: ws, quote: true, tty: true });
103
+ const name = (process.env.HERDR_PANE_ID ? herdrName(process.env.HERDR_PANE_ID) : "") || process.env.ORQ_CONDUCTOR_ID || "";
104
+ return runCapped("claude", claudeArgs(ws, a, file, name), { mode: "off", env, cwd: ws, quote: true, tty: true });
104
105
  }
@@ -4,6 +4,7 @@ import { existsSync, readdirSync, readFileSync, realpathSync, rmSync } from "nod
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
5
5
  import { loadConfig } from "../config.js";
6
6
  import { m } from "../i18n.js";
7
+ import { issueFromName, issuePattern, issuesWithLiveAgent } from "../issue.js";
7
8
  import { alive } from "../lock.js";
8
9
  import { stateDir } from "../paths.js";
9
10
  import { aliveOwner } from "./claim.js";
@@ -69,10 +70,11 @@ export async function gc(argv, clock = Date.now) {
69
70
  return 2;
70
71
  }
71
72
  }
72
- const { cfg } = loadConfig(ws), rows = [];
73
+ const { cfg } = loadConfig(ws), rows = [], issuePat = issuePattern(ws);
73
74
  let ghUnavailable = false, fetchWarned = false;
74
75
  const root = join(ws, ".worktrees");
75
76
  const orq = join(ws, ".orquestra");
77
+ const liveAgents = issuesWithLiveAgent();
76
78
  const issues = existsSync(orq) ? readdirSync(orq).filter((x) => !x.startsWith(".")) : [];
77
79
  for (const repoName of readdirSync(ws, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)) {
78
80
  const repo = join(ws, repoName);
@@ -102,8 +104,8 @@ export async function gc(argv, clock = Date.now) {
102
104
  const branch = wt.branch?.replace(/^refs\/heads\//, "") ?? "";
103
105
  if (!branch)
104
106
  continue;
105
- // The branch carries the ID in lowercase (plane-abc-123); .orquestra/ keeps it as written (ABC-123).
106
- const id = /^.*?plane-(.+)$/.exec(branch)?.[1];
107
+ // The branch carries the ID in lowercase (abc-123, maybe with a prefix or -suffix); .orquestra/ keeps it as written (ABC-123).
108
+ const id = issueFromName(branch, issuePat);
107
109
  const issue = id && issues.find((d) => d.toLowerCase() === id.toLowerCase());
108
110
  const status = output(path, "status", "--porcelain");
109
111
  const unpushed = output(repo, "rev-list", branch, "--not", "--remotes");
@@ -117,14 +119,10 @@ export async function gc(argv, clock = Date.now) {
117
119
  verdict = "KEEP";
118
120
  reason = m("issue has a live owner", "issue tem um responsável ativo");
119
121
  }
120
- // A .pane/.id without a same-stem .done is treated as a running child.
121
- const issueDir = issue && join(ws, ".orquestra", issue);
122
- if (verdict !== "KEEP" && issueDir && existsSync(issueDir)) {
123
- const files = readdirSync(issueDir), running = files.some((f) => /\.(pane|id)$/.test(f) && !existsSync(join(issueDir, `${f.replace(/\.(pane|id)$/, "")}.done`)));
124
- if (running) {
125
- verdict = "KEEP";
126
- reason = m("child agent may still be running", "um agente filho pode estar em execução");
127
- }
122
+ // A child agent of the issue whose state says running and whose pid is alive (the board's rule).
123
+ if (verdict !== "KEEP" && issue && liveAgents.has(issue)) {
124
+ verdict = "KEEP";
125
+ reason = m("child agent may still be running", "um agente filho pode estar em execução");
128
126
  }
129
127
  let merged = false;
130
128
  if (verdict !== "KEEP") {