@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 +1 -1
- package/README.pt-BR.md +1 -1
- package/dist/agents.js +12 -0
- package/dist/commands/agent.js +212 -26
- package/dist/process.js +19 -11
- package/dist/terminal.js +26 -0
- package/dist/testrun.js +9 -5
- package/package.json +1 -1
- package/skills/issue/SKILL.md +5 -5
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
|
|
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 (
|
|
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
|
package/dist/commands/agent.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
/**
|
|
62
|
-
export function
|
|
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
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
package/skills/issue/SKILL.md
CHANGED
|
@@ -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`).
|
|
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
|
|
68
|
-
agent's own TUI instead of the log; same cap, same files, same signal (end-of-turn hook). The screen stays
|
|
69
|
-
the turn (it holds memory until the tab closes); `-R` with the tab open types the follow-up into it. Use it
|
|
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
|