@join-pantheon/cli 0.24.4 → 0.25.1

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) {
@@ -263,6 +263,15 @@ export class Relay {
263
263
  : {}),
264
264
  });
265
265
  }
266
+ if (url.pathname === "/join") {
267
+ const token = (url.searchParams.get("token") ?? "").trim();
268
+ if (!/^[A-Za-z0-9_-]{16,64}$/.test(token))
269
+ return this.json(res, 400, { error: "an invite code is required: /join?token=<code>" });
270
+ const team = this.teamOfInvite(token);
271
+ return team
272
+ ? this.json(res, 200, { team })
273
+ : this.json(res, 404, { error: "no open invite matches that code — it may have been used already or expired" });
274
+ }
266
275
  if (url.pathname === "/act") {
267
276
  const space = this.spaces.get(url.searchParams.get("space") ?? "");
268
277
  const token = url.searchParams.get("token") ?? "";
@@ -361,7 +370,7 @@ export class Relay {
361
370
  onAuthHttp(req, res, url, ip) {
362
371
  if (url.pathname === "/")
363
372
  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") {
373
+ 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
374
  const A0 = this.accounts;
366
375
  if (!A0 || !this.adminToken)
367
376
  return this.json(res, 404, { error: "not found" });
@@ -392,6 +401,15 @@ export class Relay {
392
401
  return void res.end();
393
402
  }
394
403
  const authed = (fromCookie && tokenOk(fromCookie)) || (fromQuery && tokenOk(fromQuery));
404
+ if (url.pathname === "/admin/report") {
405
+ if (!authed)
406
+ return this.json(res, 401, { error: "admin token required" });
407
+ const rows = this.fleetHealth();
408
+ if (url.searchParams.get("format") === "json")
409
+ return this.json(res, 200, { relay: VERSION, generated: Date.now(), spaces: rows });
410
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
411
+ return void res.end(fleetReport(rows, VERSION) + "\n");
412
+ }
395
413
  if (url.pathname === "/admin/export") {
396
414
  if (!authed)
397
415
  return this.json(res, 403, { error: "sign in at /admin, or append ?token=…" });
@@ -704,6 +722,71 @@ button:hover{background:#1d4ed8}.err{color:var(--bad);margin-top:12px}
704
722
  adminCsrf() {
705
723
  return createHash("sha256").update("pantheon-admin-csrf " + (this.adminToken ?? "")).digest("hex").slice(0, 32);
706
724
  }
725
+ teamOfInvite(token) {
726
+ const exact = (al) => { const inv = al.findInvite(token); return !!inv && inv.token === token; };
727
+ for (const sp of this.spaces.values())
728
+ if (exact(sp.allow))
729
+ return sp.name;
730
+ const safeOf = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
731
+ const live = new Set([...this.spaces.keys()].map(safeOf));
732
+ let files = [];
733
+ try {
734
+ files = readdirSync(this.dataDir);
735
+ }
736
+ catch {
737
+ return null;
738
+ }
739
+ for (const f of files) {
740
+ if (!f.endsWith(".members.json"))
741
+ continue;
742
+ const safe = f.slice(0, -".members.json".length);
743
+ if (live.has(safe))
744
+ continue;
745
+ try {
746
+ if (!exact(new Allowlist(this.dataDir, safe)))
747
+ continue;
748
+ const named = this.sleepingRecords(safe).find((r) => r.space)?.space;
749
+ if (named)
750
+ return named;
751
+ if (safeOf(safe) === safe && !/_/.test(safe))
752
+ return safe;
753
+ return null;
754
+ }
755
+ catch { }
756
+ }
757
+ return null;
758
+ }
759
+ fleetHealth(now = Date.now()) {
760
+ const out = [];
761
+ const seen = new Set();
762
+ const safeOf = (name) => name.replace(/[^a-z0-9._-]/gi, "_");
763
+ for (const sp of this.spaces.values()) {
764
+ seen.add(safeOf(sp.name));
765
+ out.push(spaceHealth({
766
+ name: sp.name, records: sp.ledger.all(), members: sp.allow.all(),
767
+ online: [...sp.members.values()].filter((m) => m.sessions.size > 0).length, relayPkg: VERSION, now,
768
+ }));
769
+ }
770
+ let files = [];
771
+ try {
772
+ files = readdirSync(this.dataDir);
773
+ }
774
+ catch { }
775
+ for (const f of files) {
776
+ if (!f.endsWith(".members.json"))
777
+ continue;
778
+ const safe = f.slice(0, -".members.json".length);
779
+ if (seen.has(safe))
780
+ continue;
781
+ let members = [];
782
+ try {
783
+ members = new Allowlist(this.dataDir, safe).all();
784
+ }
785
+ catch { }
786
+ out.push(spaceHealth({ name: safe, records: this.sleepingRecords(safe), members, online: 0, relayPkg: VERSION, now }));
787
+ }
788
+ return out;
789
+ }
707
790
  spacesOverview() {
708
791
  const now = Date.now();
709
792
  const out = new Map();
@@ -2455,6 +2538,7 @@ pantheon signup --email you@work.com --username you</code></pre>
2455
2538
  m.agent = agent;
2456
2539
  m.last_seen = Date.now();
2457
2540
  m.sessions.set(session, { id: session, role, agent, ...(pkg ? { pkg } : {}), ws });
2541
+ sp.allow.touch(id, pkg, agent);
2458
2542
  return m;
2459
2543
  }
2460
2544
  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
package/dist/service.js CHANGED
@@ -164,14 +164,22 @@ export function uninstallService(kind) {
164
164
  export function restartService(kind, exec = (c, a) => execFileSync(c, a, { stdio: "ignore" })) {
165
165
  if (!serviceInstalled(kind))
166
166
  return [` ${kind}: not installed here`];
167
+ const uid = process.getuid?.() ?? 501;
167
168
  try {
168
169
  if (platform() === "darwin")
169
- exec("launchctl", ["kickstart", "-k", `gui/${process.getuid?.() ?? 501}/${LABEL(kind)}`]);
170
+ exec("launchctl", ["kickstart", "-k", `gui/${uid}/${LABEL(kind)}`]);
170
171
  else
171
172
  exec("systemctl", ["--user", "restart", `pantheon-${kind}`]);
172
173
  return [` ${kind}: restarted on the new code`];
173
174
  }
174
175
  catch (e) {
176
+ if (platform() === "darwin") {
177
+ try {
178
+ exec("launchctl", ["bootstrap", `gui/${uid}`, plistPath(kind)]);
179
+ return [` ${kind}: was not loaded in launchd — loaded and started on the new code`];
180
+ }
181
+ catch { }
182
+ }
175
183
  return [` ${kind}: could not restart (${e.message.split("\n")[0]}) — pantheon service restart ${kind}`];
176
184
  }
177
185
  }
@@ -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);
package/dist/upgrade.js CHANGED
@@ -16,6 +16,16 @@ function pkgVersion(root) {
16
16
  }
17
17
  }
18
18
  export const PKG_NAME = "@join-pantheon/cli";
19
+ export function rewireVia(root, fallback, run = (file, args) => execFileSync(process.execPath, [file, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], timeout: 120_000 })) {
20
+ try {
21
+ const out = JSON.parse(run(join(root, "dist", "cli.js"), ["rewire", "--json"]));
22
+ if (Array.isArray(out) && out.every((o) => o && typeof o.tool === "string" && typeof o.status === "string")) {
23
+ return out.map((o) => ({ tool: o.tool, status: o.status }));
24
+ }
25
+ }
26
+ catch { }
27
+ return fallback();
28
+ }
19
29
  export function defaultDeps() {
20
30
  const git = (root, ...args) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
21
31
  return {
@@ -29,7 +39,7 @@ export function defaultDeps() {
29
39
  pull: (root) => git(root, "pull", "--ff-only"),
30
40
  build: (root) => execFileSync("npm", ["install", "--no-audit", "--no-fund"], { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }),
31
41
  version: pkgVersion,
32
- rewire: () => installForUser().map((t) => ({ tool: t.tool, status: t.status })),
42
+ rewire: () => rewireVia(repoRoot(), () => installForUser().map((t) => ({ tool: t.tool, status: t.status }))),
33
43
  running: () => {
34
44
  const up = [];
35
45
  try {
@@ -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;