@join-pantheon/cli 0.20.0 → 0.21.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/config.js CHANGED
@@ -153,6 +153,20 @@ export function resolveFor(cwd = process.cwd(), opts = {}) {
153
153
  }
154
154
  return { cfg: null, reason: "unbound", memberships: n, repo };
155
155
  }
156
+ export function resolveForHook(cwd = process.cwd()) {
157
+ const r = resolveFor(cwd, { strict: true });
158
+ if (!r.cfg?.space)
159
+ return null;
160
+ if (r.reason !== "single" || !r.repo)
161
+ return r.cfg;
162
+ const f = loadAll();
163
+ const m = Object.values(f?.spaces ?? {})[0];
164
+ const bound = Object.values(f?.bindings ?? {}).filter((b) => b.space === m?.id);
165
+ if (!bound.length)
166
+ return r.cfg;
167
+ const here = r.repo;
168
+ return bound.some((b) => b.path === here.root || (!!b.origin && b.origin === here.origin)) ? r.cfg : null;
169
+ }
156
170
  export function load() {
157
171
  return resolveFor(process.cwd()).cfg;
158
172
  }
package/dist/doctor.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import { loadProfile, profileInitialized } from "./profile.js";
2
3
  import { existsSync, readFileSync } from "node:fs";
3
4
  import { join } from "node:path";
4
5
  import { PantheonClient } from "./client.js";
@@ -74,6 +75,14 @@ export async function doctor(projectDir) {
74
75
  verifyChallenge(cfg.publicKey, cfg.space, cfg.member, nonce, sig)
75
76
  ? add("pass", "identity", `keypair valid — fingerprint ${fingerprint(cfg.publicKey)}`)
76
77
  : add("fail", "identity", "your keypair does not verify against itself", "the config is corrupt — re-run `pantheon join` to mint a new identity, then get re-admitted");
78
+ const prof = loadProfile(cfg);
79
+ if (profileInitialized(prof)) {
80
+ const owns = Object.entries(prof.authority).filter(([, mode]) => mode === "human_required").map(([d]) => d);
81
+ add("pass", "profile", `${prof.human.display_name} — ${prof.human.title}${owns.length ? `; you personally decide: ${owns.join(", ")}` : "; no human-required domains yet"}`);
82
+ }
83
+ else {
84
+ add("warn", "profile", "no profile published — decisions cannot be routed to you, and a HUMAN_REQUIRED naming you arrives marked unverified", "pantheon profile set --name <you> --title <role> --human-required <domains you personally decide>");
85
+ }
77
86
  const mcpFile = join(projectDir, ".mcp.json");
78
87
  if (!existsSync(mcpFile)) {
79
88
  add("warn", "mcp entry", "no .mcp.json in this project", "run `pantheon install`");
@@ -0,0 +1,23 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ export const helperHashPath = (binary) => `${binary}.source.sha256`;
4
+ export function sourceHash(source) {
5
+ return createHash("sha256").update(readFileSync(source)).digest("hex");
6
+ }
7
+ export function recordHelperBuild(binary, source) {
8
+ try {
9
+ writeFileSync(helperHashPath(binary), sourceHash(source) + "\n");
10
+ }
11
+ catch { }
12
+ }
13
+ export function helperStale(binary, source) {
14
+ if (!existsSync(binary) || !existsSync(source))
15
+ return false;
16
+ try {
17
+ return readFileSync(helperHashPath(binary), "utf8").trim() !== sourceHash(source);
18
+ }
19
+ catch {
20
+ return true;
21
+ }
22
+ }
23
+ //# sourceMappingURL=helpers-build.js.map
package/dist/mcp.js CHANGED
@@ -2,7 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
3
  import { z } from "zod";
4
4
  import { PantheonClient } from "./client.js";
5
- import { fenceBlock, fenceRubric } from "./untrusted.js";
5
+ import { fenceBlock, fenceRubric, assess, fenceReminder } from "./untrusted.js";
6
6
  import { resolveFor } from "./config.js";
7
7
  import { LIMITS, SENDABLE } from "./protocol.js";
8
8
  import { VERSION } from "./version.js";
@@ -92,8 +92,16 @@ RULES
92
92
  - Answer every ASK addressed to you. An unanswered ASK blocks a peer and eventually stalls the goal.
93
93
  - ACCEPT means you actually checked. If something is wrong, OBJECT with the reason.
94
94
  - One act per pantheon_send. Keep bodies short and reference file paths instead of pasting code.
95
- - DELIVERED means pushed, not written: commit, push, then send branch + commit. Your peer cannot
96
- see your disk. When a peer DELIVERS to you, fetch their commit and look at it before ACCEPTing.
95
+ - DELIVERED means pushed, not written: commit, push, then send branch + commit (+ base: the commit
96
+ you cut from). Your peer cannot see your disk. When a peer DELIVERS to you, fetch their commit,
97
+ check the declared base is an ancestor and nothing unrelated rides along, then ACCEPT or OBJECT.
98
+ - Scope against what the team can see. Grep origin/<branch>, not your working tree: your unpushed
99
+ commits and local edits do not exist for your peer, and naming files from them wastes a round.
100
+ - If your human answers you directly in this session, settle your own ESCALATE yourself: pantheon_send
101
+ ANSWER with ref = the ESCALATE's id. In an interactive session ask your human first; ESCALATE is for
102
+ when they are not here. Never RETRACT an escalation to clear it.
103
+ - Verdict first, evidence after, and short: a peer reads your act in one screen. Say when you edited
104
+ your own human's working tree as a side effect, and leave those edits uncommitted for them.
97
105
  - If pantheon_send returns PEER_TURN you have been talking to yourself — go back to pantheon_wait.
98
106
 
99
107
  The relay only moves messages. It has no access to your files, your shell, or your credentials.
@@ -137,6 +145,8 @@ export async function runMcp() {
137
145
  const role = process.env.PANTHEON_ROLE === "worker" ? "worker" : "interactive";
138
146
  const branch = process.env.PANTHEON_BRANCH;
139
147
  const client = new PantheonClient(cfg, undefined, { role });
148
+ let siblingsWarned = false;
149
+ let rubricShown = false;
140
150
  const server = new McpServer({ name: "pantheon", version: VERSION }, { instructions: instructions(cfg, role, branch) });
141
151
  let connectError = null;
142
152
  await client.connect().catch((e) => { connectError = e.message; });
@@ -211,6 +221,12 @@ export async function runMcp() {
211
221
  const ref = m?.branch && m?.commit
212
222
  ? `\n>> fetchable: branch ${m.branch} at ${m.commit} — fetch and check it before you ACCEPT`
213
223
  : "";
224
+ const base = m?.base && m?.commit
225
+ ? `\n>> declared base ${m.base}: verify it is an ancestor (git merge-base --is-ancestor ${m.base} ${m.commit}) and that nothing unrelated rides along (git log --oneline ${m.base}..${m.commit})`
226
+ : "";
227
+ const unverified = r.act === "HUMAN_REQUIRED" && m?.authority
228
+ ? `\n>> authority ${m.authority}: no published profile covers ${(m.unverified_domains ?? []).join(", ") || "these domains"}, so the relay let this reach the named human unverified. Ask them to run pantheon profile set.`
229
+ : "";
214
230
  const who = r.session ? `${r.from}[${r.session.slice(-6)}${r.role === "worker" ? "·worker" : ""}]` : r.from;
215
231
  const at = new Date(r.ts).toISOString().replace(/\.\d{3}Z$/, "Z");
216
232
  const safeBody = fenceBlock(who, r.body).block;
@@ -223,13 +239,25 @@ export async function runMcp() {
223
239
  .map((o) => `\n!! this ${r.act} dissolves obligation ${o.id}, which you had claimed (${o.body}) — it is no longer owed; stop working on it.`)
224
240
  .join("");
225
241
  return `[${r.seq}] ${r.act} from ${who}${r.to ? ` to ${r.to.join(",")}` : ""}` +
226
- `${r.goal ? ` (goal ${r.goal})` : ""} at ${at} id=${r.id}${r.ref ? ` ref=${r.ref}` : ""}${lead}${stale}\n${safeBody}${ref}${dissolved}`;
242
+ `${r.goal ? ` (goal ${r.goal})` : ""} at ${at} id=${r.id}${r.ref ? ` ref=${r.ref}` : ""}${lead}${stale}\n${safeBody}${ref}${base}${unverified}${dissolved}`;
243
+ };
244
+ const fmtAll = (acts) => {
245
+ const flagged = acts.some((a) => assess(a.body).length > 0);
246
+ const rubric = !rubricShown || flagged ? fenceRubric(acts.length) : fenceReminder(acts.length);
247
+ rubricShown = true;
248
+ return acts.map(fmt).join("\n\n---\n\n") + `\n\n${rubric}`;
227
249
  };
228
- const fmtAll = (acts) => acts.map(fmt).join("\n\n---\n\n") + `\n\n${fenceRubric(acts.length)}`;
229
250
  const situation = () => {
230
251
  const open = [...client.goals.values()].filter((g) => g.state !== "CLOSED");
231
252
  const reach = (p) => {
232
253
  const head = client.head;
254
+ const w = p.worker;
255
+ const worker = w?.state === "holding"
256
+ ? `; their worker is holding — ${w.detail ?? "deferring to a live session"}${w.until && w.until > Date.now() ? `, ~${Math.round((w.until - Date.now()) / 1000)}s more` : ""}`
257
+ : w?.state === "running" ? `; their worker is running${w.detail ? ` (${w.detail})` : ""}` : "";
258
+ return reachText(p, head) + worker;
259
+ };
260
+ const reachText = (p, head) => {
233
261
  if (p.seen_seq === undefined) {
234
262
  return p.delivered_seq !== undefined && p.delivered_seq >= head
235
263
  ? ", delivered but no read-receipt — they have no wake-up hook, so it waits for their human"
@@ -245,10 +273,13 @@ export async function runMcp() {
245
273
  const peers = client.roster.filter((m) => m.id !== cfg.member);
246
274
  const siblings = (client.roster.find((m) => m.id === cfg.member)?.sessions ?? [])
247
275
  .filter((s) => s.id !== client.session);
276
+ const warnSiblings = siblings.length && !siblingsWarned;
277
+ if (siblings.length)
278
+ siblingsWarned = true;
248
279
  return [
249
280
  `you are: ${cfg.member} (${cfg.agent ?? "unknown agent"}, session ${client.session}, role ${role}) space: ${cfg.space} relay: ${client.connected ? "connected" : "OFFLINE"}`,
250
281
  siblings.length
251
- ? `your other sessions: ${siblings.map((s) => `${s.id}[${s.role}]`).join(" ")} — claim work before doing it, or you will duplicate theirs`
282
+ ? `your other sessions: ${siblings.map((s) => `${s.id}[${s.role}]`).join(" ")}${warnSiblings ? " — claim work before doing it, or you will duplicate theirs" : ""}`
252
283
  : `your other sessions: none`,
253
284
  peers.length
254
285
  ? `peers: ${peers.map((p) => `${p.id} [${p.online ? "online" : "offline"}, ${p.agent}${reach(p)}]`).join(" ")}`
@@ -319,7 +350,7 @@ export async function runMcp() {
319
350
  : `ASK ${owner.member}'s agent about ${owner.domains.join(", ")}.`);
320
351
  }
321
352
  if (route.unmatched_domains.length || !route.domains.length) {
322
- next.push(`Authority is unresolved${route.unmatched_domains.length ? ` for ${route.unmatched_domains.join(", ")}` : ""}; ESCALATE to your own human instead of guessing.`);
353
+ next.push(`No published profile owns${route.unmatched_domains.length ? ` ${route.unmatched_domains.join(", ")}` : " these domains"}. If one named peer's human clearly must decide, HUMAN_REQUIRED to them still goes through, marked unverified — and ask them to run pantheon profile set. If it is your own human's call, ESCALATE. Do not guess an owner.`);
323
354
  }
324
355
  return text(`${JSON.stringify(route, null, 2)}\n\nNEXT\n${next.map((x) => `- ${x}`).join("\n")}`);
325
356
  }));
@@ -362,16 +393,19 @@ export async function runMcp() {
362
393
  ref: z.string().optional().describe("id of the act you are responding to; required for ANSWER/ACCEPT/OBJECT"),
363
394
  branch: z.string().optional().describe("REQUIRED for DELIVERED: the branch you pushed the work to"),
364
395
  commit: z.string().optional().describe("REQUIRED for DELIVERED: the commit sha your peer should fetch and check"),
396
+ base: z.string().optional().describe("for DELIVERED: the commit your branch was cut from, so your peer can check nothing unrelated rides along. Worker runs fill this in automatically."),
365
397
  remote: z.string().optional().describe("for DELIVERED: the git remote your peer should fetch from (https://, ssh://, or user@host:path). Without it they have to guess that your origin is theirs."),
366
398
  defers_to: z.string().optional().describe("on CLOSE: the goal id you are standing down in favour of. The relay refuses this if that goal is closed or is itself deferring, because both sides standing down at once leaves the work with nowhere to live."),
367
399
  affects: z.array(z.string()).optional().describe("on PROPOSE: whose agreement this actually needs. Without it EVERY member of the goal owes a verdict — including anyone offline — so a three-person goal stalls on whoever is away."),
368
400
  },
369
- }, async ({ act, body, goal, to, ref, branch, commit, remote, decision_domains, summary, defers_to, affects }) => guard(async () => {
370
- const meta = branch || commit || remote || decision_domains?.length
401
+ }, async ({ act, body, goal, to, ref, branch, commit, remote, base, decision_domains, summary, defers_to, affects }) => guard(async () => {
402
+ const baseSha = act === "DELIVERED" ? (base ?? process.env.PANTHEON_BASE) : undefined;
403
+ const meta = branch || commit || remote || baseSha || decision_domains?.length
371
404
  ? {
372
405
  ...(branch ? { branch } : {}),
373
406
  ...(commit ? { commit } : {}),
374
407
  ...(remote ? { remote } : {}),
408
+ ...(baseSha ? { base: baseSha } : {}),
375
409
  ...(decision_domains?.length ? { decision_domains } : {}),
376
410
  }
377
411
  : undefined;
package/dist/notifier.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFileSync, spawn } from "node:child_process";
2
+ import { helperStale, recordHelperBuild } from "./helpers-build.js";
2
3
  import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
4
  import { platform } from "node:os";
4
5
  import { dirname, join, resolve } from "node:path";
@@ -17,10 +18,10 @@ export function dueHumanObligations(obligations, member, state, now, reminderMs)
17
18
  return last === undefined || now - last >= reminderMs;
18
19
  });
19
20
  }
20
- export function pruneNotificationState(state, obligations, member) {
21
+ export function pruneNotificationState(state, obligations, member, now = Date.now()) {
21
22
  const active = new Set(humanObligations(obligations, member).map((o) => o.id));
22
23
  return {
23
- notified_at: Object.fromEntries(Object.entries(state.notified_at).filter(([id]) => active.has(id))),
24
+ notified_at: Object.fromEntries(Object.entries(state.notified_at).filter(([id, at]) => active.has(id) || (id.startsWith("act:") && now - at < 7 * 86_400_000))),
24
25
  };
25
26
  }
26
27
  function readState(file) {
@@ -56,8 +57,10 @@ export function ensureNativeNotifier(home = CONFIG_DIR) {
56
57
  requireDeveloperTools("notifications", "notifier");
57
58
  execFileSync("/usr/bin/swiftc", ["-O", source, "-o", binary], { stdio: "pipe" });
58
59
  chmodSync(binary, 0o755);
60
+ recordHelperBuild(binary, source);
59
61
  return binary;
60
62
  }
63
+ export function notifierStale(home = CONFIG_DIR) { return helperStale(hudBinaryPath(home), hudSourcePath()); }
61
64
  export function requireDeveloperTools(what, service) {
62
65
  try {
63
66
  execFileSync("/usr/bin/xcode-select", ["-p"], { stdio: "pipe" });
@@ -108,12 +111,15 @@ export class HumanNotifier {
108
111
  this.opts.log(`notifier online as ${this.cfg.member}`);
109
112
  this.opts.log(` reminders every ${Math.round(this.opts.reminderMs / 60_000)} minutes while unresolved`);
110
113
  while (!this.stopped) {
111
- const pruned = pruneNotificationState(state, client.obligations, this.cfg.member);
114
+ const pruned = pruneNotificationState(state, client.obligations, this.cfg.member, now());
112
115
  let dirty = JSON.stringify(pruned) !== JSON.stringify(state);
113
116
  state = pruned;
114
- const due = dueHumanObligations(client.obligations, this.cfg.member, state, now(), this.opts.reminderMs);
117
+ const profile = loadProfile(this.cfg);
118
+ const prefs = profile.notifications ?? { decisions: true, deliveries: false, worker_done: false };
119
+ const due = prefs.decisions
120
+ ? dueHumanObligations(client.obligations, this.cfg.member, state, now(), this.opts.reminderMs)
121
+ : [];
115
122
  for (const obligation of due) {
116
- const profile = loadProfile(this.cfg);
117
123
  const goal = obligation.goal ? client.goals.get(obligation.goal)?.title : undefined;
118
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));
119
125
  if (ok) {
@@ -127,7 +133,24 @@ export class HumanNotifier {
127
133
  }
128
134
  if (dirty || !existsSync(stateFile))
129
135
  writeState(stateFile, state);
130
- await client.next(pollMs);
136
+ const arrived = await client.next(pollMs);
137
+ if (prefs.deliveries) {
138
+ for (const r of arrived) {
139
+ if (r.act !== "DELIVERED" || r.from === this.cfg.member || state.notified_at[`act:${r.id}`])
140
+ continue;
141
+ const goal = r.goal ? client.goals.get(r.goal) : undefined;
142
+ const forMe = (r.to?.includes(this.cfg.member) ?? false) || (goal?.members.includes(this.cfg.member) ?? false);
143
+ if (!forMe)
144
+ 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");
147
+ if (ok) {
148
+ state.notified_at[`act:${r.id}`] = now();
149
+ writeState(stateFile, state);
150
+ this.opts.log(`> delivery alert for ${r.id} from ${r.from}`);
151
+ }
152
+ }
153
+ }
131
154
  }
132
155
  client.close();
133
156
  }
package/dist/notify.js CHANGED
@@ -290,7 +290,7 @@ export async function decideStop(raw, deps) {
290
290
  const acts = report.acts ?? [];
291
291
  const owed = report.obligations ?? [];
292
292
  const closed = new Set(report.closed_goals ?? []);
293
- const DEMANDS_RESPONSE = new Set(["ASK", "PROPOSE", "OBJECT", "DELIVERED", "CONTRACT", "ESCALATE"]);
293
+ const DEMANDS_RESPONSE = new Set(["ASK", "PROPOSE", "OBJECT", "DELIVERED", "CONTRACT"]);
294
294
  const work = acts.filter((a) => {
295
295
  if (a.goal && closed.has(a.goal))
296
296
  return false;
package/dist/pending.js CHANGED
@@ -102,10 +102,12 @@ export async function pending(opts) {
102
102
  const obligations = surfaced.filter((o) => !alreadySeen.has(o.id));
103
103
  const head = obligationsBody?.seq;
104
104
  const all = (historyBody?.acts ?? []);
105
+ const mine = all.filter((r) => r.from === cfg.member);
105
106
  const acts = all
106
107
  .filter((r) => r.from !== cfg.member)
107
108
  .filter((r) => r.act !== "JOIN" && r.act !== "LEAVE")
108
- .filter((r) => !r.to || r.to.length === 0 || r.to.includes(cfg.member));
109
+ .filter((r) => !r.to || r.to.length === 0 || r.to.includes(cfg.member))
110
+ .filter((r) => !mine.some((m) => m.seq > r.seq && (m.ref === r.id || (!!r.goal && m.goal === r.goal))));
109
111
  const seq = first
110
112
  ? head
111
113
  : historyBody === null
package/dist/plans.js ADDED
@@ -0,0 +1,46 @@
1
+ export const UNLIMITED = 1_000_000;
2
+ export const DEFAULT_PLANS = {
3
+ free: { spaces: 1, members: 2, wake: true },
4
+ pro: { spaces: 10, members: 25, wake: true },
5
+ };
6
+ export const PLAN_NAME = /^[a-z][a-z0-9_-]{0,23}$/;
7
+ const int = (v, min, max) => typeof v === "number" && Number.isInteger(v) && v >= min && v <= max ? v : null;
8
+ export function parseLimits(raw) {
9
+ if (!raw || typeof raw !== "object")
10
+ return null;
11
+ const o = raw;
12
+ const num = (v) => typeof v === "string" && v.trim() !== "" ? Number(v) : v;
13
+ const spaces = int(num(o["spaces"]), 0, UNLIMITED);
14
+ const members = int(num(o["members"]), 1, UNLIMITED);
15
+ const wake = typeof o["wake"] === "boolean" ? o["wake"] : o["wake"] === "true" || o["wake"] === "on" || o["wake"] === "1" ? true : o["wake"] === "false" || o["wake"] === "off" || o["wake"] === "0" || o["wake"] === undefined ? false : null;
16
+ if (spaces === null || members === null || wake === null)
17
+ return null;
18
+ return { spaces, members, wake };
19
+ }
20
+ export function parsePlans(raw) {
21
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
22
+ return null;
23
+ const out = {};
24
+ for (const [name, lim] of Object.entries(raw)) {
25
+ if (!PLAN_NAME.test(name))
26
+ return null;
27
+ const l = parseLimits(lim);
28
+ if (!l)
29
+ return null;
30
+ out[name] = l;
31
+ }
32
+ return out["free"] ? out : null;
33
+ }
34
+ export function planLimits(plans, plan, override) {
35
+ if (override) {
36
+ try {
37
+ const l = parseLimits(JSON.parse(override));
38
+ if (l)
39
+ return l;
40
+ }
41
+ catch { }
42
+ }
43
+ return plans[plan] ?? plans["free"] ?? DEFAULT_PLANS["free"];
44
+ }
45
+ export const fmtLimit = (n) => n >= UNLIMITED ? "unlimited" : String(n);
46
+ //# sourceMappingURL=plans.js.map
package/dist/profile.js CHANGED
@@ -25,6 +25,7 @@ export function defaultProfile(cfg) {
25
25
  human: { display_name: cfg.display ?? cfg.member, title: "", responsibilities: [] },
26
26
  agent: { primary_surface: surfaceFromAgent(cfg.agent), wake_enabled: false },
27
27
  attention: { urgent: "primary_surface", decision: "primary_surface", fyi: "inbox" },
28
+ notifications: { decisions: true, deliveries: false, worker_done: false },
28
29
  authority: {},
29
30
  decision_keywords: {},
30
31
  capabilities: [],
@@ -32,6 +33,9 @@ export function defaultProfile(cfg) {
32
33
  updated_at: 0,
33
34
  };
34
35
  }
36
+ export function wakeDefault(profile) {
37
+ return (profile.agent.wake_default ?? "anyone") === "anyone";
38
+ }
35
39
  export function loadProfile(cfg, file = profilePath()) {
36
40
  if (!existsSync(file))
37
41
  return defaultProfile(cfg);
@@ -53,6 +57,7 @@ export function loadProfile(cfg, file = profilePath()) {
53
57
  },
54
58
  agent: { ...defaultProfile(cfg).agent, ...raw.agent, primary_surface: surface },
55
59
  attention: { ...defaultProfile(cfg).attention, ...raw.attention },
60
+ notifications: { ...defaultProfile(cfg).notifications, ...(raw.notifications ?? {}) },
56
61
  authority: raw.authority ?? {},
57
62
  decision_keywords: cleanKeywords(legacy.decision_keywords),
58
63
  capabilities: Array.isArray(raw.capabilities) ? raw.capabilities.filter((x) => typeof x === "string") : [],
package/dist/protocol.js CHANGED
@@ -160,6 +160,7 @@ export const BindRepo = z.object({
160
160
  cid: z.string().max(64).optional(),
161
161
  repo: z.string().regex(/^[rl]_[0-9a-f]{16}$/),
162
162
  origin: z.string().max(200).nullable().optional(),
163
+ base: z.string().min(1).max(100).optional(),
163
164
  });
164
165
  export const OpenGoal = z.object({
165
166
  t: z.literal("open_goal"),
@@ -171,7 +172,18 @@ export const OpenGoal = z.object({
171
172
  deadline: z.number().int().positive().optional(),
172
173
  });
173
174
  export const Ping = z.object({ t: z.literal("ping") });
174
- export const Inbound = z.discriminatedUnion("t", [Hello, SendAct, OpenGoal, Claim, Admit, MakeInvite, PublishProfile, BindRepo, Ping]);
175
+ export const DeleteSpace = z.object({
176
+ t: z.literal("delete_space"),
177
+ cid: z.string().max(64).optional(),
178
+ confirm: z.string().min(1).max(64),
179
+ });
180
+ export const WorkerState = z.object({
181
+ t: z.literal("worker_state"),
182
+ state: z.enum(["idle", "holding", "running"]),
183
+ detail: z.string().max(200).optional(),
184
+ until: z.number().int().positive().optional(),
185
+ });
186
+ export const Inbound = z.discriminatedUnion("t", [Hello, SendAct, OpenGoal, Claim, Admit, MakeInvite, PublishProfile, BindRepo, DeleteSpace, WorkerState, Ping]);
175
187
  export function shouldDeferToHuman(sessions, work, opts = {}) {
176
188
  const now = opts.now ?? Date.now();
177
189
  const idleMs = opts.idleMs ?? LIMITS.workerIdleMs;
@@ -4,6 +4,7 @@ import { basename } from "node:path";
4
4
  import { homedir } from "node:os";
5
5
  import { PantheonClient } from "./client.js";
6
6
  import { load, save, toInvite, resolveFor, repoInfo, bindRepo, DEFAULT_RELAY } from "./config.js";
7
+ import { loadProfile, wakeDefault } from "./profile.js";
7
8
  import { newKeypair } from "./identity.js";
8
9
  import { installForUser } from "./install.js";
9
10
  import { writeIntent, installHooks } from "./notify.js";
@@ -203,7 +204,7 @@ export async function start(opts) {
203
204
  warnings.push(`this repo was bound to another team (${replaced.space}); it now belongs to "${c.space}"`);
204
205
  }
205
206
  opts.log(` space ${c.space} ${reuse ? "— reconnected" : "— created, you own it"}${repo ? ` ${repo.origin ?? "local repo"} → bound to it` : ""}`);
206
- const inv = await client.makeInvite(true, opts.ttlMinutes);
207
+ const inv = await client.makeInvite(wakeDefault(loadProfile(c)), opts.ttlMinutes);
207
208
  invite = toInvite({ ...c, token: inv.url });
208
209
  claimed = true;
209
210
  }