@irythill/orq 0.1.0 → 0.3.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.
@@ -15,13 +15,13 @@ const END = "<!-- orq:issue end -->";
15
15
  /** The playbook for harnesses without skills: no frontmatter, a trigger line, and how to wait without background tasks. */
16
16
  export function agentsBlock(skill) {
17
17
  const body = skill.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "").trim();
18
- return `${BEGIN}
19
- When the user says "/issue <ID>", "take issue X" or "run ABC-123", follow this playbook.
20
- If your harness cannot run a command in the background, wait for children with \`orq wait\` in the foreground
21
- (\`orq wait --any <prefix>...\`): it is the one allowed blocking wait.
22
-
23
- ${body}
24
- ${END}
18
+ return `${BEGIN}
19
+ When the user says "/issue <ID>", "take issue X" or "run ABC-123", follow this playbook.
20
+ If your harness cannot run a command in the background, wait for children with \`orq wait\` in the foreground
21
+ (\`orq wait --any <prefix>...\`): it is the one allowed blocking wait.
22
+
23
+ ${body}
24
+ ${END}
25
25
  `;
26
26
  }
27
27
  /** Put the block into an existing AGENTS.md text: replace ours, or append it. */
@@ -7,7 +7,7 @@ import { spawnSync } from "node:child_process";
7
7
  import { getPath, loadConfig } from "../config.js";
8
8
  import { m } from "../i18n.js";
9
9
  import { acquire, release, status } from "../lock.js";
10
- export const USAGE = `orq lock wait|release <name> [--pid P] [--label text] test queue (slots = machine.test_slots)
10
+ export const USAGE = `orq lock wait|release <name> [--pid P] [--label text] test queue (slots = machine.test_slots)
11
11
  orq lock run <name> [--label text] -- <cmd>... · orq lock status [<name>] [--json]`;
12
12
  const slots = () => Number(getPath(loadConfig().cfg, "machine.test_slots") ?? 1);
13
13
  const since = (at) => `${Math.round((Date.now() - at) / 1000)}s`;
@@ -1,19 +1,21 @@
1
1
  // orq pick — agent, model, effort and account for a role, from [roles] (fixed or auto), the history, quota and blocks.
2
2
  // Prints the runner arguments (e.g. `-a codex -m gpt-6-sol -e low -c work`); each pick is recorded in
3
3
  // <state dir>/picks.jsonl (that feeds rotation). Exit 3 = no candidate: ask the user.
4
+ // `orq pick fix --same <file>`: the implementer's saved pick, effort one level up (no choice, nothing recorded).
4
5
  import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
5
6
  import { tmpdir } from "node:os";
6
7
  import { join } from "node:path";
7
8
  import { loadConfig } from "../config.js";
8
9
  import { m } from "../i18n.js";
9
- import { asArgs, parseQuota, pick as choose } from "../pick.js";
10
+ import { asArgs, escalate, parseQuota, pick as choose } from "../pick.js";
10
11
  import { expand, stateDir } from "../paths.js";
11
12
  import { collect } from "../quota.js";
12
13
  import { PICK_ROLES, vendorOf } from "../roles.js";
13
14
  import { collectRuns, rows } from "../stats.js";
14
15
  import { load as loadBlocks } from "./block.js";
15
- export const USAGE = `orq pick <impl|fix|worker|review|opinion> [--level simple|medium|heavy] [--impl-vendor <v>|--impl-agent <a>]
16
- [--exclude-agent <a>]... [-w <workspace>] [--explain] [--no-record]`;
16
+ export const USAGE = `orq pick <impl|fix|worker|review|opinion> [--level simple|medium|heavy] [--impl-vendor <v>|--impl-agent <a>]
17
+ [--exclude-agent <a>]... [-w <workspace>] [--explain] [--no-record]
18
+ orq pick fix --same <impl pick file> same agent/model/account as the implementer, effort one level up`;
17
19
  // The board refreshes this file; fresher than 10 min → no new quota requests.
18
20
  const QCACHE = join(process.env.XDG_RUNTIME_DIR ?? tmpdir(), "herdr-board.quota");
19
21
  async function quotaText(cfg) {
@@ -27,6 +29,24 @@ export async function pick(argv) {
27
29
  console.error(USAGE);
28
30
  return 2;
29
31
  }
32
+ if (role === "fix" && argv[1] === "--same") {
33
+ const f = argv[2];
34
+ if (!f || argv.length > 3) {
35
+ console.error(USAGE);
36
+ return 2;
37
+ }
38
+ if (!existsSync(f)) {
39
+ console.error(`orq pick: ${m("no such file: ", "arquivo não existe: ")}${f}`);
40
+ return 2;
41
+ }
42
+ const prev = readFileSync(f, "utf8").trim();
43
+ if (!/(^|\s)-a \S/.test(prev)) {
44
+ console.error(`orq pick: ${m("not a pick: ", "não é um pick: ")}${f}`);
45
+ return 2;
46
+ }
47
+ console.log(escalate(prev));
48
+ return 0;
49
+ }
30
50
  let level = "medium", implVendor, implAgent, ws;
31
51
  let explain = false, record = true;
32
52
  const exclude = new Set();
@@ -1,9 +1,39 @@
1
1
  // orq quota [name...] quota of each source in [quota] (default: all) — lines "<name> 5h=NN% week=NN% [note]"
2
+ // orq quota --line one line for a status bar (herdr's tab_bar_right command entry): the board's cache, never
3
+ // the network — a stale cache starts the board's background collection, like the board does.
2
4
  import { getPath, loadConfig } from "../config.js";
5
+ import { m } from "../i18n.js";
3
6
  import { collect } from "../quota.js";
4
- export const USAGE = "orq quota [name...] quota per agent/account ([quota] in the config)";
7
+ import { quota as cached } from "./board.js";
8
+ import { load as loadBlocks } from "./block.js";
9
+ export const USAGE = `orq quota [name...] quota per agent/account ([quota] in the config)
10
+ orq quota --line one line for a status bar, from the board's cache (e.g. herdr tab_bar_right)`;
11
+ const pct = (p) => (/^\d+%$/.test(p) ? Number(p.slice(0, -1)) : undefined);
12
+ /** "work 64 · home 93! · codex 81 · minimax ?": the tighter of the two windows per source; ! at or over the pick
13
+ * limit, ⊘ blocked (orq block), ? no data; sources with no window at all ("-" and "-") are left out. */
14
+ export function quotaLine(lines, limit, blocked, age) {
15
+ const parts = lines.filter((q) => q.five !== "-" || q.week !== "-").map((q) => {
16
+ if (blocked.has(q.name))
17
+ return `${q.name} ⊘`;
18
+ const ns = [pct(q.five), pct(q.week)].filter((n) => n !== undefined);
19
+ if (!ns.length)
20
+ return `${q.name} ?`;
21
+ const top = Math.max(...ns);
22
+ return `${q.name} ${top}${top >= limit ? "!" : ""}`;
23
+ });
24
+ if (!parts.length)
25
+ return age === undefined ? m("quota: collecting...", "cota: coletando...") : "";
26
+ const old = age !== undefined && age > 15 * 60_000 ? ` (${Math.round(age / 60_000)} min)` : "";
27
+ return parts.join(" · ") + old;
28
+ }
5
29
  export async function quota(argv) {
6
- const sources = (getPath(loadConfig().cfg, "quota") ?? {});
30
+ const { cfg } = loadConfig();
31
+ if (argv[0] === "--line") {
32
+ const q = cached(Date.now());
33
+ console.log(quotaLine(q.lines, Number(getPath(cfg, "policy.quota_limit") ?? 90), new Set(Object.keys(loadBlocks())), q.age));
34
+ return 0;
35
+ }
36
+ const sources = (getPath(cfg, "quota") ?? {});
7
37
  for (const l of await collect(sources, argv))
8
38
  console.log(l);
9
39
  return 0;
@@ -18,7 +18,7 @@ import { m } from "../i18n.js";
18
18
  import { environment, installSkills, prepareWorkspace } from "../install.js";
19
19
  import { expand, userConfigFile } from "../paths.js";
20
20
  import { suggestRoles, toToml } from "../suggest.js";
21
- export const USAGE = `orq setup [--preset economy|quality|single-agent] guided first configuration (roles, weights, memory)
21
+ export const USAGE = `orq setup [--preset economy|quality|single-agent] guided first configuration (roles, weights, memory)
22
22
  orq setup --detect | --answers <file|-> [--write] | --install | --workspace <dir> [--conductor <agent>]`;
23
23
  /** Agent name → its CLI and a cheap headless smoke test. */
24
24
  const CLIS = {
@@ -139,8 +139,8 @@ function workspace(dir, conductor) {
139
139
  for (const l of prepareWorkspace(d, c === "claude" ? "claude" : "agents"))
140
140
  console.log(l);
141
141
  }
142
- const next = () => console.log(m(`Next: fill in the workspace CLAUDE.md, open your conductor in that folder and say "/issue <ID>".
143
- Board: orq board · quota: orq quota · completion: orq completion <shell>`, `Próximo: preencha o CLAUDE.md do workspace, abra o seu conductor nessa pasta e diga "/issue <ID>".
142
+ const next = () => console.log(m(`Next: fill in the workspace CLAUDE.md, open your conductor in that folder and say "/issue <ID>".
143
+ Board: orq board · quota: orq quota · completion: orq completion <shell>`, `Próximo: preencha o CLAUDE.md do workspace, abra o seu conductor nessa pasta e diga "/issue <ID>".
144
144
  Painel: orq board · cota: orq quota · autocompletar: orq completion <shell>`));
145
145
  export async function setup(argv) {
146
146
  let preset, answers, doWrite = false, ws, conductor;
package/dist/config.js CHANGED
@@ -27,7 +27,7 @@ export const DEFAULTS = {
27
27
  ui: { lang: "auto", theme: "gunmetal" }, // lang: auto | en | pt · theme: gunmetal | chrome | lcd | terminal
28
28
  // Where quota comes from, per agent: "builtin" | "none" | false | { command = "..." } (see src/quota.ts).
29
29
  // "claude" covers every account in claude-accounts.
30
- quota: { claude: "builtin", codex: "builtin", opencode: "none", antigravity: "builtin", copilot: "builtin" },
30
+ quota: { claude: "builtin", codex: "builtin", opencode: "builtin", antigravity: "builtin", copilot: "builtin" },
31
31
  tracker: { kind: "none" }, // plane | none
32
32
  repos: {}, // defined by the workspace
33
33
  };
package/dist/pick.js CHANGED
@@ -111,5 +111,16 @@ export function pick(inp) {
111
111
  }).sort((x, y) => y.score - x.score);
112
112
  return { mode: "auto", best: cands[0], cands, notes };
113
113
  }
114
+ const EFFORTS = ["low", "medium", "high"];
115
+ /** A fix continues the implementer's session: the same runner arguments (agent, model, account) with the effort one
116
+ * level up, capped at high. Keeping the model keeps the session's prompt cache; an unknown or absent effort stays. */
117
+ export function escalate(args) {
118
+ const p = args.trim().split(/\s+/);
119
+ const i = p.indexOf("-e");
120
+ const at = i >= 0 ? EFFORTS.indexOf(p[i + 1]) : -1;
121
+ if (at >= 0)
122
+ p[i + 1] = EFFORTS[Math.min(at + 1, EFFORTS.length - 1)];
123
+ return p.join(" ");
124
+ }
114
125
  /** The arguments for the agent runner, e.g. "-a codex -m gpt-6-sol -e low -c work". */
115
126
  export const asArgs = (c) => `-a ${c.agent}` + (c.model ? ` -m ${c.model}` : "") + (c.effort ? ` -e ${c.effort}` : "") + (c.acct ? ` -c ${c.acct}` : "");
package/dist/process.js CHANGED
@@ -58,27 +58,29 @@ function parseTable(text) {
58
58
  }
59
59
  return t;
60
60
  }
61
- /** Total memory of `root` and all its descendants. */
62
- export function treeMem(root, table = processTable()) {
61
+ /** `root` and all its descendants. */
62
+ export function treePids(root, table = processTable()) {
63
63
  const kids = new Map();
64
64
  for (const [pid, [ppid]] of table)
65
65
  kids.set(ppid, [...(kids.get(ppid) ?? []), pid]);
66
- let total = 0;
67
66
  const seen = new Set(), todo = [root];
68
67
  while (todo.length) {
69
68
  const p = todo.pop();
70
69
  if (seen.has(p))
71
70
  continue;
72
71
  seen.add(p);
73
- total += table.get(p)?.[1] ?? 0;
74
72
  todo.push(...(kids.get(p) ?? []));
75
73
  }
76
- return total;
74
+ return [...seen];
75
+ }
76
+ /** Total memory of `root` and all its descendants. */
77
+ export function treeMem(root, table = processTable()) {
78
+ return treePids(root, table).reduce((t, p) => t + (table.get(p)?.[1] ?? 0), 0);
77
79
  }
78
- export function killTree(pid) {
80
+ export function killTree(pid, group = true) {
79
81
  if (process.platform === "win32")
80
82
  spawnSync("taskkill", ["/T", "/F", "/PID", String(pid)], { stdio: "ignore" });
81
- else
83
+ else if (group)
82
84
  try {
83
85
  process.kill(-pid, "SIGKILL");
84
86
  }
@@ -88,6 +90,12 @@ export function killTree(pid) {
88
90
  }
89
91
  catch { /* gone */ }
90
92
  }
93
+ else
94
+ for (const p of treePids(pid))
95
+ try {
96
+ process.kill(p, "SIGKILL");
97
+ }
98
+ catch { /* gone */ } // no group of its own
91
99
  }
92
100
  /** cmd.exe quoting for one argument (only used when Windows needs a shell). */
93
101
  export const winQuote = (a) => /[\s"&|<>^%()]/.test(a) || a === "" ? `"${a.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\+)$/, "$1$1")}"` : a;
@@ -103,10 +111,10 @@ export function runCapped(cmd, args, o = {}) {
103
111
  return wait(c);
104
112
  }
105
113
  const win = process.platform === "win32";
106
- // Own process group on Linux/macOS, so the whole tree can be killed at once.
114
+ // Own process group on Linux/macOS, so the whole tree can be killed at once (not for a screen: it needs the terminal).
107
115
  // Windows: a shell only for .cmd/.bat shims (npx, claude…); an .exe runs directly (paths with spaces stay intact).
108
116
  const shell = win && !/\.exe$/i.test(cmd), q = shell && o.quote;
109
- const child = spawn(q ? winQuote(cmd) : cmd, q ? args.map(winQuote) : args, { stdio: o.stdio ?? "inherit", cwd: o.cwd, env: o.env, detached: !win, shell, windowsHide: true });
117
+ const child = spawn(q ? winQuote(cmd) : cmd, q ? args.map(winQuote) : args, { stdio: o.stdio ?? "inherit", cwd: o.cwd, env: o.env, detached: !win && !o.tty, shell, windowsHide: true });
110
118
  o.onSpawn?.(child);
111
119
  if (mode === "off" || !child.pid)
112
120
  return wait(child);
@@ -117,7 +125,7 @@ export function runCapped(cmd, args, o = {}) {
117
125
  child.kill(sig);
118
126
  } };
119
127
  const onInt = fwd("SIGINT"), onTerm = fwd("SIGTERM");
120
- if (!win) {
128
+ if (!win && !o.tty) {
121
129
  process.on("SIGINT", onInt);
122
130
  process.on("SIGTERM", onTerm);
123
131
  }
@@ -131,7 +139,7 @@ export function runCapped(cmd, args, o = {}) {
131
139
  if (used > o.cap && !killed) {
132
140
  killed = true;
133
141
  console.error(m(`orq: memory ${gb(used)} over the cap (${gb(o.cap)}): killing the process tree`, `orq: memória ${gb(used)} acima do limite (${gb(o.cap)}): encerrando a árvore de processos`));
134
- killTree(child.pid);
142
+ killTree(child.pid, !o.tty);
135
143
  }
136
144
  }, o.intervalMs ?? 1000);
137
145
  return wait(child).then((code) => {
package/dist/quota.js CHANGED
@@ -3,6 +3,8 @@
3
3
  // codex: the rate_limits Codex writes into its latest session file
4
4
  // antigravity: per-model quota from Cloud Code (fetchAvailableModels), with the login agy keeps
5
5
  // in the OS keyring (src/keyring.ts: Secret Service, Keychain, Credential Manager)
6
+ // opencode: the MiniMax Token Plan (/v1/token_plan/remains) with $MINIMAX_API_KEY or the key
7
+ // OpenCode keeps in its auth.json; $MINIMAX_API_HOST for the China region
6
8
  // copilot: premium requests left this month (`gh api /copilot_internal/user`; a monthly
7
9
  // allowance, shown in the week slot)
8
10
  // false left out of the list entirely
@@ -117,7 +119,8 @@ function latestCodexSession() {
117
119
  walk(root, 0);
118
120
  return best?.f;
119
121
  }
120
- /** The last rate_limits in a Codex session (.jsonl): 300-min window = 5h, 10080 = week; a window already reset → 0. */
122
+ /** The last rate_limits in a Codex session (.jsonl): a window up to 6 h = 5h; the longest one over that (10080 = week,
123
+ * 43200 on the free plan = 30 days) fills the week slot, with `span` (minutes) when it isn't a week; reset → 0. */
121
124
  export function codexFromSession(text, nowSec) {
122
125
  let last;
123
126
  for (const line of text.split("\n")) {
@@ -139,13 +142,11 @@ export function codexFromSession(text, nowSec) {
139
142
  if (!last)
140
143
  return undefined;
141
144
  const ws = [last.primary, last.secondary].filter((w) => !!w);
142
- const win = (mins) => {
143
- const w = ws.find((x) => x.window_minutes === mins);
144
- if (!w)
145
- return "-";
146
- return (w.resets_at ?? 0) < nowSec ? 0 : Math.floor(w.used_percent ?? 0);
147
- };
148
- return { five: win(300), week: win(10080) };
145
+ const pct = (w) => !w ? "-" : (w.resets_at ?? 0) < nowSec ? 0 : Math.floor(w.used_percent ?? 0);
146
+ const short = ws.find((x) => (x.window_minutes ?? 0) <= 360);
147
+ const long = ws.filter((x) => (x.window_minutes ?? 0) > 360).sort((a, b) => b.window_minutes - a.window_minutes)[0];
148
+ const span = long && long.window_minutes !== 10080 ? long.window_minutes : undefined;
149
+ return { five: pct(short), week: pct(long), ...(span ? { span } : {}) };
149
150
  }
150
151
  export function codexQuota() {
151
152
  const f = latestCodexSession();
@@ -155,7 +156,10 @@ export function codexQuota() {
155
156
  if (!q)
156
157
  return noData("codex", m("no limit data", "sem dados de limite"));
157
158
  const age = Math.round((Date.now() - statSync(f).mtimeMs) / 60_000);
158
- return { name: "codex", ...q, note: m(`from the last session, ${age} min ago`, `da última sessão, há ${age} min`) };
159
+ const { span, ...pcts } = q;
160
+ const d = span && Math.round(span / 1440);
161
+ return { name: "codex", ...pcts, note: (d ? m(`${d}-day window in the week slot; `, `janela de ${d} dias no lugar da semana; `) : "")
162
+ + m(`from the last session, ${age} min ago`, `da última sessão, há ${age} min`) };
159
163
  }
160
164
  /** Worst usage per window across models: a reset within 6 h belongs to the 5h window, a later one to the week. */
161
165
  export function antigravityFromModels(models, now = Date.now()) {
@@ -243,6 +247,58 @@ export function copilotQuota() {
243
247
  return noData("copilot", m("collector failed", "coletor falhou"));
244
248
  }
245
249
  }
250
+ /** Worst usage per window across the plan's models (remaining % → used %); resets from the windows' end times. */
251
+ export function minimaxFromRemains(j) {
252
+ const ms = j?.model_remains ?? [];
253
+ if (!ms.length)
254
+ return undefined;
255
+ const worst = (pct, end) => {
256
+ let w = { pct: "-" };
257
+ for (const x of ms) {
258
+ if (typeof x[pct] !== "number")
259
+ continue;
260
+ const used = 100 - x[pct];
261
+ if (w.pct === "-" || used > w.pct)
262
+ w = { pct: used, ...(typeof x[end] === "number" ? { resetsAt: new Date(x[end]).toISOString() } : {}) };
263
+ }
264
+ return w;
265
+ };
266
+ return { five: worst("current_interval_remaining_percent", "end_time"),
267
+ week: worst("current_weekly_remaining_percent", "weekly_end_time") };
268
+ }
269
+ function minimaxKey() {
270
+ if (process.env.MINIMAX_API_KEY)
271
+ return process.env.MINIMAX_API_KEY;
272
+ const data = process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
273
+ try {
274
+ return JSON.parse(readFileSync(join(data, "opencode", "auth.json"), "utf8")).minimax?.key;
275
+ }
276
+ catch {
277
+ return undefined;
278
+ }
279
+ }
280
+ export async function minimaxQuota(name) {
281
+ const key = minimaxKey();
282
+ // OpenCode may run another provider: without a MiniMax key there is nothing to limit.
283
+ if (!key)
284
+ return { name, five: "-", week: "-", note: m("no MiniMax key", "sem chave da MiniMax") };
285
+ try {
286
+ const host = process.env.MINIMAX_API_HOST ?? "api.minimax.io";
287
+ const r = await fetch(`https://${host}/v1/token_plan/remains`, {
288
+ headers: { Authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(15_000)
289
+ });
290
+ if (!r.ok)
291
+ return fromCache(name, `HTTP ${r.status}`);
292
+ const j = await r.json();
293
+ if (j.base_resp?.status_code)
294
+ return fromCache(name, `MiniMax ${j.base_resp.status_code}: ${j.base_resp.status_msg ?? ""}`.trim());
295
+ const q = minimaxFromRemains(j);
296
+ return q ? remember(name, q.five, q.week) : noData(name, m("no plan data", "sem dados do plano"));
297
+ }
298
+ catch (e) {
299
+ return fromCache(name, m("collector failed", "coletor falhou") + `: ${e.message}`);
300
+ }
301
+ }
246
302
  // --- command ----------------------------------------------------------------------------------------------------------
247
303
  function commandQuota(name, cmd) {
248
304
  const r = spawnSync(cmd, { shell: true, encoding: "utf8", timeout: 30_000 });
@@ -270,6 +326,8 @@ export async function collect(sources, want = []) {
270
326
  return wanted(name) ? [format(await antigravityQuota())] : [];
271
327
  if (name === "copilot")
272
328
  return wanted(name) ? [format(copilotQuota())] : [];
329
+ if (name === "opencode" || name === "minimax")
330
+ return wanted(name) ? [format(await minimaxQuota(name))] : [];
273
331
  return wanted(name) ? [format(noData(name, m("no builtin collector: use \"none\" or a command", "sem coletor embutido: use \"none\" ou um comando")))] : [];
274
332
  });
275
333
  return (await Promise.all(jobs)).flat();
package/dist/terminal.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // tmux a background window in the current tmux session
4
4
  // plain a detached background process; its screen output goes to <state dir>/agents/<run>.log
5
5
  // auto = herdr inside a herdr pane (HERDR_PANE_ID), else tmux inside tmux (TMUX), else plain.
6
+ // The agent's own screen (-T) needs herdr or tmux; follow-ups are typed into it while it is open.
6
7
  import { spawn, spawnSync } from "node:child_process";
7
8
  import { closeSync, mkdirSync, openSync } from "node:fs";
8
9
  import { join } from "node:path";
@@ -69,6 +70,31 @@ export function launch(host, l) {
69
70
  closeSync(fd);
70
71
  return `pid:${c.pid}`;
71
72
  }
73
+ /** Is the screen `launch` returned still open? (herdr pane or tmux pane; a background pid has no screen) */
74
+ export function screenOpen(where) {
75
+ if (where.startsWith("tmux:"))
76
+ return spawnSync("tmux", ["display-message", "-p", "-t", where.slice(5), "#{pane_id}"], { stdio: "ignore" }).status === 0;
77
+ if (!where || where.startsWith("pid:"))
78
+ return false;
79
+ return spawnSync("herdr", ["pane", "get", where], { stdio: "ignore" }).status === 0;
80
+ }
81
+ /** Type `text` into an open screen and submit it (a follow-up to an agent's own TUI). */
82
+ export function typeInto(where, text) {
83
+ if (where.startsWith("tmux:")) {
84
+ // A bracketed paste keeps a multi-line prompt as one message; then Enter sends it.
85
+ const t = where.slice(5), run = (a, input) => {
86
+ const r = spawnSync("tmux", a, { input, encoding: "utf8" });
87
+ if (r.status !== 0)
88
+ throw new Error(`tmux ${a[0]}: ${(r.stderr || "").trim()}`);
89
+ };
90
+ run(["load-buffer", "-b", "orq-prompt", "-"], text);
91
+ run(["paste-buffer", "-p", "-d", "-b", "orq-prompt", "-t", t]);
92
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 300); // let the TUI take the paste before Enter
93
+ run(["send-keys", "-t", t, "Enter"]);
94
+ return;
95
+ }
96
+ herdr(["agent", "prompt", where, text]);
97
+ }
72
98
  /** After a run: close our own herdr tab (the result stays in the files). tmux windows close when the runner exits. */
73
99
  export function closeOwnTab(host) {
74
100
  const p = process.env.HERDR_PANE_ID;
package/dist/testrun.js CHANGED
@@ -2,7 +2,7 @@
2
2
  // memory cap (src/process.ts) and read its JSON report into "<file relative to the repo> :: <full test name>" lines.
3
3
  // The runner's own bin (node_modules/<runner>/package.json "bin") runs with this node, so Windows needs no shell;
4
4
  // without it, npx as before. Also the background job both share (launch on a TerminalHost, queue, .pid/.done, notify).
5
- import { spawnSync } from "node:child_process";
5
+ import { spawn, spawnSync } from "node:child_process";
6
6
  import { createWriteStream, existsSync, lstatSync, readFileSync, realpathSync, rmdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
7
7
  import { dirname, isAbsolute, join, relative } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
@@ -196,8 +196,12 @@ export async function inJob(j, label, slots, body, done) {
196
196
  }
197
197
  return st === "ok" ? 0 : 1;
198
198
  }
199
- /** The conductor is a herdr pane: type the line into it (herdr-notify waits for it to leave a dialog). */
200
- export function notify(pane, line) {
201
- const local = join(dirname(CLI), "..", "setup", "herdr-notify");
202
- spawnSync(existsSync(local) ? local : "herdr-notify", [pane, line], { stdio: "inherit" });
199
+ /** The conductor is a herdr pane: type the line into it (herdr-notify waits for it to leave a dialog). In the
200
+ * background when the caller must not wait for that (an agent's end-of-turn hook holds its screen meanwhile). */
201
+ export function notify(pane, line, background = false) {
202
+ const local = join(dirname(CLI), "..", "setup", "herdr-notify"), cmd = existsSync(local) ? local : "herdr-notify";
203
+ if (background)
204
+ spawn(cmd, [pane, line], { detached: true, stdio: "ignore" }).unref();
205
+ else
206
+ spawnSync(cmd, [pane, line], { stdio: "inherit" });
203
207
  }
package/package.json CHANGED
@@ -1,27 +1,48 @@
1
- {
2
- "name": "@irythill/orq",
3
- "version": "0.1.0",
4
- "description": "Orchestrate coding agents (Claude Code, Codex, OpenCode, Antigravity, Copilot) from one conductor.",
5
- "license": "MIT",
6
- "type": "module",
7
- "bin": { "orq": "dist/cli.js" },
8
- "files": ["dist", "!dist/**/*.test.js", "skills", "templates/workspace.CLAUDE.md"],
9
- "engines": { "node": ">=20" },
10
- "repository": { "type": "git", "url": "git+https://github.com/irythill/orq.git" },
11
- "homepage": "https://github.com/irythill/orq#readme",
12
- "keywords": ["agents", "orchestration", "claude-code", "codex", "opencode", "cli"],
13
- "publishConfig": { "access": "public" },
14
- "scripts": {
15
- "build": "tsc",
16
- "test": "tsc && node --test dist/orq.test.js",
17
- "prepack": "tsc"
18
- },
19
- "dependencies": {
20
- "@napi-rs/keyring": "^2.1.1",
21
- "smol-toml": "^1.9.1"
22
- },
23
- "devDependencies": {
24
- "@types/node": "^24.0.0",
25
- "typescript": "^7.0.2"
26
- }
27
- }
1
+ {
2
+ "name": "@irythill/orq",
3
+ "version": "0.3.0",
4
+ "description": "Orchestrate coding agents (Claude Code, Codex, OpenCode, Antigravity, Copilot) from one conductor.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "orq": "dist/cli.js"
9
+ },
10
+ "files": [
11
+ "dist",
12
+ "!dist/**/*.test.js",
13
+ "skills",
14
+ "templates/workspace.CLAUDE.md"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/irythill/orq.git"
22
+ },
23
+ "homepage": "https://github.com/irythill/orq#readme",
24
+ "keywords": [
25
+ "agents",
26
+ "orchestration",
27
+ "claude-code",
28
+ "codex",
29
+ "opencode",
30
+ "cli"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "scripts": {
36
+ "build": "tsc",
37
+ "test": "tsc && node --test dist/orq.test.js",
38
+ "prepack": "tsc"
39
+ },
40
+ "dependencies": {
41
+ "@napi-rs/keyring": "^2.1.1",
42
+ "smol-toml": "^1.9.1"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^24.0.0",
46
+ "typescript": "^7.0.2"
47
+ }
48
+ }