@irythill/orq 0.5.0 → 0.6.1

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";
@@ -20,8 +20,9 @@ import { getPath, loadConfig } from "../config.js";
20
20
  import { writeAtomic } from "../fsutil.js";
21
21
  import { m } from "../i18n.js";
22
22
  import { alive } from "../lock.js";
23
+ import { agyToken, humanReset, learnExhausted } from "../quota.js";
23
24
  import { stateDir } from "../paths.js";
24
- import { killTree, parseSize, resolveMode, runCapped } from "../process.js";
25
+ import { killedRc, killTree, parseSize, resolveMode, runCapped } from "../process.js";
25
26
  import { closeOwnTab, detectHost, launch, screenOpen, shq, typeInto } from "../terminal.js";
26
27
  import { ackPrefix } from "../notice.js";
27
28
  import { dropWorktree, notify } from "../testrun.js";
@@ -130,6 +131,22 @@ export async function agent(argv) {
130
131
  return 2;
131
132
  }
132
133
  const repo = resolve(d), O = resolve(o);
134
+ // 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.
135
+ // Only a -T -R follow-up typed into that run's own open screen may reuse it.
136
+ let open = "", pid = 0;
137
+ try {
138
+ open = readFileSync(`${O}.pane`, "utf8").trim();
139
+ }
140
+ catch { /* first run */ }
141
+ try {
142
+ pid = Number(readFileSync(`${O}.pid`, "utf8"));
143
+ }
144
+ catch { /* none or ended */ }
145
+ const typed = tui && resume && alive(pid) && screenOpen(open);
146
+ if (!typed && runLive(O, pid)) {
147
+ 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.`));
148
+ return 3;
149
+ }
133
150
  // Fix rounds: every -R on the same session counts, whatever the prefix is called. policy.max_rounds is enforced here,
134
151
  // not left to the conductor to remember (a 7-round fix loop is what burns the tokens). -F = the user said go on.
135
152
  if (resume) {
@@ -185,13 +202,7 @@ export async function agent(argv) {
185
202
  // Follow-ups (-R) reuse the prefix: remove the previous round's result so ".done exists" means "this one finished".
186
203
  // -T -R with the screen still open: the follow-up is typed into it (its runner, and .pid, carry on). Open = the agent
187
204
  // 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)) {
205
+ if (typed) {
195
206
  ackPrefix(O); // a follow-up on the issue: its failure notices were seen
196
207
  for (const s of [".done", ".out.md"])
197
208
  rmSync(O + s, { force: true });
@@ -264,7 +275,8 @@ export async function agent(argv) {
264
275
  swap: parseSize(mach.agent_swap_max), mode: mach.mem_enforce ?? "auto",
265
276
  throwaway, promptInDir: Boolean(def.prompt_in_dir), closeAfter,
266
277
  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,
278
+ okExit: def.ok_exit, filter: def.filter, noReview: def.no_review, ro,
279
+ idleCloseMin: tui ? Number(mach.tui_idle_close_min ?? 30) : undefined,
268
280
  };
269
281
  writeAtomic(`${O}.job.json`, JSON.stringify(job, null, 1));
270
282
  // Readable copy of the command (also what orq stats and herdr-board read).
@@ -276,6 +288,20 @@ export async function agent(argv) {
276
288
  console.log(`[orq agent] ${t} → ${where}${tui ? " (TUI)" : ""}`);
277
289
  return 0;
278
290
  }
291
+ /** 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. */
292
+ function runLive(O, pid) {
293
+ if (alive(pid))
294
+ return true;
295
+ if (existsSync(`${O}.done`) || process.platform !== "linux")
296
+ return false; // finished (or no systemd): the pid said it all
297
+ try {
298
+ const unit = JSON.parse(readFileSync(`${O}.job.json`, "utf8")).unit;
299
+ return Boolean(unit) && spawnSync("systemctl", ["--user", "is-active", "--quiet", `${unit}.scope`], { timeout: 5_000 }).status === 0;
300
+ }
301
+ catch {
302
+ return false;
303
+ }
304
+ }
279
305
  /** OpenCode (v2) plugin for -T: when the main session's run ends, its last answer goes to `orq agent --turn`. */
280
306
  function opencodePlugin(O, hook) {
281
307
  return `// orq agent -T end of turn (generated)
@@ -317,6 +343,12 @@ function turn(file, kind, rest) {
317
343
  return 0;
318
344
  answer = String(e["last-assistant-message"] ?? "");
319
345
  sid = e["thread-id"];
346
+ // Codex also runs internal turns (e.g. generating the tab title) and notifies for them: only the main thread counts.
347
+ const known = existsSync(`${O}.id`) ? readFileSync(`${O}.id`, "utf8").trim() : "";
348
+ if (known && sid && sid !== known)
349
+ return 0;
350
+ if (isTitleOnly(answer))
351
+ return 0;
320
352
  }
321
353
  else if (kind === "claude") {
322
354
  const e = JSON.parse(readFileSync(0, "utf8"));
@@ -351,6 +383,16 @@ function turn(file, kind, rest) {
351
383
  }
352
384
  return 0;
353
385
  }
386
+ /** The `{"title": "..."}` answer of Codex's session-title turn (a JSON object whose only key is `title`). */
387
+ function isTitleOnly(answer) {
388
+ try {
389
+ const o = JSON.parse(answer);
390
+ return typeof o === "object" && o !== null && !Array.isArray(o) && Object.keys(o).length === 1 && "title" in o;
391
+ }
392
+ catch {
393
+ return false;
394
+ }
395
+ }
354
396
  /** The last text Claude wrote, from its transcript (older Stop hooks have no last_assistant_message). */
355
397
  function lastClaudeText(transcript) {
356
398
  let text = "";
@@ -392,6 +434,26 @@ function noReviewMatch(pattern, answer) {
392
434
  return re.test(first) || (answer.trim().length <= 200 && re.test(answer));
393
435
  }
394
436
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
437
+ /** A -T agent is idle when its turn ended (.done exists) and, for `idleMs`, nothing touched .done (a follow-up removes
438
+ * it, a turn's end rewrites it) and the screen was not seen busy (`busyAt`, epoch ms, from herdr's agent status). */
439
+ export function idleExpired(done, idleMs, now = Date.now(), busyAt = 0) {
440
+ try {
441
+ return idleMs > 0 && now - Math.max(statSync(done).mtimeMs, busyAt) >= idleMs;
442
+ }
443
+ catch {
444
+ return false;
445
+ }
446
+ }
447
+ /** herdr's view of the agent on a pane: working | blocked | idle | done | unknown; undefined when it can't be read. */
448
+ function paneStatus(pane) {
449
+ const r = spawnSync("herdr", ["pane", "get", pane], { encoding: "utf8", timeout: 5_000 });
450
+ try {
451
+ return r.status === 0 ? JSON.parse(r.stdout).result?.pane?.agent_status : undefined;
452
+ }
453
+ catch {
454
+ return undefined;
455
+ }
456
+ }
395
457
  /** Splits a stream into lines for `each`. */
396
458
  function lines(s, each) {
397
459
  return new Promise((res) => {
@@ -420,6 +482,8 @@ async function runJob(file) {
420
482
  state();
421
483
  const fmt = FORMATS[j.format] ?? FORMATS.text;
422
484
  let rc = 0, stdout = "", logText = "";
485
+ // State of the worktree before the run, to tell what this run changed (a -R on a dirty tree is not progress).
486
+ const headBefore = j.throwaway ? undefined : git(j.dir, "rev-parse", "HEAD"), statusBefore = j.throwaway ? undefined : git(j.dir, "status", "--porcelain");
423
487
  const pre = () => {
424
488
  if (j.throwaway) {
425
489
  // Throwaway worktree: detached copy of HEAD with the repo's node_modules linked; removed at the end.
@@ -457,20 +521,26 @@ async function runJob(file) {
457
521
  if (j.tui) {
458
522
  // The agent's own screen: the terminal is its; answers come from the end-of-turn hook (`--turn`).
459
523
  let ended = false;
460
- const end = (code) => {
524
+ const end = (code, killed = false) => {
461
525
  if (ended)
462
526
  return "fail";
463
527
  ended = true;
464
528
  post();
465
529
  // 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`)) {
530
+ const ok = !killed && /\bstatus=ok\b/.test(existsSync(`${O}.done`) ? readFileSync(`${O}.done`, "utf8") : "");
531
+ if (killed) {
532
+ // SIGKILL & co: whatever an earlier turn wrote, the screen did not end normally.
533
+ writeAtomic(`${O}.done`, `status=killed rc=${code} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
534
+ if (j.parent)
535
+ notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=killed rc=${code} answer: ${O}.out.md`);
536
+ }
537
+ else if (!existsSync(`${O}.done`)) {
468
538
  writeAtomic(`${O}.done`, `status=fail rc=${code} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
469
539
  if (j.parent)
470
540
  notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=${code} answer: ${O}.out.md`);
471
541
  }
472
542
  rmSync(`${O}.pid`, { force: true });
473
- return ok ? "ok" : "fail";
543
+ return killed ? "killed" : ok ? "ok" : "fail";
474
544
  };
475
545
  if (!pre())
476
546
  rc = 2;
@@ -480,16 +550,46 @@ async function runJob(file) {
480
550
  delete env[k];
481
551
  // Ctrl+C belongs to the agent (it reaches us too, as part of the terminal's foreground group). The screen going
482
552
  // away (tab or tmux server closed) still ends the agent and cleans up.
483
- let child;
553
+ let child, idleT, idleClosed = false;
484
554
  process.on("SIGINT", () => { });
555
+ // The screen going away or being killed from outside: end the agent, and close the tab too (a dead tab stayed open).
485
556
  const gone = () => { if (child)
486
- killTree(child, false); end(129); process.exit(0); };
557
+ killTree(child, false); end(129); closeOwnTab(j.host); process.exit(0); };
487
558
  for (const sig of ["SIGHUP", "SIGTERM"])
488
559
  process.on(sig, gone);
489
560
  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; } });
561
+ onSpawn: (c) => {
562
+ child = c.pid;
563
+ // A finished agent waits for a follow-up forever and holds its memory: after the idle timeout without a new
564
+ // turn, kill it; the normal end below cleans up and closes the tab. "New turn" = .done removed (`-R` typed
565
+ // into the screen removes it) or rewritten (the next end of turn), so idle = .done untouched that long.
566
+ const idleMs = (j.idleCloseMin ?? 0) * 60_000;
567
+ if (idleMs > 0 && !j.keep) {
568
+ // On herdr the pane's agent status also catches a turn typed straight into the screen (.done only changes
569
+ // when it ends). Elsewhere (tmux) only .done tells: a turn typed directly into the screen that runs longer
570
+ // than the timeout can still be closed.
571
+ let busyAt = 0;
572
+ const pane = j.host === "herdr" ? process.env.HERDR_PANE_ID : undefined;
573
+ idleT = setInterval(() => {
574
+ const st = pane ? paneStatus(pane) : undefined;
575
+ if (st === "working" || st === "blocked")
576
+ busyAt = Date.now();
577
+ if (!idleExpired(`${O}.done`, idleMs, Date.now(), busyAt))
578
+ return;
579
+ clearInterval(idleT);
580
+ 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`));
581
+ idleClosed = true;
582
+ if (child)
583
+ killTree(child, false);
584
+ }, Math.min(30_000, Math.max(1_000, idleMs / 2)));
585
+ idleT.unref();
586
+ }
587
+ } });
588
+ clearInterval(idleT);
589
+ if (idleClosed)
590
+ rc = 0; // closed on purpose after a finished turn: not a kill
491
591
  }
492
- const st = end(rc);
592
+ const st = end(rc, killedRc(rc));
493
593
  state({ status: st, rc, end: new Date().toISOString() });
494
594
  console.log(`\n[${j.agent} (TUI) ${m("exited", "encerrado")}] ${m("answer", "resposta")}: ${O}.out.md`);
495
595
  if (!j.keep) {
@@ -552,12 +652,37 @@ async function runJob(file) {
552
652
  await new Promise((r) => log.end(r));
553
653
  post();
554
654
  const res = fmt.finish(logText, stdout);
655
+ // Denied commands do not sink a write run that did work: keep the diff and report ok with the denied list in the answer.
656
+ let recovered = false;
657
+ if (res.denied?.length && !j.ro && !j.throwaway && existsSync(j.dir)) {
658
+ const status = git(j.dir, "status", "--porcelain"), head = git(j.dir, "rev-parse", "HEAD");
659
+ if (status !== statusBefore || head !== headBefore) {
660
+ const stat = (headBefore && git(j.dir, "diff", "--stat", headBefore)) || status || `HEAD ${headBefore?.slice(0, 7)} -> ${head?.slice(0, 7)}`;
661
+ recovered = true;
662
+ 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`;
663
+ }
664
+ else
665
+ res.answer = `${res.answer ?? ""}\nNo changes were made in the worktree.\n`;
666
+ }
667
+ // 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.
668
+ if (res.exhausted) {
669
+ const model = argvModel(j.argv);
670
+ let until = "";
671
+ if (res.exhausted.secs !== undefined) {
672
+ const at = Date.now() + res.exhausted.secs * 1000;
673
+ if (model)
674
+ learnExhausted("antigravity", model, res.exhausted.secs, agyToken()?.acct);
675
+ until = ` ${m("until", "até")} ${humanReset(Math.floor(at / 1000))} (${new Date(at).toISOString()})`;
676
+ }
677
+ res.answer = `quota exhausted: ${model ?? j.agent}${until}\n\n${res.exhausted.message}\n`;
678
+ res.rc = res.rc || 3;
679
+ }
555
680
  if (j.answer !== "file")
556
681
  writeFileSync(`${O}.out.md`, res.answer ?? "");
557
682
  const sid = j.fromLog ? new RegExp(j.fromLog, "m").exec(logText)?.[1] : res.session ?? j.session;
558
683
  if (sid)
559
684
  writeFileSync(`${O}.id`, `${sid}\n`);
560
- if (rc === 0 && res.rc)
685
+ if (rc === 0 && res.rc && !recovered)
561
686
  rc = res.rc;
562
687
  // A review tool exits non-zero on findings: an ok_exit code is a verdict (rc stays in .done), not a failure.
563
688
  let verdict = rc === 0 || (j.okExit ?? []).includes(rc);
@@ -578,7 +703,8 @@ async function runJob(file) {
578
703
  // A reviewer whose answer says nothing was reviewed (agents.<name>.no_review) is not a verdict either: pick's
579
704
  // fallback and the conductor must see it as not run, same as a whitespace-only answer.
580
705
  const noReview = noReviewMatch(j.noReview, answer);
581
- const st = verdict && !noReview && /\S/.test(answer) ? "ok" : "fail"; // whitespace-only answer = failure
706
+ // killed (SIGKILL, memory cap...) is its own status: never ok, even if an ok_exit list or a leftover answer says otherwise.
707
+ const st = killedRc(rc) ? "killed" : verdict && !noReview && /\S/.test(answer) ? "ok" : "fail"; // whitespace-only answer = failure
582
708
  writeAtomic(`${O}.done`, `status=${st} rc=${rc} at=${new Date().toISOString()} agent=${j.agent}\n`);
583
709
  rmSync(`${O}.pid`, { force: true });
584
710
  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
+ }