@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 +6 -4
- package/dist/notifier.js +35 -0
- package/dist/relay.js +2 -0
- package/dist/tray.js +42 -3
- package/helper/macos/menubar.swift +198 -105
- package/package.json +1 -1
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
|
-
|
|
3477
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
let
|
|
616
|
-
let
|
|
617
|
-
let
|
|
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
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
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
|
-
|
|
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" :
|
|
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
|
-
|
|
759
|
-
|
|
760
|
-
let
|
|
761
|
-
let
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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
|
-
|
|
799
|
-
|
|
800
|
-
|
|
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
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
|
|
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.
|
|
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": {
|