@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.
@@ -2,7 +2,7 @@
2
2
  // under its memory cap (src/process.ts), in a herdr tab, a tmux window or the background (src/terminal.ts).
3
3
  // Writes <prefix>.out.md (final answer) / .log / .id (session, for -R) / .pane / .done ("status=ok|fail rc=N at=...").
4
4
  // Returns at once; `orq wait <prefix>` blocks until .done. A conductor in herdr (HERDR_PANE_ID) also gets ONE line
5
- // "[HERDR-AGENT DONE] <title> status=... answer: <prefix>.out.md" typed into its pane by herdr-notify.
5
+ // "[HERDR-AGENT DONE] <title> status=... answer: <prefix>.out.md" typed into its pane by src/notice.ts.
6
6
  // The launcher writes <prefix>.job.json and starts `orq agent --run <job>` on the terminal host; that runner does the rest.
7
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
8
  // same cap and files. The agent calls `orq agent --turn <job> <kind>` at every end of turn, which writes .out.md/.id/
@@ -10,20 +10,22 @@
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";
17
- import { agentDef, ALIASES, expandString, expandTemplate, FORMATS } from "../agents.js";
17
+ import { gateFor } from "../diagnose.js";
18
+ import { argvModel, useThrowaway, agentDef, ALIASES, expandString, expandTemplate, FORMATS } from "../agents.js";
18
19
  import { getPath, loadConfig } from "../config.js";
19
20
  import { writeAtomic } from "../fsutil.js";
20
21
  import { m } from "../i18n.js";
21
22
  import { alive } from "../lock.js";
22
23
  import { stateDir } from "../paths.js";
23
- import { killTree, parseSize, resolveMode, runCapped } from "../process.js";
24
+ import { killedRc, killTree, parseSize, resolveMode, runCapped } from "../process.js";
24
25
  import { closeOwnTab, detectHost, launch, screenOpen, shq, typeInto } from "../terminal.js";
26
+ import { ackPrefix } from "../notice.js";
25
27
  import { dropWorktree, notify } from "../testrun.js";
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";
28
+ 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 [-F]]] [-T] -- <prompt.md> run a child agent";
27
29
  const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
28
30
  const PF = ".herdr-agent-prompt.md"; // prompt_in_dir file name (kept from herdr-agent)
29
31
  const git = (dir, ...a) => {
@@ -35,8 +37,8 @@ export async function agent(argv) {
35
37
  return runJob(argv[1]);
36
38
  if (argv[0] === "--turn")
37
39
  return turn(argv[1], argv[2], argv.slice(3));
38
- let a = process.env.AGENT || "codex", g = "", t = "", d = "", o = "", model = "", effort = "", acc = "", resume = "", base = "";
39
- let ro = false, tui = false, force = false, prompt;
40
+ let a = process.env.AGENT || "", g = "", t = "", d = "", o = "", model = "", effort = "", acc = "", resume = "", base = "";
41
+ let ro = false, tui = false, force = 0, prompt;
40
42
  const inc = [];
41
43
  for (let i = 0; i < argv.length; i++) {
42
44
  const x = argv[i], v = () => argv[++i] ?? "";
@@ -73,7 +75,7 @@ export async function agent(argv) {
73
75
  else if (x === "-T")
74
76
  tui = true;
75
77
  else if (x === "-F")
76
- force = true;
78
+ force++;
77
79
  else if (!x.startsWith("-") && prompt === undefined)
78
80
  prompt = x;
79
81
  else {
@@ -90,6 +92,11 @@ export async function agent(argv) {
90
92
  console.error(`${m("missing", "faltou")} -${f}`);
91
93
  return 2;
92
94
  }
95
+ // No default agent: an empty $(orq pick ...) means no candidate, and must not start anything.
96
+ if (!a) {
97
+ console.error(m("missing -a (if it came from orq pick, it found no candidate): nothing started", "faltou -a (se veio do orq pick, ele não achou candidato): nada foi iniciado"));
98
+ return 2;
99
+ }
93
100
  a = ALIASES[a] ?? a;
94
101
  const { cfg } = loadConfig();
95
102
  const def = agentDef(cfg, a);
@@ -123,6 +130,22 @@ export async function agent(argv) {
123
130
  return 2;
124
131
  }
125
132
  const repo = resolve(d), O = resolve(o);
133
+ // A live run on this prefix owns its files: a second agent on the same files (and its .done) is how a false DONE happens.
134
+ // Only a -T -R follow-up typed into that run's own open screen may reuse it.
135
+ let open = "", pid = 0;
136
+ try {
137
+ open = readFileSync(`${O}.pane`, "utf8").trim();
138
+ }
139
+ catch { /* first run */ }
140
+ try {
141
+ pid = Number(readFileSync(`${O}.pid`, "utf8"));
142
+ }
143
+ catch { /* none or ended */ }
144
+ const typed = tui && resume && alive(pid) && screenOpen(open);
145
+ if (!typed && runLive(O, pid)) {
146
+ console.error(m(`${basename(O)} still has a live run (${pid ? `pid ${pid}` : "unit active"}): wait for it (orq wait ${O}) or stop it, or use another prefix. Nothing started.`, `${basename(O)} ainda tem uma execução viva (${pid ? `pid ${pid}` : "unit ativa"}): espere (orq wait ${O}) ou pare, ou use outro prefixo. Nada foi iniciado.`));
147
+ return 3;
148
+ }
126
149
  // Fix rounds: every -R on the same session counts, whatever the prefix is called. policy.max_rounds is enforced here,
127
150
  // not left to the conductor to remember (a 7-round fix loop is what burns the tokens). -F = the user said go on.
128
151
  if (resume) {
@@ -133,9 +156,18 @@ export async function agent(argv) {
133
156
  }
134
157
  catch { /* first follow-up */ }
135
158
  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.`));
159
+ 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, run \`orq rounds <ID> --diagnose\`, then 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, rode \`orq rounds <ID> --diagnose\` e repita com -F.`));
137
160
  return 4;
138
161
  }
162
+ // -F past the limit also needs the watchdog's diagnosis of this round count (-F -F skips it: the user's own call).
163
+ // The issue folder is the prefix's folder, .orquestra/<ID>/ (where the .rounds-<session> counter lives).
164
+ if (n >= max && force === 1 && basename(dirname(dirname(O))) === ".orquestra") {
165
+ const g = gateFor(dirname(O)), id = basename(dirname(O));
166
+ if (g.verdict !== "ok") {
167
+ console.error(m(`no current diagnosis: ${g.verdict === "stale" ? `diagnosis-${g.rounds}.md is older than the latest fix/review` : `diagnosis-${g.rounds}.md does not exist`}. Run \`orq rounds ${id} --diagnose\` first, read it, and ask the user. -F -F skips this check.`, `sem diagnóstico atual: ${g.verdict === "stale" ? `diagnosis-${g.rounds}.md é mais antigo que o último fix/revisão` : `diagnosis-${g.rounds}.md não existe`}. Rode \`orq rounds ${id} --diagnose\` antes, leia e consulte o usuário. -F -F pula esta checagem.`));
168
+ return 4;
169
+ }
170
+ }
139
171
  mkdirSync(dirname(O), { recursive: true });
140
172
  writeFileSync(cf, String(n + 1));
141
173
  }
@@ -169,13 +201,8 @@ export async function agent(argv) {
169
201
  // Follow-ups (-R) reuse the prefix: remove the previous round's result so ".done exists" means "this one finished".
170
202
  // -T -R with the screen still open: the follow-up is typed into it (its runner, and .pid, carry on). Open = the agent
171
203
  // 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)) {
204
+ if (typed) {
205
+ ackPrefix(O); // a follow-up on the issue: its failure notices were seen
179
206
  for (const s of [".done", ".out.md"])
180
207
  rmSync(O + s, { force: true });
181
208
  typeInto(open, readFileSync(P, "utf8"));
@@ -184,7 +211,7 @@ export async function agent(argv) {
184
211
  }
185
212
  for (const s of [".done", ".out.md", ".pid"])
186
213
  rmSync(O + s, { force: true });
187
- const throwaway = Boolean(def.throwaway && ro && !resume);
214
+ const throwaway = useThrowaway(def, ro, Boolean(resume), git(repo, "rev-parse", "--git-dir") !== undefined);
188
215
  const dir = throwaway ? `${repo}.review-${process.pid}` : repo;
189
216
  const gd = git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir");
190
217
  const wg = git(repo, "rev-parse", "--absolute-git-dir");
@@ -247,17 +274,33 @@ export async function agent(argv) {
247
274
  swap: parseSize(mach.agent_swap_max), mode: mach.mem_enforce ?? "auto",
248
275
  throwaway, promptInDir: Boolean(def.prompt_in_dir), closeAfter,
249
276
  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,
277
+ okExit: def.ok_exit, filter: def.filter, noReview: def.no_review, ro,
278
+ idleCloseMin: tui ? Number(mach.tui_idle_close_min ?? 30) : undefined,
251
279
  };
252
280
  writeAtomic(`${O}.job.json`, JSON.stringify(job, null, 1));
253
281
  // Readable copy of the command (also what orq stats and herdr-board read).
254
282
  writeFileSync(`${O}.run.sh`, `#!/usr/bin/env bash\n# orq agent (generated; the run itself is \`orq agent --run ${O}.job.json\`) --unit=${unit} \n` +
255
283
  `cd ${shq(dir)}\n${Object.entries(env).map(([k, val]) => `${k}=${shq(val)} `).join("")}${cmd.map(shq).join(" ")}${job.stdin ? ` < ${shq(P)}` : ""}\n`);
284
+ ackPrefix(O); // starting work on the issue again: its failure notices were seen
256
285
  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 });
257
286
  writeFileSync(`${O}.pane`, `${where}\n`);
258
287
  console.log(`[orq agent] ${t} → ${where}${tui ? " (TUI)" : ""}`);
259
288
  return 0;
260
289
  }
290
+ /** The previous run on prefix `O` is still going: its runner pid is alive, or (no pid file yet / runner gone) its systemd scope is. */
291
+ function runLive(O, pid) {
292
+ if (alive(pid))
293
+ return true;
294
+ if (existsSync(`${O}.done`) || process.platform !== "linux")
295
+ return false; // finished (or no systemd): the pid said it all
296
+ try {
297
+ const unit = JSON.parse(readFileSync(`${O}.job.json`, "utf8")).unit;
298
+ return Boolean(unit) && spawnSync("systemctl", ["--user", "is-active", "--quiet", `${unit}.scope`], { timeout: 5_000 }).status === 0;
299
+ }
300
+ catch {
301
+ return false;
302
+ }
303
+ }
261
304
  /** OpenCode (v2) plugin for -T: when the main session's run ends, its last answer goes to `orq agent --turn`. */
262
305
  function opencodePlugin(O, hook) {
263
306
  return `// orq agent -T end of turn (generated)
@@ -326,7 +369,7 @@ function turn(file, kind, rest) {
326
369
  const st = /\S/.test(answer) && !noReview ? "ok" : "fail";
327
370
  writeAtomic(`${O}.done`, `status=${st} rc=0 at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
328
371
  if (j.parent)
329
- notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=0 answer: ${O}.out.md`, true);
372
+ notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=0 answer: ${O}.out.md`);
330
373
  }
331
374
  catch (e) {
332
375
  console.error(`orq agent --turn: ${e.message}`);
@@ -374,6 +417,26 @@ function noReviewMatch(pattern, answer) {
374
417
  return re.test(first) || (answer.trim().length <= 200 && re.test(answer));
375
418
  }
376
419
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
420
+ /** A -T agent is idle when its turn ended (.done exists) and, for `idleMs`, nothing touched .done (a follow-up removes
421
+ * it, a turn's end rewrites it) and the screen was not seen busy (`busyAt`, epoch ms, from herdr's agent status). */
422
+ export function idleExpired(done, idleMs, now = Date.now(), busyAt = 0) {
423
+ try {
424
+ return idleMs > 0 && now - Math.max(statSync(done).mtimeMs, busyAt) >= idleMs;
425
+ }
426
+ catch {
427
+ return false;
428
+ }
429
+ }
430
+ /** herdr's view of the agent on a pane: working | blocked | idle | done | unknown; undefined when it can't be read. */
431
+ function paneStatus(pane) {
432
+ const r = spawnSync("herdr", ["pane", "get", pane], { encoding: "utf8", timeout: 5_000 });
433
+ try {
434
+ return r.status === 0 ? JSON.parse(r.stdout).result?.pane?.agent_status : undefined;
435
+ }
436
+ catch {
437
+ return undefined;
438
+ }
439
+ }
377
440
  /** Splits a stream into lines for `each`. */
378
441
  function lines(s, each) {
379
442
  return new Promise((res) => {
@@ -396,12 +459,14 @@ async function runJob(file) {
396
459
  const stateF = join(stateDir(), "agents", `${j.unit}.json`);
397
460
  const at = new Date().toISOString();
398
461
  const state = (extra = {}) => { try {
399
- writeAtomic(stateF, JSON.stringify({ pid: process.pid, prefix: O, title: j.title, agent: j.agent, host: j.host, cap: j.cap, at, ...extra }));
462
+ writeAtomic(stateF, JSON.stringify({ pid: process.pid, prefix: O, title: j.title, agent: j.agent, model: argvModel(j.argv), host: j.host, cap: j.cap, at, ...extra }));
400
463
  }
401
464
  catch { /* best effort */ } };
402
465
  state();
403
466
  const fmt = FORMATS[j.format] ?? FORMATS.text;
404
467
  let rc = 0, stdout = "", logText = "";
468
+ // State of the worktree before the run, to tell what this run changed (a -R on a dirty tree is not progress).
469
+ const headBefore = j.throwaway ? undefined : git(j.dir, "rev-parse", "HEAD"), statusBefore = j.throwaway ? undefined : git(j.dir, "status", "--porcelain");
405
470
  const pre = () => {
406
471
  if (j.throwaway) {
407
472
  // Throwaway worktree: detached copy of HEAD with the repo's node_modules linked; removed at the end.
@@ -439,20 +504,26 @@ async function runJob(file) {
439
504
  if (j.tui) {
440
505
  // The agent's own screen: the terminal is its; answers come from the end-of-turn hook (`--turn`).
441
506
  let ended = false;
442
- const end = (code) => {
507
+ const end = (code, killed = false) => {
443
508
  if (ended)
444
509
  return "fail";
445
510
  ended = true;
446
511
  post();
447
512
  // 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`)) {
513
+ const ok = !killed && /\bstatus=ok\b/.test(existsSync(`${O}.done`) ? readFileSync(`${O}.done`, "utf8") : "");
514
+ if (killed) {
515
+ // SIGKILL & co: whatever an earlier turn wrote, the screen did not end normally.
516
+ writeAtomic(`${O}.done`, `status=killed rc=${code} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
517
+ if (j.parent)
518
+ notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=killed rc=${code} answer: ${O}.out.md`);
519
+ }
520
+ else if (!existsSync(`${O}.done`)) {
450
521
  writeAtomic(`${O}.done`, `status=fail rc=${code} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
451
522
  if (j.parent)
452
- notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=${code} answer: ${O}.out.md`, true);
523
+ notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=${code} answer: ${O}.out.md`);
453
524
  }
454
525
  rmSync(`${O}.pid`, { force: true });
455
- return ok ? "ok" : "fail";
526
+ return killed ? "killed" : ok ? "ok" : "fail";
456
527
  };
457
528
  if (!pre())
458
529
  rc = 2;
@@ -462,16 +533,46 @@ async function runJob(file) {
462
533
  delete env[k];
463
534
  // Ctrl+C belongs to the agent (it reaches us too, as part of the terminal's foreground group). The screen going
464
535
  // away (tab or tmux server closed) still ends the agent and cleans up.
465
- let child;
536
+ let child, idleT, idleClosed = false;
466
537
  process.on("SIGINT", () => { });
538
+ // The screen going away or being killed from outside: end the agent, and close the tab too (a dead tab stayed open).
467
539
  const gone = () => { if (child)
468
- killTree(child, false); end(129); process.exit(0); };
540
+ killTree(child, false); end(129); closeOwnTab(j.host); process.exit(0); };
469
541
  for (const sig of ["SIGHUP", "SIGTERM"])
470
542
  process.on(sig, gone);
471
543
  rc = await runCapped(j.argv[0], j.argv.slice(1), { cap: j.cap, swap: j.swap, mode: j.mode, unit: j.unit, cwd: j.dir, env, quote: true, tty: true,
472
- onSpawn: (c) => { child = c.pid; } });
544
+ onSpawn: (c) => {
545
+ child = c.pid;
546
+ // A finished agent waits for a follow-up forever and holds its memory: after the idle timeout without a new
547
+ // turn, kill it; the normal end below cleans up and closes the tab. "New turn" = .done removed (`-R` typed
548
+ // into the screen removes it) or rewritten (the next end of turn), so idle = .done untouched that long.
549
+ const idleMs = (j.idleCloseMin ?? 0) * 60_000;
550
+ if (idleMs > 0 && !j.keep) {
551
+ // On herdr the pane's agent status also catches a turn typed straight into the screen (.done only changes
552
+ // when it ends). Elsewhere (tmux) only .done tells: a turn typed directly into the screen that runs longer
553
+ // than the timeout can still be closed.
554
+ let busyAt = 0;
555
+ const pane = j.host === "herdr" ? process.env.HERDR_PANE_ID : undefined;
556
+ idleT = setInterval(() => {
557
+ const st = pane ? paneStatus(pane) : undefined;
558
+ if (st === "working" || st === "blocked")
559
+ busyAt = Date.now();
560
+ if (!idleExpired(`${O}.done`, idleMs, Date.now(), busyAt))
561
+ return;
562
+ clearInterval(idleT);
563
+ console.log(m(`[orq agent] no follow-up for ${j.idleCloseMin} min: closing the finished agent`, `[orq agent] sem follow-up há ${j.idleCloseMin} min: fechando o agente que terminou`));
564
+ idleClosed = true;
565
+ if (child)
566
+ killTree(child, false);
567
+ }, Math.min(30_000, Math.max(1_000, idleMs / 2)));
568
+ idleT.unref();
569
+ }
570
+ } });
571
+ clearInterval(idleT);
572
+ if (idleClosed)
573
+ rc = 0; // closed on purpose after a finished turn: not a kill
473
574
  }
474
- const st = end(rc);
575
+ const st = end(rc, killedRc(rc));
475
576
  state({ status: st, rc, end: new Date().toISOString() });
476
577
  console.log(`\n[${j.agent} (TUI) ${m("exited", "encerrado")}] ${m("answer", "resposta")}: ${O}.out.md`);
477
578
  if (!j.keep) {
@@ -534,12 +635,24 @@ async function runJob(file) {
534
635
  await new Promise((r) => log.end(r));
535
636
  post();
536
637
  const res = fmt.finish(logText, stdout);
638
+ // Denied commands do not sink a write run that did work: keep the diff and report ok with the denied list in the answer.
639
+ let recovered = false;
640
+ if (res.denied?.length && !j.ro && !j.throwaway && existsSync(j.dir)) {
641
+ const status = git(j.dir, "status", "--porcelain"), head = git(j.dir, "rev-parse", "HEAD");
642
+ if (status !== statusBefore || head !== headBefore) {
643
+ const stat = (headBefore && git(j.dir, "diff", "--stat", headBefore)) || status || `HEAD ${headBefore?.slice(0, 7)} -> ${head?.slice(0, 7)}`;
644
+ recovered = true;
645
+ res.answer = `${res.answer ?? ""}\n## Worktree\nSome commands were denied, but the worktree changes are kept (review them before accepting):\n\`\`\`\n${stat}\n\`\`\`\n`;
646
+ }
647
+ else
648
+ res.answer = `${res.answer ?? ""}\nNo changes were made in the worktree.\n`;
649
+ }
537
650
  if (j.answer !== "file")
538
651
  writeFileSync(`${O}.out.md`, res.answer ?? "");
539
652
  const sid = j.fromLog ? new RegExp(j.fromLog, "m").exec(logText)?.[1] : res.session ?? j.session;
540
653
  if (sid)
541
654
  writeFileSync(`${O}.id`, `${sid}\n`);
542
- if (rc === 0 && res.rc)
655
+ if (rc === 0 && res.rc && !recovered)
543
656
  rc = res.rc;
544
657
  // A review tool exits non-zero on findings: an ok_exit code is a verdict (rc stays in .done), not a failure.
545
658
  let verdict = rc === 0 || (j.okExit ?? []).includes(rc);
@@ -560,7 +673,8 @@ async function runJob(file) {
560
673
  // A reviewer whose answer says nothing was reviewed (agents.<name>.no_review) is not a verdict either: pick's
561
674
  // fallback and the conductor must see it as not run, same as a whitespace-only answer.
562
675
  const noReview = noReviewMatch(j.noReview, answer);
563
- const st = verdict && !noReview && /\S/.test(answer) ? "ok" : "fail"; // whitespace-only answer = failure
676
+ // killed (SIGKILL, memory cap...) is its own status: never ok, even if an ok_exit list or a leftover answer says otherwise.
677
+ const st = killedRc(rc) ? "killed" : verdict && !noReview && /\S/.test(answer) ? "ok" : "fail"; // whitespace-only answer = failure
564
678
  writeAtomic(`${O}.done`, `status=${st} rc=${rc} at=${new Date().toISOString()} agent=${j.agent}\n`);
565
679
  rmSync(`${O}.pid`, { force: true });
566
680
  state({ status: st, rc, end: new Date().toISOString() });
@@ -8,7 +8,9 @@ import { writeAtomic } from "../fsutil.js";
8
8
  import { loginId } from "../identity.js";
9
9
  import { m } from "../i18n.js";
10
10
  import { stateDir } from "../paths.js";
11
- export const USAGE = `orq block <agent|account> [--until YYYY-MM-DD[THH:MM] | --days N] [--reason "text"] default: 7 days
11
+ import { getPath, loadConfig } from "../config.js";
12
+ import { collect } from "../quota.js";
13
+ export const USAGE = `orq block <agent|account> [--until YYYY-MM-DD[THH:MM] | --days N | --quota] [--reason "text"] default: 7 days; --quota: until the quota window resets
12
14
  orq block -r <agent|account> unblock early
13
15
  orq block -l list active blocks (expired ones disappear by themselves)`;
14
16
  const file = () => join(stateDir(), "blocks.json");
@@ -45,7 +47,16 @@ export function parseLocal(s) {
45
47
  throw new Error(m(`invalid date: ${s}`, `data inválida: ${s}`));
46
48
  return d;
47
49
  }
48
- export function block(argv) {
50
+ /** When a quota line's exhausted window resets, in epoch seconds: the week if it is used up, else the 5h window (the
51
+ * reading can lag behind the limit: Codex's comes from its last session). undefined = no reset known. */
52
+ export function quotaUntil(line) {
53
+ const reset = (k) => { const x = new RegExp(`\\b${k}=(\\d+)`).exec(line); return x ? Number(x[1]) : undefined; };
54
+ const week = /\bweek=(\d+)%/.exec(line);
55
+ if (week && Number(week[1]) >= 100 && reset("resetweek"))
56
+ return reset("resetweek");
57
+ return reset("reset5h") ?? reset("resetweek");
58
+ }
59
+ export async function block(argv) {
49
60
  const b = load();
50
61
  if (!argv.length || argv[0] === "-h" || argv[0] === "--help") {
51
62
  console.log(USAGE);
@@ -69,7 +80,7 @@ export function block(argv) {
69
80
  return 0;
70
81
  }
71
82
  const [name, ...rest] = argv;
72
- let until = new Date(Date.now() + 7 * 86400_000), reason = "";
83
+ let until = new Date(Date.now() + 7 * 86400_000), reason = "", byQuota = false;
73
84
  while (rest.length) {
74
85
  const o = rest.shift();
75
86
  if (o === "--until")
@@ -78,6 +89,18 @@ export function block(argv) {
78
89
  until = new Date(Date.now() + Number(rest.shift()) * 86400_000);
79
90
  else if (o === "--reason")
80
91
  reason = rest.shift() ?? "";
92
+ else if (o === "--quota")
93
+ byQuota = true;
94
+ }
95
+ if (byQuota) {
96
+ const sources = (getPath(loadConfig().cfg, "quota") ?? {});
97
+ const line = (await collect(sources, [name])).find((l) => l.startsWith(`${name} `));
98
+ const r = line && quotaUntil(line);
99
+ if (!r) {
100
+ console.error(m(`${name}: no quota reset known; use --until or --days`, `${name}: sem horário de renovação da cota; use --until ou --days`));
101
+ return 1;
102
+ }
103
+ until = new Date(r * 1000);
81
104
  }
82
105
  if (Number.isNaN(until.getTime())) {
83
106
  console.error(USAGE);