@join-pantheon/cli 0.33.5 → 0.33.6

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/notifier.js CHANGED
@@ -14,6 +14,7 @@ import { loadPolicy } from "./worker-policy.js";
14
14
  import { wakeAlerted } from "./continued.js";
15
15
  import { cliOnPath, detectAvailable } from "./agents.js";
16
16
  import { whichPantheon } from "./service.js";
17
+ import { removalFor, removalHolds, removalRefusal, removalWords, removedLine, removedNotice, takeTold } from "./removed.js";
17
18
  export const DEFAULT_REMINDER_MS = 4 * 60 * 60 * 1_000;
18
19
  export const PASSED_OVER_EVERY_MS = 3_600_000;
19
20
  export const MAX_REMINDERS = 4;
@@ -145,7 +146,37 @@ export class HumanNotifier {
145
146
  stop() {
146
147
  this.stopped = true;
147
148
  }
149
+ async waitWhileRemoved() {
150
+ const team = { space: this.cfg.space, spaceId: this.cfg.spaceId ?? null, relay: this.cfg.relay, member: this.cfg.member };
151
+ const now = this.opts.now ?? Date.now;
152
+ let said = false;
153
+ while (!this.stopped) {
154
+ let r = null;
155
+ try {
156
+ r = removalFor(team);
157
+ }
158
+ catch {
159
+ return;
160
+ }
161
+ if (!removalHolds(r, now()))
162
+ return;
163
+ if (!said) {
164
+ said = true;
165
+ const words = removalWords(team, r.by);
166
+ this.opts.log(`! ${removedLine(words)}`);
167
+ try {
168
+ if (takeTold(team))
169
+ await (this.opts.send ?? sendDesktopNotification)(removedNotice(words));
170
+ }
171
+ catch { }
172
+ }
173
+ await new Promise((res) => setTimeout(res, this.opts.removedPollMs ?? 60_000));
174
+ }
175
+ }
148
176
  async run() {
177
+ await this.waitWhileRemoved();
178
+ if (this.stopped)
179
+ return;
149
180
  const client = new PantheonClient(this.cfg, undefined, { role: "cli" });
150
181
  await client.connect();
151
182
  const stateFile = this.opts.stateFile ?? join(CONFIG_DIR, "notifier-state.json");
@@ -155,7 +186,21 @@ export class HumanNotifier {
155
186
  let state = readState(stateFile);
156
187
  this.opts.log(`notifier online as ${this.cfg.member}`);
157
188
  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`);
189
+ let removedSaid = false;
158
190
  while (!this.stopped) {
191
+ if (client.removedFrom && !removedSaid) {
192
+ removedSaid = true;
193
+ const team = { space: this.cfg.space, spaceId: this.cfg.spaceId ?? null, relay: this.cfg.relay, member: this.cfg.member };
194
+ const r = removalWords(team, client.removedFrom.by);
195
+ this.opts.log(`! ${removedLine(r)}`);
196
+ try {
197
+ if (takeTold(team))
198
+ await send(removedNotice(r));
199
+ }
200
+ catch { }
201
+ }
202
+ else if (!client.removedFrom)
203
+ removedSaid = false;
159
204
  const pruned = pruneNotificationState(state, client.obligations, this.cfg.member, now());
160
205
  let dirty = JSON.stringify(pruned) !== JSON.stringify(state);
161
206
  state = pruned;
@@ -268,22 +313,27 @@ export async function runNotifiers(teams, o) {
268
313
  let wait = o.retryMs ?? 30_000;
269
314
  for (;;) {
270
315
  failing.delete(i);
316
+ let removed = false;
271
317
  try {
272
318
  await t.run();
273
319
  return;
274
320
  }
275
321
  catch (e) {
276
- failing.add(i);
277
- o.log(`[${t.label}] ! not connecting: ${e.message.split("\n")[0]}`);
278
- if (failing.size === teams.length) {
279
- gaveUp = true;
280
- giveUp();
281
- return;
322
+ removed = !!removalRefusal(e.message);
323
+ if (!removed) {
324
+ failing.add(i);
325
+ o.log(`[${t.label}] ! not connecting: ${e.message.split("\n")[0]}`);
326
+ if (failing.size === teams.length) {
327
+ gaveUp = true;
328
+ giveUp();
329
+ return;
330
+ }
282
331
  }
283
332
  }
284
333
  if (gaveUp || t.stopped?.())
285
334
  return;
286
- o.log(`[${t.label}] the other teams' alerts keep running; trying this one again in ${Math.round(wait / 1000)}s`);
335
+ if (!removed)
336
+ o.log(`[${t.label}] the other teams' alerts keep running; trying this one again in ${Math.round(wait / 1000)}s`);
287
337
  await sleep(wait);
288
338
  if (gaveUp || t.stopped?.())
289
339
  return;
package/dist/persona.js CHANGED
@@ -2,9 +2,10 @@ import { lstatSync, readFileSync, readdirSync, realpathSync, writeFileSync, exis
2
2
  import { homedir } from "node:os";
3
3
  import { join, sep } from "node:path";
4
4
  import { CONFIG_DIR } from "./config.js";
5
+ import { parseRunMinutes, RUN_MINUTES } from "./worker-policy.js";
5
6
  export const PERSONA_INSTRUCTIONS_MAX = 8_000;
6
7
  const KNOWN_KEYS = new Set([
7
- "allow_bash", "max_runs_per_hour", "max_runs_per_day",
8
+ "allow_bash", "max_runs_per_hour", "max_runs_per_day", "max_run_minutes",
8
9
  "wake_by", "kinds", "goals", "agent", "name", "spaces",
9
10
  ]);
10
11
  const list = (v) => v.split(",").map((s) => s.trim()).filter(Boolean);
@@ -55,6 +56,14 @@ export function parsePersona(name, scope, file, raw) {
55
56
  p.warnings.push(`${key} must be a number, got "${value}"`);
56
57
  break;
57
58
  }
59
+ case "max_run_minutes": {
60
+ const n = parseRunMinutes(value);
61
+ if (n !== null)
62
+ p.maxRunMinutes = n;
63
+ else
64
+ p.warnings.push(`max_run_minutes must be a whole number of minutes from ${RUN_MINUTES.min} to ${RUN_MINUTES.max}, got "${value}"`);
65
+ break;
66
+ }
58
67
  case "wake_by":
59
68
  p.wakeBy = list(value);
60
69
  break;
@@ -212,6 +221,7 @@ export const PERSONA_TEMPLATE = `---
212
221
  # allow_bash: false # runs handled here never get shell, even with --allow-bash
213
222
  # max_runs_per_hour: 6
214
223
  # max_runs_per_day: 20
224
+ # max_run_minutes: 30 # a run it handles is stopped after this long (only lowers the machine's limit)
215
225
  # wake_by: alice, bob # only these peers' requests are handled by this persona
216
226
  # kinds: ANSWER, ACCEPT # ANSWER (asks), ACCEPT (verdicts), REVISE (objections), CONTINUE (replies/deliveries)
217
227
  # goals: launch-* # only goals whose id or title matches
@@ -239,24 +249,38 @@ export function initPersona(name, scope, projectDir = process.cwd(), home) {
239
249
  writeFileSync(file, PERSONA_TEMPLATE);
240
250
  return file;
241
251
  }
252
+ export function olderTeammatesNote(p) {
253
+ if (p.scope !== "project" || p.broken)
254
+ return null;
255
+ const newer = p.maxRunMinutes !== undefined ? ["max_run_minutes"] : [];
256
+ if (!newer.length)
257
+ return null;
258
+ return `uses ${newer.join(", ")}, which Pantheon before 0.33.6 does not know: a teammate's worker on an older version reads this committed persona as broken and skips it until they upgrade (pantheon upgrade). Keep it in your own ~/.pantheon/personas instead, or wait until the team is on 0.33.6`;
259
+ }
242
260
  export function describePersona(p) {
243
- const rules = [];
261
+ const filters = [];
262
+ if (p.spaces?.length)
263
+ filters.push(`spaces ${p.spaces.join(" / ")}`);
244
264
  if (p.wakeBy?.length)
245
- rules.push(`wake_by ${p.wakeBy.join(",")}`);
265
+ filters.push(`wake_by ${p.wakeBy.join(",")}`);
246
266
  if (p.kinds?.length)
247
- rules.push(`kinds ${p.kinds.join(",")}`);
267
+ filters.push(`kinds ${p.kinds.join(",")}`);
248
268
  if (p.goals?.length)
249
- rules.push(`goals ${p.goals.join(",")}`);
269
+ filters.push(`goals ${p.goals.join(",")}`);
270
+ const limits = [];
250
271
  if (p.allowBash === false)
251
- rules.push("bash off");
272
+ limits.push("bash off");
252
273
  if (p.maxRunsPerHour !== undefined)
253
- rules.push(`≤${p.maxRunsPerHour}/h`);
274
+ limits.push(`≤${p.maxRunsPerHour}/h`);
254
275
  if (p.maxRunsPerDay !== undefined)
255
- rules.push(`≤${p.maxRunsPerDay}/d`);
276
+ limits.push(`≤${p.maxRunsPerDay}/d`);
277
+ if (p.maxRunMinutes !== undefined)
278
+ limits.push(`≤${p.maxRunMinutes}min/run`);
256
279
  if (p.agent)
257
- rules.push(`agent ${p.agent}`);
280
+ limits.push(`agent ${p.agent}`);
258
281
  if (p.broken)
259
282
  return `${p.name} (${p.scope}) BROKEN — takes no work until fixed: ${p.broken}`;
260
- return `${p.name} (${p.scope})${rules.length ? ` ${rules.join(", ")}` : " matches everything"}`;
283
+ const rules = filters.length ? [...filters, ...limits].join(", ") : `matches everything${limits.length ? `; ${limits.join(", ")}` : ""}`;
284
+ return `${p.name} (${p.scope}) ${rules}`;
261
285
  }
262
286
  //# sourceMappingURL=persona.js.map
package/dist/pickup.js ADDED
@@ -0,0 +1,82 @@
1
+ import { LIMITS, WORKER_REASONS } from "./protocol.js";
2
+ export function inWords(ms) {
3
+ if (ms < 60_000)
4
+ return "under a minute";
5
+ const m = Math.round(ms / 60_000);
6
+ if (m < 60)
7
+ return `about ${m} minute${m === 1 ? "" : "s"}`;
8
+ const h = Math.round(m / 60);
9
+ return `about ${h} hour${h === 1 ? "" : "s"}`;
10
+ }
11
+ const UNAVAILABLE = {
12
+ no_agent: "Claude Code (or another agent CLI) isn't installed there",
13
+ kiro_only: "only Kiro is installed there, and Kiro answers in live sessions only",
14
+ not_signed_in: "its agent CLI isn't signed in",
15
+ unsandboxed_off: "its only agent has no sandbox, and it is set to leave requests for its person",
16
+ no_agent_choice: "no agent there can be chosen for a run",
17
+ sandbox_unavailable: "its Linux sandbox can't start",
18
+ };
19
+ function reasonOf(w) {
20
+ if (w.reason && WORKER_REASONS.includes(w.reason))
21
+ return w.reason;
22
+ const m = /\[([a-z_]{3,40})\]\s*$/.exec(w.detail ?? "");
23
+ return m && WORKER_REASONS.includes(m[1]) ? m[1] : undefined;
24
+ }
25
+ export function pickupEstimate(peer, o = {}) {
26
+ const now = o.now ?? Date.now();
27
+ const who = peer.id;
28
+ const live = (peer.sessions ?? []).filter((s) => s.role === "interactive").length > 0;
29
+ const workerOn = (peer.sessions ?? []).some((s) => s.role === "worker");
30
+ if (o.mayWake === false) {
31
+ return { kind: "no-wake", says: `${who}'s worker does not take your requests (the team does not let you wake it), so this waits for ${who}${live ? ", who has a live session open" : ""}` };
32
+ }
33
+ if (!workerOn) {
34
+ return { kind: "offline", says: live
35
+ ? `${who}'s worker is offline; ${who} has a live session open, which sees this when their agent next looks`
36
+ : `${who}'s worker is offline, so this waits until ${who} is back` };
37
+ }
38
+ const w = peer.worker && now - peer.worker.at < 15 * 60_000 ? peer.worker : undefined;
39
+ const reason = w ? reasonOf(w) : undefined;
40
+ if (w && (w.state === "unavailable" || (reason && UNAVAILABLE[reason]))) {
41
+ return { kind: "unavailable", says: `${who}'s machine can't take requests yet${reason && UNAVAILABLE[reason] ? `: ${UNAVAILABLE[reason]}` : ""}` };
42
+ }
43
+ if (w?.state === "running") {
44
+ const mine = !!o.about && ((!!o.about.ref && w.ref === o.about.ref) || (!!o.about.goal && !w.ref && w.goal === o.about.goal));
45
+ return mine
46
+ ? { kind: "working", says: `${who}'s agent is working on this now` }
47
+ : { kind: "queued", says: `${who}'s worker is busy with another request; this one is next` };
48
+ }
49
+ const idleMs = typeof w?.idle_ms === "number" ? w.idle_ms : undefined;
50
+ const wait = (ms) => (idleMs !== undefined ? inWords(ms) : `${inWords(ms)} (if they kept the default wait)`);
51
+ const lifts = w?.state === "holding" && w.until && w.until > now ? w.until - now : null;
52
+ if (w?.state === "holding" && reason === "capped") {
53
+ return { kind: "holding", says: `${who}'s worker has used its runs for now (its hourly or daily cap)${lifts ? `; it can take this in ${inWords(lifts)}` : ", so this waits"}` };
54
+ }
55
+ if (w?.state === "holding" && reason === "outage") {
56
+ return { kind: "holding", says: `${who}'s agent is out for now (a usage limit or an outage)${lifts ? `; it should be back in ${inWords(lifts)}` : ""}` };
57
+ }
58
+ if (w?.state === "holding" && reason === "awaiting_wake_approval") {
59
+ return { kind: "holding", says: `${who}'s machine may be waiting for ${who} to approve a teammate before it starts their work` };
60
+ }
61
+ if (w?.state === "holding" && reason === "busy_elsewhere") {
62
+ return { kind: "queued", says: `${who}'s machine is busy with another request (possibly for another team); this one waits until it finishes` };
63
+ }
64
+ if (w?.state === "holding" && reason === "unknown") {
65
+ return { kind: "holding", says: `${who}'s worker is holding requests for now` };
66
+ }
67
+ if (lifts)
68
+ return { kind: "holding", says: `${who}'s agent will pick this up in ${inWords(lifts)}` };
69
+ if (live && (peer.work?.busy || reason === "busy_turn")) {
70
+ return { kind: "keyboard", says: `${who} is at their keyboard; their agent picks this up ${wait(idleMs ?? LIMITS.workerIdleMs)} after they go quiet` };
71
+ }
72
+ if (live && peer.work?.idle_since) {
73
+ const left = peer.work.idle_since + (idleMs ?? LIMITS.workerIdleMs) - now;
74
+ if (left > 0)
75
+ return { kind: "keyboard", says: `${who} was at their keyboard moments ago; their agent picks this up in ${wait(left)} if they stay quiet` };
76
+ }
77
+ if (live && (!peer.work || reason === "no_signal")) {
78
+ return { kind: "keyboard", says: `${who} has a live session open (with no activity signal), so their agent sees this there; their worker takes it once that session closes` };
79
+ }
80
+ return { kind: "soon", says: `${who}'s agent should pick this up within a minute, if nothing on their machine holds it` };
81
+ }
82
+ //# sourceMappingURL=pickup.js.map
package/dist/profile.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { CONFIG_DIR } from "./config.js";
4
+ import { isCatchAllDomain } from "./protocol.js";
4
5
  export const AGENT_SURFACES = ["codex", "claude-code", "cursor", "copilot", "kiro"];
5
6
  export const SURFACE_BUNDLE_IDS = {
6
7
  codex: "com.openai.codex",
@@ -176,6 +177,28 @@ export function teamProfile(profile, ...forSpace) {
176
177
  export function profileForTeam(cfg, file = profilePath()) {
177
178
  return teamProfile(loadProfile(cfg, file), cfg.spaceId, cfg.space);
178
179
  }
180
+ export function dropCatchAllClaims(profile) {
181
+ const dropped = new Set();
182
+ const clean = (a) => {
183
+ if (!a)
184
+ return a;
185
+ const out = {};
186
+ for (const [d, m] of Object.entries(a)) {
187
+ if (isCatchAllDomain(d))
188
+ dropped.add(d);
189
+ else
190
+ out[d] = m;
191
+ }
192
+ return out;
193
+ };
194
+ const authority = clean(profile.authority);
195
+ const spaces = profile.spaces
196
+ ? Object.fromEntries(Object.entries(profile.spaces).map(([k, v]) => [k, v.authority ? { ...v, authority: clean(v.authority) } : v]))
197
+ : undefined;
198
+ if (!dropped.size)
199
+ return { profile, dropped: [] };
200
+ return { profile: { ...profile, authority, ...(spaces ? { spaces } : {}) }, dropped: [...dropped].sort() };
201
+ }
179
202
  export function organizationProfile(profile, ...forSpace) {
180
203
  const team = teamProfile(profile, ...forSpace);
181
204
  return {
@@ -185,7 +208,7 @@ export function organizationProfile(profile, ...forSpace) {
185
208
  title: profile.human.title,
186
209
  responsibilities: cleanList(team.human.responsibilities),
187
210
  authority: Object.fromEntries(Object.entries(team.authority)
188
- .map(([domain, mode]) => [normalize(domain), mode]).filter(([domain]) => !!domain)),
211
+ .map(([domain, mode]) => [normalize(domain), mode]).filter(([domain]) => !!domain && !isCatchAllDomain(domain))),
189
212
  decision_keywords: cleanKeywords(team.decision_keywords),
190
213
  capabilities: cleanList(profile.capabilities),
191
214
  delegations: cleanDelegations(profile.delegations),
package/dist/protocol.js CHANGED
@@ -24,6 +24,15 @@ export const UPKEEP_AGENT = "pantheon-upkeep";
24
24
  export function siblingSessions(roster, member, session) {
25
25
  return (roster.find((m) => m.id === member)?.sessions ?? []).filter((s) => s.id !== session && s.agent !== UPKEEP_AGENT);
26
26
  }
27
+ export const CATCH_ALL_DOMAINS = [
28
+ "all", "*", "everything", "every", "any", "anything", "everyone", "anyone", "whatever",
29
+ "none", "nothing", "n/a", "all-domains", "all_domains",
30
+ ];
31
+ export const normalizeDomainName = (d) => d.normalize("NFKC").trim().toLowerCase();
32
+ export function isCatchAllDomain(domain) {
33
+ const d = normalizeDomainName(domain);
34
+ return !d || CATCH_ALL_DOMAINS.includes(d) || /[*?]/.test(d);
35
+ }
27
36
  export const LIMITS = {
28
37
  bodyChars: 32000,
29
38
  summaryRequiredOver: 2000,
@@ -116,7 +125,7 @@ export const GoalSigSchema = z.object({
116
125
  kind: z.string().max(32).nullable().optional(),
117
126
  cert: DelegateCertSchema.optional(),
118
127
  });
119
- export const RELAY_FEATURES = ["act-sig", "delegate", "named-decider"];
128
+ export const RELAY_FEATURES = ["act-sig", "delegate", "named-decider", "worker-state-2"];
120
129
  export const Hello = z.object({
121
130
  t: z.literal("hello"),
122
131
  v: z.number().int().optional(),
@@ -236,11 +245,19 @@ export const DeleteSpace = z.object({
236
245
  cid: z.string().max(64).optional(),
237
246
  confirm: z.string().min(1).max(64),
238
247
  });
248
+ export const WORKER_REASONS = ["no_agent", "kiro_only", "not_signed_in", "unsandboxed_off", "no_agent_choice",
249
+ "busy_turn", "recently_active", "no_signal", "capped", "outage", "awaiting_wake_approval", "busy_elsewhere", "unknown", "sandbox_unavailable"];
250
+ const WORK_ID = /^(cont:)?[a-z]{1,3}_[A-Za-z0-9_-]{4,64}$/;
239
251
  export const WorkerState = z.object({
240
252
  t: z.literal("worker_state"),
241
- state: z.enum(["idle", "holding", "running"]),
253
+ state: z.enum(["idle", "holding", "running", "unavailable"]),
242
254
  detail: z.string().max(200).optional(),
243
255
  until: z.number().int().positive().optional(),
256
+ idle_ms: z.number().int().min(0).max(3_600_000).optional(),
257
+ reason: z.string().regex(/^[a-z_]{1,40}$/).optional(),
258
+ ref: z.string().regex(WORK_ID).optional(),
259
+ goal: z.string().regex(WORK_ID).optional(),
260
+ until_in: z.number().int().min(0).max(7 * 24 * 3600_000).optional(),
244
261
  });
245
262
  export const Inbound = z.discriminatedUnion("t", [Hello, SendAct, OpenGoal, Claim, Admit, MakeInvite, ListInvites, RevokeInvite, PublishProfile, ClearAuthority, BindRepo, DeleteSpace, WorkerState, Ping]);
246
263
  export function shouldDeferToHuman(sessions, work, opts = {}) {