@join-pantheon/cli 0.24.4 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -1
- package/dist/admin-stats.js +97 -0
- package/dist/cli.js +60 -7
- package/dist/client.js +2 -0
- package/dist/decision-ui.js +31 -9
- package/dist/doctor.js +101 -60
- package/dist/feedback.js +3 -0
- package/dist/grants.js +38 -17
- package/dist/humanize.js +2 -0
- package/dist/install.js +97 -21
- package/dist/mcp-instructions.js +85 -0
- package/dist/mcp-supervisor.js +2 -1
- package/dist/mcp.js +47 -82
- package/dist/measure.js +142 -0
- package/dist/notice.js +7 -2
- package/dist/relay.js +43 -2
- package/dist/roster-file.js +17 -0
- package/dist/run-failure.js +97 -0
- package/dist/setup-guide.js +30 -2
- package/dist/tools.js +9 -2
- package/dist/tray.js +10 -3
- package/dist/worker-policy.js +3 -1
- package/dist/worker.js +107 -13
- package/helper/macos/main.swift +22 -1
- package/helper/macos/menubar.swift +41 -11
- package/package.json +1 -1
package/dist/relay.js
CHANGED
|
@@ -12,7 +12,7 @@ import { Accounts, normalizeUsername, validEmail, validUsername } from "./accoun
|
|
|
12
12
|
import { emailSenderFromEnv } from "./email.js";
|
|
13
13
|
import { Allowlist } from "./roster-file.js";
|
|
14
14
|
import { StateStore } from "./store.js";
|
|
15
|
-
import { activity, mergeActivity, feed, noteRefusal, refusalsSince, countActs, ESCALATIONS, MESSAGE_ACTS, ACT_TONE, DAY } from "./admin-stats.js";
|
|
15
|
+
import { activity, mergeActivity, feed, noteRefusal, refusalsSince, countActs, spaceHealth, fleetReport, ESCALATIONS, MESSAGE_ACTS, ACT_TONE, DAY } from "./admin-stats.js";
|
|
16
16
|
import { fmtLimit, parseLimits, PLAN_NAME } from "./plans.js";
|
|
17
17
|
import { routeDecision } from "./profile.js";
|
|
18
18
|
function workState(m, now = Date.now(), busyTtlMs = LIMITS.activityBusyTtlMs, pruneMs = LIMITS.activityPruneMs) {
|
|
@@ -361,7 +361,7 @@ export class Relay {
|
|
|
361
361
|
onAuthHttp(req, res, url, ip) {
|
|
362
362
|
if (url.pathname === "/")
|
|
363
363
|
return this.landingPage(res);
|
|
364
|
-
if (url.pathname === "/admin" || url.pathname === "/admin/space" || url.pathname === "/admin/export" || url.pathname === "/admin/backup" || url.pathname === "/admin/action" || url.pathname === "/admin/login" || url.pathname === "/admin/logout") {
|
|
364
|
+
if (url.pathname === "/admin" || url.pathname === "/admin/space" || url.pathname === "/admin/export" || url.pathname === "/admin/backup" || url.pathname === "/admin/action" || url.pathname === "/admin/login" || url.pathname === "/admin/logout" || url.pathname === "/admin/report") {
|
|
365
365
|
const A0 = this.accounts;
|
|
366
366
|
if (!A0 || !this.adminToken)
|
|
367
367
|
return this.json(res, 404, { error: "not found" });
|
|
@@ -392,6 +392,15 @@ export class Relay {
|
|
|
392
392
|
return void res.end();
|
|
393
393
|
}
|
|
394
394
|
const authed = (fromCookie && tokenOk(fromCookie)) || (fromQuery && tokenOk(fromQuery));
|
|
395
|
+
if (url.pathname === "/admin/report") {
|
|
396
|
+
if (!authed)
|
|
397
|
+
return this.json(res, 401, { error: "admin token required" });
|
|
398
|
+
const rows = this.fleetHealth();
|
|
399
|
+
if (url.searchParams.get("format") === "json")
|
|
400
|
+
return this.json(res, 200, { relay: VERSION, generated: Date.now(), spaces: rows });
|
|
401
|
+
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
|
|
402
|
+
return void res.end(fleetReport(rows, VERSION) + "\n");
|
|
403
|
+
}
|
|
395
404
|
if (url.pathname === "/admin/export") {
|
|
396
405
|
if (!authed)
|
|
397
406
|
return this.json(res, 403, { error: "sign in at /admin, or append ?token=…" });
|
|
@@ -704,6 +713,37 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
|
|
|
704
713
|
adminCsrf() {
|
|
705
714
|
return createHash("sha256").update("pantheon-admin-csrf " + (this.adminToken ?? "")).digest("hex").slice(0, 32);
|
|
706
715
|
}
|
|
716
|
+
fleetHealth(now = Date.now()) {
|
|
717
|
+
const out = [];
|
|
718
|
+
const seen = new Set();
|
|
719
|
+
const safeOf = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
|
|
720
|
+
for (const sp of this.spaces.values()) {
|
|
721
|
+
seen.add(safeOf(sp.name));
|
|
722
|
+
out.push(spaceHealth({
|
|
723
|
+
name: sp.name, records: sp.ledger.all(), members: sp.allow.all(),
|
|
724
|
+
online: [...sp.members.values()].filter((m) => m.sessions.size > 0).length, relayPkg: VERSION, now,
|
|
725
|
+
}));
|
|
726
|
+
}
|
|
727
|
+
let files = [];
|
|
728
|
+
try {
|
|
729
|
+
files = readdirSync(this.dataDir);
|
|
730
|
+
}
|
|
731
|
+
catch { }
|
|
732
|
+
for (const f of files) {
|
|
733
|
+
if (!f.endsWith(".members.json"))
|
|
734
|
+
continue;
|
|
735
|
+
const safe = f.slice(0, -".members.json".length);
|
|
736
|
+
if (seen.has(safe))
|
|
737
|
+
continue;
|
|
738
|
+
let members = [];
|
|
739
|
+
try {
|
|
740
|
+
members = new Allowlist(this.dataDir, safe).all();
|
|
741
|
+
}
|
|
742
|
+
catch { }
|
|
743
|
+
out.push(spaceHealth({ name: safe, records: this.sleepingRecords(safe), members, online: 0, relayPkg: VERSION, now }));
|
|
744
|
+
}
|
|
745
|
+
return out;
|
|
746
|
+
}
|
|
707
747
|
spacesOverview() {
|
|
708
748
|
const now = Date.now();
|
|
709
749
|
const out = new Map();
|
|
@@ -2455,6 +2495,7 @@ pantheon signup --email you@work.com --username you</code></pre>
|
|
|
2455
2495
|
m.agent = agent;
|
|
2456
2496
|
m.last_seen = Date.now();
|
|
2457
2497
|
m.sessions.set(session, { id: session, role, agent, ...(pkg ? { pkg } : {}), ws });
|
|
2498
|
+
sp.allow.touch(id, pkg, agent);
|
|
2458
2499
|
return m;
|
|
2459
2500
|
}
|
|
2460
2501
|
work(m) {
|
package/dist/roster-file.js
CHANGED
|
@@ -146,6 +146,23 @@ export class Allowlist {
|
|
|
146
146
|
this.flush();
|
|
147
147
|
return true;
|
|
148
148
|
}
|
|
149
|
+
static TOUCH_EVERY_MS = 10 * 60_000;
|
|
150
|
+
touch(member, pkg, agent, now = Date.now()) {
|
|
151
|
+
const e = this.entries.get(member);
|
|
152
|
+
if (!e)
|
|
153
|
+
return false;
|
|
154
|
+
const known = agent && agent !== "unknown" ? agent : undefined;
|
|
155
|
+
const changed = (pkg !== undefined && pkg !== e.last_pkg) || (known !== undefined && known !== e.last_agent);
|
|
156
|
+
if (!changed && e.last_seen !== undefined && now - e.last_seen < Allowlist.TOUCH_EVERY_MS)
|
|
157
|
+
return false;
|
|
158
|
+
e.last_seen = now;
|
|
159
|
+
if (pkg !== undefined)
|
|
160
|
+
e.last_pkg = pkg;
|
|
161
|
+
if (known !== undefined)
|
|
162
|
+
e.last_agent = known;
|
|
163
|
+
this.flush();
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
149
166
|
admittedCount() { return [...this.entries.values()].filter((e) => e.admitted).length; }
|
|
150
167
|
founder() {
|
|
151
168
|
const f = [...this.entries.values()].filter((e) => e.admitted && (e.admitted_by === e.member || e.admitted_by === undefined)).sort((a, b) => a.first_seen - b.first_seen)[0];
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
const sh = (dir, ...args) => execFileSync("git", ["-C", dir, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000 }).trim();
|
|
3
|
+
export const gitProbe = {
|
|
4
|
+
isRepo: (dir) => { try {
|
|
5
|
+
return sh(dir, "rev-parse", "--is-inside-work-tree") === "true";
|
|
6
|
+
}
|
|
7
|
+
catch {
|
|
8
|
+
return false;
|
|
9
|
+
} },
|
|
10
|
+
hasRemote: (dir) => { try {
|
|
11
|
+
return sh(dir, "remote").split("\n").some((r) => r.trim());
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
} },
|
|
16
|
+
dirty: (dir) => { try {
|
|
17
|
+
return sh(dir, "status", "--porcelain").split("\n").map((l) => l.trim()).filter(Boolean);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return [];
|
|
21
|
+
} },
|
|
22
|
+
branchOf: (dir) => { try {
|
|
23
|
+
return sh(dir, "rev-parse", "--abbrev-ref", "HEAD");
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return "";
|
|
27
|
+
} },
|
|
28
|
+
};
|
|
29
|
+
export function exitLine(detail) {
|
|
30
|
+
const lines = detail.split("\n").map((l) => l.trim());
|
|
31
|
+
const first = lines[0] ?? "";
|
|
32
|
+
if (!/^exit\s+-?\d+$/.test(first))
|
|
33
|
+
return first.slice(0, 120);
|
|
34
|
+
const said = lines.slice(1).find((l) => l && !/^\s*$/.test(l));
|
|
35
|
+
return (said ? `${first}: ${said}` : first).slice(0, 120);
|
|
36
|
+
}
|
|
37
|
+
const list = (paths, max = 3) => paths.length <= max ? paths.join(", ") : `${paths.slice(0, max).join(", ")} and ${paths.length - max} more`;
|
|
38
|
+
const ask = (fn, fallback) => { try {
|
|
39
|
+
return fn();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return fallback;
|
|
43
|
+
} };
|
|
44
|
+
export function diagnoseRun(f, probe = gitProbe) {
|
|
45
|
+
if (!f.exited) {
|
|
46
|
+
return {
|
|
47
|
+
cause: "crashed",
|
|
48
|
+
says: `The agent did not finish — ${exitLine(f.detail)}.`,
|
|
49
|
+
fix: `Read the transcript at ${f.runLog}. If the agent CLI is broken or missing, pantheon doctor names it.`,
|
|
50
|
+
dir: f.worktree,
|
|
51
|
+
lines: [`cat ${f.runLog}`],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
if (!f.branch) {
|
|
55
|
+
return {
|
|
56
|
+
cause: "not_a_repo",
|
|
57
|
+
says: "There is no git repo here, so the run had no branch and nothing it could deliver.",
|
|
58
|
+
fix: `It can still answer and escalate. To let it deliver work, point the worker at a checkout: pantheon service install worker from inside the repo.`,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (!f.allowBash) {
|
|
62
|
+
return {
|
|
63
|
+
cause: "no_commands",
|
|
64
|
+
says: "This worker may edit files but not run commands, so it could not run tests, commit or push.",
|
|
65
|
+
fix: "Allow commands if you want unattended runs to deliver work: pantheon setup --commands yes. Until then a woken run can answer and draft, never deliver.",
|
|
66
|
+
capability: "run_commands",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
if (!ask(() => probe.hasRemote(f.project), true)) {
|
|
70
|
+
return {
|
|
71
|
+
cause: "no_remote",
|
|
72
|
+
says: "This repo has no remote, so nothing the run builds can be fetched by the teammate who asked.",
|
|
73
|
+
fix: "Add a remote your teammate can reach, then the run can push a branch and DELIVER it.",
|
|
74
|
+
dir: f.project,
|
|
75
|
+
lines: ["git remote add origin <url>", "git push -u origin HEAD"],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const dirty = ask(() => probe.dirty(f.project), []);
|
|
79
|
+
if (dirty.length) {
|
|
80
|
+
const on = ask(() => probe.branchOf(f.project), "");
|
|
81
|
+
return {
|
|
82
|
+
cause: "human_has_it",
|
|
83
|
+
says: `The work is uncommitted in your own checkout${on ? ` on ${on}` : ""} — a run never sees that, it starts from what origin has.`,
|
|
84
|
+
fix: "Commit and push it yourself, then the run (or your teammate) can see it. A worker deliberately never touches your working tree.",
|
|
85
|
+
dir: f.project,
|
|
86
|
+
lines: [`git status --short # ${list(dirty)}`, "git add -A", `git commit -m "…"`, `git push${on ? ` -u origin ${on}` : ""}`],
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
cause: "silent",
|
|
91
|
+
says: "The run finished cleanly but sent nothing back, so the request is still open.",
|
|
92
|
+
fix: `Read what it did at ${f.runLog}. If it was right to stay silent, answer the teammate yourself; if not, the transcript says where it stopped.`,
|
|
93
|
+
dir: f.worktree,
|
|
94
|
+
lines: [`cat ${f.runLog}`],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
//# sourceMappingURL=run-failure.js.map
|
package/dist/setup-guide.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { approvalSummary, installForUser } from "./install.js";
|
|
2
3
|
import { execFileSync } from "node:child_process";
|
|
3
4
|
import { homedir } from "node:os";
|
|
4
5
|
import { join } from "node:path";
|
|
@@ -33,6 +34,7 @@ export function guideDefaults(profile, cfg) {
|
|
|
33
34
|
return {
|
|
34
35
|
wake: profile.agent.wake_default ?? "anyone",
|
|
35
36
|
commands: profile.agent.allow_bash ?? false,
|
|
37
|
+
approval: profile.agent.approval ?? "approve",
|
|
36
38
|
name: profile.human.display_name || cfg.display || cfg.member,
|
|
37
39
|
title: profile.human.title || "",
|
|
38
40
|
decides: Object.entries(profile.authority).filter(([, m]) => m === "human_required").map(([d]) => d),
|
|
@@ -64,7 +66,7 @@ export function questionnaire() {
|
|
|
64
66
|
"",
|
|
65
67
|
"Only 3 and 4 need the person every time. Ask 2 whenever there is a repo (it is the one default people are",
|
|
66
68
|
"surprised by). The rest have defaults you may take and say out loud:",
|
|
67
|
-
" --wake anyone --decides none --alerts decisions --playbook yes --base <what origin calls its default>",
|
|
69
|
+
" --wake anyone --decides none --alerts decisions --playbook yes --base <what origin calls its default> --approve-all",
|
|
68
70
|
"Without a repo, --wake, --commands, --playbook and --base do nothing yet: skip them and say so.",
|
|
69
71
|
"",
|
|
70
72
|
"1. While they're away, may teammates hand work to this machine? (their agent runs here on its own,",
|
|
@@ -86,6 +88,13 @@ export function questionnaire() {
|
|
|
86
88
|
"8. Which branch should workers start from, and deliveries be checked against? Usually the branch this",
|
|
87
89
|
" project deploys or releases from (often the default branch). → --base <branch>",
|
|
88
90
|
"",
|
|
91
|
+
"9. Should their own agent ask them before each message it sends to a teammate in their name? Default no:",
|
|
92
|
+
" the agent coordinates for them without stopping. Reads and waiting never prompt either way.",
|
|
93
|
+
opt("approve-all", "", "No — send, open goals and claim work without asking (recommended; the default)"),
|
|
94
|
+
opt("ask-before-sending", "", "Yes — show each send, goal and claim for approval first"),
|
|
95
|
+
" These are switches: pass --approve-all or --ask-before-sending with no value. Either way the install",
|
|
96
|
+
" output prints the exact line written to ~/.codex/config.toml, and pantheon leave removes it.",
|
|
97
|
+
"",
|
|
89
98
|
"Example: pantheon setup --yes --wake anyone --commands yes --name \"Praneet Sinha\" --title Founder \\",
|
|
90
99
|
" --decides deployment --alerts decisions --playbook yes --base main",
|
|
91
100
|
].join("\n");
|
|
@@ -133,6 +142,12 @@ export function parseSetupFlags(argv) {
|
|
|
133
142
|
const playbook = yn("playbook");
|
|
134
143
|
if (playbook !== undefined)
|
|
135
144
|
preset.persona = playbook;
|
|
145
|
+
if (argv.includes("--ask-before-sending") && argv.includes("--approve-all"))
|
|
146
|
+
errors.push("--ask-before-sending and --approve-all contradict each other; pass one");
|
|
147
|
+
else if (argv.includes("--ask-before-sending"))
|
|
148
|
+
preset.approval = "ask-before-sending";
|
|
149
|
+
else if (argv.includes("--approve-all"))
|
|
150
|
+
preset.approval = "approve";
|
|
136
151
|
const base = val("base");
|
|
137
152
|
if (base !== undefined && base.trim())
|
|
138
153
|
preset.base = base.trim();
|
|
@@ -179,6 +194,9 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
|
|
|
179
194
|
: a.wake === "anyone" ? "teammates may hand work to this machine while you're away; invites you send grant it, and you can revoke anyone (pantheon admit <name> --revoke)"
|
|
180
195
|
: "only people you approve by hand (pantheon admit <name> --may-wake)"}`);
|
|
181
196
|
io.say(` • Alerts: ${shortAlert(a.notifications).toLowerCase()}`);
|
|
197
|
+
io.say(` • Approval: ${a.approval === "approve"
|
|
198
|
+
? "your agent sends to teammates in your name without asking each time (Claude Code, Cursor and Codex)"
|
|
199
|
+
: "your agent asks you before each send, goal and claim; reading and waiting never prompt"}`);
|
|
182
200
|
io.say(` • Decisions: ${a.decides.length ? `you personally decide ${a.decides.join(", ")}` : "none reserved for you — your agent may decide; add an area when one comes up"}`);
|
|
183
201
|
if (a.wake !== "no" && ctx.project)
|
|
184
202
|
io.say(` • Playbook: ${a.persona ? "a short starter playbook for your worker, in this repo" : "none"}`);
|
|
@@ -212,6 +230,8 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
|
|
|
212
230
|
const b = await io.choose("Which branch should your worker start from, and deliveries be checked against? Usually the branch this project deploys or releases from.", [`${suggestedBase}${suggestedBase === detected ? " (what origin calls its default)" : ""}`, "A different branch"], 0);
|
|
213
231
|
a.base = b === 0 ? suggestedBase : ((await io.ask("Branch name", suggestedBase)).trim() || suggestedBase);
|
|
214
232
|
}
|
|
233
|
+
const ap = await io.choose("Should your agent ask you before each message it sends to a teammate in your name? Reading the board and waiting for peers never prompt either way.", ["No — coordinate for me without stopping to ask (recommended)", "Yes — show me each send, goal and claim first"], a.approval === "approve" ? 0 : 1);
|
|
234
|
+
a.approval = ap === 0 ? "approve" : "ask-before-sending";
|
|
215
235
|
}
|
|
216
236
|
}
|
|
217
237
|
let next = a.title ? applyProfileInit(profile, { name: a.name, title: a.title }) : { ...profile, human: { ...profile.human, display_name: a.name } };
|
|
@@ -227,7 +247,7 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
|
|
|
227
247
|
next = {
|
|
228
248
|
...next,
|
|
229
249
|
authority,
|
|
230
|
-
agent: { ...next.agent, wake_enabled: a.wake !== "no", wake_default: a.wake, allow_bash: a.commands },
|
|
250
|
+
agent: { ...next.agent, wake_enabled: a.wake !== "no", wake_default: a.wake, allow_bash: a.commands, approval: a.approval },
|
|
231
251
|
notifications: {
|
|
232
252
|
decisions: a.notifications !== "none",
|
|
233
253
|
deliveries: a.notifications === "deliveries" || a.notifications === "worker",
|
|
@@ -246,6 +266,14 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
|
|
|
246
266
|
warnings.push(`profile saved but not published (${e.message}) — run \`pantheon profile publish\` when the relay is reachable`);
|
|
247
267
|
}
|
|
248
268
|
}
|
|
269
|
+
const wiring = deps.applyAgentWiring ?? ((ap) => installForUser(ap).map((t) => ({ tool: t.tool, status: t.status })));
|
|
270
|
+
try {
|
|
271
|
+
const codex = wiring(a.approval).find((t) => t.tool === "codex");
|
|
272
|
+
applied.push(`approval: ${approvalSummary(a.approval)}${codex ? ` — codex: ${codex.status}` : ""}`);
|
|
273
|
+
}
|
|
274
|
+
catch (e) {
|
|
275
|
+
warnings.push(`agent wiring not re-applied (${e.message}) — run pantheon install --user`);
|
|
276
|
+
}
|
|
249
277
|
if (a.wake === "no") {
|
|
250
278
|
if (installed("worker")) {
|
|
251
279
|
try {
|
package/dist/tools.js
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
export const PANTHEON_TOOL_NAMES = [
|
|
2
|
-
"pantheon_status", "pantheon_brief", "
|
|
3
|
-
"
|
|
2
|
+
"pantheon_status", "pantheon_brief", "pantheon_directory", "pantheon_route_decision",
|
|
3
|
+
"pantheon_wait", "pantheon_history",
|
|
4
|
+
"pantheon_send", "pantheon_open_goal", "pantheon_claim",
|
|
4
5
|
];
|
|
6
|
+
export const WRITE_TOOL_NAMES = ["pantheon_send", "pantheon_open_goal", "pantheon_claim"];
|
|
7
|
+
export const READ_TOOL_NAMES = PANTHEON_TOOL_NAMES.filter((t) => !WRITE_TOOL_NAMES.includes(t));
|
|
5
8
|
export const PANTHEON_TOOLS = PANTHEON_TOOL_NAMES.map((t) => `mcp__pantheon__${t}`);
|
|
9
|
+
export const APPROVAL_MODES = ["approve", "ask-before-sending"];
|
|
10
|
+
export function toolsAllowed(approval) {
|
|
11
|
+
return approval === "ask-before-sending" ? READ_TOOL_NAMES : PANTHEON_TOOL_NAMES;
|
|
12
|
+
}
|
|
6
13
|
//# sourceMappingURL=tools.js.map
|
package/dist/tray.js
CHANGED
|
@@ -11,7 +11,7 @@ import { requireDeveloperTools } from "./notifier.js";
|
|
|
11
11
|
import { plistPath, serviceInstalled, whichPantheon } from "./service.js";
|
|
12
12
|
import { VERSION } from "./version.js";
|
|
13
13
|
import { LIMITS } from "./protocol.js";
|
|
14
|
-
import { allDecisionsPrompt, decisionPrompt, decisionTitle, owedPrompt, waitingPrompt, workerSummary } from "./decision-ui.js";
|
|
14
|
+
import { allDecisionsPrompt, decisionPrompt, decisionTitle, owedPrompt, stripCeremony, waitingPrompt, workerSummary } from "./decision-ui.js";
|
|
15
15
|
import { loadProfile, SURFACE_BUNDLE_IDS } from "./profile.js";
|
|
16
16
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
17
17
|
export const trayBinaryPath = (home = CONFIG_DIR) => join(home, "bin", "pantheon-tray");
|
|
@@ -118,6 +118,7 @@ async function trayStatusOne(cfg) {
|
|
|
118
118
|
const decisionsOk = ok;
|
|
119
119
|
const decisions = mineHuman
|
|
120
120
|
.map((o) => ({ id: o.id, body: decisionTitle(o.body), detail: o.body, prompt: decisionPrompt(o.id) }));
|
|
121
|
+
const senderOf = (o) => stripCeremony(o.body).from;
|
|
121
122
|
const row = (o, kind, who, prompt) => ({
|
|
122
123
|
id: o.id, kind, title: decisionTitle(o.body), headline: decisionTitle(o.body, 200), detail: o.body, prompt, who,
|
|
123
124
|
when: o.created_at ?? 0,
|
|
@@ -127,11 +128,17 @@ async function trayStatusOne(cfg) {
|
|
|
127
128
|
const owed = all.filter((o) => o.owner === cfg.member);
|
|
128
129
|
const waiting = all.filter((o) => o.owner !== cfg.member && o.owner !== `human:${cfg.member}`);
|
|
129
130
|
const items = [
|
|
130
|
-
...mineHuman.map((o) =>
|
|
131
|
+
...mineHuman.map((o) => {
|
|
132
|
+
const from = senderOf(o);
|
|
133
|
+
return row(o, "decision", !from || from === cfg.member ? "your agent" : from, decisionPrompt(o.id));
|
|
134
|
+
}),
|
|
131
135
|
...owed.map((o) => row(o, "owed", "you", owedPrompt(decisionTitle(o.body), o.goal))),
|
|
132
136
|
...waiting.map((o) => {
|
|
133
137
|
const who = o.owner.replace(/^human:/, "");
|
|
134
|
-
|
|
138
|
+
const r = row(o, "waiting", who, waitingPrompt(who, decisionTitle(o.body), o.goal));
|
|
139
|
+
return o.owner.startsWith("human:")
|
|
140
|
+
? { ...r, title: `${who} decides: ${r.title}`, headline: `${who} decides: ${r.headline}` }
|
|
141
|
+
: r;
|
|
135
142
|
}),
|
|
136
143
|
];
|
|
137
144
|
const profile = loadProfile(cfg);
|
package/dist/worker-policy.js
CHANGED
|
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { CONFIG_PATH } from "./config.js";
|
|
4
4
|
export const ASK_COOLDOWN_MS = 24 * 60 * 60 * 1_000;
|
|
5
|
+
export const ASK_EXPIRY_MS = 14 * 24 * 60 * 60 * 1_000;
|
|
5
6
|
export function policyHome(home) {
|
|
6
7
|
return home ?? process.env.PANTHEON_HOME ?? dirname(CONFIG_PATH);
|
|
7
8
|
}
|
|
@@ -41,7 +42,8 @@ export function revoke(p, cap) {
|
|
|
41
42
|
export function mayAsk(p, cap, now = Date.now()) {
|
|
42
43
|
if (p.grants[cap])
|
|
43
44
|
return false;
|
|
44
|
-
|
|
45
|
+
const open = p.asks[cap];
|
|
46
|
+
if (open && now - open.at < ASK_EXPIRY_MS)
|
|
45
47
|
return false;
|
|
46
48
|
const deniedAt = p.denied[cap];
|
|
47
49
|
return deniedAt === undefined || now - deniedAt >= ASK_COOLDOWN_MS;
|
package/dist/worker.js
CHANGED
|
@@ -14,6 +14,7 @@ import { loadContinued, markContinued } from "./continued.js";
|
|
|
14
14
|
import { loadProfile, reservedDecisionsNote } from "./profile.js";
|
|
15
15
|
import { clip } from "./brief.js";
|
|
16
16
|
import { composeWorker } from "./notice.js";
|
|
17
|
+
import { diagnoseRun, gitProbe } from "./run-failure.js";
|
|
17
18
|
import { installedTools, preferredTool } from "./notifier.js";
|
|
18
19
|
import { loadPersonas, matchPersona, personaByName, personaMatches, effectivePolicy, personaBrief, describePersona, } from "./persona.js";
|
|
19
20
|
import { isContinuationFor, LIMITS, shouldDeferToHuman } from "./protocol.js";
|
|
@@ -180,6 +181,13 @@ Act, reply if a reply is owed, then finish.`;
|
|
|
180
181
|
}
|
|
181
182
|
let fleetLock = null;
|
|
182
183
|
const CONTINUATION_HORIZON_MS = 24 * 60 * 60_000;
|
|
184
|
+
export function answerWindow(askSeq, hint, fullScanDue) {
|
|
185
|
+
if (askSeq !== null)
|
|
186
|
+
return Math.max(0, askSeq - 1);
|
|
187
|
+
if (fullScanDue)
|
|
188
|
+
return 0;
|
|
189
|
+
return Math.max(0, hint);
|
|
190
|
+
}
|
|
183
191
|
export class Worker {
|
|
184
192
|
cfg;
|
|
185
193
|
opts;
|
|
@@ -492,10 +500,13 @@ export class Worker {
|
|
|
492
500
|
this.done.push(job);
|
|
493
501
|
this.client?.reportWorkerState("idle");
|
|
494
502
|
if (candidate && !job.ok) {
|
|
495
|
-
|
|
503
|
+
const failures = (this.failedRuns.get(candidate.id) ?? 0) + 1;
|
|
504
|
+
this.failedRuns.set(candidate.id, failures);
|
|
496
505
|
if (this.failedRuns.size > 200) {
|
|
497
506
|
this.failedRuns.delete(this.failedRuns.keys().next().value);
|
|
498
507
|
}
|
|
508
|
+
if (failures >= 2 && job.diagnosis)
|
|
509
|
+
await this.reportStuck(client, candidate, job.diagnosis);
|
|
499
510
|
}
|
|
500
511
|
this.running = false;
|
|
501
512
|
if (fleetLock === this)
|
|
@@ -537,11 +548,22 @@ export class Worker {
|
|
|
537
548
|
const trigger = client.recent(act.id) ?? `(${act.act} ${act.id} is not in this session's buffer)`;
|
|
538
549
|
return this.runJob(client, o, persona, policy, (branch) => buildContinuationBrief(act, trigger, branch, persona), false);
|
|
539
550
|
}
|
|
551
|
+
commandGrant(persona) {
|
|
552
|
+
if (persona?.allowBash === false)
|
|
553
|
+
return false;
|
|
554
|
+
const policy = loadPolicy(this.opts.home);
|
|
555
|
+
if (!useGrant(policy, "run_commands"))
|
|
556
|
+
return false;
|
|
557
|
+
savePolicy(policy, this.opts.home);
|
|
558
|
+
this.opts.log(` bash on for this run — your human granted run_commands`);
|
|
559
|
+
return true;
|
|
560
|
+
}
|
|
540
561
|
async runJob(client, o, persona, policy, makeBrief, requireReply) {
|
|
541
562
|
const goal = o.goal ?? "adhoc";
|
|
542
563
|
if (this.opts.allowBash && !policy.allowBash && persona) {
|
|
543
564
|
this.opts.log(` bash off for this run — persona "${persona.name}" says allow_bash: false`);
|
|
544
565
|
}
|
|
566
|
+
const allowBash = policy.allowBash || this.commandGrant(persona);
|
|
545
567
|
let branch = branchName(this.cfg.space, goal, this.client?.goals.get(goal)?.title);
|
|
546
568
|
let worktree = join(this.opts.scratch, goal);
|
|
547
569
|
let baseRef = "", baseSha = "";
|
|
@@ -566,14 +588,14 @@ export class Worker {
|
|
|
566
588
|
const plan = headlessSpawn({
|
|
567
589
|
backend: { id: "claude-code", label: "Claude Code", binaries: ["claude"] },
|
|
568
590
|
binary: this.opts.agentCmd,
|
|
569
|
-
prompt: brief, worktree, cfg: this.cfg, branch, allowBash
|
|
591
|
+
prompt: brief, worktree, cfg: this.cfg, branch, allowBash, runHome, ...(baseSha ? { base: baseSha } : {}), goal,
|
|
570
592
|
});
|
|
571
593
|
materializeSpawnFiles(worktree, plan);
|
|
572
594
|
({ command, args, env } = { command: plan.command, args: plan.args, env: { ...env, ...plan.env } });
|
|
573
595
|
}
|
|
574
596
|
else {
|
|
575
597
|
const { backend, binary } = resolveAgent(policy.agent ?? this.opts.agent);
|
|
576
|
-
const plan = headlessSpawn({ backend, binary, prompt: brief, worktree, cfg: this.cfg, branch, allowBash
|
|
598
|
+
const plan = headlessSpawn({ backend, binary, prompt: brief, worktree, cfg: this.cfg, branch, allowBash, runHome });
|
|
577
599
|
materializeSpawnFiles(worktree, plan);
|
|
578
600
|
({ command, args, env } = { command: plan.command, args: plan.args, env: { ...env, ...plan.env } });
|
|
579
601
|
this.opts.log(` spawn ${command} ${args.slice(0, 3).join(" ")}…`);
|
|
@@ -607,18 +629,56 @@ export class Worker {
|
|
|
607
629
|
}, this.opts.home);
|
|
608
630
|
const sent = client.mineSince(startedAt, o.goal);
|
|
609
631
|
const quiet = ok && !requireReply && sent.length === 0;
|
|
632
|
+
const diagnosis = ok ? undefined : diagnoseRun({ exited, replied, allowBash, branch, project: this.opts.project, worktree, detail, runLog }, this.opts.probe ?? gitProbe);
|
|
610
633
|
if (this.opts.notify && !quiet && (!ok || loadProfile(this.cfg).notifications?.worker_done)) {
|
|
611
634
|
try {
|
|
612
635
|
const reply = sent[sent.length - 1] ?? null;
|
|
613
|
-
this.opts.notify(composeWorker({ ok, triggeredBy, obligationBody: o.body, goal: o.goal ? client.goals.get(o.goal) : undefined, reply, obligationRef: o.ref }, (() => { const tools = installedTools(); return { tools, preferred: preferredTool(loadProfile(this.cfg), tools, this.opts.home) }; })()));
|
|
636
|
+
this.opts.notify(composeWorker({ ok, triggeredBy, obligationBody: o.body, goal: o.goal ? client.goals.get(o.goal) : undefined, reply, obligationRef: o.ref, ...(diagnosis ? { diagnosis } : {}) }, (() => { const tools = installedTools(); return { tools, preferred: preferredTool(loadProfile(this.cfg), tools, this.opts.home) }; })()));
|
|
614
637
|
}
|
|
615
638
|
catch { }
|
|
616
639
|
}
|
|
617
640
|
this.opts.log(quiet ? `< read ${o.id} on ${branch} — the thread was already settled, nothing sent`
|
|
618
641
|
: ok ? `< handled ${o.id} on ${branch}`
|
|
619
|
-
:
|
|
620
|
-
|
|
621
|
-
|
|
642
|
+
: `! ${o.id} stopped [${diagnosis.cause}]: ${diagnosis.says} (transcript: ${runLog})`);
|
|
643
|
+
return { obligation: o, branch, worktree, ok, detail, ...(diagnosis ? { diagnosis } : {}) };
|
|
644
|
+
}
|
|
645
|
+
async reportStuck(client, o, d) {
|
|
646
|
+
const key = `stuck:${o.id}`;
|
|
647
|
+
const space = this.spaceKey();
|
|
648
|
+
if (loadContinued(space, this.opts.home).has(key))
|
|
649
|
+
return;
|
|
650
|
+
const policy = loadPolicy(this.opts.home);
|
|
651
|
+
const asking = !!d.capability && mayAsk(policy, d.capability);
|
|
652
|
+
const what = clip(o.body, 120);
|
|
653
|
+
const lines = [
|
|
654
|
+
`Two runs on this machine could not finish ${o.kind === "CONTINUE" ? "a follow-up" : `the ${o.kind}`} it owes: ${what}`,
|
|
655
|
+
``,
|
|
656
|
+
`WHY IT STOPPED. ${d.says}`,
|
|
657
|
+
`WHAT UNBLOCKS IT. ${d.fix}`,
|
|
658
|
+
...(d.dir && d.lines?.length ? [``, `On this machine, in ${d.dir}:`, ...d.lines.map((l) => ` ${l}`)] : []),
|
|
659
|
+
``,
|
|
660
|
+
`Nothing was sent to the teammate who asked, and the request is still open on them.`,
|
|
661
|
+
`It will not be retried unattended — a live session here, or you, has to move it.`,
|
|
662
|
+
];
|
|
663
|
+
if (asking) {
|
|
664
|
+
lines.push(``, `MAY THE WORKER RUN COMMANDS ON THIS MACHINE? Answer "allow always", "allow once", or "deny".`, `Today it may edit files only, which is why it could not commit or push. Allowing it lets a woken`, `run execute tests and git in its own isolated worktree — never in your checkout.`);
|
|
665
|
+
}
|
|
666
|
+
try {
|
|
667
|
+
const sent = await client.send({
|
|
668
|
+
act: "ESCALATE", goal: o.goal, ref: o.ref, after_seq: client.readSeq,
|
|
669
|
+
summary: clip(`Stuck on you: ${d.says}`, 190),
|
|
670
|
+
body: lines.join("\n"),
|
|
671
|
+
});
|
|
672
|
+
markContinued(space, key, this.opts.home);
|
|
673
|
+
if (asking && d.capability) {
|
|
674
|
+
recordAsk(policy, d.capability, sent.id);
|
|
675
|
+
savePolicy(policy, this.opts.home);
|
|
676
|
+
}
|
|
677
|
+
this.opts.log(`* escalated ${o.id}: ${d.cause}${asking ? ` (and asked for ${d.capability})` : ""}`);
|
|
678
|
+
}
|
|
679
|
+
catch (e) {
|
|
680
|
+
this.opts.log(`! could not report the stuck obligation on the ledger: ${e.message}`);
|
|
681
|
+
}
|
|
622
682
|
}
|
|
623
683
|
enqueueContinuations(client, arrived) {
|
|
624
684
|
this.loadContinued();
|
|
@@ -678,13 +738,18 @@ export class Worker {
|
|
|
678
738
|
const home = this.opts.home;
|
|
679
739
|
{
|
|
680
740
|
const policy = loadPolicy(home);
|
|
681
|
-
const open = policy.asks.self_upgrade;
|
|
682
741
|
let changed = false;
|
|
683
|
-
const
|
|
684
|
-
|
|
685
|
-
|
|
742
|
+
for (const cap of Object.keys(policy.asks)) {
|
|
743
|
+
const open = policy.asks[cap];
|
|
744
|
+
if (!open)
|
|
745
|
+
continue;
|
|
746
|
+
const fromStream = arrived.find((a) => a.act === "ANSWER" && a.ref === open.act);
|
|
747
|
+
const body = fromStream?.body ?? await this.fetchGrantAnswer(open.act, client.head - 200);
|
|
748
|
+
if (body === null)
|
|
749
|
+
continue;
|
|
686
750
|
const applied = applyAnswer(policy, open.act, body);
|
|
687
751
|
if (applied) {
|
|
752
|
+
this.askAt.delete(open.act);
|
|
688
753
|
changed = true;
|
|
689
754
|
this.opts.log(`* grant request answered: ${applied.cap} -> ${applied.verdict}`);
|
|
690
755
|
}
|
|
@@ -756,10 +821,39 @@ export class Worker {
|
|
|
756
821
|
}
|
|
757
822
|
}
|
|
758
823
|
}
|
|
759
|
-
|
|
824
|
+
askAt = new Map();
|
|
825
|
+
lastFullScan = 0;
|
|
826
|
+
async actSeq(id) {
|
|
827
|
+
try {
|
|
828
|
+
const base = this.cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
|
|
829
|
+
const q = `space=${encodeURIComponent(this.cfg.space)}&token=${encodeURIComponent(this.cfg.token)}`;
|
|
830
|
+
const res = await fetch(`${base}/act?${q}&id=${encodeURIComponent(id)}`, { signal: AbortSignal.timeout(3000) });
|
|
831
|
+
if (!res.ok)
|
|
832
|
+
return null;
|
|
833
|
+
const j = await res.json();
|
|
834
|
+
return typeof j.act?.seq === "number" ? j.act.seq : null;
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
async fetchGrantAnswer(askActId, since = 0) {
|
|
841
|
+
let seq = this.askAt.get(askActId) ?? null;
|
|
842
|
+
if (seq === null) {
|
|
843
|
+
seq = await this.actSeq(askActId);
|
|
844
|
+
if (seq !== null)
|
|
845
|
+
this.askAt.set(askActId, seq);
|
|
846
|
+
}
|
|
847
|
+
let due = false;
|
|
848
|
+
if (seq === null) {
|
|
849
|
+
due = Date.now() - this.lastFullScan > 5 * 60_000;
|
|
850
|
+
if (due)
|
|
851
|
+
this.lastFullScan = Date.now();
|
|
852
|
+
}
|
|
853
|
+
const from = answerWindow(seq, since, due);
|
|
760
854
|
try {
|
|
761
855
|
const base = this.cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
|
|
762
|
-
const url = `${base}/history?space=${encodeURIComponent(this.cfg.space)}&token=${encodeURIComponent(this.cfg.token)}&since
|
|
856
|
+
const url = `${base}/history?space=${encodeURIComponent(this.cfg.space)}&token=${encodeURIComponent(this.cfg.token)}&since=${from}`;
|
|
763
857
|
const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
|
|
764
858
|
const j = await res.json();
|
|
765
859
|
const ans = (j.acts ?? []).find((a) => a.act === "ANSWER" && a.ref === askActId);
|
package/helper/macos/main.swift
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* (the choice becomes the default), through a .command file
|
|
10
10
|
* Terminal runs — no automation permission needed
|
|
11
11
|
* Show details prints the act and the decision in Terminal
|
|
12
|
+
* Fix it here (a failed run) opens a Terminal where the work is, steps shown
|
|
12
13
|
* Done (decisions) answers it as handled, silently — the reminders stop
|
|
13
14
|
* Dismiss / × closes
|
|
14
15
|
*
|
|
@@ -26,13 +27,15 @@ import AppKit
|
|
|
26
27
|
let args = CommandLine.arguments
|
|
27
28
|
|
|
28
29
|
if args.count == 2, args[1] == "version" {
|
|
29
|
-
print("
|
|
30
|
+
print("4")
|
|
30
31
|
exit(0)
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
struct Tool: Decodable { let id: String; let label: String; let kind: String?; let bundle: String?; let deeplink: String? }
|
|
34
35
|
struct Primary: Decodable { let label: String; let `default`: String; let tools: [Tool]; let prompt: String }
|
|
35
36
|
struct Details: Decodable { let act: String?; let decision: String? }
|
|
37
|
+
/// The one thing a person could do about a failed run: where, and which steps.
|
|
38
|
+
struct Fix: Decodable { let label: String; let dir: String; let lines: [String]; let why: String }
|
|
36
39
|
struct Notice: Decodable {
|
|
37
40
|
let kind: String
|
|
38
41
|
let who: String
|
|
@@ -44,6 +47,7 @@ struct Notice: Decodable {
|
|
|
44
47
|
let sound: Bool
|
|
45
48
|
let primary: Primary?
|
|
46
49
|
let details: Details?
|
|
50
|
+
let fix: Fix?
|
|
47
51
|
/** where to write the chosen action and which .command files to open — supplied by the CLI */
|
|
48
52
|
let bin_dir: String
|
|
49
53
|
/** the pantheon CLI to run silently for one-click actions — supplied by the CLI, since this helper has no PATH worth trusting */
|
|
@@ -158,6 +162,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
158
162
|
NSApp.terminate(nil)
|
|
159
163
|
}
|
|
160
164
|
|
|
165
|
+
/// Fix it here: open a Terminal where the work is, with the steps shown. The
|
|
166
|
+
/// CLI asks before running anything — a run that just failed is the wrong
|
|
167
|
+
/// moment to take another unattended action on someone's machine.
|
|
168
|
+
@objc func fixIt(_ sender: Any?) {
|
|
169
|
+
guard let f = notice.fix else { NSApp.terminate(nil); return }
|
|
170
|
+
act(["action": "fix", "dir": f.dir, "lines": f.lines, "why": f.why], command: "pantheon-details.command")
|
|
171
|
+
}
|
|
172
|
+
|
|
161
173
|
@objc func legacyOpen(_ sender: Any?) {
|
|
162
174
|
if !legacyPrompt.isEmpty { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(legacyPrompt, forType: .string) }
|
|
163
175
|
if let b = legacyBundle, let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: b) {
|
|
@@ -280,6 +292,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|
|
280
292
|
constraints += [d.leadingAnchor.constraint(equalTo: pull.trailingAnchor, constant: 14), d.centerYAnchor.constraint(equalTo: pull.centerYAnchor)]
|
|
281
293
|
trailing = d
|
|
282
294
|
}
|
|
295
|
+
if notice.fix != nil {
|
|
296
|
+
let fix = NSButton(title: notice.fix!.label, target: self, action: #selector(fixIt(_:)))
|
|
297
|
+
fix.isBordered = false; fix.font = .systemFont(ofSize: 12, weight: .medium); fix.contentTintColor = .controlAccentColor
|
|
298
|
+
fix.toolTip = notice.fix!.why
|
|
299
|
+
fix.translatesAutoresizingMaskIntoConstraints = false
|
|
300
|
+
root.addSubview(fix)
|
|
301
|
+
constraints += [fix.leadingAnchor.constraint(equalTo: trailing.trailingAnchor, constant: 14), fix.centerYAnchor.constraint(equalTo: pull.centerYAnchor)]
|
|
302
|
+
trailing = fix
|
|
303
|
+
}
|
|
283
304
|
if notice.details?.decision != nil {
|
|
284
305
|
// A decision can be settled from the banner itself: one click, no terminal, no more reminders.
|
|
285
306
|
let done = NSButton(title: "Done", target: self, action: #selector(markDone(_:)))
|