@join-pantheon/cli 0.22.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/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);
package/dist/protocol.js CHANGED
@@ -132,6 +132,15 @@ export const MakeInvite = z.object({
132
132
  may_wake: z.boolean().default(true),
133
133
  ttl_minutes: z.number().int().min(1).max(10080).default(60),
134
134
  });
135
+ export const ListInvites = z.object({
136
+ t: z.literal("invites"),
137
+ cid: z.string().max(64).optional(),
138
+ });
139
+ export const RevokeInvite = z.object({
140
+ t: z.literal("revoke_invite"),
141
+ cid: z.string().max(64).optional(),
142
+ token: z.string().min(6).max(64),
143
+ });
135
144
  export const Admit = z.object({
136
145
  t: z.literal("admit"),
137
146
  cid: z.string().max(64).optional(),
@@ -183,7 +192,7 @@ export const WorkerState = z.object({
183
192
  detail: z.string().max(200).optional(),
184
193
  until: z.number().int().positive().optional(),
185
194
  });
186
- export const Inbound = z.discriminatedUnion("t", [Hello, SendAct, OpenGoal, Claim, Admit, MakeInvite, PublishProfile, BindRepo, DeleteSpace, WorkerState, Ping]);
195
+ export const Inbound = z.discriminatedUnion("t", [Hello, SendAct, OpenGoal, Claim, Admit, MakeInvite, ListInvites, RevokeInvite, PublishProfile, BindRepo, DeleteSpace, WorkerState, Ping]);
187
196
  export function shouldDeferToHuman(sessions, work, opts = {}) {
188
197
  const now = opts.now ?? Date.now();
189
198
  const idleMs = opts.idleMs ?? LIMITS.workerIdleMs;
@@ -12,6 +12,7 @@ import { detectAvailable, agentById } from "./agents.js";
12
12
  import { installService } from "./service.js";
13
13
  import { ensureNativeNotifier } from "./notifier.js";
14
14
  import { ensureTrayBinary, writeTrayCommands } from "./tray.js";
15
+ import { desktopUnavailable, ephemeralWarning, hostEnv } from "./host-env.js";
15
16
  import { existsSync } from "node:fs";
16
17
  import { grant as grantCap, loadPolicy, policyPath, savePolicy } from "./worker-policy.js";
17
18
  import { Relay } from "./relay.js";
@@ -27,9 +28,12 @@ export function wireUp(opts, deps = {}) {
27
28
  const installSvc = deps.installService ?? installService;
28
29
  const ensureNotifier = deps.ensureNotifier ?? ensureNativeNotifier;
29
30
  const ensureTray = deps.ensureTray ?? ensureTrayBinary;
31
+ const host = deps.host ?? hostEnv();
30
32
  const tools = installForUser().map((t) => ({ tool: t.tool, status: t.status }));
31
33
  const granted = [];
32
34
  const warnings = [];
35
+ if (host.ephemeral)
36
+ warnings.push(ephemeralWarning(host));
33
37
  if (opts.hook) {
34
38
  try {
35
39
  const { agents } = installHooks("user");
@@ -47,10 +51,14 @@ export function wireUp(opts, deps = {}) {
47
51
  else {
48
52
  tools.push({ tool: "wake-up hook", status: "skipped (--no-hook) — a peer's message waits until you type" });
49
53
  }
50
- if (opts.notifications) {
54
+ if (opts.notifications && !host.desktop) {
55
+ tools.push({ tool: "notifications", status: desktopUnavailable(host, "desktop alerts") });
56
+ }
57
+ else if (opts.notifications) {
51
58
  try {
52
59
  ensureNotifier();
53
60
  installSvc("notifier", homedir(), []);
61
+ writeTrayCommands();
54
62
  tools.push({ tool: "notifications", status: "on — native alerts for decisions that require you" });
55
63
  }
56
64
  catch (e) {
@@ -61,7 +69,10 @@ export function wireUp(opts, deps = {}) {
61
69
  else {
62
70
  tools.push({ tool: "notifications", status: "off (--no-notifications) — human escalations only appear in pantheon status" });
63
71
  }
64
- if (opts.menubar) {
72
+ if (opts.menubar && !host.desktop) {
73
+ tools.push({ tool: "menu bar", status: desktopUnavailable(host, "the menu bar") });
74
+ }
75
+ else if (opts.menubar) {
65
76
  try {
66
77
  ensureTray();
67
78
  writeTrayCommands();
@@ -241,7 +252,7 @@ export async function start(opts) {
241
252
  const wired = wireUp({
242
253
  hook: opts.hook !== false,
243
254
  notifications: opts.notifications !== false,
244
- menubar: opts.menubar !== false && process.platform === "darwin",
255
+ menubar: opts.menubar !== false,
245
256
  unattended: opts.unattended ?? null,
246
257
  ...(opts.workerHow ? { workerHow: opts.workerHow } : {}),
247
258
  });
@@ -290,7 +301,7 @@ export async function join(opts) {
290
301
  const wired = wireUp({
291
302
  hook: opts.hook !== false,
292
303
  notifications: opts.notifications !== false,
293
- menubar: opts.menubar !== false && process.platform === "darwin",
304
+ menubar: opts.menubar !== false,
294
305
  unattended: opts.unattended ?? null,
295
306
  ...(opts.workerHow ? { workerHow: opts.workerHow } : {}),
296
307
  });