@irythill/orq 0.2.0 → 0.4.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.
@@ -4,6 +4,10 @@
4
4
  // Returns at once; `orq wait <prefix>` blocks until .done. A conductor in herdr (HERDR_PANE_ID) also gets ONE line
5
5
  // "[HERDR-AGENT DONE] <title> status=... answer: <prefix>.out.md" typed into its pane by herdr-notify.
6
6
  // The launcher writes <prefix>.job.json and starts `orq agent --run <job>` on the terminal host; that runner does the rest.
7
+ // -T (or machine.tui / HERDR_AGENT_TUI=1): the agent's own screen (its `tui` template) in a herdr tab or tmux window,
8
+ // same cap and files. The agent calls `orq agent --turn <job> <kind>` at every end of turn, which writes .out.md/.id/
9
+ // .done and notifies; the screen stays open (holding memory) until you close it. -T -R with the screen still open types
10
+ // the follow-up into it; with the screen closed it reopens the session (tui_resume).
7
11
  import { spawnSync } from "node:child_process";
8
12
  import { randomUUID } from "node:crypto";
9
13
  import { appendFileSync, copyFileSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
@@ -11,14 +15,15 @@ import { basename, dirname, join, resolve } from "node:path";
11
15
  import { fileURLToPath } from "node:url";
12
16
  import { claudeAccounts } from "../accounts.js";
13
17
  import { agentDef, ALIASES, expandString, expandTemplate, FORMATS } from "../agents.js";
14
- import { loadConfig } from "../config.js";
18
+ import { getPath, loadConfig } from "../config.js";
15
19
  import { writeAtomic } from "../fsutil.js";
16
20
  import { m } from "../i18n.js";
21
+ import { alive } from "../lock.js";
17
22
  import { stateDir } from "../paths.js";
18
23
  import { killTree, parseSize, resolveMode, runCapped } from "../process.js";
19
- import { closeOwnTab, detectHost, launch, shq } from "../terminal.js";
24
+ import { closeOwnTab, detectHost, launch, screenOpen, shq, typeInto } from "../terminal.js";
20
25
  import { dropWorktree, notify } from "../testrun.js";
21
- export const USAGE = "orq agent -a <agent> -t <title> -d <dir> -o <prefix> [-g group] [-m model] [-e effort] [-c account] [-i file]... [-b base] [-r] [-R session] -- <prompt.md> run a child agent";
26
+ export const USAGE = "orq agent -a <agent> -t <title> -d <dir> -o <prefix> [-g group] [-m model] [-e effort] [-c account] [-i file]... [-b base] [-r] [-R session [-F]] [-T] -- <prompt.md> run a child agent";
22
27
  const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
23
28
  const PF = ".herdr-agent-prompt.md"; // prompt_in_dir file name (kept from herdr-agent)
24
29
  const git = (dir, ...a) => {
@@ -28,8 +33,10 @@ const git = (dir, ...a) => {
28
33
  export async function agent(argv) {
29
34
  if (argv[0] === "--run")
30
35
  return runJob(argv[1]);
36
+ if (argv[0] === "--turn")
37
+ return turn(argv[1], argv[2], argv.slice(3));
31
38
  let a = process.env.AGENT || "codex", g = "", t = "", d = "", o = "", model = "", effort = "", acc = "", resume = "", base = "";
32
- let ro = false, prompt;
39
+ let ro = false, tui = false, force = false, prompt;
33
40
  const inc = [];
34
41
  for (let i = 0; i < argv.length; i++) {
35
42
  const x = argv[i], v = () => argv[++i] ?? "";
@@ -63,10 +70,10 @@ export async function agent(argv) {
63
70
  v();
64
71
  else if (x === "-r")
65
72
  ro = true;
66
- else if (x === "-T") {
67
- console.error(m("-T (the agent's own TUI) only exists in herdr: use herdr-agent -T", "-T (a TUI do próprio agente) só existe no herdr: use herdr-agent -T"));
68
- return 2;
69
- }
73
+ else if (x === "-T")
74
+ tui = true;
75
+ else if (x === "-F")
76
+ force = true;
70
77
  else if (!x.startsWith("-") && prompt === undefined)
71
78
  prompt = x;
72
79
  else {
@@ -94,11 +101,44 @@ export async function agent(argv) {
94
101
  console.error(m(`-c only applies to agents with Claude accounts (claude)`, `-c só vale para agentes com contas Claude (claude)`));
95
102
  return 2;
96
103
  }
97
- if (resume && !def.resume) {
104
+ const mach = (cfg.machine ?? {});
105
+ const host = detectHost(cfg);
106
+ // Screen by default (machine.tui, HERDR_AGENT_TUI=1) only where it can be: a terminal host and an agent with a screen.
107
+ if (!tui && (process.env.HERDR_AGENT_TUI === "1" || mach.tui === true) && host !== "plain" && def.tui && (!resume || def.tui_resume))
108
+ tui = true;
109
+ if (tui && !def.tui) {
110
+ console.error(m(`-T: ${a} has no screen mode (agents.${a}.tui)`, `-T: ${a} não tem modo tela (agents.${a}.tui)`));
111
+ return 2;
112
+ }
113
+ if (resume && !tui && !def.resume) {
98
114
  console.error(m(`${a} has no resume template (agents.${a}.resume)`, `${a} não tem template de resume (agents.${a}.resume)`));
99
115
  return 2;
100
116
  }
117
+ if (tui && resume && !def.tui_resume) {
118
+ console.error(m(`-T -R: ${a} has no tui_resume template`, `-T -R: ${a} não tem template tui_resume`));
119
+ return 2;
120
+ }
121
+ if (tui && host === "plain") {
122
+ console.error(m("-T shows the agent's own screen: it needs herdr or tmux (run inside one, or set ORQ_TERMINAL)", "-T mostra a tela do próprio agente: precisa do herdr ou do tmux (rode dentro de um, ou defina ORQ_TERMINAL)"));
123
+ return 2;
124
+ }
101
125
  const repo = resolve(d), O = resolve(o);
126
+ // Fix rounds: every -R on the same session counts, whatever the prefix is called. policy.max_rounds is enforced here,
127
+ // not left to the conductor to remember (a 7-round fix loop is what burns the tokens). -F = the user said go on.
128
+ if (resume) {
129
+ const max = Number(getPath(cfg, "policy.max_rounds") ?? 2), cf = join(dirname(O), `.rounds-${resume}`);
130
+ let n = 0;
131
+ try {
132
+ n = Number(readFileSync(cf, "utf8")) || 0;
133
+ }
134
+ catch { /* first follow-up */ }
135
+ if (n >= max && !force) {
136
+ console.error(m(`round limit: session ${resume} already had ${n} follow-up(s) (policy.max_rounds = ${max}). Stop and tell the user; with their OK, repeat with -F.`, `limite de rodadas: a sessão ${resume} já teve ${n} follow-up(s) (policy.max_rounds = ${max}). Pare e avise o usuário; com o OK dele, repita com -F.`));
137
+ return 4;
138
+ }
139
+ mkdirSync(dirname(O), { recursive: true });
140
+ writeFileSync(cf, String(n + 1));
141
+ }
102
142
  let P = resolve(prompt);
103
143
  if (!existsSync(repo)) {
104
144
  console.error(m(`no folder ${repo}`, `pasta ${repo} não existe`));
@@ -119,9 +159,6 @@ export async function agent(argv) {
119
159
  writeFileSync(`${O}.diff`, r.stdout);
120
160
  inc.push(`${O}.diff`);
121
161
  }
122
- // Follow-ups (-R) reuse the prefix: remove the previous round's result so ".done exists" means "this one finished".
123
- for (const s of [".done", ".out.md", ".pid"])
124
- rmSync(O + s, { force: true });
125
162
  if (inc.length) {
126
163
  let text = readFileSync(P, "utf8");
127
164
  for (const f of inc)
@@ -129,6 +166,24 @@ export async function agent(argv) {
129
166
  writeFileSync(`${O}.prompt.md`, text);
130
167
  P = `${O}.prompt.md`;
131
168
  }
169
+ // Follow-ups (-R) reuse the prefix: remove the previous round's result so ".done exists" means "this one finished".
170
+ // -T -R with the screen still open: the follow-up is typed into it (its runner, and .pid, carry on). Open = the agent
171
+ // is still running there, not just the pane: after it exits the pane may hold a shell, which must never get the text.
172
+ let open = "", pid = 0;
173
+ try {
174
+ open = readFileSync(`${O}.pane`, "utf8").trim();
175
+ pid = Number(readFileSync(`${O}.pid`, "utf8"));
176
+ }
177
+ catch { /* first run or ended */ }
178
+ if (tui && resume && alive(pid) && screenOpen(open)) {
179
+ for (const s of [".done", ".out.md"])
180
+ rmSync(O + s, { force: true });
181
+ typeInto(open, readFileSync(P, "utf8"));
182
+ console.log(`[orq agent] ${t} → ${open} ${m("(follow-up typed into the open screen)", "(follow-up digitado na tela aberta)")}`);
183
+ return 0;
184
+ }
185
+ for (const s of [".done", ".out.md", ".pid"])
186
+ rmSync(O + s, { force: true });
132
187
  const throwaway = Boolean(def.throwaway && ro && !resume);
133
188
  const dir = throwaway ? `${repo}.review-${process.pid}` : repo;
134
189
  const gd = git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir");
@@ -140,22 +195,36 @@ export async function agent(argv) {
140
195
  }
141
196
  catch { /* none */ }
142
197
  const session = resume || (def.session === "uuid" ? randomUUID() : "");
198
+ const tpl = tui ? (resume ? def.tui_resume : def.tui) : resume ? def.resume : def.run;
199
+ // End of turn (-T): the agent runs `orq agent --turn <job> <kind>`; Claude gets it as a Stop hook, Codex as notify,
200
+ // OpenCode through a plugin orq writes next to the job.
201
+ const hook = [process.execPath, CLI, "agent", "--turn", `${O}.job.json`, def.turn_hook ?? "text"];
202
+ // OpenCode v2 drops a partial MCP entry without a word: each one turned off needs its type and command too.
203
+ const off = { type: "local", command: ["true"], enabled: false };
204
+ const ocCfg = {
205
+ mcp: { plane: off, shadcn: off, "linear-server": off },
206
+ permission: { bash: "allow", question: "deny", webfetch: "deny",
207
+ external_directory: { "*": "deny", ...(gd ? { [`${gd}/*`]: "allow" } : {}), "/tmp/*": "allow", ...(nm ? { [`${nm}/*`]: "allow" } : {}) },
208
+ edit: ro ? "deny" : "allow" },
209
+ };
210
+ if (tui && def.turn_hook === "opencode") {
211
+ mkdirSync(`${O}.opencode`, { recursive: true }); // a v2 plugin is a folder with index.js
212
+ writeFileSync(join(`${O}.opencode`, "index.js"), opencodePlugin(O, hook));
213
+ ocCfg.plugin = [`${O}.opencode`];
214
+ if (model && model !== "auto")
215
+ ocCfg.model = model; // the screen has no -m
216
+ }
143
217
  const vars = {
144
- prompt: P, prompt_text: def.run.includes("{prompt_text}") || def.resume?.includes("{prompt_text}") ? readFileSync(P, "utf8") : "",
218
+ prompt: P, prompt_text: tpl.includes("{prompt_text}") ? readFileSync(P, "utf8") : "",
145
219
  prompt_hint: `Read the file ${PF} (in the current folder, with your file-reading tool): it holds your complete instructions for this task. Follow them. ${PF} is not part of the code: don't review or edit it.`,
146
- out: `${O}.out.md`, dir, repo, title: t, model: model === "auto" ? "" : model, effort, session,
147
- gitdir: gd ?? "", gitroots: roots, gitroots_json: JSON.stringify(roots), mode: [],
148
- opencode_config: JSON.stringify({
149
- mcp: { plane: { enabled: false }, shadcn: { enabled: false }, "linear-server": { enabled: false } },
150
- permission: { bash: "allow", question: "deny", webfetch: "deny",
151
- external_directory: { "*": "deny", ...(gd ? { [`${gd}/*`]: "allow" } : {}), "/tmp/*": "allow", ...(nm ? { [`${nm}/*`]: "allow" } : {}) },
152
- edit: ro ? "deny" : "allow" },
153
- }),
220
+ out: `${O}.out.md`, dir, repo, base, title: t, model: model === "auto" ? "" : model, effort, session,
221
+ gitdir: gd ?? "", gitroots: roots, gitroots_json: JSON.stringify(roots), mode: [], opencode_config: JSON.stringify(ocCfg),
222
+ hook_cmd: hook.map(shq).join(" "), hook_json: JSON.stringify(hook),
223
+ hook_settings: JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: "command", command: hook.map(shq).join(" ") }] }] } }),
154
224
  };
155
225
  vars.mode = expandTemplate((ro ? def.readonly : def.write) || "", vars);
156
- const tpl = resume ? def.resume : def.run;
157
226
  const cmd = expandTemplate(tpl, vars);
158
- if (!tpl.includes("{mode}") && !resume)
227
+ if (!tpl.includes("{mode}") && !resume && !tui)
159
228
  cmd.push(...vars.mode);
160
229
  const env = {};
161
230
  for (const [k, val] of Object.entries(def.env ?? {}))
@@ -168,9 +237,7 @@ export async function agent(argv) {
168
237
  }
169
238
  env.CLAUDE_CONFIG_DIR = ac.dir;
170
239
  }
171
- const mach = (cfg.machine ?? {});
172
240
  const closeAfter = Number(process.env.HERDR_CLOSE_DELAY ?? mach.close_tab_after ?? 20);
173
- const host = detectHost(cfg);
174
241
  const unit = `herdr-agent-${Math.floor(Date.now() / 1000)}-${process.pid}`; // herdr-board lists herdr-agent-* scopes
175
242
  const job = {
176
243
  agent: a, title: t, repo, dir, prefix: O, prompt: P, argv: cmd, env, unset: def.unset_env ?? [], stdin: Boolean(def.stdin),
@@ -179,7 +246,8 @@ export async function agent(argv) {
179
246
  host, unit, cap: parseSize(def.mem_max ?? mach.agent_mem_max ?? "off"),
180
247
  swap: parseSize(mach.agent_swap_max), mode: mach.mem_enforce ?? "auto",
181
248
  throwaway, promptInDir: Boolean(def.prompt_in_dir), closeAfter,
182
- keep: process.env.HERDR_KEEP_TAB === "1" || process.env.ORQ_KEEP_TAB === "1" || closeAfter <= 0,
249
+ keep: process.env.HERDR_KEEP_TAB === "1" || process.env.ORQ_KEEP_TAB === "1" || closeAfter <= 0, tui,
250
+ okExit: def.ok_exit, filter: def.filter, noReview: def.no_review,
183
251
  };
184
252
  writeAtomic(`${O}.job.json`, JSON.stringify(job, null, 1));
185
253
  // Readable copy of the command (also what orq stats and herdr-board read).
@@ -187,9 +255,124 @@ export async function agent(argv) {
187
255
  `cd ${shq(dir)}\n${Object.entries(env).map(([k, val]) => `${k}=${shq(val)} `).join("")}${cmd.map(shq).join(" ")}${job.stdin ? ` < ${shq(P)}` : ""}\n`);
188
256
  const where = launch(host, { group: g || "agents", title: t, dir: repo, argv: [process.execPath, CLI, "agent", "--run", `${O}.job.json`], id: unit, keep: job.keep });
189
257
  writeFileSync(`${O}.pane`, `${where}\n`);
190
- console.log(`[orq agent] ${t} → ${where}`);
258
+ console.log(`[orq agent] ${t} → ${where}${tui ? " (TUI)" : ""}`);
191
259
  return 0;
192
260
  }
261
+ /** OpenCode (v2) plugin for -T: when the main session's run ends, its last answer goes to `orq agent --turn`. */
262
+ function opencodePlugin(O, hook) {
263
+ return `// orq agent -T end of turn (generated)
264
+ import { writeFileSync } from "fs"; import { spawn } from "child_process";
265
+ export default {
266
+ id: "orq-turn",
267
+ setup: async (ctx) => {
268
+ const sub = new Set(), text = new Map(); // child sessions (subagents); answer text per session
269
+ (async () => {
270
+ for await (const e of ctx.event.subscribe()) {
271
+ const d = e.data ?? {}, id = d.sessionID;
272
+ if (e.type === "session.created" && d.parentID) sub.add(id);
273
+ if (!id || sub.has(id)) continue;
274
+ if (e.type === "session.execution.started") text.set(id, { msg: "", parts: [] });
275
+ else if (e.type === "session.text.ended") {
276
+ const t = text.get(id) ?? { msg: "", parts: [] };
277
+ if (t.msg !== d.assistantMessageID) { t.msg = d.assistantMessageID; t.parts = []; } // only the last message
278
+ t.parts[d.ordinal ?? t.parts.length] = d.text ?? ""; text.set(id, t);
279
+ } else if (e.type.startsWith("session.execution.") && e.type !== "session.execution.started") {
280
+ const f = ${JSON.stringify(`${O}.turn.md`)};
281
+ writeFileSync(f, (text.get(id)?.parts ?? []).filter((x) => x !== undefined).join("\\n"));
282
+ spawn(${JSON.stringify(hook[0])}, [...${JSON.stringify(hook.slice(1))}, id, f], { detached: true, stdio: "ignore" }).unref();
283
+ }
284
+ }
285
+ })().catch(() => {});
286
+ return {};
287
+ },
288
+ };
289
+ `;
290
+ }
291
+ /** End of a turn on the agent's screen: answer, session and .done from the agent's own hook payload. Never fails the agent. */
292
+ function turn(file, kind, rest) {
293
+ try {
294
+ const j = JSON.parse(readFileSync(file, "utf8")), O = j.prefix;
295
+ let answer = "", sid;
296
+ if (kind === "codex") {
297
+ const e = JSON.parse(rest[rest.length - 1] ?? "{}");
298
+ if (e.type !== "agent-turn-complete")
299
+ return 0;
300
+ answer = String(e["last-assistant-message"] ?? "");
301
+ sid = e["thread-id"];
302
+ }
303
+ else if (kind === "claude") {
304
+ const e = JSON.parse(readFileSync(0, "utf8"));
305
+ if (e.stop_hook_active)
306
+ return 0;
307
+ sid = e.session_id;
308
+ answer = String(e.last_assistant_message ?? "") || lastClaudeText(String(e.transcript_path ?? ""));
309
+ }
310
+ else if (kind === "opencode") {
311
+ sid = rest[0];
312
+ try {
313
+ answer = readFileSync(rest[1], "utf8");
314
+ rmSync(rest[1], { force: true });
315
+ }
316
+ catch { /* none */ }
317
+ }
318
+ else {
319
+ sid = rest[0];
320
+ answer = readFileSync(0, "utf8");
321
+ }
322
+ writeFileSync(`${O}.out.md`, answer);
323
+ if (sid)
324
+ writeFileSync(`${O}.id`, `${sid}\n`);
325
+ const noReview = noReviewMatch(j.noReview, answer);
326
+ const st = /\S/.test(answer) && !noReview ? "ok" : "fail";
327
+ writeAtomic(`${O}.done`, `status=${st} rc=0 at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
328
+ if (j.parent)
329
+ notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=0 answer: ${O}.out.md`, true);
330
+ }
331
+ catch (e) {
332
+ console.error(`orq agent --turn: ${e.message}`);
333
+ }
334
+ return 0;
335
+ }
336
+ /** The last text Claude wrote, from its transcript (older Stop hooks have no last_assistant_message). */
337
+ function lastClaudeText(transcript) {
338
+ let text = "";
339
+ try {
340
+ for (const l of readFileSync(transcript, "utf8").split(/\r?\n/)) {
341
+ try {
342
+ const e = JSON.parse(l);
343
+ if (e.type !== "assistant")
344
+ continue;
345
+ const t = (e.message?.content ?? []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
346
+ if (t)
347
+ text = t;
348
+ }
349
+ catch { /* not JSON */ }
350
+ }
351
+ }
352
+ catch { /* no transcript */ }
353
+ return text;
354
+ }
355
+ /** agents.<name>.no_review tested against the start of the answer (first line) or, for a short answer, the whole
356
+ * thing — not anywhere in a long one, so a review that quotes the phrase while discussing real code doesn't match.
357
+ * An invalid pattern is logged and ignored rather than killing the runner. */
358
+ function noReviewMatch(pattern, answer) {
359
+ if (pattern === undefined)
360
+ return false;
361
+ if (typeof pattern !== "string" || !pattern.trim()) {
362
+ console.error(`orq agent: ignoring invalid agents.<name>.no_review ${JSON.stringify(pattern)}: expected a non-empty regex string`);
363
+ return false;
364
+ }
365
+ let re;
366
+ try {
367
+ re = new RegExp(pattern, "m");
368
+ }
369
+ catch (e) {
370
+ console.error(`orq agent: ignoring invalid agents.<name>.no_review pattern ${JSON.stringify(pattern)}: ${e.message}`);
371
+ return false;
372
+ }
373
+ const first = answer.split(/\r?\n/, 1)[0] ?? "";
374
+ return re.test(first) || (answer.trim().length <= 200 && re.test(answer));
375
+ }
193
376
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
194
377
  /** Splits a stream into lines for `each`. */
195
378
  function lines(s, each) {
@@ -218,7 +401,6 @@ async function runJob(file) {
218
401
  catch { /* best effort */ } };
219
402
  state();
220
403
  const fmt = FORMATS[j.format] ?? FORMATS.text;
221
- const log = createWriteStream(`${O}.log`);
222
404
  let rc = 0, stdout = "", logText = "";
223
405
  const pre = () => {
224
406
  if (j.throwaway) {
@@ -254,6 +436,51 @@ async function runJob(file) {
254
436
  if (j.throwaway)
255
437
  dropWorktree(j.repo, j.dir);
256
438
  };
439
+ if (j.tui) {
440
+ // The agent's own screen: the terminal is its; answers come from the end-of-turn hook (`--turn`).
441
+ let ended = false;
442
+ const end = (code) => {
443
+ if (ended)
444
+ return "fail";
445
+ ended = true;
446
+ post();
447
+ // Closed before any turn ended (or mid follow-up): a failure, so `orq wait` doesn't hang.
448
+ const ok = /\bstatus=ok\b/.test(existsSync(`${O}.done`) ? readFileSync(`${O}.done`, "utf8") : "");
449
+ if (!existsSync(`${O}.done`)) {
450
+ writeAtomic(`${O}.done`, `status=fail rc=${code} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
451
+ if (j.parent)
452
+ notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=${code} answer: ${O}.out.md`, true);
453
+ }
454
+ rmSync(`${O}.pid`, { force: true });
455
+ return ok ? "ok" : "fail";
456
+ };
457
+ if (!pre())
458
+ rc = 2;
459
+ else {
460
+ const env = { ...process.env, ...j.env };
461
+ for (const k of j.unset)
462
+ delete env[k];
463
+ // Ctrl+C belongs to the agent (it reaches us too, as part of the terminal's foreground group). The screen going
464
+ // away (tab or tmux server closed) still ends the agent and cleans up.
465
+ let child;
466
+ process.on("SIGINT", () => { });
467
+ const gone = () => { if (child)
468
+ killTree(child, false); end(129); process.exit(0); };
469
+ for (const sig of ["SIGHUP", "SIGTERM"])
470
+ process.on(sig, gone);
471
+ 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,
472
+ onSpawn: (c) => { child = c.pid; } });
473
+ }
474
+ const st = end(rc);
475
+ state({ status: st, rc, end: new Date().toISOString() });
476
+ console.log(`\n[${j.agent} (TUI) ${m("exited", "encerrado")}] ${m("answer", "resposta")}: ${O}.out.md`);
477
+ if (!j.keep) {
478
+ await sleep(j.closeAfter * 1000);
479
+ closeOwnTab(j.host);
480
+ }
481
+ return 0;
482
+ }
483
+ const log = createWriteStream(`${O}.log`);
257
484
  if (!pre())
258
485
  rc = 2;
259
486
  else {
@@ -314,12 +541,26 @@ async function runJob(file) {
314
541
  writeFileSync(`${O}.id`, `${sid}\n`);
315
542
  if (rc === 0 && res.rc)
316
543
  rc = res.rc;
544
+ // A review tool exits non-zero on findings: an ok_exit code is a verdict (rc stays in .done), not a failure.
545
+ let verdict = rc === 0 || (j.okExit ?? []).includes(rc);
546
+ if (j.filter && verdict) {
547
+ const f = spawnSync("sh", ["-c", j.filter], { cwd: j.dir, input: readFileSync(`${O}.out.md`, "utf8"), encoding: "utf8", maxBuffer: 1 << 30 });
548
+ if (f.status === 0)
549
+ writeFileSync(`${O}.out.md`, f.stdout);
550
+ else {
551
+ appendFileSync(`${O}.log`, `[orq agent] filter failed (exit ${f.status}): ${f.stderr}\n`);
552
+ verdict = false;
553
+ }
554
+ }
317
555
  let answer = "";
318
556
  try {
319
557
  answer = readFileSync(`${O}.out.md`, "utf8");
320
558
  }
321
559
  catch { /* none */ }
322
- const st = rc === 0 && /\S/.test(answer) ? "ok" : "fail"; // whitespace-only answer = failure
560
+ // A reviewer whose answer says nothing was reviewed (agents.<name>.no_review) is not a verdict either: pick's
561
+ // fallback and the conductor must see it as not run, same as a whitespace-only answer.
562
+ const noReview = noReviewMatch(j.noReview, answer);
563
+ const st = verdict && !noReview && /\S/.test(answer) ? "ok" : "fail"; // whitespace-only answer = failure
323
564
  writeAtomic(`${O}.done`, `status=${st} rc=${rc} at=${new Date().toISOString()} agent=${j.agent}\n`);
324
565
  rmSync(`${O}.pid`, { force: true });
325
566
  state({ status: st, rc, end: new Date().toISOString() });
@@ -1,9 +1,11 @@
1
1
  // orq block — takes an agent (or account) out of orq-pick until a date. For quotas without a collector
2
2
  // (Antigravity, Copilot, MiniMax) or any outage you know about before the agent fails.
3
- // State in <state dir>/blocks.json ({name: {until: <epoch seconds>, reason}}), shared with orq-pick.
3
+ // State in <state dir>/blocks.json ({name: {until: <epoch seconds>, reason, acct}}), shared with orq-pick. `acct` is the
4
+ // login at block time (src/identity.ts): if the agent/account is logged into another one later, the block is dropped.
4
5
  import { readFileSync } from "node:fs";
5
6
  import { join } from "node:path";
6
7
  import { writeAtomic } from "../fsutil.js";
8
+ import { loginId } from "../identity.js";
7
9
  import { m } from "../i18n.js";
8
10
  import { stateDir } from "../paths.js";
9
11
  export const USAGE = `orq block <agent|account> [--until YYYY-MM-DD[THH:MM] | --days N] [--reason "text"] default: 7 days
@@ -19,7 +21,21 @@ export function load() {
19
21
  b = JSON.parse(readFileSync(file(), "utf8"));
20
22
  }
21
23
  catch { /* missing or broken: no blocks */ }
22
- return Object.fromEntries(Object.entries(b).filter(([, v]) => (v?.until ?? 0) > now()));
24
+ const live = Object.fromEntries(Object.entries(b).filter(([, v]) => (v?.until ?? 0) > now()));
25
+ // A block made under another login (the account was switched) no longer applies: the new one has its own quota.
26
+ const kept = Object.fromEntries(Object.entries(live).filter(([k, v]) => {
27
+ if (!v.acct)
28
+ return true;
29
+ const cur = loginId(k);
30
+ return cur === undefined || cur === v.acct;
31
+ }));
32
+ if (Object.keys(kept).length !== Object.keys(live).length) {
33
+ try {
34
+ save(kept);
35
+ }
36
+ catch { /* best effort */ }
37
+ }
38
+ return kept;
23
39
  }
24
40
  const save = (b) => writeAtomic(file(), JSON.stringify(b, null, 2));
25
41
  /** "YYYY-MM-DD" or "YYYY-MM-DDTHH:MM" in local time (a bare date in JS would be UTC). */
@@ -67,7 +83,8 @@ export function block(argv) {
67
83
  console.error(USAGE);
68
84
  return 2;
69
85
  }
70
- b[name] = { until: until.getTime() / 1000, reason };
86
+ const acct = loginId(name);
87
+ b[name] = { until: until.getTime() / 1000, reason, ...(acct ? { acct } : {}) };
71
88
  save(b);
72
89
  console.log(`${name} ${m("out of orq-pick until", "fora do orq-pick até")} ${fmt(until)}`);
73
90
  return 0;
@@ -7,11 +7,12 @@
7
7
  // Refreshes every --interval s on a TTY; otherwise (or with --once) prints once; --json for scripts.
8
8
  // Keys: q quits · r refreshes the quota · l language · t theme (ui.theme: gunmetal | chrome | lcd | terminal).
9
9
  import { execFile, spawn } from "node:child_process";
10
- import { closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync } from "node:fs";
10
+ import { appendFileSync, closeSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync } from "node:fs";
11
11
  import { freemem, homedir, totalmem } from "node:os";
12
12
  import { basename, dirname, join, resolve } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { colorMode } from "../banner.js";
15
+ import { collectCi, collectedAt, loadCache as loadCiCache } from "../ci.js";
15
16
  import { getPath, loadConfig } from "../config.js";
16
17
  import { writeAtomic } from "../fsutil.js";
17
18
  import { m, setLang } from "../i18n.js";
@@ -19,8 +20,10 @@ import { alive, status as lockStatus } from "../lock.js";
19
20
  import { expand, stateDir } from "../paths.js";
20
21
  import { processTableAsync, treeMem } from "../process.js";
21
22
  import { collect } from "../quota.js";
23
+ import { collectRounds, mark, roundsSig } from "../rounds.js";
22
24
  import { collectRuns, rows } from "../stats.js";
23
25
  import { load as loadBlocks } from "./block.js";
26
+ import { herdrNamesAsync, nameOf } from "./claim.js";
24
27
  import { setValue } from "./set.js";
25
28
  export const USAGE = "orq board [-w <workspace>]... [--once] [--json] [--interval S] live board: queue, agents, conductors, quota";
26
29
  const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
@@ -124,16 +127,40 @@ function agents(table, now) {
124
127
  }
125
128
  const field = (t, k) => new RegExp(`${k}=(\\S+)`).exec(t)?.[1] ?? "";
126
129
  const herdrAlive = (id) => new Promise((res) => execFile("herdr", ["agent", "get", id], { timeout: 5000, windowsHide: true }, (e) => res(!e)));
130
+ // The board redraws every few seconds: reread an issue's reviews only when its folder changed.
131
+ const roundsCache = new Map();
132
+ function cachedRounds(ws, id, max) {
133
+ const key = `${ws}\0${id}\0${max}`, sig = roundsSig(ws, id), hit = roundsCache.get(key);
134
+ if (hit && hit.sig === sig)
135
+ return hit.x;
136
+ const x = collectRounds(ws, id, max);
137
+ roundsCache.set(key, { sig, x });
138
+ return x;
139
+ }
127
140
  async function conductors(wss) {
128
141
  const by = new Map();
129
142
  for (const ws of wss) {
130
143
  const O = join(ws, ".orquestra");
131
- for (const id of existsSync(O) ? readdirSync(O) : []) {
144
+ const max = Number((() => { try {
145
+ return getPath(loadConfig(ws).cfg, "policy.max_rounds");
146
+ }
147
+ catch {
148
+ return undefined;
149
+ } })() ?? 2);
150
+ for (const id of existsSync(O) ? readdirSync(O).filter((x) => !x.startsWith(".")) : []) {
132
151
  const t = read(join(O, id, "owner")).trim();
133
152
  if (!t)
134
153
  continue;
135
154
  const who = field(t, "pane"), at = Date.parse(field(t, "at")) || 0;
136
- const c = by.get(who) ?? { id: who, kind: field(t, "kind") || "herdr", account: field(t, "account"), alive: false, issues: [], at };
155
+ const c = by.get(who) ?? { id: who, kind: field(t, "kind") || "herdr", account: field(t, "account"), alive: false, issues: [], at, name: nameOf(t) };
156
+ if (!c.name)
157
+ c.name = nameOf(t);
158
+ try {
159
+ const x = cachedRounds(ws, id, max);
160
+ if (x)
161
+ (c.rounds ??= {})[id] = { rounds: x.rounds, max: x.max, level: x.level };
162
+ }
163
+ catch { /* unreadable issue folder: no marker */ }
137
164
  c.issues.push(id);
138
165
  c.at = Math.max(c.at, at);
139
166
  by.set(who, c);
@@ -141,7 +168,15 @@ async function conductors(wss) {
141
168
  }
142
169
  const ttl = Number(process.env.ORQ_CLAIM_TTL_H ?? 24) * 3600_000;
143
170
  const out = [...by.values()];
144
- await Promise.all(out.map(async (c) => { c.alive = c.kind === "herdr" ? await herdrAlive(c.id) : Date.now() - c.at < ttl; }));
171
+ // One `herdr workspace list` per refresh, shared by every conductor (not 2 spawns each), off the event
172
+ // loop (execFile, not spawnSync) so a slow/busy herdr daemon delays only this redraw, never key handling.
173
+ const names = out.some((c) => c.kind === "herdr") ? await herdrNamesAsync(out.filter((c) => c.kind === "herdr").map((c) => c.id)) : new Map();
174
+ await Promise.all(out.map(async (c) => {
175
+ c.alive = c.kind === "herdr" ? await herdrAlive(c.id) : Date.now() - c.at < ttl;
176
+ // Alive herdr conductor: the workspace's current label (the user may have renamed it since the claim).
177
+ if (c.alive && c.kind === "herdr")
178
+ c.name = names.get(c.id) || c.name;
179
+ }));
145
180
  return out.filter((c) => c.alive).map(({ at: _, ...c }) => ({ ...c, issues: c.issues.sort() }));
146
181
  }
147
182
  // Quota: the board never waits for the network. A stale cache starts `orq board --collect-quota` detached.
@@ -149,8 +184,9 @@ const qdir = () => join(stateDir(), "board");
149
184
  const qfile = () => join(qdir(), "quota.txt");
150
185
  export function parseQuota(text) {
151
186
  return text.split(/\r?\n/).flatMap((l) => {
152
- const r = /^(\S+) 5h=(\S+) week=(\S+)(?: \[(.*)\])?/.exec(l.trim());
153
- return r ? [{ name: r[1], five: r[2], week: r[3], ...(r[4] ? { note: r[4] } : {}) }] : [];
187
+ const r = /^(\S+) 5h=(\S+) week=(\S+)(?: reset5h=(\d+))?(?: resetweek=(\d+))?(?: span5h=\d+)?(?: spanweek=\d+)?(?: \[(.*)\])?/.exec(l.trim());
188
+ return r ? [{ name: r[1], five: r[2], week: r[3], ...(r[4] ? { reset5h: Number(r[4]) } : {}),
189
+ ...(r[5] ? { resetWeek: Number(r[5]) } : {}), ...(r[6] ? { note: r[6] } : {}) }] : [];
154
190
  });
155
191
  }
156
192
  export function quota(now) {
@@ -179,6 +215,41 @@ async function collectQuota() {
179
215
  writeAtomic(qfile(), lines.join("\n") + "\n");
180
216
  return 0;
181
217
  }
218
+ // CI: the same never-block-the-board rule as quota — a stale cache starts `orq board --collect-ci -w <ws>...`
219
+ // detached, reusing this same timer instead of a new one; the board itself only ever reads the cache file.
220
+ function startCollectCi(wss) {
221
+ const lock = join(qdir(), "ci.pid");
222
+ const pid = Number(read(lock));
223
+ if (pid > 0 && alive(pid))
224
+ return;
225
+ mkdirSync(qdir(), { recursive: true });
226
+ const c = spawn(process.execPath, [CLI, "board", ...wss.flatMap((w) => ["-w", w]), "--collect-ci"], { detached: true, stdio: "ignore", windowsHide: true });
227
+ if (c.pid)
228
+ writeAtomic(lock, String(c.pid));
229
+ c.unref();
230
+ }
231
+ // Age per requested workspace, not the shared cache file's mtime: ci.json holds every workspace's entries and
232
+ // collectCi() rewrites the whole file on every run, so one workspace's collection would otherwise renew the
233
+ // mtime for all of them and starve a second workspace that was never actually collected. collectedAt() reads a
234
+ // per-workspace marker that collectCi() writes even when it finds zero entries (no claimed issue with a matching
235
+ // worktree) — without it, a workspace with nothing to collect would look permanently stale (entries' own `at`
236
+ // would never exist for it) and the board would relaunch the collector every redraw forever.
237
+ function ci(wss, now) {
238
+ const entries = loadCiCache().filter((e) => wss.includes(e.ws));
239
+ const ages = wss.map((w) => { const at = collectedAt(w); return at === undefined ? undefined : now - at; });
240
+ const age = ages.some((a) => a === undefined) ? undefined : Math.max(...ages);
241
+ if (age === undefined || age > QTTL)
242
+ startCollectCi(wss);
243
+ return { entries, age };
244
+ }
245
+ // Detached (stdio: "ignore"): collectCi()'s default notice (console.log) would otherwise vanish into /dev/null,
246
+ // so a non-herdr conductor's "CI failed" line is appended to a log file instead.
247
+ function collectCiAll(wss) {
248
+ const notice = (l) => appendFileSync(join(qdir(), "ci.log"), `${l}\n`);
249
+ for (const ws of wss)
250
+ collectCi(ws, { notice });
251
+ return 0;
252
+ }
182
253
  let sb;
183
254
  function scoreboard(ws, now) {
184
255
  if (!sb || sb.ws !== ws || now - sb.at > 60_000) {
@@ -197,7 +268,7 @@ export async function snapshot(wss, table) {
197
268
  return {
198
269
  at: new Date(now).toISOString(), mem: { used: totalmem() - freemem(), total: totalmem() }, workspaces: wss,
199
270
  queue: { holders: q.holders.map(item), waiters: q.waiters.map(item) }, agents: agents(table, now),
200
- conductors: await conductors(wss), quota: quota(now), blocks: loadBlocks(), scoreboard: scoreboard(wss[0], now),
271
+ conductors: await conductors(wss), quota: quota(now), ci: ci(wss, now), blocks: loadBlocks(), scoreboard: scoreboard(wss[0], now),
201
272
  };
202
273
  }
203
274
  // --- render -----------------------------------------------------------------------------------------------------
@@ -276,9 +347,15 @@ export function render(s, theme, cols, mode = colorMode()) {
276
347
  out.push(` ${c.mut}${m("none", "nenhum")}${c.r}`);
277
348
  section("MASTER", m("conductors", "conductors"));
278
349
  for (const k of s.conductors)
279
- out.push(` ${c.ok}●${c.r} ${c.mut}${fit(k.id, 22)}${c.r} ${c.ink2}${fit(k.account || "-", 14)}${c.r} ${c.ink}${k.issues.join(" ")}${c.r}`);
350
+ 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}`);
280
351
  if (!s.conductors.length)
281
352
  out.push(` ${c.mut}${m("no claimed issues", "nenhuma issue reivindicada")}${c.r}`);
353
+ section("MONITOR", m("CI/CD", "CI/CD"));
354
+ const ciMark = { passed: `${c.ok}✓`, failed: `${c.err}✗`, running: `${c.acc}●`, queued: `${c.mut}‖`, none: `${c.mut}·` };
355
+ for (const e of s.ci.entries)
356
+ out.push(` ${ciMark[e.state]}${c.r} ${c.ink}${fit(e.issue, 10)}${c.r} ${c.ink2}${fit(e.repo, 14)}${c.r} ${c.mut}${fit(e.branch, 24)}${c.r} ${c.ink}${(e.pr ? `#${e.pr}` : "-").padEnd(6)}${c.r} ${c.ink2}${e.state}${c.r}`);
357
+ if (!s.ci.entries.length)
358
+ out.push(` ${c.mut}${s.ci.age === undefined ? m("collecting...", "coletando...") : m("no claimed issue has a PR", "nenhuma issue reivindicada tem PR")}${c.r}`);
282
359
  section("MIXER", m("quota", "cota"));
283
360
  const win = (v, tag) => {
284
361
  const n = /^(\d+)%?$/.exec(v)?.[1];
@@ -329,6 +406,8 @@ export async function board(argv) {
329
406
  interval = Math.max(1, Number(argv[++i]) || 3);
330
407
  else if (a === "--collect-quota")
331
408
  return collectQuota();
409
+ else if (a === "--collect-ci")
410
+ return collectCiAll(wss);
332
411
  else if (a === "-h" || a === "--help") {
333
412
  console.log(USAGE);
334
413
  return 0;