@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.
- package/README.md +25 -9
- package/dist/cli.js +163 -29
- package/dist/client.js +34 -0
- package/dist/config.js +15 -3
- package/dist/doctor.js +13 -0
- package/dist/email.js +47 -23
- package/dist/history-view.js +51 -0
- package/dist/host-env.js +57 -0
- package/dist/mcp.js +4 -2
- package/dist/profile.js +8 -0
- package/dist/protocol.js +10 -1
- package/dist/quickstart.js +14 -4
- package/dist/relay.js +180 -74
- package/dist/roster-file.js +19 -0
- package/dist/service.js +25 -2
- package/dist/setup-guide.js +64 -24
- package/dist/tray.js +8 -2
- package/dist/worker.js +7 -2
- package/helper/macos/menubar.swift +110 -43
- package/package.json +2 -2
package/dist/setup-guide.js
CHANGED
|
@@ -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
|
|
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("
|
|
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
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
170
|
-
|
|
168
|
+
await askCommands();
|
|
169
|
+
commandsAsked = true;
|
|
171
170
|
}
|
|
172
171
|
const detected = ctx.project && deps.setRepoBase ? detectBaseBranch(ctx.project) : null;
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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" &&
|
|
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
|
|
110
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
|
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 !
|
|
182
|
-
info("
|
|
183
|
-
|
|
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("
|
|
186
|
-
info("Run
|
|
216
|
+
info("Connect with your team")
|
|
217
|
+
info("Run pantheon start, or join with an invite link")
|
|
187
218
|
} else {
|
|
188
|
-
info("\(state.member)
|
|
219
|
+
info("\(state.member) · \(state.space)")
|
|
189
220
|
info(state.relayOk
|
|
190
|
-
? "
|
|
191
|
-
: "
|
|
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("
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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
|
-
|
|
299
|
+
changeWorker("Pausing unattended work…", path: pantheonBin, args: ["stop", "worker"])
|
|
242
300
|
}
|
|
243
301
|
@objc private func resumeWorker() {
|
|
244
|
-
|
|
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
|
-
|
|
277
|
-
|
|
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.
|
|
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"
|