@join-pantheon/cli 0.33.4 → 0.33.5

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
@@ -9,12 +9,13 @@ import { fileURLToPath } from "node:url";
9
9
  import { PantheonClient } from "./client.js";
10
10
  import { CONFIG_DIR } from "./config.js";
11
11
  import { loadProfile } from "./profile.js";
12
- import { APP_TOOLS, composeDecision, composeDelivery, composeWakeAsk, TOOL_LABELS } from "./notice.js";
12
+ import { APP_TOOLS, composeDecision, composeDelivery, composePassedOver, composeWakeAsk, neverAlerts, passedOverNotice, TOOL_LABELS } from "./notice.js";
13
13
  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
17
  export const DEFAULT_REMINDER_MS = 4 * 60 * 60 * 1_000;
18
+ export const PASSED_OVER_EVERY_MS = 3_600_000;
18
19
  export const MAX_REMINDERS = 4;
19
20
  const DAY_MS = 24 * 60 * 60 * 1_000;
20
21
  export function wakeAskWhat(body) {
@@ -179,6 +180,11 @@ export class HumanNotifier {
179
180
  for (const obligation of due) {
180
181
  const isReminder = state.notified_at[obligation.id] !== undefined;
181
182
  const act = client.seenAct(obligation.ref);
183
+ if (neverAlerts(act)) {
184
+ state.notified_at[obligation.id] = now();
185
+ dirty = true;
186
+ continue;
187
+ }
182
188
  const wake = asks[obligation.ref];
183
189
  const workerSaid = (w) => { try {
184
190
  return this.opts.wakeAlerted ? this.opts.wakeAlerted(w, obligation.ref) : wakeAlerted(w.team, w.member, obligation.ref);
@@ -210,12 +216,34 @@ export class HumanNotifier {
210
216
  if (dirty || !existsSync(stateFile))
211
217
  writeState(stateFile, state);
212
218
  const arrived = await client.next(pollMs);
219
+ if (prefs.decisions) {
220
+ for (const r of arrived) {
221
+ if (!passedOverNotice(r, this.cfg.member) || state.notified_at[`act:${r.id}`])
222
+ continue;
223
+ state.notified_at[`act:${r.id}`] = now();
224
+ const g = r.meta?.goal;
225
+ const key = `act:passed:${typeof g === "string" ? g : r.ref ?? r.id}`;
226
+ const last = state.notified_at[key];
227
+ if (last !== undefined && now() - last < PASSED_OVER_EVERY_MS) {
228
+ this.opts.log(`> passed over again on ${key.slice("act:passed:".length)} — said within the hour, not alerted`);
229
+ writeState(stateFile, state);
230
+ continue;
231
+ }
232
+ const focus = client.obligations.find((o) => o.ref === r.ref)?.id;
233
+ const ok = await send(withLabel(composePassedOver(r, focus, now())));
234
+ if (ok) {
235
+ state.notified_at[key] = now();
236
+ this.opts.log(`> passed-over alert for ${r.ref ?? r.id}`);
237
+ }
238
+ writeState(stateFile, state);
239
+ }
240
+ }
213
241
  if (prefs.deliveries) {
214
242
  for (const r of arrived) {
215
- if (r.act !== "DELIVERED" || r.from === this.cfg.member || state.notified_at[`act:${r.id}`])
243
+ if (r.act !== "DELIVERED" || neverAlerts(r) || r.from === this.cfg.member || state.notified_at[`act:${r.id}`])
216
244
  continue;
217
245
  const goal = r.goal ? client.goals.get(r.goal) : undefined;
218
- const forMe = (r.to?.includes(this.cfg.member) ?? false) || (goal?.members.includes(this.cfg.member) ?? false);
246
+ const forMe = r.to?.length ? r.to.includes(this.cfg.member) : (goal?.members.includes(this.cfg.member) ?? false);
219
247
  if (!forMe)
220
248
  continue;
221
249
  const ok = await send(withLabel(composeDelivery(r, goal, peerOf(r.from), { tools, preferred: preferredTool(profile, tools), now: now() })));
package/dist/notify.js CHANGED
@@ -391,6 +391,8 @@ export async function decideStop(raw, deps) {
391
391
  const work = acts.filter((a) => {
392
392
  if (a.goal && closed.has(a.goal))
393
393
  return false;
394
+ if (a.act === "NOTE" && a.meta && typeof a.meta === "object" && "notice" in a.meta)
395
+ return false;
394
396
  const to = Array.isArray(a.to) ? a.to : [];
395
397
  if (to.length && deps.me && !to.includes(deps.me))
396
398
  return false;
package/dist/profile.js CHANGED
@@ -192,6 +192,34 @@ export function organizationProfile(profile, ...forSpace) {
192
192
  updated_at: profile.updated_at,
193
193
  };
194
194
  }
195
+ export function honourClearedAuthority(cfg, relayCopy, opts = {}) {
196
+ const marker = relayCopy?.authority_cleared;
197
+ if (!marker || !relayCopy)
198
+ return [];
199
+ if (!opts.founder || marker.by !== opts.founder)
200
+ return [];
201
+ const file = opts.file ?? profilePath();
202
+ if (!existsSync(file))
203
+ return [];
204
+ const local = loadProfile(cfg, file);
205
+ const relayAt = Math.min(relayCopy.updated_at, Date.now() + 86_400_000);
206
+ if (!opts.force && local.updated_at > relayAt)
207
+ return [];
208
+ const keep = new Set((opts.keep ?? []).map(normalize));
209
+ const cleared = new Set(marker.domains.map(normalize));
210
+ const team = teamProfile(local, cfg.spaceId, cfg.space).authority;
211
+ const dropped = Object.keys(team).filter((d) => cleared.has(normalize(d)) && !keep.has(normalize(d)));
212
+ if (!dropped.length)
213
+ return [];
214
+ const left = Object.fromEntries(Object.entries(team).filter(([d]) => !dropped.includes(d)));
215
+ const key = [cfg.spaceId, cfg.space].find((k) => k && local.spaces?.[k]) ?? (cfg.spaceId || cfg.space);
216
+ const own = local.spaces?.[key];
217
+ const next = own?.authority || (opts.teams ?? 1) > 1
218
+ ? { ...local, spaces: { ...(local.spaces ?? {}), [key]: { ...(own ?? {}), authority: left } } }
219
+ : { ...local, authority: left };
220
+ saveProfile({ ...next, updated_at: Math.max(local.updated_at + 1, Date.now()) }, file);
221
+ return dropped;
222
+ }
195
223
  function domainMatches(rule, domain) {
196
224
  if (rule === "*")
197
225
  return true;
@@ -199,6 +227,15 @@ function domainMatches(rule, domain) {
199
227
  return domain === rule.slice(0, -2) || domain.startsWith(rule.slice(0, -1));
200
228
  return rule === domain;
201
229
  }
230
+ export function reservedBy(profiles, domains) {
231
+ const out = [];
232
+ for (const p of profiles) {
233
+ const ds = domains.filter((d) => Object.entries(p.authority).some(([rule, mode]) => mode === "human_required" && domainMatches(normalize(rule), normalize(d))));
234
+ if (ds.length)
235
+ out.push({ member: p.member, domains: ds });
236
+ }
237
+ return out;
238
+ }
202
239
  export function routeDecision(profiles, input) {
203
240
  const all = [...profiles];
204
241
  const byMember = new Map(all.map((p) => [p.member, p]));
@@ -227,7 +264,24 @@ export function routeDecision(profiles, input) {
227
264
  current.domains.push(domain);
228
265
  bucket.set(p.member, current);
229
266
  };
230
- for (const domain of domains) {
267
+ const named = [...new Set((input.named ?? []).map((m) => m.trim()).filter(Boolean))];
268
+ const passed = new Map();
269
+ const reservedByOthers = (domain) => all.filter((p) => !named.includes(p.member)
270
+ && Object.entries(p.authority).some(([rule, mode]) => mode === "human_required" && domainMatches(normalize(rule), domain)));
271
+ const toNamed = (domain) => {
272
+ for (const member of named) {
273
+ const p = byMember.get(member);
274
+ const own = p ? Object.entries(p.authority).filter(([rule]) => domainMatches(normalize(rule), domain)).map(([, mode]) => mode) : [];
275
+ add(own.includes("human_required") ? "human_required" : "agent_can_decide", p ?? { member, display_name: member, title: "" }, domain);
276
+ }
277
+ for (const p of all) {
278
+ if (named.includes(p.member))
279
+ continue;
280
+ if (Object.keys(p.authority).some((rule) => domainMatches(normalize(rule), domain)))
281
+ passed.set(p.member, [...(passed.get(p.member) ?? []), domain]);
282
+ }
283
+ };
284
+ const byDirectory = (domain) => {
231
285
  const claims = all.flatMap((p) => {
232
286
  const modes = [...new Set(Object.entries(p.authority)
233
287
  .filter(([rule]) => domainMatches(normalize(rule), domain)).map(([, mode]) => mode))];
@@ -235,7 +289,7 @@ export function routeDecision(profiles, input) {
235
289
  });
236
290
  if (!claims.length) {
237
291
  unmatched.push(domain);
238
- continue;
292
+ return;
239
293
  }
240
294
  if (claims.length > 1) {
241
295
  conflicts.push({
@@ -243,25 +297,36 @@ export function routeDecision(profiles, input) {
243
297
  reason: new Set(claims.map((x) => x.mode)).size > 1 ? "mixed_authority" : "multiple_owners",
244
298
  members: claims.map((x) => x.profile.member),
245
299
  });
246
- continue;
300
+ return;
247
301
  }
248
302
  const claim = claims[0];
249
303
  const active = (claim.profile.delegations ?? []).filter((d) => d.starts_at <= now && now < d.expires_at && d.domains.some((rule) => domainMatches(normalize(rule), domain)));
250
304
  if (active.length > 1) {
251
305
  conflicts.push({ domain, reason: "multiple_delegates", members: active.map((d) => d.to) });
252
- continue;
306
+ return;
253
307
  }
254
308
  if (active.length === 1) {
255
309
  const delegated = byMember.get(active[0].to);
256
310
  if (!delegated) {
257
311
  conflicts.push({ domain, reason: "missing_delegate", members: [active[0].to] });
258
- continue;
312
+ return;
259
313
  }
260
314
  add(claim.mode, delegated, domain);
261
315
  }
262
316
  else
263
317
  add(claim.mode, claim.profile, domain);
318
+ };
319
+ for (const domain of domains) {
320
+ if (named.length && (input.explicit || !reservedByOthers(domain).length))
321
+ toNamed(domain);
322
+ else
323
+ byDirectory(domain);
264
324
  }
325
+ if (named.length && !domains.length)
326
+ for (const member of named) {
327
+ const p = byMember.get(member);
328
+ agent.set(member, { member, display_name: p?.display_name ?? member, title: p?.title ?? "", domains: [] });
329
+ }
265
330
  return {
266
331
  domains,
267
332
  inferred,
@@ -269,6 +334,7 @@ export function routeDecision(profiles, input) {
269
334
  delegated_agents: [...agent.values()],
270
335
  unmatched_domains: unmatched,
271
336
  conflicts,
337
+ ...(named.length ? { named, passed_over: [...passed].map(([member, ds]) => ({ member, domains: ds })) } : {}),
272
338
  };
273
339
  }
274
340
  export function profileInitialized(profile) {
package/dist/protocol.js CHANGED
@@ -116,7 +116,7 @@ export const GoalSigSchema = z.object({
116
116
  kind: z.string().max(32).nullable().optional(),
117
117
  cert: DelegateCertSchema.optional(),
118
118
  });
119
- export const RELAY_FEATURES = ["act-sig", "delegate"];
119
+ export const RELAY_FEATURES = ["act-sig", "delegate", "named-decider"];
120
120
  export const Hello = z.object({
121
121
  t: z.literal("hello"),
122
122
  v: z.number().int().optional(),
@@ -160,6 +160,11 @@ export const PublishProfile = z.object({
160
160
  cid: z.string().max(64).optional(),
161
161
  profile: OrganizationProfileSchema,
162
162
  });
163
+ export const ClearAuthority = z.object({
164
+ t: z.literal("clear_authority"),
165
+ cid: z.string().max(64).optional(),
166
+ member: z.string().min(1).max(48),
167
+ });
163
168
  export const Claim = z.object({
164
169
  t: z.literal("claim"),
165
170
  cid: z.string().max(64).optional(),
@@ -237,7 +242,7 @@ export const WorkerState = z.object({
237
242
  detail: z.string().max(200).optional(),
238
243
  until: z.number().int().positive().optional(),
239
244
  });
240
- export const Inbound = z.discriminatedUnion("t", [Hello, SendAct, OpenGoal, Claim, Admit, MakeInvite, ListInvites, RevokeInvite, PublishProfile, BindRepo, DeleteSpace, WorkerState, Ping]);
245
+ export const Inbound = z.discriminatedUnion("t", [Hello, SendAct, OpenGoal, Claim, Admit, MakeInvite, ListInvites, RevokeInvite, PublishProfile, ClearAuthority, BindRepo, DeleteSpace, WorkerState, Ping]);
241
246
  export function shouldDeferToHuman(sessions, work, opts = {}) {
242
247
  const now = opts.now ?? Date.now();
243
248
  const idleMs = opts.idleMs ?? LIMITS.workerIdleMs;
@@ -14,7 +14,7 @@ import { commandsChosen, loadProfile, wakeChosen, wakeDefault } from "./profile.
14
14
  import { newKeypair } from "./identity.js";
15
15
  import { installForUser } from "./install.js";
16
16
  import { writeIntent, installHooks } from "./notify.js";
17
- import { detectAvailable, agentById, agentsNeedingUnsandboxed, folderReaderLine } from "./agents.js";
17
+ import { detectAvailable, agentById, agentsNeedingUnsandboxed, folderReaderLine, recordedAgent } from "./agents.js";
18
18
  import { installService, servicesUnavailable } from "./service.js";
19
19
  import { clearTrayQuit } from "./win-host.js";
20
20
  import { ensureNativeNotifier } from "./notifier.js";
@@ -466,13 +466,13 @@ export async function start(opts) {
466
466
  const existing = priorCfg;
467
467
  const keepIdentity = !!existing && !opts.reset && !!existing.publicKey && !!existing.privateKey;
468
468
  let cfg = reuse
469
- ? { ...existing, relay: reach.url, agent: detectAgent() }
469
+ ? { ...existing, relay: reach.url, agent: detectAgent(existing.agent) }
470
470
  : {
471
471
  relay: reach.url,
472
472
  space: opts.space ?? teamNameFor(process.cwd(), repo),
473
473
  token: randomBytes(16).toString("hex"),
474
474
  member: opts.me ?? (keepIdentity ? existing.member : defaultMemberName()),
475
- agent: detectAgent(),
475
+ agent: detectAgent(existing?.agent),
476
476
  ...(keepIdentity ? { publicKey: existing.publicKey, privateKey: existing.privateKey } : newKeypair()),
477
477
  ...(keepIdentity && existing.account ? { account: existing.account } : {}),
478
478
  ...(repo ? { repo: repo.fingerprint, ...(repo.origin ? { repoOrigin: repo.origin } : {}) } : {}),
@@ -590,7 +590,7 @@ export async function join(opts) {
590
590
  space: opts.invite.space,
591
591
  token: "",
592
592
  member: opts.me ?? existing?.member ?? defaultMemberName(),
593
- agent: detectAgent(),
593
+ agent: detectAgent(existing?.agent),
594
594
  ...keys,
595
595
  ...(existing?.account ? { account: existing.account } : {}),
596
596
  ...(repo ? { repo: repo.fingerprint, ...(repo.origin ? { repoOrigin: repo.origin } : {}) } : {}),
@@ -660,8 +660,7 @@ function temporaryFolderNote(dir, log, team) {
660
660
  if (isTemporaryRefusal(r))
661
661
  log(` folder not bound — ${r.why.replace("<team>", team)}`);
662
662
  }
663
- function detectAgent() {
664
- const found = detectAvailable();
665
- return found[0]?.backend.id ?? "unknown";
663
+ function detectAgent(prior) {
664
+ return recordedAgent(prior, { fresh: true });
666
665
  }
667
666
  //# sourceMappingURL=quickstart.js.map
package/dist/relay.js CHANGED
@@ -12,7 +12,7 @@ import { installerLine } from "./managed.js";
12
12
  import { Ledger, SpaceFull } from "./ledger.js";
13
13
  import { fingerprint, newNonce, verifyChallenge, verifyActivity, verifyAccount, verifyRead, actPayload, goalPayload, verifyBytes, verifyDelegate, canonicalJson } from "./identity.js";
14
14
  import { Accounts, normalizeUsername, validEmail, validUsername } from "./accounts.js";
15
- import { emailSenderFromEnv } from "./email.js";
15
+ import { emailSenderFromEnv, maskEmail } from "./email.js";
16
16
  import { Allowlist, RosterUnreadable } from "./roster-file.js";
17
17
  import { StateStore, ensurePrivateDir, setWaitForDisk, takeSlowestDiskWait, waitsForDisk } from "./store.js";
18
18
  import { busyWriter, DiskBusy, flushWrites, onWriteError, pendingView, readLatest, recordWrites, whenLanded, writerFor } from "./disk-writer.js";
@@ -20,7 +20,8 @@ import { activity, mergeActivity, feed, noteRefusal, refusalsSince, countActs, s
20
20
  import { fmtLimit, inviteLimit, parseLimits, planLimits, PLAN_NAME } from "./plans.js";
21
21
  import { FEEDBACK_ADDRESS } from "./feedback.js";
22
22
  import { relayShellArg, teamNameProblem } from "./team-name.js";
23
- import { routeDecision } from "./profile.js";
23
+ import { reservedBy, routeDecision } from "./profile.js";
24
+ import { namedMarker } from "./decision-ui.js";
24
25
  const presentSessions = (m) => [...m.sessions.values()].filter((s) => !s.quiet);
25
26
  function workState(m, now = Date.now(), busyTtlMs = LIMITS.activityBusyTtlMs, pruneMs = LIMITS.activityPruneMs) {
26
27
  let known = false;
@@ -1031,7 +1032,10 @@ export class Relay {
1031
1032
  : `that code is not right — ${r.tries_left ?? "a few"} ${r.tries_left === 1 ? "try" : "tries"} left on it`;
1032
1033
  return this.json(res, 400, { error: msg, reason: r.reason, ...(r.tries_left !== undefined ? { tries_left: r.tries_left } : {}) });
1033
1034
  }
1034
- return this.json(res, 200, { ok: true, account: { id: r.account.id, username: r.account.username, email: r.account.email }, session: r.session.id, device: r.device.id });
1035
+ this.json(res, 200, { ok: true, account: { id: r.account.id, username: r.account.username, email: r.account.email }, session: r.session.id, device: r.device.id });
1036
+ if (r.welcome)
1037
+ this.sendWelcome(r.account.email, ip);
1038
+ return;
1035
1039
  });
1036
1040
  }
1037
1041
  if (url.pathname === "/account/sessions" && req.method === "GET") {
@@ -1180,11 +1184,11 @@ export class Relay {
1180
1184
  return own ? "this device has asked for too many codes in the last hour — use the latest one, or try again in an hour"
1181
1185
  : "too many codes were sent to this address in the last hour — use the latest one, or try again in an hour";
1182
1186
  }
1183
- if (!own && !this.localTrusted(ip) && live(this.emailSendsFrom.get(netOf(ip)) ?? []).length >= perNet)
1187
+ if (kind !== "welcome" && !own && !this.localTrusted(ip) && live(this.emailSendsFrom.get(netOf(ip)) ?? []).length >= perNet)
1184
1188
  return "too many codes were requested from your network in the last hour — try again later";
1185
1189
  if (this.emailSends.length >= cap)
1186
1190
  return "the relay is sending too many codes right now — try again shortly";
1187
- if (kind === "signup" && this.emailSends.filter((s) => s.signup).length >= cap - reserved)
1191
+ if (kind !== "login" && this.emailSends.filter((s) => s.signup).length >= cap - reserved)
1188
1192
  return "the relay is taking too many new sign-ups right now — try again shortly";
1189
1193
  return null;
1190
1194
  }
@@ -1194,7 +1198,7 @@ export class Relay {
1194
1198
  }
1195
1199
  noteEmailSent(kind, email, ip, pubkey = "", own = false) {
1196
1200
  const now = Date.now(), hour = 3_600_000;
1197
- this.emailSends.push({ at: now, signup: kind === "signup" });
1201
+ this.emailSends.push({ at: now, signup: kind !== "login" });
1198
1202
  const bump = (m, k) => {
1199
1203
  m.set(k, [...(m.get(k) ?? []).filter((t) => now - t < hour), now]);
1200
1204
  if (m.size > 10_000)
@@ -1207,9 +1211,26 @@ export class Relay {
1207
1211
  }
1208
1212
  };
1209
1213
  bump(this.emailSendsTo, this.emailBucket(email, pubkey, own));
1210
- if (!own && !this.localTrusted(ip))
1214
+ if (kind !== "welcome" && !own && !this.localTrusted(ip))
1211
1215
  bump(this.emailSendsFrom, netOf(ip));
1212
1216
  }
1217
+ sendWelcome(to, ip) {
1218
+ if (!this.email.sendWelcome)
1219
+ return;
1220
+ if (this.emailRefusal("welcome", to, ip)) {
1221
+ this.log(`welcome email to ${maskEmail(to)} skipped: the email budget is spent`);
1222
+ return;
1223
+ }
1224
+ let sending;
1225
+ try {
1226
+ sending = Promise.resolve(this.email.sendWelcome(to));
1227
+ }
1228
+ catch {
1229
+ return;
1230
+ }
1231
+ sending.then((sent) => { if (sent)
1232
+ this.noteEmailSent("welcome", to, ip); }, () => { });
1233
+ }
1213
1234
  authRateOk(ip) {
1214
1235
  if (this.localTrusted(ip))
1215
1236
  return true;
@@ -3223,6 +3244,29 @@ pantheon start</code></pre>
3223
3244
  this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: me.id, seq: space.ledger.head });
3224
3245
  return;
3225
3246
  }
3247
+ if (m.t === "clear_authority") {
3248
+ const founder = space.allow.founder();
3249
+ if (!founder || me.id !== founder)
3250
+ return fail("FOUNDER_ONLY", `only ${founder ?? "the team's founder"}, who founded "${space.name}", can clear someone's decision claims`, m.cid);
3251
+ if (!this.writeOk(me))
3252
+ return fail("RATE_LIMIT", "too many changes too fast — wait a few seconds", m.cid);
3253
+ const current = space.profiles.get(m.member);
3254
+ const domains = Object.keys(current?.authority ?? {});
3255
+ if (!current || !domains.length)
3256
+ return fail("NO_CLAIMS", `${m.member} claims no decisions in "${space.name}" — nothing to clear`, m.cid);
3257
+ const cleared = { ...current, authority: {}, updated_at: current.updated_at + 1, authority_cleared: { by: me.id, at: Date.now(), domains } };
3258
+ space.profiles.set(m.member, cleared);
3259
+ space.store.flush(space.goals, space.obligations, space.profiles);
3260
+ this.broadcast(space, { t: "profiles", profiles: [...space.profiles.values()] });
3261
+ const listed = domains.length > 4 ? `${domains.slice(0, 4).join(", ")} …` : domains.join(", ");
3262
+ const note = this.commit(space, {
3263
+ from: me.id, session: sessionId, role, act: "NOTE", to: null, goal: null, ref: null,
3264
+ body: `${me.id} cleared ${m.member}'s decision claims (${listed}); ${m.member} can set them again with pantheon setup --decides. An older Pantheon on ${m.member}'s machine may restore them on its next profile change`,
3265
+ meta: { organization_profile: cleared },
3266
+ });
3267
+ this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: note.id, seq: space.ledger.head });
3268
+ return;
3269
+ }
3226
3270
  if (m.t === "admit") {
3227
3271
  const self = space.allow.get(me.id);
3228
3272
  if (!self?.admitted)
@@ -3621,6 +3665,17 @@ pantheon start</code></pre>
3621
3665
  space.recent.delete(k);
3622
3666
  }
3623
3667
  this.send(ws, { t: "ack", ...(m.cid ? { cid: m.cid } : {}), id: rec.id, seq: rec.seq });
3668
+ const passedOver = rec.act === "HUMAN_REQUIRED" && rec.meta?.["authority"] === "named" ? passedOverBy(space, rec.to[0], decisionDomains(rec)) : [];
3669
+ const goalTitle = rec.goal ? space.goals.get(rec.goal)?.title : undefined;
3670
+ for (const p of passedOver) {
3671
+ if (!space.members.has(p.member))
3672
+ continue;
3673
+ this.commit(space, {
3674
+ from: me.id, session: sessionId, role, act: "NOTE", to: [p.member], goal: null, ref: rec.id,
3675
+ body: `${me.id} named ${rec.to[0]} to decide ${p.domains.join(", ")} on '${goalTitle ?? first(rec.body, 80)}' — you were passed over`,
3676
+ meta: { notice: "passed_over", decider: rec.to[0], domains: p.domains, ...(rec.goal ? { goal: rec.goal } : {}) },
3677
+ });
3678
+ }
3624
3679
  if (recoveryStep) {
3625
3680
  const { action, member: who, pend } = recoveryStep;
3626
3681
  if (space.allow.recoveryOf(who)?.pubkey === pend.pubkey) {
@@ -3831,20 +3886,40 @@ pantheon start</code></pre>
3831
3886
  message: "HUMAN_REQUIRED must name decision_domains so the relay can verify the human owner. Call pantheon_route_decision first." };
3832
3887
  }
3833
3888
  const target = sp.profiles.get(to[0]);
3834
- const route = routeDecision(sp.profiles.values(), { domains });
3835
- if (route.conflicts.length) {
3889
+ const flagged = meta?.["named_by_requester"] === true;
3890
+ const passed = flagged ? passedOverBy(sp, to[0], domains) : [];
3891
+ if (flagged && role === "worker" && passed.length) {
3892
+ return { code: "NAMED_BY_A_RUN",
3893
+ message: "only a person, in their own session, can name who decides over the directory — a woken run routes each domain to its published owner (pantheon_route_decision)" };
3894
+ }
3895
+ const namedBy = flagged && role !== "worker";
3896
+ if (namedBy) {
3897
+ const own = reservedBy(target ? [target] : [], domains)[0]?.domains ?? [];
3898
+ const uncovered = domains.filter((d) => !own.includes(d));
3899
+ if (passed.length) {
3900
+ meta["authority"] = "named";
3901
+ meta["unverified_domains"] = uncovered;
3902
+ }
3903
+ else if (uncovered.length) {
3904
+ meta["authority"] = target ? "partly_unverified" : "unverified";
3905
+ meta["unverified_domains"] = uncovered;
3906
+ }
3907
+ }
3908
+ const route = namedBy ? null : routeDecision(sp.profiles.values(), { domains });
3909
+ if (route && route.conflicts.length) {
3836
3910
  return { code: "AMBIGUOUS_DECISION_AUTHORITY",
3837
3911
  message: `authority conflicts for ${route.conflicts.map((c) => `${c.domain} (${c.reason}: ${c.members.join(", ")})`).join("; ")}. Fix the profiles or ESCALATE instead of guessing.` };
3838
3912
  }
3839
- const others = route.required_humans.filter((h) => h.member !== to[0] && h.domains.some((d) => domains.includes(d)));
3913
+ const others = (route?.required_humans ?? []).filter((h) => h.member !== to[0] && h.domains.some((d) => domains.includes(d)));
3840
3914
  if (others.length) {
3841
3915
  const theirs = [...new Set(others.flatMap((h) => h.domains.filter((d) => domains.includes(d))))];
3842
3916
  return { code: "WRONG_DECISION_OWNER",
3843
- message: `${to[0]} is not the human-required owner for: ${theirs.join(", ")} — ${others.map((h) => h.member).join(", ")} ${others.length === 1 ? "is" : "are"}. Call pantheon_route_decision and route each domain to its published owner.` };
3917
+ message: `${to[0]} is not the human-required owner for: ${theirs.join(", ")} — ${others.map((h) => h.member).join(", ")} ${others.length === 1 ? "is" : "are"}. Call pantheon_route_decision and route each domain to its published owner.` +
3918
+ (role === "worker" ? "" : ` If the person who asked for this work named ${to[0]} to decide, send it again with named_by_requester: true — a name wins over the directory.`) };
3844
3919
  }
3845
- const owner = route.required_humans.find((x) => x.member === to[0]);
3920
+ const owner = route?.required_humans.find((x) => x.member === to[0]);
3846
3921
  const uncovered = domains.filter((d) => !owner?.domains.includes(d));
3847
- if (uncovered.length && meta) {
3922
+ if (route && uncovered.length && meta) {
3848
3923
  meta["authority"] = target ? "partly_unverified" : "unverified";
3849
3924
  meta["unverified_domains"] = uncovered;
3850
3925
  }
@@ -4129,7 +4204,7 @@ pantheon start</code></pre>
4129
4204
  break;
4130
4205
  case "HUMAN_REQUIRED":
4131
4206
  this.oblige(sp, { kind: "HUMAN", owner: `human:${rec.to[0]}`, ref: rec.id, goal: rec.goal,
4132
- body: `${rec.from} needs a decision: ${first(rec.body)}` });
4207
+ body: decisionBody(rec, sp) });
4133
4208
  if (goal && goal.state === "OPEN") {
4134
4209
  goal.state = "BLOCKED";
4135
4210
  this.broadcast(sp, { t: "goal", goal });
@@ -4157,7 +4232,7 @@ pantheon start</code></pre>
4157
4232
  if (asked && decider === rec.from && !stillAnswered && !alreadyLive && !retracted.has(asked.id)) {
4158
4233
  this.oblige(sp, { kind: "HUMAN", owner: `human:${decider}`, ref: asked.id, goal: asked.goal,
4159
4234
  ...(asked.act === "ESCALATE" && asked.ref ? { about: asked.ref } : {}),
4160
- body: asked.act === "ESCALATE" ? `${asked.from} escalated: ${first(asked.body)}` : `${asked.from} needs a decision: ${first(asked.body)}` });
4235
+ body: asked.act === "ESCALATE" ? `${asked.from} escalated: ${first(asked.body)}` : decisionBody(asked, sp) });
4161
4236
  const g = asked.goal ? sp.goals.get(asked.goal) : undefined;
4162
4237
  if (g && g.state === "OPEN") {
4163
4238
  g.state = "BLOCKED";
@@ -4427,7 +4502,7 @@ pantheon start</code></pre>
4427
4502
  }
4428
4503
  for (const e of allow.all()) {
4429
4504
  if (e.admitted && !sp.members.has(e.member)) {
4430
- sp.members.set(e.member, { id: e.member, display: e.member, agent: "unknown", last_seen: e.first_seen, sessions: new Map(), activity: new Map(), delivered_seq: 0, stamps: [] });
4505
+ sp.members.set(e.member, { id: e.member, display: e.member, agent: e.last_agent ?? "unknown", last_seen: e.first_seen, sessions: new Map(), activity: new Map(), delivered_seq: 0, stamps: [] });
4431
4506
  }
4432
4507
  }
4433
4508
  this.rebuildRecent(sp);
@@ -4578,7 +4653,9 @@ pantheon start</code></pre>
4578
4653
  return m;
4579
4654
  }
4580
4655
  m.display = display;
4581
- m.agent = agent;
4656
+ const known = agent && agent !== "unknown";
4657
+ if (known || !m.agent || m.agent === "unknown")
4658
+ m.agent = agent;
4582
4659
  m.last_seen = Date.now();
4583
4660
  m.sessions.set(session, { id: session, role, agent, ...(pkg ? { pkg } : {}), ws });
4584
4661
  sp.allow.touch(id, pkg, agent);
@@ -4686,6 +4763,17 @@ pantheon start</code></pre>
4686
4763
  this.hangUp(failed.ws, "STORAGE");
4687
4764
  }
4688
4765
  }
4766
+ function decisionDomains(rec) {
4767
+ const d = rec.meta?.["decision_domains"];
4768
+ return Array.isArray(d) ? [...new Set(d.filter((x) => typeof x === "string" && x.trim().length > 0))] : [];
4769
+ }
4770
+ function passedOverBy(sp, decider, domains) {
4771
+ return reservedBy([...sp.profiles.values()].filter((p) => p.member !== decider), domains);
4772
+ }
4773
+ function decisionBody(rec, sp) {
4774
+ const marker = rec.meta?.["authority"] === "named" ? namedMarker(rec.from, rec.meta, passedOverBy(sp, rec.to?.[0] ?? "", decisionDomains(rec))) : null;
4775
+ return `${rec.from} needs a decision: ${marker ? `[${marker}] ` : ""}${first(rec.body)}`;
4776
+ }
4689
4777
  function first(s, n = 120) {
4690
4778
  const line = s.split("\n")[0] ?? "";
4691
4779
  return line.length > n ? line.slice(0, n - 1) + "…" : line;
@@ -138,4 +138,8 @@ export function refuseSecrets(guard, fields) {
138
138
  }
139
139
  return null;
140
140
  }
141
+ export const PRIVATE_ARTIFACT_NOTE = "claude.ai artifact links are private to the sender's Claude account; teammates on other accounts get 'Page not found'. Share files through the repo, Jira, Confluence or Drive instead.";
142
+ export function privateLinkNote(...texts) {
143
+ return texts.some((t) => typeof t === "string" && /claude\.ai\/(?:artifact\/|public\/artifacts)/i.test(t)) ? PRIVATE_ARTIFACT_NOTE : null;
144
+ }
141
145
  //# sourceMappingURL=secret-guard.js.map
@@ -3,7 +3,7 @@ import { approvalSummary, installForUser } from "./install.js";
3
3
  import { execFileSync } from "node:child_process";
4
4
  import { homedir } from "node:os";
5
5
  import { join } from "node:path";
6
- import { applyProfileInit, loadProfile, profilePath, saveProfile, wakeMode } from "./profile.js";
6
+ import { applyProfileInit, loadProfile, profilePath, saveProfile, spaceOverride, teamProfile, wakeMode } from "./profile.js";
7
7
  import { installService as realInstall, uninstallService as realUninstall, serviceInstalled as realInstalled } from "./service.js";
8
8
  import { personaDir } from "./persona.js";
9
9
  import { hudBinaryPath } from "./notifier.js";
@@ -47,7 +47,7 @@ export function guideDefaults(profile, cfg) {
47
47
  approval: profile.agent.approval ?? "approve",
48
48
  name: profile.human.display_name || cfg.display || gitUserName() || cfg.member,
49
49
  title: profile.human.title || "",
50
- decides: Object.entries(profile.authority).filter(([, m]) => m === "human_required").map(([d]) => d),
50
+ decides: Object.entries(teamProfile(profile, cfg.spaceId, cfg.space).authority).filter(([, m]) => m === "human_required").map(([d]) => d),
51
51
  notifications: !n.decisions ? "none" : n.worker_done ? "worker" : n.deliveries ? "deliveries" : "decisions",
52
52
  persona: false,
53
53
  };
@@ -169,8 +169,21 @@ export function parseSetupFlags(argv) {
169
169
  const base = val("base");
170
170
  if (base !== undefined && base.trim())
171
171
  preset.base = base.trim();
172
+ for (const f of PROFILE_ONLY_FLAGS) {
173
+ if (argv.some((x) => x === `--${f}` || x.startsWith(`--${f}=`)))
174
+ errors.push(`--${f} is a profile field, not a setup answer — nothing was changed. Use pantheon profile set --${f} …${f === "human-required" || f === "agent-can-decide" ? " (decision areas like engineering,product)" : ""}`);
175
+ }
172
176
  return { preset, errors };
173
177
  }
178
+ export const PROFILE_ONLY_FLAGS = ["responsibilities", "human-required", "agent-can-decide", "capabilities"];
179
+ export function decisionAreasProblem(flagName, raw) {
180
+ if (raw === undefined)
181
+ return null;
182
+ const items = raw.split(",").map((x) => x.trim()).filter(Boolean);
183
+ if (!raw.includes(";") && !items.some((x) => /\s/.test(x)))
184
+ return null;
185
+ return `--${flagName} "${raw}": these take decision areas like engineering,product; describe the role with --responsibilities`;
186
+ }
174
187
  export async function runGuide(io, ctx, deps = {}, preset = {}) {
175
188
  const optOut = ctx.optOut ?? {};
176
189
  const serves = ctx.project ? { path: ctx.project, repos: true } : ctx.serves ?? null;
@@ -267,7 +280,8 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
267
280
  }
268
281
  }
269
282
  let next = a.title ? applyProfileInit(profile, { name: a.name, title: a.title }) : { ...profile, human: { ...profile.human, display_name: a.name } };
270
- const authority = { ...next.authority };
283
+ const ovKey = [ctx.cfg.spaceId, ctx.cfg.space].find((k) => k && spaceOverride(next, k)?.authority);
284
+ const authority = { ...(ovKey ? next.spaces[ovKey].authority : next.authority) };
271
285
  const prior = guideDefaults(profile, ctx.cfg).decides;
272
286
  const replace = !!io || preset.decides !== undefined;
273
287
  for (const area of DECISION_AREAS) {
@@ -278,7 +292,7 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
278
292
  }
279
293
  next = {
280
294
  ...next,
281
- authority,
295
+ ...(ovKey ? { spaces: { ...next.spaces, [ovKey]: { ...next.spaces[ovKey], authority } } } : { authority }),
282
296
  agent: { ...next.agent, wake_enabled: a.wake !== "no", wake_default: a.wake, allow_bash: a.commands, approval: a.approval },
283
297
  notifications: {
284
298
  decisions: a.notifications !== "none",
@@ -291,8 +305,14 @@ export async function runGuide(io, ctx, deps = {}, preset = {}) {
291
305
  applied.push(`profile: ${next.human.display_name}${next.human.title ? `, ${next.human.title}` : ""}${a.decides.length ? ` — you personally decide ${a.decides.join(", ")}` : " — no decisions reserved for you; your agent may decide"}`);
292
306
  if (deps.publish) {
293
307
  try {
294
- await deps.publish(next);
308
+ const team = await deps.publish(next, a.decides);
295
309
  applied.push("profile published to your team, so decisions route to you by name");
310
+ if (Array.isArray(team)) {
311
+ const held = (d) => team.some((p) => p.member !== ctx.cfg.member && Object.keys(p.authority).includes(d));
312
+ for (const d of a.decides.filter((x) => !prior.includes(x) && !held(x))) {
313
+ warnings.push(`"${d}": every ${d} decision in ${ctx.cfg.space} will wait for you — nobody else holds it. If that is more than you meant, pantheon setup --decides ${a.decides.filter((x) => x !== d).join(",") || "none"}`);
314
+ }
315
+ }
296
316
  }
297
317
  catch (e) {
298
318
  warnings.push(`profile saved but not published (${e.message}) — run \`pantheon profile publish\` when the relay is reachable`);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "format": 1,
3
- "built_at": "2026-10-07T10:43:05.668Z",
3
+ "built_at": "2026-10-08T17:23:00.642Z",
4
4
  "swiftc": "Apple Swift version 6.3.3 (swiftlang-6.3.3.1.3 clang-2100.1.1.101)",
5
5
  "helpers": {
6
6
  "pantheon-tray": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@join-pantheon/cli",
3
- "version": "0.33.4",
3
+ "version": "0.33.5",
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": {
@@ -24,7 +24,7 @@
24
24
  "test:installer": "node scripts/installer-tests/run.mjs"
25
25
  },
26
26
  "dependencies": {
27
- "@modelcontextprotocol/sdk": "^1.0.0",
27
+ "@modelcontextprotocol/sdk": "^1.32.1",
28
28
  "undici": "^7.29.1",
29
29
  "ws": "^8.18.0",
30
30
  "zod": "^3.23.8"