@join-pantheon/cli 0.31.0 → 0.31.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/cli.js CHANGED
@@ -18,7 +18,7 @@ import { install, launcher, installForUser } from "./install.js";
18
18
  import { Relay } from "./relay.js";
19
19
  import { runMcp } from "./mcp.js";
20
20
  import { Worker } from "./worker.js";
21
- import { DEFAULT_REMINDER_MS, ensureNativeNotifier, HumanNotifier, sendDesktopNotification } from "./notifier.js";
21
+ import { DEFAULT_REMINDER_MS, ensureNativeNotifier, HumanNotifier, runNotifiers, sendDesktopNotification } from "./notifier.js";
22
22
  import { desktopUnavailable, hostEnv } from "./host-env.js";
23
23
  import { AGENT_SURFACES, applyProfileInit, cleanList, isAgentSurface, loadProfile, organizationProfile, profileInitialized, saveProfile, wakeDefault, wakeMode } from "./profile.js";
24
24
  import { runGuide, terminalIO, questionOrNull, parseSetupFlags, questionnaire, optOutsFrom } from "./setup-guide.js";
@@ -3473,8 +3473,8 @@ async function main() {
3473
3473
  return;
3474
3474
  }
3475
3475
  const open = o.goal && c2.goals.get(o.goal)?.state !== "CLOSED" ? o.goal : null;
3476
- await c2.send({ act: "ANSWER", ref: o.ref, goal: open, after_seq: c2.readSeq,
3477
- body: "Marked done from the alert — handled outside this thread." }).catch((e) => die(e.message));
3476
+ const doneBody = "Marked done from the alert — handled outside this thread.";
3477
+ await c2.send({ act: "ANSWER", ref: o.ref, goal: open, after_seq: c2.readSeq, body: doneBody }).catch((e) => die(e.message));
3478
3478
  c2.close();
3479
3479
  console.error(D(`done — ${o.id} is no longer waiting on you`));
3480
3480
  return;
@@ -3876,7 +3876,9 @@ async function main() {
3876
3876
  n.stop(); process.exit(0); };
3877
3877
  process.on("SIGINT", stopAll);
3878
3878
  process.on("SIGTERM", stopAll);
3879
- await Promise.all(notifiers.map((n) => n.run()));
3879
+ const ok = await runNotifiers(notifiers.map((n, i) => ({ label: cfgs[i].space, run: () => n.run(), stopped: () => n.isStopped })), { log: (line) => console.error(line) });
3880
+ if (!ok)
3881
+ process.exit(1);
3880
3882
  return;
3881
3883
  }
3882
3884
  case "doctor": {
package/dist/notifier.js CHANGED
@@ -139,6 +139,7 @@ export class HumanNotifier {
139
139
  this.cfg = cfg;
140
140
  this.opts = opts;
141
141
  }
142
+ get isStopped() { return this.stopped; }
142
143
  stop() {
143
144
  this.stopped = true;
144
145
  }
@@ -203,4 +204,38 @@ export class HumanNotifier {
203
204
  client.close();
204
205
  }
205
206
  }
207
+ export async function runNotifiers(teams, o) {
208
+ const sleep = o.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
209
+ const failing = new Set();
210
+ let gaveUp = false;
211
+ let giveUp;
212
+ const allFailing = new Promise((r) => { giveUp = () => r(false); });
213
+ const each = Promise.all(teams.map(async (t, i) => {
214
+ let wait = o.retryMs ?? 30_000;
215
+ for (;;) {
216
+ failing.delete(i);
217
+ try {
218
+ await t.run();
219
+ return;
220
+ }
221
+ catch (e) {
222
+ failing.add(i);
223
+ o.log(`[${t.label}] ! not connecting: ${e.message.split("\n")[0]}`);
224
+ if (failing.size === teams.length) {
225
+ gaveUp = true;
226
+ giveUp();
227
+ return;
228
+ }
229
+ }
230
+ if (gaveUp || t.stopped?.())
231
+ return;
232
+ o.log(`[${t.label}] the other teams' alerts keep running; trying this one again in ${Math.round(wait / 1000)}s`);
233
+ await sleep(wait);
234
+ if (gaveUp || t.stopped?.())
235
+ return;
236
+ wait = Math.min(wait * 2, o.maxRetryMs ?? 10 * 60_000);
237
+ }
238
+ })).then(() => !gaveUp);
239
+ return Promise.race([each, allFailing]);
240
+ }
206
241
  //# sourceMappingURL=notifier.js.map
package/dist/relay.js CHANGED
@@ -3524,6 +3524,7 @@ pantheon signup --email you@work.com --username you</code></pre>
3524
3524
  break;
3525
3525
  case "ESCALATE":
3526
3526
  this.oblige(sp, { kind: "HUMAN", owner: `human:${rec.from}`, ref: rec.id, goal: rec.goal,
3527
+ ...(rec.ref ? { about: rec.ref } : {}),
3527
3528
  body: `${rec.from} escalated: ${first(rec.body)}` });
3528
3529
  if (goal && goal.state === "OPEN") {
3529
3530
  goal.state = "BLOCKED";
@@ -3559,6 +3560,7 @@ pantheon signup --email you@work.com --username you</code></pre>
3559
3560
  const alreadyLive = asked && [...sp.obligations.values()].some((o) => o.kind === "HUMAN" && o.ref === asked.id);
3560
3561
  if (asked && decider === rec.from && !stillAnswered && !alreadyLive && !retracted.has(asked.id)) {
3561
3562
  this.oblige(sp, { kind: "HUMAN", owner: `human:${decider}`, ref: asked.id, goal: asked.goal,
3563
+ ...(asked.act === "ESCALATE" && asked.ref ? { about: asked.ref } : {}),
3562
3564
  body: asked.act === "ESCALATE" ? `${asked.from} escalated: ${first(asked.body)}` : `${asked.from} needs a decision: ${first(asked.body)}` });
3563
3565
  const g = asked.goal ? sp.goals.get(asked.goal) : undefined;
3564
3566
  if (g && g.state === "OPEN") {
package/dist/tray.js CHANGED
@@ -52,6 +52,41 @@ export function writeTrayCommands(home = CONFIG_DIR) {
52
52
  }
53
53
  return out;
54
54
  }
55
+ const WEEK_MS = 7 * 24 * 3600_000;
56
+ const RANK = (i) => (i.kind === "decision" ? 0 : 1);
57
+ export const boardOrder = (a, b) => RANK(a) - RANK(b) || a.when - b.when;
58
+ export function arrangeBoard(items, o) {
59
+ const fallback = o.fallbackMs ?? LIMITS.workerFallbackMs;
60
+ const out = items.map((i) => ({ ...i }));
61
+ const spaceOf = (i) => i.space ?? "";
62
+ const asked = new Map();
63
+ for (const d of out)
64
+ if (d.kind === "decision" && d.about && (d.who === "your agent" || d.choices))
65
+ asked.set(`${spaceOf(d)}|${d.about}`, d);
66
+ for (const i of out) {
67
+ if (i.kind === "decision") {
68
+ i.lane = "now";
69
+ continue;
70
+ }
71
+ if (i.kind === "waiting") {
72
+ i.lane = "waiting";
73
+ continue;
74
+ }
75
+ const d = i.ref ? asked.get(`${spaceOf(i)}|${i.ref}`) : undefined;
76
+ if (d) {
77
+ d.context ??= i.headline;
78
+ i.lane = undefined;
79
+ continue;
80
+ }
81
+ const age = Math.max(0, o.now - i.when);
82
+ if (i.claimed || (o.workerRunning && age < fallback)) {
83
+ i.lane = "handling";
84
+ continue;
85
+ }
86
+ i.lane = age > WEEK_MS ? "older" : "now";
87
+ }
88
+ return out.filter((i) => i.lane).sort(boardOrder);
89
+ }
55
90
  export function localChoices(ref) {
56
91
  if (!ref)
57
92
  return null;
@@ -115,7 +150,7 @@ export async function trayStatus(cfg = load(), f = loadAll()) {
115
150
  members: ok.reduce((n, p) => n + (p.members ?? 0), 0),
116
151
  decisions: ok.flatMap((p) => (p.decisions ?? []).map((d) => ({ ...d, body: `[${p.space}] ${d.body}` }))),
117
152
  decisions_prompt: allDecisionsPrompt(ok.reduce((n, p) => n + (p.decisions?.length ?? 0), 0)),
118
- items: ok.flatMap((p) => (p.items ?? []).map((i) => ({ ...i, space: p.space }))),
153
+ items: ok.flatMap((p) => (p.items ?? []).map((i) => ({ ...i, space: p.space }))).sort(boardOrder),
119
154
  owed_count: ok.reduce((n, p) => n + (p.owed_count ?? 0), 0),
120
155
  waiting_count: ok.reduce((n, p) => n + (p.waiting_count ?? 0), 0),
121
156
  };
@@ -143,8 +178,10 @@ async function trayStatusOne(cfg) {
143
178
  const row = (o, kind, who, prompt) => ({
144
179
  id: o.id, kind, title: decisionTitle(o.body), headline: decisionTitle(o.body, 200), detail: o.body, prompt, who,
145
180
  when: o.created_at ?? 0, space: cfg.space,
181
+ ...(o.claimed ? { claimed: true } : {}),
146
182
  ...(o.goal ? { goal: o.goal } : {}),
147
183
  ...(o.ref ? { ref: o.ref } : {}),
184
+ ...(typeof o.about === "string" && o.about ? { about: o.about } : {}),
148
185
  });
149
186
  const owed = all.filter((o) => o.owner === cfg.member);
150
187
  const waiting = all.filter((o) => o.owner !== cfg.member && o.owner !== `human:${cfg.member}`);
@@ -163,6 +200,8 @@ async function trayStatusOne(cfg) {
163
200
  : r;
164
201
  }),
165
202
  ];
203
+ const workerRunning = workerProc().running;
204
+ const board = arrangeBoard(items, { now: Date.now(), workerRunning });
166
205
  const profile = loadProfile(cfg);
167
206
  const surface = profile.agent.primary_surface;
168
207
  const surfaceName = surface === "claude-code" ? "Claude" : surface === "cursor" ? "Cursor" : surface === "copilot" ? "Copilot" : surface === "kiro" ? "Kiro" : "Codex";
@@ -179,7 +218,7 @@ async function trayStatusOne(cfg) {
179
218
  unavailable_spaces: !space || !decisionsOk ? [cfg.space] : [],
180
219
  online: space?.online ?? 0,
181
220
  members: space?.members ?? 0,
182
- worker_running: workerProc().running,
221
+ worker_running: workerRunning,
183
222
  worker_installed: serviceInstalled("worker"),
184
223
  worker_plist: plistPath("worker"),
185
224
  notifier_running: notifierProc().running,
@@ -189,7 +228,7 @@ async function trayStatusOne(cfg) {
189
228
  surface_bundle_id: SURFACE_BUNDLE_IDS[surface],
190
229
  decisions_prompt: allDecisionsPrompt(decisions.length),
191
230
  decisions,
192
- items,
231
+ items: board,
193
232
  owed_count: owed.length,
194
233
  waiting_count: waiting.length,
195
234
  };
@@ -26,7 +26,11 @@ guard argv.count >= 2 else {
26
26
  fputs("usage: pantheon-tray <path-to-pantheon> [poll-seconds]\n", stderr)
27
27
  exit(64)
28
28
  }
29
- let pantheonBin = argv[1]
29
+ /// `pantheon-tray render <path-to-pantheon> <out.png>`: draw the board from tray-status into
30
+ /// a PNG and exit — the layout can be checked without anyone clicking the icon.
31
+ /// PANTHEON_TRAY_OPEN="handling,waiting,…" opens those counted lines in the picture.
32
+ let renderTo: String? = argv.count >= 4 && argv[1] == "render" ? argv[3] : nil
33
+ let pantheonBin = renderTo != nil ? argv[2] : argv[1]
30
34
  let pollSeconds = argv.count >= 3 ? (Double(argv[2]) ?? 15) : 15
31
35
 
32
36
  struct TrayState {
@@ -72,8 +76,19 @@ struct Item {
72
76
  let decider: String
73
77
  /// one-click answers for a question this machine asked its own person (may a teammate wake it, may runs use commands)
74
78
  var choices: [(label: String, answer: String)] = []
79
+ /// where the board draws it (tray.ts arrangeBoard): now (a card), handling, waiting, older
80
+ var lane: String = ""
81
+ /// what else this card stands for — the teammate's request a decision is holding up
82
+ var context: String = ""
83
+ /// a session of this person's has claimed it (handling: an agent is on it, not just queued)
84
+ var claimed: Bool = false
75
85
 
76
86
  var isDecision: Bool { kind == "decision" }
87
+ /// A tray-status from before lanes — or with a lane this helper does not know —
88
+ /// draws as it did before lanes: a decision or owed row is a card, a wait is not.
89
+ /// An unknown lane must never hide a row.
90
+ var effectiveLane: String { Item.lanes.contains(lane) ? lane : (kind == "waiting" ? "waiting" : "now") }
91
+ static let lanes: Set<String> = ["now", "handling", "waiting", "older"]
77
92
  var heading: String {
78
93
  switch kind {
79
94
  case "decision": return "Needs your decision"
@@ -87,10 +102,17 @@ struct Item {
87
102
  var parts: [String] = []
88
103
  // Who is asking, on every kind. A decision row used to say only "11h ago",
89
104
  // which is the one fact a reader could already guess.
90
- if kind == "owed" { parts.append("you owe") } else if !who.isEmpty { parts.append(who) }
105
+ // Who has it, by where the board put it: a card is on this person; a handled row is an agent's.
106
+ if kind == "owed" {
107
+ parts.append(effectiveLane == "handling" ? (claimed ? "your agent is on it" : "your worker picks it up") : "waiting on you")
108
+ } else if !who.isEmpty { parts.append(who) }
91
109
  if when > 0 { parts.append(Item.age(when)) }
92
110
  if showSpace && !space.isEmpty { parts.append(space) }
93
- return parts.joined(separator: " · ")
111
+ let head = parts.joined(separator: " · ")
112
+ // The request a decision is holding up, said once, after who and when.
113
+ guard !context.isEmpty else { return head }
114
+ let c = context.count > 70 ? String(context.prefix(69)) + "…" : context
115
+ return head + " · for: " + c
94
116
  }
95
117
  static func age(_ ms: Double) -> String {
96
118
  let secs = max(0, Date().timeIntervalSince1970 - ms / 1000)
@@ -161,7 +183,10 @@ func fetchState() -> TrayState {
161
183
  choices: (i["choices"] as? [[String: Any]] ?? []).compactMap { c in
162
184
  guard let l = c["label"] as? String, let a = c["answer"] as? String else { return nil }
163
185
  return (label: l, answer: a)
164
- }
186
+ },
187
+ lane: i["lane"] as? String ?? "",
188
+ context: i["context"] as? String ?? "",
189
+ claimed: i["claimed"] as? Bool ?? false
165
190
  ))
166
191
  }
167
192
  for d in (j["decisions"] as? [[String: Any]] ?? []) {
@@ -186,6 +211,29 @@ func fetchState() -> TrayState {
186
211
  * loop, and hit-testing two zones in one view is far more predictable there than
187
212
  * nesting buttons inside an NSMenuItem.
188
213
  */
214
+ /// "Later": a card put away until tomorrow morning, on this machine only — a
215
+ /// view preference, never an answer. Kept in the menu bar's own defaults.
216
+ enum Later {
217
+ private static let key = "pantheon.later"
218
+ /// The ids put away and not yet due, with when they come back (seconds since 1970).
219
+ static func active() -> [String: Double] {
220
+ let now = Date().timeIntervalSince1970
221
+ let all = (UserDefaults.standard.dictionary(forKey: key) as? [String: Double]) ?? [:]
222
+ let live = all.filter { $0.value > now }
223
+ if live.count != all.count { UserDefaults.standard.set(live, forKey: key) }
224
+ return live
225
+ }
226
+ /// Back at 9:00 tomorrow, local time.
227
+ static func snooze(_ id: String) {
228
+ let cal = Calendar.current
229
+ let tomorrow = cal.date(byAdding: .day, value: 1, to: Date()) ?? Date().addingTimeInterval(86_400)
230
+ let at = cal.date(bySettingHour: 9, minute: 0, second: 0, of: tomorrow) ?? tomorrow
231
+ var all = active(); all[id] = at.timeIntervalSince1970
232
+ UserDefaults.standard.set(all, forKey: key)
233
+ }
234
+ static func bring(_ id: String) { var all = active(); all.removeValue(forKey: id); UserDefaults.standard.set(all, forKey: key) }
235
+ }
236
+
189
237
  final class RowView: NSView {
190
238
  private let item: Item
191
239
  private let onOpen: (Item) -> Void
@@ -232,13 +280,13 @@ final class RowView: NSView {
232
280
  return max(1, min(3, Int(ceil(h / lineHeight))))
233
281
  }
234
282
 
235
- init(item: Item, width: CGFloat, inTeamSection: Bool = false, onOpen: @escaping (Item) -> Void, onCopy: @escaping (Item) -> Void) {
283
+ init(item: Item, width: CGFloat, inTeamSection: Bool = false, showSpace: Bool? = nil, onOpen: @escaping (Item) -> Void, onCopy: @escaping (Item) -> Void) {
236
284
  self.item = item
237
285
  self.onOpen = onOpen
238
286
  self.onCopy = onCopy
239
287
  self.leading = inTeamSection ? 26 : 12
240
288
  self.dot = inTeamSection && item.isDecision
241
- self.sub = item.subtitle(showSpace: !inTeamSection)
289
+ self.sub = item.subtitle(showSpace: showSpace ?? !inTeamSection)
242
290
  self.titleLines = RowView.lines(for: item.title, width: (RowView.knownWidth ?? width) - RowView.textInsets - (leading - 12))
243
291
  let textHeight = CGFloat(titleLines) * RowView.lineHeight
244
292
  super.init(frame: NSRect(x: 0, y: 0, width: width, height: textHeight + (sub.isEmpty ? 13 : 29)))
@@ -469,6 +517,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
469
517
  func applicationDidFinishLaunching(_ note: Notification) {
470
518
  NSApp.setActivationPolicy(.accessory)
471
519
  item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
520
+ // (After the item exists: the board reads its button for placement.)
521
+ if let out = renderTo { renderBoard(to: out); exit(0) }
472
522
  let menu = NSMenu()
473
523
  menu.delegate = self
474
524
  classicMenu = menu
@@ -519,12 +569,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
519
569
 
520
570
  private var classicMenu: NSMenu?
521
571
  private var boardPanel: BoardPanel?
572
+ /// which of the board's counted lines are open ("handling", "waiting", "older", "later", "more")
573
+ private var boardOpen: Set<String> = []
522
574
  private var boardSignature = ""
523
575
  private var boardMonitor: Any?
524
576
  static let boardWidth: CGFloat = 470
525
577
 
578
+ /// The cards: what needs this person now, less what they put away for later.
579
+ /// The badge, the tooltip and both boards' headers all count these, so they agree.
580
+ private var boardCards: [Item] {
581
+ let later = Later.active()
582
+ return state.items.filter { $0.effectiveLane == "now" && later[$0.id] == nil }
583
+ }
584
+
526
585
  private func signature() -> String {
527
- "\(state.items.map { $0.id }.joined(separator: ","))|\(state.decisions.count)|\(state.online)|\(state.members)|\(state.workerRunning)|\(state.relayOk)|\(actionError ?? "")|\(workerAction ?? "")"
586
+ // Everything the board draws from, not just which ids: a row moving lane (out
587
+ // of the worker's window), a new repeat, a claim, or a Later coming due all
588
+ // change what an open board must show.
589
+ "\(state.items.map { "\($0.id):\($0.effectiveLane):\($0.claimed):\($0.context)" }.joined(separator: ","))|\(Later.active().keys.sorted().joined(separator: ","))|\(state.decisions.count)|\(state.online)|\(state.members)|\(state.workerRunning)|\(state.relayOk)|\(actionError ?? "")|\(workerAction ?? "")"
528
590
  }
529
591
 
530
592
  @objc private func iconClicked(_ sender: Any?) {
@@ -557,6 +619,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
557
619
  item.button?.highlight(false)
558
620
  }
559
621
 
622
+ /// One card as a row of the classic menu.
623
+ private func menuRow(_ it: Item, showSpace: Bool) -> NSMenuItem {
624
+ let mi = NSMenuItem(title: it.title, action: nil, keyEquivalent: "")
625
+ mi.toolTip = it.detail
626
+ mi.view = RowView(item: it, width: RowView.rowWidth, showSpace: showSpace,
627
+ onOpen: { [weak self] x in self?.showDetail(x) },
628
+ onCopy: { [weak self] x in self?.copyPrompt(x) })
629
+ return mi
630
+ }
631
+
632
+ /// Open or close one of the board's counted lines, and redraw.
633
+ private func toggleBoardLine(_ key: String) {
634
+ if boardOpen.contains(key) { boardOpen.remove(key) } else { boardOpen.insert(key) }
635
+ rebuildBoard()
636
+ }
637
+
638
+ /// The board as it would open now, drawn into a PNG (render mode, above).
639
+ func renderBoard(to path: String) {
640
+ state = fetchState()
641
+ hasLoaded = true
642
+ boardOpen = Set((ProcessInfo.processInfo.environment["PANTHEON_TRAY_OPEN"] ?? "").split(separator: ",").map(String.init))
643
+ let panel = makeBoardPanel()
644
+ boardPanel = panel
645
+ rebuildBoard()
646
+ guard let view = panel.contentView, let rep = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return }
647
+ view.cacheDisplay(in: view.bounds, to: rep)
648
+ try? rep.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path))
649
+ }
650
+
560
651
  private func makeBoardPanel() -> BoardPanel {
561
652
  let panel = BoardPanel(contentRect: NSRect(x: 0, y: 0, width: AppDelegate.boardWidth, height: 200),
562
653
  styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
@@ -595,52 +686,55 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
595
686
  let sep = { rows.append(BoardSeparator(width: w)) }
596
687
  let openRow: (Item) -> Void = { [weak self] it in self?.closeBoard(); self?.showDetail(it) }
597
688
  let copyRow: (Item) -> Void = { [weak self] it in self?.copyPrompt(it) }
598
- let section = { (title: String, detail: String, items: [Item], team: Bool) in
599
- guard !items.isEmpty else { return }
600
- rows.append(HeaderView(lead: title, rest: detail, width: w, bright: team))
601
- for it in items.prefix(team ? 8 : 6) { rows.append(RowView(item: it, width: w, inTeamSection: team, onOpen: openRow, onCopy: copyRow)) }
602
- if items.count > (team ? 8 : 6) { rows.append(ActionRow("…and \(items.count - (team ? 8 : 6)) more", width: w)) }
603
- }
604
-
605
689
  if !hasLoaded {
606
690
  note("Checking Pantheon…")
607
691
  } else if !state.reachable {
608
- note("Unable to check Pantheon status"); note("Try Refresh now, or Check connection below")
692
+ note("Unable to check Pantheon status"); note("Open More below: Refresh now, or Check connection")
609
693
  } else if !state.configured {
610
694
  note("Connect with your team"); note("Run pantheon start, or join with an invite link")
611
695
  } else {
612
- let teams = state.spaces.count > 1 ? state.spaces : []
613
- let decisions = state.items.filter { $0.kind == "decision" }
614
- let owed = state.items.filter { $0.kind == "owed" }
615
- let waiting = state.items.filter { $0.kind == "waiting" }
616
- let need = decisions.isEmpty ? "Nothing needs you" : "\(decisions.count) need\(decisions.count == 1 ? "s" : "") you"
617
- let where_ = teams.isEmpty ? state.space : "\(teams.count) teams"
696
+ // One board, no noise (tray.ts arrangeBoard): every card is something this
697
+ // person has to do, each once, across every team; the rest is one line each,
698
+ // opened on demand. No cap — nothing that needs them is hidden.
699
+ let multi = state.spaces.count > 1
700
+ let later = Later.active()
701
+ let cards = boardCards
702
+ let putAway = state.items.filter { $0.effectiveLane == "now" && later[$0.id] != nil }
703
+ let handling = state.items.filter { $0.effectiveLane == "handling" }
704
+ let waiting = state.items.filter { $0.effectiveLane == "waiting" }
705
+ let older = state.items.filter { $0.effectiveLane == "older" }
706
+ let need = cards.isEmpty ? "Nothing needs you" : "\(cards.count) need\(cards.count == 1 ? "s" : "") you"
707
+ let where_ = multi ? "\(state.spaces.count) teams" : state.space
618
708
  rows.append(HeaderView(lead: need, rest: "\(state.member) · \(where_) · " + (state.relayOk ? "\(state.online) of \(state.members) online" : "connection unavailable"), width: w))
619
709
  if !state.relayOk { note("Could not reach: \(state.unavailableSpaces.joined(separator: ", "))") }
620
710
  if !state.decisionsOk { note("Some of this could not be checked — the list may be incomplete") }
621
711
  sep()
622
- if !teams.isEmpty {
623
- for (i, team) in teams.enumerated() {
624
- if i > 0 { sep() }
625
- let mine = state.items.filter { $0.space == team }
626
- let ordered = mine.filter { $0.kind == "decision" } + mine.filter { $0.kind == "owed" } + mine.filter { $0.kind == "waiting" }
627
- if state.unavailableSpaces.contains(team) { rows.append(HeaderView(lead: team, rest: "could not be checked", width: w, bright: false)) }
628
- else if ordered.isEmpty { rows.append(HeaderView(lead: team, rest: "nothing open", width: w, bright: false)) }
629
- else { section(team, counts(ordered), ordered, true) }
630
- }
631
- } else if state.items.isEmpty {
632
- if state.decisionsOk { note("Nothing open — nothing needs you, nothing owed either way") }
633
- } else {
634
- section(decisions.count == 1 ? "1 decision needs you" : "\(decisions.count) decisions need you", "", decisions, false)
635
- section(owed.count == 1 ? "You owe 1 thing" : "You owe \(owed.count) things", "", owed, false)
636
- for who in orderedPeers(waiting) {
637
- let theirs = waiting.filter { $0.who == who }
638
- section(theirs.count == 1 ? "Waiting on \(who)" : "Waiting on \(who) · \(theirs.count)", "", theirs, false)
639
- }
712
+ for it in cards { rows.append(RowView(item: it, width: w, showSpace: multi, onOpen: openRow, onCopy: copyRow)) }
713
+ // The heading already says nothing needs you: said again only what it does not.
714
+ if cards.isEmpty && state.decisionsOk && handling.isEmpty && waiting.isEmpty && older.isEmpty && putAway.isEmpty {
715
+ note("All clear — nothing open for your agents either")
716
+ }
717
+ // The rest, counted: a line each, opened in place. (A plain loop: a local
718
+ // closure capturing both `rows` and a weak self crashed the optimised build.)
719
+ let lanes: [(key: String, title: String, items: [Item])] = [
720
+ ("handling", "Your agents are handling · \(handling.count)", handling),
721
+ ("waiting", "Waiting on teammates · \(waiting.count)", waiting),
722
+ ("older", "Older than a week · \(older.count)", older),
723
+ ("later", "Later · \(putAway.count)", putAway),
724
+ ]
725
+ if lanes.contains(where: { !$0.items.isEmpty }) { sep() }
726
+ for lane in lanes where !lane.items.isEmpty {
727
+ let open = boardOpen.contains(lane.key)
728
+ let key = lane.key
729
+ rows.append(ActionRow(lane.title, hint: open ? "hide" : "show", width: w, onClick: { [weak self] in self?.toggleBoardLine(key) }))
730
+ // Indented under their line, so an opened lane never reads as more cards.
731
+ if open { for it in lane.items { rows.append(RowView(item: it, width: w, inTeamSection: true, showSpace: multi, onOpen: openRow, onCopy: copyRow)) } }
640
732
  }
641
- if !decisions.isEmpty {
733
+ // The prompt it hands over asks about every question waiting on this person,
734
+ // put away or not, so the count is of all of them.
735
+ if state.decisions.count > 1 {
642
736
  sep()
643
- act(decisions.count > 1 ? "Review all \(decisions.count) in \(state.surfaceName)" : "Review it in \(state.surfaceName)", "") { [weak self] in self?.openAllDecisions() }
737
+ act("Review all \(state.decisions.count) questions in \(state.surfaceName)", "") { [weak self] in self?.openAllDecisions() }
644
738
  }
645
739
  sep()
646
740
  if let action = workerAction { note(action) }
@@ -650,12 +744,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
650
744
  }
651
745
  if let error = actionError { sep(); note(error) }
652
746
  sep()
653
- act("Open status in Terminal", "") { [weak self] in self?.openStatus() }
654
- act("Check connection in Terminal", "version \(state.version)") { [weak self] in self?.openDoctor() }
655
- act("View decision details in Terminal", "") { [weak self] in self?.openApprovals() }
656
- act("Refresh now", "") { [weak self] in self?.refresh() }
657
- sep()
658
- act("Quit pantheon menu bar", "") { [weak self] in self?.quit() }
747
+ // The terminal tools, out of the way until asked for.
748
+ let moreOpen = boardOpen.contains("more")
749
+ rows.append(ActionRow("More", hint: moreOpen ? "hide" : "version \(state.version)", width: w, onClick: { [weak self] in self?.toggleBoardLine("more") }))
750
+ if moreOpen {
751
+ act("Open status in Terminal", "") { [weak self] in self?.openStatus() }
752
+ act("Check connection in Terminal", "version \(state.version)") { [weak self] in self?.openDoctor() }
753
+ act("View decision details in Terminal", "") { [weak self] in self?.openApprovals() }
754
+ act("Refresh now", "") { [weak self] in self?.refresh() }
755
+ act("Quit pantheon menu bar", "") { [weak self] in self?.quit() }
756
+ }
659
757
 
660
758
  // Lay the rows out top to bottom at their own heights, in a card no taller than the screen.
661
759
  let total = rows.reduce(CGFloat(16)) { $0 + $1.frame.height }
@@ -728,9 +826,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
728
826
  let mode: MarkMode = !state.configured || !state.relayOk ? .off : state.workerRunning ? .full : .idle
729
827
  item.button?.image = markImage(mode)
730
828
  let unknown = hasLoaded && (!state.reachable || (state.configured && !state.decisionsOk))
731
- item.button?.title = actionError != nil ? " !" : state.decisions.isEmpty ? (unknown ? " !" : "") : " \(state.decisions.count)\(unknown ? "+" : "")"
829
+ let need = hasLoaded ? boardCards.count : 0
830
+ item.button?.title = actionError != nil ? " !" : need == 0 ? (unknown ? " !" : "") : " \(need)\(unknown ? "+" : "")"
732
831
  item.button?.imagePosition = .imageLeading
733
- 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"
832
+ 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" : need == 0 ? "Nothing needs you" : "\(need) need\(need == 1 ? "s" : "") you"
734
833
  item.button?.toolTip = "Pantheon — " + (actionError ?? summary)
735
834
  item.button?.setAccessibilityLabel("Pantheon — " + (actionError ?? summary))
736
835
  }
@@ -755,65 +854,46 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
755
854
  info("Connect with your team")
756
855
  info("Run pantheon start, or join with an invite link")
757
856
  } else {
758
- let teams = state.spaces.count > 1 ? state.spaces : []
759
- let decisions = state.items.filter { $0.kind == "decision" }
760
- let owed = state.items.filter { $0.kind == "owed" }
761
- let waiting = state.items.filter { $0.kind == "waiting" }
762
- if !teams.isEmpty {
763
- // Several teams: the board is drawn per team, and "what needs you" is read
764
- // off the dots. One mixed list with the team as a grey suffix on every row
765
- // was the version a person with two teams called impossible to follow.
766
- let summary = NSMenuItem(title: state.member, action: nil, keyEquivalent: "")
767
- summary.isEnabled = false
768
- summary.view = HeaderView(lead: decisions.isEmpty ? "Nothing needs you" : "\(decisions.count) need\(decisions.count == 1 ? "s" : "") you",
769
- rest: "\(state.member) · \(teams.count) teams · " + (state.relayOk ? "\(state.online) of \(state.members) online" : "connection unavailable"),
770
- width: RowView.rowWidth)
771
- menu.addItem(summary)
772
- if !state.relayOk { info("Could not reach: \(state.unavailableSpaces.joined(separator: ", "))") }
773
- menu.addItem(.separator())
774
- for (index, team) in teams.enumerated() {
775
- if index > 0 { menu.addItem(.separator()) }
776
- let rows = state.items.filter { $0.space == team }
777
- let ordered = rows.filter { $0.kind == "decision" } + rows.filter { $0.kind == "owed" } + rows.filter { $0.kind == "waiting" }
778
- if state.unavailableSpaces.contains(team) {
779
- let h = NSMenuItem(title: team, action: nil, keyEquivalent: ""); h.isEnabled = false
780
- h.view = HeaderView(lead: team, rest: "could not be checked", width: RowView.rowWidth, bright: false); menu.addItem(h)
781
- } else if ordered.isEmpty {
782
- let h = NSMenuItem(title: team, action: nil, keyEquivalent: ""); h.isEnabled = false
783
- h.view = HeaderView(lead: team, rest: "nothing open", width: RowView.rowWidth, bright: false); menu.addItem(h)
784
- } else {
785
- addRows(menu, team, ordered, team: true, detail: counts(ordered))
786
- }
787
- }
788
- if !decisions.isEmpty {
789
- menu.addItem(.separator())
790
- menu.addItem(makeItem(decisions.count > 1 ? "Review all \(decisions.count) in \(state.surfaceName)" : "Review it in \(state.surfaceName)", #selector(openAllDecisions)))
791
- }
792
- } else {
793
- info("\(state.member) · \(state.space)")
794
- info(state.relayOk
795
- ? "Connected · \(state.online) of \(state.members) members online"
796
- : "Connection unavailable · \(state.unavailableSpaces.joined(separator: ", "))")
857
+ // The same board as the panel (rebuildBoard), as a menu: the cards, then the
858
+ // rest as a submenu each.
859
+ let multi = state.spaces.count > 1
860
+ let later = Later.active()
861
+ let cards = boardCards
862
+ let putAway = state.items.filter { $0.effectiveLane == "now" && later[$0.id] != nil }
863
+ let handling = state.items.filter { $0.effectiveLane == "handling" }
864
+ let waiting = state.items.filter { $0.effectiveLane == "waiting" }
865
+ let older = state.items.filter { $0.effectiveLane == "older" }
866
+ let summary = NSMenuItem(title: state.member, action: nil, keyEquivalent: "")
867
+ summary.isEnabled = false
868
+ summary.view = HeaderView(lead: cards.isEmpty ? "Nothing needs you" : "\(cards.count) need\(cards.count == 1 ? "s" : "") you",
869
+ rest: "\(state.member) · " + (multi ? "\(state.spaces.count) teams" : state.space) + " · " + (state.relayOk ? "\(state.online) of \(state.members) online" : "connection unavailable"),
870
+ width: RowView.rowWidth)
871
+ menu.addItem(summary)
872
+ if !state.relayOk { info("Could not reach: \(state.unavailableSpaces.joined(separator: ", "))") }
873
+ if !state.decisionsOk { info("Some of this could not be checked — the list may be incomplete") }
797
874
  menu.addItem(.separator())
798
- if !state.decisionsOk {
799
- info("Some of this could not be checked")
800
- info("Refresh to try again — the list may be incomplete")
875
+ let row = { (it: Item) -> NSMenuItem in self.menuRow(it, showSpace: multi) }
876
+ for it in cards { menu.addItem(row(it)) }
877
+ if cards.isEmpty && state.decisionsOk && handling.isEmpty && waiting.isEmpty && older.isEmpty && putAway.isEmpty {
878
+ info("All clear — nothing open for your agents either")
801
879
  }
802
- if state.items.isEmpty {
803
- if state.decisionsOk { info("Nothing open — nothing needs you, nothing owed either way") }
804
- } else {
805
- addRows(menu, decisions.count == 1 ? "1 decision needs you" : "\(decisions.count) decisions need you", decisions)
806
- addRows(menu, owed.count == 1 ? "You owe 1 thing" : "You owe \(owed.count) things", owed)
807
- // Grouped by person, because "waiting on praneet" is what someone
808
- // actually wants to know before they chase it.
809
- for who in orderedPeers(waiting) {
810
- let theirs = waiting.filter { $0.who == who }
811
- addRows(menu, theirs.count == 1 ? "Waiting on \(who)" : "Waiting on \(who) · \(theirs.count)", theirs)
812
- }
813
- if !decisions.isEmpty {
814
- menu.addItem(makeItem(decisions.count > 1 ? "Review all \(decisions.count) in \(state.surfaceName)" : "Review it in \(state.surfaceName)", #selector(openAllDecisions)))
815
- }
880
+ let lanes: [(String, [Item])] = [
881
+ ("Your agents are handling · \(handling.count)", handling),
882
+ ("Waiting on teammates · \(waiting.count)", waiting),
883
+ ("Older than a week · \(older.count)", older),
884
+ ("Later · \(putAway.count)", putAway),
885
+ ].filter { !$0.1.isEmpty }
886
+ if !lanes.isEmpty { menu.addItem(.separator()) }
887
+ for (title, items) in lanes {
888
+ let mi = NSMenuItem(title: title, action: nil, keyEquivalent: "")
889
+ let sub = NSMenu()
890
+ for it in items { sub.addItem(row(it)) }
891
+ mi.submenu = sub
892
+ menu.addItem(mi)
816
893
  }
894
+ if state.decisions.count > 1 {
895
+ menu.addItem(.separator())
896
+ menu.addItem(makeItem("Review all \(state.decisions.count) questions in \(state.surfaceName)", #selector(openAllDecisions)))
817
897
  }
818
898
  menu.addItem(.separator())
819
899
  info(state.workerRunning ? "Unattended work is on" : state.workerInstalled ? "Unattended work is paused" : "Unattended work is not set up")
@@ -1219,6 +1299,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1219
1299
  if item.isDecision {
1220
1300
  row1.addArrangedSubview(button("Mark done", #selector(panelDone), "Tells the agent you settled this in person, so it stops asking."))
1221
1301
  }
1302
+ // Put away until tomorrow morning, on this machine — nothing is sent.
1303
+ if item.effectiveLane == "now" {
1304
+ let away = Later.active()[item.id] != nil
1305
+ row1.addArrangedSubview(button(away ? "Bring back" : "Later", #selector(panelLater),
1306
+ away ? "Puts it back on the board now." : "Puts it away until 9:00 tomorrow, on this machine only. Nothing is sent; it is still owed."))
1307
+ }
1222
1308
  row1.addArrangedSubview(button("Show the thread", #selector(panelThread), "Every act in this goal, in Terminal, from the team's folder."))
1223
1309
  rows.addArrangedSubview(row1)
1224
1310
 
@@ -1441,6 +1527,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1441
1527
  showThread(item)
1442
1528
  }
1443
1529
  /// Answer the decision as handled, with no terminal — the same action the alert's Done takes.
1530
+ @objc private func panelLater() {
1531
+ guard let item = panelItem else { return }
1532
+ if Later.active()[item.id] != nil { Later.bring(item.id) } else { Later.snooze(item.id) }
1533
+ detailPanel?.close()
1534
+ if boardPanel?.isVisible == true { rebuildBoard() }
1535
+ }
1536
+
1444
1537
  @objc private func panelDone() {
1445
1538
  guard let item = panelItem, item.isDecision else { return }
1446
1539
  setStatus("Marking it done…")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@join-pantheon/cli",
3
- "version": "0.31.0",
3
+ "version": "0.31.1",
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": {