@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/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) {
@@ -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
@@ -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", "pantheon_wait", "pantheon_send",
3
- "pantheon_claim", "pantheon_open_goal", "pantheon_history",
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) => row(o, "decision", "you", decisionPrompt(o.id))),
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
- return row(o, "waiting", who, waitingPrompt(who, decisionTitle(o.body), o.goal));
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);
@@ -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
- if (p.asks[cap])
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
- this.failedRuns.set(candidate.id, (this.failedRuns.get(candidate.id) ?? 0) + 1);
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: policy.allowBash, runHome, ...(baseSha ? { base: baseSha } : {}), goal,
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: policy.allowBash, runHome });
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
- : !exited ? `! run failed for ${o.id}: ${detail.split("\n")[0]} (see ${runLog})`
620
- : `! run exited cleanly but sent no act for ${o.id} — see ${runLog}`);
621
- return { obligation: o, branch, worktree, ok, detail };
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 fromStream = arrived.find((a) => a.act === "ANSWER" && a.ref && open && a.ref === open.act);
684
- const body = fromStream?.body ?? (open ? await this.fetchGrantAnswer(open.act) : null);
685
- if (open && body !== null) {
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
- async fetchGrantAnswer(askActId) {
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=0`;
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);
@@ -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("3")
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(_:)))