@irythill/orq 0.5.0 → 0.6.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.
@@ -10,7 +10,7 @@
10
10
  // the follow-up into it; with the screen closed it reopens the session (tui_resume).
11
11
  import { spawnSync } from "node:child_process";
12
12
  import { randomUUID } from "node:crypto";
13
- import { appendFileSync, copyFileSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
13
+ import { appendFileSync, copyFileSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
14
14
  import { basename, dirname, join, resolve } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { claudeAccounts } from "../accounts.js";
@@ -21,7 +21,7 @@ import { writeAtomic } from "../fsutil.js";
21
21
  import { m } from "../i18n.js";
22
22
  import { alive } from "../lock.js";
23
23
  import { stateDir } from "../paths.js";
24
- import { killTree, parseSize, resolveMode, runCapped } from "../process.js";
24
+ import { killedRc, killTree, parseSize, resolveMode, runCapped } from "../process.js";
25
25
  import { closeOwnTab, detectHost, launch, screenOpen, shq, typeInto } from "../terminal.js";
26
26
  import { ackPrefix } from "../notice.js";
27
27
  import { dropWorktree, notify } from "../testrun.js";
@@ -130,6 +130,22 @@ export async function agent(argv) {
130
130
  return 2;
131
131
  }
132
132
  const repo = resolve(d), O = resolve(o);
133
+ // A live run on this prefix owns its files: a second agent on the same files (and its .done) is how a false DONE happens.
134
+ // Only a -T -R follow-up typed into that run's own open screen may reuse it.
135
+ let open = "", pid = 0;
136
+ try {
137
+ open = readFileSync(`${O}.pane`, "utf8").trim();
138
+ }
139
+ catch { /* first run */ }
140
+ try {
141
+ pid = Number(readFileSync(`${O}.pid`, "utf8"));
142
+ }
143
+ catch { /* none or ended */ }
144
+ const typed = tui && resume && alive(pid) && screenOpen(open);
145
+ if (!typed && runLive(O, pid)) {
146
+ console.error(m(`${basename(O)} still has a live run (${pid ? `pid ${pid}` : "unit active"}): wait for it (orq wait ${O}) or stop it, or use another prefix. Nothing started.`, `${basename(O)} ainda tem uma execução viva (${pid ? `pid ${pid}` : "unit ativa"}): espere (orq wait ${O}) ou pare, ou use outro prefixo. Nada foi iniciado.`));
147
+ return 3;
148
+ }
133
149
  // Fix rounds: every -R on the same session counts, whatever the prefix is called. policy.max_rounds is enforced here,
134
150
  // not left to the conductor to remember (a 7-round fix loop is what burns the tokens). -F = the user said go on.
135
151
  if (resume) {
@@ -185,13 +201,7 @@ export async function agent(argv) {
185
201
  // Follow-ups (-R) reuse the prefix: remove the previous round's result so ".done exists" means "this one finished".
186
202
  // -T -R with the screen still open: the follow-up is typed into it (its runner, and .pid, carry on). Open = the agent
187
203
  // is still running there, not just the pane: after it exits the pane may hold a shell, which must never get the text.
188
- let open = "", pid = 0;
189
- try {
190
- open = readFileSync(`${O}.pane`, "utf8").trim();
191
- pid = Number(readFileSync(`${O}.pid`, "utf8"));
192
- }
193
- catch { /* first run or ended */ }
194
- if (tui && resume && alive(pid) && screenOpen(open)) {
204
+ if (typed) {
195
205
  ackPrefix(O); // a follow-up on the issue: its failure notices were seen
196
206
  for (const s of [".done", ".out.md"])
197
207
  rmSync(O + s, { force: true });
@@ -264,7 +274,8 @@ export async function agent(argv) {
264
274
  swap: parseSize(mach.agent_swap_max), mode: mach.mem_enforce ?? "auto",
265
275
  throwaway, promptInDir: Boolean(def.prompt_in_dir), closeAfter,
266
276
  keep: process.env.HERDR_KEEP_TAB === "1" || process.env.ORQ_KEEP_TAB === "1" || closeAfter <= 0, tui,
267
- okExit: def.ok_exit, filter: def.filter, noReview: def.no_review,
277
+ okExit: def.ok_exit, filter: def.filter, noReview: def.no_review, ro,
278
+ idleCloseMin: tui ? Number(mach.tui_idle_close_min ?? 30) : undefined,
268
279
  };
269
280
  writeAtomic(`${O}.job.json`, JSON.stringify(job, null, 1));
270
281
  // Readable copy of the command (also what orq stats and herdr-board read).
@@ -276,6 +287,20 @@ export async function agent(argv) {
276
287
  console.log(`[orq agent] ${t} → ${where}${tui ? " (TUI)" : ""}`);
277
288
  return 0;
278
289
  }
290
+ /** The previous run on prefix `O` is still going: its runner pid is alive, or (no pid file yet / runner gone) its systemd scope is. */
291
+ function runLive(O, pid) {
292
+ if (alive(pid))
293
+ return true;
294
+ if (existsSync(`${O}.done`) || process.platform !== "linux")
295
+ return false; // finished (or no systemd): the pid said it all
296
+ try {
297
+ const unit = JSON.parse(readFileSync(`${O}.job.json`, "utf8")).unit;
298
+ return Boolean(unit) && spawnSync("systemctl", ["--user", "is-active", "--quiet", `${unit}.scope`], { timeout: 5_000 }).status === 0;
299
+ }
300
+ catch {
301
+ return false;
302
+ }
303
+ }
279
304
  /** OpenCode (v2) plugin for -T: when the main session's run ends, its last answer goes to `orq agent --turn`. */
280
305
  function opencodePlugin(O, hook) {
281
306
  return `// orq agent -T end of turn (generated)
@@ -392,6 +417,26 @@ function noReviewMatch(pattern, answer) {
392
417
  return re.test(first) || (answer.trim().length <= 200 && re.test(answer));
393
418
  }
394
419
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
420
+ /** A -T agent is idle when its turn ended (.done exists) and, for `idleMs`, nothing touched .done (a follow-up removes
421
+ * it, a turn's end rewrites it) and the screen was not seen busy (`busyAt`, epoch ms, from herdr's agent status). */
422
+ export function idleExpired(done, idleMs, now = Date.now(), busyAt = 0) {
423
+ try {
424
+ return idleMs > 0 && now - Math.max(statSync(done).mtimeMs, busyAt) >= idleMs;
425
+ }
426
+ catch {
427
+ return false;
428
+ }
429
+ }
430
+ /** herdr's view of the agent on a pane: working | blocked | idle | done | unknown; undefined when it can't be read. */
431
+ function paneStatus(pane) {
432
+ const r = spawnSync("herdr", ["pane", "get", pane], { encoding: "utf8", timeout: 5_000 });
433
+ try {
434
+ return r.status === 0 ? JSON.parse(r.stdout).result?.pane?.agent_status : undefined;
435
+ }
436
+ catch {
437
+ return undefined;
438
+ }
439
+ }
395
440
  /** Splits a stream into lines for `each`. */
396
441
  function lines(s, each) {
397
442
  return new Promise((res) => {
@@ -420,6 +465,8 @@ async function runJob(file) {
420
465
  state();
421
466
  const fmt = FORMATS[j.format] ?? FORMATS.text;
422
467
  let rc = 0, stdout = "", logText = "";
468
+ // State of the worktree before the run, to tell what this run changed (a -R on a dirty tree is not progress).
469
+ const headBefore = j.throwaway ? undefined : git(j.dir, "rev-parse", "HEAD"), statusBefore = j.throwaway ? undefined : git(j.dir, "status", "--porcelain");
423
470
  const pre = () => {
424
471
  if (j.throwaway) {
425
472
  // Throwaway worktree: detached copy of HEAD with the repo's node_modules linked; removed at the end.
@@ -457,20 +504,26 @@ async function runJob(file) {
457
504
  if (j.tui) {
458
505
  // The agent's own screen: the terminal is its; answers come from the end-of-turn hook (`--turn`).
459
506
  let ended = false;
460
- const end = (code) => {
507
+ const end = (code, killed = false) => {
461
508
  if (ended)
462
509
  return "fail";
463
510
  ended = true;
464
511
  post();
465
512
  // Closed before any turn ended (or mid follow-up): a failure, so `orq wait` doesn't hang.
466
- const ok = /\bstatus=ok\b/.test(existsSync(`${O}.done`) ? readFileSync(`${O}.done`, "utf8") : "");
467
- if (!existsSync(`${O}.done`)) {
513
+ const ok = !killed && /\bstatus=ok\b/.test(existsSync(`${O}.done`) ? readFileSync(`${O}.done`, "utf8") : "");
514
+ if (killed) {
515
+ // SIGKILL & co: whatever an earlier turn wrote, the screen did not end normally.
516
+ writeAtomic(`${O}.done`, `status=killed rc=${code} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
517
+ if (j.parent)
518
+ notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=killed rc=${code} answer: ${O}.out.md`);
519
+ }
520
+ else if (!existsSync(`${O}.done`)) {
468
521
  writeAtomic(`${O}.done`, `status=fail rc=${code} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
469
522
  if (j.parent)
470
523
  notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=${code} answer: ${O}.out.md`);
471
524
  }
472
525
  rmSync(`${O}.pid`, { force: true });
473
- return ok ? "ok" : "fail";
526
+ return killed ? "killed" : ok ? "ok" : "fail";
474
527
  };
475
528
  if (!pre())
476
529
  rc = 2;
@@ -480,16 +533,46 @@ async function runJob(file) {
480
533
  delete env[k];
481
534
  // Ctrl+C belongs to the agent (it reaches us too, as part of the terminal's foreground group). The screen going
482
535
  // away (tab or tmux server closed) still ends the agent and cleans up.
483
- let child;
536
+ let child, idleT, idleClosed = false;
484
537
  process.on("SIGINT", () => { });
538
+ // The screen going away or being killed from outside: end the agent, and close the tab too (a dead tab stayed open).
485
539
  const gone = () => { if (child)
486
- killTree(child, false); end(129); process.exit(0); };
540
+ killTree(child, false); end(129); closeOwnTab(j.host); process.exit(0); };
487
541
  for (const sig of ["SIGHUP", "SIGTERM"])
488
542
  process.on(sig, gone);
489
543
  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,
490
- onSpawn: (c) => { child = c.pid; } });
544
+ onSpawn: (c) => {
545
+ child = c.pid;
546
+ // A finished agent waits for a follow-up forever and holds its memory: after the idle timeout without a new
547
+ // turn, kill it; the normal end below cleans up and closes the tab. "New turn" = .done removed (`-R` typed
548
+ // 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
+ }
570
+ } });
571
+ clearInterval(idleT);
572
+ if (idleClosed)
573
+ rc = 0; // closed on purpose after a finished turn: not a kill
491
574
  }
492
- const st = end(rc);
575
+ const st = end(rc, killedRc(rc));
493
576
  state({ status: st, rc, end: new Date().toISOString() });
494
577
  console.log(`\n[${j.agent} (TUI) ${m("exited", "encerrado")}] ${m("answer", "resposta")}: ${O}.out.md`);
495
578
  if (!j.keep) {
@@ -552,12 +635,24 @@ async function runJob(file) {
552
635
  await new Promise((r) => log.end(r));
553
636
  post();
554
637
  const res = fmt.finish(logText, stdout);
638
+ // 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
+ let recovered = false;
640
+ if (res.denied?.length && !j.ro && !j.throwaway && existsSync(j.dir)) {
641
+ const status = git(j.dir, "status", "--porcelain"), head = git(j.dir, "rev-parse", "HEAD");
642
+ if (status !== statusBefore || head !== headBefore) {
643
+ const stat = (headBefore && git(j.dir, "diff", "--stat", headBefore)) || status || `HEAD ${headBefore?.slice(0, 7)} -> ${head?.slice(0, 7)}`;
644
+ recovered = true;
645
+ res.answer = `${res.answer ?? ""}\n## Worktree\nSome commands were denied, but the worktree changes are kept (review them before accepting):\n\`\`\`\n${stat}\n\`\`\`\n`;
646
+ }
647
+ else
648
+ res.answer = `${res.answer ?? ""}\nNo changes were made in the worktree.\n`;
649
+ }
555
650
  if (j.answer !== "file")
556
651
  writeFileSync(`${O}.out.md`, res.answer ?? "");
557
652
  const sid = j.fromLog ? new RegExp(j.fromLog, "m").exec(logText)?.[1] : res.session ?? j.session;
558
653
  if (sid)
559
654
  writeFileSync(`${O}.id`, `${sid}\n`);
560
- if (rc === 0 && res.rc)
655
+ if (rc === 0 && res.rc && !recovered)
561
656
  rc = res.rc;
562
657
  // A review tool exits non-zero on findings: an ok_exit code is a verdict (rc stays in .done), not a failure.
563
658
  let verdict = rc === 0 || (j.okExit ?? []).includes(rc);
@@ -578,7 +673,8 @@ async function runJob(file) {
578
673
  // A reviewer whose answer says nothing was reviewed (agents.<name>.no_review) is not a verdict either: pick's
579
674
  // fallback and the conductor must see it as not run, same as a whitespace-only answer.
580
675
  const noReview = noReviewMatch(j.noReview, answer);
581
- const st = verdict && !noReview && /\S/.test(answer) ? "ok" : "fail"; // whitespace-only answer = failure
676
+ // 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
582
678
  writeAtomic(`${O}.done`, `status=${st} rc=${rc} at=${new Date().toISOString()} agent=${j.agent}\n`);
583
679
  rmSync(`${O}.pid`, { force: true });
584
680
  state({ status: st, rc, end: new Date().toISOString() });
@@ -19,7 +19,7 @@ import { m, setLang } from "../i18n.js";
19
19
  import { argvModel } from "../agents.js";
20
20
  import { alive, status as lockStatus } from "../lock.js";
21
21
  import { expand, stateDir } from "../paths.js";
22
- import { processTableAsync, treeMem } from "../process.js";
22
+ import { killedRc, processTableAsync, treeMem, treePids } from "../process.js";
23
23
  import { collect } from "../quota.js";
24
24
  import { collectRounds, mark, roundsSig } from "../rounds.js";
25
25
  import { collectRuns, rows } from "../stats.js";
@@ -29,7 +29,7 @@ import { herdrNamesAsync, nameOf } from "./claim.js";
29
29
  import { setValue } from "./set.js";
30
30
  export const USAGE = "orq board [-w <workspace>]... [--once] [--json] [--interval S] live board: queue, agents, conductors, quota";
31
31
  const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
32
- const QTTL = 300_000, RECENT_MS = 30 * 60_000, ORPHAN_MS = 24 * 3600_000;
32
+ const QTTL = 300_000, HIDE_H = 6; // finished/dead rows older than ui.board_hide_hours (default 6) are folded into a count
33
33
  const read = (f) => { try {
34
34
  return readFileSync(f, "utf8");
35
35
  }
@@ -91,9 +91,47 @@ function tail(file, bytes = 16_384) {
91
91
  closeSync(fd);
92
92
  }
93
93
  }
94
+ // --- idle detection -----------------------------------------------------------------------------------------------
95
+ // An interactive (-T) agent that finished keeps its screen, and its pid, open: "running" for hours. Its tree's CPU time
96
+ // (/proc/<pid>/stat, utime+stime) barely moves while it only waits. Samples persist in <state>/board/cpu.json so a
97
+ // one-shot board and a restarted one see the same history: per unit, the CPU reading when it last did real work.
98
+ const IDLE_MIN = 30, ACTIVE_TICKS = 200; // 2 s of CPU (at 100 ticks/s) since the baseline counts as work
99
+ /** utime+stime, in clock ticks, summed over `pids` (Linux; undefined elsewhere or when nothing is readable). */
100
+ export function cpuTicks(pids) {
101
+ if (process.platform !== "linux")
102
+ return undefined;
103
+ let sum;
104
+ for (const p of pids) {
105
+ // comm (field 2) may hold spaces and parens: fields resume after the last ")"; state is field 3, utime/stime 14/15.
106
+ const raw = read(`/proc/${p}/stat`), f = raw.slice(raw.lastIndexOf(")") + 2).split(" ");
107
+ const t = Number(f[11]) + Number(f[12]);
108
+ if (!Number.isNaN(t))
109
+ sum = (sum ?? 0) + t;
110
+ }
111
+ return sum;
112
+ }
113
+ /** Seconds `unit` has been idle (no real CPU work since its baseline), or 0 while it is not; updates `base` in place. */
114
+ export function idleFor(base, unit, ticks, now, idleMin = IDLE_MIN) {
115
+ const b = base[unit];
116
+ if (!b || ticks < b.ticks || ticks - b.ticks >= ACTIVE_TICKS) {
117
+ base[unit] = { ticks, at: now };
118
+ return 0;
119
+ } // new, restarted tree, or worked
120
+ return now - b.at >= idleMin * 60_000 ? Math.round((now - b.at) / 1000) : 0;
121
+ }
122
+ const cpuFile = () => join(qdir(), "cpu.json");
123
+ function idleMinutes() {
124
+ const v = Number(getPath(loadConfig().cfg, "ui.board_idle_min") ?? IDLE_MIN);
125
+ return v > 0 ? v : IDLE_MIN;
126
+ }
94
127
  function agents(table, now) {
95
- const d = join(stateDir(), "agents"), out = [];
96
- let files = [];
128
+ const d = join(stateDir(), "agents"), out = [], running = new Set(), cpu = (() => { try {
129
+ return JSON.parse(read(cpuFile()));
130
+ }
131
+ catch {
132
+ return {};
133
+ } })();
134
+ let idleMin, files = [];
97
135
  try {
98
136
  files = readdirSync(d).filter((f) => f.endsWith(".json"));
99
137
  }
@@ -108,24 +146,43 @@ function agents(table, now) {
108
146
  }
109
147
  const pid = Number(s.pid), at = Date.parse(String(s.at)), end = s.end ? Date.parse(String(s.end)) : undefined;
110
148
  const prefix = String(s.prefix ?? "");
111
- const st = s.status === "ok" || s.status === "fail" ? s.status : alive(pid) ? "running" : "died";
112
- if (st === "died" && now - at > ORPHAN_MS)
113
- continue;
114
- if ((st === "ok" || st === "fail") && now - (end ?? at) > RECENT_MS)
115
- continue;
149
+ const st = s.status === "ok" && killedRc(Number(s.rc)) ? "killed" : s.status === "ok" || s.status === "fail" || s.status === "killed" ? s.status : alive(pid) ? "running" : "died";
116
150
  const a = { unit: f.slice(0, -5), title: String(s.title ?? ""), agent: String(s.agent ?? "?"), model: typeof s.model === "string" ? s.model : modelOf(prefix, f.slice(0, -5)),
117
151
  host: s.host, pid, issue: basename(dirname(prefix)), label: basename(prefix), state: st,
118
- secs: Math.max(0, Math.round(((end ?? now) - at) / 1000)), cap: typeof s.cap === "number" ? s.cap : undefined };
152
+ secs: Math.max(0, Math.round(((end ?? now) - at) / 1000)), age: Math.max(0, Math.round((now - (end ?? at)) / 1000)), cap: typeof s.cap === "number" ? s.cap : undefined };
119
153
  if (s.rc !== undefined)
120
154
  a.rc = Number(s.rc);
121
155
  if (st === "running") {
122
- if (table)
156
+ if (table) {
123
157
  a.mem = treeMem(pid, table);
158
+ const t = cpuTicks(treePids(pid, table));
159
+ if (t !== undefined) {
160
+ running.add(a.unit);
161
+ const i = idleFor(cpu, a.unit, t, now, idleMin ??= idleMinutes());
162
+ if (i) {
163
+ a.state = "idle";
164
+ a.idleSecs = i;
165
+ }
166
+ }
167
+ }
124
168
  a.activity = activity(tail(`${prefix}.log`))?.replace(/\s+/g, " ").replace(homedir(), "~").slice(0, 60);
125
169
  }
126
170
  out.push(a);
127
171
  }
128
- const rank = { running: 0, died: 1, fail: 2, ok: 3 };
172
+ if (table) { // keep baselines of units still running; the rest are done or gone
173
+ for (const u of Object.keys(cpu))
174
+ if (!running.has(u))
175
+ delete cpu[u];
176
+ const text = JSON.stringify(cpu);
177
+ if (text !== read(cpuFile())) {
178
+ try {
179
+ mkdirSync(qdir(), { recursive: true });
180
+ writeAtomic(cpuFile(), text);
181
+ }
182
+ catch { /* best effort */ }
183
+ }
184
+ }
185
+ const rank = { running: 0, idle: 1, died: 1, killed: 2, fail: 2, ok: 3 };
129
186
  return out.sort((x, y) => rank[x.state] - rank[y.state] || y.secs - x.secs);
130
187
  }
131
188
  const field = (t, k) => new RegExp(`${k}=(\\S+)`).exec(t)?.[1] ?? "";
@@ -310,7 +367,12 @@ const when = (sec) => {
310
367
  };
311
368
  const gb = (b) => (b ? `${(b / 2 ** 30).toFixed(1)} GB` : "-");
312
369
  const fit = (s, n) => (s.length > n ? s.slice(0, Math.max(0, n - 1)) + "…" : s.padEnd(n));
313
- export function render(s, theme, cols, mode = colorMode()) {
370
+ /** Hours after which finished/dead agents leave the board (they stay in --json). */
371
+ export function hideHours() {
372
+ const v = Number(getPath(loadConfig().cfg, "ui.board_hide_hours") ?? HIDE_H);
373
+ return v > 0 ? v : HIDE_H;
374
+ }
375
+ export function render(s, theme, cols, mode = colorMode(), hideH = HIDE_H) {
314
376
  const c = inks(theme, mode), W = Math.max(60, cols), out = [];
315
377
  const meter = (pct, n) => {
316
378
  const f = Math.min(n, Math.round(Math.max(0, pct) * n / 100)), k = pct >= 90 ? c.err : pct >= 70 ? c.wrn : c.ok;
@@ -342,21 +404,29 @@ export function render(s, theme, cols, mode = colorMode()) {
342
404
  if (!s.queue.holders.length && !s.queue.waiters.length)
343
405
  out.push(` ${c.mut}${m("empty", "vazia")}${c.r}`);
344
406
  section("TRACKS", m("agents", "agentes"));
345
- s.agents.forEach((a, i) => {
407
+ // A finished or dead agent stops being news after a while; running ones always show. age = since its end (a
408
+ // died row's end is unknown: since its start).
409
+ const old = (a) => a.state !== "running" && a.state !== "idle" && a.age > hideH * 3600;
410
+ const shownAgents = s.agents.filter((a) => !old(a)), hidden = s.agents.length - shownAgents.length;
411
+ shownAgents.forEach((a, i) => {
346
412
  const n = `${c.mut}${String(i + 1).padStart(2, "0")}${c.r}`, who = `${c.ink}${fit(a.issue, 9)}${c.r} ${c.ink2}${fit(a.label, 22)}${c.r} `
347
413
  + `${c.ink}${fit(a.agent, 11)}${c.r}${c.mut}${fit(a.model ?? m("default", "padrão"), 17)}${c.r} ${c.ink}${(a.state === "died" ? "?" : dur(a.secs)).padEnd(7)}${c.r}`; // died: when it stopped is unknown
348
- if (a.state === "running") {
414
+ if (a.state === "running" || a.state === "idle") {
349
415
  const pct = a.cap && a.mem ? Math.round(100 * a.mem / a.cap) : 0, used = 1 + 2 + 2 + 10 + 23 + 11 + 17 + 1 + 8 + 6 + 1 + 7 + 1;
350
- const warn = a.secs > 3 * 3600 ? ` ${c.err}⚠ ${m("for", "há")} ${dur(a.secs)}${c.r}` : "";
351
- out.push(` ${c.acc}●${c.r} ${n} ${who} ${meter(pct, 4)} ${c.ink2}${gb(a.mem).padEnd(6)}${c.r} ${c.mut}${fit(a.activity ?? "", Math.max(0, W - used - 2)).trimEnd()}${c.r}${warn}`);
416
+ const idle = a.state === "idle", mark = idle ? `${c.wrn}◌` : `${c.acc}●`;
417
+ const warn = !idle && a.secs > 3 * 3600 ? ` ${c.err}⚠ ${m("for", "há")} ${dur(a.secs)}${c.r}` : "";
418
+ const what = idle ? `${c.wrn}${m("idle", "ocioso")} ${dur(a.idleSecs ?? 0)}` : fit(a.activity ?? "", Math.max(0, W - used - 2)).trimEnd();
419
+ out.push(` ${mark}${c.r} ${n} ${who} ${meter(pct, 4)} ${c.ink2}${gb(a.mem).padEnd(6)}${c.r} ${c.mut}${what}${c.r}${warn}`);
352
420
  }
353
421
  else if (a.state === "died")
354
422
  out.push(` ${c.err}●${c.r} ${n} ${who} ${c.err}${m("runner gone without finishing", "runner sumiu sem terminar")}${c.r}`);
355
423
  else
356
424
  out.push(` ${a.state === "ok" ? c.ok : c.err}${a.state === "ok" ? "✓" : "✗"}${c.r} ${n} ${c.mut}${a.issue.padEnd(9).slice(0, 9)} ${fit(a.label, 22)} ${fit(a.agent, 11)}${fit(a.model ?? "", 17)} ${dur(a.secs).padEnd(7)} ${a.state} rc=${a.rc ?? "?"}${c.r}`);
357
425
  });
358
- if (!s.agents.length)
426
+ if (!shownAgents.length)
359
427
  out.push(` ${c.mut}${m("none", "nenhum")}${c.r}`);
428
+ if (hidden)
429
+ out.push(` ${c.mut}${m(`+${hidden} older finished hidden`, `+${hidden} antigos finalizados ocultos`)}${c.r}`);
360
430
  section("MASTER", m("conductors", "conductors"));
361
431
  for (const k of s.conductors)
362
432
  out.push(` ${c.ok}●${c.r} ${c.mut}${fit(k.name || k.id, 22)}${c.r} ${c.ink2}${fit(k.account || "-", 14)}${c.r} ${c.ink}${k.issues.map((i) => (k.rounds?.[i] ? `${i} ${mark(k.rounds[i])}` : i)).join(" ")}${c.r}`);
@@ -441,7 +511,7 @@ export async function board(argv) {
441
511
  }
442
512
  const tty = Boolean(process.stdout.isTTY);
443
513
  if (once || !tty) {
444
- const lines = render(await snapshot(wss, await processTableAsync()), theme, process.stdout.columns ?? 100);
514
+ const lines = render(await snapshot(wss, await processTableAsync()), theme, process.stdout.columns ?? 100, colorMode(), hideHours());
445
515
  const c = inks(theme);
446
516
  process.stdout.write(lines.map((l) => c.base + l + (c.base ? "\x1b[K" : "")).join("\n") + (c.base ? "\x1b[0m" : "") + "\n");
447
517
  return 0;
@@ -457,7 +527,7 @@ export async function board(argv) {
457
527
  }
458
528
  drawing = true;
459
529
  try {
460
- const c = inks(theme), lines = render(await snapshot(wss, table), theme, out.columns ?? 100);
530
+ const c = inks(theme), lines = render(await snapshot(wss, table), theme, out.columns ?? 100, colorMode(), hideHours());
461
531
  out.write(`${c.base}\x1b[H` + lines.map((l) => c.base + l + "\x1b[K").join("\n") + `${c.base}\x1b[J`);
462
532
  }
463
533
  finally {
@@ -12,6 +12,7 @@ import { basename, join, resolve } from "node:path";
12
12
  import { writeAtomic } from "../fsutil.js";
13
13
  import { m } from "../i18n.js";
14
14
  import { acquire, release } from "../lock.js";
15
+ import { handoffFile } from "../notice.js";
15
16
  export const USAGE = `orq claim [-w <workspace>] <ID> | -f <ID> | -r <ID> | -l | --register issue owner among conductors (exit 1 = someone else's)
16
17
  orq claim [-w <workspace>] --from <name> [-f] a conductor's issues by its name (part of it is enough); -f takes them all`;
17
18
  const field = (text, k) => new RegExp(`${k}=(\\S+)`).exec(text)?.[1] ?? "";
@@ -139,6 +140,7 @@ export async function claim(argv) {
139
140
  if (mode === "register") {
140
141
  if (kind !== "herdr")
141
142
  return 0;
143
+ rmSync(handoffFile(me), { force: true }); // this pane is a live conductor again: drop a stale `orq handoff` redirect out of it
142
144
  const dir = join(O, ".conductors");
143
145
  mkdirSync(dir, { recursive: true });
144
146
  writeAtomic(join(dir, me.replace(/[^A-Za-z0-9_-]/g, "_")), `pane=${me} kind=herdr account=${account} at=${new Date().toISOString()} name=${myName || me}\n`);
@@ -11,7 +11,7 @@ const DESC = {
11
11
  quota: "quota per agent/account", pick: "pick agent, model and account for a role", stats: "scoreboard per agent", rounds: "rounds, time and repeated findings of an issue",
12
12
  board: "live board", lock: "test queue", run: "run a command under a memory cap", agent: "run a child agent",
13
13
  wait: "wait for child agents", verify: "tests of a diff", baseline: "tests already failing on the base",
14
- instructions: "write the /issue playbook", claim: "issue owner among conductors", deps: "link node_modules into a worktree", gc: "remove merged issue worktrees", ci: "CI/CD status per claimed issue", handoff: "move a conductor low on quota to another account",
14
+ instructions: "write the /issue playbook", conductor: "open the conductor (Claude) in this terminal", claim: "issue owner among conductors", deps: "link node_modules into a worktree", gc: "remove merged issue worktrees", ci: "CI/CD status per claimed issue", handoff: "move a conductor low on quota to another account",
15
15
  block: "block an agent or account", completion: "shell completion script",
16
16
  };
17
17
  /** Fixed words after a subcommand. */
@@ -0,0 +1,104 @@
1
+ // orq conductor — opens the conductor (Claude Code) in the current terminal, with the workspace's conductor
2
+ // instructions, the chosen account and model (port of setup/herdr-conductor, for any terminal and OS).
3
+ // orq conductor [account|-] [model] [-r <session>]
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>.
6
+ import { spawnSync } from "node:child_process";
7
+ import { existsSync } from "node:fs";
8
+ import { basename, join, resolve } from "node:path";
9
+ import { claudeAccounts } from "../accounts.js";
10
+ import { m } from "../i18n.js";
11
+ import { runCapped } from "../process.js";
12
+ import { claim } from "./claim.js";
13
+ export const USAGE = `orq conductor [account|-] [model] [-r <session>] open the conductor (Claude) here: instructions, account, model
14
+ model: sonnet | opus | haiku | a full id; "-" as account = default account; -r resumes that conversation`;
15
+ export const MODELS = {
16
+ sonnet: "claude-sonnet-5",
17
+ opus: "claude-opus-5-5",
18
+ haiku: "claude-haiku-4-5-20251001",
19
+ };
20
+ export const modelId = (alias) => MODELS[alias] ?? alias;
21
+ /** [account|-] [model] [-r|--resume <id>]; further positionals are ignored (as herdr-conductor did). */
22
+ export function parseArgs(argv) {
23
+ const out = { account: "", model: "", resume: "", help: false };
24
+ const pos = [];
25
+ for (let i = 0; i < argv.length; i++) {
26
+ const a = argv[i];
27
+ if (a === "-r" || a === "--resume") {
28
+ const v = argv[++i];
29
+ if (!v) {
30
+ out.error = m("give the session id", "informe o id da sessão");
31
+ return out;
32
+ }
33
+ out.resume = v;
34
+ }
35
+ else if (a === "-h" || a === "--help")
36
+ out.help = true;
37
+ else
38
+ pos.push(a);
39
+ }
40
+ [out.account = "", out.model = ""] = pos;
41
+ return out;
42
+ }
43
+ /** The workspace folder: cwd when it holds .orquestra-conductor.md, else $CONDUCTOR_WS (resolved); undefined if neither. */
44
+ export function findWorkspace(cwd, env, exists = existsSync) {
45
+ if (exists(join(cwd, ".orquestra-conductor.md")))
46
+ return cwd;
47
+ return env.CONDUCTOR_WS ? resolve(cwd, env.CONDUCTOR_WS) : undefined;
48
+ }
49
+ export const instructionsFile = (ws, env) => env.CONDUCTOR_FILE ? resolve(env.CONDUCTOR_FILE) : join(ws, ".orquestra-conductor.md");
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)}`,
53
+ ...(a.model ? ["--model", modelId(a.model)] : []), "--append-system-prompt-file", file];
54
+ }
55
+ export async function conductor(argv) {
56
+ const a = parseArgs(argv);
57
+ if (a.help) {
58
+ console.log(USAGE);
59
+ return 0;
60
+ }
61
+ if (a.error) {
62
+ console.error(a.error);
63
+ return 2;
64
+ }
65
+ const ws = findWorkspace(process.cwd(), process.env);
66
+ if (!ws) {
67
+ console.error(m("no .orquestra-conductor.md here: cd into a workspace (orq setup --workspace <dir>) or set CONDUCTOR_WS", "não há .orquestra-conductor.md aqui: entre num workspace (orq setup --workspace <pasta>) ou defina CONDUCTOR_WS"));
68
+ return 2;
69
+ }
70
+ const file = instructionsFile(ws, process.env);
71
+ if (!existsSync(file)) {
72
+ console.error(`${m("conductor instructions not found", "instruções do conductor não encontradas")}: ${file}`);
73
+ return 2;
74
+ }
75
+ const env = { ...process.env };
76
+ if (a.account && a.account !== "-") {
77
+ const accounts = claudeAccounts(), found = accounts.find((x) => x.name === a.account);
78
+ if (!found) {
79
+ console.error(m(`account '${a.account}' does not exist. Accounts:`, `a conta '${a.account}' não existe. Contas:`));
80
+ for (const x of accounts)
81
+ console.error(`${x.name} ${x.dir}`);
82
+ return 1;
83
+ }
84
+ env.CLAUDE_CONFIG_DIR = found.dir;
85
+ }
86
+ if (process.env.HERDR_ENV === "1" && process.env.HERDR_PANE_ID)
87
+ spawnSync("herdr", ["pane", "rename", process.env.HERDR_PANE_ID, "conductor"], { stdio: "ignore", timeout: 5_000 });
88
+ process.chdir(ws);
89
+ // Registration (.orquestra/.conductors) is a herdr thing (keyed by pane id; `claim --register` does nothing elsewhere),
90
+ // so only inside herdr. claim reads the account from this process's env (best effort, silent).
91
+ const saved = process.env.CLAUDE_CONFIG_DIR;
92
+ if (env.CLAUDE_CONFIG_DIR)
93
+ process.env.CLAUDE_CONFIG_DIR = env.CLAUDE_CONFIG_DIR;
94
+ if (process.env.HERDR_PANE_ID)
95
+ try {
96
+ await claim(["-w", ws, "--register"]);
97
+ }
98
+ catch { /* best effort */ }
99
+ if (saved === undefined)
100
+ delete process.env.CLAUDE_CONFIG_DIR;
101
+ else
102
+ process.env.CLAUDE_CONFIG_DIR = saved;
103
+ return runCapped("claude", claudeArgs(ws, a, file), { mode: "off", env, cwd: ws, quote: true, tty: true });
104
+ }
@@ -1,20 +1,27 @@
1
1
  // orq gc — find issue worktrees whose branch is already safe to remove.
2
2
  import { spawnSync } from "node:child_process";
3
- import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
4
- import { basename, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { existsSync, readdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
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 { alive } from "../lock.js";
8
+ import { stateDir } from "../paths.js";
7
9
  import { aliveOwner } from "./claim.js";
8
- export const USAGE = "orq gc [-w <workspace>] [--yes] [--days N] [--json] remove merged issue worktrees";
10
+ export const USAGE = "orq gc [-w <workspace>] [--yes] [--days N] [--json] remove merged issue worktrees and stale agent state";
9
11
  const git = (cwd, ...args) => spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
10
12
  // Paths as git prints them can differ from ours (macOS /var → /private/var, Windows C:/ vs C:\): compare real paths.
11
- const real = (p) => { try {
12
- return realpathSync.native(p);
13
- }
14
- catch {
15
- return resolve(p);
16
- } };
17
- const inside = (dir, p) => { const r = relative(dir, p); return !!r && !r.startsWith("..") && !isAbsolute(r); };
13
+ // A path that does not exist (yet) is resolved through its deepest existing ancestor, so both sides get the same prefix.
14
+ const real = (p) => {
15
+ const abs = resolve(p);
16
+ try {
17
+ return realpathSync.native(abs);
18
+ }
19
+ catch { /* missing: resolve the parent */ }
20
+ const up = dirname(abs);
21
+ return up === abs ? abs : join(real(up), basename(abs));
22
+ };
23
+ const fold = (p) => (process.platform === "win32" ? p.toLowerCase() : p); // NTFS is case-insensitive
24
+ const inside = (dir, p) => { const r = relative(fold(dir), fold(p)); return !!r && !r.startsWith("..") && !isAbsolute(r); };
18
25
  // ORQ_GH may be a .js file (tests: a fake gh that also runs on Windows).
19
26
  const gh = (cwd, args) => {
20
27
  const bin = process.env.ORQ_GH || "gh";
@@ -22,6 +29,30 @@ const gh = (cwd, args) => {
22
29
  : spawnSync(bin, args, { cwd, encoding: "utf8" });
23
30
  };
24
31
  const output = (cwd, ...args) => { const r = git(cwd, ...args); return r.status === 0 ? r.stdout.trim() : undefined; };
32
+ /** Per-agent state files (<state>/agents/<unit>.json) of runs under `ws` that are finished or dead and older than `days`. */
33
+ function staleAgentStates(ws, days, now) {
34
+ const dir = join(stateDir(), "agents"), out = [];
35
+ let files = [];
36
+ try {
37
+ files = readdirSync(dir).filter((f) => f.endsWith(".json"));
38
+ }
39
+ catch { /* no runs yet */ }
40
+ for (const f of files) {
41
+ try {
42
+ const s = JSON.parse(readFileSync(join(dir, f), "utf8"));
43
+ if (!inside(real(ws), real(String(s.prefix ?? ""))))
44
+ continue;
45
+ const finished = s.status === "ok" || s.status === "fail" || s.status === "killed";
46
+ if (!finished && alive(Number(s.pid)))
47
+ continue;
48
+ const since = Date.parse(String(s.end ?? s.at));
49
+ if (now - since > days * 86400_000)
50
+ out.push(join(dir, f));
51
+ }
52
+ catch { /* unreadable state: leave it */ }
53
+ }
54
+ return out;
55
+ }
25
56
  export async function gc(argv, clock = Date.now) {
26
57
  let ws = process.cwd(), yes = false, json = false, days = 14;
27
58
  for (let i = 0; i < argv.length; i++) {
@@ -153,6 +184,10 @@ export async function gc(argv, clock = Date.now) {
153
184
  }
154
185
  git(join(ws, row.repo), "worktree", "prune");
155
186
  }
187
+ const stale = staleAgentStates(ws, days, clock());
188
+ if (yes)
189
+ for (const f of stale)
190
+ rmSync(f, { force: true });
156
191
  if (json)
157
192
  console.log(JSON.stringify(rows));
158
193
  else {
@@ -160,5 +195,8 @@ export async function gc(argv, clock = Date.now) {
160
195
  const w = table[0].map((_, i) => Math.max(...table.map((t) => t[i].length)));
161
196
  console.log(table.map((t) => t.map((c, i) => i < t.length - 1 ? c.padEnd(w[i]) : c).join(" ")).join("\n"));
162
197
  }
198
+ if (stale.length && !json)
199
+ console.log(yes ? m(`pruned ${stale.length} stale agent state file(s)`, `${stale.length} arquivo(s) de estado de agente antigo(s) removido(s)`)
200
+ : m(`${stale.length} stale agent state file(s) (finished/dead, older than ${days} days): --yes removes them`, `${stale.length} arquivo(s) de estado de agente antigo(s) (terminados/mortos, mais de ${days} dias): --yes remove`));
163
201
  return failed ? 1 : 0;
164
202
  }