@irythill/orq 0.4.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.
@@ -16,18 +16,20 @@ import { collectCi, collectedAt, loadCache as loadCiCache } from "../ci.js";
16
16
  import { getPath, loadConfig } from "../config.js";
17
17
  import { writeAtomic } from "../fsutil.js";
18
18
  import { m, setLang } from "../i18n.js";
19
+ import { argvModel } from "../agents.js";
19
20
  import { alive, status as lockStatus } from "../lock.js";
20
21
  import { expand, stateDir } from "../paths.js";
21
- import { processTableAsync, treeMem } from "../process.js";
22
+ import { killedRc, processTableAsync, treeMem, treePids } from "../process.js";
22
23
  import { collect } from "../quota.js";
23
24
  import { collectRounds, mark, roundsSig } from "../rounds.js";
24
25
  import { collectRuns, rows } from "../stats.js";
26
+ import { pendingNotices, readLog } from "../notice.js";
25
27
  import { load as loadBlocks } from "./block.js";
26
28
  import { herdrNamesAsync, nameOf } from "./claim.js";
27
29
  import { setValue } from "./set.js";
28
30
  export const USAGE = "orq board [-w <workspace>]... [--once] [--json] [--interval S] live board: queue, agents, conductors, quota";
29
31
  const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
30
- 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
31
33
  const read = (f) => { try {
32
34
  return readFileSync(f, "utf8");
33
35
  }
@@ -35,19 +37,20 @@ catch {
35
37
  return "";
36
38
  } };
37
39
  const models = new Map();
38
- /** The model a run was started with, from its job file (-m / --model in the argv). */
39
- function modelOf(prefix) {
40
- if (!models.has(prefix)) {
40
+ /** The model of a run recorded before its state file had one, from its job file; a rerun on the same prefix
41
+ * overwrites that file, so another unit's job says nothing about this run. */
42
+ function modelOf(prefix, unit) {
43
+ if (!models.has(unit)) {
41
44
  let v;
42
45
  try {
43
- const a = (JSON.parse(read(`${prefix}.job.json`)).argv ?? []);
44
- const i = a.findIndex((x) => x === "-m" || x === "--model");
45
- v = i >= 0 ? a[i + 1] : a.find((x) => x.startsWith("--model="))?.slice(8);
46
+ const j = JSON.parse(read(`${prefix}.job.json`));
47
+ if (!j.unit || j.unit === unit)
48
+ v = argvModel(j.argv ?? []);
46
49
  }
47
50
  catch { /* no job file */ }
48
- models.set(prefix, v?.split("/").pop());
51
+ models.set(unit, v);
49
52
  }
50
- return models.get(prefix);
53
+ return models.get(unit);
51
54
  }
52
55
  /** What the agent did last, from the tail of its log (stream JSON of claude/opencode, else the last text line). */
53
56
  export function activity(log) {
@@ -88,9 +91,47 @@ function tail(file, bytes = 16_384) {
88
91
  closeSync(fd);
89
92
  }
90
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
+ }
91
127
  function agents(table, now) {
92
- const d = join(stateDir(), "agents"), out = [];
93
- 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 = [];
94
135
  try {
95
136
  files = readdirSync(d).filter((f) => f.endsWith(".json"));
96
137
  }
@@ -105,24 +146,43 @@ function agents(table, now) {
105
146
  }
106
147
  const pid = Number(s.pid), at = Date.parse(String(s.at)), end = s.end ? Date.parse(String(s.end)) : undefined;
107
148
  const prefix = String(s.prefix ?? "");
108
- const st = s.status === "ok" || s.status === "fail" ? s.status : alive(pid) ? "running" : "died";
109
- if (st === "died" && now - at > ORPHAN_MS)
110
- continue;
111
- if ((st === "ok" || st === "fail") && now - (end ?? at) > RECENT_MS)
112
- continue;
113
- const a = { unit: f.slice(0, -5), title: String(s.title ?? ""), agent: String(s.agent ?? "?"), model: modelOf(prefix),
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";
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)),
114
151
  host: s.host, pid, issue: basename(dirname(prefix)), label: basename(prefix), state: st,
115
- 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 };
116
153
  if (s.rc !== undefined)
117
154
  a.rc = Number(s.rc);
118
155
  if (st === "running") {
119
- if (table)
156
+ if (table) {
120
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
+ }
121
168
  a.activity = activity(tail(`${prefix}.log`))?.replace(/\s+/g, " ").replace(homedir(), "~").slice(0, 60);
122
169
  }
123
170
  out.push(a);
124
171
  }
125
- 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 };
126
186
  return out.sort((x, y) => rank[x.state] - rank[y.state] || y.secs - x.secs);
127
187
  }
128
188
  const field = (t, k) => new RegExp(`${k}=(\\S+)`).exec(t)?.[1] ?? "";
@@ -268,7 +328,7 @@ export async function snapshot(wss, table) {
268
328
  return {
269
329
  at: new Date(now).toISOString(), mem: { used: totalmem() - freemem(), total: totalmem() }, workspaces: wss,
270
330
  queue: { holders: q.holders.map(item), waiters: q.waiters.map(item) }, agents: agents(table, now),
271
- conductors: await conductors(wss), quota: quota(now), ci: ci(wss, now), blocks: loadBlocks(), scoreboard: scoreboard(wss[0], now),
331
+ conductors: await conductors(wss), quota: quota(now), ci: ci(wss, now), blocks: loadBlocks(), scoreboard: scoreboard(wss[0], now), notices: pendingNotices(readLog(), now),
272
332
  };
273
333
  }
274
334
  // --- render -----------------------------------------------------------------------------------------------------
@@ -307,7 +367,12 @@ const when = (sec) => {
307
367
  };
308
368
  const gb = (b) => (b ? `${(b / 2 ** 30).toFixed(1)} GB` : "-");
309
369
  const fit = (s, n) => (s.length > n ? s.slice(0, Math.max(0, n - 1)) + "…" : s.padEnd(n));
310
- 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) {
311
376
  const c = inks(theme, mode), W = Math.max(60, cols), out = [];
312
377
  const meter = (pct, n) => {
313
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;
@@ -322,6 +387,15 @@ export function render(s, theme, cols, mode = colorMode()) {
322
387
  out.push(`${c.surf}${c.b}${c.acc} ORQ${c.b ? "\x1b[22m" : ""}${c.surf}${c.ink2} ${ws}${" ".repeat(Math.max(1, W - left.length - right.length))}${c.mut}${right}`);
323
388
  const mp = Math.round(100 * s.mem.used / s.mem.total);
324
389
  out.push(` ${c.mut}MEM${c.r} ${meter(mp, 24)} ${c.ink}${gb(s.mem.used)}${c.r} ${c.mut}/ ${gb(s.mem.total)}${c.r}`);
390
+ // Red line(s) first: a failure the conductor may not know about is what a person must see before anything else.
391
+ if (s.notices.length) {
392
+ const ago = (at) => dur(Math.max(0, Math.round((new Date(s.at).getTime() - at) / 1000)));
393
+ out.push(` ${c.err}${c.b}✗ ${m(`${s.notices.length} failure(s) the conductor may not know about`, `${s.notices.length} falha(s) que o conductor pode não saber`)}${c.r}`);
394
+ for (const n of s.notices.slice(0, 5)) {
395
+ const what = n.line.replace(/^\[[^\]]+\] /, "").replace(/ answer: .*/, "");
396
+ out.push(` ${c.err} ${fit(n.issue ?? "-", 10)}${c.r} ${c.ink}${fit(what, Math.max(20, W - 44))}${c.r} ${c.err}${n.why === "undelivered" ? m("not delivered", "não entregue") : m("not acknowledged", "sem resposta")}${c.r} ${c.mut}${ago(n.at)}${m(" ago", " atrás")}${c.r}`);
397
+ }
398
+ }
325
399
  section("TRANSPORT", m("test queue", "fila de testes"));
326
400
  for (const h of s.queue.holders)
327
401
  out.push(` ${c.acc}▶ REC${c.r} ${c.ink}${fit(h.label, 34)}${c.r} ${c.ok}${m("running", "rodando")}${c.r} ${c.ink}${dur(h.secs).padEnd(7)}${c.r} ${c.ink2}${gb(h.mem)}${c.r}`
@@ -330,21 +404,29 @@ export function render(s, theme, cols, mode = colorMode()) {
330
404
  if (!s.queue.holders.length && !s.queue.waiters.length)
331
405
  out.push(` ${c.mut}${m("empty", "vazia")}${c.r}`);
332
406
  section("TRACKS", m("agents", "agentes"));
333
- 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) => {
334
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} `
335
- + `${c.ink}${fit(a.agent, 11)}${c.r}${c.mut}${fit(a.model ?? m("default", "padrão"), 17)}${c.r} ${c.ink}${dur(a.secs).padEnd(7)}${c.r}`;
336
- if (a.state === "running") {
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
414
+ if (a.state === "running" || a.state === "idle") {
337
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;
338
- const warn = a.secs > 3 * 3600 ? ` ${c.err}⚠ ${m("for", "há")} ${dur(a.secs)}${c.r}` : "";
339
- 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}`);
340
420
  }
341
421
  else if (a.state === "died")
342
422
  out.push(` ${c.err}●${c.r} ${n} ${who} ${c.err}${m("runner gone without finishing", "runner sumiu sem terminar")}${c.r}`);
343
423
  else
344
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}`);
345
425
  });
346
- if (!s.agents.length)
426
+ if (!shownAgents.length)
347
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}`);
348
430
  section("MASTER", m("conductors", "conductors"));
349
431
  for (const k of s.conductors)
350
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}`);
@@ -429,7 +511,7 @@ export async function board(argv) {
429
511
  }
430
512
  const tty = Boolean(process.stdout.isTTY);
431
513
  if (once || !tty) {
432
- 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());
433
515
  const c = inks(theme);
434
516
  process.stdout.write(lines.map((l) => c.base + l + (c.base ? "\x1b[K" : "")).join("\n") + (c.base ? "\x1b[0m" : "") + "\n");
435
517
  return 0;
@@ -445,7 +527,7 @@ export async function board(argv) {
445
527
  }
446
528
  drawing = true;
447
529
  try {
448
- 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());
449
531
  out.write(`${c.base}\x1b[H` + lines.map((l) => c.base + l + "\x1b[K").join("\n") + `${c.base}\x1b[J`);
450
532
  }
451
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,16 +11,16 @@ 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. */
18
18
  export const WORDS = {
19
19
  setup: ["--preset", "--detect", "--answers", "--write", "--install", "--workspace"],
20
20
  config: ["get", "files", "-w"], lang: ["pt", "en", "auto", "toggle"], lock: ["wait", "release", "run", "status"],
21
- pick: ["impl", "fix", "worker", "review", "opinion"], instructions: ["claude", "agents"], board: ["--once", "--json", "-w"],
21
+ pick: ["impl", "fix", "worker", "review", "opinion", "watchdog"], instructions: ["claude", "agents"], board: ["--once", "--json", "-w"],
22
22
  completion: ["bash", "zsh", "fish", "powershell"], stats: ["--days", "--json", "-w"], wait: ["--any", "--timeout", "--answer"],
23
- ci: ["--json", "-w"], rounds: ["--json", "-w"],
23
+ ci: ["--json", "-w"], rounds: ["--json", "--diagnose", "-w"],
24
24
  };
25
25
  const q = (s) => `'${s.replace(/'/g, "''")}'`; // PowerShell single quotes
26
26
  const qf = (s) => `'${s.replace(/[\\']/g, "\\$&")}'`; // fish single quotes
@@ -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
  }
@@ -163,6 +163,7 @@ async function move(ws, c, to, dir, model, mark) {
163
163
  for (const id of c.issues)
164
164
  writeAtomic(join(ws, ".orquestra", id, "owner"), `pane=${pane} kind=herdr account=${basename(dir)} at=${new Date().toISOString()} name=${label}\n`);
165
165
  writeAtomic(handoffFile(c.pane), pane);
166
+ rmSync(handoffFile(pane), { force: true }); // the new pane is live: a leftover redirect out of it would make a cycle
166
167
  const regs = join(ws, ".orquestra", ".conductors");
167
168
  mkdirSync(regs, { recursive: true });
168
169
  writeAtomic(join(regs, pane.replace(/[^A-Za-z0-9_-]/g, "_")), `pane=${pane} kind=herdr account=${basename(dir)} at=${new Date().toISOString()} name=${label}\n`);
@@ -13,7 +13,7 @@ import { collect } from "../quota.js";
13
13
  import { PICK_ROLES, resolveRole, vendorOf } from "../roles.js";
14
14
  import { collectRuns, rows } from "../stats.js";
15
15
  import { load as loadBlocks } from "./block.js";
16
- export const USAGE = `orq pick <impl|fix|worker|review|opinion> [--level simple|medium|heavy] [--impl-vendor <v>|--impl-agent <a>]
16
+ export const USAGE = `orq pick <impl|fix|worker|review|opinion|watchdog> [--level simple|medium|heavy] [--impl-vendor <v>|--impl-agent <a>]
17
17
  [--exclude-agent <a>]... [-w <workspace>] [--explain] [--no-record]
18
18
  orq pick fix --same <impl pick file> same agent/model/account as the implementer, effort one level up`;
19
19
  // The board refreshes this file; fresher than 10 min → no new quota requests.
@@ -1,9 +1,22 @@
1
1
  // orq rounds <ID> [-w <workspace>] [--json] per-issue round metrics from .orquestra/<ID>/ (src/rounds.ts), no LLM
2
+ // orq rounds <ID> --diagnose [-w <workspace>] warn/stop only: a cheap read only "watchdog" agent (another vendor than the
3
+ // implementer when known) diagnoses why the rounds don't converge → .orquestra/<ID>/diagnosis-<rounds>.md (advisory)
4
+ import { spawnSync } from "node:child_process";
5
+ import { copyFileSync, existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { fileURLToPath } from "node:url";
2
9
  import { getPath, loadConfig } from "../config.js";
10
+ import { buildPrompt, evidence, latestDiagnosis, recommendation } from "../diagnose.js";
3
11
  import { m } from "../i18n.js";
4
12
  import { expand } from "../paths.js";
5
13
  import { collectRounds, mark } from "../rounds.js";
6
- export const USAGE = "orq rounds <ID> [-w <workspace>] [--json] rounds vs max_rounds, time per round, repeated findings, alert level";
14
+ import { alive } from "../lock.js";
15
+ import { wait } from "./wait.js";
16
+ export const USAGE = `orq rounds <ID> [-w <workspace>] [--json] rounds vs max_rounds, time per round, repeated findings, alert level
17
+ orq rounds <ID> --diagnose [-w <workspace>] on warn/stop: an independent cheap agent diagnoses why rounds don't converge (advisory; needed for orq agent -R -F)`;
18
+ export const WATCHDOG_TIMEOUT = 900; // seconds `orq rounds --diagnose` waits for the agent
19
+ const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
7
20
  const dur = (s) => (s >= 3600 ? `${Math.floor(s / 3600)}h${String(Math.floor(s / 60) % 60).padStart(2, "0")}m` : s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s` : `${s}s`);
8
21
  const why = (r) => ({
9
22
  over_max: m(`rounds ${r.n} > max_rounds ${r.max}`, `rodadas ${r.n} > max_rounds ${r.max}`),
@@ -12,8 +25,8 @@ const why = (r) => ({
12
25
  repeated: m(`${r.n} repeated finding(s)`, `${r.n} achado(s) repetido(s)`),
13
26
  slow: m(`round ${r.rounds?.join(", ")} much slower than the others`, `rodada ${r.rounds?.join(", ")} bem mais lenta que as outras`),
14
27
  })[r.code];
15
- export function rounds(argv) {
16
- let ws, id, json = false;
28
+ export async function rounds(argv) {
29
+ let ws, id, json = false, diagnose = false;
17
30
  for (let i = 0; i < argv.length; i++) {
18
31
  if (argv[i] === "-w") {
19
32
  if (!argv[i + 1]) {
@@ -24,6 +37,8 @@ export function rounds(argv) {
24
37
  }
25
38
  else if (argv[i] === "--json")
26
39
  json = true;
40
+ else if (argv[i] === "--diagnose")
41
+ diagnose = true;
27
42
  else if (!id && !argv[i].startsWith("-"))
28
43
  id = argv[i];
29
44
  else {
@@ -42,6 +57,8 @@ export function rounds(argv) {
42
57
  console.error(m(`no rounds recorded for ${id} in ${ws}/.orquestra`, `nenhuma rodada registrada para ${id} em ${ws}/.orquestra`));
43
58
  return 1;
44
59
  }
60
+ if (diagnose)
61
+ return diagnoseIssue(ws, id, x);
45
62
  if (json) {
46
63
  console.log(JSON.stringify(x, null, 2));
47
64
  return 0;
@@ -54,5 +71,62 @@ export function rounds(argv) {
54
71
  console.log(m("repeated findings:", "achados repetidos:"));
55
72
  for (const r of x.repeats)
56
73
  console.log(` [${m("rounds", "rodadas")} ${r.rounds.join(",")}] ${r.title.slice(0, 100)}`);
74
+ const d = latestDiagnosis(join(ws, ".orquestra", id));
75
+ if (d)
76
+ console.log(`${m("diagnosis", "diagnóstico")} ${d.n}: ${d.line ?? m("(no RECOMMENDATION line)", "(sem linha RECOMMENDATION)")}`);
77
+ return 0;
78
+ }
79
+ const orq = (...a) => spawnSync(process.execPath, [CLI, ...a], { encoding: "utf8" });
80
+ /** --diagnose: pick a watchdog, run it read only on the evidence, keep its answer as diagnosis-<rounds>.md. */
81
+ async function diagnoseIssue(ws, id, x) {
82
+ if (x.level === "ok") {
83
+ console.log(m(`${id}: level is ok, no diagnosis needed`, `${id}: nível ok, não precisa de diagnóstico`));
84
+ return 0;
85
+ }
86
+ const dir = join(ws, ".orquestra", id), prefix = join(dir, `watchdog-${x.rounds}-${Date.now()}`), out = join(dir, `diagnosis-${x.rounds}.md`);
87
+ // The implementer, when known (impl-<repo>.pick saved by the playbook): the watchdog must be another vendor.
88
+ const implPick = readdirSync(dir).filter((n) => /^impl-.+\.pick$/.test(n)).map((n) => /(?:^|\s)-a (\S+)/.exec(readFileSync(join(dir, n), "utf8"))?.[1]).find(Boolean);
89
+ // A watchdog still running for this issue (a previous --diagnose that timed out or was interrupted): don't start another.
90
+ for (const n of readdirSync(dir).filter((f) => /^watchdog-.+\.pid$/.test(f))) {
91
+ const pid = Number(readFileSync(join(dir, n), "utf8"));
92
+ if (pid && alive(pid)) {
93
+ console.error(m(`a watchdog for ${id} is still running (${n}, pid ${pid}); wait for it or stop it first`, `um watchdog de ${id} ainda está rodando (${n}, pid ${pid}); espere ou encerre antes`));
94
+ return 1;
95
+ }
96
+ }
97
+ const ev = evidence(dir);
98
+ writeFileSync(`${prefix}.md`, buildPrompt(x, ev.fixes, ev.reviews));
99
+ const p = orq("pick", "watchdog", "--level", "simple", "-w", ws, ...(implPick ? ["--impl-agent", implPick] : []));
100
+ if (p.status !== 0) {
101
+ console.error(p.stderr.trim() || `orq pick watchdog: exit ${p.status}`);
102
+ return p.status ?? 1;
103
+ }
104
+ const picked = p.stdout.trim().split(/\s+/);
105
+ // The agent runs in a scratch folder (everything it needs is in the prompt): it can't touch the issue files, even write sandboxed.
106
+ const scratch = mkdtempSync(join(tmpdir(), "orq-watchdog-"));
107
+ try {
108
+ const a = orq("agent", ...picked, "-r", "-t", `${id} watchdog`, "-d", scratch, "-g", "watchdog", "-o", prefix, "--", `${prefix}.md`);
109
+ process.stderr.write(a.stderr);
110
+ process.stdout.write(a.stdout);
111
+ if (a.status !== 0)
112
+ return a.status ?? 1;
113
+ return await finish(await wait(["--timeout", String(WATCHDOG_TIMEOUT), prefix]), prefix, out);
114
+ }
115
+ finally {
116
+ rmSync(scratch, { recursive: true, force: true });
117
+ }
118
+ }
119
+ async function finish(rc, prefix, out) {
120
+ if (rc === 124) {
121
+ console.error(m(`diagnosis not written: the watchdog agent did not finish in ${WATCHDOG_TIMEOUT}s`, `diagnóstico não gravado: o agente watchdog não terminou em ${WATCHDOG_TIMEOUT}s`));
122
+ return 124;
123
+ }
124
+ if (rc !== 0 || !existsSync(`${prefix}.out.md`)) {
125
+ console.error(m("the watchdog agent failed; no diagnosis written", "o agente watchdog falhou; diagnóstico não gravado"));
126
+ return 1;
127
+ }
128
+ copyFileSync(`${prefix}.out.md`, out);
129
+ console.log(m(`diagnosis saved to ${out}`, `diagnóstico gravado em ${out}`));
130
+ console.log(recommendation(readFileSync(out, "utf8")) ?? m("(no RECOMMENDATION line in the answer)", "(sem linha RECOMMENDATION na resposta)"));
57
131
  return 0;
58
132
  }
@@ -5,7 +5,7 @@ import { expand } from "../paths.js";
5
5
  import { collectRuns, rows } from "../stats.js";
6
6
  export const USAGE = "orq stats [-w <workspace>] [--days N] [--json] scoreboard: delivery, time and tokens per agent";
7
7
  const k = (n) => (n === null ? "-" : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n));
8
- const ORDER = ["impl", "fix", "review", "other"];
8
+ const ORDER = ["impl", "fix", "review", "watchdog", "other"];
9
9
  export function stats(argv) {
10
10
  let ws, days = 30, json = false;
11
11
  for (let i = 0; i < argv.length; i++) {