pitroom 0.6.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/.claude-plugin/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +22 -0
- package/.codex-plugin/plugin.json +29 -0
- package/CHANGELOG.md +85 -0
- package/LICENSE +21 -0
- package/README.md +471 -0
- package/dist/lib.mjs +1222 -0
- package/dist/pitroom.mjs +3685 -0
- package/docs/backends.md +113 -0
- package/hooks/hooks.json +28 -0
- package/hooks/session-start.mjs +42 -0
- package/package.json +60 -0
- package/skills/pitroom-brainstorming/SKILL.md +243 -0
- package/skills/pitroom-brainstorming/spec-document-reviewer-prompt.md +22 -0
- package/skills/pitroom-crew/SKILL.md +80 -0
- package/skills/pitroom-debugging/SKILL.md +287 -0
- package/skills/pitroom-debugging/condition-based-waiting-example.ts +170 -0
- package/skills/pitroom-debugging/condition-based-waiting.md +115 -0
- package/skills/pitroom-debugging/defense-in-depth.md +122 -0
- package/skills/pitroom-debugging/find-polluter.sh +72 -0
- package/skills/pitroom-debugging/root-cause-tracing.md +169 -0
- package/skills/pitroom-driven-development/SKILL.md +138 -0
- package/skills/pitroom-driven-development/implementer-prompt.md +68 -0
- package/skills/pitroom-driven-development/re-review-prompt.md +41 -0
- package/skills/pitroom-driven-development/task-reviewer-prompt.md +59 -0
- package/skills/pitroom-finishing/SKILL.md +226 -0
- package/skills/pitroom-implement/SKILL.md +42 -0
- package/skills/pitroom-receiving-review/SKILL.md +209 -0
- package/skills/pitroom-research/SKILL.md +35 -0
- package/skills/pitroom-review/SKILL.md +47 -0
- package/skills/pitroom-review/code-reviewer.md +38 -0
- package/skills/pitroom-tdd/SKILL.md +322 -0
- package/skills/pitroom-tdd/writing-good-tests.md +198 -0
- package/skills/pitroom-verification/SKILL.md +124 -0
- package/skills/pitroom-worktrees/SKILL.md +171 -0
- package/skills/pitroom-writing-plans/SKILL.md +183 -0
- package/skills/pitroom-writing-plans/plan-document-reviewer-prompt.md +23 -0
- package/skills/using-pitroom/SKILL.md +66 -0
package/dist/pitroom.mjs
ADDED
|
@@ -0,0 +1,3685 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/core/errors.ts
|
|
4
|
+
var UserError = class extends Error {
|
|
5
|
+
constructor(message, code = 2) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
// src/backends/claude/index.ts
|
|
12
|
+
import { spawnSync } from "node:child_process";
|
|
13
|
+
import fs2 from "node:fs";
|
|
14
|
+
import os2 from "node:os";
|
|
15
|
+
import path2 from "node:path";
|
|
16
|
+
|
|
17
|
+
// src/backends/exec.ts
|
|
18
|
+
import fs from "node:fs";
|
|
19
|
+
import os from "node:os";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
function findBinary(name, envVar, known = []) {
|
|
22
|
+
const override = process.env[envVar];
|
|
23
|
+
if (override) return override;
|
|
24
|
+
const exts = process.platform === "win32" ? [".exe", ".cmd", ""] : [""];
|
|
25
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
|
26
|
+
for (const ext of exts) {
|
|
27
|
+
const p = path.join(dir, `${name}${ext}`);
|
|
28
|
+
if (isFile(p)) return p;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return known.map((k) => k.replace(/^~/, os.homedir())).find(isFile) ?? name;
|
|
32
|
+
}
|
|
33
|
+
function isFile(p) {
|
|
34
|
+
try {
|
|
35
|
+
return fs.statSync(p).isFile();
|
|
36
|
+
} catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function resolveCommand(bin) {
|
|
41
|
+
try {
|
|
42
|
+
const real = fs.realpathSync(bin);
|
|
43
|
+
const fd = fs.openSync(real, "r");
|
|
44
|
+
const head = Buffer.alloc(128);
|
|
45
|
+
const n = fs.readSync(fd, head, 0, 128, 0);
|
|
46
|
+
fs.closeSync(fd);
|
|
47
|
+
const firstLine = head.subarray(0, n).toString("utf8").split("\n")[0] ?? "";
|
|
48
|
+
if (/^#!.*\bnode\b/.test(firstLine)) return { command: process.execPath, prefix: [real] };
|
|
49
|
+
} catch {
|
|
50
|
+
}
|
|
51
|
+
return { command: bin, prefix: [] };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// src/backends/claude/events.ts
|
|
55
|
+
var EDIT_TOOLS = /* @__PURE__ */ new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
|
56
|
+
var DENIED = /permission|not allowed|denied|blocked|disallowed/i;
|
|
57
|
+
function parseEvents(jsonl) {
|
|
58
|
+
const usage = {
|
|
59
|
+
input: 0,
|
|
60
|
+
output: 0,
|
|
61
|
+
reasoning: 0,
|
|
62
|
+
cacheRead: 0,
|
|
63
|
+
cacheWrite: 0,
|
|
64
|
+
total: 0,
|
|
65
|
+
steps: 0,
|
|
66
|
+
toolCalls: 0,
|
|
67
|
+
denied: 0
|
|
68
|
+
};
|
|
69
|
+
const tools = {};
|
|
70
|
+
const edits = [];
|
|
71
|
+
const pendingEdits = /* @__PURE__ */ new Map();
|
|
72
|
+
const steps = /* @__PURE__ */ new Set();
|
|
73
|
+
let sessionId;
|
|
74
|
+
let model;
|
|
75
|
+
let lastText = "";
|
|
76
|
+
let finalText;
|
|
77
|
+
let lastActivity;
|
|
78
|
+
let error;
|
|
79
|
+
for (const line of jsonl.split("\n")) {
|
|
80
|
+
if (!line.trim()) continue;
|
|
81
|
+
let e;
|
|
82
|
+
try {
|
|
83
|
+
e = JSON.parse(line);
|
|
84
|
+
} catch {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
sessionId ??= e.session_id;
|
|
88
|
+
if (e.type === "system" && e.subtype === "init") {
|
|
89
|
+
model = e.model;
|
|
90
|
+
} else if (e.type === "assistant") {
|
|
91
|
+
const content = e.message?.content ?? [];
|
|
92
|
+
if (e.is_api_error_message || e.error) {
|
|
93
|
+
error = content.map((c) => c.text).filter(Boolean).join(" ") || String(e.error);
|
|
94
|
+
if (e.error) error = `${error} [${e.error}]`;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
steps.add(String(e.message?.id ?? e.uuid ?? steps.size));
|
|
98
|
+
for (const c of content) {
|
|
99
|
+
if (c.type === "text" && String(c.text ?? "").trim()) {
|
|
100
|
+
lastText = String(c.text).trim();
|
|
101
|
+
lastActivity = `says: ${oneLine(lastText)}`;
|
|
102
|
+
} else if (c.type === "tool_use") {
|
|
103
|
+
usage.toolCalls++;
|
|
104
|
+
tools[c.name] = (tools[c.name] ?? 0) + 1;
|
|
105
|
+
const target = c.input?.file_path ?? c.input?.path ?? c.input?.pattern ?? c.input?.command ?? c.input?.url;
|
|
106
|
+
lastActivity = `${c.name} ${target ? oneLine(String(target), 60) : ""}`.trim();
|
|
107
|
+
if (EDIT_TOOLS.has(c.name) && c.input?.file_path) pendingEdits.set(c.id, String(c.input.file_path));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
} else if (e.type === "user") {
|
|
111
|
+
for (const c of e.message?.content ?? []) {
|
|
112
|
+
if (c?.type !== "tool_result") continue;
|
|
113
|
+
const text = typeof c.content === "string" ? c.content : JSON.stringify(c.content ?? "");
|
|
114
|
+
if (c.is_error) {
|
|
115
|
+
if (DENIED.test(text)) usage.denied++;
|
|
116
|
+
} else if (pendingEdits.has(c.tool_use_id)) {
|
|
117
|
+
edits.push(pendingEdits.get(c.tool_use_id));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} else if (e.type === "result") {
|
|
121
|
+
const u = e.usage ?? {};
|
|
122
|
+
usage.input += num(u.input_tokens);
|
|
123
|
+
usage.cacheRead += num(u.cache_read_input_tokens);
|
|
124
|
+
usage.cacheWrite += num(u.cache_creation_input_tokens);
|
|
125
|
+
usage.reasoning += num(u.output_tokens_details?.thinking_tokens);
|
|
126
|
+
usage.output += Math.max(0, num(u.output_tokens) - num(u.output_tokens_details?.thinking_tokens));
|
|
127
|
+
usage.total += num(u.input_tokens) + num(u.cache_read_input_tokens) + num(u.cache_creation_input_tokens) + num(u.output_tokens);
|
|
128
|
+
usage.cost = (usage.cost ?? 0) + num(e.total_cost_usd);
|
|
129
|
+
usage.denied = Math.max(usage.denied, Array.isArray(e.permission_denials) ? e.permission_denials.length : 0);
|
|
130
|
+
if (e.is_error) {
|
|
131
|
+
const tags = [e.terminal_reason, e.api_error_status && `HTTP ${e.api_error_status}`].filter(Boolean);
|
|
132
|
+
error ??= `${String(e.result ?? e.subtype ?? "Claude Code error")}${tags.length ? ` [${tags.join(", ")}]` : ""}`;
|
|
133
|
+
} else if (typeof e.result === "string") {
|
|
134
|
+
finalText = e.result.trim();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
usage.steps = steps.size;
|
|
139
|
+
return { sessionId, model, finalText: finalText ?? (error ? "" : lastText), usage, tools, edits, lastActivity, error };
|
|
140
|
+
}
|
|
141
|
+
function num(v) {
|
|
142
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
143
|
+
}
|
|
144
|
+
function oneLine(s, max = 80) {
|
|
145
|
+
const t = s.replace(/\s+/g, " ").trim();
|
|
146
|
+
return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// src/backends/claude/index.ts
|
|
150
|
+
var binary = () => findBinary("claude", "PITROOM_CLAUDE_BIN", ["~/.local/bin/claude", "~/.claude/local/claude"]);
|
|
151
|
+
function cl(args, timeout = 6e4) {
|
|
152
|
+
const { command, prefix } = resolveCommand(binary());
|
|
153
|
+
const r = spawnSync(command, [...prefix, ...args], {
|
|
154
|
+
encoding: "utf8",
|
|
155
|
+
timeout,
|
|
156
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
157
|
+
maxBuffer: 16 * 1024 * 1024
|
|
158
|
+
});
|
|
159
|
+
return { ok: r.status === 0, out: r.stdout ?? "", err: r.stderr ?? "", missing: !!r.error };
|
|
160
|
+
}
|
|
161
|
+
var READ_TOOLS = ["Read", "Grep", "Glob"];
|
|
162
|
+
var WRITE_TOOLS = [...READ_TOOLS, "Edit", "Write", "Bash"];
|
|
163
|
+
var WEB_TOOLS = ["WebFetch", "WebSearch"];
|
|
164
|
+
var DENY_BASH = [
|
|
165
|
+
"git commit",
|
|
166
|
+
"git push",
|
|
167
|
+
"git pull",
|
|
168
|
+
"git reset",
|
|
169
|
+
"git checkout",
|
|
170
|
+
"git restore",
|
|
171
|
+
"git clean",
|
|
172
|
+
"git stash",
|
|
173
|
+
"git rebase",
|
|
174
|
+
"git merge",
|
|
175
|
+
"git switch",
|
|
176
|
+
"git branch",
|
|
177
|
+
"git tag",
|
|
178
|
+
"git rm",
|
|
179
|
+
"git worktree",
|
|
180
|
+
"git update-ref",
|
|
181
|
+
"git config",
|
|
182
|
+
"git remote",
|
|
183
|
+
"git cherry-pick",
|
|
184
|
+
"git revert",
|
|
185
|
+
"git am",
|
|
186
|
+
"git add",
|
|
187
|
+
"rm -rf",
|
|
188
|
+
"rm -fr",
|
|
189
|
+
"sudo",
|
|
190
|
+
"chown",
|
|
191
|
+
"kill",
|
|
192
|
+
"pkill",
|
|
193
|
+
"killall",
|
|
194
|
+
"gh",
|
|
195
|
+
"npm publish",
|
|
196
|
+
"pitroom",
|
|
197
|
+
"opencode",
|
|
198
|
+
"claude",
|
|
199
|
+
"codex"
|
|
200
|
+
].map((c) => `Bash(${c}:*)`);
|
|
201
|
+
var SECRETS = ["**/.env", "**/.env.*", "**/*.pem", "**/id_rsa*", "**/id_ed25519*"].map((p) => `Read(${p})`);
|
|
202
|
+
function toolsFor(mode, web2) {
|
|
203
|
+
return [...mode === "read" ? READ_TOOLS : WRITE_TOOLS, ...web2 ? WEB_TOOLS : []];
|
|
204
|
+
}
|
|
205
|
+
function invocation(req) {
|
|
206
|
+
const tools = toolsFor(req.mode, req.web).join(",");
|
|
207
|
+
const args = [
|
|
208
|
+
"-p",
|
|
209
|
+
"--safe-mode",
|
|
210
|
+
"--restricted",
|
|
211
|
+
"--strict-mcp-config",
|
|
212
|
+
"--tools",
|
|
213
|
+
tools,
|
|
214
|
+
"--allowedTools",
|
|
215
|
+
tools
|
|
216
|
+
];
|
|
217
|
+
if (req.mode !== "read") args.push("--disallowedTools", ...DENY_BASH);
|
|
218
|
+
args.push(
|
|
219
|
+
"--settings",
|
|
220
|
+
JSON.stringify({ permissions: { deny: SECRETS } }),
|
|
221
|
+
// Non-variadic options last, so no list above can swallow what follows.
|
|
222
|
+
"--permission-mode",
|
|
223
|
+
"dontAsk",
|
|
224
|
+
"--output-format",
|
|
225
|
+
"stream-json",
|
|
226
|
+
"--verbose"
|
|
227
|
+
);
|
|
228
|
+
if (req.model) args.push("--model", req.model);
|
|
229
|
+
if (req.sessionId) args.push("--resume", req.sessionId);
|
|
230
|
+
args.push("--", req.prompt);
|
|
231
|
+
const { command, prefix } = resolveCommand(binary());
|
|
232
|
+
return { command, args: [...prefix, ...args], env: { DISABLE_AUTOUPDATER: "1" } };
|
|
233
|
+
}
|
|
234
|
+
function classify(message) {
|
|
235
|
+
if (/authentication_failed|failed to authenticate|oauth|not logged in|\/login|invalid api key|\b40[13]\b|unauthori[sz]ed/i.test(message)) {
|
|
236
|
+
return "auth";
|
|
237
|
+
}
|
|
238
|
+
if (/rate.?limit|too many requests|\b429\b|\b529\b|overloaded|usage limit|limit reached|quota|capacity/i.test(message)) return "rate-limited";
|
|
239
|
+
if (/model[^.]*(not found|does not exist|invalid|not available)|invalid model|not_found_error/i.test(message)) return "model-unavailable";
|
|
240
|
+
return "other";
|
|
241
|
+
}
|
|
242
|
+
function failure(run, stderr, exitCode) {
|
|
243
|
+
if (exitCode === 0 && !run.error) return void 0;
|
|
244
|
+
const detail = stderr.trim().split("\n").filter(Boolean).pop();
|
|
245
|
+
const message = run.error ?? detail ?? `claude exited with code ${exitCode}`;
|
|
246
|
+
return { kind: classify(message), message };
|
|
247
|
+
}
|
|
248
|
+
function defaultModel() {
|
|
249
|
+
try {
|
|
250
|
+
const settings = JSON.parse(fs2.readFileSync(path2.join(os2.homedir(), ".claude", "settings.json"), "utf8"));
|
|
251
|
+
return typeof settings.model === "string" ? settings.model : void 0;
|
|
252
|
+
} catch {
|
|
253
|
+
return void 0;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function doctor({ models, hasFallback }) {
|
|
257
|
+
const version = cl(["--version"]);
|
|
258
|
+
if (version.missing || !version.ok) {
|
|
259
|
+
return [{ level: "fail", message: `claude not runnable (${binary()}). Install Claude Code, or set PITROOM_CLAUDE_BIN` }];
|
|
260
|
+
}
|
|
261
|
+
const checks = [{ level: "ok", message: `Claude Code ${version.out.trim()} at ${binary()}` }];
|
|
262
|
+
let loggedIn = false;
|
|
263
|
+
try {
|
|
264
|
+
loggedIn = JSON.parse(cl(["auth", "status"]).out).loggedIn === true;
|
|
265
|
+
} catch {
|
|
266
|
+
}
|
|
267
|
+
checks.push(
|
|
268
|
+
loggedIn || process.env.ANTHROPIC_API_KEY ? { level: "ok", message: `Claude Code: ${loggedIn ? "logged in" : "ANTHROPIC_API_KEY set"}` } : { level: "fail", message: "Claude Code is not logged in (or the session expired): run `claude auth login`" }
|
|
269
|
+
);
|
|
270
|
+
for (const m of models) {
|
|
271
|
+
const model = m ?? defaultModel();
|
|
272
|
+
const pricey = model && /opus|fable/i.test(model);
|
|
273
|
+
checks.push({
|
|
274
|
+
level: pricey ? "warn" : "ok",
|
|
275
|
+
message: pricey ? `Claude Code model: ${model} is the most expensive tier for a worker; consider -W claude:haiku or claude:sonnet` : model ? `Claude Code model: ${model}` : "Claude Code model: Claude Code's default (often the largest model; a cheaper worker is -W claude:haiku)"
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
if (!hasFallback) checks.push({ level: "warn", message: "no fallback workers configured for Claude Code runs" });
|
|
279
|
+
return checks;
|
|
280
|
+
}
|
|
281
|
+
var claude = {
|
|
282
|
+
id: "claude",
|
|
283
|
+
name: "Claude Code",
|
|
284
|
+
capabilities: { readOnly: "tool-allowlist", resume: "by-id", reportsCost: true, attachFiles: false },
|
|
285
|
+
binary,
|
|
286
|
+
invocation,
|
|
287
|
+
parse: parseEvents,
|
|
288
|
+
failure,
|
|
289
|
+
defaultModel,
|
|
290
|
+
doctor
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// src/backends/codex/index.ts
|
|
294
|
+
import { spawnSync as spawnSync2 } from "node:child_process";
|
|
295
|
+
import fs3 from "node:fs";
|
|
296
|
+
import os3 from "node:os";
|
|
297
|
+
import path3 from "node:path";
|
|
298
|
+
|
|
299
|
+
// src/backends/codex/events.ts
|
|
300
|
+
var WORK = /* @__PURE__ */ new Set(["agent_message", "reasoning", "command_execution", "file_change", "mcp_tool_call", "web_search", "todo_list"]);
|
|
301
|
+
var TOOLS = /* @__PURE__ */ new Set(["command_execution", "file_change", "mcp_tool_call", "web_search"]);
|
|
302
|
+
function parseEvents2(jsonl) {
|
|
303
|
+
const usage = {
|
|
304
|
+
input: 0,
|
|
305
|
+
output: 0,
|
|
306
|
+
reasoning: 0,
|
|
307
|
+
cacheRead: 0,
|
|
308
|
+
cacheWrite: 0,
|
|
309
|
+
total: 0,
|
|
310
|
+
steps: 0,
|
|
311
|
+
toolCalls: 0,
|
|
312
|
+
denied: 0
|
|
313
|
+
};
|
|
314
|
+
const tools = {};
|
|
315
|
+
const edits = [];
|
|
316
|
+
let sessionId;
|
|
317
|
+
let finalText = "";
|
|
318
|
+
let lastActivity;
|
|
319
|
+
let error;
|
|
320
|
+
for (const line of jsonl.split("\n")) {
|
|
321
|
+
if (!line.trim()) continue;
|
|
322
|
+
let e;
|
|
323
|
+
try {
|
|
324
|
+
e = JSON.parse(line);
|
|
325
|
+
} catch {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
switch (e.type) {
|
|
329
|
+
case "thread.started":
|
|
330
|
+
sessionId ??= e.thread_id;
|
|
331
|
+
break;
|
|
332
|
+
case "item.completed": {
|
|
333
|
+
const it = e.item ?? {};
|
|
334
|
+
if (!WORK.has(it.type)) break;
|
|
335
|
+
usage.steps++;
|
|
336
|
+
if (TOOLS.has(it.type)) {
|
|
337
|
+
usage.toolCalls++;
|
|
338
|
+
tools[it.type] = (tools[it.type] ?? 0) + 1;
|
|
339
|
+
}
|
|
340
|
+
if (it.type === "agent_message" && String(it.text ?? "").trim()) {
|
|
341
|
+
finalText = String(it.text).trim();
|
|
342
|
+
lastActivity = `says: ${oneLine2(finalText)}`;
|
|
343
|
+
} else if (it.type === "command_execution") {
|
|
344
|
+
if (it.status === "declined" || /operation not permitted|sandbox/i.test(String(it.aggregated_output ?? ""))) usage.denied++;
|
|
345
|
+
lastActivity = `shell ${oneLine2(String(it.command ?? ""), 60)}`;
|
|
346
|
+
} else if (it.type === "file_change") {
|
|
347
|
+
const paths = (it.changes ?? []).map((c) => String(c.path));
|
|
348
|
+
if (it.status !== "failed" && it.status !== "declined") edits.push(...paths);
|
|
349
|
+
else usage.denied++;
|
|
350
|
+
lastActivity = `edit ${oneLine2(paths.join(", "), 60)}`;
|
|
351
|
+
}
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
case "turn.completed": {
|
|
355
|
+
const u = e.usage ?? {};
|
|
356
|
+
const cached2 = num2(u.cached_input_tokens);
|
|
357
|
+
const reasoning = num2(u.reasoning_output_tokens);
|
|
358
|
+
usage.input += Math.max(0, num2(u.input_tokens) - cached2);
|
|
359
|
+
usage.cacheRead += cached2;
|
|
360
|
+
usage.cacheWrite += num2(u.cache_write_input_tokens);
|
|
361
|
+
usage.output += Math.max(0, num2(u.output_tokens) - reasoning);
|
|
362
|
+
usage.reasoning += reasoning;
|
|
363
|
+
usage.total += num2(u.input_tokens) + num2(u.output_tokens);
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
case "error":
|
|
367
|
+
error = describe(e.message);
|
|
368
|
+
break;
|
|
369
|
+
case "turn.failed":
|
|
370
|
+
error = describe(e.error?.message ?? e.message);
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return { sessionId, finalText, usage, tools, edits, lastActivity, error };
|
|
375
|
+
}
|
|
376
|
+
function describe(raw) {
|
|
377
|
+
const s = String(raw ?? "unknown Codex error");
|
|
378
|
+
try {
|
|
379
|
+
const j = JSON.parse(s);
|
|
380
|
+
const inner = j.error ?? j;
|
|
381
|
+
const tags = [inner.type, j.status && `HTTP ${j.status}`].filter(Boolean);
|
|
382
|
+
return `${inner.message ?? s}${tags.length ? ` [${tags.join(", ")}]` : ""}`;
|
|
383
|
+
} catch {
|
|
384
|
+
return s;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
function num2(v) {
|
|
388
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
389
|
+
}
|
|
390
|
+
function oneLine2(s, max = 80) {
|
|
391
|
+
const t = s.replace(/\s+/g, " ").trim();
|
|
392
|
+
return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// src/backends/codex/index.ts
|
|
396
|
+
var binary2 = () => findBinary("codex", "PITROOM_CODEX_BIN");
|
|
397
|
+
var codexHome = () => process.env.CODEX_HOME ?? path3.join(os3.homedir(), ".codex");
|
|
398
|
+
function cx(args, timeout = 6e4) {
|
|
399
|
+
const { command, prefix } = resolveCommand(binary2());
|
|
400
|
+
const r = spawnSync2(command, [...prefix, ...args], {
|
|
401
|
+
encoding: "utf8",
|
|
402
|
+
timeout,
|
|
403
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
404
|
+
maxBuffer: 16 * 1024 * 1024
|
|
405
|
+
});
|
|
406
|
+
return { ok: r.status === 0, out: `${r.stdout ?? ""}${r.stderr ?? ""}`, missing: !!r.error };
|
|
407
|
+
}
|
|
408
|
+
var SANDBOX = { read: "read-only", write: "workspace-write", isolate: "workspace-write" };
|
|
409
|
+
var toml = (s) => JSON.stringify(s);
|
|
410
|
+
function splitModel(model) {
|
|
411
|
+
if (!model) return {};
|
|
412
|
+
const [m, effort] = model.split("#");
|
|
413
|
+
return { model: m || void 0, effort: effort || void 0 };
|
|
414
|
+
}
|
|
415
|
+
function invocation2(req) {
|
|
416
|
+
const { model, effort } = splitModel(req.model);
|
|
417
|
+
const args = ["exec"];
|
|
418
|
+
if (req.sessionId) args.push("resume", req.sessionId);
|
|
419
|
+
args.push(
|
|
420
|
+
"--json",
|
|
421
|
+
"--skip-git-repo-check",
|
|
422
|
+
"--ignore-user-config",
|
|
423
|
+
"-c",
|
|
424
|
+
`sandbox_mode=${toml(SANDBOX[req.mode])}`,
|
|
425
|
+
"-c",
|
|
426
|
+
'approval_policy="never"'
|
|
427
|
+
);
|
|
428
|
+
if (req.web) args.push("-c", 'web_search="live"');
|
|
429
|
+
if (model) args.push("--model", model);
|
|
430
|
+
if (effort) args.push("-c", `model_reasoning_effort=${toml(effort)}`);
|
|
431
|
+
args.push(req.prompt);
|
|
432
|
+
const { command, prefix } = resolveCommand(binary2());
|
|
433
|
+
return { command, args: [...prefix, ...args], env: {} };
|
|
434
|
+
}
|
|
435
|
+
function classify2(message) {
|
|
436
|
+
if (/not supported|model[^.]*(not found|does not exist|unknown|unavailable)|unsupported model|no such model/i.test(message)) {
|
|
437
|
+
return "model-unavailable";
|
|
438
|
+
}
|
|
439
|
+
if (/rate.?limit|too many requests|\b429\b|usage limit|quota|overloaded|\b50[23]\b|capacity/i.test(message)) return "rate-limited";
|
|
440
|
+
if (/unauthori[sz]ed|\b40[13]\b|not logged in|log ?in|api.?key|credits?\b|billing|subscription/i.test(message)) return "auth";
|
|
441
|
+
return "other";
|
|
442
|
+
}
|
|
443
|
+
function failure2(run, stderr, exitCode) {
|
|
444
|
+
if (exitCode === 0 && !run.error) return void 0;
|
|
445
|
+
const detail = stderr.split("\n").find((l) => /^Error[: ]|ERROR/.test(l) && !/rmcp|models cache/.test(l));
|
|
446
|
+
let message = run.error ?? (detail ? detail.trim() : `codex exited with code ${exitCode}`);
|
|
447
|
+
if (/not supported when using Codex/i.test(message)) message += " (an outdated Codex CLI says this too: npm i -g @openai/codex@latest)";
|
|
448
|
+
return { kind: classify2(message), message };
|
|
449
|
+
}
|
|
450
|
+
function resolveModel(sessionId) {
|
|
451
|
+
const root = path3.join(codexHome(), "sessions");
|
|
452
|
+
const day = (d) => path3.join(root, String(d.getFullYear()), String(d.getMonth() + 1).padStart(2, "0"), String(d.getDate()).padStart(2, "0"));
|
|
453
|
+
const now = /* @__PURE__ */ new Date();
|
|
454
|
+
for (const dir of [day(now), day(new Date(now.getTime() - 864e5))]) {
|
|
455
|
+
let files = [];
|
|
456
|
+
try {
|
|
457
|
+
files = fs3.readdirSync(dir).filter((f) => f.includes(sessionId));
|
|
458
|
+
} catch {
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
for (const f of files) {
|
|
462
|
+
const m = /"model":"([^"]+)"/.exec(fs3.readFileSync(path3.join(dir, f), "utf8"));
|
|
463
|
+
if (m) return m[1];
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return void 0;
|
|
467
|
+
}
|
|
468
|
+
function doctor2({ models, hasFallback }) {
|
|
469
|
+
const version = cx(["--version"]);
|
|
470
|
+
if (version.missing || !version.ok) {
|
|
471
|
+
return [{ level: "fail", message: `codex not runnable (${binary2()}). Install: npm i -g @openai/codex, or set PITROOM_CODEX_BIN` }];
|
|
472
|
+
}
|
|
473
|
+
const checks = [{ level: "ok", message: `${version.out.trim()} at ${binary2()}` }];
|
|
474
|
+
const login = cx(["login", "status"]);
|
|
475
|
+
checks.push(
|
|
476
|
+
login.ok && /logged in/i.test(login.out) ? { level: "ok", message: `Codex: ${login.out.trim().split("\n")[0]}` } : { level: "fail", message: "Codex is not logged in: run `codex login`" }
|
|
477
|
+
);
|
|
478
|
+
for (const m of models) {
|
|
479
|
+
checks.push({
|
|
480
|
+
level: "ok",
|
|
481
|
+
message: m ? `Codex model: ${m} (checked on first use; unsupported models fail over)` : "Codex model: Codex's built-in default (your ~/.codex/config.toml is ignored for workers; pick one with -W codex:<model>)"
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
if (!hasFallback) checks.push({ level: "warn", message: "no fallback workers configured for Codex runs" });
|
|
485
|
+
return checks;
|
|
486
|
+
}
|
|
487
|
+
var codex = {
|
|
488
|
+
id: "codex",
|
|
489
|
+
name: "Codex",
|
|
490
|
+
capabilities: { readOnly: "os-sandbox", resume: "by-id", reportsCost: false, attachFiles: false },
|
|
491
|
+
binary: binary2,
|
|
492
|
+
invocation: invocation2,
|
|
493
|
+
parse: parseEvents2,
|
|
494
|
+
failure: failure2,
|
|
495
|
+
resolveModel,
|
|
496
|
+
doctor: doctor2
|
|
497
|
+
};
|
|
498
|
+
|
|
499
|
+
// src/backends/opencode/index.ts
|
|
500
|
+
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
501
|
+
|
|
502
|
+
// src/backends/opencode/events.ts
|
|
503
|
+
var EDIT_TOOLS2 = /* @__PURE__ */ new Set(["edit", "write", "patch", "multiedit", "apply_patch"]);
|
|
504
|
+
var DENIED2 = /rule which prevents you|permission denied|permission\.rejected/i;
|
|
505
|
+
function parseEvents3(ndjson) {
|
|
506
|
+
const usage = {
|
|
507
|
+
input: 0,
|
|
508
|
+
output: 0,
|
|
509
|
+
reasoning: 0,
|
|
510
|
+
cacheRead: 0,
|
|
511
|
+
cacheWrite: 0,
|
|
512
|
+
total: 0,
|
|
513
|
+
cost: 0,
|
|
514
|
+
steps: 0,
|
|
515
|
+
toolCalls: 0,
|
|
516
|
+
denied: 0
|
|
517
|
+
};
|
|
518
|
+
const tools = {};
|
|
519
|
+
const edits = [];
|
|
520
|
+
const textByMessage = /* @__PURE__ */ new Map();
|
|
521
|
+
let sessionId;
|
|
522
|
+
let lastActivity;
|
|
523
|
+
let error;
|
|
524
|
+
for (const line of ndjson.split("\n")) {
|
|
525
|
+
if (!line.trim()) continue;
|
|
526
|
+
let e;
|
|
527
|
+
try {
|
|
528
|
+
e = JSON.parse(line);
|
|
529
|
+
} catch {
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
sessionId ??= e.sessionID ?? e.part?.sessionID;
|
|
533
|
+
const p = e.part ?? {};
|
|
534
|
+
switch (e.type) {
|
|
535
|
+
case "text": {
|
|
536
|
+
const text = String(p.text ?? "");
|
|
537
|
+
if (!text.trim()) break;
|
|
538
|
+
const key = String(p.messageID ?? "");
|
|
539
|
+
if (!textByMessage.has(key)) textByMessage.set(key, []);
|
|
540
|
+
textByMessage.get(key).push(text);
|
|
541
|
+
lastActivity = `says: ${oneLine3(text)}`;
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
case "tool_use": {
|
|
545
|
+
const name = String(p.tool ?? "tool");
|
|
546
|
+
const st = p.state ?? {};
|
|
547
|
+
usage.toolCalls++;
|
|
548
|
+
tools[name] = (tools[name] ?? 0) + 1;
|
|
549
|
+
if (st.status === "error" && DENIED2.test(String(st.error ?? ""))) usage.denied++;
|
|
550
|
+
if (st.status === "completed" && EDIT_TOOLS2.has(name)) {
|
|
551
|
+
edits.push(String(st.input?.filePath ?? st.input?.path ?? name));
|
|
552
|
+
}
|
|
553
|
+
lastActivity = `${name} ${describe2(st.input)}`.trim();
|
|
554
|
+
break;
|
|
555
|
+
}
|
|
556
|
+
case "step_finish": {
|
|
557
|
+
const t = p.tokens ?? {};
|
|
558
|
+
usage.steps++;
|
|
559
|
+
usage.input += num3(t.input);
|
|
560
|
+
usage.output += num3(t.output);
|
|
561
|
+
usage.reasoning += num3(t.reasoning);
|
|
562
|
+
usage.cacheRead += num3(t.cache?.read);
|
|
563
|
+
usage.cacheWrite += num3(t.cache?.write);
|
|
564
|
+
usage.total += num3(t.total) || num3(t.input) + num3(t.output) + num3(t.reasoning) + num3(t.cache?.read);
|
|
565
|
+
usage.cost = (usage.cost ?? 0) + num3(p.cost);
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
case "error": {
|
|
569
|
+
error = describeError(e.error);
|
|
570
|
+
break;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
const groups = [...textByMessage.values()];
|
|
575
|
+
const finalText = (groups[groups.length - 1] ?? []).join("\n").trim();
|
|
576
|
+
return { sessionId, finalText, usage, tools, edits, lastActivity, error };
|
|
577
|
+
}
|
|
578
|
+
function describeError(err) {
|
|
579
|
+
if (!err || typeof err !== "object") return "unknown OpenCode error";
|
|
580
|
+
const message = String(err.data?.message ?? err.message ?? err.name ?? "unknown OpenCode error");
|
|
581
|
+
let inner;
|
|
582
|
+
try {
|
|
583
|
+
inner = JSON.parse(err.response?.body ?? "{}")?.error?.type;
|
|
584
|
+
} catch {
|
|
585
|
+
}
|
|
586
|
+
const tags = [err.type, inner, err.status && `HTTP ${err.status}`].filter(Boolean);
|
|
587
|
+
return tags.length ? `${message} [${tags.join(", ")}]` : message;
|
|
588
|
+
}
|
|
589
|
+
function num3(v) {
|
|
590
|
+
return typeof v === "number" && Number.isFinite(v) ? v : 0;
|
|
591
|
+
}
|
|
592
|
+
function oneLine3(s, max = 80) {
|
|
593
|
+
const t = s.replace(/\s+/g, " ").trim();
|
|
594
|
+
return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
|
|
595
|
+
}
|
|
596
|
+
function describe2(input) {
|
|
597
|
+
if (!input || typeof input !== "object") return "";
|
|
598
|
+
const v = input.filePath ?? input.path ?? input.pattern ?? input.command ?? input.url ?? input.query;
|
|
599
|
+
return v ? oneLine3(String(v), 60) : "";
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// src/backends/opencode/profiles.ts
|
|
603
|
+
import fs4 from "node:fs";
|
|
604
|
+
import os4 from "node:os";
|
|
605
|
+
import path4 from "node:path";
|
|
606
|
+
var AGENT = { read: "pitroom-read", write: "pitroom-write" };
|
|
607
|
+
var READ_FILES = {
|
|
608
|
+
"*": "allow",
|
|
609
|
+
// Keep secrets away from third-party / free model providers.
|
|
610
|
+
"*.env": "deny",
|
|
611
|
+
"*.env.*": "deny",
|
|
612
|
+
"*.env.example": "allow",
|
|
613
|
+
"*.env.sample": "allow",
|
|
614
|
+
"*.pem": "deny",
|
|
615
|
+
"*id_rsa*": "deny",
|
|
616
|
+
"*id_ed25519*": "deny"
|
|
617
|
+
};
|
|
618
|
+
var READ_ONLY_BASH = {
|
|
619
|
+
"*": "deny",
|
|
620
|
+
"git status*": "allow",
|
|
621
|
+
"git diff*": "allow",
|
|
622
|
+
"git log*": "allow",
|
|
623
|
+
"git show*": "allow",
|
|
624
|
+
"git grep*": "allow",
|
|
625
|
+
"git blame*": "allow",
|
|
626
|
+
"git ls-files*": "allow",
|
|
627
|
+
"ls*": "allow",
|
|
628
|
+
pwd: "allow",
|
|
629
|
+
"rg *": "allow",
|
|
630
|
+
"wc *": "allow",
|
|
631
|
+
// Flags that would let "read" commands execute or write.
|
|
632
|
+
"rg *--pre*": "deny",
|
|
633
|
+
"git *--output*": "deny",
|
|
634
|
+
"git *--ext-diff*": "deny"
|
|
635
|
+
};
|
|
636
|
+
var FORBIDDEN_BASH = [
|
|
637
|
+
// history, branches and anything that can discard uncommitted work
|
|
638
|
+
"git commit*",
|
|
639
|
+
"git push*",
|
|
640
|
+
"git reset*",
|
|
641
|
+
"git checkout*",
|
|
642
|
+
"git restore*",
|
|
643
|
+
"git clean*",
|
|
644
|
+
"git stash*",
|
|
645
|
+
"git rebase*",
|
|
646
|
+
"git merge*",
|
|
647
|
+
"git switch*",
|
|
648
|
+
"git branch*",
|
|
649
|
+
"git tag*",
|
|
650
|
+
"git rm*",
|
|
651
|
+
"git worktree*",
|
|
652
|
+
"git update-ref*",
|
|
653
|
+
"git filter-branch*",
|
|
654
|
+
"git filter-repo*",
|
|
655
|
+
"git gc*",
|
|
656
|
+
"git prune*",
|
|
657
|
+
"git reflog*",
|
|
658
|
+
"git config*",
|
|
659
|
+
"git remote*",
|
|
660
|
+
"git cherry-pick*",
|
|
661
|
+
"git revert*",
|
|
662
|
+
"git am*",
|
|
663
|
+
"git add*",
|
|
664
|
+
"git mv*",
|
|
665
|
+
"git pull*",
|
|
666
|
+
"git -C*",
|
|
667
|
+
"git --git-dir*",
|
|
668
|
+
"git --work-tree*",
|
|
669
|
+
// git by absolute path would bypass the PATH shim (see guard.ts)
|
|
670
|
+
"/*/git*",
|
|
671
|
+
// bulk deletion and privilege
|
|
672
|
+
"rm -rf*",
|
|
673
|
+
"rm -fr*",
|
|
674
|
+
"rm -r *",
|
|
675
|
+
"rm -R *",
|
|
676
|
+
"rm --recursive*",
|
|
677
|
+
"sudo *",
|
|
678
|
+
"su *",
|
|
679
|
+
"doas *",
|
|
680
|
+
"chown *",
|
|
681
|
+
"chmod -R*",
|
|
682
|
+
"dd *",
|
|
683
|
+
"mkfs*",
|
|
684
|
+
"shutdown*",
|
|
685
|
+
"reboot*",
|
|
686
|
+
// killing processes could kill the primary agent
|
|
687
|
+
"kill *",
|
|
688
|
+
"pkill*",
|
|
689
|
+
"killall*",
|
|
690
|
+
// publishing / remote side effects
|
|
691
|
+
"gh *",
|
|
692
|
+
"npm publish*",
|
|
693
|
+
"pnpm publish*",
|
|
694
|
+
"yarn publish*",
|
|
695
|
+
"cargo publish*",
|
|
696
|
+
"twine upload*",
|
|
697
|
+
// no recursive delegation
|
|
698
|
+
"opencode*",
|
|
699
|
+
"pitroom *",
|
|
700
|
+
"*pitroom*"
|
|
701
|
+
];
|
|
702
|
+
function scratchDirs() {
|
|
703
|
+
const data = process.env.XDG_DATA_HOME ?? path4.join(os4.homedir(), ".local", "share");
|
|
704
|
+
const rules = {
|
|
705
|
+
"*": "deny",
|
|
706
|
+
[path4.join(data, "opencode", "tool-output", "*")]: "allow",
|
|
707
|
+
[path4.join(data, "opencode", "shell", "*", "*")]: "allow"
|
|
708
|
+
};
|
|
709
|
+
for (const tmp of /* @__PURE__ */ new Set([os4.tmpdir(), realpath(os4.tmpdir())])) rules[path4.join(tmp, "opencode", "*")] = "allow";
|
|
710
|
+
return rules;
|
|
711
|
+
}
|
|
712
|
+
function realpath(p) {
|
|
713
|
+
try {
|
|
714
|
+
return fs4.realpathSync(p);
|
|
715
|
+
} catch {
|
|
716
|
+
return p;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
var COMMON = {
|
|
720
|
+
skill: { "*": "allow", "*pitroom*": "deny" },
|
|
721
|
+
task: "deny",
|
|
722
|
+
// subagents carry their own, broader permissions
|
|
723
|
+
// v2 "execute" runs namespaced tools (browser automation, the OpenCode API itself).
|
|
724
|
+
execute: "deny",
|
|
725
|
+
question: "deny",
|
|
726
|
+
// nobody is there to answer
|
|
727
|
+
doom_loop: "deny"
|
|
728
|
+
};
|
|
729
|
+
var web = (on) => ({ webfetch: on ? "allow" : "deny", websearch: on ? "allow" : "deny" });
|
|
730
|
+
function readProfile(allowWeb = false) {
|
|
731
|
+
return {
|
|
732
|
+
"*": "deny",
|
|
733
|
+
read: READ_FILES,
|
|
734
|
+
glob: "allow",
|
|
735
|
+
grep: "allow",
|
|
736
|
+
list: "allow",
|
|
737
|
+
lsp: "allow",
|
|
738
|
+
codesearch: "allow",
|
|
739
|
+
todowrite: "allow",
|
|
740
|
+
todoread: "allow",
|
|
741
|
+
...web(allowWeb),
|
|
742
|
+
// v1 calls the shell tool "bash", v2 "shell"; both get the same rules.
|
|
743
|
+
bash: READ_ONLY_BASH,
|
|
744
|
+
shell: READ_ONLY_BASH,
|
|
745
|
+
edit: "deny",
|
|
746
|
+
external_directory: scratchDirs(),
|
|
747
|
+
...COMMON
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
function writeProfile(allowWeb = false) {
|
|
751
|
+
const bash = { "*": "allow" };
|
|
752
|
+
for (const pattern of FORBIDDEN_BASH) bash[pattern] = "deny";
|
|
753
|
+
return {
|
|
754
|
+
"*": "allow",
|
|
755
|
+
read: READ_FILES,
|
|
756
|
+
...web(allowWeb),
|
|
757
|
+
bash,
|
|
758
|
+
shell: bash,
|
|
759
|
+
external_directory: scratchDirs(),
|
|
760
|
+
...COMMON
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
function agentFor(mode) {
|
|
764
|
+
return mode === "read" ? AGENT.read : AGENT.write;
|
|
765
|
+
}
|
|
766
|
+
function configContent(existing, allowWeb = false) {
|
|
767
|
+
const ours = {
|
|
768
|
+
agent: {
|
|
769
|
+
[AGENT.read]: {
|
|
770
|
+
mode: "primary",
|
|
771
|
+
description: "pitroom: read-only worker (research, search, review)",
|
|
772
|
+
permission: readProfile(allowWeb)
|
|
773
|
+
},
|
|
774
|
+
[AGENT.write]: {
|
|
775
|
+
mode: "primary",
|
|
776
|
+
description: "pitroom: editing worker (no git history changes, no bulk deletes)",
|
|
777
|
+
permission: writeProfile(allowWeb)
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
let base = {};
|
|
782
|
+
if (existing?.trim()) {
|
|
783
|
+
try {
|
|
784
|
+
base = JSON.parse(existing);
|
|
785
|
+
} catch {
|
|
786
|
+
base = {};
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return JSON.stringify(deepMerge(base, ours));
|
|
790
|
+
}
|
|
791
|
+
function deepMerge(a, b) {
|
|
792
|
+
if (!isObject(a) || !isObject(b)) return b;
|
|
793
|
+
const out = { ...a };
|
|
794
|
+
for (const [k, v] of Object.entries(b)) out[k] = k in out ? deepMerge(out[k], v) : v;
|
|
795
|
+
return out;
|
|
796
|
+
}
|
|
797
|
+
function isObject(v) {
|
|
798
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// src/backends/opencode/index.ts
|
|
802
|
+
var binary3 = () => findBinary("opencode", "PITROOM_OPENCODE_BIN", ["~/.opencode/bin/opencode"]);
|
|
803
|
+
function oc(args, opts = {}) {
|
|
804
|
+
const { command, prefix } = resolveCommand(binary3());
|
|
805
|
+
const r = spawnSync3(command, [...prefix, ...args], {
|
|
806
|
+
encoding: "utf8",
|
|
807
|
+
timeout: opts.timeout ?? 6e4,
|
|
808
|
+
env: opts.env ?? process.env,
|
|
809
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
810
|
+
maxBuffer: 64 * 1024 * 1024
|
|
811
|
+
});
|
|
812
|
+
return { ok: r.status === 0, out: r.stdout ?? "", err: r.stderr ?? "", missing: !!r.error };
|
|
813
|
+
}
|
|
814
|
+
var jsonIn = (s) => JSON.parse(s.slice(s.indexOf("{")));
|
|
815
|
+
function invocation3(req) {
|
|
816
|
+
const args = [
|
|
817
|
+
"run",
|
|
818
|
+
"--standalone",
|
|
819
|
+
"--agent",
|
|
820
|
+
agentFor(req.mode),
|
|
821
|
+
"--format",
|
|
822
|
+
"json",
|
|
823
|
+
"--title",
|
|
824
|
+
req.title,
|
|
825
|
+
// Some failure causes only appear in the server logs.
|
|
826
|
+
"--print-logs",
|
|
827
|
+
"--log-level",
|
|
828
|
+
"error"
|
|
829
|
+
];
|
|
830
|
+
if (req.model) args.push("--model", req.model);
|
|
831
|
+
if (req.sessionId) args.push("--session", req.sessionId);
|
|
832
|
+
for (const f of req.files) args.push("--file", f);
|
|
833
|
+
args.push(req.prompt);
|
|
834
|
+
const { command, prefix } = resolveCommand(binary3());
|
|
835
|
+
return {
|
|
836
|
+
command,
|
|
837
|
+
args: [...prefix, ...args],
|
|
838
|
+
env: {
|
|
839
|
+
OPENCODE_CONFIG_CONTENT: configContent(process.env.OPENCODE_CONFIG_CONTENT, req.web),
|
|
840
|
+
// A worker run must not upgrade OpenCode underneath the user.
|
|
841
|
+
OPENCODE_DISABLE_AUTOUPDATE: "1"
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
function majorVersion(output) {
|
|
846
|
+
const m = /(\d+)\.\d+\.\d+/.exec(output);
|
|
847
|
+
return m ? Number(m[1]) : void 0;
|
|
848
|
+
}
|
|
849
|
+
function classify3(message) {
|
|
850
|
+
if (/model not found|ModelNotFound|unknown model|no such model|deprecated/i.test(message)) return "model-unavailable";
|
|
851
|
+
if (/rate.?limit|too many requests|\b429\b|quota|overloaded|\b50[23]\b|unavailable|capacity/i.test(message)) return "rate-limited";
|
|
852
|
+
if (/provider\.auth|FreeTierError|free tier|unauthori[sz]ed|\b40[13]\b|api.?key|insufficient|credits?\b|billing|payment/i.test(message)) {
|
|
853
|
+
return "auth";
|
|
854
|
+
}
|
|
855
|
+
return "other";
|
|
856
|
+
}
|
|
857
|
+
function failure3(run, stderr, exitCode) {
|
|
858
|
+
if (exitCode === 0 && !run.error) return void 0;
|
|
859
|
+
const detail = stderr.match(/error="([^"]+)"/g)?.pop()?.slice(7, -1);
|
|
860
|
+
const raw = run.error ?? `opencode exited with code ${exitCode}`;
|
|
861
|
+
const message = detail && !raw.includes(detail) ? `${raw}: ${detail}` : raw;
|
|
862
|
+
return { kind: classify3(message), message };
|
|
863
|
+
}
|
|
864
|
+
function modelId(m) {
|
|
865
|
+
if (typeof m === "string") return m;
|
|
866
|
+
const id = m?.model ?? m?.id;
|
|
867
|
+
return m?.providerID && id ? `${m.providerID}/${id}` : void 0;
|
|
868
|
+
}
|
|
869
|
+
function defaultModel2() {
|
|
870
|
+
try {
|
|
871
|
+
const out = oc(["debug", "config"], { timeout: 3e4 }).out;
|
|
872
|
+
const json = JSON.parse(out.slice(out.search(/[[{]/)));
|
|
873
|
+
if (Array.isArray(json)) {
|
|
874
|
+
const withModel = json.filter((s) => s?.info?.model);
|
|
875
|
+
return modelId(withModel[withModel.length - 1]?.info.model);
|
|
876
|
+
}
|
|
877
|
+
return modelId(json.model);
|
|
878
|
+
} catch {
|
|
879
|
+
return void 0;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
function resolveModel2(sessionId) {
|
|
883
|
+
try {
|
|
884
|
+
const json = jsonIn(oc(["session", "export", sessionId], { timeout: 2e4 }).out);
|
|
885
|
+
const msgs = [...json.messages ?? []].reverse();
|
|
886
|
+
const v2 = msgs.find((m) => m.type === "assistant" && m.model)?.model;
|
|
887
|
+
if (v2) return modelId(v2);
|
|
888
|
+
const v1 = msgs.find((m) => m.info?.role === "assistant")?.info;
|
|
889
|
+
return v1?.providerID && v1?.modelID ? `${v1.providerID}/${v1.modelID}` : void 0;
|
|
890
|
+
} catch {
|
|
891
|
+
return void 0;
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
function listModels() {
|
|
895
|
+
return oc(["models"]).out.split("\n").map((s) => s.trim()).filter(Boolean);
|
|
896
|
+
}
|
|
897
|
+
function doctor3({ models, hasFallback }) {
|
|
898
|
+
const checks = [];
|
|
899
|
+
const version = oc(["--version"]);
|
|
900
|
+
if (version.missing || !version.ok) {
|
|
901
|
+
return [{ level: "fail", message: `opencode not runnable (${binary3()}). Install: https://opencode.ai or set PITROOM_OPENCODE_BIN` }];
|
|
902
|
+
}
|
|
903
|
+
const v = version.out.trim().replace(/^opencode\s+/i, "");
|
|
904
|
+
const major = majorVersion(v);
|
|
905
|
+
if (major !== void 0 && major < 2) {
|
|
906
|
+
return [{ level: "fail", message: `OpenCode ${v} is too old: Pitroom needs OpenCode v2 or newer (run \`opencode upgrade\`)` }];
|
|
907
|
+
}
|
|
908
|
+
checks.push({ level: "ok", message: `opencode ${v} at ${binary3()} (private --standalone server per run)` });
|
|
909
|
+
const known = new Set(listModels());
|
|
910
|
+
const def = defaultModel2();
|
|
911
|
+
for (const m of models) {
|
|
912
|
+
const model = m ?? def;
|
|
913
|
+
const label = m ? "model" : "default model";
|
|
914
|
+
if (!model) {
|
|
915
|
+
checks.push({ level: "warn", message: 'OpenCode has no default model; it will pick one (set "model" in opencode.json)' });
|
|
916
|
+
} else if (known.size && !known.has(model)) {
|
|
917
|
+
checks.push({ level: "fail", message: `OpenCode ${label} "${model}" is not in \`opencode models\`` });
|
|
918
|
+
} else {
|
|
919
|
+
checks.push({ level: "ok", message: `OpenCode ${label}: ${model}` });
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
if (!hasFallback) {
|
|
923
|
+
const free = [...known].filter((m) => /-free$/.test(m) && m !== def).slice(0, 4);
|
|
924
|
+
checks.push({
|
|
925
|
+
level: "warn",
|
|
926
|
+
message: `no fallback workers: if the model is rate-limited or removed, runs fail.${free.length ? ` Free OpenCode models you have: ${free.join(", ")}` : ""}`
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
checks.push({ level: "ok", message: `permission profiles ${AGENT.read}/${AGENT.write} injected per run (verify: pitroom doctor --probe)` });
|
|
930
|
+
return checks;
|
|
931
|
+
}
|
|
932
|
+
var opencode = {
|
|
933
|
+
id: "opencode",
|
|
934
|
+
name: "OpenCode",
|
|
935
|
+
capabilities: { readOnly: "permission-rules", resume: "by-id", reportsCost: true, attachFiles: true },
|
|
936
|
+
binary: binary3,
|
|
937
|
+
invocation: invocation3,
|
|
938
|
+
parse: parseEvents3,
|
|
939
|
+
failure: failure3,
|
|
940
|
+
defaultModel: defaultModel2,
|
|
941
|
+
resolveModel: resolveModel2,
|
|
942
|
+
listModels,
|
|
943
|
+
doctor: doctor3
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
// src/backends/index.ts
|
|
947
|
+
var REGISTRY = new Map([opencode, codex, claude].map((b) => [b.id, b]));
|
|
948
|
+
var DEFAULT_BACKEND = opencode.id;
|
|
949
|
+
var PLANNED = ["gemini"];
|
|
950
|
+
var backendIds = () => [...REGISTRY.keys()];
|
|
951
|
+
var isBackendId = (id) => REGISTRY.has(id) || PLANNED.includes(id);
|
|
952
|
+
function getBackend(id) {
|
|
953
|
+
const backend = REGISTRY.get(id);
|
|
954
|
+
if (backend) return backend;
|
|
955
|
+
const available = backendIds().join(", ");
|
|
956
|
+
throw new UserError(
|
|
957
|
+
PLANNED.includes(id) ? `the "${id}" worker is not supported yet (available: ${available})` : `unknown worker "${id}" (available: ${available})`,
|
|
958
|
+
3
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
// src/cli/args.ts
|
|
963
|
+
import fs7 from "node:fs";
|
|
964
|
+
|
|
965
|
+
// src/core/config.ts
|
|
966
|
+
import fs5 from "node:fs";
|
|
967
|
+
import os5 from "node:os";
|
|
968
|
+
import path5 from "node:path";
|
|
969
|
+
var SCHEMA = {
|
|
970
|
+
worker: "string",
|
|
971
|
+
fallback: "string[]",
|
|
972
|
+
timeout: "string",
|
|
973
|
+
primary: "string",
|
|
974
|
+
price: "string",
|
|
975
|
+
link: "string[]",
|
|
976
|
+
web: "boolean",
|
|
977
|
+
maxParallel: "number",
|
|
978
|
+
models: "record",
|
|
979
|
+
tiers: "record"
|
|
980
|
+
};
|
|
981
|
+
function configPath() {
|
|
982
|
+
if (process.env.PITROOM_CONFIG) return path5.resolve(process.env.PITROOM_CONFIG);
|
|
983
|
+
const base = process.platform === "win32" ? process.env.APPDATA ?? path5.join(os5.homedir(), "AppData", "Roaming") : process.env.XDG_CONFIG_HOME ?? path5.join(os5.homedir(), ".config");
|
|
984
|
+
return path5.join(base, "pitroom", "config.json");
|
|
985
|
+
}
|
|
986
|
+
var cached;
|
|
987
|
+
function loadConfig() {
|
|
988
|
+
if (cached) return cached;
|
|
989
|
+
const file = configPath();
|
|
990
|
+
const config = {};
|
|
991
|
+
const warnings = [];
|
|
992
|
+
if (fs5.existsSync(file)) {
|
|
993
|
+
let raw;
|
|
994
|
+
try {
|
|
995
|
+
raw = JSON.parse(fs5.readFileSync(file, "utf8"));
|
|
996
|
+
} catch (e) {
|
|
997
|
+
warnings.push(`${file}: invalid JSON (${e.message}); ignored`);
|
|
998
|
+
}
|
|
999
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
1000
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
1001
|
+
const type = SCHEMA[key];
|
|
1002
|
+
if (!type) {
|
|
1003
|
+
warnings.push(`${file}: unknown key "${key}" ignored`);
|
|
1004
|
+
} else if (matches(value, type)) {
|
|
1005
|
+
config[key] = value;
|
|
1006
|
+
} else {
|
|
1007
|
+
warnings.push(`${file}: "${key}" must be ${type}; ignored`);
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
cached = { config, warnings };
|
|
1013
|
+
return cached;
|
|
1014
|
+
}
|
|
1015
|
+
function matches(v, type) {
|
|
1016
|
+
if (type === "string[]") return Array.isArray(v) && v.every((s) => typeof s === "string");
|
|
1017
|
+
if (type === "record") {
|
|
1018
|
+
return typeof v === "object" && v !== null && !Array.isArray(v) && Object.values(v).every((s) => typeof s === "string");
|
|
1019
|
+
}
|
|
1020
|
+
return typeof v === type;
|
|
1021
|
+
}
|
|
1022
|
+
var positiveInt = (v) => {
|
|
1023
|
+
const n = Number(v);
|
|
1024
|
+
return v !== void 0 && v !== "" && Number.isInteger(n) && n > 0 ? n : void 0;
|
|
1025
|
+
};
|
|
1026
|
+
var list = (s) => s?.split(",").map((x) => x.trim()).filter(Boolean);
|
|
1027
|
+
function setting(flag2, env, conf, fallback) {
|
|
1028
|
+
if (flag2 !== void 0) return { value: flag2, source: "flag" };
|
|
1029
|
+
if (env !== void 0) return { value: env, source: "env" };
|
|
1030
|
+
if (conf !== void 0) return { value: conf, source: "config" };
|
|
1031
|
+
return { value: fallback, source: "default" };
|
|
1032
|
+
}
|
|
1033
|
+
function effective(flags = {}) {
|
|
1034
|
+
const c = loadConfig().config;
|
|
1035
|
+
const e = process.env;
|
|
1036
|
+
return {
|
|
1037
|
+
worker: setting(flags.worker, e.PITROOM_WORKER, c.worker, DEFAULT_BACKEND),
|
|
1038
|
+
/** Overrides the model of the preferred worker only. */
|
|
1039
|
+
model: setting(flags.model, e.PITROOM_MODEL, void 0, void 0),
|
|
1040
|
+
fallback: setting(void 0, list(e.PITROOM_FALLBACK), c.fallback, []),
|
|
1041
|
+
timeout: setting(flags.timeout, e.PITROOM_TIMEOUT, c.timeout, "30m"),
|
|
1042
|
+
primary: setting(void 0, e.PITROOM_PRIMARY, c.primary, "sonnet"),
|
|
1043
|
+
price: setting(void 0, e.PITROOM_PRICE, c.price, void 0),
|
|
1044
|
+
link: setting(void 0, void 0, c.link, []),
|
|
1045
|
+
web: setting(void 0, void 0, c.web, false),
|
|
1046
|
+
maxParallel: setting(void 0, positiveInt(e.PITROOM_MAX_PARALLEL), positiveInt(c.maxParallel), 4),
|
|
1047
|
+
models: setting(void 0, void 0, c.models, {}),
|
|
1048
|
+
tiers: setting(void 0, void 0, c.tiers, {})
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// src/core/store.ts
|
|
1053
|
+
import fs6 from "node:fs";
|
|
1054
|
+
import os6 from "node:os";
|
|
1055
|
+
import path6 from "node:path";
|
|
1056
|
+
import crypto from "node:crypto";
|
|
1057
|
+
var TERMINAL = ["done", "failed", "timeout", "stopped"];
|
|
1058
|
+
var isActive = (s) => !TERMINAL.includes(s);
|
|
1059
|
+
function home() {
|
|
1060
|
+
if (process.env.PITROOM_HOME) return path6.resolve(process.env.PITROOM_HOME);
|
|
1061
|
+
if (process.platform === "win32") {
|
|
1062
|
+
return path6.join(process.env.LOCALAPPDATA ?? path6.join(os6.homedir(), "AppData", "Local"), "pitroom");
|
|
1063
|
+
}
|
|
1064
|
+
return path6.join(process.env.XDG_STATE_HOME ?? path6.join(os6.homedir(), ".local", "state"), "pitroom");
|
|
1065
|
+
}
|
|
1066
|
+
var runsDir = () => path6.join(home(), "runs");
|
|
1067
|
+
var worktreesDir = () => path6.join(home(), "worktrees");
|
|
1068
|
+
var ledgerFile = () => path6.join(home(), "ledger.jsonl");
|
|
1069
|
+
var runDir = (id) => path6.join(runsDir(), id);
|
|
1070
|
+
var runFile = (id, name) => path6.join(runDir(id), name);
|
|
1071
|
+
function newRunId(now = /* @__PURE__ */ new Date()) {
|
|
1072
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
1073
|
+
const stamp = `${now.getFullYear()}${p(now.getMonth() + 1)}${p(now.getDate())}-${p(now.getHours())}${p(now.getMinutes())}${p(now.getSeconds())}`;
|
|
1074
|
+
return `${stamp}-${crypto.randomBytes(2).toString("hex")}`;
|
|
1075
|
+
}
|
|
1076
|
+
function writeMeta(meta) {
|
|
1077
|
+
fs6.mkdirSync(runDir(meta.id), { recursive: true });
|
|
1078
|
+
const file = runFile(meta.id, "meta.json");
|
|
1079
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
1080
|
+
fs6.writeFileSync(tmp, JSON.stringify(meta, null, 2));
|
|
1081
|
+
fs6.renameSync(tmp, file);
|
|
1082
|
+
}
|
|
1083
|
+
function readMeta(id) {
|
|
1084
|
+
return upgrade(JSON.parse(fs6.readFileSync(runFile(id, "meta.json"), "utf8")));
|
|
1085
|
+
}
|
|
1086
|
+
function upgrade(raw) {
|
|
1087
|
+
if (!raw.worker) raw.worker = raw.model ? { backend: "opencode", model: raw.model } : { backend: "opencode" };
|
|
1088
|
+
if (!Array.isArray(raw.fallback)) {
|
|
1089
|
+
raw.fallback = (raw.fallbackModels ?? []).map((m) => ({ backend: "opencode", model: m }));
|
|
1090
|
+
}
|
|
1091
|
+
raw.warnings ??= [];
|
|
1092
|
+
raw.files ??= [];
|
|
1093
|
+
raw.link ??= [];
|
|
1094
|
+
return raw;
|
|
1095
|
+
}
|
|
1096
|
+
function listRunIds() {
|
|
1097
|
+
if (!fs6.existsSync(runsDir())) return [];
|
|
1098
|
+
return fs6.readdirSync(runsDir()).filter((d) => fs6.existsSync(runFile(d, "meta.json"))).sort();
|
|
1099
|
+
}
|
|
1100
|
+
function resolveRun(ref) {
|
|
1101
|
+
const ids = listRunIds();
|
|
1102
|
+
if (!ids.length) throw new UserError("no runs yet");
|
|
1103
|
+
if (!ref || ref === "latest" || ref === "last") return ids[ids.length - 1];
|
|
1104
|
+
if (ids.includes(ref)) return ref;
|
|
1105
|
+
const hits = ids.filter((id) => id.startsWith(ref) || id.endsWith(ref));
|
|
1106
|
+
if (hits.length === 1) return hits[0];
|
|
1107
|
+
throw new UserError(hits.length ? `ambiguous run "${ref}": ${hits.join(", ")}` : `unknown run "${ref}"`);
|
|
1108
|
+
}
|
|
1109
|
+
function isAlive(pid) {
|
|
1110
|
+
if (!pid) return false;
|
|
1111
|
+
try {
|
|
1112
|
+
process.kill(pid, 0);
|
|
1113
|
+
return true;
|
|
1114
|
+
} catch (e) {
|
|
1115
|
+
return e.code === "EPERM";
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
function freshMeta(id) {
|
|
1119
|
+
const meta = readMeta(id);
|
|
1120
|
+
if (!TERMINAL.includes(meta.state) && meta.pid && !isAlive(meta.pid)) {
|
|
1121
|
+
meta.state = "failed";
|
|
1122
|
+
meta.error ??= "worker process exited unexpectedly";
|
|
1123
|
+
meta.endedAt ??= (/* @__PURE__ */ new Date()).toISOString();
|
|
1124
|
+
writeMeta(meta);
|
|
1125
|
+
}
|
|
1126
|
+
return meta;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
// src/cli/args.ts
|
|
1130
|
+
var VALUE_FLAGS = {
|
|
1131
|
+
"-d": "dir",
|
|
1132
|
+
"--dir": "dir",
|
|
1133
|
+
"-f": "file",
|
|
1134
|
+
"--file": "file",
|
|
1135
|
+
"-m": "model",
|
|
1136
|
+
"--model": "model",
|
|
1137
|
+
"-W": "worker",
|
|
1138
|
+
"--worker": "worker",
|
|
1139
|
+
"--tier": "tier",
|
|
1140
|
+
"-g": "group",
|
|
1141
|
+
"--group": "group",
|
|
1142
|
+
"-t": "timeout",
|
|
1143
|
+
"--timeout": "timeout",
|
|
1144
|
+
"--verify": "verify",
|
|
1145
|
+
"--link": "link",
|
|
1146
|
+
"-c": "continue",
|
|
1147
|
+
"--continue": "continue",
|
|
1148
|
+
"--task-file": "task-file",
|
|
1149
|
+
"--since": "since",
|
|
1150
|
+
"--card": "card",
|
|
1151
|
+
"--days": "days",
|
|
1152
|
+
"--interval": "interval",
|
|
1153
|
+
"--range": "range",
|
|
1154
|
+
"--then": "then",
|
|
1155
|
+
"--plan": "plan",
|
|
1156
|
+
"--step": "step"
|
|
1157
|
+
};
|
|
1158
|
+
var BOOL_FLAGS = {
|
|
1159
|
+
"-r": "read",
|
|
1160
|
+
"--read": "read",
|
|
1161
|
+
"-w": "write",
|
|
1162
|
+
"--write": "write",
|
|
1163
|
+
"-i": "isolate",
|
|
1164
|
+
"--isolate": "isolate",
|
|
1165
|
+
"--bg": "bg",
|
|
1166
|
+
"--web": "web",
|
|
1167
|
+
"--no-fallback": "no-fallback",
|
|
1168
|
+
"--json": "json",
|
|
1169
|
+
"--allow-non-git": "allow-non-git",
|
|
1170
|
+
"--patch": "patch",
|
|
1171
|
+
"--events": "events",
|
|
1172
|
+
"--full": "full",
|
|
1173
|
+
"--badge": "badge",
|
|
1174
|
+
"--probe": "probe",
|
|
1175
|
+
"--copy": "copy",
|
|
1176
|
+
"--force": "force",
|
|
1177
|
+
"--yes": "yes",
|
|
1178
|
+
"--any": "any",
|
|
1179
|
+
"--brief": "brief",
|
|
1180
|
+
"--running": "running",
|
|
1181
|
+
"-h": "help",
|
|
1182
|
+
"--help": "help",
|
|
1183
|
+
"-v": "version",
|
|
1184
|
+
"--version": "version"
|
|
1185
|
+
};
|
|
1186
|
+
function parse(argv) {
|
|
1187
|
+
const positional = [];
|
|
1188
|
+
const flags = /* @__PURE__ */ new Map();
|
|
1189
|
+
const set = (k, v) => flags.set(k, [...flags.get(k) ?? [], v]);
|
|
1190
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1191
|
+
const a = argv[i];
|
|
1192
|
+
if (a === "--") {
|
|
1193
|
+
positional.push(...argv.slice(i + 1));
|
|
1194
|
+
break;
|
|
1195
|
+
}
|
|
1196
|
+
const [name, inline] = a.startsWith("--") && a.includes("=") ? [a.slice(0, a.indexOf("=")), a.slice(a.indexOf("=") + 1)] : [a, void 0];
|
|
1197
|
+
if (VALUE_FLAGS[name]) {
|
|
1198
|
+
const v = inline ?? argv[++i];
|
|
1199
|
+
if (v === void 0) throw new UserError(`${name} needs a value`);
|
|
1200
|
+
set(VALUE_FLAGS[name], v);
|
|
1201
|
+
} else if (BOOL_FLAGS[name]) {
|
|
1202
|
+
set(BOOL_FLAGS[name], "true");
|
|
1203
|
+
} else if (a.startsWith("-") && a !== "-") {
|
|
1204
|
+
throw new UserError(`unknown option ${a} (see pitroom --help)`);
|
|
1205
|
+
} else {
|
|
1206
|
+
positional.push(a);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
return { positional, flags };
|
|
1210
|
+
}
|
|
1211
|
+
var flag = (p, k) => p.flags.get(k)?.at(-1);
|
|
1212
|
+
var has = (p, k) => p.flags.has(k);
|
|
1213
|
+
function parseDuration(s) {
|
|
1214
|
+
const m = /^(\d+(?:\.\d+)?)\s*(s|m|h)?$/i.exec(s.trim());
|
|
1215
|
+
if (!m) throw new UserError(`bad duration "${s}" (use e.g. 900, 20m, 1h)`);
|
|
1216
|
+
return Math.round(Number(m[1]) * ({ s: 1, m: 60, h: 3600 }[(m[2] ?? "s").toLowerCase()] ?? 1));
|
|
1217
|
+
}
|
|
1218
|
+
function exitCodeFor(meta) {
|
|
1219
|
+
switch (meta.state) {
|
|
1220
|
+
case "queued":
|
|
1221
|
+
case "running":
|
|
1222
|
+
return 75;
|
|
1223
|
+
case "timeout":
|
|
1224
|
+
return 4;
|
|
1225
|
+
case "failed":
|
|
1226
|
+
case "stopped":
|
|
1227
|
+
return 1;
|
|
1228
|
+
default:
|
|
1229
|
+
if (meta.warnings.some((w) => w.startsWith("READ-ONLY VIOLATION"))) return 5;
|
|
1230
|
+
if (meta.verifyResult && !meta.verifyResult.ok) return 6;
|
|
1231
|
+
return 0;
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
function readTask(p) {
|
|
1235
|
+
const file = flag(p, "task-file");
|
|
1236
|
+
if (file) return fs7.readFileSync(file, "utf8");
|
|
1237
|
+
const words = p.positional;
|
|
1238
|
+
if (words.length === 1 && words[0] === "-") return fs7.readFileSync(0, "utf8");
|
|
1239
|
+
return words.join(" ");
|
|
1240
|
+
}
|
|
1241
|
+
function runOptions(p, task) {
|
|
1242
|
+
const modes = ["read", "write", "isolate"].filter((m) => has(p, m));
|
|
1243
|
+
if (modes.length > 1) throw new UserError("choose one of --read, --write, --isolate");
|
|
1244
|
+
const cont = flag(p, "continue");
|
|
1245
|
+
return {
|
|
1246
|
+
mode: modes[0] ?? "read",
|
|
1247
|
+
task,
|
|
1248
|
+
dir: flag(p, "dir") ?? process.cwd(),
|
|
1249
|
+
files: p.flags.get("file") ?? [],
|
|
1250
|
+
link: p.flags.has("link") ? (p.flags.get("link") ?? []).flatMap((s) => s.split(",")).map((s) => s.trim()).filter(Boolean) : effective().link.value,
|
|
1251
|
+
worker: flag(p, "worker"),
|
|
1252
|
+
model: flag(p, "model"),
|
|
1253
|
+
tier: flag(p, "tier"),
|
|
1254
|
+
timeoutSec: parseDuration(effective({ timeout: flag(p, "timeout") }).timeout.value),
|
|
1255
|
+
verify: flag(p, "verify"),
|
|
1256
|
+
continueFrom: cont ? resolveRun(cont) : void 0,
|
|
1257
|
+
allowNonGit: has(p, "allow-non-git"),
|
|
1258
|
+
web: has(p, "web") || effective().web.value,
|
|
1259
|
+
noFallback: has(p, "no-fallback"),
|
|
1260
|
+
group: flag(p, "group")
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
function planStep(p) {
|
|
1264
|
+
const file = flag(p, "plan");
|
|
1265
|
+
const step = flag(p, "step");
|
|
1266
|
+
if (!file && !step) return void 0;
|
|
1267
|
+
if (!file || !step) throw new UserError("--plan and --step go together: pitroom run -i --plan PLAN.md --step N");
|
|
1268
|
+
if (!/^\d+$/.test(step)) throw new UserError(`--step takes a task number, not "${step}"`);
|
|
1269
|
+
return { file, step: Number(step) };
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
// src/cli/commands.ts
|
|
1273
|
+
import { spawnSync as spawnSync6 } from "node:child_process";
|
|
1274
|
+
import fs22 from "node:fs";
|
|
1275
|
+
import path18 from "node:path";
|
|
1276
|
+
|
|
1277
|
+
// src/core/report.ts
|
|
1278
|
+
import fs10 from "node:fs";
|
|
1279
|
+
|
|
1280
|
+
// src/core/plan.ts
|
|
1281
|
+
import fs8 from "node:fs";
|
|
1282
|
+
import path7 from "node:path";
|
|
1283
|
+
var FENCE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
1284
|
+
var RULE = /^ {0,3}([-*_])(\s*\1){2,}\s*$/;
|
|
1285
|
+
function structure(lines) {
|
|
1286
|
+
const headings = [];
|
|
1287
|
+
const rules = [];
|
|
1288
|
+
const fenced = [];
|
|
1289
|
+
let open;
|
|
1290
|
+
lines.forEach((l, i) => {
|
|
1291
|
+
const f = FENCE.exec(l);
|
|
1292
|
+
if (f) {
|
|
1293
|
+
fenced[i] = true;
|
|
1294
|
+
const mark = f[1];
|
|
1295
|
+
if (!open) open = mark;
|
|
1296
|
+
else if (mark[0] === open[0] && mark.length >= open.length && !f[2].trim()) open = void 0;
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1299
|
+
fenced[i] = !!open;
|
|
1300
|
+
if (open) return;
|
|
1301
|
+
const h = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(l);
|
|
1302
|
+
if (h) headings.push({ line: i, level: h[1].length, text: h[2] });
|
|
1303
|
+
else if (RULE.test(l)) rules.push(i);
|
|
1304
|
+
});
|
|
1305
|
+
return { headings, rules, fenced };
|
|
1306
|
+
}
|
|
1307
|
+
function parsePlan(text, file = "") {
|
|
1308
|
+
const lines = text.replace(/\r\n?/g, "\n").split("\n");
|
|
1309
|
+
const { headings, rules, fenced } = structure(lines);
|
|
1310
|
+
const after = (line) => Math.min(headings.find((h) => h.line > line)?.line ?? lines.length, rules.find((r) => r > line) ?? lines.length);
|
|
1311
|
+
const titleHeading = headings.find((h) => h.level === 1);
|
|
1312
|
+
const start = titleHeading ? titleHeading.line + 1 : 0;
|
|
1313
|
+
const header = lines.slice(start, after(start - 1)).filter((l) => !/^\s*>/.test(l)).join("\n").trim();
|
|
1314
|
+
const gc = headings.find((h) => /^global constraints\b/i.test(h.text));
|
|
1315
|
+
const constraints = gc ? lines.slice(gc.line + 1, after(gc.line)).join("\n").trim() : "";
|
|
1316
|
+
const tasks = [];
|
|
1317
|
+
for (const h of headings) {
|
|
1318
|
+
const m = /^Task\s+(\d+)\b\s*[:.)\-–—]?\s*(.*)$/i.exec(h.text);
|
|
1319
|
+
if (!m) continue;
|
|
1320
|
+
const end = headings.find((o) => o.line > h.line && o.level <= h.level)?.line ?? lines.length;
|
|
1321
|
+
const body = lines.slice(h.line, end).join("\n").replace(/(\n\s*(?:---|\*\*\*|___)\s*)+$/, "").trimEnd();
|
|
1322
|
+
const unfenced = lines.slice(h.line, end).filter((_, j) => !fenced[h.line + j]).join("\n");
|
|
1323
|
+
const tier = /^\s*[-*]?\s*\*\*Worker:\*\*\s*`?([\w-]+)`?/m.exec(unfenced)?.[1]?.toLowerCase();
|
|
1324
|
+
tasks.push({ step: Number(m[1]), title: m[2].trim(), text: body, ...tier ? { tier } : {} });
|
|
1325
|
+
}
|
|
1326
|
+
return { file, title: titleHeading?.text ?? "", header, constraints, tasks };
|
|
1327
|
+
}
|
|
1328
|
+
function loadPlan(file) {
|
|
1329
|
+
const abs = path7.resolve(file);
|
|
1330
|
+
if (!fs8.existsSync(abs) || !fs8.statSync(abs).isFile()) throw new UserError(`plan not found: ${file}`);
|
|
1331
|
+
const plan = parsePlan(fs8.readFileSync(abs, "utf8"), fs8.realpathSync(abs));
|
|
1332
|
+
if (!plan.tasks.length) throw new UserError(`${file} has no "Task N" headings (see pitroom-writing-plans)`);
|
|
1333
|
+
return plan;
|
|
1334
|
+
}
|
|
1335
|
+
function planTask(plan, step) {
|
|
1336
|
+
const t = plan.tasks.find((x) => x.step === step);
|
|
1337
|
+
if (!t) throw new UserError(`no Task ${step} in ${plan.file}; it has ${plan.tasks.map((x) => `Task ${x.step}`).join(", ")}`);
|
|
1338
|
+
return t;
|
|
1339
|
+
}
|
|
1340
|
+
function brief(plan, task) {
|
|
1341
|
+
return `${[
|
|
1342
|
+
`# ${plan.title || planName(plan.file)}`,
|
|
1343
|
+
plan.header,
|
|
1344
|
+
"## Global Constraints",
|
|
1345
|
+
plan.constraints || "(none stated in the plan)",
|
|
1346
|
+
task.text
|
|
1347
|
+
].filter(Boolean).join("\n\n")}
|
|
1348
|
+
`;
|
|
1349
|
+
}
|
|
1350
|
+
var planName = (file) => path7.basename(file).replace(/\.md$/i, "");
|
|
1351
|
+
|
|
1352
|
+
// src/core/receipt.ts
|
|
1353
|
+
import fs9 from "node:fs";
|
|
1354
|
+
import path8 from "node:path";
|
|
1355
|
+
var PRESETS = {
|
|
1356
|
+
sonnet: { name: "Claude Sonnet", input: 3, output: 15, cachedInput: 0.3 },
|
|
1357
|
+
opus: { name: "Claude Opus", input: 5, output: 25, cachedInput: 0.5 },
|
|
1358
|
+
haiku: { name: "Claude Haiku", input: 1, output: 5, cachedInput: 0.1 },
|
|
1359
|
+
"gpt-5": { name: "GPT-5", input: 1.25, output: 10, cachedInput: 0.125 }
|
|
1360
|
+
};
|
|
1361
|
+
function primaryPrice() {
|
|
1362
|
+
const eff = effective();
|
|
1363
|
+
const custom = eff.price.value?.split(",").map(Number);
|
|
1364
|
+
if (custom && custom.length >= 2 && custom.every((n) => Number.isFinite(n) && n >= 0)) {
|
|
1365
|
+
return { name: "custom", input: custom[0], output: custom[1], cachedInput: custom[2] ?? custom[0] / 10 };
|
|
1366
|
+
}
|
|
1367
|
+
return PRESETS[eff.primary.value.toLowerCase()] ?? PRESETS.sonnet;
|
|
1368
|
+
}
|
|
1369
|
+
var estimateTokens = (text) => Math.ceil(text.length / 4);
|
|
1370
|
+
function savedUsd(usage, returnedTokens, price = primaryPrice()) {
|
|
1371
|
+
const wouldCost = (usage.input * price.input + usage.cacheRead * price.cachedInput + (usage.output + usage.reasoning) * price.output) / 1e6;
|
|
1372
|
+
const readingTheReport = returnedTokens * price.input / 1e6;
|
|
1373
|
+
return Math.max(0, wouldCost - (usage.cost ?? 0) - readingTheReport);
|
|
1374
|
+
}
|
|
1375
|
+
function record(meta) {
|
|
1376
|
+
if (!meta.usage?.steps) return;
|
|
1377
|
+
const entry = {
|
|
1378
|
+
id: meta.id,
|
|
1379
|
+
at: meta.endedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1380
|
+
mode: meta.mode,
|
|
1381
|
+
state: meta.state,
|
|
1382
|
+
backend: (meta.ran ?? meta.worker).backend,
|
|
1383
|
+
model: meta.resolvedModel ?? (meta.ran ?? meta.worker).model,
|
|
1384
|
+
tokens: meta.usage.total,
|
|
1385
|
+
returned: meta.returnedTokens ?? 0,
|
|
1386
|
+
workerCost: meta.usage.cost ?? 0,
|
|
1387
|
+
saved: meta.savedUsd ?? 0,
|
|
1388
|
+
price: primaryPrice().name
|
|
1389
|
+
};
|
|
1390
|
+
fs9.mkdirSync(path8.dirname(ledgerFile()), { recursive: true });
|
|
1391
|
+
fs9.appendFileSync(ledgerFile(), `${JSON.stringify(entry)}
|
|
1392
|
+
`);
|
|
1393
|
+
}
|
|
1394
|
+
function readLedger(sinceMs2) {
|
|
1395
|
+
if (!fs9.existsSync(ledgerFile())) return [];
|
|
1396
|
+
return fs9.readFileSync(ledgerFile(), "utf8").split("\n").filter(Boolean).flatMap((l) => {
|
|
1397
|
+
try {
|
|
1398
|
+
return [JSON.parse(l)];
|
|
1399
|
+
} catch {
|
|
1400
|
+
return [];
|
|
1401
|
+
}
|
|
1402
|
+
}).filter((e) => !sinceMs2 || Date.parse(e.at) >= sinceMs2);
|
|
1403
|
+
}
|
|
1404
|
+
function totals(entries) {
|
|
1405
|
+
const t = entries.reduce(
|
|
1406
|
+
(a, e) => ({
|
|
1407
|
+
runs: a.runs + 1,
|
|
1408
|
+
tokens: a.tokens + e.tokens,
|
|
1409
|
+
returned: a.returned + e.returned,
|
|
1410
|
+
workerCost: a.workerCost + e.workerCost,
|
|
1411
|
+
saved: a.saved + e.saved
|
|
1412
|
+
}),
|
|
1413
|
+
{ runs: 0, tokens: 0, returned: 0, workerCost: 0, saved: 0 }
|
|
1414
|
+
);
|
|
1415
|
+
return { ...t, ratio: t.returned ? t.tokens / t.returned : 0 };
|
|
1416
|
+
}
|
|
1417
|
+
var usd = (n) => n >= 100 ? `$${n.toFixed(0)}` : n >= 1 ? `$${n.toFixed(2)}` : `$${n.toFixed(3)}`;
|
|
1418
|
+
function compact(n) {
|
|
1419
|
+
const fmt = (v) => v.toFixed(v >= 100 ? 0 : 1).replace(/\.0$/, "");
|
|
1420
|
+
if (n >= 1e6) return `${fmt(n / 1e6)}M`;
|
|
1421
|
+
if (n >= 1e3) return `${fmt(n / 1e3)}k`;
|
|
1422
|
+
return String(Math.round(n));
|
|
1423
|
+
}
|
|
1424
|
+
function badgeUrl(t) {
|
|
1425
|
+
const msg = encodeURIComponent(`saved ${usd(t.saved)} \xB7 ${compact(t.tokens)} tokens offloaded`).replace(/-/g, "--");
|
|
1426
|
+
return `https://img.shields.io/badge/pitroom-${msg}-7c3aed`;
|
|
1427
|
+
}
|
|
1428
|
+
var esc = (s) => s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
|
1429
|
+
function card(t, period) {
|
|
1430
|
+
const stat = (x, value, label) => `<text x="${x}" y="148" class="v">${esc(value)}</text><text x="${x}" y="170" class="l">${esc(label)}</text>`;
|
|
1431
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="600" height="220" viewBox="0 0 600 220" role="img" aria-label="pitroom saved ${esc(usd(t.saved))}">
|
|
1432
|
+
<style>
|
|
1433
|
+
text{font-family:ui-sans-serif,-apple-system,Segoe UI,Helvetica,Arial,sans-serif;fill:#ede9fe}
|
|
1434
|
+
.h{font-size:15px;font-weight:600;fill:#a78bfa;letter-spacing:.08em}
|
|
1435
|
+
.big{font-size:54px;font-weight:800;fill:#fff}
|
|
1436
|
+
.sub{font-size:15px;fill:#c4b5fd}
|
|
1437
|
+
.v{font-size:22px;font-weight:700;fill:#fff}
|
|
1438
|
+
.l{font-size:12px;fill:#a78bfa}
|
|
1439
|
+
.f{font-size:11px;fill:#8b5cf6}
|
|
1440
|
+
</style>
|
|
1441
|
+
<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#1e1b4b"/><stop offset="1" stop-color="#3b0764"/></linearGradient></defs>
|
|
1442
|
+
<rect width="600" height="220" rx="18" fill="url(#g)"/>
|
|
1443
|
+
<text x="32" y="42" class="h">PITROOM \xB7 ${esc(period.toUpperCase())}</text>
|
|
1444
|
+
<text x="32" y="98" class="big">${esc(usd(t.saved))}</text>
|
|
1445
|
+
<text x="${Math.min(60 + usd(t.saved).length * 30, 330)}" y="98" class="sub">saved on my main coding agent</text>
|
|
1446
|
+
${stat(32, compact(t.tokens), "tokens offloaded")}
|
|
1447
|
+
${stat(200, t.ratio ? `${Math.round(t.ratio)}\xD7` : "\u2014", "context compression")}
|
|
1448
|
+
${stat(380, String(t.runs), "delegated tasks")}
|
|
1449
|
+
<text x="32" y="202" class="f">estimated vs. ${esc(primaryPrice().name)} pricing \xB7 npx pitroom</text>
|
|
1450
|
+
</svg>
|
|
1451
|
+
`;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// src/core/report.ts
|
|
1455
|
+
var ICON = {
|
|
1456
|
+
queued: "\u22EF",
|
|
1457
|
+
running: "\u2026",
|
|
1458
|
+
done: "\u2714",
|
|
1459
|
+
failed: "\u2718",
|
|
1460
|
+
timeout: "\u23F1",
|
|
1461
|
+
stopped: "\u25A0"
|
|
1462
|
+
};
|
|
1463
|
+
var plural = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
1464
|
+
function duration(meta) {
|
|
1465
|
+
const end = meta.endedAt ? Date.parse(meta.endedAt) : Date.now();
|
|
1466
|
+
const s = Math.max(0, Math.round((end - Date.parse(meta.startedAt)) / 1e3));
|
|
1467
|
+
return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s` : `${s}s`;
|
|
1468
|
+
}
|
|
1469
|
+
function readSummary(meta) {
|
|
1470
|
+
const f = runFile(meta.id, "summary.md");
|
|
1471
|
+
return fs10.existsSync(f) ? fs10.readFileSync(f, "utf8").trim() : "";
|
|
1472
|
+
}
|
|
1473
|
+
function formatReport(meta, finalText = readSummary(meta), maxLines = 400) {
|
|
1474
|
+
const out = [];
|
|
1475
|
+
out.push(`pitroom ${ICON[meta.state]} ${meta.state} \xB7 ${meta.mode} \xB7 ${duration(meta)} \xB7 run ${meta.id}`);
|
|
1476
|
+
const ran = meta.ran ?? meta.worker;
|
|
1477
|
+
const ids = [`worker ${ran.backend}`, meta.resolvedModel && `model ${meta.resolvedModel}`, meta.sessionId && `session ${meta.sessionId}`];
|
|
1478
|
+
out.push(ids.filter(Boolean).join(" \xB7 "));
|
|
1479
|
+
if (meta.reviewOf) {
|
|
1480
|
+
const v = meta.verdict;
|
|
1481
|
+
const verdict = v ? ` \xB7 SPEC ${v.spec.toUpperCase()} \xB7 QUALITY ${v.quality.toUpperCase()} \xB7 critical ${v.critical} \xB7 important ${v.important} \xB7 minor ${v.minor}` : "";
|
|
1482
|
+
out.push(`review of ${meta.reviewOf} (${meta.reviewKind})${verdict}`);
|
|
1483
|
+
} else if (meta.plan) {
|
|
1484
|
+
const status = meta.taskStatus ? ` \xB7 STATUS ${meta.taskStatus}` : "";
|
|
1485
|
+
out.push(`plan: ${planName(meta.plan.file)} \xB7 Task ${meta.plan.step}: ${meta.plan.title}${status}`);
|
|
1486
|
+
}
|
|
1487
|
+
for (const a of meta.attempts ?? []) out.push(`fallback: ${a.target} failed (${a.error.slice(0, 160)})`);
|
|
1488
|
+
if (meta.error) out.push(`error: ${meta.error}`);
|
|
1489
|
+
for (const w of meta.warnings) out.push(`warning: ${w}`);
|
|
1490
|
+
if (finalText) {
|
|
1491
|
+
const lines = finalText.split("\n");
|
|
1492
|
+
out.push("", ...lines.slice(0, maxLines));
|
|
1493
|
+
if (lines.length > maxLines) out.push(`\u2026 (${lines.length - maxLines} more lines: pitroom show ${meta.id} --full)`);
|
|
1494
|
+
}
|
|
1495
|
+
out.push("");
|
|
1496
|
+
if (meta.refs) {
|
|
1497
|
+
const r = meta.refs;
|
|
1498
|
+
const bad = r.invalid.slice(0, 8).map((i) => `${i.ref} (${i.reason})`).join(", ");
|
|
1499
|
+
out.push(`\u2500\u2500 refs: ${r.valid}/${r.total} verified${bad ? ` \xB7 bad: ${bad}` : ""}${r.invalid.length > 8 ? " \u2026" : ""}`);
|
|
1500
|
+
}
|
|
1501
|
+
const u = meta.usage;
|
|
1502
|
+
if (u && u.steps) {
|
|
1503
|
+
const ratio = meta.returnedTokens ? `, ${Math.max(1, Math.round(u.total / meta.returnedTokens))}\xD7 compression` : "";
|
|
1504
|
+
out.push(
|
|
1505
|
+
`\u2500\u2500 receipt: worker processed ${compact(u.total)} tokens in ${plural(u.steps, "step")} (${plural(u.toolCalls, "tool call")}${u.denied ? `, ${u.denied} blocked` : ""}) \xB7 worker cost ${u.cost === void 0 ? "n/a" : usd(u.cost)} \xB7 returned ~${compact(meta.returnedTokens ?? 0)} tokens${ratio}` + (meta.savedUsd !== void 0 ? ` \xB7 est. saved ${usd(meta.savedUsd)} vs ${primaryPrice().name}` : "")
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1508
|
+
if (meta.changes) {
|
|
1509
|
+
const s = meta.stats;
|
|
1510
|
+
const where = meta.mode === "isolate" ? "in the isolated copy, NOT applied yet" : "in your working tree";
|
|
1511
|
+
const flag2 = meta.applied ? " [applied]" : meta.reverted ? " [reverted]" : meta.discarded ? " [discarded]" : "";
|
|
1512
|
+
out.push(
|
|
1513
|
+
meta.changes.length ? `\u2500\u2500 changes (${where})${flag2}: ${s.files} files, +${s.insertions} \u2212${s.deletions}` : `\u2500\u2500 changes: none`
|
|
1514
|
+
);
|
|
1515
|
+
for (const c of meta.changes.slice(0, 50)) out.push(` ${c.status} ${c.path}`);
|
|
1516
|
+
if (meta.changes.length > 50) out.push(` \u2026 ${meta.changes.length - 50} more`);
|
|
1517
|
+
if (meta.changes.length) {
|
|
1518
|
+
out.push(` diff: pitroom show ${meta.id} --patch`);
|
|
1519
|
+
out.push(` review: pitroom review ${meta.id}`);
|
|
1520
|
+
if (meta.mode === "isolate" && !meta.applied && !meta.discarded) {
|
|
1521
|
+
out.push(` apply: pitroom apply ${meta.id}`, ` discard: pitroom discard ${meta.id}`);
|
|
1522
|
+
}
|
|
1523
|
+
if (meta.mode === "write" && !meta.reverted) out.push(` undo: pitroom revert ${meta.id}`);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
if (meta.verifyResult) {
|
|
1527
|
+
const v = meta.verifyResult;
|
|
1528
|
+
out.push(`\u2500\u2500 verify: \`${meta.verify}\` ${v.ok ? "\u2714 passed" : `\u2718 failed (exit ${v.code})`}`);
|
|
1529
|
+
if (!v.ok && v.tail) out.push(...v.tail.split("\n").map((l) => ` ${l}`));
|
|
1530
|
+
}
|
|
1531
|
+
if (isActive(meta.state)) {
|
|
1532
|
+
out.push(`\u2500\u2500 still running: pitroom wait ${meta.id} (stop: pitroom stop ${meta.id})`);
|
|
1533
|
+
} else if (meta.sessionId && !meta.applied && !meta.discarded && !meta.reviewOf) {
|
|
1534
|
+
out.push(`\u2500\u2500 follow up: pitroom run --continue ${meta.id} "\u2026"`);
|
|
1535
|
+
}
|
|
1536
|
+
return out.join("\n");
|
|
1537
|
+
}
|
|
1538
|
+
function live(meta) {
|
|
1539
|
+
const f = runFile(meta.id, "events.jsonl");
|
|
1540
|
+
if (!fs10.existsSync(f)) return { steps: 0, toolCalls: 0 };
|
|
1541
|
+
const p = getBackend((meta.ran ?? meta.worker).backend).parse(fs10.readFileSync(f, "utf8"));
|
|
1542
|
+
return { steps: p.usage.steps, toolCalls: p.usage.toolCalls, last: p.lastActivity };
|
|
1543
|
+
}
|
|
1544
|
+
function progress(meta) {
|
|
1545
|
+
if (meta.state === "queued") return `pitroom \u22EF queued \xB7 ${meta.mode} \xB7 waiting for a free worker slot \xB7 run ${meta.id}`;
|
|
1546
|
+
const l = live(meta);
|
|
1547
|
+
const last = l.last ? ` \xB7 last: ${l.last}` : "";
|
|
1548
|
+
return `pitroom \u2026 running \xB7 ${meta.mode} \xB7 ${duration(meta)} \xB7 ${l.steps} steps, ${l.toolCalls} tool calls${last} \xB7 run ${meta.id}`;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// src/core/target.ts
|
|
1552
|
+
function parseTarget(spec, defaultBackend) {
|
|
1553
|
+
const s = spec.trim();
|
|
1554
|
+
if (!s) throw new UserError("empty worker target");
|
|
1555
|
+
const i = s.indexOf(":");
|
|
1556
|
+
if (i > 0 && isBackendId(s.slice(0, i))) {
|
|
1557
|
+
const model = s.slice(i + 1).trim();
|
|
1558
|
+
return model ? { backend: s.slice(0, i), model } : { backend: s.slice(0, i) };
|
|
1559
|
+
}
|
|
1560
|
+
if (isBackendId(s)) return { backend: s };
|
|
1561
|
+
return { backend: defaultBackend, model: s };
|
|
1562
|
+
}
|
|
1563
|
+
var formatTarget = (t) => t.model ? `${t.backend}:${t.model}` : t.backend;
|
|
1564
|
+
var describeTarget = (t) => t.model ? formatTarget(t) : `${t.backend} (default model)`;
|
|
1565
|
+
var sameTarget = (a, b) => a.backend === b.backend && a.model === b.model;
|
|
1566
|
+
|
|
1567
|
+
// src/core/group.ts
|
|
1568
|
+
function groupIds(group) {
|
|
1569
|
+
return listRunIds().filter((id) => {
|
|
1570
|
+
try {
|
|
1571
|
+
return readMeta(id).group === group;
|
|
1572
|
+
} catch {
|
|
1573
|
+
return false;
|
|
1574
|
+
}
|
|
1575
|
+
});
|
|
1576
|
+
}
|
|
1577
|
+
var oneLine4 = (s, max) => {
|
|
1578
|
+
const t = s.replace(/\s+/g, " ").trim();
|
|
1579
|
+
return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
|
|
1580
|
+
};
|
|
1581
|
+
function headline(meta, max = 160) {
|
|
1582
|
+
const text = readSummary(meta);
|
|
1583
|
+
const line = text.split("\n").find((l) => /^SUMMARY:/i.test(l.trim())) ?? text.split("\n").find((l) => l.trim()) ?? "";
|
|
1584
|
+
return oneLine4(line.replace(/^SUMMARY:\s*/i, ""), max);
|
|
1585
|
+
}
|
|
1586
|
+
function table(metas) {
|
|
1587
|
+
if (!metas.length) return "no runs";
|
|
1588
|
+
const rows = metas.map((m) => {
|
|
1589
|
+
const l = m.state === "running" ? live(m) : void 0;
|
|
1590
|
+
const steps = l ? String(l.steps) : m.usage ? String(m.usage.steps) : "-";
|
|
1591
|
+
const note = l?.last ? oneLine4(l.last, 48) : isActive(m.state) ? oneLine4(m.task, 48) : headline(m, 48) || oneLine4(m.error ?? m.task, 48);
|
|
1592
|
+
return [m.id, m.state, m.mode, duration(m), steps, describeTarget(m.ran ?? m.worker), note];
|
|
1593
|
+
});
|
|
1594
|
+
const head = ["RUN", "STATE", "MODE", "TIME", "STEPS", "WORKER", "NOW / RESULT"];
|
|
1595
|
+
const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
1596
|
+
const fmt = (r) => r.map((c, i) => i === r.length - 1 ? c : c.padEnd(widths[i])).join(" ");
|
|
1597
|
+
return [fmt(head), ...rows.map(fmt)].join("\n");
|
|
1598
|
+
}
|
|
1599
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1600
|
+
async function waitMany(ids, opts) {
|
|
1601
|
+
const deadline = Date.now() + opts.timeoutMs;
|
|
1602
|
+
for (; ; ) {
|
|
1603
|
+
const metas = ids.map((id) => freshMeta(id));
|
|
1604
|
+
const done = metas.filter((m) => TERMINAL.includes(m.state)).length;
|
|
1605
|
+
if (opts.any ? done > 0 : done === metas.length) return { metas, timedOut: false };
|
|
1606
|
+
if (Date.now() >= deadline) return { metas, timedOut: true };
|
|
1607
|
+
await sleep(1500);
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
async function watch(select, opts) {
|
|
1611
|
+
const seen = /* @__PURE__ */ new Map();
|
|
1612
|
+
const emit = (e) => opts.write(`${JSON.stringify(e)}
|
|
1613
|
+
`);
|
|
1614
|
+
const deadline = opts.timeoutMs > 0 ? Date.now() + opts.timeoutMs : Infinity;
|
|
1615
|
+
for (; ; ) {
|
|
1616
|
+
const metas = select().map((id) => freshMeta(id));
|
|
1617
|
+
if (opts.json) {
|
|
1618
|
+
for (const m of metas) {
|
|
1619
|
+
const prev = seen.get(m.id);
|
|
1620
|
+
const l = m.state === "running" ? live(m) : { steps: m.usage?.steps ?? 0, toolCalls: m.usage?.toolCalls ?? 0 };
|
|
1621
|
+
const attempts = m.attempts?.length ?? 0;
|
|
1622
|
+
const base = { run: m.id, mode: m.mode, task: oneLine4(m.task, 60) };
|
|
1623
|
+
if (!prev) emit({ event: m.state === "queued" ? "queued" : "started", ...base });
|
|
1624
|
+
else if (prev.state === "queued" && m.state === "running") emit({ event: "started", ...base });
|
|
1625
|
+
if (attempts > (prev?.attempts ?? 0)) {
|
|
1626
|
+
const a = m.attempts[attempts - 1];
|
|
1627
|
+
emit({ event: "fallback", run: m.id, failed: a.target, reason: oneLine4(a.error, 120) });
|
|
1628
|
+
}
|
|
1629
|
+
if (m.state === "running" && l.steps > (prev?.steps ?? 0)) {
|
|
1630
|
+
emit({ event: "progress", run: m.id, steps: l.steps, tools: l.toolCalls, last: "last" in l && l.last ? oneLine4(String(l.last), 80) : void 0 });
|
|
1631
|
+
}
|
|
1632
|
+
if (TERMINAL.includes(m.state) && prev?.state !== m.state) {
|
|
1633
|
+
emit({
|
|
1634
|
+
event: m.state,
|
|
1635
|
+
run: m.id,
|
|
1636
|
+
time: duration(m),
|
|
1637
|
+
worker: describeTarget(m.ran ?? m.worker),
|
|
1638
|
+
summary: headline(m) || void 0,
|
|
1639
|
+
error: m.error ? oneLine4(m.error, 160) : void 0,
|
|
1640
|
+
refs: m.refs ? `${m.refs.valid}/${m.refs.total}` : void 0,
|
|
1641
|
+
changes: m.changes?.length || void 0
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
seen.set(m.id, { state: m.state, steps: l.steps, attempts });
|
|
1645
|
+
}
|
|
1646
|
+
} else {
|
|
1647
|
+
opts.write(`\x1B[H\x1B[2J${table(metas)}
|
|
1648
|
+
|
|
1649
|
+
${(/* @__PURE__ */ new Date()).toLocaleTimeString()} \xB7 Ctrl-C to stop watching (workers keep running)
|
|
1650
|
+
`);
|
|
1651
|
+
}
|
|
1652
|
+
const allDone = metas.length > 0 && metas.every((m) => TERMINAL.includes(m.state));
|
|
1653
|
+
if (allDone || metas.length === 0 && seen.size === 0) {
|
|
1654
|
+
if (opts.json) {
|
|
1655
|
+
const ok = metas.filter((m) => m.state === "done").length;
|
|
1656
|
+
emit({ event: "all-done", runs: metas.length, ok, failed: metas.length - ok });
|
|
1657
|
+
}
|
|
1658
|
+
return { metas, timedOut: false };
|
|
1659
|
+
}
|
|
1660
|
+
if (Date.now() >= deadline) return { metas, timedOut: true };
|
|
1661
|
+
await sleep(opts.intervalMs);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
// src/core/install.ts
|
|
1666
|
+
import fs11 from "node:fs";
|
|
1667
|
+
import os7 from "node:os";
|
|
1668
|
+
import path9 from "node:path";
|
|
1669
|
+
import { fileURLToPath } from "node:url";
|
|
1670
|
+
var LEGACY = ["pitroom", "opencode-worker"];
|
|
1671
|
+
function packageRoot() {
|
|
1672
|
+
return path9.resolve(path9.dirname(fileURLToPath(import.meta.url)), "..");
|
|
1673
|
+
}
|
|
1674
|
+
function skillNames(root = packageRoot()) {
|
|
1675
|
+
const dir = path9.join(root, "skills");
|
|
1676
|
+
if (!fs11.existsSync(dir)) return [];
|
|
1677
|
+
return fs11.readdirSync(dir).filter((d) => fs11.existsSync(path9.join(dir, d, "SKILL.md"))).sort();
|
|
1678
|
+
}
|
|
1679
|
+
function skillTargets() {
|
|
1680
|
+
const home2 = os7.homedir();
|
|
1681
|
+
const targets = [path9.join(home2, ".agents", "skills")];
|
|
1682
|
+
if (fs11.existsSync(path9.join(home2, ".claude"))) targets.push(path9.join(home2, ".claude", "skills"));
|
|
1683
|
+
return targets;
|
|
1684
|
+
}
|
|
1685
|
+
var launcherPath = () => path9.join(os7.homedir(), ".local", "bin", "pitroom");
|
|
1686
|
+
var LAUNCHER_MARK = "# pitroom launcher";
|
|
1687
|
+
function launcherScript(bundle) {
|
|
1688
|
+
const q = (s) => `'${s.replace(/'/g, `'\\''`)}'`;
|
|
1689
|
+
return `#!/bin/sh
|
|
1690
|
+
${LAUNCHER_MARK} (created by \`pitroom install\`; \`pitroom uninstall\` removes it)
|
|
1691
|
+
cli=${q(bundle)}
|
|
1692
|
+
ok() { [ -n "$1" ] && [ -x "$1" ] && "$1" -e 'process.exit(+process.versions.node.split(".")[0] >= 18 ? 0 : 1)' 2>/dev/null; }
|
|
1693
|
+
for n in "\${PITROOM_NODE:-}" ${q(process.execPath)} "$(command -v node 2>/dev/null)" "$HOME"/.nvm/versions/node/*/bin/node /opt/homebrew/bin/node /usr/local/bin/node; do
|
|
1694
|
+
if ok "$n"; then exec "$n" "$cli" "$@"; fi
|
|
1695
|
+
done
|
|
1696
|
+
echo "pitroom: needs Node.js 18 or newer (set PITROOM_NODE to its path)" >&2
|
|
1697
|
+
exit 127
|
|
1698
|
+
`;
|
|
1699
|
+
}
|
|
1700
|
+
function isOurLauncher(file, root) {
|
|
1701
|
+
if (linksInto(file, root)) return true;
|
|
1702
|
+
try {
|
|
1703
|
+
const s = fs11.readFileSync(file, "utf8");
|
|
1704
|
+
return s.includes(LAUNCHER_MARK) && s.includes(root);
|
|
1705
|
+
} catch {
|
|
1706
|
+
return false;
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
function placeLauncher(bundle, root, force) {
|
|
1710
|
+
const dest = launcherPath();
|
|
1711
|
+
fs11.mkdirSync(path9.dirname(dest), { recursive: true });
|
|
1712
|
+
const exists = fs11.lstatSync(dest, { throwIfNoEntry: false });
|
|
1713
|
+
if (exists && !isOurLauncher(dest, root)) {
|
|
1714
|
+
if (!force) return `! ${dest} exists and is not Pitroom's launcher; kept (use --force to back it up and replace)`;
|
|
1715
|
+
fs11.renameSync(dest, `${dest}.bak-${Date.now()}`);
|
|
1716
|
+
} else if (exists) {
|
|
1717
|
+
fs11.rmSync(dest, { force: true });
|
|
1718
|
+
}
|
|
1719
|
+
fs11.writeFileSync(dest, launcherScript(bundle), { mode: 493 });
|
|
1720
|
+
return `\u2714 ${dest} \u2192 launcher for ${bundle} (Node 18+)`;
|
|
1721
|
+
}
|
|
1722
|
+
function linksInto(link, dir) {
|
|
1723
|
+
const st = fs11.lstatSync(link, { throwIfNoEntry: false });
|
|
1724
|
+
if (!st?.isSymbolicLink()) return false;
|
|
1725
|
+
const target = path9.resolve(path9.dirname(link), fs11.readlinkSync(link));
|
|
1726
|
+
const rel = path9.relative(dir, target);
|
|
1727
|
+
return !rel.startsWith("..") && !path9.isAbsolute(rel);
|
|
1728
|
+
}
|
|
1729
|
+
function place(src, dest, opts) {
|
|
1730
|
+
fs11.mkdirSync(path9.dirname(dest), { recursive: true });
|
|
1731
|
+
const st = fs11.lstatSync(dest, { throwIfNoEntry: false });
|
|
1732
|
+
if (st && !st.isSymbolicLink()) {
|
|
1733
|
+
if (!opts.force) return `! ${dest} exists and is not a link; kept (use --force to back it up and replace)`;
|
|
1734
|
+
fs11.renameSync(dest, `${dest}.bak-${Date.now()}`);
|
|
1735
|
+
} else if (st) {
|
|
1736
|
+
fs11.unlinkSync(dest);
|
|
1737
|
+
}
|
|
1738
|
+
if (opts.copy) fs11.cpSync(src, dest, { recursive: true });
|
|
1739
|
+
else fs11.symlinkSync(src, dest, process.platform === "win32" ? "junction" : fs11.statSync(src).isDirectory() ? "dir" : "file");
|
|
1740
|
+
return `\u2714 ${dest} \u2192 ${opts.copy ? "copied" : src}`;
|
|
1741
|
+
}
|
|
1742
|
+
function install(opts) {
|
|
1743
|
+
const root = packageRoot();
|
|
1744
|
+
const skills = skillNames(root);
|
|
1745
|
+
if (!skills.length) throw new Error(`no skills found under ${path9.join(root, "skills")}`);
|
|
1746
|
+
const out = [];
|
|
1747
|
+
for (const base of skillTargets()) {
|
|
1748
|
+
for (const legacy of LEGACY) {
|
|
1749
|
+
const l = path9.join(base, legacy);
|
|
1750
|
+
if (!skills.includes(legacy) && linksInto(l, root)) {
|
|
1751
|
+
fs11.unlinkSync(l);
|
|
1752
|
+
out.push(`\u2714 removed old link ${l}`);
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
for (const name of skills) out.push(place(path9.join(root, "skills", name), path9.join(base, name), opts));
|
|
1756
|
+
}
|
|
1757
|
+
const bundle = path9.join(root, "dist", "pitroom.mjs");
|
|
1758
|
+
if (fs11.existsSync(bundle)) {
|
|
1759
|
+
out.push(placeLauncher(bundle, root, opts.force));
|
|
1760
|
+
const onPath = (process.env.PATH ?? "").split(path9.delimiter).some((d) => path9.resolve(d) === path9.dirname(launcherPath()));
|
|
1761
|
+
if (!onPath) out.push(`! ${path9.dirname(launcherPath())} is not on PATH; add it, or run ${launcherPath()} directly`);
|
|
1762
|
+
}
|
|
1763
|
+
return out;
|
|
1764
|
+
}
|
|
1765
|
+
function uninstall() {
|
|
1766
|
+
const root = packageRoot();
|
|
1767
|
+
const out = [];
|
|
1768
|
+
for (const base of skillTargets()) {
|
|
1769
|
+
if (!fs11.existsSync(base)) continue;
|
|
1770
|
+
for (const name of fs11.readdirSync(base)) {
|
|
1771
|
+
const l = path9.join(base, name);
|
|
1772
|
+
if (linksInto(l, root)) {
|
|
1773
|
+
fs11.unlinkSync(l);
|
|
1774
|
+
out.push(`\u2714 removed ${l}`);
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
if (isOurLauncher(launcherPath(), root)) {
|
|
1779
|
+
fs11.rmSync(launcherPath(), { force: true });
|
|
1780
|
+
out.push(`\u2714 removed ${launcherPath()}`);
|
|
1781
|
+
}
|
|
1782
|
+
return out.length ? out : ["nothing to remove"];
|
|
1783
|
+
}
|
|
1784
|
+
function installedSkills() {
|
|
1785
|
+
const names = skillNames();
|
|
1786
|
+
return skillTargets().map((base) => ({ base, names: names.filter((n) => fs11.existsSync(path9.join(base, n, "SKILL.md"))) }));
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
// src/core/plan-status.ts
|
|
1790
|
+
import crypto2 from "node:crypto";
|
|
1791
|
+
import fs12 from "node:fs";
|
|
1792
|
+
import path10 from "node:path";
|
|
1793
|
+
function notesFile(plan) {
|
|
1794
|
+
const id = crypto2.createHash("sha1").update(plan.file).digest("hex").slice(0, 12);
|
|
1795
|
+
return path10.join(home(), "plans", id, "notes.md");
|
|
1796
|
+
}
|
|
1797
|
+
function readNotes(plan) {
|
|
1798
|
+
const f = notesFile(plan);
|
|
1799
|
+
if (!fs12.existsSync(f)) return [];
|
|
1800
|
+
return fs12.readFileSync(f, "utf8").split("\n").slice(1).filter(Boolean).map((l) => {
|
|
1801
|
+
const m = /^(\d{4}-\d\d-\d\d \d\d:\d\d) (.*)$/.exec(l);
|
|
1802
|
+
return m ? { at: m[1], text: m[2] } : { at: "", text: l };
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1805
|
+
function addNote(planFile, text) {
|
|
1806
|
+
const plan = loadPlan(planFile);
|
|
1807
|
+
const f = notesFile(plan);
|
|
1808
|
+
fs12.mkdirSync(path10.dirname(f), { recursive: true });
|
|
1809
|
+
if (!fs12.existsSync(f)) fs12.writeFileSync(f, `# plan: ${plan.file}
|
|
1810
|
+
`);
|
|
1811
|
+
const at = (/* @__PURE__ */ new Date()).toISOString().slice(0, 16).replace("T", " ");
|
|
1812
|
+
fs12.appendFileSync(f, `${at} ${text.replace(/\s+/g, " ").trim()}
|
|
1813
|
+
`);
|
|
1814
|
+
return f;
|
|
1815
|
+
}
|
|
1816
|
+
function planStatus(planFile) {
|
|
1817
|
+
const plan = loadPlan(planFile);
|
|
1818
|
+
const runs = [];
|
|
1819
|
+
for (const id of listRunIds()) {
|
|
1820
|
+
try {
|
|
1821
|
+
const m = freshMeta(id);
|
|
1822
|
+
if (m.plan?.file === plan.file) runs.push(m);
|
|
1823
|
+
} catch {
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
const notes = readNotes(plan);
|
|
1827
|
+
const tasks = plan.tasks.map((t) => {
|
|
1828
|
+
const impl = runs.filter((m) => m.plan.step === t.step && !m.reviewOf);
|
|
1829
|
+
const rev = runs.filter((m) => m.plan.step === t.step && m.reviewOf).at(-1);
|
|
1830
|
+
const last = impl.at(-1);
|
|
1831
|
+
return {
|
|
1832
|
+
step: t.step,
|
|
1833
|
+
title: t.title,
|
|
1834
|
+
runs: impl.length,
|
|
1835
|
+
last: last && { id: last.id, state: last.state, status: last.taskStatus },
|
|
1836
|
+
review: rev && { id: rev.id, state: rev.state, kind: rev.reviewKind ?? "task", verdict: rev.verdict },
|
|
1837
|
+
applied: impl.some((m) => m.applied || m.mode === "write" && m.state === "done" && !m.reverted && !!m.changes?.length),
|
|
1838
|
+
note: [...notes].reverse().find((n) => n.text.startsWith(`Task ${t.step}:`))?.text
|
|
1839
|
+
};
|
|
1840
|
+
});
|
|
1841
|
+
return { plan, tasks, rulings: notes.filter((n) => /\bRuling:/.test(n.text)), notesFile: notesFile(plan) };
|
|
1842
|
+
}
|
|
1843
|
+
var cell = (s, max) => {
|
|
1844
|
+
const t = s.replace(/\s+/g, " ").trim();
|
|
1845
|
+
return t.length > max ? `${t.slice(0, max - 1)}\u2026` : t;
|
|
1846
|
+
};
|
|
1847
|
+
function formatPlanStatus(s) {
|
|
1848
|
+
const head = ["TASK", "STATE", "STATUS", "REVIEW", "ROUNDS", "APPLIED", "TITLE", "NOTE"];
|
|
1849
|
+
const rows = s.tasks.map((t) => {
|
|
1850
|
+
const v = t.review?.verdict;
|
|
1851
|
+
const review = v ? `${v.spec}/${v.quality} (c${v.critical} i${v.important} m${v.minor})` : t.review?.state ?? "-";
|
|
1852
|
+
return [
|
|
1853
|
+
String(t.step),
|
|
1854
|
+
t.last?.state ?? "-",
|
|
1855
|
+
t.last?.status ?? "-",
|
|
1856
|
+
review,
|
|
1857
|
+
t.runs ? String(t.runs - 1) : "-",
|
|
1858
|
+
t.runs ? t.applied ? "yes" : "no" : "-",
|
|
1859
|
+
cell(t.title, 32),
|
|
1860
|
+
cell(t.note ?? "", 60)
|
|
1861
|
+
];
|
|
1862
|
+
});
|
|
1863
|
+
const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length)));
|
|
1864
|
+
const fmt = (r) => r.map((c, i) => i === r.length - 1 ? c : c.padEnd(widths[i])).join(" ").trimEnd();
|
|
1865
|
+
const rulings = s.rulings.length ? s.rulings.map((n) => ` ${n.at} ${n.text}`).join("\n") : " none";
|
|
1866
|
+
return [
|
|
1867
|
+
`plan ${s.plan.file} \xB7 ${s.plan.title || planName(s.plan.file)} \xB7 ${s.tasks.length} tasks`,
|
|
1868
|
+
"",
|
|
1869
|
+
fmt(head),
|
|
1870
|
+
...rows.map(fmt),
|
|
1871
|
+
"",
|
|
1872
|
+
`Rulings:
|
|
1873
|
+
${rulings}`,
|
|
1874
|
+
`notes: ${s.notesFile}`
|
|
1875
|
+
].join("\n");
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
// src/core/review.ts
|
|
1879
|
+
import crypto4 from "node:crypto";
|
|
1880
|
+
import fs14 from "node:fs";
|
|
1881
|
+
import path12 from "node:path";
|
|
1882
|
+
|
|
1883
|
+
// src/vcs/git.ts
|
|
1884
|
+
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
1885
|
+
import crypto3 from "node:crypto";
|
|
1886
|
+
import fs13 from "node:fs";
|
|
1887
|
+
import os8 from "node:os";
|
|
1888
|
+
import path11 from "node:path";
|
|
1889
|
+
var SAFE = [
|
|
1890
|
+
"-c",
|
|
1891
|
+
"color.ui=false",
|
|
1892
|
+
"-c",
|
|
1893
|
+
"core.quotepath=false",
|
|
1894
|
+
"-c",
|
|
1895
|
+
"diff.noprefix=false",
|
|
1896
|
+
"-c",
|
|
1897
|
+
"diff.mnemonicPrefix=false"
|
|
1898
|
+
];
|
|
1899
|
+
function git(cwd, args, env) {
|
|
1900
|
+
const r = spawnSync4("git", [...SAFE, ...args], {
|
|
1901
|
+
cwd,
|
|
1902
|
+
env: env ?? process.env,
|
|
1903
|
+
encoding: "utf8",
|
|
1904
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
1905
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
1906
|
+
});
|
|
1907
|
+
if (r.error) return { code: 127, stdout: "", stderr: String(r.error.message) };
|
|
1908
|
+
return { code: r.status, stdout: r.stdout ?? "", stderr: r.stderr ?? "" };
|
|
1909
|
+
}
|
|
1910
|
+
function must(cwd, args, env) {
|
|
1911
|
+
const r = git(cwd, args, env);
|
|
1912
|
+
if (r.code !== 0) throw new Error(`git ${args.join(" ")} failed: ${r.stderr.trim() || r.code}`);
|
|
1913
|
+
return r.stdout;
|
|
1914
|
+
}
|
|
1915
|
+
function gitAvailable() {
|
|
1916
|
+
return git(process.cwd(), ["--version"]).code === 0;
|
|
1917
|
+
}
|
|
1918
|
+
function repoRoot(dir) {
|
|
1919
|
+
const r = git(dir, ["rev-parse", "--show-toplevel"]);
|
|
1920
|
+
return r.code === 0 ? path11.resolve(r.stdout.trim()) : void 0;
|
|
1921
|
+
}
|
|
1922
|
+
function snapshotTree(root, exclude = []) {
|
|
1923
|
+
const tmp = path11.join(os8.tmpdir(), `pitroom-index-${process.pid}-${crypto3.randomBytes(4).toString("hex")}`);
|
|
1924
|
+
const real = path11.resolve(root, must(root, ["rev-parse", "--git-path", "index"]).trim());
|
|
1925
|
+
const env = { ...process.env, GIT_INDEX_FILE: tmp };
|
|
1926
|
+
try {
|
|
1927
|
+
if (fs13.existsSync(real)) fs13.copyFileSync(real, tmp);
|
|
1928
|
+
else must(root, ["read-tree", "--empty"], env);
|
|
1929
|
+
must(root, ["add", "-A", "--", ":/", ...exclude.map((p) => `:(top,exclude)${p}`)], env);
|
|
1930
|
+
return must(root, ["write-tree"], env).trim();
|
|
1931
|
+
} finally {
|
|
1932
|
+
fs13.rmSync(tmp, { force: true });
|
|
1933
|
+
fs13.rmSync(`${tmp}.lock`, { force: true });
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
var DIFF = ["diff", "--no-ext-diff", "--no-textconv", "--no-renames"];
|
|
1937
|
+
function diffTrees(root, a, b) {
|
|
1938
|
+
const changes = [];
|
|
1939
|
+
if (a === b) return { changes, stats: { files: 0, insertions: 0, deletions: 0 }, patch: "" };
|
|
1940
|
+
const parts = must(root, [...DIFF, "--name-status", "-z", a, b]).split("\0").filter(Boolean);
|
|
1941
|
+
for (let i = 0; i + 1 < parts.length; i += 2) changes.push({ status: parts[i], path: parts[i + 1] });
|
|
1942
|
+
let insertions = 0;
|
|
1943
|
+
let deletions = 0;
|
|
1944
|
+
for (const line of must(root, [...DIFF, "--numstat", a, b]).split("\n")) {
|
|
1945
|
+
const [ins, del] = line.split(" ");
|
|
1946
|
+
if (ins && ins !== "-") insertions += Number(ins);
|
|
1947
|
+
if (del && del !== "-") deletions += Number(del);
|
|
1948
|
+
}
|
|
1949
|
+
const patch = must(root, [...DIFF, "--binary", "--full-index", a, b]);
|
|
1950
|
+
return { changes, stats: { files: changes.length, insertions, deletions }, patch };
|
|
1951
|
+
}
|
|
1952
|
+
function createIsolatedCopy(root, tree, dest) {
|
|
1953
|
+
const objects = path11.join(path11.resolve(root, must(root, ["rev-parse", "--git-common-dir"]).trim()), "objects");
|
|
1954
|
+
fs13.mkdirSync(path11.dirname(dest), { recursive: true });
|
|
1955
|
+
must(path11.dirname(dest), ["init", "--quiet", dest]);
|
|
1956
|
+
fs13.writeFileSync(path11.join(dest, ".git", "objects", "info", "alternates"), `${objects}
|
|
1957
|
+
`);
|
|
1958
|
+
must(dest, ["read-tree", tree]);
|
|
1959
|
+
must(dest, ["checkout-index", "--all", "--force"]);
|
|
1960
|
+
}
|
|
1961
|
+
function removeIsolatedCopy(dest, ownedBy) {
|
|
1962
|
+
const rel = path11.relative(ownedBy, dest);
|
|
1963
|
+
if (!rel || rel.startsWith("..") || path11.isAbsolute(rel)) throw new Error(`refusing to remove ${dest}`);
|
|
1964
|
+
fs13.rmSync(dest, { recursive: true, force: true });
|
|
1965
|
+
}
|
|
1966
|
+
function linkIntoWorktree(root, worktree, rels) {
|
|
1967
|
+
const linked = [];
|
|
1968
|
+
for (const rel of rels) {
|
|
1969
|
+
const src = path11.join(root, rel);
|
|
1970
|
+
const dst = path11.join(worktree, rel);
|
|
1971
|
+
if (!fs13.existsSync(src) || fs13.existsSync(dst)) continue;
|
|
1972
|
+
fs13.mkdirSync(path11.dirname(dst), { recursive: true });
|
|
1973
|
+
fs13.symlinkSync(src, dst, process.platform === "win32" ? "junction" : void 0);
|
|
1974
|
+
linked.push(rel);
|
|
1975
|
+
}
|
|
1976
|
+
return linked;
|
|
1977
|
+
}
|
|
1978
|
+
function applyPatch(root, patchFile, reverse) {
|
|
1979
|
+
const flags = reverse ? ["-R"] : [];
|
|
1980
|
+
const check = git(root, ["apply", "--check", "--whitespace=nowarn", ...flags, patchFile]);
|
|
1981
|
+
if (check.code !== 0) return { ok: false, message: check.stderr.trim() };
|
|
1982
|
+
const r = git(root, ["apply", "--whitespace=nowarn", ...flags, patchFile]);
|
|
1983
|
+
return { ok: r.code === 0, message: r.stderr.trim() };
|
|
1984
|
+
}
|
|
1985
|
+
function reviewDiff(root, a, b) {
|
|
1986
|
+
return `${must(root, [...DIFF, "--stat", a, b]).trim()}
|
|
1987
|
+
|
|
1988
|
+
${must(root, [...DIFF, "-U10", a, b])}`;
|
|
1989
|
+
}
|
|
1990
|
+
function rangeDiff(root, a, b) {
|
|
1991
|
+
const range = `${a}..${b}`;
|
|
1992
|
+
const log = must(root, ["log", "--oneline", "--no-decorate", range]).trim();
|
|
1993
|
+
return [
|
|
1994
|
+
`## COMMITS
|
|
1995
|
+
|
|
1996
|
+
${log || "(none)"}`,
|
|
1997
|
+
`## FILES CHANGED
|
|
1998
|
+
|
|
1999
|
+
${must(root, [...DIFF, "--stat", range]).trim() || "(none)"}`,
|
|
2000
|
+
`## DIFF
|
|
2001
|
+
|
|
2002
|
+
${must(root, [...DIFF, "-U10", range])}`
|
|
2003
|
+
].join("\n\n");
|
|
2004
|
+
}
|
|
2005
|
+
function commitOf(root, ref) {
|
|
2006
|
+
const r = git(root, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
2007
|
+
return r.code === 0 ? r.stdout.trim() : void 0;
|
|
2008
|
+
}
|
|
2009
|
+
function gitDir(dir) {
|
|
2010
|
+
const r = git(dir, ["rev-parse", "--absolute-git-dir"]);
|
|
2011
|
+
return r.code === 0 ? r.stdout.trim() : void 0;
|
|
2012
|
+
}
|
|
2013
|
+
|
|
2014
|
+
// src/core/review.ts
|
|
2015
|
+
var TEMPLATE = { task: "task-reviewer", fix: "re-review", range: "code-reviewer" };
|
|
2016
|
+
var section = (title, body) => `## ${title}
|
|
2017
|
+
|
|
2018
|
+
${body.trim() || "(empty)"}
|
|
2019
|
+
`;
|
|
2020
|
+
function ancestors(m) {
|
|
2021
|
+
const out = [];
|
|
2022
|
+
for (let id = m.parent; id; ) {
|
|
2023
|
+
const a = readMeta(id);
|
|
2024
|
+
out.push(a);
|
|
2025
|
+
id = a.parent;
|
|
2026
|
+
}
|
|
2027
|
+
return out;
|
|
2028
|
+
}
|
|
2029
|
+
function briefOf(m) {
|
|
2030
|
+
const root = [m, ...ancestors(m)].at(-1);
|
|
2031
|
+
const f = runFile(root.id, "brief.md");
|
|
2032
|
+
return fs14.existsSync(f) ? fs14.readFileSync(f, "utf8") : root.task;
|
|
2033
|
+
}
|
|
2034
|
+
function latestReview(ids) {
|
|
2035
|
+
for (const id of listRunIds().reverse()) {
|
|
2036
|
+
try {
|
|
2037
|
+
const m = readMeta(id);
|
|
2038
|
+
if (m.reviewOf && ids.includes(m.reviewOf) && m.state === "done") return m;
|
|
2039
|
+
} catch {
|
|
2040
|
+
}
|
|
2041
|
+
}
|
|
2042
|
+
return void 0;
|
|
2043
|
+
}
|
|
2044
|
+
function diffOf(m, from = m.baseTree) {
|
|
2045
|
+
const where = m.mode === "isolate" ? m.worktree : m.repoRoot;
|
|
2046
|
+
if (where && fs14.existsSync(where) && from && m.afterTree) return reviewDiff(where, from, m.afterTree);
|
|
2047
|
+
if (from !== m.baseTree) {
|
|
2048
|
+
throw new UserError(`the isolated copy of ${m.id} is gone, so its fix round cannot be shown on its own; review a run that is not applied yet`, 3);
|
|
2049
|
+
}
|
|
2050
|
+
return fs14.readFileSync(runFile(m.id, "changes.patch"), "utf8");
|
|
2051
|
+
}
|
|
2052
|
+
function runReview(id) {
|
|
2053
|
+
const m = freshMeta(id);
|
|
2054
|
+
if (isActive(m.state)) throw new UserError(`run ${m.id} is still ${m.state}; pitroom wait ${m.id} first`, 3);
|
|
2055
|
+
if (m.reviewOf) throw new UserError(`run ${m.id} is itself a review`);
|
|
2056
|
+
if (m.mode === "read") throw new UserError(`run ${m.id} was read-only; there is no change to review`);
|
|
2057
|
+
if (!m.changes?.length) throw new UserError(`run ${m.id} made no changes; nothing to review`);
|
|
2058
|
+
const chain = ancestors(m);
|
|
2059
|
+
const previous = latestReview(chain.map((a) => a.id));
|
|
2060
|
+
const dir = m.mode === "isolate" && m.worktree && fs14.existsSync(m.worktree) ? m.cwd : m.dir;
|
|
2061
|
+
const job = { of: m.id, dir, implementer: m.ran ?? m.worker, group: m.group, plan: m.plan };
|
|
2062
|
+
const title = m.plan ? `Task ${m.plan.step}: ${m.plan.title}` : `run ${m.id}`;
|
|
2063
|
+
if (!previous) {
|
|
2064
|
+
return {
|
|
2065
|
+
...job,
|
|
2066
|
+
kind: "task",
|
|
2067
|
+
package: [`# Review package \xB7 ${title}
|
|
2068
|
+
`, section("BRIEF", briefOf(m)), section("REPORT", readSummary(m)), section("DIFF", diffOf(m))].join("\n")
|
|
2069
|
+
};
|
|
2070
|
+
}
|
|
2071
|
+
const reviewed = chain.find((a) => a.id === previous.reviewOf);
|
|
2072
|
+
const from = m.mode === "isolate" ? reviewed.afterTree : m.baseTree;
|
|
2073
|
+
return {
|
|
2074
|
+
...job,
|
|
2075
|
+
kind: "fix",
|
|
2076
|
+
package: [
|
|
2077
|
+
`# Re-review package \xB7 ${title} \xB7 fix round after review ${previous.id}
|
|
2078
|
+
`,
|
|
2079
|
+
section("BRIEF", briefOf(m)),
|
|
2080
|
+
section("PREVIOUS FINDINGS", readSummary(previous)),
|
|
2081
|
+
section("FIX REPORT", readSummary(m)),
|
|
2082
|
+
section("FIX DIFF", diffOf(m, from))
|
|
2083
|
+
].join("\n")
|
|
2084
|
+
};
|
|
2085
|
+
}
|
|
2086
|
+
function rangeReview(range, dir, planFile) {
|
|
2087
|
+
const root = repoRoot(dir);
|
|
2088
|
+
if (!root) throw new UserError("--range needs a git repository");
|
|
2089
|
+
const i = range.indexOf("..");
|
|
2090
|
+
const a = i > 0 ? range.slice(0, i) : "";
|
|
2091
|
+
const b = i > 0 ? range.slice(i + 2) || "HEAD" : "";
|
|
2092
|
+
if (!a || b.startsWith(".")) throw new UserError(`--range takes A..B (e.g. main..HEAD), not "${range}"`);
|
|
2093
|
+
for (const ref of [a, b]) if (!commitOf(root, ref)) throw new UserError(`not a commit: ${ref}`);
|
|
2094
|
+
const plan = planFile ? loadPlan(planFile) : void 0;
|
|
2095
|
+
const notes = plan ? readNotes(plan) : [];
|
|
2096
|
+
const requirements = plan ? [
|
|
2097
|
+
`Plan: ${plan.file}`,
|
|
2098
|
+
"### Global Constraints",
|
|
2099
|
+
plan.constraints || "(none stated in the plan)",
|
|
2100
|
+
"### Tasks",
|
|
2101
|
+
plan.tasks.map((t) => `- Task ${t.step}: ${t.title}`).join("\n")
|
|
2102
|
+
].join("\n\n") : "(none given; judge the change on its own terms)";
|
|
2103
|
+
return {
|
|
2104
|
+
kind: "range",
|
|
2105
|
+
of: `${a}..${b}`,
|
|
2106
|
+
dir: root,
|
|
2107
|
+
group: plan ? planName(plan.file) : void 0,
|
|
2108
|
+
package: [
|
|
2109
|
+
`# Review package \xB7 ${a}..${b}
|
|
2110
|
+
`,
|
|
2111
|
+
section("WHAT WAS IMPLEMENTED", plan ? `${plan.title}
|
|
2112
|
+
|
|
2113
|
+
${plan.header}` : "The commits below."),
|
|
2114
|
+
section("REQUIREMENTS", requirements),
|
|
2115
|
+
...notes.length ? [section("NOTES FROM EXECUTION", notes.map((n) => `- ${n.text}`).join("\n"))] : [],
|
|
2116
|
+
rangeDiff(root, a, b)
|
|
2117
|
+
].join("\n")
|
|
2118
|
+
};
|
|
2119
|
+
}
|
|
2120
|
+
function pickReviewer(job) {
|
|
2121
|
+
const eff = effective();
|
|
2122
|
+
const tiers = eff.tiers.value;
|
|
2123
|
+
if (job.kind === "range") return tiers.capable;
|
|
2124
|
+
const implementer = job.implementer;
|
|
2125
|
+
if (!implementer) return void 0;
|
|
2126
|
+
const def = parseTarget(eff.worker.value, DEFAULT_BACKEND).backend;
|
|
2127
|
+
const candidates = [tiers.standard, tiers.capable, ...eff.fallback.value, eff.worker.value].filter((s) => !!s);
|
|
2128
|
+
return candidates.find((c) => parseTarget(c, def).backend !== implementer.backend);
|
|
2129
|
+
}
|
|
2130
|
+
function writePackage(job) {
|
|
2131
|
+
const g = gitDir(job.dir);
|
|
2132
|
+
if (!g) throw new UserError(`not a git repository: ${job.dir}`);
|
|
2133
|
+
const file = path12.join(g, "pitroom", `review-${crypto4.randomBytes(4).toString("hex")}.md`);
|
|
2134
|
+
fs14.mkdirSync(path12.dirname(file), { recursive: true });
|
|
2135
|
+
fs14.writeFileSync(file, job.package);
|
|
2136
|
+
return file;
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
// src/core/templates.ts
|
|
2140
|
+
import fs15 from "node:fs";
|
|
2141
|
+
import path13 from "node:path";
|
|
2142
|
+
var FILES = {
|
|
2143
|
+
implementer: "pitroom-driven-development/implementer-prompt.md",
|
|
2144
|
+
"task-reviewer": "pitroom-driven-development/task-reviewer-prompt.md",
|
|
2145
|
+
"re-review": "pitroom-driven-development/re-review-prompt.md",
|
|
2146
|
+
"code-reviewer": "pitroom-review/code-reviewer.md"
|
|
2147
|
+
};
|
|
2148
|
+
function loadTemplate(name, root = packageRoot()) {
|
|
2149
|
+
const file = path13.join(root, "skills", FILES[name]);
|
|
2150
|
+
if (!fs15.existsSync(file)) throw new UserError(`template missing: ${file} (broken install? run pitroom doctor)`, 3);
|
|
2151
|
+
return fs15.readFileSync(file, "utf8").replace(/^\s*<!--[\s\S]*?-->\s*/, "");
|
|
2152
|
+
}
|
|
2153
|
+
function fill(template, values) {
|
|
2154
|
+
const missing = [...template.matchAll(/\{\{([A-Z_]+)\}\}/g)].map((m) => m[1]).filter((k) => !(k in values));
|
|
2155
|
+
if (missing.length) throw new UserError(`no value for template placeholder(s): ${[...new Set(missing)].join(", ")}`, 3);
|
|
2156
|
+
return template.replace(/\{\{([A-Z_]+)\}\}/g, (_, key) => values[key]);
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
// src/core/ui.ts
|
|
2160
|
+
import fs16 from "node:fs";
|
|
2161
|
+
var WEEK_MS = 7 * 24 * 3600 * 1e3;
|
|
2162
|
+
var RECENT = 40;
|
|
2163
|
+
function activeRuns() {
|
|
2164
|
+
const out = [];
|
|
2165
|
+
for (const id of listRunIds().slice(-RECENT)) {
|
|
2166
|
+
try {
|
|
2167
|
+
const m = readMeta(id);
|
|
2168
|
+
if (isActive(m.state) && (m.state === "queued" || isAlive(m.pid))) out.push(m);
|
|
2169
|
+
} catch {
|
|
2170
|
+
}
|
|
2171
|
+
}
|
|
2172
|
+
return out;
|
|
2173
|
+
}
|
|
2174
|
+
function statusLine() {
|
|
2175
|
+
const running = activeRuns();
|
|
2176
|
+
const saved = totals(readLedger(Date.now() - WEEK_MS)).saved;
|
|
2177
|
+
if (!running.length && !saved) return "";
|
|
2178
|
+
const parts = ["\u{1F3C1} pitroom"];
|
|
2179
|
+
if (running.length) {
|
|
2180
|
+
const who = [...new Set(running.map((m) => (m.ran ?? m.worker).backend))].join(", ");
|
|
2181
|
+
parts.push(`${running.length} running (${who})`);
|
|
2182
|
+
}
|
|
2183
|
+
if (saved) parts.push(`~${usd(saved)} saved this week`);
|
|
2184
|
+
return parts.join(" \xB7 ");
|
|
2185
|
+
}
|
|
2186
|
+
var RUN_ID = /\b\d{8}-\d{6}-[0-9a-f]{4}\b/g;
|
|
2187
|
+
var PITROOM_CALL = /(^|[\s;&|(`])(\S*\/)?pitroom(\.mjs)?(\s|$)/;
|
|
2188
|
+
var ICON2 = { done: "\u2714", failed: "\u2718", timeout: "\u23F1", stopped: "\u25A0" };
|
|
2189
|
+
function kind(m) {
|
|
2190
|
+
if (m.reviewOf) return m.reviewKind === "range" ? "branch review" : m.reviewKind === "fix" ? "re-review" : "review";
|
|
2191
|
+
return m.mode === "read" ? "research" : m.mode === "isolate" ? "change (isolated copy)" : "change";
|
|
2192
|
+
}
|
|
2193
|
+
var SUBJECT_MAX = 48;
|
|
2194
|
+
function short(text) {
|
|
2195
|
+
const s = text.replace(/`/g, "").replace(/^#+\s*/, "").replace(/\s+/g, " ").trim();
|
|
2196
|
+
if (s.length <= SUBJECT_MAX) return s;
|
|
2197
|
+
const cut = s.slice(0, SUBJECT_MAX);
|
|
2198
|
+
const space = cut.lastIndexOf(" ");
|
|
2199
|
+
return `${(space > SUBJECT_MAX / 2 ? cut.slice(0, space) : cut).replace(/[\s,.;:·-]+$/, "")}\u2026`;
|
|
2200
|
+
}
|
|
2201
|
+
var MODEL_MAX = 24;
|
|
2202
|
+
function workerName(t) {
|
|
2203
|
+
if (!t.model) return t.backend;
|
|
2204
|
+
const model = (t.model.split("/").pop() ?? t.model).replace(/-(contributor-)?free$/, "").replace(/-\d+\.\d+$/, "");
|
|
2205
|
+
return `${t.backend} (${model.length > MODEL_MAX ? `${model.slice(0, MODEL_MAX - 1)}\u2026` : model})`;
|
|
2206
|
+
}
|
|
2207
|
+
function what(m) {
|
|
2208
|
+
if (m.plan) return short(`Task ${m.plan.step}: ${m.plan.title}`);
|
|
2209
|
+
if (m.reviewOf) return `of ${m.reviewOf}`;
|
|
2210
|
+
const line = m.task.split("\n").find((l) => l.trim()) ?? "";
|
|
2211
|
+
const brief2 = /^You are implementing Task (\d+)\b.*?\bplan\s+(\S+)/i.exec(line);
|
|
2212
|
+
if (brief2) return short(`Task ${brief2[1]} \xB7 ${brief2[2].split("/").pop().replace(/\.md\W*$/, "")}`);
|
|
2213
|
+
return short(line);
|
|
2214
|
+
}
|
|
2215
|
+
function duration2(m) {
|
|
2216
|
+
const s = Math.round((Date.parse(m.endedAt ?? "") - Date.parse(m.startedAt)) / 1e3);
|
|
2217
|
+
if (!Number.isFinite(s) || s < 0) return "";
|
|
2218
|
+
return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s` : `${s}s`;
|
|
2219
|
+
}
|
|
2220
|
+
function cardsFor(m) {
|
|
2221
|
+
const phases = [
|
|
2222
|
+
["started", () => `\u{1F3C1} Pitroom \u25B6 ${kind(m)} on ${workerName(m.worker)} \xB7 ${what(m)} (${m.id})`]
|
|
2223
|
+
];
|
|
2224
|
+
if (!isActive(m.state)) {
|
|
2225
|
+
phases.push([
|
|
2226
|
+
"ended",
|
|
2227
|
+
() => {
|
|
2228
|
+
const v = m.verdict;
|
|
2229
|
+
const bits = [
|
|
2230
|
+
`\u{1F3C1} Pitroom ${ICON2[m.state] ?? "\u2022"} ${kind(m)} ${m.state} on ${workerName(m.ran ?? m.worker)}`,
|
|
2231
|
+
what(m),
|
|
2232
|
+
duration2(m),
|
|
2233
|
+
v && `SPEC ${v.spec.toUpperCase()} \xB7 QUALITY ${v.quality.toUpperCase()}`,
|
|
2234
|
+
m.changes?.length ? `${m.changes.length} file${m.changes.length === 1 ? "" : "s"} changed` : "",
|
|
2235
|
+
m.savedUsd ? `~${usd(m.savedUsd)} saved` : ""
|
|
2236
|
+
];
|
|
2237
|
+
return `${bits.filter(Boolean).join(" \xB7 ")} (${m.id})`;
|
|
2238
|
+
}
|
|
2239
|
+
]);
|
|
2240
|
+
}
|
|
2241
|
+
if (m.applied) {
|
|
2242
|
+
phases.push(["applied", () => `\u{1F3C1} Pitroom \u2935 applied ${m.changes?.length ?? 0} file(s) from ${m.id} to your tree`]);
|
|
2243
|
+
}
|
|
2244
|
+
const out = [];
|
|
2245
|
+
for (const [phase, text] of phases) {
|
|
2246
|
+
const marker = runFile(m.id, `card-${phase}`);
|
|
2247
|
+
if (fs16.existsSync(marker)) continue;
|
|
2248
|
+
if (!(phase === "started" && !isActive(m.state))) out.push(text());
|
|
2249
|
+
fs16.writeFileSync(marker, "");
|
|
2250
|
+
}
|
|
2251
|
+
return out;
|
|
2252
|
+
}
|
|
2253
|
+
function hookCards(input) {
|
|
2254
|
+
const event = JSON.parse(input);
|
|
2255
|
+
const command = event.tool_input?.command ?? "";
|
|
2256
|
+
if (event.tool_name !== "Bash" || !PITROOM_CALL.test(command)) return "";
|
|
2257
|
+
const response = event.tool_response;
|
|
2258
|
+
const output = typeof response === "string" ? response : [response?.stdout, response?.stderr].filter(Boolean).join("\n");
|
|
2259
|
+
const ids = [...new Set(`${command}
|
|
2260
|
+
${output}`.match(RUN_ID) ?? [])].filter((id) => fs16.existsSync(`${runsDir()}/${id}`));
|
|
2261
|
+
const cards = [];
|
|
2262
|
+
for (const id of ids) {
|
|
2263
|
+
try {
|
|
2264
|
+
cards.push(...cardsFor(readMeta(id)));
|
|
2265
|
+
} catch {
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
return cards.join("\n");
|
|
2269
|
+
}
|
|
2270
|
+
|
|
2271
|
+
// src/core/run.ts
|
|
2272
|
+
import { spawn as spawn2, spawnSync as spawnSync5 } from "node:child_process";
|
|
2273
|
+
import fs21 from "node:fs";
|
|
2274
|
+
import path17 from "node:path";
|
|
2275
|
+
|
|
2276
|
+
// src/core/chain.ts
|
|
2277
|
+
function resolveChain(flags = {}) {
|
|
2278
|
+
const warnings = [];
|
|
2279
|
+
let spec = flags.worker;
|
|
2280
|
+
if (!spec && flags.tier) {
|
|
2281
|
+
const tiers = effective().tiers.value;
|
|
2282
|
+
spec = Object.hasOwn(tiers, flags.tier) ? tiers[flags.tier] : void 0;
|
|
2283
|
+
if (!spec) warnings.push(`tier "${flags.tier}" is not configured (config "tiers"); using the default worker`);
|
|
2284
|
+
}
|
|
2285
|
+
const eff = effective({ worker: spec, model: flags.model });
|
|
2286
|
+
const models = eff.models.value;
|
|
2287
|
+
const withDefault = (t) => t.model || !models[t.backend] ? t : { ...t, model: models[t.backend] };
|
|
2288
|
+
let worker = parseTarget(eff.worker.value, DEFAULT_BACKEND);
|
|
2289
|
+
if (eff.model.value) worker = { ...worker, model: eff.model.value };
|
|
2290
|
+
worker = withDefault(worker);
|
|
2291
|
+
const fallback = [];
|
|
2292
|
+
if (!flags.noFallback) {
|
|
2293
|
+
for (const s of eff.fallback.value) {
|
|
2294
|
+
const t = withDefault(parseTarget(s, worker.backend));
|
|
2295
|
+
try {
|
|
2296
|
+
getBackend(t.backend);
|
|
2297
|
+
fallback.push(t);
|
|
2298
|
+
} catch (e) {
|
|
2299
|
+
warnings.push(`fallback ${s} skipped: ${e.message}`);
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
return { worker, fallback, warnings };
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
// src/core/process.ts
|
|
2307
|
+
import { spawn } from "node:child_process";
|
|
2308
|
+
import fs18 from "node:fs";
|
|
2309
|
+
|
|
2310
|
+
// src/vcs/guard.ts
|
|
2311
|
+
import fs17 from "node:fs";
|
|
2312
|
+
import path14 from "node:path";
|
|
2313
|
+
var VERSION = 1;
|
|
2314
|
+
var SHIM = `#!/bin/sh
|
|
2315
|
+
# pitroom git guard v${VERSION}. Blocks git commands that change history, refs, the
|
|
2316
|
+
# index or discard work. The primary agent reviews and commits, not the worker.
|
|
2317
|
+
set -f
|
|
2318
|
+
real="\${PITROOM_REAL_GIT:-}"
|
|
2319
|
+
if [ -z "$real" ] || [ ! -x "$real" ]; then
|
|
2320
|
+
echo "pitroom: git guard cannot find the real git" >&2
|
|
2321
|
+
exit 127
|
|
2322
|
+
fi
|
|
2323
|
+
block() {
|
|
2324
|
+
echo "pitroom: 'git $1' is blocked for workers (the primary agent reviews and commits)" >&2
|
|
2325
|
+
exit 1
|
|
2326
|
+
}
|
|
2327
|
+
sub=""; skip=""; after=""
|
|
2328
|
+
for a do
|
|
2329
|
+
if [ -n "$sub" ]; then after="$after $a"; continue; fi
|
|
2330
|
+
case "$skip" in
|
|
2331
|
+
c) skip=""; case "$a" in [Aa][Ll][Ii][Aa][Ss].*) block "-c $a" ;; esac; continue ;;
|
|
2332
|
+
v) skip=""; continue ;;
|
|
2333
|
+
esac
|
|
2334
|
+
case "$a" in
|
|
2335
|
+
-c|--config-env) skip=c ;;
|
|
2336
|
+
-C|--git-dir|--work-tree|--namespace|--super-prefix|--exec-path) skip=v ;;
|
|
2337
|
+
-*) ;;
|
|
2338
|
+
*) sub="$a" ;;
|
|
2339
|
+
esac
|
|
2340
|
+
done
|
|
2341
|
+
[ -z "$sub" ] && exec "$real" "$@"
|
|
2342
|
+
|
|
2343
|
+
# Resolve aliases (also those injected via GIT_CONFIG_* env); shell aliases are refused.
|
|
2344
|
+
depth=0
|
|
2345
|
+
while def=$("$real" config --get "alias.$sub" 2>/dev/null) && [ -n "$def" ]; do
|
|
2346
|
+
depth=$((depth+1)); [ "$depth" -gt 5 ] && block "$sub (alias loop)"
|
|
2347
|
+
case "$def" in '!'*) block "$sub (shell alias)" ;; -*) block "$sub (alias with options)" ;; esac
|
|
2348
|
+
next=\${def%% *}
|
|
2349
|
+
[ "$next" != "$def" ] && after=" \${def#* }$after"
|
|
2350
|
+
sub="$next"
|
|
2351
|
+
done
|
|
2352
|
+
|
|
2353
|
+
has() { for f in "$@"; do case " $after " in *" $f "*|*" $f="*) return 0 ;; esac; done; return 1; }
|
|
2354
|
+
count() { n=0; for w in $after; do case "$w" in -*) ;; *) n=$((n+1)) ;; esac; done; echo "$n"; }
|
|
2355
|
+
|
|
2356
|
+
case "$sub" in
|
|
2357
|
+
commit|push|pull|reset|checkout|restore|clean|stash|rebase|merge|switch|cherry-pick|revert|am|\\
|
|
2358
|
+
worktree|update-ref|update-index|filter-branch|filter-repo|gc|prune|replace|notes|add|rm|mv|\\
|
|
2359
|
+
sparse-checkout|read-tree|commit-tree|write-tree|fast-import|bisect)
|
|
2360
|
+
block "$sub" ;;
|
|
2361
|
+
branch)
|
|
2362
|
+
has -d -D --delete -m -M --move -c -C --copy -f --force -u --set-upstream-to --unset-upstream --edit-description && block "branch$after"
|
|
2363
|
+
[ "$(count)" -gt 0 ] && ! has -l --list --contains --no-contains --merged --no-merged --points-at && block "branch$after" ;;
|
|
2364
|
+
tag)
|
|
2365
|
+
has -d --delete -a --annotate -s --sign -u --local-user -f --force -m --message -F --file && block "tag$after"
|
|
2366
|
+
[ "$(count)" -gt 0 ] && ! has -l --list --contains --no-contains --merged --no-merged --points-at && block "tag$after" ;;
|
|
2367
|
+
config)
|
|
2368
|
+
has --unset --unset-all --add --replace-all --rename-section --remove-section -e --edit set unset rename-section remove-section edit && block "config$after"
|
|
2369
|
+
[ "$(count)" -ge 2 ] && ! has get --get --get-all --get-regexp --get-urlmatch && block "config$after" ;;
|
|
2370
|
+
remote)
|
|
2371
|
+
for w in $after; do case "$w" in add|remove|rm|rename|set-url|set-head|set-branches|prune|update) block "remote $w" ;; esac; done ;;
|
|
2372
|
+
fetch)
|
|
2373
|
+
for w in $after; do case "$w" in -*) ;; *:*) block "fetch $w (writes a local ref)" ;; esac; done ;;
|
|
2374
|
+
apply)
|
|
2375
|
+
has --index --cached -3 --3way && block "apply$after" ;;
|
|
2376
|
+
reflog)
|
|
2377
|
+
has expire delete && block "reflog$after" ;;
|
|
2378
|
+
symbolic-ref)
|
|
2379
|
+
has -d --delete && block "symbolic-ref$after"
|
|
2380
|
+
[ "$(count)" -ge 2 ] && block "symbolic-ref$after" ;;
|
|
2381
|
+
submodule)
|
|
2382
|
+
for w in $after; do case "$w" in -*) ;; status|summary) break ;; *) block "submodule $w" ;; esac; done ;;
|
|
2383
|
+
esac
|
|
2384
|
+
exec "$real" "$@"
|
|
2385
|
+
`;
|
|
2386
|
+
function shimDir() {
|
|
2387
|
+
return path14.join(home(), "shim", `v${VERSION}`);
|
|
2388
|
+
}
|
|
2389
|
+
function findRealGit(skip) {
|
|
2390
|
+
for (const dir of (process.env.PATH ?? "").split(path14.delimiter).filter(Boolean)) {
|
|
2391
|
+
if (path14.resolve(dir) === skip) continue;
|
|
2392
|
+
const p = path14.join(dir, "git");
|
|
2393
|
+
try {
|
|
2394
|
+
fs17.accessSync(p, fs17.constants.X_OK);
|
|
2395
|
+
if (fs17.statSync(p).isFile()) return p;
|
|
2396
|
+
} catch {
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
return void 0;
|
|
2400
|
+
}
|
|
2401
|
+
var HOOK_VERSION = 1;
|
|
2402
|
+
var REF_HOOK = `#!/bin/sh
|
|
2403
|
+
# pitroom git guard (layer 2, v${HOOK_VERSION}): refuse ref updates made by workers.
|
|
2404
|
+
[ "$1" = prepared ] || exit 0
|
|
2405
|
+
echo "pitroom: git ref updates (commit, reset, branch, tag, stash, rebase, merge) are blocked for workers" >&2
|
|
2406
|
+
exit 1
|
|
2407
|
+
`;
|
|
2408
|
+
var hooksDir = () => path14.join(home(), "git-hooks", `v${HOOK_VERSION}`);
|
|
2409
|
+
function writeIfChanged(file, content) {
|
|
2410
|
+
if (fs17.existsSync(file) && fs17.readFileSync(file, "utf8") === content) return;
|
|
2411
|
+
fs17.mkdirSync(path14.dirname(file), { recursive: true });
|
|
2412
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
2413
|
+
fs17.writeFileSync(tmp, content, { mode: 493 });
|
|
2414
|
+
fs17.renameSync(tmp, file);
|
|
2415
|
+
}
|
|
2416
|
+
function withGitConfig(env, entries) {
|
|
2417
|
+
const out = { ...env };
|
|
2418
|
+
let n = Number(env.GIT_CONFIG_COUNT) || 0;
|
|
2419
|
+
for (const [key, value] of entries) {
|
|
2420
|
+
out[`GIT_CONFIG_KEY_${n}`] = key;
|
|
2421
|
+
out[`GIT_CONFIG_VALUE_${n}`] = value;
|
|
2422
|
+
n++;
|
|
2423
|
+
}
|
|
2424
|
+
out.GIT_CONFIG_COUNT = String(n);
|
|
2425
|
+
return out;
|
|
2426
|
+
}
|
|
2427
|
+
function guardEnv(env) {
|
|
2428
|
+
const quiet = {
|
|
2429
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
2430
|
+
GIT_EDITOR: "true",
|
|
2431
|
+
GIT_SEQUENCE_EDITOR: "true",
|
|
2432
|
+
GIT_PAGER: "cat",
|
|
2433
|
+
PAGER: "cat"
|
|
2434
|
+
};
|
|
2435
|
+
if (process.platform === "win32") return { ...env, ...quiet };
|
|
2436
|
+
writeIfChanged(path14.join(hooksDir(), "reference-transaction"), REF_HOOK);
|
|
2437
|
+
env = withGitConfig(env, [
|
|
2438
|
+
["core.hooksPath", hooksDir()],
|
|
2439
|
+
["url.pitroom-push-blocked://.pushInsteadOf", ""]
|
|
2440
|
+
]);
|
|
2441
|
+
const dir = shimDir();
|
|
2442
|
+
const real = findRealGit(dir);
|
|
2443
|
+
if (!real) return { ...env, ...quiet };
|
|
2444
|
+
writeIfChanged(path14.join(dir, "git"), SHIM);
|
|
2445
|
+
return { ...env, ...quiet, PITROOM_REAL_GIT: real, PATH: `${dir}${path14.delimiter}${env.PATH ?? ""}` };
|
|
2446
|
+
}
|
|
2447
|
+
|
|
2448
|
+
// src/core/process.ts
|
|
2449
|
+
async function spawnWorker(inv, opts) {
|
|
2450
|
+
const out = fs18.openSync(opts.stdoutFile, "w");
|
|
2451
|
+
const err = fs18.openSync(opts.stderrFile, "w");
|
|
2452
|
+
const res = { code: null, timedOut: false, stopped: false };
|
|
2453
|
+
const child = spawn(inv.command, inv.args, {
|
|
2454
|
+
cwd: opts.cwd,
|
|
2455
|
+
stdio: ["ignore", out, err],
|
|
2456
|
+
// OpenCode v2 takes its workspace from $PWD, not the process cwd: without this a worker
|
|
2457
|
+
// started in an isolated copy would read and edit the directory pitroom was launched from.
|
|
2458
|
+
env: guardEnv({ ...process.env, ...inv.env, PWD: opts.cwd, PITROOM_ACTIVE: "1" })
|
|
2459
|
+
});
|
|
2460
|
+
const stop = () => {
|
|
2461
|
+
res.stopped = true;
|
|
2462
|
+
child.kill("SIGTERM");
|
|
2463
|
+
};
|
|
2464
|
+
process.once("SIGINT", stop);
|
|
2465
|
+
process.once("SIGTERM", stop);
|
|
2466
|
+
let killer;
|
|
2467
|
+
const timer = setTimeout(() => {
|
|
2468
|
+
res.timedOut = true;
|
|
2469
|
+
child.kill("SIGTERM");
|
|
2470
|
+
killer = setTimeout(() => child.kill("SIGKILL"), 1e4);
|
|
2471
|
+
}, opts.timeoutSec * 1e3);
|
|
2472
|
+
res.code = await new Promise((resolve2) => {
|
|
2473
|
+
child.on("error", (e) => {
|
|
2474
|
+
res.spawnError = e.code === "ENOENT" ? `${inv.command} not found` : e.message;
|
|
2475
|
+
resolve2(127);
|
|
2476
|
+
});
|
|
2477
|
+
child.on("close", (c) => resolve2(c));
|
|
2478
|
+
});
|
|
2479
|
+
clearTimeout(timer);
|
|
2480
|
+
if (killer) clearTimeout(killer);
|
|
2481
|
+
process.off("SIGINT", stop);
|
|
2482
|
+
process.off("SIGTERM", stop);
|
|
2483
|
+
fs18.closeSync(out);
|
|
2484
|
+
fs18.closeSync(err);
|
|
2485
|
+
return res;
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
// src/core/answers.ts
|
|
2489
|
+
var TASK_STATUSES = ["DONE", "DONE_WITH_CONCERNS", "NEEDS_CONTEXT", "BLOCKED"];
|
|
2490
|
+
function parseStatus(text) {
|
|
2491
|
+
const value = /^\s*STATUS:\s*([A-Za-z_]+)/im.exec(text)?.[1]?.toUpperCase() ?? "";
|
|
2492
|
+
return TASK_STATUSES.includes(value) ? value : "unknown";
|
|
2493
|
+
}
|
|
2494
|
+
function parseVerdict(text) {
|
|
2495
|
+
const line = /^\s*SUMMARY:.*\bSPEC:.*$/im.exec(text)?.[0] ?? /^.*\bSPEC:.*$/im.exec(text)?.[0] ?? "";
|
|
2496
|
+
const spec = /\bSPEC:\s*(PASS|FAIL)\b/i.exec(line)?.[1]?.toLowerCase();
|
|
2497
|
+
const quality = /\bQUALITY:\s*(APPROVED|NEEDS[_ -]?FIXES)\b/i.exec(line)?.[1]?.toUpperCase();
|
|
2498
|
+
const count = (k) => Number(new RegExp(`\\b${k}=(\\d+)`, "i").exec(line)?.[1] ?? 0);
|
|
2499
|
+
return {
|
|
2500
|
+
spec: spec === "pass" || spec === "fail" ? spec : "unknown",
|
|
2501
|
+
quality: quality === "APPROVED" ? "approved" : quality ? "needs-fixes" : "unknown",
|
|
2502
|
+
critical: count("critical"),
|
|
2503
|
+
important: count("important"),
|
|
2504
|
+
minor: count("minor")
|
|
2505
|
+
};
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
// src/core/slots.ts
|
|
2509
|
+
import crypto5 from "node:crypto";
|
|
2510
|
+
import fs19 from "node:fs";
|
|
2511
|
+
import path15 from "node:path";
|
|
2512
|
+
var slotsDir = () => path15.join(home(), "slots");
|
|
2513
|
+
var locksDir = () => path15.join(home(), "locks");
|
|
2514
|
+
var STARTUP_GRACE_MS = 3e4;
|
|
2515
|
+
function holderActive(runId, self) {
|
|
2516
|
+
if (!runId || runId === self) return false;
|
|
2517
|
+
try {
|
|
2518
|
+
const m = freshMeta(runId);
|
|
2519
|
+
if (!isActive(m.state)) return false;
|
|
2520
|
+
if (m.pid) return isAlive(m.pid);
|
|
2521
|
+
return Date.now() - Date.parse(m.startedAt) < STARTUP_GRACE_MS;
|
|
2522
|
+
} catch {
|
|
2523
|
+
return false;
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
function tryClaim(file, runId) {
|
|
2527
|
+
fs19.mkdirSync(path15.dirname(file), { recursive: true });
|
|
2528
|
+
try {
|
|
2529
|
+
fs19.writeFileSync(file, runId, { flag: "wx" });
|
|
2530
|
+
return true;
|
|
2531
|
+
} catch (e) {
|
|
2532
|
+
if (e.code !== "EEXIST") throw e;
|
|
2533
|
+
}
|
|
2534
|
+
let owner = "";
|
|
2535
|
+
try {
|
|
2536
|
+
owner = fs19.readFileSync(file, "utf8").trim();
|
|
2537
|
+
} catch {
|
|
2538
|
+
}
|
|
2539
|
+
if (owner === runId) return true;
|
|
2540
|
+
if (holderActive(owner, runId)) return false;
|
|
2541
|
+
fs19.rmSync(file, { force: true });
|
|
2542
|
+
try {
|
|
2543
|
+
fs19.writeFileSync(file, runId, { flag: "wx" });
|
|
2544
|
+
return true;
|
|
2545
|
+
} catch {
|
|
2546
|
+
return false;
|
|
2547
|
+
}
|
|
2548
|
+
}
|
|
2549
|
+
function releaseIfOwner(file, runId) {
|
|
2550
|
+
if (!file) return;
|
|
2551
|
+
try {
|
|
2552
|
+
if (fs19.readFileSync(file, "utf8").trim() === runId) fs19.rmSync(file, { force: true });
|
|
2553
|
+
} catch {
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
function tryAcquireSlot(runId, maxParallel) {
|
|
2557
|
+
for (let n = 0; n < Math.max(1, maxParallel); n++) {
|
|
2558
|
+
const file = path15.join(slotsDir(), `slot-${n}`);
|
|
2559
|
+
if (tryClaim(file, runId)) return file;
|
|
2560
|
+
}
|
|
2561
|
+
return void 0;
|
|
2562
|
+
}
|
|
2563
|
+
var releaseSlot = (file, runId) => releaseIfOwner(file, runId);
|
|
2564
|
+
var lockFile = (repoRoot2) => path15.join(locksDir(), `write-${crypto5.createHash("sha1").update(path15.resolve(repoRoot2)).digest("hex").slice(0, 16)}`);
|
|
2565
|
+
function acquireWriteLock(repoRoot2, runId) {
|
|
2566
|
+
const file = lockFile(repoRoot2);
|
|
2567
|
+
if (tryClaim(file, runId)) return;
|
|
2568
|
+
let owner = "";
|
|
2569
|
+
try {
|
|
2570
|
+
owner = fs19.readFileSync(file, "utf8").trim();
|
|
2571
|
+
} catch {
|
|
2572
|
+
}
|
|
2573
|
+
throw new UserError(`another --write run (${owner}) is active in this repo; use --isolate for parallel changes`, 3);
|
|
2574
|
+
}
|
|
2575
|
+
var releaseWriteLock = (repoRoot2, runId) => releaseIfOwner(lockFile(repoRoot2), runId);
|
|
2576
|
+
|
|
2577
|
+
// src/core/prompt.ts
|
|
2578
|
+
var RULES = {
|
|
2579
|
+
read: ["READ-ONLY task: do not create, modify or delete any file. Read, search and analyse only."],
|
|
2580
|
+
write: [
|
|
2581
|
+
"You may edit files that the task requires. Make the smallest correct change; no drive-by refactors.",
|
|
2582
|
+
"Run the relevant tests, type checks or linters if they are cheap and available, and report the result."
|
|
2583
|
+
],
|
|
2584
|
+
isolate: [
|
|
2585
|
+
"You are in an isolated copy of the project. Edit files as the task requires; the primary agent reviews your diff before anything reaches the real tree.",
|
|
2586
|
+
"Make the smallest correct change; no drive-by refactors. Dependency folders (node_modules, virtualenvs, build output) may be missing unless linked.",
|
|
2587
|
+
"Run the relevant tests, type checks or linters if they are cheap and available, and report the result."
|
|
2588
|
+
]
|
|
2589
|
+
};
|
|
2590
|
+
function buildPrompt(mode, task, followUp) {
|
|
2591
|
+
if (followUp) return `Follow-up from the primary agent. Same rules and answer format as before.
|
|
2592
|
+
|
|
2593
|
+
${task}`;
|
|
2594
|
+
return `You are a worker agent. A primary coding agent delegated this bounded task to you. Your answer is draft work that the primary agent will verify, and it is the only thing the primary sees, so make it self-contained.
|
|
2595
|
+
|
|
2596
|
+
Rules:
|
|
2597
|
+
- ${RULES[mode].join("\n- ")}
|
|
2598
|
+
- Stay inside the task scope and the project directory.
|
|
2599
|
+
- Never commit, push, reset, checkout, restore, stash, clean, rebase or merge, and never discard or overwrite uncommitted work you did not create.
|
|
2600
|
+
- Never read or reveal secrets (.env files, keys, tokens).
|
|
2601
|
+
- Do not ask questions. If something is ambiguous, choose the safest reasonable interpretation and state the assumption.
|
|
2602
|
+
- Be economical: open only what you need.
|
|
2603
|
+
|
|
2604
|
+
End with this answer format (plain text, no preamble):
|
|
2605
|
+
SUMMARY: 1-5 lines that directly answer the task.
|
|
2606
|
+
DETAILS: key findings with file:line references; only what the primary agent needs.
|
|
2607
|
+
FILES CHANGED: paths, or "none".
|
|
2608
|
+
VERIFICATION: commands you ran and their results, or "not run".
|
|
2609
|
+
OPEN ISSUES: risks, uncertainties, follow-ups, or "none".
|
|
2610
|
+
|
|
2611
|
+
Task:
|
|
2612
|
+
${task}`;
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
// src/core/refs.ts
|
|
2616
|
+
import fs20 from "node:fs";
|
|
2617
|
+
import path16 from "node:path";
|
|
2618
|
+
var EXTENSIONLESS = "Makefile|Dockerfile|Containerfile|Gemfile|Rakefile|Procfile|Justfile|Vagrantfile|BUILD|WORKSPACE";
|
|
2619
|
+
var REF = new RegExp(
|
|
2620
|
+
String.raw`(?<![\w/:.-])(\.{0,2}/?(?:[\w@.+-]+/)*(?:[\w@+-][\w@.+-]*\.[A-Za-z][A-Za-z0-9]{0,7}|${EXTENSIONLESS})):(\d+)(?:[-–](\d+))?`,
|
|
2621
|
+
"g"
|
|
2622
|
+
);
|
|
2623
|
+
var IDENT = /`([A-Za-z_$][\w$]*)(?:\([^`]*\))?`/g;
|
|
2624
|
+
var CLAUSE = /[;|—]|\.\s|:\s/g;
|
|
2625
|
+
var MAX_BYTES = 5 * 1024 * 1024;
|
|
2626
|
+
var WINDOW = 5;
|
|
2627
|
+
var DEFINITION_LOOKBACK = 80;
|
|
2628
|
+
function looksLikeSymbol(m) {
|
|
2629
|
+
const name = m[1];
|
|
2630
|
+
return m[0].includes("(") || /[A-Z_$0-9]/.test(name) || name.length >= 5;
|
|
2631
|
+
}
|
|
2632
|
+
function extractRefs(answer) {
|
|
2633
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2634
|
+
for (const line of answer.split("\n")) {
|
|
2635
|
+
const matches2 = [...line.matchAll(REF)];
|
|
2636
|
+
const seps = [...line.matchAll(CLAUSE)].map((s) => s.index ?? 0);
|
|
2637
|
+
const sepBetween = (a, b) => seps.some((s) => s >= a && s < b);
|
|
2638
|
+
const idents = [...line.matchAll(IDENT)].filter((i) => {
|
|
2639
|
+
if (!looksLikeSymbol(i)) return false;
|
|
2640
|
+
const pos = i.index ?? 0;
|
|
2641
|
+
const before = matches2.filter((m) => (m.index ?? 0) + m[0].length <= pos).at(-1);
|
|
2642
|
+
const after = matches2.find((m) => (m.index ?? 0) >= pos + i[0].length);
|
|
2643
|
+
return !(before && after && !sepBetween((before.index ?? 0) + before[0].length, after.index ?? 0));
|
|
2644
|
+
});
|
|
2645
|
+
matches2.forEach((m, k) => {
|
|
2646
|
+
const start = Number(m[2]);
|
|
2647
|
+
const end = m[3] ? Number(m[3]) : start;
|
|
2648
|
+
if (start < 1 || end < start) return;
|
|
2649
|
+
const at = m.index ?? 0;
|
|
2650
|
+
const prev = matches2[k - 1];
|
|
2651
|
+
const next = matches2[k + 1];
|
|
2652
|
+
const from = Math.max(at - 80, prev ? (prev.index ?? 0) + prev[0].length : 0, ...seps.filter((s) => s < at).map((s) => s + 1));
|
|
2653
|
+
const to = Math.min(at + m[0].length + 40, next?.index ?? Infinity, ...seps.filter((s) => s >= at + m[0].length));
|
|
2654
|
+
const near = new Set(idents.filter((i) => (i.index ?? 0) >= from && (i.index ?? 0) < to).map((i) => i[1]));
|
|
2655
|
+
const symbol = near.size === 1 ? [...near][0] : void 0;
|
|
2656
|
+
const key = `${m[1]}:${start}-${end}:${symbol ?? ""}`;
|
|
2657
|
+
if (!seen.has(key)) seen.set(key, { text: m[0], file: m[1], start, end, symbol });
|
|
2658
|
+
});
|
|
2659
|
+
}
|
|
2660
|
+
return [...seen.values()];
|
|
2661
|
+
}
|
|
2662
|
+
function verifyRefs(refs, dirs) {
|
|
2663
|
+
const roots = [...new Set(dirs.filter(Boolean).map((d) => path16.resolve(d)))];
|
|
2664
|
+
const invalid = [];
|
|
2665
|
+
const lineCache = /* @__PURE__ */ new Map();
|
|
2666
|
+
const load = (file) => {
|
|
2667
|
+
if (!lineCache.has(file)) {
|
|
2668
|
+
const size = fs20.statSync(file).size;
|
|
2669
|
+
const lines = size > MAX_BYTES ? null : fs20.readFileSync(file, "utf8").split("\n");
|
|
2670
|
+
if (lines && lines[lines.length - 1] === "") lines.pop();
|
|
2671
|
+
lineCache.set(file, lines);
|
|
2672
|
+
}
|
|
2673
|
+
return lineCache.get(file);
|
|
2674
|
+
};
|
|
2675
|
+
for (const ref of refs) {
|
|
2676
|
+
const file = resolve(ref.file, roots);
|
|
2677
|
+
if (!file) {
|
|
2678
|
+
invalid.push({ ref: ref.text, reason: path16.isAbsolute(ref.file) && !inside(ref.file, roots) ? "outside the project" : "file not found" });
|
|
2679
|
+
continue;
|
|
2680
|
+
}
|
|
2681
|
+
const lines = load(file);
|
|
2682
|
+
if (!lines) continue;
|
|
2683
|
+
if (ref.end > lines.length) {
|
|
2684
|
+
invalid.push({ ref: ref.text, reason: `file has ${lines.length} lines` });
|
|
2685
|
+
continue;
|
|
2686
|
+
}
|
|
2687
|
+
if (ref.symbol && !mentions(lines, ref) && !enclosedBy(lines, ref)) {
|
|
2688
|
+
invalid.push({ ref: ref.text, reason: `\`${ref.symbol}\` not near line ${ref.start}` });
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
return { total: refs.length, valid: refs.length - invalid.length, invalid };
|
|
2692
|
+
}
|
|
2693
|
+
function mentions(lines, ref) {
|
|
2694
|
+
const near = lines.slice(Math.max(0, ref.start - 1 - WINDOW), ref.end + WINDOW).join("\n");
|
|
2695
|
+
return new RegExp(`(^|[^\\w$])${escape(ref.symbol)}([^\\w$]|$)`).test(near);
|
|
2696
|
+
}
|
|
2697
|
+
function enclosedBy(lines, ref) {
|
|
2698
|
+
const name = escape(ref.symbol);
|
|
2699
|
+
const def = new RegExp(
|
|
2700
|
+
`\\b(function|def|fn|func|class|interface|struct|impl|type|const|let|var)\\s+\\*?${name}\\b|^\\s*(export\\s+)?(default\\s+)?(async\\s+)?(static\\s+)?${name}\\s*[(:=]`
|
|
2701
|
+
);
|
|
2702
|
+
return lines.slice(Math.max(0, ref.start - 1 - DEFINITION_LOOKBACK), ref.start).some((l) => def.test(l));
|
|
2703
|
+
}
|
|
2704
|
+
function resolve(ref, roots) {
|
|
2705
|
+
const candidates = path16.isAbsolute(ref) ? inside(ref, roots) ? [ref] : [] : roots.map((r) => path16.join(r, ref));
|
|
2706
|
+
return candidates.find((c) => {
|
|
2707
|
+
try {
|
|
2708
|
+
return fs20.statSync(c).isFile() && inside(c, roots);
|
|
2709
|
+
} catch {
|
|
2710
|
+
return false;
|
|
2711
|
+
}
|
|
2712
|
+
});
|
|
2713
|
+
}
|
|
2714
|
+
function inside(file, roots) {
|
|
2715
|
+
return roots.some((r) => {
|
|
2716
|
+
const rel = path16.relative(r, path16.resolve(file));
|
|
2717
|
+
return rel !== "" && !rel.startsWith("..") && !path16.isAbsolute(rel);
|
|
2718
|
+
});
|
|
2719
|
+
}
|
|
2720
|
+
var escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2721
|
+
|
|
2722
|
+
// src/core/run.ts
|
|
2723
|
+
var VERSION2 = true ? "0.6.0" : "0.0.0-dev";
|
|
2724
|
+
function planWork(o) {
|
|
2725
|
+
if (!o.plan) return { task: o.task.trim(), tier: o.tier };
|
|
2726
|
+
if (o.continueFrom) throw new UserError("a follow-up continues its parent's task; drop --plan/--step");
|
|
2727
|
+
if (o.mode === "read") throw new UserError("--plan runs implement a task: add -i (an isolated copy, recommended) or -w");
|
|
2728
|
+
const plan = loadPlan(o.plan.file);
|
|
2729
|
+
const t = planTask(plan, o.plan.step);
|
|
2730
|
+
const text = brief(plan, t);
|
|
2731
|
+
const task = fill(loadTemplate("implementer"), {
|
|
2732
|
+
PLAN_FILE: plan.file,
|
|
2733
|
+
STEP: String(t.step),
|
|
2734
|
+
TITLE: t.title,
|
|
2735
|
+
BRIEF: text.trim(),
|
|
2736
|
+
NOTES: o.task.trim() || "(none)"
|
|
2737
|
+
});
|
|
2738
|
+
const tier = o.tier ?? (o.worker ? void 0 : t.tier);
|
|
2739
|
+
return { task, tier, plan: { file: plan.file, step: t.step, title: t.title }, brief: text };
|
|
2740
|
+
}
|
|
2741
|
+
function prepareRun(o) {
|
|
2742
|
+
if (process.env.PITROOM_ACTIVE === "1") {
|
|
2743
|
+
throw new UserError("refusing to delegate from inside a Pitroom worker (no recursive delegation)", 3);
|
|
2744
|
+
}
|
|
2745
|
+
const work = planWork(o);
|
|
2746
|
+
if (!work.task) throw new UserError("empty task");
|
|
2747
|
+
const warnings = [];
|
|
2748
|
+
let parent;
|
|
2749
|
+
let worker;
|
|
2750
|
+
let fallback;
|
|
2751
|
+
if (o.continueFrom) {
|
|
2752
|
+
parent = readMeta(o.continueFrom);
|
|
2753
|
+
if (o.worker || o.tier) throw new UserError("a follow-up runs on the same worker as its parent; drop --worker/--tier");
|
|
2754
|
+
if (!parent.sessionId) throw new UserError(`run ${parent.id} has no worker session to continue`, 3);
|
|
2755
|
+
if (parent.mode === "isolate" && (!parent.worktree || !fs21.existsSync(parent.worktree))) {
|
|
2756
|
+
throw new UserError(`the isolated copy of run ${parent.id} is gone (applied or discarded)`, 3);
|
|
2757
|
+
}
|
|
2758
|
+
const ran = parent.ran ?? parent.worker;
|
|
2759
|
+
worker = o.model ? { ...ran, model: o.model } : ran;
|
|
2760
|
+
fallback = o.noFallback ? [] : parent.fallback.filter((t) => t.backend === ran.backend);
|
|
2761
|
+
} else {
|
|
2762
|
+
const chain = resolveChain({ worker: o.worker, model: o.model, tier: work.tier, noFallback: o.noFallback });
|
|
2763
|
+
({ worker, fallback } = chain);
|
|
2764
|
+
warnings.push(...chain.warnings);
|
|
2765
|
+
}
|
|
2766
|
+
const backend = getBackend(worker.backend);
|
|
2767
|
+
if (parent && backend.capabilities.resume === "none") {
|
|
2768
|
+
throw new UserError(`the ${backend.name} worker cannot continue sessions`, 3);
|
|
2769
|
+
}
|
|
2770
|
+
if (o.files.length && !backend.capabilities.attachFiles) {
|
|
2771
|
+
throw new UserError(`the ${backend.name} worker cannot attach files; put the content in the task`);
|
|
2772
|
+
}
|
|
2773
|
+
const mode = parent?.mode ?? o.mode;
|
|
2774
|
+
const dir = path17.resolve(parent?.dir ?? o.dir);
|
|
2775
|
+
if (!fs21.existsSync(dir) || !fs21.statSync(dir).isDirectory()) throw new UserError(`not a directory: ${dir}`);
|
|
2776
|
+
const root = repoRoot(dir);
|
|
2777
|
+
if (mode === "isolate" && !root) throw new UserError("--isolate needs a git repository", 3);
|
|
2778
|
+
if (mode === "write" && !root && !o.allowNonGit) {
|
|
2779
|
+
throw new UserError("--write outside a git repo cannot be tracked or reverted; pass --allow-non-git to accept that", 3);
|
|
2780
|
+
}
|
|
2781
|
+
const files = o.files.map((f) => path17.resolve(f));
|
|
2782
|
+
for (const f of files) if (!fs21.existsSync(f)) throw new UserError(`file not found: ${f}`);
|
|
2783
|
+
const meta = {
|
|
2784
|
+
id: newRunId(),
|
|
2785
|
+
version: VERSION2,
|
|
2786
|
+
mode,
|
|
2787
|
+
task: work.task,
|
|
2788
|
+
dir,
|
|
2789
|
+
cwd: parent?.cwd ?? dir,
|
|
2790
|
+
repoRoot: root,
|
|
2791
|
+
worker,
|
|
2792
|
+
fallback,
|
|
2793
|
+
parent: parent?.id,
|
|
2794
|
+
group: o.group ?? parent?.group ?? (work.plan ? planName(work.plan.file) : void 0),
|
|
2795
|
+
sessionId: parent?.sessionId,
|
|
2796
|
+
files,
|
|
2797
|
+
link: parent?.link ?? o.link,
|
|
2798
|
+
timeoutSec: o.timeoutSec,
|
|
2799
|
+
verify: o.verify,
|
|
2800
|
+
web: o.web || !!parent?.web,
|
|
2801
|
+
state: "queued",
|
|
2802
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2803
|
+
warnings,
|
|
2804
|
+
// Follow-ups in an isolated copy accumulate into one patch against the original snapshot.
|
|
2805
|
+
baseTree: parent?.mode === "isolate" ? parent.baseTree : void 0,
|
|
2806
|
+
worktree: parent?.mode === "isolate" ? parent.worktree : void 0,
|
|
2807
|
+
reviewOf: o.review?.of,
|
|
2808
|
+
plan: work.plan ?? o.review?.plan ?? parent?.plan,
|
|
2809
|
+
reviewKind: o.review?.kind,
|
|
2810
|
+
packageFile: o.review?.packageFile
|
|
2811
|
+
};
|
|
2812
|
+
writeMeta(meta);
|
|
2813
|
+
if (mode === "write" && root) {
|
|
2814
|
+
try {
|
|
2815
|
+
acquireWriteLock(root, meta.id);
|
|
2816
|
+
} catch (e) {
|
|
2817
|
+
fs21.rmSync(runDir(meta.id), { recursive: true, force: true });
|
|
2818
|
+
throw e;
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
fs21.writeFileSync(runFile(meta.id, "task.md"), `${meta.task}
|
|
2822
|
+
`);
|
|
2823
|
+
if (work.brief) fs21.writeFileSync(runFile(meta.id, "brief.md"), work.brief);
|
|
2824
|
+
return meta;
|
|
2825
|
+
}
|
|
2826
|
+
var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
2827
|
+
async function waitForSlot(meta) {
|
|
2828
|
+
let stopped = false;
|
|
2829
|
+
const stop = () => {
|
|
2830
|
+
stopped = true;
|
|
2831
|
+
};
|
|
2832
|
+
process.once("SIGINT", stop);
|
|
2833
|
+
process.once("SIGTERM", stop);
|
|
2834
|
+
try {
|
|
2835
|
+
const max = effective().maxParallel.value;
|
|
2836
|
+
for (; ; ) {
|
|
2837
|
+
const slot = tryAcquireSlot(meta.id, max);
|
|
2838
|
+
if (slot) return slot;
|
|
2839
|
+
if (stopped) return void 0;
|
|
2840
|
+
await sleep2(1e3);
|
|
2841
|
+
if (stopped) return void 0;
|
|
2842
|
+
}
|
|
2843
|
+
} finally {
|
|
2844
|
+
process.off("SIGINT", stop);
|
|
2845
|
+
process.off("SIGTERM", stop);
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
function startInBackground(meta) {
|
|
2849
|
+
const script = process.argv[1];
|
|
2850
|
+
if (!script) throw new Error("cannot locate the pitroom executable");
|
|
2851
|
+
const child = spawn2(process.execPath, [script, "__exec", meta.id], {
|
|
2852
|
+
detached: true,
|
|
2853
|
+
stdio: "ignore",
|
|
2854
|
+
cwd: meta.dir,
|
|
2855
|
+
env: process.env
|
|
2856
|
+
});
|
|
2857
|
+
child.unref();
|
|
2858
|
+
meta.pid = child.pid;
|
|
2859
|
+
writeMeta(meta);
|
|
2860
|
+
return meta;
|
|
2861
|
+
}
|
|
2862
|
+
async function execute(meta) {
|
|
2863
|
+
meta.pid = process.pid;
|
|
2864
|
+
meta.state = "queued";
|
|
2865
|
+
writeMeta(meta);
|
|
2866
|
+
let result = { code: null, timedOut: false, stopped: false };
|
|
2867
|
+
const slot = await waitForSlot(meta);
|
|
2868
|
+
if (!slot) return finalize(meta, { code: null, timedOut: false, stopped: true });
|
|
2869
|
+
meta.state = "running";
|
|
2870
|
+
meta.startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2871
|
+
writeMeta(meta);
|
|
2872
|
+
try {
|
|
2873
|
+
prepareTree(meta);
|
|
2874
|
+
writeMeta(meta);
|
|
2875
|
+
const chain = [];
|
|
2876
|
+
for (const t of [meta.worker, ...meta.fallback]) if (!chain.some((c) => sameTarget(c, t))) chain.push(t);
|
|
2877
|
+
for (let i = 0; i < chain.length; i++) {
|
|
2878
|
+
const target = chain[i];
|
|
2879
|
+
const backend = getBackend(target.backend);
|
|
2880
|
+
meta.ran = target;
|
|
2881
|
+
writeMeta(meta);
|
|
2882
|
+
result = await attempt(meta, backend, target);
|
|
2883
|
+
if (result.timedOut || result.stopped || result.spawnError) break;
|
|
2884
|
+
const why = retryableFailure(meta, backend, result);
|
|
2885
|
+
if (!why) break;
|
|
2886
|
+
if (!target.model && backend.defaultModel) {
|
|
2887
|
+
const failed = backend.defaultModel();
|
|
2888
|
+
for (let j = chain.length - 1; j > i; j--) {
|
|
2889
|
+
if (chain[j].backend === target.backend && chain[j].model === failed) chain.splice(j, 1);
|
|
2890
|
+
}
|
|
2891
|
+
}
|
|
2892
|
+
if (i === chain.length - 1) break;
|
|
2893
|
+
(meta.attempts ??= []).push({ target: describeTarget(target), error: why.message });
|
|
2894
|
+
for (const f of ["events.jsonl", "stderr.log"]) {
|
|
2895
|
+
const from = runFile(meta.id, f);
|
|
2896
|
+
if (fs21.existsSync(from)) fs21.renameSync(from, runFile(meta.id, f.replace(".", `.attempt-${i + 1}.`)));
|
|
2897
|
+
}
|
|
2898
|
+
writeMeta(meta);
|
|
2899
|
+
}
|
|
2900
|
+
if (result.spawnError) {
|
|
2901
|
+
const b = getBackend((meta.ran ?? meta.worker).backend);
|
|
2902
|
+
meta.error = `${result.spawnError} (install ${b.name} or set PITROOM_${b.id.toUpperCase()}_BIN)`;
|
|
2903
|
+
}
|
|
2904
|
+
} catch (e) {
|
|
2905
|
+
meta.error ??= e.message;
|
|
2906
|
+
} finally {
|
|
2907
|
+
releaseSlot(slot, meta.id);
|
|
2908
|
+
}
|
|
2909
|
+
return finalize(meta, result);
|
|
2910
|
+
}
|
|
2911
|
+
function prepareTree(meta) {
|
|
2912
|
+
const root = meta.repoRoot;
|
|
2913
|
+
if (root && meta.mode === "write") meta.baseTree = snapshotTree(root);
|
|
2914
|
+
if (root && meta.mode === "isolate" && !meta.worktree) {
|
|
2915
|
+
meta.baseTree = snapshotTree(root);
|
|
2916
|
+
meta.worktree = path17.join(worktreesDir(), meta.id);
|
|
2917
|
+
createIsolatedCopy(root, meta.baseTree, meta.worktree);
|
|
2918
|
+
meta.cwd = path17.join(meta.worktree, path17.relative(root, meta.dir));
|
|
2919
|
+
const linked = linkIntoWorktree(root, meta.worktree, meta.link);
|
|
2920
|
+
if (linked.length) meta.warnings.push(`linked into the isolated copy (shared with your tree): ${linked.join(", ")}`);
|
|
2921
|
+
}
|
|
2922
|
+
}
|
|
2923
|
+
function attempt(meta, backend, target) {
|
|
2924
|
+
const inv = backend.invocation({
|
|
2925
|
+
mode: meta.mode,
|
|
2926
|
+
prompt: buildPrompt(meta.mode, meta.task, !!meta.parent),
|
|
2927
|
+
cwd: meta.cwd,
|
|
2928
|
+
model: target.model,
|
|
2929
|
+
sessionId: meta.sessionId,
|
|
2930
|
+
files: meta.files,
|
|
2931
|
+
web: !!meta.web,
|
|
2932
|
+
title: `pitroom ${meta.mode}: ${meta.task.replace(/\s+/g, " ").slice(0, 60)}`
|
|
2933
|
+
});
|
|
2934
|
+
return spawnWorker(inv, {
|
|
2935
|
+
cwd: meta.cwd,
|
|
2936
|
+
stdoutFile: runFile(meta.id, "events.jsonl"),
|
|
2937
|
+
stderrFile: runFile(meta.id, "stderr.log"),
|
|
2938
|
+
timeoutSec: meta.timeoutSec
|
|
2939
|
+
});
|
|
2940
|
+
}
|
|
2941
|
+
var read = (f) => fs21.existsSync(f) ? fs21.readFileSync(f, "utf8") : "";
|
|
2942
|
+
function retryableFailure(meta, backend, res) {
|
|
2943
|
+
const run = backend.parse(read(runFile(meta.id, "events.jsonl")));
|
|
2944
|
+
if (run.usage.steps > 0) return void 0;
|
|
2945
|
+
const f = backend.failure(run, read(runFile(meta.id, "stderr.log")), res.code);
|
|
2946
|
+
return f && f.kind !== "other" ? f : void 0;
|
|
2947
|
+
}
|
|
2948
|
+
var HINTS = {
|
|
2949
|
+
"model-unavailable": "the model is not available; fix the worker config, pass --worker/--model, or configure fallback workers (see `pitroom doctor`)",
|
|
2950
|
+
"rate-limited": "the model is rate-limited or overloaded; configure fallback workers to fail over automatically",
|
|
2951
|
+
auth: "authentication or billing problem with the worker's provider",
|
|
2952
|
+
other: ""
|
|
2953
|
+
};
|
|
2954
|
+
function finalize(meta, res) {
|
|
2955
|
+
const ran = meta.ran ?? meta.worker;
|
|
2956
|
+
const backend = getBackend(ran.backend);
|
|
2957
|
+
const run = backend.parse(read(runFile(meta.id, "events.jsonl")));
|
|
2958
|
+
meta.sessionId = run.sessionId ?? meta.sessionId;
|
|
2959
|
+
meta.usage = run.usage;
|
|
2960
|
+
fs21.writeFileSync(runFile(meta.id, "summary.md"), `${run.finalText}
|
|
2961
|
+
`);
|
|
2962
|
+
if (meta.plan && !meta.reviewOf) meta.taskStatus = parseStatus(run.finalText);
|
|
2963
|
+
if (meta.reviewOf) {
|
|
2964
|
+
meta.verdict = parseVerdict(run.finalText);
|
|
2965
|
+
if (meta.packageFile) fs21.rmSync(meta.packageFile, { force: true });
|
|
2966
|
+
}
|
|
2967
|
+
if (!res.timedOut && !res.stopped && !meta.error) {
|
|
2968
|
+
const f = backend.failure(run, read(runFile(meta.id, "stderr.log")), res.code);
|
|
2969
|
+
if (f) meta.error = HINTS[f.kind] ? `${f.message} (${HINTS[f.kind]})` : f.message;
|
|
2970
|
+
}
|
|
2971
|
+
try {
|
|
2972
|
+
captureChanges(meta);
|
|
2973
|
+
} catch (e) {
|
|
2974
|
+
meta.warnings.push(`could not compute changes: ${e.message}`);
|
|
2975
|
+
}
|
|
2976
|
+
if (meta.mode === "read" && run.edits.length) {
|
|
2977
|
+
meta.warnings.push(`READ-ONLY VIOLATION: worker modified ${[...new Set(run.edits)].join(", ")}`);
|
|
2978
|
+
}
|
|
2979
|
+
if (meta.sessionId && backend.resolveModel) meta.resolvedModel = backend.resolveModel(meta.sessionId);
|
|
2980
|
+
meta.resolvedModel ??= run.model ?? ran.model;
|
|
2981
|
+
const refs = extractRefs(run.finalText);
|
|
2982
|
+
if (refs.length) {
|
|
2983
|
+
meta.refs = verifyRefs(refs, [meta.cwd, meta.repoRoot ?? "", meta.dir]);
|
|
2984
|
+
if (meta.refs.invalid.length) {
|
|
2985
|
+
meta.warnings.push(`${meta.refs.invalid.length} of ${meta.refs.total} file references in the answer did not check out`);
|
|
2986
|
+
}
|
|
2987
|
+
}
|
|
2988
|
+
meta.state = res.timedOut ? "timeout" : res.stopped ? "stopped" : meta.error ? "failed" : "done";
|
|
2989
|
+
if (meta.state === "done" && !run.finalText) meta.warnings.push("worker finished without a written answer");
|
|
2990
|
+
if (meta.state === "done" && meta.verify) meta.verifyResult = runVerify(meta);
|
|
2991
|
+
meta.endedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2992
|
+
meta.returnedTokens = estimateTokens(formatReport(meta, run.finalText));
|
|
2993
|
+
meta.savedUsd = savedUsd(meta.usage, meta.returnedTokens);
|
|
2994
|
+
writeMeta(meta);
|
|
2995
|
+
if (meta.mode === "write" && meta.repoRoot) releaseWriteLock(meta.repoRoot, meta.id);
|
|
2996
|
+
record(meta);
|
|
2997
|
+
return meta;
|
|
2998
|
+
}
|
|
2999
|
+
function captureChanges(meta) {
|
|
3000
|
+
const root = meta.repoRoot;
|
|
3001
|
+
if (!root || !meta.baseTree || meta.mode === "read") return;
|
|
3002
|
+
const target = meta.mode === "isolate" ? meta.worktree : root;
|
|
3003
|
+
meta.afterTree = snapshotTree(target, meta.mode === "isolate" ? meta.link : []);
|
|
3004
|
+
const d = diffTrees(target, meta.baseTree, meta.afterTree);
|
|
3005
|
+
meta.changes = d.changes;
|
|
3006
|
+
meta.stats = d.stats;
|
|
3007
|
+
fs21.writeFileSync(runFile(meta.id, "changes.patch"), d.patch);
|
|
3008
|
+
}
|
|
3009
|
+
function runVerify(meta) {
|
|
3010
|
+
const r = spawnSync5(meta.verify, {
|
|
3011
|
+
cwd: meta.cwd,
|
|
3012
|
+
shell: true,
|
|
3013
|
+
encoding: "utf8",
|
|
3014
|
+
timeout: 15 * 6e4,
|
|
3015
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3016
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
3017
|
+
env: { ...process.env, PWD: meta.cwd, PITROOM_ACTIVE: "1" }
|
|
3018
|
+
});
|
|
3019
|
+
const output = `${r.stdout ?? ""}${r.stderr ?? ""}`;
|
|
3020
|
+
fs21.writeFileSync(runFile(meta.id, "verify.log"), output);
|
|
3021
|
+
return { ok: r.status === 0, code: r.status, tail: output.trimEnd().split("\n").slice(-25).join("\n") };
|
|
3022
|
+
}
|
|
3023
|
+
function applyRun(meta) {
|
|
3024
|
+
if (meta.mode !== "isolate") throw new UserError(`run ${meta.id} edited your tree directly (${meta.mode}); nothing to apply`);
|
|
3025
|
+
if (meta.applied) throw new UserError(`run ${meta.id} was already applied`);
|
|
3026
|
+
if (!meta.changes?.length) throw new UserError(`run ${meta.id} has no changes`);
|
|
3027
|
+
const res = applyPatch(meta.repoRoot, runFile(meta.id, "changes.patch"), false);
|
|
3028
|
+
if (!res.ok) throw new UserError(`patch does not apply cleanly (your tree changed since the snapshot):
|
|
3029
|
+
${res.message}`, 1);
|
|
3030
|
+
meta.applied = true;
|
|
3031
|
+
cleanupWorktree(meta);
|
|
3032
|
+
writeMeta(meta);
|
|
3033
|
+
return `applied ${meta.changes.length} file(s) from ${meta.id} to ${meta.repoRoot}`;
|
|
3034
|
+
}
|
|
3035
|
+
function revertRun(meta) {
|
|
3036
|
+
if (meta.mode !== "write") throw new UserError(`only --write runs can be reverted (this is ${meta.mode})`);
|
|
3037
|
+
if (meta.reverted) throw new UserError(`run ${meta.id} was already reverted`);
|
|
3038
|
+
if (!meta.changes?.length) throw new UserError(`run ${meta.id} has no changes`);
|
|
3039
|
+
const res = applyPatch(meta.repoRoot, runFile(meta.id, "changes.patch"), true);
|
|
3040
|
+
if (!res.ok) throw new UserError(`cannot revert cleanly (files changed after the run):
|
|
3041
|
+
${res.message}`, 1);
|
|
3042
|
+
meta.reverted = true;
|
|
3043
|
+
writeMeta(meta);
|
|
3044
|
+
return `reverted ${meta.changes.length} file(s) changed by ${meta.id}`;
|
|
3045
|
+
}
|
|
3046
|
+
function discardRun(meta) {
|
|
3047
|
+
if (meta.mode !== "isolate") throw new UserError("only --isolate runs have an isolated copy to discard");
|
|
3048
|
+
cleanupWorktree(meta);
|
|
3049
|
+
meta.discarded = true;
|
|
3050
|
+
writeMeta(meta);
|
|
3051
|
+
return `discarded the isolated copy of ${meta.id}; the patch stays in ${runFile(meta.id, "changes.patch")}`;
|
|
3052
|
+
}
|
|
3053
|
+
function cleanupWorktree(meta) {
|
|
3054
|
+
if (meta.worktree && fs21.existsSync(meta.worktree)) removeIsolatedCopy(meta.worktree, worktreesDir());
|
|
3055
|
+
}
|
|
3056
|
+
|
|
3057
|
+
// src/cli/commands.ts
|
|
3058
|
+
async function launch(p, meta) {
|
|
3059
|
+
if (has(p, "bg")) {
|
|
3060
|
+
startInBackground(meta);
|
|
3061
|
+
console.log(
|
|
3062
|
+
has(p, "json") ? JSON.stringify(meta, null, 2) : `pitroom started ${meta.mode} run ${meta.id} in background${meta.group ? ` (group ${meta.group})` : ""}
|
|
3063
|
+
wait: pitroom wait ${meta.id}
|
|
3064
|
+
status: pitroom status ${meta.id}`
|
|
3065
|
+
);
|
|
3066
|
+
return 0;
|
|
3067
|
+
}
|
|
3068
|
+
const done = await execute(meta);
|
|
3069
|
+
console.log(has(p, "json") ? JSON.stringify(done, null, 2) : formatReport(done));
|
|
3070
|
+
return exitCodeFor(done);
|
|
3071
|
+
}
|
|
3072
|
+
async function cmdRun(p) {
|
|
3073
|
+
const opts = { ...runOptions(p, readTask(p)), plan: planStep(p) };
|
|
3074
|
+
if (!opts.task.trim() && !opts.plan) throw new UserError('no task given (pitroom "find where X is handled")');
|
|
3075
|
+
return launch(p, prepareRun(opts));
|
|
3076
|
+
}
|
|
3077
|
+
async function cmdReview(p) {
|
|
3078
|
+
const range = flag(p, "range");
|
|
3079
|
+
if (range && p.positional.length) throw new UserError("review takes a run or --range A..B, not both");
|
|
3080
|
+
if (flag(p, "plan") && !range) throw new UserError("--plan goes with --range (a run's review already knows its plan)");
|
|
3081
|
+
if (has(p, "write") || has(p, "isolate")) throw new UserError("reviews are read-only; drop -w/-i");
|
|
3082
|
+
if (has(p, "continue")) throw new UserError("to review a follow-up, pass its run id: pitroom review <run>");
|
|
3083
|
+
const job = range ? rangeReview(range, flag(p, "dir") ?? process.cwd(), flag(p, "plan")) : runReview(resolveRun(p.positional[0]));
|
|
3084
|
+
const packageFile = writePackage(job);
|
|
3085
|
+
let meta;
|
|
3086
|
+
try {
|
|
3087
|
+
meta = prepareRun({
|
|
3088
|
+
...runOptions(p, fill(loadTemplate(TEMPLATE[job.kind]), { PACKAGE_FILE: packageFile })),
|
|
3089
|
+
mode: "read",
|
|
3090
|
+
dir: job.dir,
|
|
3091
|
+
worker: flag(p, "worker") ?? (flag(p, "tier") ? void 0 : pickReviewer(job)),
|
|
3092
|
+
group: flag(p, "group") ?? job.group,
|
|
3093
|
+
review: { of: job.of, kind: job.kind, packageFile, plan: job.plan }
|
|
3094
|
+
});
|
|
3095
|
+
const automatic = !range && !flag(p, "worker") && !flag(p, "tier");
|
|
3096
|
+
if (automatic && job.implementer && meta.worker.backend === job.implementer.backend) {
|
|
3097
|
+
meta.warnings.push(
|
|
3098
|
+
`reviewer runs on the same backend as the implementer (${job.implementer.backend}); configure tiers "standard" or "capable" for a second model`
|
|
3099
|
+
);
|
|
3100
|
+
writeMeta(meta);
|
|
3101
|
+
}
|
|
3102
|
+
} catch (e) {
|
|
3103
|
+
fs22.rmSync(packageFile, { force: true });
|
|
3104
|
+
throw e;
|
|
3105
|
+
}
|
|
3106
|
+
fs22.writeFileSync(runFile(meta.id, "package.md"), job.package);
|
|
3107
|
+
return launch(p, meta);
|
|
3108
|
+
}
|
|
3109
|
+
function cmdPlan(p) {
|
|
3110
|
+
const [sub, file, ...rest] = p.positional;
|
|
3111
|
+
if (sub === "status" && file) {
|
|
3112
|
+
const s = planStatus(file);
|
|
3113
|
+
console.log(
|
|
3114
|
+
has(p, "json") ? JSON.stringify({ plan: s.plan.file, title: s.plan.title, tasks: s.tasks, rulings: s.rulings, notesFile: s.notesFile }, null, 2) : formatPlanStatus(s)
|
|
3115
|
+
);
|
|
3116
|
+
return 0;
|
|
3117
|
+
}
|
|
3118
|
+
if (sub === "note" && file && rest.length) {
|
|
3119
|
+
console.log(`noted in ${addNote(file, rest.join(" "))}`);
|
|
3120
|
+
return 0;
|
|
3121
|
+
}
|
|
3122
|
+
throw new UserError('usage: pitroom plan status PLAN.md [--json] | pitroom plan note PLAN.md "Task N: \u2026"');
|
|
3123
|
+
}
|
|
3124
|
+
function cmdCrew(p) {
|
|
3125
|
+
const file = flag(p, "task-file");
|
|
3126
|
+
const tasks = (file ? fs22.readFileSync(file, "utf8").split(/^\s*---\s*$/m) : p.positional).map((t) => t.trim()).filter(Boolean);
|
|
3127
|
+
if (!tasks.length) throw new UserError('crew needs tasks: pitroom crew -g NAME "task 1" "task 2" (or --task-file with --- separators)');
|
|
3128
|
+
if (has(p, "write") && tasks.length > 1) {
|
|
3129
|
+
throw new UserError("parallel --write runs would edit the same tree; use --isolate (each worker gets its own isolated copy)");
|
|
3130
|
+
}
|
|
3131
|
+
if (has(p, "continue")) throw new UserError("--continue applies to a single run; use pitroom run --continue");
|
|
3132
|
+
if (has(p, "plan") || has(p, "step")) {
|
|
3133
|
+
throw new UserError("start plan tasks with pitroom run -i --plan PLAN --step N --bg (they share the plan's group)");
|
|
3134
|
+
}
|
|
3135
|
+
const group = flag(p, "group") ?? `crew-${(/* @__PURE__ */ new Date()).toISOString().slice(11, 19).replace(/:/g, "")}`;
|
|
3136
|
+
const metas = tasks.map((task) => startInBackground(prepareRun({ ...runOptions(p, task), group })));
|
|
3137
|
+
if (has(p, "json")) {
|
|
3138
|
+
console.log(JSON.stringify({ group, runs: metas.map((m) => ({ id: m.id, mode: m.mode, task: m.task })) }, null, 2));
|
|
3139
|
+
return 0;
|
|
3140
|
+
}
|
|
3141
|
+
console.log(`pitroom crew "${group}": ${metas.length} ${metas[0].mode} workers started (max ${effective().maxParallel.value} at once)`);
|
|
3142
|
+
for (const m of metas) console.log(` ${m.id} ${m.task.replace(/\s+/g, " ").slice(0, 70)}`);
|
|
3143
|
+
console.log(` live: pitroom watch -g ${group} (agents: add --json)
|
|
3144
|
+
results: pitroom wait -g ${group}`);
|
|
3145
|
+
return 0;
|
|
3146
|
+
}
|
|
3147
|
+
function selectIds(p, fallback) {
|
|
3148
|
+
const group = flag(p, "group");
|
|
3149
|
+
if (group) {
|
|
3150
|
+
const ids = groupIds(group);
|
|
3151
|
+
if (!ids.length) throw new UserError(`no runs in group "${group}"`);
|
|
3152
|
+
return ids;
|
|
3153
|
+
}
|
|
3154
|
+
return p.positional.length ? p.positional.map((r) => resolveRun(r)) : fallback();
|
|
3155
|
+
}
|
|
3156
|
+
function cmdStatus(p) {
|
|
3157
|
+
if (flag(p, "group") || p.positional.length > 1) {
|
|
3158
|
+
const metas = selectIds(p, () => []).map((id) => freshMeta(id));
|
|
3159
|
+
console.log(table(metas));
|
|
3160
|
+
return metas.some((m) => isActive(m.state)) ? 75 : 0;
|
|
3161
|
+
}
|
|
3162
|
+
const meta = freshMeta(resolveRun(p.positional[0]));
|
|
3163
|
+
console.log(isActive(meta.state) ? progress(meta) : formatReport(meta));
|
|
3164
|
+
return exitCodeFor(meta);
|
|
3165
|
+
}
|
|
3166
|
+
async function cmdWait(p) {
|
|
3167
|
+
const ids = selectIds(p, () => [resolveRun(void 0)]);
|
|
3168
|
+
const { metas, timedOut } = await waitMany(ids, {
|
|
3169
|
+
any: has(p, "any"),
|
|
3170
|
+
timeoutMs: parseDuration(flag(p, "timeout") ?? "540") * 1e3
|
|
3171
|
+
});
|
|
3172
|
+
const finished = metas.filter((m) => TERMINAL.includes(m.state));
|
|
3173
|
+
const pending = metas.filter((m) => isActive(m.state));
|
|
3174
|
+
const parts = finished.map(
|
|
3175
|
+
(m) => has(p, "brief") ? `pitroom ${m.state} \xB7 ${m.id} \xB7 ${headline(m) || m.error || m.task}` : formatReport(m)
|
|
3176
|
+
);
|
|
3177
|
+
for (const m of pending) parts.push(progress(m));
|
|
3178
|
+
if (pending.length) {
|
|
3179
|
+
const again = flag(p, "group") ? `-g ${flag(p, "group")}` : pending.map((m) => m.id).join(" ");
|
|
3180
|
+
parts.push(`\u2500\u2500 ${pending.length} still active: pitroom wait ${again}`);
|
|
3181
|
+
}
|
|
3182
|
+
console.log(parts.join(metas.length > 1 && !has(p, "brief") ? "\n\n\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n\n" : "\n"));
|
|
3183
|
+
if (pending.length && (timedOut || !has(p, "any"))) return 75;
|
|
3184
|
+
return finished.map(exitCodeFor).find((c) => c !== 0) ?? 0;
|
|
3185
|
+
}
|
|
3186
|
+
async function cmdWatch(p) {
|
|
3187
|
+
const group = flag(p, "group");
|
|
3188
|
+
const explicit = p.positional.map((r) => resolveRun(r));
|
|
3189
|
+
const initial = group ? groupIds(group) : explicit.length ? explicit : listRunIds().filter((id) => isActive(freshMeta(id).state));
|
|
3190
|
+
if (!initial.length) {
|
|
3191
|
+
console.log(group ? `no runs in group "${group}"` : "nothing is running");
|
|
3192
|
+
return 0;
|
|
3193
|
+
}
|
|
3194
|
+
const { metas, timedOut } = await watch(group ? () => groupIds(group) : () => initial, {
|
|
3195
|
+
json: has(p, "json") || !process.stdout.isTTY,
|
|
3196
|
+
intervalMs: parseDuration(flag(p, "interval") ?? "2") * 1e3,
|
|
3197
|
+
timeoutMs: flag(p, "timeout") ? parseDuration(flag(p, "timeout")) * 1e3 : 0,
|
|
3198
|
+
write: (s) => process.stdout.write(s)
|
|
3199
|
+
});
|
|
3200
|
+
if (timedOut) return 75;
|
|
3201
|
+
return metas.some((m) => m.state !== "done") ? 1 : 0;
|
|
3202
|
+
}
|
|
3203
|
+
function cmdShow(p) {
|
|
3204
|
+
const meta = freshMeta(resolveRun(p.positional[0]));
|
|
3205
|
+
const dump = (name) => {
|
|
3206
|
+
const f = runFile(meta.id, name);
|
|
3207
|
+
process.stdout.write(fs22.existsSync(f) ? fs22.readFileSync(f, "utf8") : `(no ${name})
|
|
3208
|
+
`);
|
|
3209
|
+
};
|
|
3210
|
+
if (has(p, "patch")) dump("changes.patch");
|
|
3211
|
+
else if (has(p, "events")) dump("events.jsonl");
|
|
3212
|
+
else if (has(p, "json")) console.log(JSON.stringify(meta, null, 2));
|
|
3213
|
+
else if (isActive(meta.state)) console.log(progress(meta));
|
|
3214
|
+
else console.log(formatReport(meta, void 0, has(p, "full") ? Infinity : 400));
|
|
3215
|
+
return 0;
|
|
3216
|
+
}
|
|
3217
|
+
function cmdLs(p) {
|
|
3218
|
+
const group = flag(p, "group");
|
|
3219
|
+
let ids = group ? groupIds(group) : listRunIds();
|
|
3220
|
+
if (has(p, "running")) ids = ids.filter((id) => isActive(freshMeta(id).state));
|
|
3221
|
+
const metas = ids.slice(-20).map((id) => freshMeta(id));
|
|
3222
|
+
if (!metas.length) {
|
|
3223
|
+
console.log(has(p, "running") ? "nothing is running" : "no runs yet");
|
|
3224
|
+
return 0;
|
|
3225
|
+
}
|
|
3226
|
+
console.log(table(metas));
|
|
3227
|
+
return 0;
|
|
3228
|
+
}
|
|
3229
|
+
function cmdApply(p) {
|
|
3230
|
+
const group = flag(p, "group");
|
|
3231
|
+
if (!group) {
|
|
3232
|
+
console.log(applyRun(freshMeta(resolveRun(p.positional[0]))));
|
|
3233
|
+
return 0;
|
|
3234
|
+
}
|
|
3235
|
+
const pending = groupIds(group).map((id) => freshMeta(id)).filter((m) => m.mode === "isolate" && m.state === "done" && m.changes?.length && !m.applied && !m.discarded);
|
|
3236
|
+
if (!pending.length) throw new UserError(`group "${group}" has no finished isolate patches to apply`);
|
|
3237
|
+
for (const [i, m] of pending.entries()) {
|
|
3238
|
+
try {
|
|
3239
|
+
console.log(applyRun(m));
|
|
3240
|
+
} catch (e) {
|
|
3241
|
+
const rest = pending.slice(i + 1).map((r) => r.id);
|
|
3242
|
+
console.log(`\u2718 ${m.id}: ${e.message}`);
|
|
3243
|
+
if (rest.length) console.log(` not applied yet: ${rest.join(" ")}`);
|
|
3244
|
+
console.log(` resolve it (e.g. pitroom run --continue ${m.id} "rebase your change on the current tree"), then apply the rest`);
|
|
3245
|
+
return 1;
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
return 0;
|
|
3249
|
+
}
|
|
3250
|
+
function cmdStop(p) {
|
|
3251
|
+
const ids = selectIds(p, () => [resolveRun(void 0)]);
|
|
3252
|
+
let stopped = 0;
|
|
3253
|
+
for (const id of ids) {
|
|
3254
|
+
const meta = freshMeta(id);
|
|
3255
|
+
if (!isActive(meta.state) || !isAlive(meta.pid)) continue;
|
|
3256
|
+
process.kill(meta.pid, "SIGTERM");
|
|
3257
|
+
console.log(`stopping ${meta.id} (${meta.state})`);
|
|
3258
|
+
stopped++;
|
|
3259
|
+
}
|
|
3260
|
+
if (!stopped) throw new UserError(ids.length === 1 ? `run ${ids[0]} is not active` : "none of these runs is active");
|
|
3261
|
+
return 0;
|
|
3262
|
+
}
|
|
3263
|
+
function sinceMs(s) {
|
|
3264
|
+
if (!s || s === "all") return void 0;
|
|
3265
|
+
const m = /^(\d+)d$/.exec(s);
|
|
3266
|
+
if (!m) throw new UserError("--since takes 7d, 30d, \u2026 or all");
|
|
3267
|
+
return Date.now() - Number(m[1]) * 864e5;
|
|
3268
|
+
}
|
|
3269
|
+
var readStdin = () => process.stdin.isTTY ? "" : fs22.readFileSync(0, "utf8");
|
|
3270
|
+
function cmdStatusline(p) {
|
|
3271
|
+
const input = readStdin();
|
|
3272
|
+
const lines = [];
|
|
3273
|
+
const then = flag(p, "then");
|
|
3274
|
+
if (then) {
|
|
3275
|
+
const r = spawnSync6("/bin/sh", ["-c", then], { input, encoding: "utf8", timeout: 5e3 });
|
|
3276
|
+
if (r.stdout?.trimEnd()) lines.push(r.stdout.trimEnd());
|
|
3277
|
+
}
|
|
3278
|
+
try {
|
|
3279
|
+
const own = statusLine();
|
|
3280
|
+
if (own) lines.push(own);
|
|
3281
|
+
} catch {
|
|
3282
|
+
}
|
|
3283
|
+
if (lines.length) console.log(lines.join("\n"));
|
|
3284
|
+
return 0;
|
|
3285
|
+
}
|
|
3286
|
+
function cmdHookCard() {
|
|
3287
|
+
try {
|
|
3288
|
+
const cards = hookCards(readStdin());
|
|
3289
|
+
if (cards) console.log(JSON.stringify({ systemMessage: cards }));
|
|
3290
|
+
} catch {
|
|
3291
|
+
}
|
|
3292
|
+
return 0;
|
|
3293
|
+
}
|
|
3294
|
+
function cmdSavings(p) {
|
|
3295
|
+
const since = flag(p, "since") ?? "all";
|
|
3296
|
+
const t = totals(readLedger(sinceMs(since)));
|
|
3297
|
+
const period = since === "all" ? "all time" : `last ${since.replace("d", " days")}`;
|
|
3298
|
+
if (has(p, "json")) {
|
|
3299
|
+
console.log(JSON.stringify({ period, ...t, price: primaryPrice() }, null, 2));
|
|
3300
|
+
return 0;
|
|
3301
|
+
}
|
|
3302
|
+
console.log(`pitroom savings \xB7 ${period}
|
|
3303
|
+
delegated tasks ${t.runs}
|
|
3304
|
+
tokens offloaded ${compact(t.tokens)}
|
|
3305
|
+
returned to primary ~${compact(t.returned)}${t.ratio ? ` (${Math.round(t.ratio)}\xD7 compression)` : ""}
|
|
3306
|
+
worker cost ${usd(t.workerCost)}
|
|
3307
|
+
est. saved ${usd(t.saved)} (vs ${primaryPrice().name} list prices)`);
|
|
3308
|
+
const out = flag(p, "card");
|
|
3309
|
+
if (out) {
|
|
3310
|
+
fs22.writeFileSync(out, card(t, period));
|
|
3311
|
+
console.log(`card written to ${path18.resolve(out)}`);
|
|
3312
|
+
}
|
|
3313
|
+
if (has(p, "badge")) console.log(`})`);
|
|
3314
|
+
return 0;
|
|
3315
|
+
}
|
|
3316
|
+
function cmdInstall(p) {
|
|
3317
|
+
for (const line of install({ copy: has(p, "copy"), force: has(p, "force") })) console.log(line);
|
|
3318
|
+
return 0;
|
|
3319
|
+
}
|
|
3320
|
+
function cmdUninstall() {
|
|
3321
|
+
for (const line of uninstall()) console.log(line);
|
|
3322
|
+
return 0;
|
|
3323
|
+
}
|
|
3324
|
+
function cmdConfig(p) {
|
|
3325
|
+
const { warnings } = loadConfig();
|
|
3326
|
+
const eff = effective();
|
|
3327
|
+
if (has(p, "json")) {
|
|
3328
|
+
console.log(JSON.stringify({ path: configPath(), settings: eff, warnings }, null, 2));
|
|
3329
|
+
return 0;
|
|
3330
|
+
}
|
|
3331
|
+
console.log(`config file: ${configPath()}${fs22.existsSync(configPath()) ? "" : " (not present)"}`);
|
|
3332
|
+
for (const [key, s] of Object.entries(eff)) {
|
|
3333
|
+
const v = Array.isArray(s.value) ? s.value.join(", ") || "\u2014" : s.value && typeof s.value === "object" ? Object.entries(s.value).map(([k, m]) => `${k}=${m}`).join(", ") || "\u2014" : s.value === void 0 ? key === "model" ? "the worker's default" : "\u2014" : String(s.value);
|
|
3334
|
+
console.log(` ${key.padEnd(15)} ${v} (${s.source})`);
|
|
3335
|
+
}
|
|
3336
|
+
for (const w of warnings) console.log(`! ${w}`);
|
|
3337
|
+
return 0;
|
|
3338
|
+
}
|
|
3339
|
+
function cmdClean(p) {
|
|
3340
|
+
const days = Number(flag(p, "days") ?? 14);
|
|
3341
|
+
const cutoff = Date.now() - days * 864e5;
|
|
3342
|
+
const old = listRunIds().filter((id) => {
|
|
3343
|
+
const m = freshMeta(id);
|
|
3344
|
+
const pendingPatch = m.mode === "isolate" && !m.applied && !m.discarded && m.changes?.length;
|
|
3345
|
+
return TERMINAL.includes(m.state) && Date.parse(m.startedAt) < cutoff && !pendingPatch;
|
|
3346
|
+
});
|
|
3347
|
+
if (!has(p, "yes")) {
|
|
3348
|
+
console.log(`${old.length} run(s) older than ${days} days would be removed (unapplied isolate patches are kept). Re-run with --yes.`);
|
|
3349
|
+
return 0;
|
|
3350
|
+
}
|
|
3351
|
+
for (const id of old) {
|
|
3352
|
+
const m = readMeta(id);
|
|
3353
|
+
if (m.mode === "isolate" && !m.discarded && !m.applied) discardRun(m);
|
|
3354
|
+
fs22.rmSync(runDir(id), { recursive: true, force: true });
|
|
3355
|
+
}
|
|
3356
|
+
console.log(`removed ${old.length} run(s); the savings ledger is kept`);
|
|
3357
|
+
return 0;
|
|
3358
|
+
}
|
|
3359
|
+
var cmdRevert = (p) => (console.log(revertRun(freshMeta(resolveRun(p.positional[0])))), 0);
|
|
3360
|
+
var cmdDiscard = (p) => (console.log(discardRun(freshMeta(resolveRun(p.positional[0])))), 0);
|
|
3361
|
+
|
|
3362
|
+
// src/core/doctor.ts
|
|
3363
|
+
import { spawnSync as spawnSync7 } from "node:child_process";
|
|
3364
|
+
import fs23 from "node:fs";
|
|
3365
|
+
import os9 from "node:os";
|
|
3366
|
+
import path19 from "node:path";
|
|
3367
|
+
var MARK = { ok: "\u2714", warn: "!", fail: "\u2718" };
|
|
3368
|
+
var READ_ONLY_HOW = {
|
|
3369
|
+
"permission-rules": "per-run permission rules",
|
|
3370
|
+
"os-sandbox": "an OS sandbox",
|
|
3371
|
+
"tool-allowlist": "a tool allowlist",
|
|
3372
|
+
"approval-mode": "the CLI's read-only approval mode"
|
|
3373
|
+
};
|
|
3374
|
+
function doctor4(probe) {
|
|
3375
|
+
const checks = [];
|
|
3376
|
+
const add = (level, message) => checks.push({ level, message });
|
|
3377
|
+
add("ok", `pitroom ${VERSION2} \xB7 node ${process.versions.node} \xB7 state in ${home()}`);
|
|
3378
|
+
add(gitAvailable() ? "ok" : "warn", gitAvailable() ? "git available" : "git not found: --write/--isolate tracking disabled");
|
|
3379
|
+
const cfg = loadConfig();
|
|
3380
|
+
add(cfg.warnings.length ? "warn" : "ok", `config: ${configPath()}${fs23.existsSync(configPath()) ? "" : " (not present, defaults in use)"}`);
|
|
3381
|
+
for (const w of cfg.warnings) add("warn", w);
|
|
3382
|
+
if (process.platform !== "win32") {
|
|
3383
|
+
const guarded = guardEnv(process.env).PATH?.startsWith(shimDir());
|
|
3384
|
+
add(guarded ? "ok" : "warn", guarded ? "git guard shim ready" : "git guard unavailable (git not on PATH)");
|
|
3385
|
+
}
|
|
3386
|
+
const chain = [];
|
|
3387
|
+
try {
|
|
3388
|
+
const resolved = resolveChain();
|
|
3389
|
+
chain.push(resolved.worker, ...resolved.fallback);
|
|
3390
|
+
for (const w of resolved.warnings) add("warn", w);
|
|
3391
|
+
} catch (e) {
|
|
3392
|
+
add("fail", e.message);
|
|
3393
|
+
}
|
|
3394
|
+
if (chain.length) add("ok", `worker chain: ${chain.map(describeTarget).join(" \u2192 ")}`);
|
|
3395
|
+
const tierTargets = [];
|
|
3396
|
+
const tierNames = [];
|
|
3397
|
+
for (const name of Object.keys(effective().tiers.value)) {
|
|
3398
|
+
try {
|
|
3399
|
+
tierTargets.push(resolveChain({ tier: name, noFallback: true }).worker);
|
|
3400
|
+
tierNames.push(name);
|
|
3401
|
+
} catch (e) {
|
|
3402
|
+
add("fail", `tier "${name}": ${e.message}`);
|
|
3403
|
+
}
|
|
3404
|
+
}
|
|
3405
|
+
if (tierNames.length) add("ok", `tiers: ${tierNames.map((name, i) => `${name}=${describeTarget(tierTargets[i])}`).join(", ")}`);
|
|
3406
|
+
const byBackend = /* @__PURE__ */ new Map();
|
|
3407
|
+
for (const t of [...chain, ...tierTargets]) byBackend.set(t.backend, [...byBackend.get(t.backend) ?? [], t.model]);
|
|
3408
|
+
for (const [id, models] of byBackend) {
|
|
3409
|
+
let backend;
|
|
3410
|
+
try {
|
|
3411
|
+
backend = getBackend(id);
|
|
3412
|
+
} catch (e) {
|
|
3413
|
+
add("fail", e.message);
|
|
3414
|
+
continue;
|
|
3415
|
+
}
|
|
3416
|
+
checks.push(...backend.doctor({ models: [...new Set(models)], hasFallback: chain.length > 1 }));
|
|
3417
|
+
add("ok", `${backend.name}: read-only runs enforced by ${READ_ONLY_HOW[backend.capabilities.readOnly]}`);
|
|
3418
|
+
}
|
|
3419
|
+
checks.push(...skillChecks());
|
|
3420
|
+
if (probe && chain[0]) checks.push(liveProbe(chain[0]));
|
|
3421
|
+
for (const c of checks) console.log(`${MARK[c.level]} ${c.message}`);
|
|
3422
|
+
return checks.some((c) => c.level === "fail") ? 1 : 0;
|
|
3423
|
+
}
|
|
3424
|
+
function liveProbe(target) {
|
|
3425
|
+
let backend;
|
|
3426
|
+
try {
|
|
3427
|
+
backend = getBackend(target.backend);
|
|
3428
|
+
} catch (e) {
|
|
3429
|
+
return { level: "fail", message: e.message };
|
|
3430
|
+
}
|
|
3431
|
+
const inv = backend.invocation({
|
|
3432
|
+
mode: "read",
|
|
3433
|
+
prompt: "Reply with exactly: PONG",
|
|
3434
|
+
cwd: process.cwd(),
|
|
3435
|
+
model: target.model,
|
|
3436
|
+
files: [],
|
|
3437
|
+
web: false,
|
|
3438
|
+
title: "pitroom doctor probe"
|
|
3439
|
+
});
|
|
3440
|
+
const t0 = Date.now();
|
|
3441
|
+
const r = spawnSync7(inv.command, inv.args, {
|
|
3442
|
+
encoding: "utf8",
|
|
3443
|
+
timeout: 3e5,
|
|
3444
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
3445
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
3446
|
+
env: guardEnv({ ...process.env, ...inv.env, PWD: process.cwd(), PITROOM_ACTIVE: "1" })
|
|
3447
|
+
});
|
|
3448
|
+
const secs = ((Date.now() - t0) / 1e3).toFixed(1);
|
|
3449
|
+
const run = backend.parse(r.stdout ?? "");
|
|
3450
|
+
if (run.finalText.includes("PONG")) return { level: "ok", message: `live probe (${describeTarget(target)}) answered in ${secs}s` };
|
|
3451
|
+
const why = backend.failure(run, r.stderr ?? "", r.status)?.message ?? run.finalText.slice(0, 200);
|
|
3452
|
+
return { level: "fail", message: `live probe (${describeTarget(target)}) failed after ${secs}s: ${why}` };
|
|
3453
|
+
}
|
|
3454
|
+
function skillChecks() {
|
|
3455
|
+
const checks = [];
|
|
3456
|
+
const all = skillNames();
|
|
3457
|
+
const viaPlugin = pluginInstalled();
|
|
3458
|
+
const superpowers = superpowersActive();
|
|
3459
|
+
if (superpowers.length) {
|
|
3460
|
+
checks.push({
|
|
3461
|
+
level: "warn",
|
|
3462
|
+
message: `superpowers is installed too (${superpowers.join(", ")}): two bootstraps compete for the same work; keep one (Pitroom includes the superpowers workflow)`
|
|
3463
|
+
});
|
|
3464
|
+
}
|
|
3465
|
+
for (const { base, names } of installedSkills()) {
|
|
3466
|
+
if (names.length === all.length) checks.push({ level: "ok", message: `skills in ${base}: ${names.join(", ")}` });
|
|
3467
|
+
else if (names.length) checks.push({ level: "warn", message: `skills in ${base}: only ${names.join(", ")} of ${all.length}; run \`pitroom install\`` });
|
|
3468
|
+
else if (!viaPlugin) checks.push({ level: "warn", message: `no Pitroom skills in ${base}; run \`pitroom install\`` });
|
|
3469
|
+
}
|
|
3470
|
+
if (viaPlugin) {
|
|
3471
|
+
const linked = installedSkills().some((i) => i.base.includes(`${path19.sep}.claude${path19.sep}`) && i.names.length);
|
|
3472
|
+
checks.push(
|
|
3473
|
+
linked ? { level: "warn", message: "Pitroom is installed as a Claude Code plugin and linked into ~/.claude/skills: skills load twice; run `pitroom uninstall` or remove the plugin" } : { level: "ok", message: "Claude Code plugin installed (skills + session-start hook)" }
|
|
3474
|
+
);
|
|
3475
|
+
}
|
|
3476
|
+
const launcher = launcherPath();
|
|
3477
|
+
if (fs23.existsSync(launcher)) {
|
|
3478
|
+
const r = spawnSync7(launcher, ["--version"], { encoding: "utf8", timeout: 3e4, stdio: ["ignore", "pipe", "pipe"] });
|
|
3479
|
+
checks.push(
|
|
3480
|
+
r.status === 0 ? { level: "ok", message: `launcher ${launcher} \u2192 pitroom ${r.stdout.trim()}` } : { level: "fail", message: `launcher ${launcher} does not start: ${(r.stderr || r.stdout).trim().slice(0, 200)}` }
|
|
3481
|
+
);
|
|
3482
|
+
} else {
|
|
3483
|
+
checks.push({ level: "warn", message: "no `pitroom` launcher on PATH: run `pitroom install`" });
|
|
3484
|
+
}
|
|
3485
|
+
return checks;
|
|
3486
|
+
}
|
|
3487
|
+
function pluginInstalled() {
|
|
3488
|
+
try {
|
|
3489
|
+
const f = path19.join(os9.homedir(), ".claude", "plugins", "installed_plugins.json");
|
|
3490
|
+
const plugins = JSON.parse(fs23.readFileSync(f, "utf8")).plugins ?? {};
|
|
3491
|
+
return Object.keys(plugins).some((k) => k.startsWith("pitroom@"));
|
|
3492
|
+
} catch {
|
|
3493
|
+
return false;
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
function superpowersActive() {
|
|
3497
|
+
const found = [];
|
|
3498
|
+
for (const key of claudePlugins()) {
|
|
3499
|
+
if (key.startsWith("superpowers@") && claudePluginEnabled(key)) found.push(`Claude Code plugin ${key}`);
|
|
3500
|
+
}
|
|
3501
|
+
for (const [key, enabled] of codexPlugins()) {
|
|
3502
|
+
if (key.startsWith("superpowers@") && enabled) found.push(`Codex plugin ${key}`);
|
|
3503
|
+
}
|
|
3504
|
+
for (const p of openCodePlugins()) {
|
|
3505
|
+
if (/superpowers/i.test(p)) found.push(`OpenCode plugin ${p}`);
|
|
3506
|
+
}
|
|
3507
|
+
for (const base of [path19.join(os9.homedir(), ".agents", "skills"), path19.join(os9.homedir(), ".claude", "skills")]) {
|
|
3508
|
+
const dir = path19.join(base, "using-superpowers");
|
|
3509
|
+
if (fs23.existsSync(path19.join(dir, "SKILL.md"))) found.push(dir);
|
|
3510
|
+
}
|
|
3511
|
+
return found;
|
|
3512
|
+
}
|
|
3513
|
+
function claudePlugins() {
|
|
3514
|
+
try {
|
|
3515
|
+
const f = path19.join(os9.homedir(), ".claude", "plugins", "installed_plugins.json");
|
|
3516
|
+
return Object.keys(JSON.parse(fs23.readFileSync(f, "utf8")).plugins ?? {});
|
|
3517
|
+
} catch {
|
|
3518
|
+
return [];
|
|
3519
|
+
}
|
|
3520
|
+
}
|
|
3521
|
+
function claudePluginEnabled(key) {
|
|
3522
|
+
try {
|
|
3523
|
+
const f = path19.join(os9.homedir(), ".claude", "settings.json");
|
|
3524
|
+
return JSON.parse(fs23.readFileSync(f, "utf8")).enabledPlugins?.[key] !== false;
|
|
3525
|
+
} catch {
|
|
3526
|
+
return true;
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
function codexPlugins() {
|
|
3530
|
+
const plugins = /* @__PURE__ */ new Map();
|
|
3531
|
+
let text;
|
|
3532
|
+
try {
|
|
3533
|
+
text = fs23.readFileSync(path19.join(process.env.CODEX_HOME ?? path19.join(os9.homedir(), ".codex"), "config.toml"), "utf8");
|
|
3534
|
+
} catch {
|
|
3535
|
+
return plugins;
|
|
3536
|
+
}
|
|
3537
|
+
let current;
|
|
3538
|
+
for (const line of text.split(/\r?\n/)) {
|
|
3539
|
+
const table2 = /^\s*\[(.*)\]\s*(#.*)?$/.exec(line);
|
|
3540
|
+
if (table2) {
|
|
3541
|
+
current = /^plugins\."([^"]+)"$/.exec(table2[1].trim())?.[1];
|
|
3542
|
+
if (current) plugins.set(current, true);
|
|
3543
|
+
continue;
|
|
3544
|
+
}
|
|
3545
|
+
const flag2 = current && /^\s*enabled\s*=\s*(true|false)\b/.exec(line);
|
|
3546
|
+
if (flag2) plugins.set(current, flag2[1] === "true");
|
|
3547
|
+
}
|
|
3548
|
+
return plugins;
|
|
3549
|
+
}
|
|
3550
|
+
function openCodePlugins() {
|
|
3551
|
+
const dir = path19.join(process.env.XDG_CONFIG_HOME ?? path19.join(os9.homedir(), ".config"), "opencode");
|
|
3552
|
+
const plugins = [];
|
|
3553
|
+
for (const name of ["opencode.json", "opencode.jsonc"]) {
|
|
3554
|
+
try {
|
|
3555
|
+
const text = fs23.readFileSync(path19.join(dir, name), "utf8").replace(/\/\*[\s\S]*?\*\/|^\s*\/\/.*$/gm, "");
|
|
3556
|
+
const list2 = JSON.parse(text).plugin;
|
|
3557
|
+
if (Array.isArray(list2)) plugins.push(...list2.filter((p) => typeof p === "string"));
|
|
3558
|
+
} catch {
|
|
3559
|
+
}
|
|
3560
|
+
}
|
|
3561
|
+
for (const folder of ["plugin", "plugins"]) {
|
|
3562
|
+
try {
|
|
3563
|
+
for (const f of fs23.readdirSync(path19.join(dir, folder))) plugins.push(path19.join(dir, folder, f));
|
|
3564
|
+
} catch {
|
|
3565
|
+
}
|
|
3566
|
+
}
|
|
3567
|
+
return plugins;
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
// src/cli.ts
|
|
3571
|
+
var HELP = `pitroom ${VERSION2} \u2014 a free pit crew for your expensive coding agent.
|
|
3572
|
+
Delegates bounded tasks to a worker agent CLI (${backendIds().join(", ")}), using the
|
|
3573
|
+
worker's own default model, and returns a compact answer, exact changes and a receipt.
|
|
3574
|
+
|
|
3575
|
+
Usage
|
|
3576
|
+
pitroom [run] [options] "task" run a worker (task: argument, "-" for stdin, or --task-file)
|
|
3577
|
+
pitroom crew [options] -g NAME "task 1" "task 2" \u2026
|
|
3578
|
+
start several workers in background as one group
|
|
3579
|
+
pitroom review [run | --range A..B [--plan PLAN]] [--tier T | -W T] [--bg]
|
|
3580
|
+
read-only review of a run's change (a follow-up: only its
|
|
3581
|
+
fix round) or of a commit range; by default on another worker
|
|
3582
|
+
pitroom plan status PLAN [--json] a plan's progress: runs, STATUS, review, fix rounds, applied
|
|
3583
|
+
pitroom plan note PLAN "Task N: \u2026" record a completion, deferred finding or ruling (outside the repo)
|
|
3584
|
+
pitroom status [run | -g NAME] state / live progress (default: latest run)
|
|
3585
|
+
pitroom wait [run\u2026 | -g NAME] [--any] [--brief] [--timeout 540]
|
|
3586
|
+
block until all (or any) are done, then print reports
|
|
3587
|
+
pitroom watch [run\u2026 | -g NAME] [--json] [--interval 2]
|
|
3588
|
+
live table (TTY) or one JSON line per change, until done
|
|
3589
|
+
pitroom show [run] [--patch|--events|--full|--json]
|
|
3590
|
+
pitroom ls [--running] [-g NAME] recent runs
|
|
3591
|
+
pitroom apply [run | -g NAME] apply --isolate patch(es) to your tree (checked first)
|
|
3592
|
+
pitroom discard [run] drop an --isolate run's copy (the patch is kept)
|
|
3593
|
+
pitroom revert [run] undo the changes of a --write run (checked first)
|
|
3594
|
+
pitroom stop [run | -g NAME] stop running or queued workers
|
|
3595
|
+
pitroom savings [--since 7d|30d|all] [--card file.svg] [--badge]
|
|
3596
|
+
pitroom statusline [--then CMD] status-bar line: running workers, savings this week (after CMD's)
|
|
3597
|
+
pitroom hook-card PostToolUse hook: a card after each Bash \`pitroom\` command
|
|
3598
|
+
pitroom doctor [--probe] check workers, models, permissions, skills
|
|
3599
|
+
pitroom config effective settings, where each comes from, config file path
|
|
3600
|
+
pitroom install [--copy] [--force] link the skills into ~/.agents/skills + ~/.claude/skills,
|
|
3601
|
+
and the CLI into ~/.local/bin
|
|
3602
|
+
pitroom uninstall remove what install linked
|
|
3603
|
+
pitroom clean [--days 14] [--yes]
|
|
3604
|
+
|
|
3605
|
+
Run options
|
|
3606
|
+
-r, --read read-only research / review (default)
|
|
3607
|
+
-w, --write edit your working tree; changes are snapshotted and revertible
|
|
3608
|
+
-i, --isolate edit an isolated copy of your current state; you apply the patch
|
|
3609
|
+
-d, --dir PATH project directory (default: cwd)
|
|
3610
|
+
-f, --file PATH attach a file (repeatable)
|
|
3611
|
+
-W, --worker T worker target "backend[:model]" (default: config "worker", else opencode)
|
|
3612
|
+
-m, --model M model for that worker (default: the worker CLI's own default)
|
|
3613
|
+
--tier NAME a worker from the config's "tiers" (e.g. cheap, standard, capable); -W wins
|
|
3614
|
+
--plan PLAN with --step N: implement Task N of a plan (-i or -w); the task text is your notes
|
|
3615
|
+
--step N the plan task for --plan
|
|
3616
|
+
-t, --timeout DUR e.g. 900, 20m, 1h (default 30m, or PITROOM_TIMEOUT)
|
|
3617
|
+
--verify CMD run CMD after the worker (in the isolated copy for --isolate)
|
|
3618
|
+
--link a,b isolate: symlink ignored dirs (e.g. node_modules) into the copy
|
|
3619
|
+
-c, --continue RUN follow up in the same worker session (and the same isolated copy)
|
|
3620
|
+
--bg start in background, print the run id, return immediately
|
|
3621
|
+
-g, --group NAME tag the run as part of a group (crew, wait, watch, apply by group)
|
|
3622
|
+
--web let the worker use webfetch/websearch (off by default)
|
|
3623
|
+
--no-fallback do not fail over to fallback workers on model/provider errors
|
|
3624
|
+
--json print the run record as JSON
|
|
3625
|
+
--task-file PATH read the task from a file (crew: tasks separated by --- lines)
|
|
3626
|
+
--allow-non-git allow --write outside a git repository (no tracking/revert)
|
|
3627
|
+
|
|
3628
|
+
Parallel: at most maxParallel workers (default 4, PITROOM_MAX_PARALLEL) run at once; others queue.
|
|
3629
|
+
One --write run per repository; use --isolate for parallel changes.
|
|
3630
|
+
Exit codes: 0 ok \xB7 1 worker failed \xB7 2 usage \xB7 3 refused/setup \xB7 4 timeout
|
|
3631
|
+
5 read-only violation \xB7 6 verify failed \xB7 75 still running (wait again)
|
|
3632
|
+
Workers: ${backendIds().join(", ")} (targets: "opencode", "opencode:provider/model", or a bare model)
|
|
3633
|
+
Env: PITROOM_WORKER, PITROOM_MODEL, PITROOM_FALLBACK="t1,t2", PITROOM_TIMEOUT, PITROOM_MAX_PARALLEL,
|
|
3634
|
+
PITROOM_PRIMARY=sonnet|opus|haiku|gpt-5, PITROOM_PRICE="in,out", PITROOM_HOME, PITROOM_CONFIG,
|
|
3635
|
+
PITROOM_<WORKER>_BIN
|
|
3636
|
+
Config: ~/.config/pitroom/config.json (worker, fallback, models, tiers, timeout, primary, price, link, web, maxParallel)`;
|
|
3637
|
+
var COMMANDS = {
|
|
3638
|
+
run: cmdRun,
|
|
3639
|
+
crew: cmdCrew,
|
|
3640
|
+
review: cmdReview,
|
|
3641
|
+
plan: cmdPlan,
|
|
3642
|
+
status: cmdStatus,
|
|
3643
|
+
wait: cmdWait,
|
|
3644
|
+
watch: cmdWatch,
|
|
3645
|
+
show: cmdShow,
|
|
3646
|
+
ls: cmdLs,
|
|
3647
|
+
list: cmdLs,
|
|
3648
|
+
apply: cmdApply,
|
|
3649
|
+
revert: cmdRevert,
|
|
3650
|
+
discard: cmdDiscard,
|
|
3651
|
+
stop: cmdStop,
|
|
3652
|
+
savings: cmdSavings,
|
|
3653
|
+
statusline: cmdStatusline,
|
|
3654
|
+
"hook-card": cmdHookCard,
|
|
3655
|
+
doctor: (p) => doctor4(has(p, "probe")),
|
|
3656
|
+
config: cmdConfig,
|
|
3657
|
+
install: cmdInstall,
|
|
3658
|
+
uninstall: cmdUninstall,
|
|
3659
|
+
clean: cmdClean,
|
|
3660
|
+
// internal: the detached process behind --bg
|
|
3661
|
+
__exec: async (p) => exitCodeFor(await execute(readMeta(p.positional[0])))
|
|
3662
|
+
};
|
|
3663
|
+
async function main(argv) {
|
|
3664
|
+
let [name, ...rest] = argv;
|
|
3665
|
+
if (name === void 0) name = "help";
|
|
3666
|
+
else if (name !== "help" && name !== "version" && !COMMANDS[name]) {
|
|
3667
|
+
rest = argv;
|
|
3668
|
+
name = "run";
|
|
3669
|
+
}
|
|
3670
|
+
const p = parse(rest);
|
|
3671
|
+
if (has(p, "help") || name === "help") return console.log(HELP), 0;
|
|
3672
|
+
if (has(p, "version") || name === "version") return console.log(VERSION2), 0;
|
|
3673
|
+
return COMMANDS[name](p);
|
|
3674
|
+
}
|
|
3675
|
+
main(process.argv.slice(2)).then(
|
|
3676
|
+
(code) => process.exit(code),
|
|
3677
|
+
(e) => {
|
|
3678
|
+
if (e instanceof UserError) {
|
|
3679
|
+
console.error(`pitroom: ${e.message}`);
|
|
3680
|
+
process.exit(e.code);
|
|
3681
|
+
}
|
|
3682
|
+
console.error(`pitroom: ${e.stack ?? e}`);
|
|
3683
|
+
process.exit(1);
|
|
3684
|
+
}
|
|
3685
|
+
);
|