@join-pantheon/cli 0.21.3 → 0.23.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.
@@ -59,7 +59,13 @@ export function questionnaire() {
59
59
  const opt = (flag, id, label) => ` ${label} → --${flag} ${id}`;
60
60
  return [
61
61
  "pantheon setup — the questions, and the flag for each answer.",
62
- "Ask your human each one in plain words, then run: pantheon setup --yes <flags>",
62
+ "Ask your human in plain words, then pass the answers as flags — to `pantheon setup --yes`, or straight to",
63
+ "`pantheon start` / `pantheon join`, which apply them the moment the team exists.",
64
+ "",
65
+ "Only 3 and 4 need the person every time. Ask 2 whenever there is a repo (it is the one default people are",
66
+ "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>",
68
+ "Without a repo, --wake, --commands, --playbook and --base do nothing yet: skip them and say so.",
63
69
  "",
64
70
  "1. While they're away, may teammates hand work to this machine? (their agent runs here on its own,",
65
71
  " in a separate copy of the repo, and delivers on a branch for review)",
@@ -71,7 +77,7 @@ export function questionnaire() {
71
77
  `4. Their role, in a word (${TITLE_OPTIONS.slice(0, -1).join(", ")}, or their own). → --title \"…\"`,
72
78
  "5. Which of these do they decide personally? Agents will stop and ask them first. Any number, or none.",
73
79
  ...DECISION_AREAS.map((x) => ` ${x.label} → ${x.id}`),
74
- " pass them comma-separated: --decides deployment,pricing (or --decides none)",
80
+ " pass them comma-separated: --decides deployment,pricing (or --decides none) — the full list; an area not named is released",
75
81
  "6. Which desktop alerts do they want?",
76
82
  ...ALERT_OPTIONS.map((o) => opt("alerts", o.id, o.label)),
77
83
  "7. Should their worker follow a short written playbook for this repo? (skip if --wake no)",
@@ -146,42 +152,76 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
146
152
  }
147
153
  const applied = [], warnings = [];
148
154
  if (io) {
149
- io.say("A few choices only you can make. Pick a number; Enter keeps the suggested one.\n");
150
- const wakeIdx = await io.choose("While you're away, may teammates hand work to this machine? Your agent would run here on its own, in a separate copy of the repo, and deliver on a branch for review.", WAKE_OPTIONS.map((o) => o.label), Math.max(0, WAKE_OPTIONS.findIndex((o) => o.id === d.wake)));
151
- a.wake = WAKE_OPTIONS[wakeIdx].id;
152
- if (a.wake !== "no" && ctx.project) {
153
- const c = await io.choose("When a teammate's request wakes this machine, what may the run do?", ["Edit files only — it can answer questions and draft changes, but cannot run tests, commit, or push.",
154
- "Run commands too — needed to run tests and to commit and push work for review. Only requests from people you granted wake permission start a run."], a.commands ? 1 : 0);
155
- a.commands = c === 1;
156
- }
157
- else if (a.wake === "no") {
158
- a.commands = false;
159
- }
155
+ io.say("Three questions, then one screen of defaults. Enter keeps the suggested answer.\n");
160
156
  a.name = (await io.ask("Your name, as teammates will see it", d.name)).trim() || d.name;
161
157
  const known = TITLE_OPTIONS.findIndex((t) => t.toLowerCase() === d.title.toLowerCase());
162
158
  const t = await io.choose("Your role, in a word — it goes on the team directory", TITLE_OPTIONS, known >= 0 ? known : d.title ? TITLE_OPTIONS.length - 1 : 0);
163
159
  a.title = t === TITLE_OPTIONS.length - 1 ? (await io.ask("Your role", d.title)).trim() || d.title || "Member" : TITLE_OPTIONS[t];
164
- const picks = await io.chooseMany("Which of these do you decide personally? Agents — yours and your teammates' — will stop and ask you before acting on them. Pick any number, or none.", DECISION_AREAS.map((x) => x.label), d.decides.map((id) => DECISION_AREAS.findIndex((x) => x.id === id)).filter((i) => i >= 0));
165
- a.decides = [...new Set(picks.map((i) => DECISION_AREAS[i].id))];
166
- const n = await io.choose("Which desktop alerts do you want?", ALERT_OPTIONS.map((o) => o.label), Math.max(0, ALERT_OPTIONS.findIndex((o) => o.id === d.notifications)));
167
- a.notifications = ALERT_OPTIONS[n].id;
160
+ const askCommands = async () => {
161
+ const undecided = profile.agent.allow_bash === undefined && preset.commands === undefined;
162
+ const c = await io.choose("Teammates can hand work to this machine while you're away. It runs in a separate copy of the repo and delivers on a branch you review. May a run also execute commands?", ["Yes — run tests, commit, and push work for review (recommended)",
163
+ "No — edit files and answer only; it cannot run tests, commit, or push"], undecided || a.commands ? 0 : 1);
164
+ a.commands = c === 0;
165
+ };
166
+ let commandsAsked = false;
168
167
  if (a.wake !== "no" && ctx.project) {
169
- const p = await io.choose("Should your worker follow a short written playbook for this repo? It says what a woken run may do here and what it must leave to you. You can edit it any time.", ["Yes — write a starter playbook now (recommended)", "Not now"], 0);
170
- a.persona = p === 0;
168
+ await askCommands();
169
+ commandsAsked = true;
171
170
  }
172
171
  const detected = ctx.project && deps.setRepoBase ? detectBaseBranch(ctx.project) : null;
173
- if (detected) {
174
- const suggested = preset.base ?? detected;
175
- 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.", [`${suggested}${suggested === detected ? " (what origin calls its default)" : ""}`, "A different branch"], 0);
176
- a.base = b === 0 ? suggested : ((await io.ask("Branch name", suggested)).trim() || suggested);
172
+ const suggestedBase = detected ? (preset.base ?? detected) : undefined;
173
+ if (a.wake !== "no" && ctx.project)
174
+ a.persona = preset.persona ?? true;
175
+ const shortAlert = (id) => ALERT_OPTIONS.find((o) => o.id === id).label.split(" — ")[0].replace(/ \(recommended\)/, "");
176
+ io.say("\nThe rest has defaults. Change them now, or any time with `pantheon setup`:");
177
+ io.say(` • Wake: ${a.wake === "no" ? "off — this machine only works while you are at it"
178
+ : !ctx.project ? "on once this machine has a repo (run pantheon setup from your project's repo)"
179
+ : 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
+ : "only people you approve by hand (pantheon admit <name> --may-wake)"}`);
181
+ io.say(` • Alerts: ${shortAlert(a.notifications).toLowerCase()}`);
182
+ 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
+ if (a.wake !== "no" && ctx.project)
184
+ io.say(` • Playbook: ${a.persona ? "a short starter playbook for your worker, in this repo" : "none"}`);
185
+ if (suggestedBase)
186
+ io.say(` • Base branch: ${suggestedBase}${suggestedBase === detected ? " (what origin calls its default)" : ""}`);
187
+ const keep = await io.choose("Keep these?", ["Keep them (recommended)", "Change some"], 0);
188
+ if (keep === 0) {
189
+ if (suggestedBase)
190
+ a.base = suggestedBase;
191
+ }
192
+ else {
193
+ const wakeIdx = await io.choose("While you're away, may teammates hand work to this machine? Your agent would run here on its own, in a separate copy of the repo, and deliver on a branch for review.", WAKE_OPTIONS.map((o) => o.label), Math.max(0, WAKE_OPTIONS.findIndex((o) => o.id === a.wake)));
194
+ a.wake = WAKE_OPTIONS[wakeIdx].id;
195
+ if (a.wake === "no") {
196
+ a.commands = false;
197
+ a.persona = false;
198
+ }
199
+ else if (ctx.project && !commandsAsked) {
200
+ await askCommands();
201
+ a.persona = preset.persona ?? true;
202
+ }
203
+ const picks = await io.chooseMany("Which of these do you decide personally? Agents — yours and your teammates' — will stop and ask you before acting on them. Pick any number, or none.", DECISION_AREAS.map((x) => x.label), a.decides.map((id) => DECISION_AREAS.findIndex((x) => x.id === id)).filter((i) => i >= 0));
204
+ a.decides = [...new Set(picks.map((i) => DECISION_AREAS[i].id))];
205
+ const n = await io.choose("Which desktop alerts do you want?", ALERT_OPTIONS.map((o) => o.label), Math.max(0, ALERT_OPTIONS.findIndex((o) => o.id === a.notifications)));
206
+ a.notifications = ALERT_OPTIONS[n].id;
207
+ if (a.wake !== "no" && ctx.project) {
208
+ const p = await io.choose("Should your worker follow a short written playbook for this repo? It says what a woken run may do here and what it must leave to you. You can edit it any time.", ["Yes — write a starter playbook now (recommended)", "Not now"], a.persona ? 0 : 1);
209
+ a.persona = p === 0;
210
+ }
211
+ if (suggestedBase) {
212
+ 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
+ a.base = b === 0 ? suggestedBase : ((await io.ask("Branch name", suggestedBase)).trim() || suggestedBase);
214
+ }
177
215
  }
178
216
  }
179
217
  let next = a.title ? applyProfileInit(profile, { name: a.name, title: a.title }) : { ...profile, human: { ...profile.human, display_name: a.name } };
180
218
  const authority = { ...next.authority };
219
+ const prior = guideDefaults(profile, ctx.cfg).decides;
220
+ const replace = !!io || preset.decides !== undefined;
181
221
  for (const area of DECISION_AREAS) {
182
222
  if (a.decides.includes(area.id))
183
223
  authority[area.id] = "human_required";
184
- else if (authority[area.id] === "human_required" && d.decides.includes(area.id) && io)
224
+ else if (authority[area.id] === "human_required" && prior.includes(area.id) && replace)
185
225
  delete authority[area.id];
186
226
  }
187
227
  next = {
package/dist/tray.js CHANGED
@@ -87,6 +87,8 @@ export async function trayStatus(cfg = load()) {
87
87
  ...first,
88
88
  space: ok.map((p) => p.space).join(" · "),
89
89
  relay_ok: ok.every((p) => p.relay_ok),
90
+ decisions_ok: ok.every((p) => p.decisions_ok),
91
+ unavailable_spaces: ok.flatMap((p) => p.unavailable_spaces ?? []),
90
92
  online: ok.reduce((n, p) => n + (p.online ?? 0), 0),
91
93
  members: ok.reduce((n, p) => n + (p.members ?? 0), 0),
92
94
  decisions: ok.flatMap((p) => (p.decisions ?? []).map((d) => ({ ...d, body: `[${p.space}] ${d.body}` }))),
@@ -106,8 +108,10 @@ async function trayStatusOne(cfg) {
106
108
  ]);
107
109
  const space = health
108
110
  ?.spaces?.find((s) => s.name === cfg.space);
109
- const decisions = (obligations?.obligations ?? [])
110
- .map((o) => ({ id: o.id, body: decisionTitle(o.body), prompt: decisionPrompt(o.id) }));
111
+ const pending = obligations?.obligations;
112
+ const decisionsOk = Array.isArray(pending) && pending.every((o) => o && typeof o.id === "string" && typeof o.body === "string");
113
+ const decisions = (decisionsOk ? pending : [])
114
+ .map((o) => ({ id: o.id, body: decisionTitle(o.body), detail: o.body, prompt: decisionPrompt(o.id) }));
111
115
  const profile = loadProfile(cfg);
112
116
  const surface = profile.agent.primary_surface;
113
117
  const surfaceName = surface === "claude-code" ? "Claude" : surface === "cursor" ? "Cursor" : "Codex";
@@ -119,6 +123,8 @@ async function trayStatusOne(cfg) {
119
123
  member: cfg.member,
120
124
  relay: cfg.relay,
121
125
  relay_ok: !!space,
126
+ decisions_ok: decisionsOk,
127
+ unavailable_spaces: !space || !decisionsOk ? [cfg.space] : [],
122
128
  online: space?.online ?? 0,
123
129
  members: space?.members ?? 0,
124
130
  worker_running: workerProc().running,
package/dist/worker.js CHANGED
@@ -10,7 +10,7 @@ import { applyAnswer, loadPolicy, mayAsk, needsSelfUpgrade, recordAsk, savePolic
10
10
  import { agentHookStates, repairHook } from "./notify.js";
11
11
  import { fence, assess } from "./untrusted.js";
12
12
  import { allowed, recordRun } from "./spend.js";
13
- import { loadProfile } from "./profile.js";
13
+ import { loadProfile, reservedDecisionsNote } from "./profile.js";
14
14
  import { clip, workerDoneNotice } from "./brief.js";
15
15
  import { loadPersonas, matchPersona, personaByName, personaMatches, effectivePolicy, personaBrief, describePersona, } from "./persona.js";
16
16
  import { isContinuationFor, LIMITS, shouldDeferToHuman } from "./protocol.js";
@@ -536,7 +536,8 @@ export class Worker {
536
536
  this.opts.log(`! ${detail}`);
537
537
  return { obligation: o, branch, worktree, ok: false, detail };
538
538
  }
539
- const brief = makeBrief(branch) + baseNote(baseRef, baseSha);
539
+ const reserved = reservedDecisionsNote(loadProfile(this.cfg));
540
+ const brief = makeBrief(branch) + baseNote(baseRef, baseSha) + (reserved ? `\n\n${reserved}` : "");
540
541
  writeFileSync(join(worktree, ".pantheon-task.md"), brief);
541
542
  let command;
542
543
  let args;
@@ -692,6 +693,10 @@ export class Worker {
692
693
  result = { ok: false, alreadyCurrent: false, from: VERSION, to: relayPkg ?? "?" };
693
694
  this.opts.log(`! self-upgrade failed: ${e.message}`);
694
695
  }
696
+ if (result.ok && !result.alreadyCurrent && result.to === result.from) {
697
+ this.opts.log(`* self-upgrade: the registry does not serve ${relayPkg} yet (installed ${result.to}); will check again`);
698
+ return;
699
+ }
695
700
  if (result.ok && !result.alreadyCurrent) {
696
701
  await client.send({
697
702
  act: "NOTE", goal: null, after_seq: client.readSeq,
@@ -26,6 +26,8 @@ let pollSeconds = argv.count >= 3 ? (Double(argv[2]) ?? 15) : 15
26
26
  struct TrayState {
27
27
  var configured = false
28
28
  var reachable = false // did tray-status itself run and parse
29
+ var decisionsOk = false
30
+ var unavailableSpaces: [String] = []
29
31
  var relayOk = false
30
32
  var space = ""
31
33
  var member = ""
@@ -40,7 +42,7 @@ struct TrayState {
40
42
  var surfaceName = "agent"
41
43
  var surfaceBundleID = ""
42
44
  var decisionsPrompt = ""
43
- var decisions: [(id: String, body: String, prompt: String)] = []
45
+ var decisions: [(id: String, body: String, detail: String, prompt: String)] = []
44
46
  var version = ""
45
47
  }
46
48
 
@@ -70,6 +72,8 @@ func fetchState() -> TrayState {
70
72
  s.reachable = true
71
73
  s.configured = j["configured"] as? Bool ?? false
72
74
  s.version = j["version"] as? String ?? ""
75
+ s.decisionsOk = j["decisions_ok"] as? Bool ?? false
76
+ s.unavailableSpaces = j["unavailable_spaces"] as? [String] ?? []
73
77
  s.relayOk = j["relay_ok"] as? Bool ?? false
74
78
  s.space = j["space"] as? String ?? ""
75
79
  s.member = j["member"] as? String ?? ""
@@ -88,6 +92,7 @@ func fetchState() -> TrayState {
88
92
  s.decisions.append((
89
93
  id: d["id"] as? String ?? "",
90
94
  body: d["body"] as? String ?? "",
95
+ detail: d["detail"] as? String ?? d["body"] as? String ?? "",
91
96
  prompt: d["prompt"] as? String ?? ""
92
97
  ))
93
98
  }
@@ -98,6 +103,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
98
103
  private var item: NSStatusItem!
99
104
  private var state = TrayState()
100
105
  private var timer: Timer?
106
+ private var refreshing = false
107
+ private var menuOpen = false
108
+ private var hasLoaded = false
109
+ private var workerAction: String?
110
+ private var actionError: String?
101
111
 
102
112
  func applicationDidFinishLaunching(_ note: Notification) {
103
113
  NSApp.setActivationPolicy(.accessory)
@@ -105,6 +115,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
105
115
  let menu = NSMenu()
106
116
  menu.delegate = self
107
117
  item.menu = menu
118
+ paint()
108
119
  refresh()
109
120
  timer = Timer.scheduledTimer(withTimeInterval: pollSeconds, repeats: true) { [weak self] _ in
110
121
  self?.refresh()
@@ -112,12 +123,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
112
123
  }
113
124
 
114
125
  @objc func refresh() {
126
+ guard !refreshing else { return }
127
+ refreshing = true
115
128
  DispatchQueue.global().async {
116
129
  let next = fetchState()
117
- DispatchQueue.main.async { self.state = next; self.paint() }
130
+ DispatchQueue.main.async {
131
+ self.state = next
132
+ self.hasLoaded = true
133
+ self.refreshing = false
134
+ self.paint()
135
+ if !self.menuOpen, let menu = self.item.menu { self.menuNeedsUpdate(menu) }
136
+ }
118
137
  }
119
138
  }
120
139
 
140
+ // Keep targets stable while a person is navigating the menu. The badge can
141
+ // refresh immediately; refreshed rows appear on the next opening.
142
+ func menuWillOpen(_ menu: NSMenu) { menuOpen = true; refresh() }
143
+ func menuDidClose(_ menu: NSMenu) { menuOpen = false }
144
+
121
145
  enum MarkMode { case full, idle, off }
122
146
 
123
147
  /// The Three Stones mark — the product's own logo, the same three arcs the
@@ -164,68 +188,87 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
164
188
  private func paint() {
165
189
  let mode: MarkMode = !state.configured || !state.relayOk ? .off : state.workerRunning ? .full : .idle
166
190
  item.button?.image = markImage(mode)
167
- item.button?.title = state.decisions.isEmpty ? "" : " \(state.decisions.count)"
191
+ let unknown = hasLoaded && (!state.reachable || (state.configured && !state.decisionsOk))
192
+ item.button?.title = actionError != nil ? " !" : state.decisions.isEmpty ? (unknown ? " !" : "") : " \(state.decisions.count)\(unknown ? "+" : "")"
168
193
  item.button?.imagePosition = .imageLeading
169
- item.button?.toolTip = state.configured
170
- ? "pantheon — \(state.member) in \(state.space)\(state.relayOk ? "" : " (relay unreachable)")"
171
- : "pantheon — not joined to a space yet"
194
+ let summary = !hasLoaded ? "Checking status…" : !state.reachable ? "Unable to check status" : !state.configured ? "Not joined to a team yet" : !state.decisionsOk ? "Unable to check every decision" : state.decisions.isEmpty ? "No decisions need your input" : "\(state.decisions.count) decisions need your input"
195
+ item.button?.toolTip = "Pantheon — " + (actionError ?? summary)
196
+ item.button?.setAccessibilityLabel("Pantheon — " + (actionError ?? summary))
172
197
  }
173
198
 
174
199
  func menuNeedsUpdate(_ menu: NSMenu) {
175
200
  menu.removeAllItems()
176
201
  let info = { (text: String) in
177
- let mi = NSMenuItem(title: text, action: nil, keyEquivalent: ""); mi.isEnabled = false
202
+ let title = text.count > 110 ? String(text.prefix(109)) + "…" : text
203
+ let mi = NSMenuItem(title: title, action: nil, keyEquivalent: "")
204
+ mi.isEnabled = false
205
+ mi.toolTip = text
206
+ mi.attributedTitle = NSAttributedString(string: title, attributes: [.foregroundColor: NSColor.labelColor])
178
207
  menu.addItem(mi)
179
208
  }
180
209
 
181
- if !state.reachable {
182
- info("pantheon CLI is not answering")
183
- info("check: \(pantheonBin)")
210
+ if !hasLoaded {
211
+ info("Checking Pantheon…")
212
+ } else if !state.reachable {
213
+ info("Unable to check Pantheon status")
214
+ info("Try Refresh now, or Check connection below")
184
215
  } else if !state.configured {
185
- info("Not joined to a space yet")
186
- info("Run `pantheon start` or `pantheon join <invite>`")
216
+ info("Connect with your team")
217
+ info("Run pantheon start, or join with an invite link")
187
218
  } else {
188
- info("\(state.member) in \(state.space) · v\(state.version)")
219
+ info("\(state.member) · \(state.space)")
189
220
  info(state.relayOk
190
- ? "Relay reachable · \(state.online) of \(state.members) members online"
191
- : "Relay unreachable — peers cannot reach you")
192
- info("\(state.workerRunning ? "●" : "○") Unattended work \(state.workerRunning ? "on" : "off") " +
193
- "\(state.notifierRunning ? "●" : "○") Alerts \(state.hookOn ? "●" : "○") Wake hook")
194
- if !state.workerState.isEmpty {
195
- info("Worker: " + String(state.workerState.prefix(80)))
196
- }
197
-
221
+ ? "Connected · \(state.online) of \(state.members) members online"
222
+ : "Connection unavailable · \(state.unavailableSpaces.joined(separator: ", "))")
198
223
  menu.addItem(.separator())
224
+ if !state.decisionsOk {
225
+ info("Some decisions could not be checked")
226
+ info("Refresh to try again. The count may be incomplete.")
227
+ }
199
228
  if state.decisions.isEmpty {
200
- info("You are all caught up")
229
+ if state.decisionsOk { info("No decisions need your input") }
201
230
  } else {
202
231
  let noun = state.decisions.count == 1 ? "decision needs" : "decisions need"
203
232
  info("\(state.decisions.count) \(noun) your input")
204
- info("Choose one — your agent opens with the question already asked")
205
233
  for d in state.decisions.prefix(5) {
206
- let item = makeItem(String(d.body.prefix(100)), #selector(openDecision(_:)))
207
- item.representedObject = [d.id, d.prompt]
208
- menu.addItem(item)
234
+ let decision = makeItem(d.body.count > 72 ? String(d.body.prefix(71)) + "…" : d.body, #selector(openDecision(_:)))
235
+ decision.toolTip = d.detail
236
+ decision.representedObject = [d.id, d.prompt]
237
+ menu.addItem(decision)
209
238
  }
210
- if state.decisions.count > 1 {
211
- menu.addItem(makeItem("Explain all in \(state.surfaceName)", #selector(openAllDecisions)))
212
- }
213
- menu.addItem(makeItem("View all decision details", #selector(openApprovals)))
239
+ menu.addItem(makeItem("\(state.decisions.count > 5 ? "Review all \(state.decisions.count) decisions" : "Review decisions") in \(state.surfaceName)", #selector(openAllDecisions)))
214
240
  }
215
-
216
241
  menu.addItem(.separator())
217
- if state.workerRunning {
242
+ info(state.workerRunning ? "Unattended work is on" : state.workerInstalled ? "Unattended work is paused" : "Unattended work is not set up")
243
+ if !state.workerState.isEmpty && state.workerRunning { info(state.workerState) }
244
+ if let action = workerAction {
245
+ info(action)
246
+ } else if state.workerRunning {
218
247
  menu.addItem(makeItem("Pause unattended work", #selector(pauseWorker)))
219
248
  } else if state.workerInstalled {
220
249
  menu.addItem(makeItem("Resume unattended work", #selector(resumeWorker)))
221
250
  } else {
222
- info("Unattended work not set up (pantheon service install worker)")
251
+ info("Set up with pantheon service install worker")
223
252
  }
224
253
  }
254
+ if let error = actionError {
255
+ menu.addItem(.separator())
256
+ info(error)
257
+ }
225
258
 
259
+ let details = NSMenuItem(title: "Diagnostics", action: nil, keyEquivalent: "")
260
+ let submenu = NSMenu()
261
+ for text in ["Version \(state.version)", "Alerts: \(state.notifierRunning ? "running" : "off")", "Wake hook: \(state.hookOn ? "installed" : "off")"] {
262
+ let row = NSMenuItem(title: text, action: nil, keyEquivalent: "")
263
+ row.isEnabled = false
264
+ submenu.addItem(row)
265
+ }
266
+ submenu.addItem(makeItem("View decision details in Terminal", #selector(openApprovals)))
267
+ details.submenu = submenu
268
+ menu.addItem(details)
226
269
  menu.addItem(.separator())
227
270
  menu.addItem(makeItem("Open status in Terminal", #selector(openStatus)))
228
- menu.addItem(makeItem("Run doctor in Terminal", #selector(openDoctor)))
271
+ menu.addItem(makeItem("Check connection in Terminal", #selector(openDoctor)))
229
272
  menu.addItem(makeItem("Refresh now", #selector(refreshNow)))
230
273
  menu.addItem(.separator())
231
274
  menu.addItem(makeItem("Quit pantheon menu bar", #selector(quit)))
@@ -237,15 +280,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
237
280
  return mi
238
281
  }
239
282
 
283
+ private func changeWorker(_ title: String, path: String, args: [String]) {
284
+ guard workerAction == nil else { return }
285
+ workerAction = title
286
+ actionError = nil
287
+ paint()
288
+ if let menu = item.menu { menuNeedsUpdate(menu) }
289
+ DispatchQueue.global().async {
290
+ let result = run(path, args)
291
+ DispatchQueue.main.async {
292
+ self.workerAction = nil
293
+ if result?.code != 0 { self.reportFailure("Could not change unattended work. Check connection in Terminal.") }
294
+ self.refresh()
295
+ }
296
+ }
297
+ }
240
298
  @objc private func pauseWorker() {
241
- DispatchQueue.global().async { _ = run(pantheonBin, ["stop", "worker"]); self.refresh() }
299
+ changeWorker("Pausing unattended work…", path: pantheonBin, args: ["stop", "worker"])
242
300
  }
243
301
  @objc private func resumeWorker() {
244
- let plist = state.workerPlist
245
- DispatchQueue.global().async {
246
- _ = run("/bin/launchctl", ["load", "-w", plist])
247
- self.refresh()
248
- }
302
+ changeWorker("Resuming unattended work…", path: "/bin/launchctl", args: ["load", "-w", state.workerPlist])
249
303
  }
250
304
  // .command files are written by `pantheon menubar` next to the tray binary;
251
305
  // Terminal owns running them, so an unsigned accessory app never needs
@@ -273,13 +327,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
273
327
  }
274
328
  let dir = (Bundle.main.executablePath as NSString?)?.deletingLastPathComponent
275
329
  ?? URL(fileURLWithPath: CommandLine.arguments[0]).deletingLastPathComponent().path
276
- try? id.write(toFile: dir + "/.explain-id", atomically: true, encoding: .utf8)
277
- openCommand("pantheon-explain.command")
330
+ do {
331
+ try id.write(toFile: dir + "/.explain-id", atomically: true, encoding: .utf8)
332
+ openCommand("pantheon-explain.command")
333
+ } catch {
334
+ reportFailure("Could not open your agent. The question is copied; paste it into your agent.")
335
+ }
278
336
  }
279
337
  private func openCommand(_ name: String) {
280
338
  let dir = (Bundle.main.executablePath as NSString?)?.deletingLastPathComponent
281
339
  ?? URL(fileURLWithPath: CommandLine.arguments[0]).deletingLastPathComponent().path
282
- NSWorkspace.shared.open(URL(fileURLWithPath: dir + "/" + name))
340
+ if !NSWorkspace.shared.open(URL(fileURLWithPath: dir + "/" + name)) {
341
+ reportFailure("Could not open Terminal. Run pantheon doctor to check your setup.")
342
+ } else {
343
+ actionError = nil
344
+ paint()
345
+ }
346
+ }
347
+ private func reportFailure(_ message: String) {
348
+ actionError = message
349
+ paint()
283
350
  }
284
351
  @objc private func refreshNow() { refresh() }
285
352
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@join-pantheon/cli",
3
- "version": "0.21.3",
3
+ "version": "0.23.0",
4
4
  "description": "Let your coding agent collaborate directly with your teammates' agents — ask, agree an interface, and deliver, without a human relaying messages.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,7 +13,7 @@
13
13
  "build": "rm -rf dist && tsc -p .",
14
14
  "dev": "tsc -p . --watch",
15
15
  "relay": "node dist/cli.js relay",
16
- "test": "PANTHEON_HOME=\"$(mktemp -d)\" node --test --test-force-exit dist/test/*.test.js",
16
+ "test": "PANTHEON_HOME=\"$(mktemp -d)\" HOME=\"$(mktemp -d)\" node --test --test-force-exit dist/test/*.test.js",
17
17
  "prepare": "npm run build",
18
18
  "prepublishOnly": "npm run build && npm test",
19
19
  "start": "node dist/cli.js relay"