@join-pantheon/cli 0.24.0 → 0.24.4

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
@@ -331,6 +331,42 @@ async function fetchHistory(cfg, since = 0) {
331
331
  throw new Error(`relay answered ${res.status} for the ledger`);
332
332
  return (await res.json()).acts ?? [];
333
333
  }
334
+ async function fetchAct(cfg, id) {
335
+ const base = cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
336
+ const q = `space=${encodeURIComponent(cfg.space)}&token=${encodeURIComponent(cfg.token)}`;
337
+ try {
338
+ const res = await fetch(`${base}/act?${q}&id=${encodeURIComponent(id)}`, { signal: AbortSignal.timeout(8000) });
339
+ if (res.ok) {
340
+ const body = await res.json();
341
+ if (body.act)
342
+ return { act: body.act, ...(body.goal?.title ? { goalTitle: body.goal.title } : {}) };
343
+ }
344
+ if (res.status === 404) {
345
+ }
346
+ }
347
+ catch { }
348
+ let head = 0;
349
+ try {
350
+ const h = await (await fetch(`${base}/health?space=${encodeURIComponent(cfg.space)}`, { signal: AbortSignal.timeout(8000) })).json();
351
+ head = h.spaces?.[0]?.seq ?? 0;
352
+ }
353
+ catch { }
354
+ const windows = head > 0 ? [60, 200, 600].map((n) => Math.max(0, head - n)) : [0];
355
+ if (windows[windows.length - 1] !== 0)
356
+ windows.push(0);
357
+ for (const since of windows) {
358
+ try {
359
+ const acts = await fetchHistory(cfg, since);
360
+ const act = acts.find((a) => a.id === id || String(a.seq) === id);
361
+ if (act)
362
+ return { act };
363
+ if (since === 0)
364
+ return null;
365
+ }
366
+ catch { }
367
+ }
368
+ return null;
369
+ }
334
370
  function workerMidRun() {
335
371
  try {
336
372
  const s = JSON.parse(readFileSync(join(CONFIG_DIR, "worker-state.json"), "utf8"));
@@ -2165,6 +2201,21 @@ async function main() {
2165
2201
  hud = null;
2166
2202
  }
2167
2203
  }
2204
+ if (hud?.action === "done") {
2205
+ const c2 = new PantheonClient(cfg, undefined, { role: "cli" });
2206
+ await c2.connect().catch((e) => die(e.message));
2207
+ const o = hud.decision ? c2.obligations.find((x) => x.id === hud.decision) : undefined;
2208
+ if (!o) {
2209
+ c2.close();
2210
+ console.error(D("that decision is no longer waiting on you"));
2211
+ return;
2212
+ }
2213
+ await c2.send({ act: "ANSWER", ref: o.ref, goal: o.goal, after_seq: c2.readSeq,
2214
+ body: "Marked done from the alert — handled outside this thread." }).catch((e) => die(e.message));
2215
+ c2.close();
2216
+ console.error(D(`done — ${o.id} is no longer waiting on you`));
2217
+ return;
2218
+ }
2168
2219
  if (hud?.action === "details") {
2169
2220
  const acts = await fetchHistory(cfg).catch(() => []);
2170
2221
  const a = hud.act ? acts.find((x) => x.id === hud.act) : undefined;
@@ -2460,6 +2511,31 @@ async function main() {
2460
2511
  console.log(JSON.stringify(await trayStatus()));
2461
2512
  return;
2462
2513
  }
2514
+ case "tray-detail": {
2515
+ const key = argv[1] ?? die("usage: pantheon tray-detail <act id>");
2516
+ const cfgNow = load() ?? die("not joined to a team");
2517
+ const { humanize } = await import("./humanize.js");
2518
+ const found = await fetchAct(cfgNow, key);
2519
+ if (!found) {
2520
+ console.log(JSON.stringify({ ok: false }));
2521
+ return;
2522
+ }
2523
+ const a = found.act;
2524
+ let goalTitle = found.goalTitle;
2525
+ if (!goalTitle && a.goal) {
2526
+ const c2 = new PantheonClient(cfgNow, undefined, { role: "cli" });
2527
+ goalTitle = await c2.connect().then(() => c2.goals.get(a.goal)?.title).catch(() => undefined);
2528
+ c2.close();
2529
+ }
2530
+ const { headline, blocks } = humanize(a.body, { ...(goalTitle ? { goalTitle } : {}), from: a.from });
2531
+ console.log(JSON.stringify({
2532
+ ok: true, id: a.id, act: a.act, from: a.from, ts: a.ts,
2533
+ ...(a.summary ? { summary: a.summary } : {}),
2534
+ ...(goalTitle ? { goal_title: goalTitle } : {}),
2535
+ headline, blocks, raw: a.body,
2536
+ }));
2537
+ return;
2538
+ }
2463
2539
  case "menubar": {
2464
2540
  need();
2465
2541
  const { ensureTrayBinary, writeTrayCommands } = await import("./tray.js");
@@ -0,0 +1,35 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { CONFIG_DIR } from "./config.js";
4
+ const KEEP = 500;
5
+ export function continuedPath(home) {
6
+ return join(home ?? CONFIG_DIR, "continued.json");
7
+ }
8
+ function readAll(home) {
9
+ try {
10
+ const p = continuedPath(home);
11
+ if (!existsSync(p))
12
+ return {};
13
+ const v = JSON.parse(readFileSync(p, "utf8"));
14
+ return v && typeof v === "object" ? v : {};
15
+ }
16
+ catch {
17
+ return {};
18
+ }
19
+ }
20
+ export function loadContinued(space, home) {
21
+ return new Set(readAll(home)[space] ?? []);
22
+ }
23
+ export function markContinued(space, id, home) {
24
+ try {
25
+ const all = readAll(home);
26
+ const list = (all[space] ?? []).filter((x) => x !== id);
27
+ list.push(id);
28
+ all[space] = list.slice(-KEEP);
29
+ const p = continuedPath(home);
30
+ mkdirSync(dirname(p), { recursive: true });
31
+ writeFileSync(p, JSON.stringify(all));
32
+ }
33
+ catch { }
34
+ }
35
+ //# sourceMappingURL=continued.js.map
@@ -4,7 +4,14 @@ export function decisionTitle(body, max = 96) {
4
4
  .replace(/^[a-z0-9._-]+\s+escalated:\s*/i, "")
5
5
  .replace(/^[a-z0-9._-]+\s+needs a decision:\s*/i, "")
6
6
  .replace(/^human (?:action|input) required\s*[—:-]\s*/i, "")
7
- .replace(/^human (?:action|input) required\s*/i, "");
7
+ .replace(/^human (?:action|input) required\s*/i, "")
8
+ .replace(/^needs (?:you|your input|a human)[,:]?\s*[a-z0-9._-]*\s*[:—-]\s*/i, "")
9
+ .replace(/\(goal\s+g_[a-z0-9]+\s*,\s*/gi, "(")
10
+ .replace(/\s*\b(?:of|in|for|on|from)?\s*\(?\bgoal\s+g_[a-z0-9]+\)?/gi, "")
11
+ .replace(/\s{2,}/g, " ")
12
+ .replace(/[\s,]+(?:of|in|for|on|at|to|from)$/i, "")
13
+ .replace(/[\s,;:]+$/, "")
14
+ .trim();
8
15
  const blockedAsk = text.match(/^cannot answer (.+?)\s+ask\s*:/i);
9
16
  if (blockedAsk) {
10
17
  text = `Decide whether to share the information requested for ${blockedAsk[1]}`;
@@ -25,6 +32,12 @@ export function decisionPrompt(id) {
25
32
  export function allDecisionsPrompt(count) {
26
33
  return `Check my ${count} Pantheon decision${count === 1 ? "" : "s"}. Explain each one in plain language: what happened, why I am needed, my options, and your recommendation. Prioritize what needs attention first. Do not decide or act for me; wait for my answers.`;
27
34
  }
35
+ export function owedPrompt(title, goal) {
36
+ return `Pantheon: my side owes this — "${title}". Read the thread (pantheon history${goal ? ` --goal ${goal}` : ""}), tell me in plain language what is owed, what is already done, and exactly what you would send to discharge it. Draft the act, then wait for me before sending.`;
37
+ }
38
+ export function waitingPrompt(who, title, goal) {
39
+ return `Pantheon: I am waiting on ${who} for "${title}". Read the thread (pantheon history${goal ? ` --goal ${goal}` : ""}), tell me whether it is genuinely blocked on them, how long it has been waiting, and whether anything on my side is holding it up. If a nudge is warranted, draft a short one and wait for me.`;
40
+ }
28
41
  export function workerSummary(raw) {
29
42
  const state = oneLine(raw);
30
43
  if (/deferring.*turn is in flight|holding.*live session/i.test(state))
@@ -0,0 +1,223 @@
1
+ const PARA_CHARS = 340;
2
+ const flatten = (s) => s.replace(/[\u2018\u2019\u201B]/g, "'").replace(/[\u201C\u201D]/g, '"');
3
+ export function stripIds(text, goalTitle) {
4
+ let out = flatten(text);
5
+ if (goalTitle) {
6
+ const esc = flatten(goalTitle).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7
+ out = out.replace(new RegExp(`\\s*\\(\\s*"${esc}"\\s*\\)`, "gi"), "");
8
+ out = out.replace(new RegExp(`\\s*,?\\s*"${esc}"`, "gi"), "");
9
+ }
10
+ return out
11
+ .replace(/^needs (?:you|your input|a human)[,:]?\s*[a-z0-9._-]*\s*[:—-]\s*/i, "")
12
+ .replace(/^[a-z0-9._-]+\s+(?:escalated|needs a decision)\s*:\s*/i, "")
13
+ .replace(/\(\s*goal\s+g_[a-z0-9]+\s*,\s*/gi, "(")
14
+ .replace(/\s*\(\s*goal\s+g_[a-z0-9]+\s*\)/gi, "")
15
+ .replace(/\s*\bgoal\s+g_[a-z0-9]+/gi, "")
16
+ .replace(/\s*\bid=[ao]_[a-z0-9]+/gi, "")
17
+ .replace(/\(\s*\)/g, "")
18
+ .replace(/\s+([,.;:])/g, "$1")
19
+ .replace(/[ \t]{2,}/g, " ")
20
+ .trim();
21
+ }
22
+ const ABBREV = /\b(?:e\.g|i\.e|vs|etc|approx|no|fig|mr|ms|dr|jr|sr|[a-z])\.$/i;
23
+ export function sentences(text) {
24
+ const out = [];
25
+ let buf = "";
26
+ for (const piece of text.split(/(?<=[.!?])\s+/)) {
27
+ buf = buf ? `${buf} ${piece}` : piece;
28
+ if (ABBREV.test(buf.trim()))
29
+ continue;
30
+ if (/[.!?]["')\]]?$/.test(buf.trim())) {
31
+ out.push(buf.trim());
32
+ buf = "";
33
+ }
34
+ }
35
+ if (buf.trim())
36
+ out.push(buf.trim());
37
+ return out;
38
+ }
39
+ const VERBS = /^(?:uname|hostname|echo|ls|cat|grep|tail|head|node|npm|npx|pnpm|yarn|git|gh|pantheon|cowork|xcode-select|launchctl|systemctl|curl|wget|which|ps|kill|pkill|sudo|open|defaults|codesign|swiftc|swift|python3?|pip3?|docker|make|cd|mkdir|rm|cp|mv|chmod|export|brew|sw_vers|scutil|lsof|dig|ping|ssh|scp|awk|sed|sort|uniq|wc|jq|tar|zip|unzip|env|printenv|type|command)\b/;
40
+ const PROSE_WORDS = /\s(?:the|a|an|is|are|was|were|be|been|and|or|to|of|that|this|with|from|into|please|it|its|our|your|their|her|his|for|but|so|then|when|while|which|who|what|why|how|should|would|could|will|can|may|must|has|have|had|does|did|say|says|said|asks?|needs?|wants?)\s/i;
41
+ export function commandLike(segment) {
42
+ const t = segment.trim().replace(/[.,]$/, "").trim();
43
+ if (!t || t.length > 160)
44
+ return false;
45
+ if (PROSE_WORDS.test(` ${t} `))
46
+ return false;
47
+ const first = t.split(/\s+/)[0] ?? "";
48
+ if (!/^[a-z[$][\w.[\]$-]*$/.test(first))
49
+ return false;
50
+ const words = t.split(/\s+/).length;
51
+ return VERBS.test(t) || /\s-{1,2}[\w-]|[~/]|\||&&|\$\(|^\[/.test(t) || words <= 2;
52
+ }
53
+ function splitLeadIn(segment) {
54
+ const marks = [...segment.matchAll(/[:,]\s+/g)];
55
+ for (let i = marks.length - 1; i >= 0; i--) {
56
+ const at = (marks[i].index ?? 0) + marks[i][0].length;
57
+ const tail = segment.slice(at);
58
+ if (commandLike(tail))
59
+ return { lead: segment.slice(0, at).trim(), cmd: tail.trim() };
60
+ }
61
+ return null;
62
+ }
63
+ function asOptions(sentence) {
64
+ const marks = [...sentence.matchAll(/\((?:[a-z]|[ivx]{1,3})\)\s*/gi)];
65
+ if (marks.length < 2)
66
+ return null;
67
+ const lead = sentence.slice(0, marks[0].index ?? 0).trim().replace(/[,:]$/, "");
68
+ const items = [];
69
+ for (let i = 0; i < marks.length; i++) {
70
+ const from = (marks[i].index ?? 0) + marks[i][0].length;
71
+ const to = i + 1 < marks.length ? marks[i + 1].index ?? sentence.length : sentence.length;
72
+ const item = sentence.slice(from, to).trim()
73
+ .replace(/[,;.]\s*(?:or|and)?\s*$/i, "")
74
+ .replace(/^(?:or|and)\s+/i, "")
75
+ .trim();
76
+ if (item)
77
+ items.push(item);
78
+ }
79
+ return items.length >= 2 ? { lead, items } : null;
80
+ }
81
+ const ITEM = /^(?:[-*•]|\d+[.)])\s+/;
82
+ function asWrittenList(lines) {
83
+ const at = lines.findIndex((l) => ITEM.test(l));
84
+ if (at < 0 || lines.length - at < 2)
85
+ return null;
86
+ if (!lines.slice(at).every((l) => ITEM.test(l)))
87
+ return null;
88
+ return {
89
+ lead: lines.slice(0, at).join(" ").trim(),
90
+ items: lines.slice(at).map((l) => l.replace(ITEM, "").trim()),
91
+ };
92
+ }
93
+ function extractCommands(sentence) {
94
+ if (!sentence.includes(";"))
95
+ return null;
96
+ const parts = sentence.split(";").map((p) => p.trim()).filter(Boolean);
97
+ if (parts.length < 2)
98
+ return null;
99
+ let lead = "";
100
+ let start = -1;
101
+ const lines = [];
102
+ for (let i = 0; i < parts.length; i++) {
103
+ const part = parts[i];
104
+ if (commandLike(part)) {
105
+ if (start < 0)
106
+ start = i;
107
+ lines.push(part.replace(/\.$/, "").trim());
108
+ continue;
109
+ }
110
+ if (start < 0) {
111
+ const split = splitLeadIn(part);
112
+ if (split) {
113
+ lead = [lead, split.lead].filter(Boolean).join(" ");
114
+ start = i;
115
+ lines.push(split.cmd);
116
+ continue;
117
+ }
118
+ lead = [lead, part].filter(Boolean).join("; ");
119
+ continue;
120
+ }
121
+ return lines.length >= 2 ? { lead: lead.trim(), lines, tail: parts.slice(i).join("; ").trim() } : null;
122
+ }
123
+ return lines.length >= 2 ? { lead: lead.trim(), lines, tail: "" } : null;
124
+ }
125
+ function paragraphs(text) {
126
+ const parts = sentences(text);
127
+ const out = [];
128
+ let buf = "";
129
+ for (const s of parts) {
130
+ const next = buf ? `${buf} ${s}` : s;
131
+ if (next.length > PARA_CHARS && buf) {
132
+ out.push({ kind: "para", text: buf });
133
+ buf = s;
134
+ }
135
+ else
136
+ buf = next;
137
+ }
138
+ if (buf)
139
+ out.push({ kind: "para", text: buf });
140
+ return out;
141
+ }
142
+ const asLeadIn = (text) => text.trim().replace(/[,;]$/, ":").replace(/([^:.!?])$/, "$1:");
143
+ const push = (blocks, text, lead = false) => {
144
+ let t = text.trim();
145
+ if (!t)
146
+ return;
147
+ if (/^[a-z]/.test(t))
148
+ t = t[0].toUpperCase() + t.slice(1);
149
+ if (lead) {
150
+ blocks.push({ kind: "para", text: asLeadIn(t) });
151
+ return;
152
+ }
153
+ blocks.push(...paragraphs(t));
154
+ };
155
+ export function humanize(body, opts = {}) {
156
+ const clean = stripIds(body.replace(/\r\n/g, "\n"), opts.goalTitle);
157
+ const blocks = [];
158
+ for (const chunk of clean.split(/\n{2,}/)) {
159
+ const lines = chunk.split("\n").map((l) => l.trim()).filter(Boolean);
160
+ if (!lines.length)
161
+ continue;
162
+ const written = asWrittenList(lines);
163
+ if (written) {
164
+ if (written.lead)
165
+ push(blocks, written.lead, true);
166
+ blocks.push({ kind: "bullets", items: written.items });
167
+ continue;
168
+ }
169
+ if (lines.length >= 2 && lines.every((l) => commandLike(l))) {
170
+ blocks.push({ kind: "code", lines });
171
+ continue;
172
+ }
173
+ let pending = "";
174
+ for (const sentence of sentences(lines.join(" "))) {
175
+ const options = asOptions(sentence);
176
+ if (options) {
177
+ push(blocks, `${pending} ${options.lead}`.trim(), true);
178
+ pending = "";
179
+ blocks.push({ kind: "bullets", items: options.items });
180
+ continue;
181
+ }
182
+ const cmds = extractCommands(sentence);
183
+ if (cmds) {
184
+ push(blocks, `${pending} ${cmds.lead}`.trim(), true);
185
+ pending = "";
186
+ blocks.push({ kind: "code", lines: cmds.lines });
187
+ if (cmds.tail)
188
+ pending = cmds.tail;
189
+ continue;
190
+ }
191
+ pending = pending ? `${pending} ${sentence}` : sentence;
192
+ }
193
+ push(blocks, pending);
194
+ }
195
+ if (!blocks.length)
196
+ blocks.push({ kind: "para", text: clean });
197
+ const firstPara = blocks.find((b) => b.kind === "para");
198
+ let headline = sentences(firstPara?.text ?? clean)[0] ?? clean;
199
+ headline = headline
200
+ .replace(/^needs (?:you|your input|a human)[,:]?\s*[a-z0-9._-]*\s*[:—-]\s*/i, "")
201
+ .replace(/^[a-z0-9._-]+\s+(?:escalated|needs a decision)\s*:\s*/i, "")
202
+ .trim();
203
+ if (headline)
204
+ headline = headline[0].toUpperCase() + headline.slice(1);
205
+ headline = headline.replace(/\.$/, "").trim();
206
+ const full = headline;
207
+ if (headline.length > 180)
208
+ headline = `${headline.slice(0, 179).trimEnd()}…`;
209
+ const first = blocks[0];
210
+ if (full && first && first.kind === "para") {
211
+ const [lead, ...rest] = sentences(first.text);
212
+ const same = (lead ?? "").replace(/\.$/, "").trim().toLowerCase() === full.toLowerCase();
213
+ if (same) {
214
+ const remainder = rest.join(" ").trim();
215
+ if (remainder)
216
+ blocks[0] = { kind: "para", text: remainder };
217
+ else
218
+ blocks.shift();
219
+ }
220
+ }
221
+ return { headline: headline || "Open item", blocks };
222
+ }
223
+ //# sourceMappingURL=humanize.js.map
package/dist/notifier.js CHANGED
@@ -9,26 +9,37 @@ import { CONFIG_DIR } from "./config.js";
9
9
  import { loadProfile } from "./profile.js";
10
10
  import { APP_TOOLS, composeDecision, composeDelivery, TOOL_LABELS } from "./notice.js";
11
11
  import { cliOnPath, detectAvailable } from "./agents.js";
12
+ import { whichPantheon } from "./service.js";
12
13
  export const DEFAULT_REMINDER_MS = 4 * 60 * 60 * 1_000;
14
+ export const MAX_REMINDERS = 4;
15
+ const DAY_MS = 24 * 60 * 60 * 1_000;
13
16
  export function humanObligations(obligations, member) {
14
17
  return obligations.filter((o) => o.kind === "HUMAN" && o.owner === `human:${member}`);
15
18
  }
16
19
  export function dueHumanObligations(obligations, member, state, now, reminderMs) {
17
20
  return humanObligations(obligations, member).filter((o) => {
18
21
  const last = state.notified_at[o.id];
19
- return last === undefined || now - last >= reminderMs;
22
+ if (last === undefined)
23
+ return true;
24
+ const n = state.reminded?.[o.id] ?? 0;
25
+ if (n >= MAX_REMINDERS)
26
+ return false;
27
+ const gap = Math.min(reminderMs * 2 ** n, Math.max(reminderMs, DAY_MS));
28
+ return now - last >= gap;
20
29
  });
21
30
  }
22
31
  export function pruneNotificationState(state, obligations, member, now = Date.now()) {
23
32
  const active = new Set(humanObligations(obligations, member).map((o) => o.id));
33
+ const reminded = Object.fromEntries(Object.entries(state.reminded ?? {}).filter(([id]) => active.has(id)));
24
34
  return {
25
35
  notified_at: Object.fromEntries(Object.entries(state.notified_at).filter(([id, at]) => active.has(id) || (id.startsWith("act:") && now - at < 7 * 86_400_000))),
36
+ ...(Object.keys(reminded).length ? { reminded } : {}),
26
37
  };
27
38
  }
28
39
  function readState(file) {
29
40
  try {
30
41
  const parsed = JSON.parse(readFileSync(file, "utf8"));
31
- return { notified_at: parsed.notified_at ?? {} };
42
+ return { notified_at: parsed.notified_at ?? {}, ...(parsed.reminded ? { reminded: parsed.reminded } : {}) };
32
43
  }
33
44
  catch {
34
45
  return { notified_at: {} };
@@ -111,7 +122,7 @@ export function sendDesktopNotification(notice, home = CONFIG_DIR) {
111
122
  if (!existsSync(binary))
112
123
  return false;
113
124
  try {
114
- const child = spawn(binary, ["post", "--json", JSON.stringify({ ...notice, bin_dir: join(home, "bin") })], { detached: true, stdio: "ignore" });
125
+ const child = spawn(binary, ["post", "--json", JSON.stringify({ ...notice, bin_dir: join(home, "bin"), pantheon: whichPantheon() ?? "pantheon" })], { detached: true, stdio: "ignore" });
115
126
  child.unref();
116
127
  return true;
117
128
  }
@@ -139,7 +150,7 @@ export class HumanNotifier {
139
150
  const pollMs = this.opts.pollMs ?? 5_000;
140
151
  let state = readState(stateFile);
141
152
  this.opts.log(`notifier online as ${this.cfg.member}`);
142
- this.opts.log(` reminders every ${Math.round(this.opts.reminderMs / 60_000)} minutes while unresolved`);
153
+ this.opts.log(` reminders after ${Math.round(this.opts.reminderMs / 60_000)} minutes, then doubling to daily, ${MAX_REMINDERS} at most — after that a decision waits on pantheon brief and the menu bar`);
143
154
  while (!this.stopped) {
144
155
  const pruned = pruneNotificationState(state, client.obligations, this.cfg.member, now());
145
156
  let dirty = JSON.stringify(pruned) !== JSON.stringify(state);
@@ -153,13 +164,16 @@ export class HumanNotifier {
153
164
  const peerOf = (from) => from ? { member: from, agent: client.roster.find((m) => m.id === from)?.agent } : undefined;
154
165
  const withLabel = (n) => this.opts.label ? { ...n, context: n.context ? `${this.opts.label} · ${n.context}` : this.opts.label } : n;
155
166
  for (const obligation of due) {
167
+ const isReminder = state.notified_at[obligation.id] !== undefined;
156
168
  const act = client.seenAct(obligation.ref);
157
169
  const notice = withLabel(composeDecision(obligation, act, obligation.goal ? client.goals.get(obligation.goal) : undefined, peerOf(act?.from), { tools, preferred: preferredTool(profile, tools), now: now() }));
158
170
  const ok = send(notice);
159
171
  if (ok) {
160
172
  state.notified_at[obligation.id] = now();
173
+ if (isReminder)
174
+ state.reminded = { ...(state.reminded ?? {}), [obligation.id]: (state.reminded?.[obligation.id] ?? 0) + 1 };
161
175
  dirty = true;
162
- this.opts.log(`> notified for ${obligation.id} (${oneLine(obligation.body, 100)})`);
176
+ this.opts.log(`> ${isReminder ? `reminder ${state.reminded[obligation.id]}/${MAX_REMINDERS}` : "notified"} for ${obligation.id} (${oneLine(obligation.body, 100)})`);
163
177
  }
164
178
  else {
165
179
  this.opts.log(`! could not show a desktop notification for ${obligation.id}`);
package/dist/relay.js CHANGED
@@ -263,6 +263,17 @@ export class Relay {
263
263
  : {}),
264
264
  });
265
265
  }
266
+ if (url.pathname === "/act") {
267
+ const space = this.spaces.get(url.searchParams.get("space") ?? "");
268
+ const token = url.searchParams.get("token") ?? "";
269
+ if (!space || space.token !== token)
270
+ return this.json(res, 404, { error: "no such space" });
271
+ const act = space.ledger.find(url.searchParams.get("id") ?? "");
272
+ if (!act)
273
+ return this.json(res, 404, { error: "no such act" });
274
+ const goal = act.goal ? space.goals.get(act.goal) : undefined;
275
+ return this.json(res, 200, { act, ...(goal ? { goal: { id: goal.id, title: goal.title, state: goal.state } } : {}) });
276
+ }
266
277
  if (url.pathname === "/history") {
267
278
  const space = this.spaces.get(url.searchParams.get("space") ?? "");
268
279
  const token = url.searchParams.get("token") ?? "";
package/dist/tray.js CHANGED
@@ -11,7 +11,7 @@ import { requireDeveloperTools } from "./notifier.js";
11
11
  import { plistPath, serviceInstalled, whichPantheon } from "./service.js";
12
12
  import { VERSION } from "./version.js";
13
13
  import { LIMITS } from "./protocol.js";
14
- import { allDecisionsPrompt, decisionPrompt, decisionTitle, workerSummary } from "./decision-ui.js";
14
+ import { allDecisionsPrompt, decisionPrompt, decisionTitle, owedPrompt, waitingPrompt, workerSummary } from "./decision-ui.js";
15
15
  import { loadProfile, SURFACE_BUNDLE_IDS } from "./profile.js";
16
16
  const HERE = dirname(fileURLToPath(import.meta.url));
17
17
  export const trayBinaryPath = (home = CONFIG_DIR) => join(home, "bin", "pantheon-tray");
@@ -93,6 +93,9 @@ export async function trayStatus(cfg = load(), f = loadAll()) {
93
93
  members: ok.reduce((n, p) => n + (p.members ?? 0), 0),
94
94
  decisions: ok.flatMap((p) => (p.decisions ?? []).map((d) => ({ ...d, body: `[${p.space}] ${d.body}` }))),
95
95
  decisions_prompt: allDecisionsPrompt(ok.reduce((n, p) => n + (p.decisions?.length ?? 0), 0)),
96
+ items: ok.flatMap((p) => (p.items ?? []).map((i) => ({ ...i, space: p.space }))),
97
+ owed_count: ok.reduce((n, p) => n + (p.owed_count ?? 0), 0),
98
+ waiting_count: ok.reduce((n, p) => n + (p.waiting_count ?? 0), 0),
96
99
  };
97
100
  }
98
101
  return trayStatusOne(cfg);
@@ -104,14 +107,33 @@ async function trayStatusOne(cfg) {
104
107
  const q = `space=${encodeURIComponent(cfg.space)}&token=${encodeURIComponent(cfg.token)}`;
105
108
  const [health, obligations] = await Promise.all([
106
109
  getJson(`${base}/health?space=${encodeURIComponent(cfg.space)}`),
107
- getJson(`${base}/obligations?${q}&member=${encodeURIComponent(`human:${cfg.member}`)}`),
110
+ getJson(`${base}/obligations?${q}`),
108
111
  ]);
109
112
  const space = health
110
113
  ?.spaces?.find((s) => s.name === cfg.space);
111
114
  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 : [])
115
+ const ok = Array.isArray(pending) && pending.every((o) => o && typeof o.id === "string" && typeof o.body === "string" && typeof o.owner === "string");
116
+ const all = (ok ? pending : []);
117
+ const mineHuman = all.filter((o) => o.owner === `human:${cfg.member}`);
118
+ const decisionsOk = ok;
119
+ const decisions = mineHuman
114
120
  .map((o) => ({ id: o.id, body: decisionTitle(o.body), detail: o.body, prompt: decisionPrompt(o.id) }));
121
+ const row = (o, kind, who, prompt) => ({
122
+ id: o.id, kind, title: decisionTitle(o.body), headline: decisionTitle(o.body, 200), detail: o.body, prompt, who,
123
+ when: o.created_at ?? 0,
124
+ ...(o.goal ? { goal: o.goal } : {}),
125
+ ...(o.ref ? { ref: o.ref } : {}),
126
+ });
127
+ const owed = all.filter((o) => o.owner === cfg.member);
128
+ const waiting = all.filter((o) => o.owner !== cfg.member && o.owner !== `human:${cfg.member}`);
129
+ const items = [
130
+ ...mineHuman.map((o) => row(o, "decision", "you", decisionPrompt(o.id))),
131
+ ...owed.map((o) => row(o, "owed", "you", owedPrompt(decisionTitle(o.body), o.goal))),
132
+ ...waiting.map((o) => {
133
+ const who = o.owner.replace(/^human:/, "");
134
+ return row(o, "waiting", who, waitingPrompt(who, decisionTitle(o.body), o.goal));
135
+ }),
136
+ ];
115
137
  const profile = loadProfile(cfg);
116
138
  const surface = profile.agent.primary_surface;
117
139
  const surfaceName = surface === "claude-code" ? "Claude" : surface === "cursor" ? "Cursor" : "Codex";
@@ -137,6 +159,9 @@ async function trayStatusOne(cfg) {
137
159
  surface_bundle_id: SURFACE_BUNDLE_IDS[surface],
138
160
  decisions_prompt: allDecisionsPrompt(decisions.length),
139
161
  decisions,
162
+ items,
163
+ owed_count: owed.length,
164
+ waiting_count: waiting.length,
140
165
  };
141
166
  }
142
167
  //# sourceMappingURL=tray.js.map
package/dist/upgrade.js CHANGED
@@ -87,7 +87,11 @@ export function upgrade(deps = defaultDeps()) {
87
87
  catch (e) {
88
88
  steps.push({ name: "wire", ok: false, detail: `could not refresh agent config: ${e.message.split("\n")[0]} (run \`pantheon install --user\`)` });
89
89
  }
90
- const to = deps.npmLatest() ?? "latest";
90
+ const to = deps.version(root);
91
+ if (to === from) {
92
+ steps.push({ name: "check", ok: true, detail: `${from} is the newest the registry serves right now — a version published minutes ago can take a few minutes to appear` });
93
+ return { root, from, to, steps, ok: true, alreadyCurrent: true, restart: [] };
94
+ }
91
95
  const restart = [...deps.running(), "your agent session"];
92
96
  return { root, from, to, steps, ok: true, alreadyCurrent: false, restart };
93
97
  }