@join-pantheon/cli 0.22.0 → 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.
@@ -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.22.0",
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"