@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.
- package/README.md +59 -38
- package/README.pt-BR.md +60 -38
- package/dist/agents.js +80 -6
- package/dist/ci.js +6 -6
- package/dist/cli.js +3 -1
- package/dist/commands/agent.js +146 -20
- package/dist/commands/board.js +90 -20
- package/dist/commands/claim.js +2 -0
- package/dist/commands/completion.js +1 -1
- package/dist/commands/conductor.js +104 -0
- package/dist/commands/gc.js +57 -21
- package/dist/commands/handoff.js +1 -0
- package/dist/commands/quota.js +1 -1
- package/dist/commands/rounds.js +45 -18
- package/dist/commands/wait.js +5 -1
- package/dist/config.js +5 -1
- package/dist/install.js +5 -0
- package/dist/issue.js +47 -0
- package/dist/notice.js +33 -5
- package/dist/pick.js +105 -17
- package/dist/process.js +7 -0
- package/dist/quota.js +85 -19
- package/dist/stats.js +2 -1
- package/package.json +3 -2
- package/skills/issue/SKILL.md +41 -41
- package/templates/conductor.instructions.md +31 -0
- package/templates/workspace.CLAUDE.md +6 -5
package/dist/commands/agent.js
CHANGED
|
@@ -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
|
-
|
|
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 (
|
|
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) => {
|
|
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
|
-
|
|
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() });
|
package/dist/commands/board.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
351
|
-
|
|
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 (!
|
|
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 {
|
package/dist/commands/claim.js
CHANGED
|
@@ -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
|
+
}
|