@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.
@@ -1,20 +1,28 @@
1
1
  // orq gc — find issue worktrees whose branch is already safe to remove.
2
2
  import { spawnSync } from "node:child_process";
3
- import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs";
4
- import { basename, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { existsSync, readdirSync, readFileSync, realpathSync, rmSync } from "node:fs";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
5
5
  import { loadConfig } from "../config.js";
6
6
  import { m } from "../i18n.js";
7
+ import { issueFromName, issuePattern, issuesWithLiveAgent } from "../issue.js";
8
+ import { alive } from "../lock.js";
9
+ import { stateDir } from "../paths.js";
7
10
  import { aliveOwner } from "./claim.js";
8
- export const USAGE = "orq gc [-w <workspace>] [--yes] [--days N] [--json] remove merged issue worktrees";
11
+ export const USAGE = "orq gc [-w <workspace>] [--yes] [--days N] [--json] remove merged issue worktrees and stale agent state";
9
12
  const git = (cwd, ...args) => spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
10
13
  // Paths as git prints them can differ from ours (macOS /var → /private/var, Windows C:/ vs C:\): compare real paths.
11
- const real = (p) => { try {
12
- return realpathSync.native(p);
13
- }
14
- catch {
15
- return resolve(p);
16
- } };
17
- const inside = (dir, p) => { const r = relative(dir, p); return !!r && !r.startsWith("..") && !isAbsolute(r); };
14
+ // A path that does not exist (yet) is resolved through its deepest existing ancestor, so both sides get the same prefix.
15
+ const real = (p) => {
16
+ const abs = resolve(p);
17
+ try {
18
+ return realpathSync.native(abs);
19
+ }
20
+ catch { /* missing: resolve the parent */ }
21
+ const up = dirname(abs);
22
+ return up === abs ? abs : join(real(up), basename(abs));
23
+ };
24
+ const fold = (p) => (process.platform === "win32" ? p.toLowerCase() : p); // NTFS is case-insensitive
25
+ const inside = (dir, p) => { const r = relative(fold(dir), fold(p)); return !!r && !r.startsWith("..") && !isAbsolute(r); };
18
26
  // ORQ_GH may be a .js file (tests: a fake gh that also runs on Windows).
19
27
  const gh = (cwd, args) => {
20
28
  const bin = process.env.ORQ_GH || "gh";
@@ -22,6 +30,30 @@ const gh = (cwd, args) => {
22
30
  : spawnSync(bin, args, { cwd, encoding: "utf8" });
23
31
  };
24
32
  const output = (cwd, ...args) => { const r = git(cwd, ...args); return r.status === 0 ? r.stdout.trim() : undefined; };
33
+ /** Per-agent state files (<state>/agents/<unit>.json) of runs under `ws` that are finished or dead and older than `days`. */
34
+ function staleAgentStates(ws, days, now) {
35
+ const dir = join(stateDir(), "agents"), out = [];
36
+ let files = [];
37
+ try {
38
+ files = readdirSync(dir).filter((f) => f.endsWith(".json"));
39
+ }
40
+ catch { /* no runs yet */ }
41
+ for (const f of files) {
42
+ try {
43
+ const s = JSON.parse(readFileSync(join(dir, f), "utf8"));
44
+ if (!inside(real(ws), real(String(s.prefix ?? ""))))
45
+ continue;
46
+ const finished = s.status === "ok" || s.status === "fail" || s.status === "killed";
47
+ if (!finished && alive(Number(s.pid)))
48
+ continue;
49
+ const since = Date.parse(String(s.end ?? s.at));
50
+ if (now - since > days * 86400_000)
51
+ out.push(join(dir, f));
52
+ }
53
+ catch { /* unreadable state: leave it */ }
54
+ }
55
+ return out;
56
+ }
25
57
  export async function gc(argv, clock = Date.now) {
26
58
  let ws = process.cwd(), yes = false, json = false, days = 14;
27
59
  for (let i = 0; i < argv.length; i++) {
@@ -38,10 +70,11 @@ export async function gc(argv, clock = Date.now) {
38
70
  return 2;
39
71
  }
40
72
  }
41
- const { cfg } = loadConfig(ws), rows = [];
73
+ const { cfg } = loadConfig(ws), rows = [], issuePat = issuePattern(ws);
42
74
  let ghUnavailable = false, fetchWarned = false;
43
75
  const root = join(ws, ".worktrees");
44
76
  const orq = join(ws, ".orquestra");
77
+ const liveAgents = issuesWithLiveAgent();
45
78
  const issues = existsSync(orq) ? readdirSync(orq).filter((x) => !x.startsWith(".")) : [];
46
79
  for (const repoName of readdirSync(ws, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)) {
47
80
  const repo = join(ws, repoName);
@@ -71,8 +104,8 @@ export async function gc(argv, clock = Date.now) {
71
104
  const branch = wt.branch?.replace(/^refs\/heads\//, "") ?? "";
72
105
  if (!branch)
73
106
  continue;
74
- // The branch carries the ID in lowercase (plane-abc-123); .orquestra/ keeps it as written (ABC-123).
75
- const id = /^.*?plane-(.+)$/.exec(branch)?.[1];
107
+ // The branch carries the ID in lowercase (abc-123, maybe with a prefix or -suffix); .orquestra/ keeps it as written (ABC-123).
108
+ const id = issueFromName(branch, issuePat);
76
109
  const issue = id && issues.find((d) => d.toLowerCase() === id.toLowerCase());
77
110
  const status = output(path, "status", "--porcelain");
78
111
  const unpushed = output(repo, "rev-list", branch, "--not", "--remotes");
@@ -86,14 +119,10 @@ export async function gc(argv, clock = Date.now) {
86
119
  verdict = "KEEP";
87
120
  reason = m("issue has a live owner", "issue tem um responsável ativo");
88
121
  }
89
- // A .pane/.id without a same-stem .done is treated as a running child.
90
- const issueDir = issue && join(ws, ".orquestra", issue);
91
- if (verdict !== "KEEP" && issueDir && existsSync(issueDir)) {
92
- const files = readdirSync(issueDir), running = files.some((f) => /\.(pane|id)$/.test(f) && !existsSync(join(issueDir, `${f.replace(/\.(pane|id)$/, "")}.done`)));
93
- if (running) {
94
- verdict = "KEEP";
95
- reason = m("child agent may still be running", "um agente filho pode estar em execução");
96
- }
122
+ // A child agent of the issue whose state says running and whose pid is alive (the board's rule).
123
+ if (verdict !== "KEEP" && issue && liveAgents.has(issue)) {
124
+ verdict = "KEEP";
125
+ reason = m("child agent may still be running", "um agente filho pode estar em execução");
97
126
  }
98
127
  let merged = false;
99
128
  if (verdict !== "KEEP") {
@@ -153,6 +182,10 @@ export async function gc(argv, clock = Date.now) {
153
182
  }
154
183
  git(join(ws, row.repo), "worktree", "prune");
155
184
  }
185
+ const stale = staleAgentStates(ws, days, clock());
186
+ if (yes)
187
+ for (const f of stale)
188
+ rmSync(f, { force: true });
156
189
  if (json)
157
190
  console.log(JSON.stringify(rows));
158
191
  else {
@@ -160,5 +193,8 @@ export async function gc(argv, clock = Date.now) {
160
193
  const w = table[0].map((_, i) => Math.max(...table.map((t) => t[i].length)));
161
194
  console.log(table.map((t) => t.map((c, i) => i < t.length - 1 ? c.padEnd(w[i]) : c).join(" ")).join("\n"));
162
195
  }
196
+ if (stale.length && !json)
197
+ console.log(yes ? m(`pruned ${stale.length} stale agent state file(s)`, `${stale.length} arquivo(s) de estado de agente antigo(s) removido(s)`)
198
+ : m(`${stale.length} stale agent state file(s) (finished/dead, older than ${days} days): --yes removes them`, `${stale.length} arquivo(s) de estado de agente antigo(s) (terminados/mortos, mais de ${days} dias): --yes remove`));
163
199
  return failed ? 1 : 0;
164
200
  }
@@ -163,6 +163,7 @@ async function move(ws, c, to, dir, model, mark) {
163
163
  for (const id of c.issues)
164
164
  writeAtomic(join(ws, ".orquestra", id, "owner"), `pane=${pane} kind=herdr account=${basename(dir)} at=${new Date().toISOString()} name=${label}\n`);
165
165
  writeAtomic(handoffFile(c.pane), pane);
166
+ rmSync(handoffFile(pane), { force: true }); // the new pane is live: a leftover redirect out of it would make a cycle
166
167
  const regs = join(ws, ".orquestra", ".conductors");
167
168
  mkdirSync(regs, { recursive: true });
168
169
  writeAtomic(join(regs, pane.replace(/[^A-Za-z0-9_-]/g, "_")), `pane=${pane} kind=herdr account=${basename(dir)} at=${new Date().toISOString()} name=${label}\n`);
@@ -34,7 +34,7 @@ export function humanizeQuotaLine(line, now = Date.now()) {
34
34
  * `policy.quota_week`) — the same thresholds `orq pick` applies for an unspecified task level; ⊘ blocked
35
35
  * (orq block), ? no data; sources with no window at all ("-" and "-") are left out. */
36
36
  export function quotaLine(lines, limit5, limitWeek, blocked, age) {
37
- const parts = lines.filter((q) => q.five !== "-" || q.week !== "-").map((q) => {
37
+ const parts = lines.filter((q) => !q.name.includes("/") && (q.five !== "-" || q.week !== "-")).map((q) => {
38
38
  if (blocked.has(q.name))
39
39
  return `${q.name} ⊘`;
40
40
  const five = pct(q.five), week = pct(q.week);
@@ -96,37 +96,64 @@ async function diagnoseIssue(ws, id, x) {
96
96
  }
97
97
  const ev = evidence(dir);
98
98
  writeFileSync(`${prefix}.md`, buildPrompt(x, ev.fixes, ev.reviews));
99
- const p = orq("pick", "watchdog", "--level", "simple", "-w", ws, ...(implPick ? ["--impl-agent", implPick] : []));
100
- if (p.status !== 0) {
101
- console.error(p.stderr.trim() || `orq pick watchdog: exit ${p.status}`);
102
- return p.status ?? 1;
103
- }
104
- const picked = p.stdout.trim().split(/\s+/);
105
- // The agent runs in a scratch folder (everything it needs is in the prompt): it can't touch the issue files, even write sandboxed.
106
- const scratch = mkdtempSync(join(tmpdir(), "orq-watchdog-"));
99
+ // A failed watchdog (e.g. a model catalog timeout) gets one retry with the next candidate, never the same agent.
100
+ const excluded = [];
101
+ let first;
102
+ const scratches = [];
107
103
  try {
108
- const a = orq("agent", ...picked, "-r", "-t", `${id} watchdog`, "-d", scratch, "-g", "watchdog", "-o", prefix, "--", `${prefix}.md`);
109
- process.stderr.write(a.stderr);
110
- process.stdout.write(a.stdout);
111
- if (a.status !== 0)
112
- return a.status ?? 1;
113
- return await finish(await wait(["--timeout", String(WATCHDOG_TIMEOUT), prefix]), prefix, out);
104
+ for (let attempt = 0; attempt < 2; attempt++) {
105
+ const p = orq("pick", "watchdog", "--level", "simple", "-w", ws, ...(implPick ? ["--impl-agent", implPick] : []), ...excluded.flatMap((e) => ["--exclude-agent", e]));
106
+ if (p.status !== 0) {
107
+ if (first) {
108
+ console.error(m("the watchdog agent failed; no diagnosis written", "o agente watchdog falhou; diagnóstico não gravado"));
109
+ return first.rc;
110
+ }
111
+ console.error(p.stderr.trim() || `orq pick watchdog: exit ${p.status}`);
112
+ return p.status ?? 1;
113
+ }
114
+ const picked = p.stdout.trim().split(/\s+/), agent = /(?:^|\s)-a (\S+)/.exec(p.stdout)?.[1];
115
+ // The agent runs in a scratch folder (everything it needs is in the prompt): it can't touch the issue files, even write sandboxed.
116
+ const scratch = mkdtempSync(join(tmpdir(), "orq-watchdog-"));
117
+ scratches.push(scratch);
118
+ const run = `${prefix}-${attempt}`;
119
+ {
120
+ writeFileSync(`${run}.md`, readFileSync(`${prefix}.md`));
121
+ const a = orq("agent", ...picked, "-r", "-t", `${id} watchdog`, "-d", scratch, "-g", "watchdog", "-o", run, "--", `${run}.md`);
122
+ process.stderr.write(a.stderr);
123
+ process.stdout.write(a.stdout);
124
+ const rc = a.status !== 0 ? a.status ?? 1 : await finish(await wait(["--timeout", String(WATCHDOG_TIMEOUT), run]), run, out, agent, attempt === 1 || !agent);
125
+ if (rc === 0 || rc === 124 || !agent)
126
+ return rc;
127
+ if (attempt === 0)
128
+ console.error(m(`watchdog ${agent} failed; retrying with another agent`, `watchdog ${agent} falhou; tentando com outro agente`));
129
+ first = { rc };
130
+ excluded.push(agent);
131
+ }
132
+ }
133
+ return first?.rc ?? 1;
134
+ // Removed only after every attempt, and best-effort: on Windows a just-finished agent can still hold its cwd (EPERM/EBUSY).
114
135
  }
115
136
  finally {
116
- rmSync(scratch, { recursive: true, force: true });
137
+ for (const s of scratches) {
138
+ try {
139
+ rmSync(s, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
140
+ }
141
+ catch { /* temp dir: the OS cleans it */ }
142
+ }
117
143
  }
118
144
  }
119
- async function finish(rc, prefix, out) {
145
+ async function finish(rc, prefix, out, agent, final = true) {
120
146
  if (rc === 124) {
121
147
  console.error(m(`diagnosis not written: the watchdog agent did not finish in ${WATCHDOG_TIMEOUT}s`, `diagnóstico não gravado: o agente watchdog não terminou em ${WATCHDOG_TIMEOUT}s`));
122
148
  return 124;
123
149
  }
124
150
  if (rc !== 0 || !existsSync(`${prefix}.out.md`)) {
125
- console.error(m("the watchdog agent failed; no diagnosis written", "o agente watchdog falhou; diagnóstico não gravado"));
151
+ if (final)
152
+ console.error(m("the watchdog agent failed; no diagnosis written", "o agente watchdog falhou; diagnóstico não gravado"));
126
153
  return 1;
127
154
  }
128
155
  copyFileSync(`${prefix}.out.md`, out);
129
- console.log(m(`diagnosis saved to ${out}`, `diagnóstico gravado em ${out}`));
156
+ console.log(m(`diagnosis saved to ${out}${agent ? ` (by ${agent})` : ""}`, `diagnóstico gravado em ${out}${agent ? ` (por ${agent})` : ""}`));
130
157
  console.log(recommendation(readFileSync(out, "utf8")) ?? m("(no RECOMMENDATION line in the answer)", "(sem linha RECOMMENDATION na resposta)"));
131
158
  return 0;
132
159
  }
@@ -7,6 +7,7 @@ import { basename } from "node:path";
7
7
  import { m } from "../i18n.js";
8
8
  import { alive } from "../lock.js";
9
9
  import { ackPrefix } from "../notice.js";
10
+ import { doneStatus } from "../process.js";
10
11
  export const USAGE = "orq wait [--any] [--timeout <s>] [--answer] <prefix>... block until child agents finish (exit 1 = one failed)";
11
12
  const read = (f) => { try {
12
13
  return readFileSync(f, "utf8");
@@ -29,7 +30,10 @@ export function doneLine(prefix) {
29
30
  }
30
31
  catch { /* verify/baseline: no job */ }
31
32
  const answer = [`${prefix}.out.md`, `${prefix}.txt`].find(existsSync) ?? prefix;
32
- return { line: `[ORQ AGENT DONE] ${title} ${done} answer: ${answer}`, ok: /\bstatus=ok\b/.test(done) };
33
+ const status = doneStatus(done);
34
+ if (status === "killed")
35
+ done = done.replace(/\bstatus=\S+/, "status=killed"); // legacy "status=ok rc=137"
36
+ return { line: `[ORQ AGENT DONE] ${title} ${done} answer: ${answer}`, ok: status === "ok" };
33
37
  }
34
38
  export async function wait(argv) {
35
39
  let any = false, timeout = 0, answer = false;
package/dist/config.js CHANGED
@@ -15,6 +15,7 @@ export const DEFAULTS = {
15
15
  mem_enforce: "auto", // auto (kernel cap where possible, else watchdog) | kernel | watchdog | off
16
16
  test_slots: 1, // test suites in parallel on the machine
17
17
  close_tab_after: 20, // seconds until a finished child tab closes (0 = never)
18
+ tui_idle_close_min: 30, // minutes a finished -T agent waits for a follow-up before it is closed (0 = never)
18
19
  tui: false, // children in the agent's own screen (-T) by default
19
20
  terminal: "auto", // where children show their output: auto (herdr, else tmux, else plain) | herdr | tmux | plain
20
21
  },
@@ -27,10 +28,13 @@ export const DEFAULTS = {
27
28
  max_rounds: 2, // fix rounds before stopping and calling the user
28
29
  review: "other-vendor", // other-vendor | any
29
30
  },
30
- ui: { lang: "auto", theme: "gunmetal", desktop_notify: true }, // lang: auto | en | pt · theme: gunmetal | chrome | lcd | terminal · desktop_notify: desktop popup for failures (Linux, notify-send)
31
+ ui: { lang: "auto", theme: "gunmetal", desktop_notify: true, board_hide_hours: 6 }, // board_hide_hours: finished/dead agents older than this leave `orq board` (still in --json) · lang: auto | en | pt · theme: gunmetal | chrome | lcd | terminal · desktop_notify: desktop popup for failures (Linux, notify-send)
31
32
  // Where quota comes from, per agent: "builtin" | "none" | false | { command = "..." } (see src/quota.ts).
32
33
  // "claude" covers every account in claude-accounts.
33
34
  quota: { claude: "builtin", codex: "builtin", opencode: "builtin", antigravity: "builtin", copilot: "builtin" },
35
+ // Issue ID in a branch/worktree name (orq gc, orq ci): case-insensitive regex, the named group "issue" (else group 1) is the ID.
36
+ // The default finds <LETTERS>-<DIGITS> anywhere (feature/ABC-123, abc-123-fix, plane-abc-123-api-2); suffixes are ignored.
37
+ git: { issue_pattern: "(?<![a-z0-9])(?<issue>[a-z][a-z0-9]*-\\d+)(?![a-z0-9])" },
34
38
  tracker: { kind: "none" }, // plane | none
35
39
  repos: {}, // defined by the workspace
36
40
  };
package/dist/install.js CHANGED
@@ -89,6 +89,11 @@ export function prepareWorkspace(dir, fmt, name = basename(resolve(dir))) {
89
89
  }
90
90
  else
91
91
  out.push(m(` ok ${map} (kept)`, ` ok ${map} (mantido)`));
92
+ // The conductor's instructions (what `orq conductor` looks for): regenerated on every run.
93
+ const ci = join(ws, ".orquestra-conductor.md");
94
+ const t = readFileSync(join(PKG, "templates", "conductor.instructions.md"), "utf8");
95
+ writeAtomic(ci, t.split("{{WORKSPACE}}").join(ws).split("{{NAME}}").join(name));
96
+ out.push(` ${m("wrote", "escrevi")} ${ci}`);
92
97
  const r = spawnSync(process.execPath, [join(PKG, "dist", "cli.js"), "instructions", fmt, "-w", ws], { encoding: "utf8" });
93
98
  out.push(` ${(r.stdout || r.stderr).trim()}`);
94
99
  return out;
package/dist/issue.js ADDED
@@ -0,0 +1,47 @@
1
+ // The issue a branch or worktree belongs to, and which issues have an agent really running.
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { basename, dirname, join } from "node:path";
4
+ import { getPath, loadConfig } from "./config.js";
5
+ import { alive } from "./lock.js";
6
+ import { stateDir } from "./paths.js";
7
+ /** Finds "<LETTERS>-<DIGITS>" anywhere in the name, as its own token; anything after the number is a suffix. */
8
+ export const DEFAULT_ISSUE_PATTERN = "(?<![a-z0-9])(?<issue>[a-z][a-z0-9]*-\\d+)(?![a-z0-9])";
9
+ /** The issue ID (as written in the name) from a branch or worktree name like "me/plane-proj-68-api-2" or
10
+ * "feature/ABC-123". `pattern` is [git] issue_pattern: a case-insensitive regex whose named group "issue"
11
+ * (else group 1, else the whole match) is the ID. */
12
+ export function issueFromName(name, pattern = DEFAULT_ISSUE_PATTERN) {
13
+ let re;
14
+ try {
15
+ re = new RegExp(pattern, "i");
16
+ }
17
+ catch (e) {
18
+ throw new Error(`git.issue_pattern: ${e.message}`);
19
+ }
20
+ const r = re.exec(name);
21
+ return r ? (r.groups?.issue ?? r[1] ?? r[0]) : undefined;
22
+ }
23
+ /** [git] issue_pattern of the workspace's merged config (default when unset). */
24
+ export function issuePattern(ws) {
25
+ const v = getPath(loadConfig(ws).cfg, "git.issue_pattern");
26
+ return typeof v === "string" && v ? v : DEFAULT_ISSUE_PATTERN;
27
+ }
28
+ /** Issue directories (basename of the run prefix's folder) with an agent whose state says running and whose pid is
29
+ * alive: the same rule the board uses for "running". */
30
+ export function issuesWithLiveAgent() {
31
+ const dir = join(stateDir(), "agents"), out = new Set();
32
+ let files = [];
33
+ try {
34
+ files = readdirSync(dir).filter((f) => f.endsWith(".json"));
35
+ }
36
+ catch { /* no runs yet */ }
37
+ for (const f of files) {
38
+ try {
39
+ const s = JSON.parse(readFileSync(join(dir, f), "utf8"));
40
+ if (s.status === "ok" || s.status === "fail" || s.status === "killed" || !alive(Number(s.pid)))
41
+ continue;
42
+ out.add(basename(dirname(String(s.prefix ?? ""))));
43
+ }
44
+ catch { /* unreadable state: ignore */ }
45
+ }
46
+ return out;
47
+ }
package/dist/notice.js CHANGED
@@ -9,7 +9,7 @@
9
9
  // if ui.desktop_notify allows, a desktop notification. Successes stay quiet: a plain herdr notification, nothing else.
10
10
  // `orq board` reads the same log to show failures that were not delivered or not yet acknowledged.
11
11
  import { spawn, spawnSync } from "node:child_process";
12
- import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from "node:fs";
12
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync } from "node:fs";
13
13
  import { dirname, join } from "node:path";
14
14
  import { fileURLToPath } from "node:url";
15
15
  import { getPath, loadConfig } from "./config.js";
@@ -181,9 +181,24 @@ const exec = (cmd, args, timeoutMs = 15_000) => {
181
181
  return { status: r.error ? null : r.status, stdout: r.stdout ?? "", stderr: r.stderr || (r.error ? String(r.error.message) : "") };
182
182
  };
183
183
  const hasBin = (b) => spawnSync(process.platform === "win32" ? "where" : "which", [b], { stdio: "ignore" }).status === 0;
184
- /** Follow `orq handoff` redirects to the pane that now holds the conductor (stops at cycles and unreadable files). */
185
- export function currentPane(pane) {
186
- const seen = new Set();
184
+ /** Does herdr know this pane? true | false (herdr answered pane_not_found) | undefined (unreachable or unclear).
185
+ * `pane get`, not `agent get`: a live pane whose agent exited has no agent but is still a place to type. */
186
+ export function paneExists(pane, run = exec) {
187
+ const r = run("herdr", ["pane", "get", pane], 2_000);
188
+ if (r.status === 0)
189
+ return true;
190
+ if (r.status === null)
191
+ return undefined; // herdr is not installed / did not answer
192
+ return /"code"\s*:\s*"pane_not_found"/.test(`${r.stdout}\n${r.stderr}`) ? false : undefined;
193
+ }
194
+ /**
195
+ * Follow `orq handoff` redirects to the pane that now holds the conductor (stops at cycles and unreadable files).
196
+ * Walks the whole chain by files and returns the furthest pane that still exists, so a dead middle hop (A→B→C, B gone,
197
+ * C alive) does not strand the notice. The redirect out of that last live pane leads nowhere live: it is deleted (and
198
+ * logged). When herdr cannot be asked, a hop counts as live (the old behaviour: follow blindly).
199
+ */
200
+ export function currentPane(pane, run = exec) {
201
+ const chain = [pane], seen = new Set();
187
202
  while (!seen.has(pane) && existsSync(handoffFile(pane))) {
188
203
  seen.add(pane);
189
204
  let next;
@@ -195,9 +210,22 @@ export function currentPane(pane) {
195
210
  }
196
211
  if (!next || next === pane)
197
212
  break;
213
+ chain.push(next);
198
214
  pane = next;
199
215
  }
200
- return pane;
216
+ let last = 0;
217
+ for (let i = 1; i < chain.length; i++)
218
+ if (paneExists(chain[i], run) !== false)
219
+ last = i;
220
+ // A redirect out of the last live pane exists only when the chain went on to dead panes.
221
+ if (last < chain.length - 1 && existsSync(handoffFile(chain[last]))) {
222
+ try {
223
+ rmSync(handoffFile(chain[last]), { force: true });
224
+ append({ id: `redirect-${Date.now().toString(36)}`, ev: "redirect", pane: chain[last], detail: `stale handoff to closed ${chain[last + 1]} removed` });
225
+ }
226
+ catch { /* best effort */ }
227
+ }
228
+ return chain[last];
201
229
  }
202
230
  function realDeps(id, pane, issue) {
203
231
  let setting;
package/dist/pick.js CHANGED
@@ -3,7 +3,8 @@
3
3
  // for an agent the user didn't list.
4
4
  // auto role: every pool agent that passes is scored:
5
5
  // quality * delivery + tokens * economy + speed * quickness − rotation * (picks in the last `window`)
6
- // − reserve (agents with reserve = true) + explore (fewer than `min_samples` runs)
6
+ // − reserve (agents with reserve = true) + explore (fewer than `min_samples` runs, fading with n, none after
7
+ // `explore_streak` picks in a row). Each axis is (prior·k + observed·n)/(k + n), k = min_samples.
7
8
  // Filters: --exclude-agent, orq block, nobody reviews their own code (unless no other agent can), cross-vendor rule, a model for the role/level,
8
9
  // quota under the limits: policy.quota_week (95) on the weekly window, and on the 5h one policy.quota_5h per level
9
10
  // (simple 90, medium 85, heavy 80): a heavy task needs more room left to finish.
@@ -84,7 +85,37 @@ export function modelFor(a, role, level) {
84
85
  const [model, effort] = spec.split("@");
85
86
  return [model, effort || undefined];
86
87
  }
87
- const WEIGHTS = { quality: 0.4, tokens: 0.45, speed: 0.15, rotation: 0.2, reserve: 0.3, explore: 0.1, min_samples: 8, window: 6 };
88
+ // --- per-model quota ---------------------------------------------------------------------------------------------------
89
+ // A collector may add lines "<account>/<model> 5h=.. week=.." (antigravity: one bucket per model group). The model a pick is about
90
+ // to choose is checked against its own line, and an exhausted one gives way to a lighter model the agent has configured.
91
+ const LEVEL_RANK = { simple: 0, medium: 1, default: 1, heavy: 2 };
92
+ /** The quota line key for a configured model: the exact id, else the id without its -low/-medium/-high suffix, plain or
93
+ * as "<base>-tiered" (one bucket serves all three efforts of a tiered model). */
94
+ export function modelKey(model, keys) {
95
+ const id = model.split("/").pop() ?? model;
96
+ const base = id.replace(/-(low|medium|high)$/, "");
97
+ return [id, base, `${base}-tiered`].find((k) => keys.includes(k));
98
+ }
99
+ /** The models of an agent's role section lighter than `level`, the closest first, without repeats of `tried`. */
100
+ export function lighterModels(a, role, level, tried) {
101
+ const models = a.models;
102
+ if (!models)
103
+ return [];
104
+ const lr = legacyRole(role);
105
+ const ms = models[lr] ?? (lr === "fix" ? models.impl : lr === "watchdog" ? models.opinion ?? models.review : undefined) ?? {};
106
+ const rank = LEVEL_RANK[level] ?? 1, seen = new Set(tried);
107
+ const out = [];
108
+ for (const [, spec] of Object.entries(ms).filter(([lv]) => (LEVEL_RANK[lv] ?? 1) < rank)
109
+ .sort((x, y) => (LEVEL_RANK[y[0]] ?? 1) - (LEVEL_RANK[x[0]] ?? 1))) {
110
+ const [model, effort] = spec.split("@");
111
+ if (seen.has(model))
112
+ continue;
113
+ seen.add(model);
114
+ out.push([model, effort || undefined]);
115
+ }
116
+ return out;
117
+ }
118
+ const WEIGHTS = { quality: 0.4, tokens: 0.45, speed: 0.15, rotation: 0.2, reserve: 0.3, explore: 0.1, explore_streak: 2, min_samples: 8, window: 6 };
88
119
  export const weights = (cfg) => ({ ...WEIGHTS, ...((cfg.policy ?? {}).weights ?? {}) });
89
120
  /** The merged definition (built-in + config) takes a prompt; an agent that can't be resolved doesn't. */
90
121
  function takesPrompt(cfg, name) {
@@ -198,14 +229,49 @@ export function pick(inp) {
198
229
  return { pace: paces.length ? Math.max(...paces) : undefined,
199
230
  hot: penalty > 0 && ((p5 !== undefined && p5 > limit5) || (pw !== undefined && pw > limitWeek)) };
200
231
  };
232
+ // The chosen account's per-model buckets: an exhausted model gives way to a lighter one of the same agent.
233
+ let mfx = mf;
234
+ const acc0 = usable.length ? [...usable].sort((x, y) => x[1] - y[1])[0][0] : undefined;
235
+ const mkeys = acc0 ? Object.keys(inp.windows ?? {}).filter((k) => k.startsWith(`${acc0}/`)).map((k) => k.slice(acc0.length + 1)) : [];
236
+ if (acc0 && mkeys.length) {
237
+ const over = (model) => {
238
+ const k = modelKey(model, mkeys), q = k ? inp.windows?.[`${acc0}/${k}`] : undefined;
239
+ if (!q)
240
+ return undefined;
241
+ const five = (q.five ?? -1) >= limit5, week = (q.week ?? -1) >= limitWeek;
242
+ if (!five && !week)
243
+ return undefined;
244
+ const at = [five ? q.resetFive : undefined, week ? q.resetWeek : undefined].filter((t) => t !== undefined);
245
+ return `${model} ${five ? `5h ${q.five}%` : `${m("week", "semana")} ${q.week}%`}`
246
+ + (at.length ? ` (${m("back", "volta")} ${humanReset(Math.max(...at))})` : "");
247
+ };
248
+ const tried = [], skipped = [];
249
+ let found;
250
+ for (const cand of [mf, ...lighterModels(a, role, level, [mf[0]])]) {
251
+ const why = over(cand[0]);
252
+ if (!why) {
253
+ found = cand;
254
+ break;
255
+ }
256
+ tried.push(cand[0]);
257
+ skipped.push(why);
258
+ }
259
+ if (!found) {
260
+ out(name, `${m("every model over its quota", "todo modelo acima da cota")}: ${skipped.join(", ")}`);
261
+ continue;
262
+ }
263
+ if (skipped.length)
264
+ out(name, `${m("quota exhausted", "cota esgotada")}: ${skipped.join(", ")}; ${m("using", "usando")} ${found[0]}`);
265
+ mfx = found;
266
+ }
201
267
  const ranked = usable.map(([n, v]) => ({ n, v: v, ...paceOf(n) }))
202
268
  .sort((x, y) => Number(x.hot) - Number(y.hot) || x.v - y.v);
203
269
  const chosen = ranked[0];
204
270
  const acct = qn.length > 1 ? chosen?.n : undefined;
205
271
  const statRole = role === "fix" || role === "worker" ? "impl" : role;
206
- const stOf = (sr) => inp.stats.find((r) => r.role === sr && r.agent === name && r.model === (mf[0].split("/").pop() || "default"));
272
+ const stOf = (sr) => inp.stats.find((r) => r.role === sr && r.agent === name && r.model === (mfx[0].split("/").pop() || "default"));
207
273
  const st = stOf(statRole) ?? (role === "watchdog" ? stOf("review") : undefined); // no watchdog runs yet: its review record
208
- pass.push({ agent: name, model: mf[0], effort: mf[1], acct, a, st, pace: chosen?.pace, hot: Boolean(chosen?.hot) });
274
+ pass.push({ agent: name, model: mfx[0], effort: mfx[1], acct, a, st, pace: chosen?.pace, hot: Boolean(chosen?.hot) });
209
275
  if (res.mode === "fixed")
210
276
  break; // the first that passes wins
211
277
  }
@@ -215,24 +281,46 @@ export function pick(inp) {
215
281
  why: m(`fixed by [roles.${res.section}]`, `fixo em [roles.${res.section}]`) };
216
282
  return { mode: "fixed", best, cands: best ? [best] : [], notes };
217
283
  }
218
- const few = (st) => (st?.n ?? 0) < w.min_samples;
219
- const toks = pass.filter((c) => c.st?.new && !few(c.st)).map((c) => c.st.new);
220
- const mins = pass.filter((c) => c.st?.min && !few(c.st)).map((c) => c.st.min);
284
+ // Each axis blends the agent's prior with what was measured, (prior·k + observed·n) / (k + n) with k = min_samples,
285
+ // so a couple of runs already move the score instead of being ignored until the threshold.
286
+ const k = Math.max(0, w.min_samples);
287
+ const few = (st) => (st?.n ?? 0) < k;
288
+ const blend = (prior, obs, n) => obs === undefined || k + n <= 0 ? prior : (prior * k + obs * n) / (k + n);
289
+ // The reference cost/time is the best of the well-sampled agents, or of everyone measured when none is.
290
+ const best = (vals) => {
291
+ const solid = vals.filter((x) => x.solid);
292
+ const use = solid.length ? solid : vals;
293
+ return use.length ? Math.min(...use.map((x) => x.v)) : undefined;
294
+ };
295
+ const refTok = best(pass.filter((c) => c.st?.new).map((c) => ({ v: c.st.new, solid: !few(c.st) })));
296
+ const refMin = best(pass.filter((c) => c.st?.min).map((c) => ({ v: Math.max(c.st.min, 0.1), solid: !few(c.st) })));
297
+ const axis = (label, prior, obs, n) => {
298
+ const v = blend(prior, obs, n);
299
+ return { v, txt: `${label}=${v.toFixed(2)}` + (obs === undefined ? "" : `(${prior.toFixed(2)}→${obs.toFixed(2)})`) };
300
+ };
221
301
  const cands = pass.map((c) => {
222
302
  const prior = Number(c.a.prior ?? 0.6), n = c.st?.n ?? 0, reserve = Boolean(c.a.reserve);
223
- const deliv = role === "impl" && c.st?.approved != null && c.st.n_appr >= 3 ? c.st.approved / 100
224
- : c.st ? c.st.ok / 100 : undefined;
225
- const d = few(c.st) || deliv === undefined ? prior : deliv;
226
- const t = few(c.st) || !c.st?.new || !toks.length ? prior : Math.min(...toks) / c.st.new;
227
- const s = few(c.st) || !c.st?.min || !mins.length ? prior : Math.min(...mins) / Math.max(c.st.min, 0.1);
303
+ const appr = role === "impl" && c.st?.approved != null && c.st.n_appr >= 3;
304
+ const deliv = appr ? c.st.approved / 100 : c.st ? c.st.ok / 100 : undefined;
305
+ const d = axis(m("delivery", "entrega"), prior, deliv, appr ? c.st.n_appr : n);
306
+ const t = axis(m("economy", "economia"), prior, c.st?.new && refTok ? Math.min(1, refTok / c.st.new) : undefined, n);
307
+ const s = axis(m("speed", "rapidez"), prior, c.st?.min && refMin ? Math.min(1, refMin / Math.max(c.st.min, 0.1)) : undefined, n);
228
308
  const used = inp.recent.filter((p) => p.agent === c.agent).length;
309
+ // Exploration fades as samples come in and stops after explore_streak picks of the agent in a row.
310
+ let streak = 0;
311
+ for (let i = inp.recent.length - 1; i >= 0 && inp.recent[i].agent === c.agent; i--)
312
+ streak++;
313
+ const capped = few(c.st) && streak >= w.explore_streak;
314
+ const explore = few(c.st) && !capped ? w.explore * (k - n) / k : 0;
229
315
  const hot = Boolean(c.hot) && penalty > 0;
230
- const score = w.quality * d + w.tokens * t + w.speed * s - w.rotation * used
231
- - (reserve ? w.reserve : 0) + (few(c.st) ? w.explore : 0) - (hot ? penalty : 0);
232
- const why = `${m("delivery", "entrega")}=${d.toFixed(2)} ${m("economy", "economia")}=${t.toFixed(2)} `
233
- + `${m("speed", "rapidez")}=${s.toFixed(2)} ${m("rotation", "rodízio")}=-${used} `
316
+ const score = w.quality * d.v + w.tokens * t.v + w.speed * s.v - w.rotation * used
317
+ - (reserve ? w.reserve : 0) + explore - (hot ? penalty : 0);
318
+ const why = `${d.txt} ${t.txt} ${s.txt} ${m("rotation", "rodízio")}=-${used} `
234
319
  + (reserve ? m("reserve ", "reserva ") : "")
235
- + (hot ? `${m("pace", "ritmo")}=${Math.round(c.pace)}%→-${penalty} ` : "") + (few(c.st) ? `${m("few samples", "poucos dados")}(n=${n})` : `n=${n}`);
320
+ + (hot ? `${m("pace", "ritmo")}=${Math.round(c.pace)}%→-${penalty} ` : "")
321
+ + (explore ? `${m("explore", "explorar")}=+${explore.toFixed(2)} ` : "")
322
+ + (capped ? `${m("explore capped", "exploração limitada")}(${streak}x) ` : "")
323
+ + (few(c.st) ? `${m("few samples", "poucos dados")}(n=${n})` : `n=${n}`);
236
324
  return { agent: c.agent, model: c.model, effort: c.effort, acct: c.acct, score, why };
237
325
  }).sort((x, y) => y.score - x.score);
238
326
  return { mode: "auto", best: cands[0], cands, notes };
package/dist/process.js CHANGED
@@ -77,6 +77,13 @@ export function treePids(root, table = processTable()) {
77
77
  export function treeMem(root, table = processTable()) {
78
78
  return treePids(root, table).reduce((t, p) => t + (table.get(p)?.[1] ?? 0), 0);
79
79
  }
80
+ /** An exit code of 128+signal (137 = SIGKILL): the process was killed, it did not finish. */
81
+ export const killedRc = (rc) => rc !== undefined && rc > 128 && rc <= 128 + 64;
82
+ /** "status=ok rc=137" (written before `killed` existed) is a kill, not a success: the status a done line really means. */
83
+ export function doneStatus(line) {
84
+ const st = /\bstatus=(\S+)/.exec(line)?.[1], rc = /\brc=(\d+)/.exec(line)?.[1];
85
+ return st === "ok" && killedRc(rc === undefined ? undefined : Number(rc)) ? "killed" : st;
86
+ }
80
87
  export function killTree(pid, group = true) {
81
88
  if (process.platform === "win32")
82
89
  spawnSync("taskkill", ["/T", "/F", "/PID", String(pid)], { stdio: "ignore" });