@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.
@@ -4,6 +4,10 @@
4
4
  // Returns at once; `orq wait <prefix>` blocks until .done. A conductor in herdr (HERDR_PANE_ID) also gets ONE line
5
5
  // "[HERDR-AGENT DONE] <title> status=... answer: <prefix>.out.md" typed into its pane by herdr-notify.
6
6
  // The launcher writes <prefix>.job.json and starts `orq agent --run <job>` on the terminal host; that runner does the rest.
7
+ // -T (or machine.tui / HERDR_AGENT_TUI=1): the agent's own screen (its `tui` template) in a herdr tab or tmux window,
8
+ // same cap and files. The agent calls `orq agent --turn <job> <kind>` at every end of turn, which writes .out.md/.id/
9
+ // .done and notifies; the screen stays open (holding memory) until you close it. -T -R with the screen still open types
10
+ // the follow-up into it; with the screen closed it reopens the session (tui_resume).
7
11
  import { spawnSync } from "node:child_process";
8
12
  import { randomUUID } from "node:crypto";
9
13
  import { appendFileSync, copyFileSync, createWriteStream, existsSync, mkdirSync, openSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
@@ -14,11 +18,12 @@ import { agentDef, ALIASES, expandString, expandTemplate, FORMATS } from "../age
14
18
  import { loadConfig } from "../config.js";
15
19
  import { writeAtomic } from "../fsutil.js";
16
20
  import { m } from "../i18n.js";
21
+ import { alive } from "../lock.js";
17
22
  import { stateDir } from "../paths.js";
18
23
  import { killTree, parseSize, resolveMode, runCapped } from "../process.js";
19
- import { closeOwnTab, detectHost, launch, shq } from "../terminal.js";
24
+ import { closeOwnTab, detectHost, launch, screenOpen, shq, typeInto } from "../terminal.js";
20
25
  import { dropWorktree, notify } from "../testrun.js";
21
- export const USAGE = "orq agent -a <agent> -t <title> -d <dir> -o <prefix> [-g group] [-m model] [-e effort] [-c account] [-i file]... [-b base] [-r] [-R session] -- <prompt.md> run a child agent";
26
+ export const USAGE = "orq agent -a <agent> -t <title> -d <dir> -o <prefix> [-g group] [-m model] [-e effort] [-c account] [-i file]... [-b base] [-r] [-R session] [-T] -- <prompt.md> run a child agent";
22
27
  const CLI = fileURLToPath(new URL("../cli.js", import.meta.url));
23
28
  const PF = ".herdr-agent-prompt.md"; // prompt_in_dir file name (kept from herdr-agent)
24
29
  const git = (dir, ...a) => {
@@ -28,8 +33,10 @@ const git = (dir, ...a) => {
28
33
  export async function agent(argv) {
29
34
  if (argv[0] === "--run")
30
35
  return runJob(argv[1]);
36
+ if (argv[0] === "--turn")
37
+ return turn(argv[1], argv[2], argv.slice(3));
31
38
  let a = process.env.AGENT || "codex", g = "", t = "", d = "", o = "", model = "", effort = "", acc = "", resume = "", base = "";
32
- let ro = false, prompt;
39
+ let ro = false, tui = false, prompt;
33
40
  const inc = [];
34
41
  for (let i = 0; i < argv.length; i++) {
35
42
  const x = argv[i], v = () => argv[++i] ?? "";
@@ -63,10 +70,8 @@ export async function agent(argv) {
63
70
  v();
64
71
  else if (x === "-r")
65
72
  ro = true;
66
- else if (x === "-T") {
67
- console.error(m("-T (the agent's own TUI) only exists in herdr: use herdr-agent -T", "-T (a TUI do próprio agente) só existe no herdr: use herdr-agent -T"));
68
- return 2;
69
- }
73
+ else if (x === "-T")
74
+ tui = true;
70
75
  else if (!x.startsWith("-") && prompt === undefined)
71
76
  prompt = x;
72
77
  else {
@@ -94,10 +99,27 @@ export async function agent(argv) {
94
99
  console.error(m(`-c only applies to agents with Claude accounts (claude)`, `-c só vale para agentes com contas Claude (claude)`));
95
100
  return 2;
96
101
  }
97
- if (resume && !def.resume) {
102
+ const mach = (cfg.machine ?? {});
103
+ const host = detectHost(cfg);
104
+ // Screen by default (machine.tui, HERDR_AGENT_TUI=1) only where it can be: a terminal host and an agent with a screen.
105
+ if (!tui && (process.env.HERDR_AGENT_TUI === "1" || mach.tui === true) && host !== "plain" && def.tui && (!resume || def.tui_resume))
106
+ tui = true;
107
+ if (tui && !def.tui) {
108
+ console.error(m(`-T: ${a} has no screen mode (agents.${a}.tui)`, `-T: ${a} não tem modo tela (agents.${a}.tui)`));
109
+ return 2;
110
+ }
111
+ if (resume && !tui && !def.resume) {
98
112
  console.error(m(`${a} has no resume template (agents.${a}.resume)`, `${a} não tem template de resume (agents.${a}.resume)`));
99
113
  return 2;
100
114
  }
115
+ if (tui && resume && !def.tui_resume) {
116
+ console.error(m(`-T -R: ${a} has no tui_resume template`, `-T -R: ${a} não tem template tui_resume`));
117
+ return 2;
118
+ }
119
+ if (tui && host === "plain") {
120
+ console.error(m("-T shows the agent's own screen: it needs herdr or tmux (run inside one, or set ORQ_TERMINAL)", "-T mostra a tela do próprio agente: precisa do herdr ou do tmux (rode dentro de um, ou defina ORQ_TERMINAL)"));
121
+ return 2;
122
+ }
101
123
  const repo = resolve(d), O = resolve(o);
102
124
  let P = resolve(prompt);
103
125
  if (!existsSync(repo)) {
@@ -119,9 +141,6 @@ export async function agent(argv) {
119
141
  writeFileSync(`${O}.diff`, r.stdout);
120
142
  inc.push(`${O}.diff`);
121
143
  }
122
- // Follow-ups (-R) reuse the prefix: remove the previous round's result so ".done exists" means "this one finished".
123
- for (const s of [".done", ".out.md", ".pid"])
124
- rmSync(O + s, { force: true });
125
144
  if (inc.length) {
126
145
  let text = readFileSync(P, "utf8");
127
146
  for (const f of inc)
@@ -129,6 +148,24 @@ export async function agent(argv) {
129
148
  writeFileSync(`${O}.prompt.md`, text);
130
149
  P = `${O}.prompt.md`;
131
150
  }
151
+ // Follow-ups (-R) reuse the prefix: remove the previous round's result so ".done exists" means "this one finished".
152
+ // -T -R with the screen still open: the follow-up is typed into it (its runner, and .pid, carry on). Open = the agent
153
+ // is still running there, not just the pane: after it exits the pane may hold a shell, which must never get the text.
154
+ let open = "", pid = 0;
155
+ try {
156
+ open = readFileSync(`${O}.pane`, "utf8").trim();
157
+ pid = Number(readFileSync(`${O}.pid`, "utf8"));
158
+ }
159
+ catch { /* first run or ended */ }
160
+ if (tui && resume && alive(pid) && screenOpen(open)) {
161
+ for (const s of [".done", ".out.md"])
162
+ rmSync(O + s, { force: true });
163
+ typeInto(open, readFileSync(P, "utf8"));
164
+ console.log(`[orq agent] ${t} → ${open} ${m("(follow-up typed into the open screen)", "(follow-up digitado na tela aberta)")}`);
165
+ return 0;
166
+ }
167
+ for (const s of [".done", ".out.md", ".pid"])
168
+ rmSync(O + s, { force: true });
132
169
  const throwaway = Boolean(def.throwaway && ro && !resume);
133
170
  const dir = throwaway ? `${repo}.review-${process.pid}` : repo;
134
171
  const gd = git(repo, "rev-parse", "--path-format=absolute", "--git-common-dir");
@@ -140,22 +177,36 @@ export async function agent(argv) {
140
177
  }
141
178
  catch { /* none */ }
142
179
  const session = resume || (def.session === "uuid" ? randomUUID() : "");
180
+ const tpl = tui ? (resume ? def.tui_resume : def.tui) : resume ? def.resume : def.run;
181
+ // End of turn (-T): the agent runs `orq agent --turn <job> <kind>`; Claude gets it as a Stop hook, Codex as notify,
182
+ // OpenCode through a plugin orq writes next to the job.
183
+ const hook = [process.execPath, CLI, "agent", "--turn", `${O}.job.json`, def.turn_hook ?? "text"];
184
+ // OpenCode v2 drops a partial MCP entry without a word: each one turned off needs its type and command too.
185
+ const off = { type: "local", command: ["true"], enabled: false };
186
+ const ocCfg = {
187
+ mcp: { plane: off, shadcn: off, "linear-server": off },
188
+ permission: { bash: "allow", question: "deny", webfetch: "deny",
189
+ external_directory: { "*": "deny", ...(gd ? { [`${gd}/*`]: "allow" } : {}), "/tmp/*": "allow", ...(nm ? { [`${nm}/*`]: "allow" } : {}) },
190
+ edit: ro ? "deny" : "allow" },
191
+ };
192
+ if (tui && def.turn_hook === "opencode") {
193
+ mkdirSync(`${O}.opencode`, { recursive: true }); // a v2 plugin is a folder with index.js
194
+ writeFileSync(join(`${O}.opencode`, "index.js"), opencodePlugin(O, hook));
195
+ ocCfg.plugin = [`${O}.opencode`];
196
+ if (model && model !== "auto")
197
+ ocCfg.model = model; // the screen has no -m
198
+ }
143
199
  const vars = {
144
- prompt: P, prompt_text: def.run.includes("{prompt_text}") || def.resume?.includes("{prompt_text}") ? readFileSync(P, "utf8") : "",
200
+ prompt: P, prompt_text: tpl.includes("{prompt_text}") ? readFileSync(P, "utf8") : "",
145
201
  prompt_hint: `Read the file ${PF} (in the current folder, with your file-reading tool): it holds your complete instructions for this task. Follow them. ${PF} is not part of the code: don't review or edit it.`,
146
202
  out: `${O}.out.md`, dir, repo, title: t, model: model === "auto" ? "" : model, effort, session,
147
- gitdir: gd ?? "", gitroots: roots, gitroots_json: JSON.stringify(roots), mode: [],
148
- opencode_config: JSON.stringify({
149
- mcp: { plane: { enabled: false }, shadcn: { enabled: false }, "linear-server": { enabled: false } },
150
- permission: { bash: "allow", question: "deny", webfetch: "deny",
151
- external_directory: { "*": "deny", ...(gd ? { [`${gd}/*`]: "allow" } : {}), "/tmp/*": "allow", ...(nm ? { [`${nm}/*`]: "allow" } : {}) },
152
- edit: ro ? "deny" : "allow" },
153
- }),
203
+ gitdir: gd ?? "", gitroots: roots, gitroots_json: JSON.stringify(roots), mode: [], opencode_config: JSON.stringify(ocCfg),
204
+ hook_cmd: hook.map(shq).join(" "), hook_json: JSON.stringify(hook),
205
+ hook_settings: JSON.stringify({ hooks: { Stop: [{ hooks: [{ type: "command", command: hook.map(shq).join(" ") }] }] } }),
154
206
  };
155
207
  vars.mode = expandTemplate((ro ? def.readonly : def.write) || "", vars);
156
- const tpl = resume ? def.resume : def.run;
157
208
  const cmd = expandTemplate(tpl, vars);
158
- if (!tpl.includes("{mode}") && !resume)
209
+ if (!tpl.includes("{mode}") && !resume && !tui)
159
210
  cmd.push(...vars.mode);
160
211
  const env = {};
161
212
  for (const [k, val] of Object.entries(def.env ?? {}))
@@ -168,9 +219,7 @@ export async function agent(argv) {
168
219
  }
169
220
  env.CLAUDE_CONFIG_DIR = ac.dir;
170
221
  }
171
- const mach = (cfg.machine ?? {});
172
222
  const closeAfter = Number(process.env.HERDR_CLOSE_DELAY ?? mach.close_tab_after ?? 20);
173
- const host = detectHost(cfg);
174
223
  const unit = `herdr-agent-${Math.floor(Date.now() / 1000)}-${process.pid}`; // herdr-board lists herdr-agent-* scopes
175
224
  const job = {
176
225
  agent: a, title: t, repo, dir, prefix: O, prompt: P, argv: cmd, env, unset: def.unset_env ?? [], stdin: Boolean(def.stdin),
@@ -179,7 +228,7 @@ export async function agent(argv) {
179
228
  host, unit, cap: parseSize(def.mem_max ?? mach.agent_mem_max ?? "off"),
180
229
  swap: parseSize(mach.agent_swap_max), mode: mach.mem_enforce ?? "auto",
181
230
  throwaway, promptInDir: Boolean(def.prompt_in_dir), closeAfter,
182
- keep: process.env.HERDR_KEEP_TAB === "1" || process.env.ORQ_KEEP_TAB === "1" || closeAfter <= 0,
231
+ keep: process.env.HERDR_KEEP_TAB === "1" || process.env.ORQ_KEEP_TAB === "1" || closeAfter <= 0, tui,
183
232
  };
184
233
  writeAtomic(`${O}.job.json`, JSON.stringify(job, null, 1));
185
234
  // Readable copy of the command (also what orq stats and herdr-board read).
@@ -187,9 +236,102 @@ export async function agent(argv) {
187
236
  `cd ${shq(dir)}\n${Object.entries(env).map(([k, val]) => `${k}=${shq(val)} `).join("")}${cmd.map(shq).join(" ")}${job.stdin ? ` < ${shq(P)}` : ""}\n`);
188
237
  const where = launch(host, { group: g || "agents", title: t, dir: repo, argv: [process.execPath, CLI, "agent", "--run", `${O}.job.json`], id: unit, keep: job.keep });
189
238
  writeFileSync(`${O}.pane`, `${where}\n`);
190
- console.log(`[orq agent] ${t} → ${where}`);
239
+ console.log(`[orq agent] ${t} → ${where}${tui ? " (TUI)" : ""}`);
191
240
  return 0;
192
241
  }
242
+ /** OpenCode (v2) plugin for -T: when the main session's run ends, its last answer goes to `orq agent --turn`. */
243
+ function opencodePlugin(O, hook) {
244
+ return `// orq agent -T end of turn (generated)
245
+ import { writeFileSync } from "fs"; import { spawn } from "child_process";
246
+ export default {
247
+ id: "orq-turn",
248
+ setup: async (ctx) => {
249
+ const sub = new Set(), text = new Map(); // child sessions (subagents); answer text per session
250
+ (async () => {
251
+ for await (const e of ctx.event.subscribe()) {
252
+ const d = e.data ?? {}, id = d.sessionID;
253
+ if (e.type === "session.created" && d.parentID) sub.add(id);
254
+ if (!id || sub.has(id)) continue;
255
+ if (e.type === "session.execution.started") text.set(id, { msg: "", parts: [] });
256
+ else if (e.type === "session.text.ended") {
257
+ const t = text.get(id) ?? { msg: "", parts: [] };
258
+ if (t.msg !== d.assistantMessageID) { t.msg = d.assistantMessageID; t.parts = []; } // only the last message
259
+ t.parts[d.ordinal ?? t.parts.length] = d.text ?? ""; text.set(id, t);
260
+ } else if (e.type.startsWith("session.execution.") && e.type !== "session.execution.started") {
261
+ const f = ${JSON.stringify(`${O}.turn.md`)};
262
+ writeFileSync(f, (text.get(id)?.parts ?? []).filter((x) => x !== undefined).join("\\n"));
263
+ spawn(${JSON.stringify(hook[0])}, [...${JSON.stringify(hook.slice(1))}, id, f], { detached: true, stdio: "ignore" }).unref();
264
+ }
265
+ }
266
+ })().catch(() => {});
267
+ return {};
268
+ },
269
+ };
270
+ `;
271
+ }
272
+ /** End of a turn on the agent's screen: answer, session and .done from the agent's own hook payload. Never fails the agent. */
273
+ function turn(file, kind, rest) {
274
+ try {
275
+ const j = JSON.parse(readFileSync(file, "utf8")), O = j.prefix;
276
+ let answer = "", sid;
277
+ if (kind === "codex") {
278
+ const e = JSON.parse(rest[rest.length - 1] ?? "{}");
279
+ if (e.type !== "agent-turn-complete")
280
+ return 0;
281
+ answer = String(e["last-assistant-message"] ?? "");
282
+ sid = e["thread-id"];
283
+ }
284
+ else if (kind === "claude") {
285
+ const e = JSON.parse(readFileSync(0, "utf8"));
286
+ if (e.stop_hook_active)
287
+ return 0;
288
+ sid = e.session_id;
289
+ answer = String(e.last_assistant_message ?? "") || lastClaudeText(String(e.transcript_path ?? ""));
290
+ }
291
+ else if (kind === "opencode") {
292
+ sid = rest[0];
293
+ try {
294
+ answer = readFileSync(rest[1], "utf8");
295
+ rmSync(rest[1], { force: true });
296
+ }
297
+ catch { /* none */ }
298
+ }
299
+ else {
300
+ sid = rest[0];
301
+ answer = readFileSync(0, "utf8");
302
+ }
303
+ writeFileSync(`${O}.out.md`, answer);
304
+ if (sid)
305
+ writeFileSync(`${O}.id`, `${sid}\n`);
306
+ const st = /\S/.test(answer) ? "ok" : "fail";
307
+ writeAtomic(`${O}.done`, `status=${st} rc=0 at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
308
+ if (j.parent)
309
+ notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=${st} rc=0 answer: ${O}.out.md`, true);
310
+ }
311
+ catch (e) {
312
+ console.error(`orq agent --turn: ${e.message}`);
313
+ }
314
+ return 0;
315
+ }
316
+ /** The last text Claude wrote, from its transcript (older Stop hooks have no last_assistant_message). */
317
+ function lastClaudeText(transcript) {
318
+ let text = "";
319
+ try {
320
+ for (const l of readFileSync(transcript, "utf8").split(/\r?\n/)) {
321
+ try {
322
+ const e = JSON.parse(l);
323
+ if (e.type !== "assistant")
324
+ continue;
325
+ const t = (e.message?.content ?? []).filter((c) => c.type === "text").map((c) => c.text).join("\n");
326
+ if (t)
327
+ text = t;
328
+ }
329
+ catch { /* not JSON */ }
330
+ }
331
+ }
332
+ catch { /* no transcript */ }
333
+ return text;
334
+ }
193
335
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
194
336
  /** Splits a stream into lines for `each`. */
195
337
  function lines(s, each) {
@@ -218,7 +360,6 @@ async function runJob(file) {
218
360
  catch { /* best effort */ } };
219
361
  state();
220
362
  const fmt = FORMATS[j.format] ?? FORMATS.text;
221
- const log = createWriteStream(`${O}.log`);
222
363
  let rc = 0, stdout = "", logText = "";
223
364
  const pre = () => {
224
365
  if (j.throwaway) {
@@ -254,6 +395,51 @@ async function runJob(file) {
254
395
  if (j.throwaway)
255
396
  dropWorktree(j.repo, j.dir);
256
397
  };
398
+ if (j.tui) {
399
+ // The agent's own screen: the terminal is its; answers come from the end-of-turn hook (`--turn`).
400
+ let ended = false;
401
+ const end = (code) => {
402
+ if (ended)
403
+ return "fail";
404
+ ended = true;
405
+ post();
406
+ // Closed before any turn ended (or mid follow-up): a failure, so `orq wait` doesn't hang.
407
+ const ok = /\bstatus=ok\b/.test(existsSync(`${O}.done`) ? readFileSync(`${O}.done`, "utf8") : "");
408
+ if (!existsSync(`${O}.done`)) {
409
+ writeAtomic(`${O}.done`, `status=fail rc=${code} at=${new Date().toISOString()} agent=${j.agent} mode=tui\n`);
410
+ if (j.parent)
411
+ notify(j.parent, `[HERDR-AGENT DONE] ${j.title} status=fail rc=${code} answer: ${O}.out.md`, true);
412
+ }
413
+ rmSync(`${O}.pid`, { force: true });
414
+ return ok ? "ok" : "fail";
415
+ };
416
+ if (!pre())
417
+ rc = 2;
418
+ else {
419
+ const env = { ...process.env, ...j.env };
420
+ for (const k of j.unset)
421
+ delete env[k];
422
+ // Ctrl+C belongs to the agent (it reaches us too, as part of the terminal's foreground group). The screen going
423
+ // away (tab or tmux server closed) still ends the agent and cleans up.
424
+ let child;
425
+ process.on("SIGINT", () => { });
426
+ const gone = () => { if (child)
427
+ killTree(child, false); end(129); process.exit(0); };
428
+ for (const sig of ["SIGHUP", "SIGTERM"])
429
+ process.on(sig, gone);
430
+ 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,
431
+ onSpawn: (c) => { child = c.pid; } });
432
+ }
433
+ const st = end(rc);
434
+ state({ status: st, rc, end: new Date().toISOString() });
435
+ console.log(`\n[${j.agent} (TUI) ${m("exited", "encerrado")}] ${m("answer", "resposta")}: ${O}.out.md`);
436
+ if (!j.keep) {
437
+ await sleep(j.closeAfter * 1000);
438
+ closeOwnTab(j.host);
439
+ }
440
+ return 0;
441
+ }
442
+ const log = createWriteStream(`${O}.log`);
257
443
  if (!pre())
258
444
  rc = 2;
259
445
  else {
@@ -6,8 +6,8 @@ import { join } from "node:path";
6
6
  import { writeAtomic } from "../fsutil.js";
7
7
  import { m } from "../i18n.js";
8
8
  import { stateDir } from "../paths.js";
9
- export const USAGE = `orq block <agent|account> [--until YYYY-MM-DD[THH:MM] | --days N] [--reason "text"] default: 7 days
10
- orq block -r <agent|account> unblock early
9
+ export const USAGE = `orq block <agent|account> [--until YYYY-MM-DD[THH:MM] | --days N] [--reason "text"] default: 7 days
10
+ orq block -r <agent|account> unblock early
11
11
  orq block -l list active blocks (expired ones disappear by themselves)`;
12
12
  const file = () => join(stateDir(), "blocks.json");
13
13
  const now = () => Date.now() / 1000;
@@ -153,7 +153,7 @@ export function parseQuota(text) {
153
153
  return r ? [{ name: r[1], five: r[2], week: r[3], ...(r[4] ? { note: r[4] } : {}) }] : [];
154
154
  });
155
155
  }
156
- function quota(now) {
156
+ export function quota(now) {
157
157
  let age;
158
158
  try {
159
159
  age = now - statSync(qfile()).mtimeMs;
@@ -12,7 +12,7 @@ import { m } from "../i18n.js";
12
12
  import { acquire, release } from "../lock.js";
13
13
  export const USAGE = `orq claim [-w <workspace>] <ID> | -f <ID> | -r <ID> | -l issue owner among conductors (exit 1 = someone else's)`;
14
14
  const field = (text, k) => new RegExp(`${k}=(\\S+)`).exec(text)?.[1] ?? "";
15
- function aliveOwner(text) {
15
+ export function aliveOwner(text) {
16
16
  const id = field(text, "pane"), kind = field(text, "kind") || "herdr";
17
17
  if (kind === "herdr")
18
18
  return spawnSync("herdr", ["agent", "get", id], { stdio: "ignore" }).status === 0;
@@ -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",
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",
14
+ instructions: "write the /issue playbook", claim: "issue owner among conductors", deps: "link node_modules into a worktree", gc: "remove merged issue worktrees",
15
15
  block: "block an agent or account", completion: "shell completion script",
16
16
  };
17
17
  /** Fixed words after a subcommand. */
@@ -27,52 +27,52 @@ export function script(shell, cmds) {
27
27
  const all = cmds.join(" ");
28
28
  const cases = (fmt) => Object.entries(WORDS).filter(([c]) => cmds.includes(c)).map(([c, w]) => fmt(c, w.join(" "))).join("\n");
29
29
  switch (shell) {
30
- case "bash": return `# orq completion (bash): eval "$(orq completion bash)" in ~/.bashrc
31
- _orq() {
32
- local cur=\${COMP_WORDS[COMP_CWORD]}
33
- if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=($(compgen -W "${all}" -- "$cur")); return; fi
34
- [ "$COMP_CWORD" -eq 2 ] || return
35
- case \${COMP_WORDS[1]} in
36
- ${cases((c, w) => ` ${c}) COMPREPLY=($(compgen -W "${w}" -- "$cur")) ;;`)}
37
- esac
38
- }
39
- complete -o default -F _orq orq
30
+ case "bash": return `# orq completion (bash): eval "$(orq completion bash)" in ~/.bashrc
31
+ _orq() {
32
+ local cur=\${COMP_WORDS[COMP_CWORD]}
33
+ if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=($(compgen -W "${all}" -- "$cur")); return; fi
34
+ [ "$COMP_CWORD" -eq 2 ] || return
35
+ case \${COMP_WORDS[1]} in
36
+ ${cases((c, w) => ` ${c}) COMPREPLY=($(compgen -W "${w}" -- "$cur")) ;;`)}
37
+ esac
38
+ }
39
+ complete -o default -F _orq orq
40
40
  `;
41
- case "zsh": return `# orq completion (zsh): eval "$(orq completion zsh)" in ~/.zshrc, after compinit
42
- _orq() {
43
- if (( CURRENT == 2 )); then compadd -- ${all}; return; fi
44
- if (( CURRENT == 3 )); then
45
- case $words[2] in
46
- ${cases((c, w) => ` ${c}) compadd -- ${w}; return ;;`)}
47
- esac
48
- fi
49
- _files
50
- }
51
- compdef _orq orq
41
+ case "zsh": return `# orq completion (zsh): eval "$(orq completion zsh)" in ~/.zshrc, after compinit
42
+ _orq() {
43
+ if (( CURRENT == 2 )); then compadd -- ${all}; return; fi
44
+ if (( CURRENT == 3 )); then
45
+ case $words[2] in
46
+ ${cases((c, w) => ` ${c}) compadd -- ${w}; return ;;`)}
47
+ esac
48
+ fi
49
+ _files
50
+ }
51
+ compdef _orq orq
52
52
  `;
53
- case "fish": return `# orq completion (fish): orq completion fish > ~/.config/fish/completions/orq.fish
54
- complete -c orq -f -n __fish_use_subcommand
55
- ${cmds.map((c) => `complete -c orq -n __fish_use_subcommand -a ${c}${DESC[c] ? ` -d ${qf(DESC[c])}` : ""}`).join("\n")}
56
- ${cases((c, w) => `complete -c orq -n "__fish_seen_subcommand_from ${c}" -a ${qf(w)}`)}
53
+ case "fish": return `# orq completion (fish): orq completion fish > ~/.config/fish/completions/orq.fish
54
+ complete -c orq -f -n __fish_use_subcommand
55
+ ${cmds.map((c) => `complete -c orq -n __fish_use_subcommand -a ${c}${DESC[c] ? ` -d ${qf(DESC[c])}` : ""}`).join("\n")}
56
+ ${cases((c, w) => `complete -c orq -n "__fish_seen_subcommand_from ${c}" -a ${qf(w)}`)}
57
57
  `;
58
58
  case "powershell":
59
- case "pwsh": return `# orq completion (PowerShell): add to $PROFILE: orq completion powershell | Out-String | Invoke-Expression
60
- Register-ArgumentCompleter -Native -CommandName orq -ScriptBlock {
61
- param($word, $ast, $cursor)
62
- $w = @($ast.CommandElements | ForEach-Object { $_.ToString() })
63
- $n = $w.Count; if ($word -ne '') { $n-- }
64
- $words = @{
65
- ${Object.entries(WORDS).filter(([c]) => cmds.includes(c)).map(([c, w]) => ` ${q(c)} = @(${w.map(q).join(", ")})`).join("\n")}
66
- }
67
- $desc = @{
68
- ${cmds.map((c) => ` ${q(c)} = ${q(DESC[c] ?? c)}`).join("\n")}
69
- }
70
- if ($n -eq 1) { $list = @(${cmds.map(q).join(", ")}) } elseif ($n -eq 2 -and $words.ContainsKey($w[1])) { $list = $words[$w[1]] } else { return }
71
- $list | Where-Object { $_ -like "$word*" } | ForEach-Object {
72
- $tip = if ($n -eq 1) { $desc[$_] } else { $_ }
73
- [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $tip)
74
- }
75
- }
59
+ case "pwsh": return `# orq completion (PowerShell): add to $PROFILE: orq completion powershell | Out-String | Invoke-Expression
60
+ Register-ArgumentCompleter -Native -CommandName orq -ScriptBlock {
61
+ param($word, $ast, $cursor)
62
+ $w = @($ast.CommandElements | ForEach-Object { $_.ToString() })
63
+ $n = $w.Count; if ($word -ne '') { $n-- }
64
+ $words = @{
65
+ ${Object.entries(WORDS).filter(([c]) => cmds.includes(c)).map(([c, w]) => ` ${q(c)} = @(${w.map(q).join(", ")})`).join("\n")}
66
+ }
67
+ $desc = @{
68
+ ${cmds.map((c) => ` ${q(c)} = ${q(DESC[c] ?? c)}`).join("\n")}
69
+ }
70
+ if ($n -eq 1) { $list = @(${cmds.map(q).join(", ")}) } elseif ($n -eq 2 -and $words.ContainsKey($w[1])) { $list = $words[$w[1]] } else { return }
71
+ $list | Where-Object { $_ -like "$word*" } | ForEach-Object {
72
+ $tip = if ($n -eq 1) { $desc[$_] } else { $_ }
73
+ [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $tip)
74
+ }
75
+ }
76
76
  `;
77
77
  default: return undefined;
78
78
  }
@@ -5,8 +5,8 @@
5
5
  import { getPath, loadConfig } from "../config.js";
6
6
  import { expand } from "../paths.js";
7
7
  import { m } from "../i18n.js";
8
- export const USAGE = `orq config [-w <workspace>] everything, as JSON
9
- orq config [-w <workspace>] get <a.b.c> one value (plain text if scalar; JSON if object/list)
8
+ export const USAGE = `orq config [-w <workspace>] everything, as JSON
9
+ orq config [-w <workspace>] get <a.b.c> one value (plain text if scalar; JSON if object/list)
10
10
  orq config [-w <workspace>] files which files were read`;
11
11
  export function config(argv) {
12
12
  let ws;
@@ -0,0 +1,164 @@
1
+ // orq gc — find issue worktrees whose branch is already safe to remove.
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";
5
+ import { loadConfig } from "../config.js";
6
+ import { m } from "../i18n.js";
7
+ import { aliveOwner } from "./claim.js";
8
+ export const USAGE = "orq gc [-w <workspace>] [--yes] [--days N] [--json] remove merged issue worktrees";
9
+ const git = (cwd, ...args) => spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
10
+ // 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); };
18
+ // ORQ_GH may be a .js file (tests: a fake gh that also runs on Windows).
19
+ const gh = (cwd, args) => {
20
+ const bin = process.env.ORQ_GH || "gh";
21
+ return bin.endsWith(".js") ? spawnSync(process.execPath, [bin, ...args], { cwd, encoding: "utf8" })
22
+ : spawnSync(bin, args, { cwd, encoding: "utf8" });
23
+ };
24
+ const output = (cwd, ...args) => { const r = git(cwd, ...args); return r.status === 0 ? r.stdout.trim() : undefined; };
25
+ export async function gc(argv, clock = Date.now) {
26
+ let ws = process.cwd(), yes = false, json = false, days = 14;
27
+ for (let i = 0; i < argv.length; i++) {
28
+ if (argv[i] === "-w" && argv[i + 1])
29
+ ws = resolve(argv[++i]);
30
+ else if (argv[i] === "--yes")
31
+ yes = true;
32
+ else if (argv[i] === "--json")
33
+ json = true;
34
+ else if (argv[i] === "--days" && argv[i + 1] && /^\d+$/.test(argv[i + 1]))
35
+ days = Number(argv[++i]);
36
+ else {
37
+ console.error(USAGE);
38
+ return 2;
39
+ }
40
+ }
41
+ const { cfg } = loadConfig(ws), rows = [];
42
+ let ghUnavailable = false, fetchWarned = false;
43
+ const root = join(ws, ".worktrees");
44
+ const orq = join(ws, ".orquestra");
45
+ const issues = existsSync(orq) ? readdirSync(orq) : [];
46
+ for (const repoName of readdirSync(ws, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)) {
47
+ const repo = join(ws, repoName);
48
+ if (!existsSync(join(repo, ".git")))
49
+ continue;
50
+ const listings = output(repo, "worktree", "list", "--porcelain");
51
+ if (listings === undefined)
52
+ continue;
53
+ const worktrees = listings.split(/\n\n/).map((block) => Object.fromEntries(block.split("\n").map((line) => {
54
+ const i = line.indexOf(" ");
55
+ return [i < 0 ? line : line.slice(0, i), i < 0 ? "" : line.slice(i + 1)];
56
+ })));
57
+ const main = real(worktrees[0]?.worktree ?? repo);
58
+ const conf = cfg.repos?.[repoName];
59
+ const head = output(repo, "symbolic-ref", "refs/remotes/origin/HEAD")?.replace("refs/remotes/", "");
60
+ const prod = conf?.prod_ref || head || "origin/main";
61
+ const fetchRef = prod.replace(/^origin\//, "");
62
+ if (git(repo, "fetch", "origin", fetchRef).status !== 0 && !fetchWarned) {
63
+ console.error(m("WARNING: git fetch failed; using local refs", "AVISO: git fetch falhou; usando as refs locais"));
64
+ fetchWarned = true;
65
+ }
66
+ const prodTree = output(repo, "rev-parse", `${prod}^{tree}`);
67
+ for (const wt of worktrees) {
68
+ const path = real(wt.worktree ?? "");
69
+ if (!wt.worktree || path === main || !inside(real(root), path))
70
+ continue;
71
+ const branch = wt.branch?.replace(/^refs\/heads\//, "") ?? "";
72
+ if (!branch)
73
+ 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];
76
+ const issue = id && issues.find((d) => d.toLowerCase() === id.toLowerCase());
77
+ const status = output(path, "status", "--porcelain");
78
+ const unpushed = output(repo, "rev-list", branch, "--not", "--remotes");
79
+ let reason = "", verdict = "REMOVABLE";
80
+ if (status) {
81
+ verdict = "KEEP";
82
+ reason = m("uncommitted changes", "alterações não commitadas");
83
+ }
84
+ const ownerFile = issue && join(ws, ".orquestra", issue, "owner");
85
+ if (verdict !== "KEEP" && ownerFile && existsSync(ownerFile) && aliveOwner(readFileSync(ownerFile, "utf8"))) {
86
+ verdict = "KEEP";
87
+ reason = m("issue has a live owner", "issue tem um responsável ativo");
88
+ }
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
+ }
97
+ }
98
+ let merged = false;
99
+ if (verdict !== "KEEP") {
100
+ // Merged counts only if the PR's head is the local head: a commit made after the merge must not be lost.
101
+ const pr = gh(repo, ["pr", "list", "--head", branch, "--state", "merged", "--json", "number,headRefOid", "--limit", "1"]);
102
+ const local = output(repo, "rev-parse", branch);
103
+ if (pr.status === 0) {
104
+ try {
105
+ const p = JSON.parse(pr.stdout)[0];
106
+ merged = !!p && (!p.headRefOid || p.headRefOid === local);
107
+ }
108
+ catch {
109
+ ghUnavailable = true;
110
+ }
111
+ }
112
+ else
113
+ ghUnavailable = true;
114
+ }
115
+ const inBase = verdict !== "KEEP" && !!prodTree && output(repo, "merge-tree", "--write-tree", prod, branch) === prodTree;
116
+ if (verdict !== "KEEP" && unpushed && !merged && !inBase) {
117
+ verdict = "KEEP";
118
+ reason = m("commits not contained in remote refs", "commits fora das refs remotas");
119
+ }
120
+ else if (verdict !== "KEEP" && (merged || inBase)) {
121
+ verdict = "REMOVABLE";
122
+ reason = merged ? "PR merged" : m("content is in base", "conteúdo já está na base");
123
+ }
124
+ else if (verdict !== "KEEP") {
125
+ const closed = gh(repo, ["pr", "list", "--head", branch, "--state", "closed", "--json", "number", "--limit", "1"]);
126
+ const isClosed = closed?.status === 0 && (() => { try {
127
+ return JSON.parse(closed.stdout).length > 0;
128
+ }
129
+ catch {
130
+ return false;
131
+ } })();
132
+ const commit = output(repo, "log", "-1", "--format=%ct", branch), old = commit !== undefined && clock() - Number(commit) * 1000 > days * 86400_000;
133
+ verdict = "STALE";
134
+ reason = isClosed ? m("PR closed without merge", "PR fechada sem merge") : old ? m(`last commit is older than ${days} days`, `último commit tem mais de ${days} dias`) : m("not merged", "não integrado");
135
+ }
136
+ rows.push({ repo: repoName, worktree: basename(path), branch, verdict, reason, path });
137
+ }
138
+ }
139
+ if (ghUnavailable)
140
+ console.error(m("NOTE: gh unavailable or failed; PR signals were skipped", "NOTA: gh indisponível ou falhou; sinais de PR ignorados"));
141
+ let failed = false;
142
+ if (yes)
143
+ for (const row of rows.filter((r) => r.verdict === "REMOVABLE")) {
144
+ const removed = git(join(ws, row.repo), "worktree", "remove", row.path);
145
+ if (removed.status !== 0) {
146
+ failed = true;
147
+ row.reason += `; ${m("worktree removal failed", "falha ao remover worktree")}`;
148
+ continue;
149
+ }
150
+ if (git(join(ws, row.repo), "branch", "-D", row.branch).status !== 0) {
151
+ failed = true;
152
+ row.reason += `; ${m("branch deletion failed", "falha ao excluir branch")}`;
153
+ }
154
+ git(join(ws, row.repo), "worktree", "prune");
155
+ }
156
+ if (json)
157
+ console.log(JSON.stringify(rows));
158
+ else {
159
+ const table = [["REPO", "WORKTREE", "BRANCH", "VERDICT", "REASON"], ...rows.map((r) => [r.repo, r.worktree, r.branch, r.verdict, r.reason])];
160
+ const w = table[0].map((_, i) => Math.max(...table.map((t) => t[i].length)));
161
+ console.log(table.map((t) => t.map((c, i) => i < t.length - 1 ? c.padEnd(w[i]) : c).join(" ")).join("\n"));
162
+ }
163
+ return failed ? 1 : 0;
164
+ }