@irythill/orq 0.2.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.
package/README.md CHANGED
@@ -206,7 +206,7 @@ off the same issue. Details, shortcuts and scenarios: [docs/herdr.md](docs/herdr
206
206
 
207
207
  | Command | What it does |
208
208
  |---|---|
209
- | `orq agent -a <agent> ...` (`herdr-agent`) | Runs an agent with a memory cap in a herdr tab, a tmux window or the background; `-r` read only, `-R <id>` continue the session, `-i` attach a file, `-b <base>` attach the diff, `-T` the agent's own screen (herdr only) |
209
+ | `orq agent -a <agent> ...` (`herdr-agent`) | Runs an agent with a memory cap in a herdr tab, a tmux window or the background; `-r` read only, `-R <id>` continue the session, `-i` attach a file, `-b <base>` attach the diff, `-T` the agent's own screen (herdr or tmux; `-R` with it open types the follow-up into it) |
210
210
  | `orq instructions claude\|agents` | Writes the `/issue` playbook as `SKILL.md` (Claude Code) or into `AGENTS.md` (Codex, OpenCode…) |
211
211
  | `orq wait <prefix>...` | Blocks until those agents finish; prints `status=` and the answer file (any conductor, any terminal) |
212
212
  | `orq verify ...` (`herdr-verify`) | Tests related to the diff, queued and memory-capped; failures that also fail on the base count as pre-existing |
package/README.pt-BR.md CHANGED
@@ -207,7 +207,7 @@ dois conductors peguem a mesma issue. Detalhes, atalhos e cenários: [docs/pt-BR
207
207
 
208
208
  | Comando | O que faz |
209
209
  |---|---|
210
- | `orq agent -a <agent> ...` (`herdr-agent`) | Roda um agente com limite de memória numa aba do herdr, numa janela do tmux ou em segundo plano; `-r` só leitura, `-R <id>` continua a sessão, `-i` anexa um arquivo, `-b <base>` anexa o diff, `-T` a tela do próprio agente (só herdr) |
210
+ | `orq agent -a <agent> ...` (`herdr-agent`) | Roda um agente com limite de memória numa aba do herdr, numa janela do tmux ou em segundo plano; `-r` só leitura, `-R <id>` continua a sessão, `-i` anexa um arquivo, `-b <base>` anexa o diff, `-T` a tela do próprio agente (herdr ou tmux; `-R` com ela aberta digita o follow-up nela) |
211
211
  | `orq instructions claude\|agents` | Escreve o playbook do `/issue` como `SKILL.md` (Claude Code) ou dentro do `AGENTS.md` (Codex, OpenCode…) |
212
212
  | `orq wait <prefixo>...` | Bloqueia até esses agentes terminarem; imprime `status=` e o arquivo da resposta (qualquer conductor, qualquer terminal) |
213
213
  | `orq verify ...` (`herdr-verify`) | Testes relacionados ao diff, em fila e com limite de memória; falhas que também falham na base contam como pré-existentes |
package/dist/agents.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { m } from "./i18n.js";
2
2
  const NOMCP_CODEX = "-c mcp_servers.plane.enabled=false -c mcp_servers.shadcn.enabled=false";
3
3
  const CLAUDE = "claude -p --strict-mcp-config --output-format stream-json --verbose [--model {model}] --effort {effort:low}";
4
+ const CLAUDE_TUI = "claude --strict-mcp-config [--model {model}] --effort {effort:low}";
4
5
  const OPENCODE = "opencode run --standalone --format json [-m {model}]"; // v2: effort goes in the model, "provider/model#variant"
5
6
  const AGY = "agy --output-format stream-json --sandbox [--model {model}] [--effort {effort}]";
6
7
  const COPILOT = "-s -C {dir} --disable-builtin-mcps --disable-mcp-server plane --allow-tool 'shell(git diff:*)' " +
@@ -14,6 +15,8 @@ export const BUILTIN_AGENTS = {
14
15
  run: `${CLAUDE} --session-id {session} {mode}`, resume: `${CLAUDE} --resume {session} {mode}`,
15
16
  readonly: "--permission-mode plan", write: "--permission-mode auto --add-dir {gitdir}",
16
17
  stdin: true, format: "claude", session: "uuid", accounts: true, unset_env: ["HERDR_PANE_ID"],
18
+ tui: `${CLAUDE_TUI} --session-id {session} {mode} --settings {hook_settings} {prompt_text}`,
19
+ tui_resume: `${CLAUDE_TUI} --resume {session} {mode} --settings {hook_settings} {prompt_text}`, turn_hook: "claude",
17
20
  },
18
21
  codex: {
19
22
  // Codex treats any .git inside a writable root as read only: the folders a commit needs are granted one by one.
@@ -23,12 +26,21 @@ export const BUILTIN_AGENTS = {
23
26
  resume: `codex exec resume {session} -c model_reasoning_effort={effort:low} ` +
24
27
  `-c sandbox_workspace_write.writable_roots={gitroots_json} ${NOMCP_CODEX} -o {out} -`,
25
28
  readonly: "", write: "[--add-dir {gitroots}]", stdin: true, format: "codex", answer: "file", throwaway: true,
29
+ // The screen: no approvals (nobody to approve), the same sandbox as exec; notify = end of turn.
30
+ tui: `codex [-m {model}] -c model_reasoning_effort={effort:low} -s workspace-write -a never -C {dir} {mode} ` +
31
+ `${NOMCP_CODEX} -c notify={hook_json} {prompt_text}`,
32
+ tui_resume: `codex resume {session} -c model_reasoning_effort={effort:low} -s workspace-write -a never ` +
33
+ `-c sandbox_workspace_write.writable_roots={gitroots_json} ${NOMCP_CODEX} -c notify={hook_json} {prompt_text}`,
34
+ turn_hook: "codex",
26
35
  },
27
36
  opencode: {
28
37
  // Permissions and MCPs off via OPENCODE_CONFIG_CONTENT. Headless has nobody to approve, so write = --auto (only
29
38
  // ever in a worktree) and read only = throwaway worktree with the edit tool denied.
30
39
  run: `${OPENCODE} {mode} {prompt_text}`, resume: `${OPENCODE} -s {session} {mode} {prompt_text}`, readonly: "", write: "--auto",
31
40
  format: "opencode", throwaway: true, env: { OPENCODE_CONFIG_CONTENT: "{opencode_config}" },
41
+ // The screen: no --auto ("ask" shows up for you); the model and orq's end-of-turn plugin go in the config.
42
+ tui: "opencode --standalone --prompt {prompt_text} {dir}", tui_resume: "opencode --standalone -s {session} --prompt {prompt_text} {dir}",
43
+ turn_hook: "opencode",
32
44
  },
33
45
  antigravity: {
34
46
  // --sandbox + accept-edits (no skip-permissions); headless denies commands outside agy's allowlist, so in practice
@@ -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 {
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/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,6 +1,6 @@
1
1
  {
2
2
  "name": "@irythill/orq",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Orchestrate coding agents (Claude Code, Codex, OpenCode, Antigravity, Copilot) from one conductor.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -15,7 +15,7 @@ start. Below, `$WS` = workspace.
15
15
  **Inside herdr** (`HERDR_ENV=1`): children open in herdr tabs and their notices reach your pane — the flow below.
16
16
  **Outside herdr** (any terminal, tmux, any OS): the same flow with these swaps —
17
17
  - `herdr-agent ...` → `orq agent ...` (same flags; it is the same program). Children run in tmux windows inside tmux,
18
- else in the background (their screen goes to a log; the answer is still `<prefix>.out.md`). No `-T`.
18
+ else in the background (their screen goes to a log; the answer is still `<prefix>.out.md`). `-T` needs tmux.
19
19
  - `herdr-claim` → `orq claim` (set `ORQ_CONDUCTOR_ID` to a fixed name per conductor), `herdr-deps` → `orq deps`.
20
20
  - **Completion signal:** no line arrives in your pane. After dispatching, run `orq wait <prefix>` **as a background
21
21
  task** (Claude Code: Bash with `run_in_background`); its exit wakes you and its output is the line
@@ -64,10 +64,10 @@ The output goes straight into `herdr-agent` (`herdr-agent $(orq pick ...) -g ...
64
64
  `opencode run` / `agy -p` / `copilot -p`) in a herdr tab (workspace = group `-g`, tab = title) with live output.
65
65
  When it finishes it writes `<prefix>.done` and sends ONE line `[HERDR-AGENT DONE] <title> status=ok|fail ...` to your
66
66
  pane; no MCP, and a **4 GB memory cap per agent** (own cgroup: if it blows up, only it dies). NEVER `herdr agent start`
67
- for children (no memory cap, no answer file). **Screen mode (`-T`, or `HERDR_AGENT_TUI=1`):** the tab shows the
68
- agent's own TUI instead of the log; same cap, same files, same signal (end-of-turn hook). The screen stays open after
69
- the turn (it holds memory until the tab closes); `-R` with the tab open types the follow-up into it. Use it when the
70
- user wants to watch. The repo must already be trusted in Codex/Claude (otherwise the TUI stops at the "trust this
67
+ for children (no memory cap, no answer file). **Screen mode (`-T`, or `HERDR_AGENT_TUI=1`; herdr or tmux):** the tab
68
+ shows the agent's own TUI instead of the log; same cap, same files, same signal (end-of-turn hook). The screen stays
69
+ open after the turn (it holds memory until the tab closes); `-R` with the tab open types the follow-up into it. Use it
70
+ when the user wants to watch. The repo must already be trusted in Codex/Claude (otherwise the TUI stops at the "trust this
71
71
  folder?" dialog — tell the user, don't approve it for them).
72
72
 
73
73
  ## 4. Dispatch the implementer