@join-pantheon/cli 0.23.0 → 0.23.2

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) {