@join-pantheon/cli 0.23.0 → 0.23.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
@@ -2108,6 +2108,60 @@ async function main() {
2108
2108
  if (id === "all")
2109
2109
  id = undefined;
2110
2110
  }
2111
+ let hud = null;
2112
+ if (argv.includes("--queued")) {
2113
+ const f = join(CONFIG_DIR, "bin", ".hud-action.json");
2114
+ try {
2115
+ hud = JSON.parse(readFileSync(f, "utf8"));
2116
+ rmSync(f, { force: true });
2117
+ }
2118
+ catch {
2119
+ hud = null;
2120
+ }
2121
+ }
2122
+ if (hud?.action === "details") {
2123
+ const acts = await fetchHistory(cfg).catch(() => []);
2124
+ const a = hud.act ? acts.find((x) => x.id === hud.act) : undefined;
2125
+ if (a) {
2126
+ console.log(formatAct(a));
2127
+ if (a.ref) {
2128
+ const r = acts.find((x) => x.id === a.ref);
2129
+ if (r)
2130
+ console.log(D("\nin reply to:\n") + formatAct(r, { short: true }));
2131
+ }
2132
+ }
2133
+ else if (!hud.decision)
2134
+ console.log(D("that act is no longer in what the relay holds"));
2135
+ if (hud.decision) {
2136
+ const c2 = new PantheonClient(cfg, undefined, { role: "cli" });
2137
+ await c2.connect().catch((e) => die(e.message));
2138
+ const o = c2.obligations.find((x) => x.id === hud.decision);
2139
+ c2.close();
2140
+ if (o)
2141
+ console.log(`\n${B("Decision required")} ${o.id}\n ${o.body}\n\nAnswer: ${C(`pantheon approvals answer ${o.id} 'your decision and rationale'`)}\n${D(`or have your agent explain it: pantheon explain ${o.id}`)}`);
2142
+ else
2143
+ console.log(D("\nthat decision is no longer waiting on you"));
2144
+ }
2145
+ return;
2146
+ }
2147
+ if (hud?.action === "open") {
2148
+ const p = loadProfile(cfg);
2149
+ if (hud.tool && isAgentSurface(hud.tool) && p.agent.primary_surface !== hud.tool) {
2150
+ saveProfile({ ...p, agent: { ...p.agent, primary_surface: hud.tool }, updated_at: Date.now() });
2151
+ console.error(D(`${hud.tool} is now where your decisions open`));
2152
+ }
2153
+ if (hud.prompt) {
2154
+ const agent = pickExplainAgent(hud.tool ?? p.agent.primary_surface, detectAvailable());
2155
+ if (!agent)
2156
+ die("no agent CLI found on this machine (claude, cursor-agent, or codex)");
2157
+ console.error(D(`opening ${agent.id}…`));
2158
+ const { spawnSync } = await import("node:child_process");
2159
+ spawnSync(agent.binary, [hud.prompt], { stdio: "inherit" });
2160
+ return;
2161
+ }
2162
+ if (hud.decision)
2163
+ id = hud.decision;
2164
+ }
2111
2165
  const client = new PantheonClient(cfg, undefined, { role: "cli" });
2112
2166
  await client.connect().catch((e) => die(e.message));
2113
2167
  const waiting = client.obligations.filter((o) => o.kind === "HUMAN" && o.owner === `human:${cfg.member}`);
@@ -2230,14 +2284,10 @@ async function main() {
2230
2284
  graceMs: numFlag("grace", LIMITS.workerGraceMs / 1000) * 1000,
2231
2285
  once: argv.includes("--once"),
2232
2286
  ...(argv.includes("--no-notify") ? {} : {
2233
- notify: (n) => {
2234
- try {
2235
- const c = load();
2236
- if (c)
2237
- sendDesktopNotification(n.title, n.subtitle, n.body, loadProfile(c), "", n.eyebrow);
2238
- }
2239
- catch { }
2240
- },
2287
+ notify: (n) => { try {
2288
+ sendDesktopNotification(n);
2289
+ }
2290
+ catch { } },
2241
2291
  }),
2242
2292
  log: (l) => console.error(l),
2243
2293
  });
@@ -2265,12 +2315,10 @@ async function main() {
2265
2315
  graceMs: numFlag("grace", LIMITS.workerGraceMs / 1000) * 1000,
2266
2316
  once: argv.includes("--once"),
2267
2317
  ...(argv.includes("--no-notify") ? {} : {
2268
- notify: (n) => {
2269
- try {
2270
- sendDesktopNotification(n.title, n.subtitle, n.body, loadProfile(cfg), "", n.eyebrow);
2271
- }
2272
- catch { }
2273
- },
2318
+ notify: (n) => { try {
2319
+ sendDesktopNotification(n);
2320
+ }
2321
+ catch { } },
2274
2322
  }),
2275
2323
  log: (l) => console.error(l),
2276
2324
  });
package/dist/client.js CHANGED
@@ -469,6 +469,14 @@ export class PantheonClient {
469
469
  recentFrom(id) {
470
470
  return this.seen.get(id)?.from ?? null;
471
471
  }
472
+ seenAct(id) {
473
+ return this.seen.get(id) ?? null;
474
+ }
475
+ mineSince(ts, goal) {
476
+ return [...this.seen.values()]
477
+ .filter((r) => r.from === this.cfg.member && r.ts >= ts && r.act !== "JOIN" && r.act !== "LEAVE" && (goal == null || r.goal === goal))
478
+ .sort((a, b) => a.seq - b.seq);
479
+ }
472
480
  takenBySibling() {
473
481
  return this.myObligations().filter((o) => o.claimed_by && o.claimed_by.session !== this.session);
474
482
  }
package/dist/notice.js ADDED
@@ -0,0 +1,157 @@
1
+ import { decisionPrompt, decisionTitle } from "./decision-ui.js";
2
+ export const TOOL_LABELS = { "claude-code": "Claude Code", codex: "Codex", cursor: "Cursor" };
3
+ export const BUDGET = { who: 60, heading: 40, context: 70, body: 190 };
4
+ const oneLine = (s) => s.replace(/\s+/g, " ").trim();
5
+ export function fit(text, max) {
6
+ const t = oneLine(text);
7
+ if (t.length <= max)
8
+ return t;
9
+ const cut = t.slice(0, max - 1);
10
+ const space = cut.lastIndexOf(" ");
11
+ return `${(space > max * 0.6 ? cut.slice(0, space) : cut).trimEnd()}…`;
12
+ }
13
+ export function enrich(summary, body, max) {
14
+ const head = plain(summary);
15
+ const sentences = plain(body).split(/(?<=[.!?])\s+/).filter(Boolean);
16
+ let out = head;
17
+ for (const s of sentences) {
18
+ if (!out) {
19
+ out = s;
20
+ continue;
21
+ }
22
+ if (out.includes(s.slice(0, 40)))
23
+ continue;
24
+ if (out.length + 1 + s.length > max)
25
+ break;
26
+ out = `${out} ${s}`;
27
+ }
28
+ return fit(out || head, max);
29
+ }
30
+ export function relTime(ts, now = Date.now()) {
31
+ const s = Math.max(0, Math.round((now - ts) / 1000));
32
+ if (s < 45)
33
+ return "just now";
34
+ const m = Math.round(s / 60);
35
+ if (m < 60)
36
+ return `${m}m ago`;
37
+ const h = Math.round(m / 60);
38
+ if (h < 24)
39
+ return `${h}h ago`;
40
+ const d = Math.round(h / 24);
41
+ return d === 1 ? "yesterday" : `${d}d ago`;
42
+ }
43
+ export function plain(text) {
44
+ return oneLine(text)
45
+ .replace(/^[a-z0-9._-]+\s+(?:asked|asks|says|said|needs a decision|escalated)\s*:\s*/i, "")
46
+ .replace(/^(?:ASK|ANSWER|PROPOSE|ACCEPT|OBJECT|CONTRACT|COMMIT|DELIVERED|ESCALATE|HUMAN_REQUIRED|NOTE|CLOSE)\s+from\s+[a-z0-9._-]+\s*:\s*/i, "")
47
+ .replace(/^(?:ASK|ANSWER|PROPOSE|ACCEPT|OBJECT|CONTRACT|COMMIT|DELIVERED|ESCALATE|HUMAN_REQUIRED|NOTE|CLOSE)\s*[:—-]\s*/, "")
48
+ .replace(/^(?:human (?:action|input) required)\s*[—:-]?\s*/i, "")
49
+ .replace(/^[a-z0-9._-]+\s+(?:asked|asks)\s*:\s*/i, "");
50
+ }
51
+ const agentLabel = (agent) => {
52
+ if (!agent)
53
+ return null;
54
+ const a = agent.toLowerCase();
55
+ if (a.includes("claude"))
56
+ return "Claude Code";
57
+ if (a.includes("codex"))
58
+ return "Codex";
59
+ if (a.includes("cursor"))
60
+ return "Cursor";
61
+ return null;
62
+ };
63
+ function whoLine(from, agent, ts, now) {
64
+ const parts = [from];
65
+ const a = agentLabel(agent);
66
+ if (a)
67
+ parts.push(a);
68
+ if (ts)
69
+ parts.push(relTime(ts, now));
70
+ return fit(parts.join(" · "), BUDGET.who);
71
+ }
72
+ function primaryFor(tools, preferred, prompt) {
73
+ if (!tools.length)
74
+ return undefined;
75
+ const def = tools.find((t) => t.id === preferred) ?? tools[0];
76
+ return { label: `Open in ${def.label}`, default: def.id, tools, prompt };
77
+ }
78
+ export function composeDecision(o, act, goal, peer, ctx) {
79
+ const now = ctx.now ?? Date.now();
80
+ const from = act?.from ?? peer?.member ?? o.body.match(/^([a-z0-9._-]+)\s+(?:needs a decision|escalated)/i)?.[1] ?? "a teammate";
81
+ const body = act
82
+ ? enrich(act.summary ?? "", act.body, BUDGET.body) || decisionTitle(plain(o.body), BUDGET.body)
83
+ : decisionTitle(plain(o.body), BUDGET.body);
84
+ return {
85
+ kind: "decision",
86
+ who: whoLine(from, peer?.agent ?? act?.role === "worker" ? peer?.agent : peer?.agent, act?.ts ?? o.created_at, now),
87
+ heading: "Needs your decision",
88
+ context: fit(goal?.title ?? "", BUDGET.context),
89
+ body: fit(body, BUDGET.body),
90
+ ttl: 0,
91
+ sound: true,
92
+ primary: primaryFor(ctx.tools, ctx.preferred, decisionPrompt(o.id)),
93
+ details: { decision: o.id, ...(act ? { act: act.id } : {}) },
94
+ };
95
+ }
96
+ export function composeDelivery(act, goal, peer, ctx) {
97
+ const now = ctx.now ?? Date.now();
98
+ const m = (act.meta ?? {});
99
+ const where = m.branch ? `${m.branch}${m.commit ? ` @ ${m.commit.slice(0, 7)}` : ""}` : "a branch";
100
+ const said = enrich(act.summary ?? "", act.body, BUDGET.body - where.length - 4);
101
+ const prompt = `A teammate delivered work in Pantheon: fetch ${m.branch ?? "their branch"}${m.commit ? ` at ${m.commit}` : ""} from ${act.from}, check it against the goal "${goal?.title ?? act.goal ?? ""}", then ACCEPT or OBJECT (act ${act.id}).`;
102
+ return {
103
+ kind: "delivery",
104
+ who: whoLine(act.from, peer?.agent, act.ts, now),
105
+ heading: "Delivered work to you",
106
+ context: fit(goal?.title ?? "", BUDGET.context),
107
+ body: fit(`${said ? `${said} ` : ""}(${where})`, BUDGET.body),
108
+ ttl: 15,
109
+ sound: false,
110
+ primary: primaryFor(ctx.tools, ctx.preferred, prompt),
111
+ details: { act: act.id },
112
+ };
113
+ }
114
+ const VERB = {
115
+ ANSWER: "Answered", ACCEPT: "Accepted", OBJECT: "Objected to", DELIVERED: "Delivered on", PROPOSE: "Proposed for", NOTE: "Noted on", ESCALATE: "Escalated", COMMIT: "Committed to",
116
+ };
117
+ const NOUN = { ANSWER: "question", ACCEPT: "delivery", OBJECT: "delivery", DELIVERED: "request", PROPOSE: "request", NOTE: "request", ESCALATE: "request to you", COMMIT: "request" };
118
+ const ASKED = { ASK: "question", PROPOSE: "proposal", DELIVERED: "delivery", CONTRACT: "contract", COMMIT: "commitment", ESCALATE: "escalation", HUMAN_REQUIRED: "decision" };
119
+ function nounFor(obligationBody, replyAct) {
120
+ const m = /^(ASK|PROPOSE|DELIVERED|CONTRACT|COMMIT|ESCALATE|HUMAN_REQUIRED)\s+from\s+/i.exec(obligationBody.trim());
121
+ if (m)
122
+ return ASKED[m[1].toUpperCase()] ?? "request";
123
+ if (/^[a-z0-9._-]+\s+asked\s*:/i.test(obligationBody))
124
+ return "question";
125
+ return (replyAct && NOUN[replyAct]) ?? "request";
126
+ }
127
+ export function composeWorker(run, ctx) {
128
+ const peer = run.triggeredBy && run.triggeredBy !== "unknown" ? run.triggeredBy : "a teammate";
129
+ const asked = plain(run.obligationBody);
130
+ let body;
131
+ if (!run.ok) {
132
+ body = `Could not finish ${peer}'s request: ${asked}. It needs a live session or your decision.`;
133
+ }
134
+ else if (run.reply) {
135
+ const verb = VERB[run.reply.act] ?? "Replied to";
136
+ const noun = nounFor(run.obligationBody, run.reply.act);
137
+ const lead = `${verb} ${peer}'s ${noun}: `;
138
+ const said = enrich(run.reply.summary ?? "", run.reply.body, BUDGET.body - lead.length);
139
+ body = said ? lead + said : lead.replace(/: $/, "");
140
+ }
141
+ else {
142
+ body = `Handled ${peer}'s request: ${asked}`;
143
+ }
144
+ const prompt = `A Pantheon run on this machine could not finish ${peer}'s request${run.goal ? ` in "${run.goal.title}"` : ""}${run.obligationRef ? ` (act ${run.obligationRef})` : ""}. Read the thread, then answer or deliver what is still owed.`;
145
+ return {
146
+ kind: run.ok ? "worker" : "worker_failed",
147
+ who: fit(`Your worker · for ${peer}`, BUDGET.who),
148
+ heading: run.ok ? "Your worker handled it" : "Your worker could not finish",
149
+ context: fit(run.goal?.title ?? "", BUDGET.context),
150
+ body: fit(body, BUDGET.body),
151
+ ttl: run.ok ? 15 : 0,
152
+ sound: !run.ok,
153
+ ...(run.ok ? {} : { primary: primaryFor(ctx.tools, ctx.preferred, prompt) }),
154
+ details: { ...(run.reply ? { act: run.reply.id } : run.obligationRef ? { act: run.obligationRef } : {}) },
155
+ };
156
+ }
157
+ //# sourceMappingURL=notice.js.map
package/dist/notifier.js CHANGED
@@ -6,8 +6,9 @@ import { dirname, join, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { PantheonClient } from "./client.js";
8
8
  import { CONFIG_DIR } from "./config.js";
9
- import { decisionPrompt, decisionTitle } from "./decision-ui.js";
10
- import { loadProfile, SURFACE_BUNDLE_IDS } from "./profile.js";
9
+ import { loadProfile } from "./profile.js";
10
+ import { composeDecision, composeDelivery, TOOL_LABELS } from "./notice.js";
11
+ import { detectAvailable } from "./agents.js";
11
12
  export const DEFAULT_REMINDER_MS = 4 * 60 * 60 * 1_000;
12
13
  export function humanObligations(obligations, member) {
13
14
  return obligations.filter((o) => o.kind === "HUMAN" && o.owner === `human:${member}`);
@@ -69,19 +70,26 @@ export function requireDeveloperTools(what, service) {
69
70
  throw new Error(`building ${what} needs the Xcode Command Line Tools — run \`xcode-select --install\`, then \`pantheon service install ${service}\``);
70
71
  }
71
72
  }
72
- export function sendDesktopNotification(title, subtitle, body, profile, prompt = "", eyebrow = "Decision needed") {
73
+ export function installedTools() {
74
+ const seen = new Set();
75
+ const out = [];
76
+ for (const { backend } of detectAvailable()) {
77
+ const id = backend.id;
78
+ if (!(id in TOOL_LABELS) || seen.has(id))
79
+ continue;
80
+ seen.add(id);
81
+ out.push({ id, label: TOOL_LABELS[id] });
82
+ }
83
+ return out;
84
+ }
85
+ export function sendDesktopNotification(notice, home = CONFIG_DIR) {
73
86
  if (platform() !== "darwin")
74
87
  return false;
75
- const binary = hudBinaryPath();
88
+ const binary = hudBinaryPath(home);
76
89
  if (!existsSync(binary))
77
90
  return false;
78
91
  try {
79
- const child = spawn(binary, [
80
- "post", title, `${subtitle} — ${body}`, SURFACE_BUNDLE_IDS[profile.agent.primary_surface], prompt,
81
- ], {
82
- env: { ...process.env, PANTHEON_HUD_EYEBROW: eyebrow },
83
- detached: true, stdio: "ignore",
84
- });
92
+ const child = spawn(binary, ["post", "--json", JSON.stringify({ ...notice, bin_dir: join(home, "bin") })], { detached: true, stdio: "ignore" });
85
93
  child.unref();
86
94
  return true;
87
95
  }
@@ -119,9 +127,13 @@ export class HumanNotifier {
119
127
  const due = prefs.decisions
120
128
  ? dueHumanObligations(client.obligations, this.cfg.member, state, now(), this.opts.reminderMs)
121
129
  : [];
130
+ const tools = installedTools();
131
+ const peerOf = (from) => from ? { member: from, agent: client.roster.find((m) => m.id === from)?.agent } : undefined;
132
+ const withLabel = (n) => this.opts.label ? { ...n, context: n.context ? `${this.opts.label} · ${n.context}` : this.opts.label } : n;
122
133
  for (const obligation of due) {
123
- const goal = obligation.goal ? client.goals.get(obligation.goal)?.title : undefined;
124
- const ok = send("Your input is needed", oneLine(`${this.opts.label ? `${this.opts.label} · ` : ""}${goal ?? "Pantheon decision"}`, 80), decisionTitle(obligation.body, 180), profile, decisionPrompt(obligation.id));
134
+ const act = client.seenAct(obligation.ref);
135
+ const notice = withLabel(composeDecision(obligation, act, obligation.goal ? client.goals.get(obligation.goal) : undefined, peerOf(act?.from), { tools, preferred: profile.agent.primary_surface, now: now() }));
136
+ const ok = send(notice);
125
137
  if (ok) {
126
138
  state.notified_at[obligation.id] = now();
127
139
  dirty = true;
@@ -142,8 +154,7 @@ export class HumanNotifier {
142
154
  const forMe = (r.to?.includes(this.cfg.member) ?? false) || (goal?.members.includes(this.cfg.member) ?? false);
143
155
  if (!forMe)
144
156
  continue;
145
- const m = r.meta;
146
- const ok = send("Work delivered to you", oneLine(`${this.opts.label ? `${this.opts.label} · ` : ""}${goal?.title ?? "Pantheon delivery"}`, 80), oneLine(`${r.from} pushed ${m?.branch ?? "a branch"}${m?.commit ? ` @ ${m.commit.slice(0, 7)}` : ""} — ${r.body}`, 180), profile, `A teammate delivered work in pantheon: fetch ${m?.branch ?? "their branch"}${m?.commit ? ` at ${m.commit}` : ""} from ${r.from}, check it against the goal "${goal?.title ?? r.goal ?? ""}", then ACCEPT or OBJECT.`, "Delivered");
157
+ const ok = send(withLabel(composeDelivery(r, goal, peerOf(r.from), { tools, preferred: profile.agent.primary_surface, now: now() })));
147
158
  if (ok) {
148
159
  state.notified_at[`act:${r.id}`] = now();
149
160
  writeState(stateFile, state);
@@ -58,6 +58,7 @@ export function wireUp(opts, deps = {}) {
58
58
  try {
59
59
  ensureNotifier();
60
60
  installSvc("notifier", homedir(), []);
61
+ writeTrayCommands();
61
62
  tools.push({ tool: "notifications", status: "on — native alerts for decisions that require you" });
62
63
  }
63
64
  catch (e) {
package/dist/tray.js CHANGED
@@ -41,6 +41,7 @@ export function writeTrayCommands(home = CONFIG_DIR) {
41
41
  ["pantheon-doctor.command", `${JSON.stringify(bin)} doctor`],
42
42
  ["pantheon-approvals.command", `${JSON.stringify(bin)} approvals`],
43
43
  ["pantheon-explain.command", `${JSON.stringify(bin)} explain --queued`],
44
+ ["pantheon-details.command", `${JSON.stringify(bin)} explain --queued`],
44
45
  ]) {
45
46
  const file = join(dir, name);
46
47
  const keep = name === "pantheon-explain.command" ? "" : `\necho\nread -s -k '?press any key to close '`;
@@ -74,8 +75,7 @@ async function getJson(url) {
74
75
  clearTimeout(t);
75
76
  }
76
77
  }
77
- export async function trayStatus(cfg = load()) {
78
- const f = loadAll();
78
+ export async function trayStatus(cfg = load(), f = loadAll()) {
79
79
  const ms = Object.values(f?.spaces ?? {});
80
80
  if (f && ms.length > 1) {
81
81
  const parts = await Promise.all(ms.map((m) => trayStatusOne(configFor(f, m, null))));
package/dist/worker.js CHANGED
@@ -11,7 +11,9 @@ import { agentHookStates, repairHook } from "./notify.js";
11
11
  import { fence, assess } from "./untrusted.js";
12
12
  import { allowed, recordRun } from "./spend.js";
13
13
  import { loadProfile, reservedDecisionsNote } from "./profile.js";
14
- import { clip, workerDoneNotice } from "./brief.js";
14
+ import { clip } from "./brief.js";
15
+ import { composeWorker } from "./notice.js";
16
+ import { installedTools } from "./notifier.js";
15
17
  import { loadPersonas, matchPersona, personaByName, personaMatches, effectivePolicy, personaBrief, describePersona, } from "./persona.js";
16
18
  import { isContinuationFor, LIMITS, shouldDeferToHuman } from "./protocol.js";
17
19
  const git = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
@@ -538,6 +540,7 @@ export class Worker {
538
540
  }
539
541
  const reserved = reservedDecisionsNote(loadProfile(this.cfg));
540
542
  const brief = makeBrief(branch) + baseNote(baseRef, baseSha) + (reserved ? `\n\n${reserved}` : "");
543
+ const startedAt = Date.now();
541
544
  writeFileSync(join(worktree, ".pantheon-task.md"), brief);
542
545
  let command;
543
546
  let args;
@@ -587,17 +590,10 @@ export class Worker {
587
590
  at: Date.now(), goal: o.goal, obligation: o.id, ok,
588
591
  triggeredBy, summary,
589
592
  }, this.opts.home);
590
- if (!ok && this.opts.notify) {
593
+ if (this.opts.notify && (!ok || loadProfile(this.cfg).notifications?.worker_done)) {
591
594
  try {
592
- const n = workerDoneNotice({ ok, triggeredBy, summary });
593
- this.opts.notify({ title: n.title, subtitle: n.subtitle, body: n.body, eyebrow: "Pantheon · needs attention" });
594
- }
595
- catch { }
596
- }
597
- else if (ok && this.opts.notify && loadProfile(this.cfg).notifications?.worker_done) {
598
- try {
599
- const n = workerDoneNotice({ ok, triggeredBy, summary });
600
- this.opts.notify({ title: n.title, subtitle: n.subtitle, body: n.body, eyebrow: "Pantheon · worker finished" });
595
+ const reply = client.mineSince(startedAt, o.goal).pop() ?? null;
596
+ this.opts.notify(composeWorker({ ok, triggeredBy, obligationBody: o.body, goal: o.goal ? client.goals.get(o.goal) : undefined, reply, obligationRef: o.ref }, { tools: installedTools(), preferred: loadProfile(this.cfg).agent.primary_surface }));
601
597
  }
602
598
  catch { }
603
599
  }
@@ -1,9 +1,22 @@
1
1
  /*
2
- * Pantheon decision HUD.
2
+ * Pantheon notification HUD.
3
3
  *
4
4
  * A small, unsigned AppKit panel instead of a Notification Center or
5
- * AppleScript notification. Clicking it activates the agent surface selected
6
- * in the human's Pantheon profile. It owns no state and exits after 20 seconds.
5
+ * AppleScript notification. It answers the four things a person needs in a
6
+ * glance — who, what happened, what to do, where — and offers three actions:
7
+ *
8
+ * Open in <tool> ▾ launches the chosen agent CLI with the question pre-filled
9
+ * (the choice becomes the default), through a .command file
10
+ * Terminal runs — no automation permission needed
11
+ * Show details prints the act and the decision in Terminal
12
+ * Dismiss / × closes
13
+ *
14
+ * It owns no state. Everything it needs arrives as one JSON document:
15
+ *
16
+ * pantheon-hud post --json '<notice json>'
17
+ *
18
+ * The older positional form is still accepted so a freshly upgraded CLI and a
19
+ * not-yet-rebuilt helper (or the reverse) keep working during an upgrade:
7
20
  *
8
21
  * pantheon-hud post <title> <body> <activate-bundle-id> [ready-prompt]
9
22
  */
@@ -12,73 +25,138 @@ import AppKit
12
25
  let args = CommandLine.arguments
13
26
 
14
27
  if args.count == 2, args[1] == "version" {
15
- print("1")
28
+ print("2")
16
29
  exit(0)
17
30
  }
18
31
 
19
- guard args.count >= 5, args[1] == "post" else {
20
- fputs("usage: pantheon-hud post <title> <body> <activate-bundle-id> [ready-prompt]\n", stderr)
32
+ struct Tool: Decodable { let id: String; let label: String }
33
+ struct Primary: Decodable { let label: String; let `default`: String; let tools: [Tool]; let prompt: String }
34
+ struct Details: Decodable { let act: String?; let decision: String? }
35
+ struct Notice: Decodable {
36
+ let kind: String
37
+ let who: String
38
+ let avatar: String?
39
+ let heading: String
40
+ let context: String
41
+ let body: String
42
+ let ttl: Double
43
+ let sound: Bool
44
+ let primary: Primary?
45
+ let details: Details?
46
+ /** where to write the chosen action and which .command files to open — supplied by the CLI */
47
+ let bin_dir: String
48
+ }
49
+
50
+ var notice: Notice
51
+ var legacyBundle: String? = nil
52
+ var legacyPrompt = ""
53
+
54
+ if args.count >= 4, args[1] == "post", args[2] == "--json", let data = args[3].data(using: .utf8), let n = try? JSONDecoder().decode(Notice.self, from: data) {
55
+ notice = n
56
+ } else if args.count >= 5, args[1] == "post" {
57
+ // Legacy: <title> <body> <bundle> [prompt]. Shown in the new layout with a single "Open" action that activates the bundle.
58
+ let eyebrow = ProcessInfo.processInfo.environment["PANTHEON_HUD_EYEBROW"] ?? "Pantheon"
59
+ legacyBundle = args[4]
60
+ legacyPrompt = args.count >= 6 ? args[5] : ""
61
+ let json = """
62
+ {"kind":"legacy","who":\(jsonString(eyebrow)),"heading":\(jsonString(args[2])),"context":"","body":\(jsonString(args[3])),"ttl":20,"sound":false,"primary":null,"details":null,"bin_dir":""}
63
+ """
64
+ notice = try! JSONDecoder().decode(Notice.self, from: json.data(using: .utf8)!)
65
+ } else {
66
+ fputs("usage: pantheon-hud post --json '<notice>' | pantheon-hud post <title> <body> <bundle-id> [prompt]\n", stderr)
21
67
  exit(64)
22
68
  }
23
69
 
24
- let eventTitle = args[2]
25
- let eventBody = args[3]
26
- let activateBundle = args[4]
27
- let readyPrompt = args.count >= 6 ? args[5] : ""
70
+ func jsonString(_ s: String) -> String {
71
+ let d = try! JSONSerialization.data(withJSONObject: [s])
72
+ let arr = String(data: d, encoding: .utf8)!
73
+ return String(arr.dropFirst().dropLast())
74
+ }
28
75
 
29
76
  private func label(_ text: String, font: NSFont, colour: NSColor, lines: Int) -> NSTextField {
30
77
  let field = NSTextField(wrappingLabelWithString: text)
31
78
  field.font = font
32
79
  field.textColor = colour
33
80
  field.maximumNumberOfLines = lines
34
- field.lineBreakMode = .byTruncatingTail
81
+ field.lineBreakMode = lines > 1 ? .byWordWrapping : .byTruncatingTail
82
+ field.cell?.truncatesLastVisibleLine = true
35
83
  field.translatesAutoresizingMaskIntoConstraints = false
84
+ field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
85
+ // Without this a wrapping label under Auto Layout lays out as one truncated line.
86
+ field.preferredMaxLayoutWidth = 420 - 32
36
87
  return field
37
88
  }
38
89
 
39
- final class ActionView: NSView {
40
- override func hitTest(_ point: NSPoint) -> NSView? {
41
- bounds.contains(point) ? self : nil
42
- }
90
+ final class AppDelegate: NSObject, NSApplicationDelegate {
91
+ private var panel: NSPanel?
92
+ private var timer: DispatchWorkItem?
93
+ private var hovering = false
43
94
 
44
- override func mouseDown(with event: NSEvent) {
45
- if !readyPrompt.isEmpty {
46
- let pasteboard = NSPasteboard.general
47
- pasteboard.clearContents()
48
- pasteboard.setString(readyPrompt, forType: .string)
49
- }
50
- if let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: activateBundle) {
51
- let config = NSWorkspace.OpenConfiguration()
52
- config.activates = true
53
- NSWorkspace.shared.openApplication(at: url, configuration: config) { _, _ in
54
- NSApp.terminate(nil)
55
- }
56
- } else {
57
- NSApp.terminate(nil)
95
+ // MARK: actions
96
+
97
+ /// Write what was chosen for `pantheon explain --queued`, then open the .command that runs it in Terminal.
98
+ private func act(_ payload: [String: Any], command: String) {
99
+ guard !notice.bin_dir.isEmpty else { return }
100
+ let dir = URL(fileURLWithPath: notice.bin_dir)
101
+ if let data = try? JSONSerialization.data(withJSONObject: payload) {
102
+ try? data.write(to: dir.appendingPathComponent(".hud-action.json"), options: .atomic)
58
103
  }
104
+ NSWorkspace.shared.open(dir.appendingPathComponent(command))
105
+ NSApp.terminate(nil)
59
106
  }
60
- }
61
107
 
62
- final class AppDelegate: NSObject, NSApplicationDelegate {
63
- private var panel: NSPanel?
108
+ @objc func openTool(_ sender: NSMenuItem) {
109
+ guard let p = notice.primary else { return }
110
+ let tool = (sender.representedObject as? String) ?? p.default
111
+ act(["action": "open", "tool": tool, "prompt": p.prompt, "decision": notice.details?.decision as Any, "act": notice.details?.act as Any].compactMapValues { $0 is NSNull ? nil : $0 }, command: "pantheon-explain.command")
112
+ }
113
+
114
+ @objc func openDefault(_ sender: Any?) {
115
+ guard let p = notice.primary else { return }
116
+ act(["action": "open", "tool": p.default, "prompt": p.prompt, "decision": notice.details?.decision as Any, "act": notice.details?.act as Any].compactMapValues { $0 is NSNull ? nil : $0 }, command: "pantheon-explain.command")
117
+ }
118
+
119
+ @objc func showDetails(_ sender: Any?) {
120
+ act(["action": "details", "act": notice.details?.act as Any, "decision": notice.details?.decision as Any].compactMapValues { $0 is NSNull ? nil : $0 }, command: "pantheon-details.command")
121
+ }
122
+
123
+ @objc func legacyOpen(_ sender: Any?) {
124
+ if !legacyPrompt.isEmpty { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(legacyPrompt, forType: .string) }
125
+ if let b = legacyBundle, let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: b) {
126
+ let config = NSWorkspace.OpenConfiguration(); config.activates = true
127
+ NSWorkspace.shared.openApplication(at: url, configuration: config) { _, _ in NSApp.terminate(nil) }
128
+ } else { NSApp.terminate(nil) }
129
+ }
130
+
131
+ @objc func dismiss(_ sender: Any?) { NSApp.terminate(nil) }
132
+
133
+ // MARK: timing — a hover pauses the fade; ttl 0 stays until acted on
134
+
135
+ private func schedule() {
136
+ timer?.cancel()
137
+ guard notice.ttl > 0 else { return }
138
+ let item = DispatchWorkItem { [weak self] in if self?.hovering == false { NSApp.terminate(nil) } else { self?.schedule() } }
139
+ timer = item
140
+ DispatchQueue.main.asyncAfter(deadline: .now() + notice.ttl, execute: item)
141
+ }
142
+
143
+ // MARK: layout
64
144
 
65
145
  func applicationDidFinishLaunching(_ notification: Notification) {
66
146
  NSApp.setActivationPolicy(.accessory)
67
147
 
68
- let width: CGFloat = 400
69
- let height: CGFloat = 170
70
- let panel = NSPanel(
71
- contentRect: NSRect(x: 0, y: 0, width: width, height: height),
72
- styleMask: [.borderless, .nonactivatingPanel],
73
- backing: .buffered,
74
- defer: false
75
- )
148
+ let width: CGFloat = 420
149
+ let hasContext = !notice.context.isEmpty
150
+ // Height follows the content: measured after the constraints are in place, so a
151
+ // five-line body gets five lines and a one-line body leaves no empty band.
152
+ let panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: width, height: 200), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: false)
76
153
  panel.isOpaque = false
77
154
  panel.backgroundColor = .clear
78
155
  panel.hasShadow = true
79
156
  panel.level = .floating
80
157
  panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .transient]
81
158
  panel.hidesOnDeactivate = false
159
+ panel.isMovableByWindowBackground = true
82
160
 
83
161
  let effect = NSVisualEffectView(frame: panel.contentView?.bounds ?? .zero)
84
162
  effect.autoresizingMask = [.width, .height]
@@ -89,32 +167,109 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
89
167
  effect.layer?.cornerRadius = 16
90
168
  effect.layer?.masksToBounds = true
91
169
 
92
- let root = ActionView(frame: effect.bounds)
93
- root.autoresizingMask = [.width, .height]
170
+ let root = HoverView(frame: effect.bounds)
171
+ root.translatesAutoresizingMaskIntoConstraints = false
172
+ root.onHover = { [weak self] inside in self?.hovering = inside }
94
173
  effect.addSubview(root)
95
174
 
96
- let eyebrowText = ProcessInfo.processInfo.environment["PANTHEON_HUD_EYEBROW"] ?? "Decision needed"
97
- let eyebrow = label(eyebrowText, font: .systemFont(ofSize: 10, weight: .semibold), colour: .secondaryLabelColor, lines: 1)
98
- let title = label(eventTitle, font: .systemFont(ofSize: 16, weight: .semibold), colour: .labelColor, lines: 2)
99
- let body = label(eventBody, font: .systemFont(ofSize: 13), colour: .secondaryLabelColor, lines: 3)
100
- let actionText = readyPrompt.isEmpty ? "Open your agent →" : "Open your agent · question copied →"
101
- let action = label(actionText, font: .systemFont(ofSize: 12, weight: .semibold), colour: .controlAccentColor, lines: 1)
102
-
103
- for view in [eyebrow, title, body, action] { root.addSubview(view) }
104
- NSLayoutConstraint.activate([
105
- eyebrow.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 18),
106
- eyebrow.topAnchor.constraint(equalTo: root.topAnchor, constant: 16),
107
- title.leadingAnchor.constraint(equalTo: eyebrow.leadingAnchor),
108
- title.topAnchor.constraint(equalTo: eyebrow.bottomAnchor, constant: 7),
109
- title.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -18),
110
- body.leadingAnchor.constraint(equalTo: eyebrow.leadingAnchor),
111
- body.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
112
- body.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -18),
113
- action.leadingAnchor.constraint(equalTo: eyebrow.leadingAnchor),
114
- action.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -15),
115
- ])
175
+ let who = label(notice.who, font: .systemFont(ofSize: 12, weight: .medium), colour: .secondaryLabelColor, lines: 1)
176
+ let heading = label(notice.heading, font: .systemFont(ofSize: 16, weight: .semibold), colour: .labelColor, lines: 1)
177
+ let context = label(notice.context, font: .systemFont(ofSize: 12, weight: .medium), colour: .tertiaryLabelColor, lines: 1)
178
+ let body = label(notice.body, font: .systemFont(ofSize: 13), colour: .secondaryLabelColor, lines: 3)
179
+
180
+ let close = NSButton(title: "×", target: self, action: #selector(dismiss(_:)))
181
+ close.isBordered = false
182
+ close.font = .systemFont(ofSize: 15, weight: .medium)
183
+ close.contentTintColor = .tertiaryLabelColor
184
+ close.translatesAutoresizingMaskIntoConstraints = false
185
+ close.setAccessibilityLabel("Dismiss")
186
+
187
+ for view in [who, heading, body, close] { root.addSubview(view) }
188
+ if hasContext { root.addSubview(context) }
189
+
190
+ var constraints: [NSLayoutConstraint] = [
191
+ root.widthAnchor.constraint(equalToConstant: width),
192
+ root.leadingAnchor.constraint(equalTo: effect.leadingAnchor), root.trailingAnchor.constraint(equalTo: effect.trailingAnchor),
193
+ root.topAnchor.constraint(equalTo: effect.topAnchor), root.bottomAnchor.constraint(equalTo: effect.bottomAnchor),
194
+ who.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 16),
195
+ who.topAnchor.constraint(equalTo: root.topAnchor, constant: 16),
196
+ who.trailingAnchor.constraint(lessThanOrEqualTo: close.leadingAnchor, constant: -8),
197
+ close.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -12),
198
+ close.topAnchor.constraint(equalTo: root.topAnchor, constant: 10),
199
+ heading.leadingAnchor.constraint(equalTo: who.leadingAnchor),
200
+ heading.topAnchor.constraint(equalTo: who.bottomAnchor, constant: 10),
201
+ heading.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
202
+ body.leadingAnchor.constraint(equalTo: who.leadingAnchor),
203
+ body.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
204
+ ]
205
+ if hasContext {
206
+ constraints += [
207
+ context.leadingAnchor.constraint(equalTo: who.leadingAnchor),
208
+ context.topAnchor.constraint(equalTo: heading.bottomAnchor, constant: 2),
209
+ context.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
210
+ body.topAnchor.constraint(equalTo: context.bottomAnchor, constant: 6),
211
+ ]
212
+ } else {
213
+ constraints.append(body.topAnchor.constraint(equalTo: heading.bottomAnchor, constant: 6))
214
+ }
215
+
216
+ // actions
217
+ if let p = notice.primary {
218
+ let pull = NSPopUpButton(frame: .zero, pullsDown: true)
219
+ pull.translatesAutoresizingMaskIntoConstraints = false
220
+ pull.bezelStyle = .rounded
221
+ pull.font = .systemFont(ofSize: 12, weight: .semibold)
222
+ pull.addItem(withTitle: p.label) // the visible title of a pull-down is its first item
223
+ for t in p.tools {
224
+ let mi = NSMenuItem(title: "Open in \(t.label)\(t.id == p.default ? " (default)" : "")", action: #selector(openTool(_:)), keyEquivalent: "")
225
+ mi.target = self
226
+ mi.representedObject = t.id
227
+ pull.menu?.addItem(mi)
228
+ }
229
+ root.addSubview(pull)
230
+ constraints += [
231
+ pull.leadingAnchor.constraint(equalTo: who.leadingAnchor),
232
+ pull.topAnchor.constraint(equalTo: body.bottomAnchor, constant: 16),
233
+ pull.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -14),
234
+ pull.widthAnchor.constraint(greaterThanOrEqualToConstant: 170),
235
+ ]
236
+ var trailing: NSView = pull
237
+ if notice.details != nil {
238
+ let d = NSButton(title: "Show details", target: self, action: #selector(showDetails(_:)))
239
+ d.isBordered = false; d.font = .systemFont(ofSize: 12, weight: .medium); d.contentTintColor = .controlAccentColor
240
+ d.translatesAutoresizingMaskIntoConstraints = false
241
+ root.addSubview(d)
242
+ constraints += [d.leadingAnchor.constraint(equalTo: pull.trailingAnchor, constant: 14), d.centerYAnchor.constraint(equalTo: pull.centerYAnchor)]
243
+ trailing = d
244
+ }
245
+ let dis = NSButton(title: "Dismiss", target: self, action: #selector(dismiss(_:)))
246
+ dis.isBordered = false; dis.font = .systemFont(ofSize: 12); dis.contentTintColor = .secondaryLabelColor
247
+ dis.translatesAutoresizingMaskIntoConstraints = false
248
+ root.addSubview(dis)
249
+ constraints += [dis.leadingAnchor.constraint(equalTo: trailing.trailingAnchor, constant: 14), dis.centerYAnchor.constraint(equalTo: pull.centerYAnchor)]
250
+ } else if notice.details != nil || legacyBundle != nil {
251
+ let main = NSButton(title: legacyBundle != nil ? "Open your agent" : "Show details", target: self, action: legacyBundle != nil ? #selector(legacyOpen(_:)) : #selector(showDetails(_:)))
252
+ main.bezelStyle = .rounded; main.font = .systemFont(ofSize: 12, weight: .semibold)
253
+ main.translatesAutoresizingMaskIntoConstraints = false
254
+ let dis = NSButton(title: "Dismiss", target: self, action: #selector(dismiss(_:)))
255
+ dis.isBordered = false; dis.font = .systemFont(ofSize: 12); dis.contentTintColor = .secondaryLabelColor
256
+ dis.translatesAutoresizingMaskIntoConstraints = false
257
+ root.addSubview(main); root.addSubview(dis)
258
+ constraints += [
259
+ main.leadingAnchor.constraint(equalTo: who.leadingAnchor), main.topAnchor.constraint(equalTo: body.bottomAnchor, constant: 16), main.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -14),
260
+ dis.leadingAnchor.constraint(equalTo: main.trailingAnchor, constant: 14), dis.centerYAnchor.constraint(equalTo: main.centerYAnchor),
261
+ ]
262
+ }
263
+ if notice.primary == nil && notice.details == nil && legacyBundle == nil {
264
+ constraints.append(body.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -16))
265
+ }
266
+ NSLayoutConstraint.activate(constraints)
116
267
 
117
268
  panel.contentView = effect
269
+ effect.layoutSubtreeIfNeeded()
270
+ let height = max(120, root.fittingSize.height)
271
+ panel.setContentSize(NSSize(width: width, height: height))
272
+ effect.frame = NSRect(x: 0, y: 0, width: width, height: height)
118
273
  guard let screen = NSScreen.main ?? NSScreen.screens.first else { NSApp.terminate(nil); return }
119
274
  let visible = screen.visibleFrame
120
275
  let target = NSPoint(x: visible.maxX - width - 18, y: visible.maxY - height - 18)
@@ -126,11 +281,22 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
126
281
  panel.animator().alphaValue = 1
127
282
  panel.animator().setFrameOrigin(target)
128
283
  }
129
- NSSound(named: "Glass")?.play()
130
-
284
+ if notice.sound { NSSound(named: "Glass")?.play() }
131
285
  self.panel = panel
132
- DispatchQueue.main.asyncAfter(deadline: .now() + 20) { NSApp.terminate(nil) }
286
+ schedule()
287
+ }
288
+ }
289
+
290
+ /// Tracks the pointer so a notification being read does not fade under it.
291
+ final class HoverView: NSView {
292
+ var onHover: ((Bool) -> Void)?
293
+ override func updateTrackingAreas() {
294
+ super.updateTrackingAreas()
295
+ for a in trackingAreas { removeTrackingArea(a) }
296
+ addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], owner: self, userInfo: nil))
133
297
  }
298
+ override func mouseEntered(with event: NSEvent) { onHover?(true) }
299
+ override func mouseExited(with event: NSEvent) { onHover?(false) }
134
300
  }
135
301
 
136
302
  let app = NSApplication.shared
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@join-pantheon/cli",
3
- "version": "0.23.0",
3
+ "version": "0.23.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": {