@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/client.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import WebSocket from "ws";
2
+ import { clearRemoval, recordRemoval, removalFor, removalHoldMs, removalRefusal, REMOVED_CHECK_MS, REMOVED_FIRST_MS, REMOVED_MAX_MS } from "./removed.js";
2
3
  import { adoptSpaceId, loadAll, rememberTeamRepos, updateMembershipToken } from "./config.js";
3
4
  import { honourClearedAuthority } from "./profile.js";
4
5
  import { markContinued } from "./continued.js";
@@ -35,9 +36,13 @@ export class PantheonClient {
35
36
  cid = 0;
36
37
  static REFUSED_RETRY_MS = 5 * 60_000;
37
38
  static REFUSED_GRACE_MS = 2 * 60_000;
39
+ static REMOVED_CHECK_MS = REMOVED_CHECK_MS;
38
40
  get refused() { return this.connected ? null : this.refusedCode; }
39
41
  refusedCode = null;
40
42
  refusedSince = 0;
43
+ get removedFrom() { return this.connected ? null : this.removal; }
44
+ removal = null;
45
+ refusedAsRemoval = false;
41
46
  seq = 0;
42
47
  readSeq = 0;
43
48
  head = 0;
@@ -180,6 +185,7 @@ export class PantheonClient {
180
185
  return;
181
186
  }
182
187
  let settled = false;
188
+ this.refusedAsRemoval = false;
183
189
  let heard = false;
184
190
  const fail = reject;
185
191
  reject = (err) => fail(heard ? Object.assign(err, { relayAnswered: true }) : err);
@@ -277,6 +283,8 @@ export class PantheonClient {
277
283
  this.connected = true;
278
284
  this.pending = null;
279
285
  this.refusedCode = null;
286
+ this.removal = null;
287
+ this.removedWaitMs = 0;
280
288
  this.session = msg.session;
281
289
  this.everConnected = true;
282
290
  this.head = msg.seq;
@@ -317,12 +325,17 @@ export class PantheonClient {
317
325
  this.spaceId = msg.space_id;
318
326
  this.cfg.spaceId = msg.space_id;
319
327
  }
328
+ try {
329
+ clearRemoval({ space: this.cfg.space, spaceId: this.cfg.spaceId ?? msg.space_id ?? null, relay: this.cfg.relay });
330
+ }
331
+ catch { }
320
332
  this.repos = msg.repos ?? [];
321
333
  this.keepRepos();
322
334
  this.plan = msg.plan ?? null;
323
335
  this.adoptKnownRepo();
324
336
  this.backoff = 500;
325
337
  this.roster = msg.roster;
338
+ this.learnRelayNow(msg.now);
326
339
  this.obligations = msg.obligations;
327
340
  this.members = msg.members;
328
341
  this.features = msg.features ?? [];
@@ -371,6 +384,7 @@ export class PantheonClient {
371
384
  break;
372
385
  case "roster":
373
386
  this.roster = msg.roster;
387
+ this.learnRelayNow(msg.now);
374
388
  break;
375
389
  case "obligations":
376
390
  this.obligations = msg.list;
@@ -382,7 +396,7 @@ export class PantheonClient {
382
396
  const fn = msg.cid ? this.acks.get(msg.cid) : undefined;
383
397
  if (fn && msg.cid) {
384
398
  this.acks.delete(msg.cid);
385
- fn({ id: msg.id, seq: msg.seq });
399
+ fn({ id: msg.id, seq: msg.seq, ...(typeof msg.note === "string" && msg.note ? { note: msg.note.slice(0, 300) } : {}) });
386
400
  }
387
401
  break;
388
402
  }
@@ -393,6 +407,15 @@ export class PantheonClient {
393
407
  if (code && !this.refusedCode)
394
408
  this.refusedSince = Date.now();
395
409
  this.refusedCode = code;
410
+ const removed = code === "REVOKED" ? removalRefusal(this.lastError) : null;
411
+ this.removal = removed;
412
+ this.refusedAsRemoval = !!removed;
413
+ if (removed) {
414
+ try {
415
+ recordRemoval({ space: this.cfg.space, spaceId: this.cfg.spaceId ?? null, relay: this.cfg.relay, member: this.cfg.member }, removed.by);
416
+ }
417
+ catch { }
418
+ }
396
419
  }
397
420
  if (msg.code === "SPACE_DELETED")
398
421
  this.closed = true;
@@ -435,17 +458,64 @@ export class PantheonClient {
435
458
  reject(new Error(this.lastError ?? "relay connection failed"));
436
459
  }
437
460
  const slow = !!this.refused && Date.now() - this.refusedSince >= PantheonClient.REFUSED_GRACE_MS;
438
- const wait = slow ? PantheonClient.REFUSED_RETRY_MS : Math.round(this.backoff * (0.5 + Math.random()));
439
- setTimeout(() => { if (!this.closed)
440
- this.connect().catch(() => { }); }, wait);
461
+ if (slow && this.removal && this.refusedAsRemoval)
462
+ this.waitOutRemoval();
463
+ else {
464
+ const wait = slow ? PantheonClient.REFUSED_RETRY_MS : Math.round(this.backoff * (0.5 + Math.random()));
465
+ setTimeout(() => { if (!this.closed)
466
+ this.connect().catch(() => { }); }, wait);
467
+ }
441
468
  this.backoff = Math.min(this.backoff * 2, 15_000);
442
469
  };
443
470
  ws.on("close", down);
444
471
  ws.on("error", (e) => { this.lastError = String(e.message ?? e); down(); });
445
472
  });
446
473
  }
474
+ waitOutRemoval() {
475
+ const team = { space: this.cfg.space, spaceId: this.cfg.spaceId ?? null, relay: this.cfg.relay, member: this.cfg.member };
476
+ let rec = null;
477
+ try {
478
+ rec = removalFor(team);
479
+ }
480
+ catch {
481
+ rec = null;
482
+ }
483
+ this.removedWaitMs = Math.min(this.removedWaitMs ? this.removedWaitMs * 2 : REMOVED_FIRST_MS, REMOVED_MAX_MS);
484
+ const now = Date.now();
485
+ const until = Math.max(rec ? rec.last + removalHoldMs(rec) : 0, now + this.removedWaitMs);
486
+ const next = () => Math.max(1_000, Math.min(PantheonClient.REMOVED_CHECK_MS, until - Date.now()));
487
+ const tick = () => {
488
+ this.removalTimer = null;
489
+ if (this.closed || this.connected)
490
+ return;
491
+ let gone = false;
492
+ if (rec) {
493
+ try {
494
+ gone = !removalFor(team);
495
+ }
496
+ catch {
497
+ gone = false;
498
+ }
499
+ }
500
+ if (gone || Date.now() >= until) {
501
+ this.connect().catch(() => { });
502
+ return;
503
+ }
504
+ this.removalTimer = setTimeout(tick, next());
505
+ };
506
+ if (this.removalTimer)
507
+ clearTimeout(this.removalTimer);
508
+ this.removalTimer = setTimeout(tick, next());
509
+ }
510
+ removalTimer = null;
511
+ removedWaitMs = 0;
447
512
  handOff() {
448
513
  this.closed = true;
514
+ this.stopRenewing();
515
+ if (this.removalTimer) {
516
+ clearTimeout(this.removalTimer);
517
+ this.removalTimer = null;
518
+ }
449
519
  for (const w of this.waiters) {
450
520
  clearTimeout(w.timer);
451
521
  w.resolve([]);
@@ -463,6 +533,11 @@ export class PantheonClient {
463
533
  }
464
534
  close() {
465
535
  this.closed = true;
536
+ this.stopRenewing();
537
+ if (this.removalTimer) {
538
+ clearTimeout(this.removalTimer);
539
+ this.removalTimer = null;
540
+ }
466
541
  for (const w of this.waiters) {
467
542
  clearTimeout(w.timer);
468
543
  w.resolve([]);
@@ -764,8 +839,55 @@ export class PantheonClient {
764
839
  const o = this.obligations.find((x) => x.id === obligation);
765
840
  if (o)
766
841
  this.claimedByMe.set(o.id, o);
842
+ if (this.role === "worker") {
843
+ this.held.add(obligation);
844
+ this.startRenewing();
845
+ }
767
846
  return r;
768
847
  }
848
+ static CLAIM_RENEW_MS = 10 * 60_000;
849
+ held = new Set();
850
+ renewTimer = null;
851
+ startRenewing() {
852
+ if (this.renewTimer)
853
+ return;
854
+ this.renewTimer = setInterval(() => { void this.renewClaims(); }, PantheonClient.CLAIM_RENEW_MS);
855
+ this.renewTimer.unref?.();
856
+ }
857
+ stopRenewing() {
858
+ if (this.renewTimer) {
859
+ clearInterval(this.renewTimer);
860
+ this.renewTimer = null;
861
+ }
862
+ }
863
+ forgetClaim(id) {
864
+ this.held.delete(id);
865
+ if (!this.held.size)
866
+ this.stopRenewing();
867
+ }
868
+ get renewing() { return [...this.held]; }
869
+ async renewClaims() {
870
+ if (!this.connected)
871
+ return 0;
872
+ let renewed = 0;
873
+ for (const id of [...this.held]) {
874
+ if (!id.startsWith("cont:") && !this.obligations.some((o) => o.id === id)) {
875
+ this.held.delete(id);
876
+ continue;
877
+ }
878
+ try {
879
+ await this.rpc({ t: "claim", obligation: id });
880
+ renewed++;
881
+ }
882
+ catch (e) {
883
+ if (/^(NO_SUCH_OBLIGATION|NO_SUCH_ACT|NOT_YOURS|NOT_ALLOWED_TO_WAKE|PLAN_LIMIT): /.test(e.message))
884
+ this.held.delete(id);
885
+ }
886
+ }
887
+ if (!this.held.size)
888
+ this.stopRenewing();
889
+ return renewed;
890
+ }
769
891
  dissolutionNotices(rec) {
770
892
  const out = [];
771
893
  for (const [id, o] of this.claimedByMe) {
@@ -851,12 +973,27 @@ export class PantheonClient {
851
973
  if (c.cwdRepoOrigin)
852
974
  c.repoOrigin = c.cwdRepoOrigin;
853
975
  }
854
- reportWorkerState(state, detail, until) {
976
+ reportWorkerState(state, detail, until, idleMs, more = {}) {
977
+ const v2 = this.features.includes("worker-state-2");
978
+ const st = state === "unavailable" && !v2 ? "holding" : state;
979
+ const d = !v2 && more.reason ? `${detail ?? ""} [${more.reason}]`.trim() : detail;
980
+ const untilIn = until ? Math.max(0, Math.round(until - this.relayNow())) : undefined;
855
981
  try {
856
- this.ws?.send(JSON.stringify({ t: "worker_state", state, ...(detail ? { detail: detail.slice(0, 200) } : {}), ...(until ? { until } : {}) }));
982
+ this.ws?.send(JSON.stringify({ t: "worker_state", state: st, ...(d ? { detail: d.slice(0, 200) } : {}), ...(until ? { until } : {}),
983
+ ...(v2 && untilIn !== undefined ? { until_in: Math.min(untilIn, 7 * 24 * 3600_000) } : {}),
984
+ ...(v2 && idleMs !== undefined ? { idle_ms: Math.max(0, Math.min(3_600_000, Math.round(idleMs))) } : {}),
985
+ ...(v2 && more.reason ? { reason: more.reason } : {}),
986
+ ...(v2 && more.ref && /^(cont:)?[a-z]{1,3}_[A-Za-z0-9_-]{4,64}$/.test(more.ref) ? { ref: more.ref } : {}),
987
+ ...(v2 && more.goal && /^[a-z]{1,3}_[A-Za-z0-9_-]{4,64}$/.test(more.goal) ? { goal: more.goal } : {}) }));
857
988
  }
858
989
  catch { }
859
990
  }
991
+ relayNow() { return Date.now() + this.relaySkewMs; }
992
+ relaySkewMs = 0;
993
+ learnRelayNow(now) {
994
+ if (typeof now === "number" && Number.isFinite(now) && now > 0)
995
+ this.relaySkewMs = now - Date.now();
996
+ }
860
997
  deleteSpace(confirm) {
861
998
  return this.rpc({ t: "delete_space", confirm });
862
999
  }
package/dist/doctor.js CHANGED
@@ -6,7 +6,7 @@ import { describeWindows, scanMcpWindows } from "./mcp-windows.js";
6
6
  import { grantsSeen, measureMcp, toolsReport, HOST_STARTUP_ALLOWANCE_MS, STARTUP_WARN_MS } from "./measure.js";
7
7
  import { FEEDBACK_ADDRESS } from "./feedback.js";
8
8
  import { loadProfile, profileInitialized, reservedDomains, teamProfile, wakeMode } from "./profile.js";
9
- import { isApprovedWaker, loadPolicy, relayKnowsAdmitters, unsandboxedAllowed } from "./worker-policy.js";
9
+ import { inWokenRun, isApprovedWaker, loadPolicy, relayKnowsAdmitters, requestReadinessRecheck, unsandboxedAllowed } from "./worker-policy.js";
10
10
  import { commandState } from "./command-state.js";
11
11
  import { existsSync, readFileSync, realpathSync } from "node:fs";
12
12
  import { homedir, platform } from "node:os";
@@ -19,8 +19,11 @@ import { load, resolveFor, loadAll, configFor, CONFIG_PATH } from "./config.js";
19
19
  import { fingerprint, signChallenge, verifyChallenge } from "./identity.js";
20
20
  import { approvalSummary, chosenApproval, copilotMcpConfigFile, kiroMcpConfigFile, onPath } from "./install.js";
21
21
  import { hookState, readIntent, agentHookStates, repairNote } from "./notify.js";
22
- import { loadPersonas } from "./persona.js";
23
- import { workerProc, menubarProc, notifierProc } from "./machine.js";
22
+ import { loadPersonas, olderTeammatesNote } from "./persona.js";
23
+ import { workerProc, menubarProc, notifierProc, workerFlagRunMinutes } from "./machine.js";
24
+ import { isCatchAllDomain } from "./protocol.js";
25
+ import { probeLinuxSandbox, sandboxDoctorRow } from "./linux-sandbox.js";
26
+ import { removalKey, removalRefusal, removals, removalWords, teamsOf } from "./removed.js";
24
27
  import { agentById, chooseAgent, describeChoice, detectAvailable, folderReaderLine, unattendedRefusal, skippedSummary, SANDBOXED_FOR_UNATTENDED } from "./agents.js";
25
28
  import { loginShellLookup, npmSpawn, onPathNamed, which, whichWindows } from "./which.js";
26
29
  import { VERSION } from "./version.js";
@@ -151,6 +154,18 @@ function newestRelease() {
151
154
  return null;
152
155
  }
153
156
  }
157
+ function repoBoundToTeam(cfg) {
158
+ try {
159
+ const f = loadAll();
160
+ const m = f && Object.values(f.spaces ?? {}).find((x) => x.id === cfg.spaceId || x.name === cfg.space);
161
+ if (!f || !m)
162
+ return false;
163
+ return Object.entries(f.bindings ?? {}).some(([k, b]) => b.space === m.id && !k.startsWith("d_") && existsSync(join(b.path, ".git")));
164
+ }
165
+ catch {
166
+ return false;
167
+ }
168
+ }
154
169
  export async function doctor(projectDir) {
155
170
  const out = [];
156
171
  const add = (level, name, detail, fix, share) => out.push({ level, name, detail, ...(fix ? { fix } : {}), ...(share ? { share } : {}) });
@@ -240,6 +255,10 @@ export async function doctor(projectDir) {
240
255
  }
241
256
  else if (here.reason === "stale")
242
257
  add("fail", "team", `this ${here.repo ? "repo" : "folder"} is bound to a team this machine has left (${here.binding?.space})`, "pantheon use <team> --here, or pantheon join <invite>", "bound to a team this machine has left");
258
+ for (const { team, record } of removals(teamsOf(loadAll())).filter((x) => x.record.team !== removalKey(cfg) && x.record.team !== `${cfg.relay}|${cfg.space}`)) {
259
+ const w = removalWords(team, record.by);
260
+ add("warn", "removed", `${w.by ? `${w.by} removed you` : "you were removed"} from "${w.name}" (${new Date(record.at).toISOString().slice(0, 10)}) — this machine stopped connecting to it and checks now and then (at most every 6 hours)`, `to come back, you need a new invite from someone in "${w.name}" (pantheon join <invite>) or for a member to let you back in (pantheon admit ${w.member}); to forget it here: pantheon leave "${w.name}"`, "removed from a team");
261
+ }
243
262
  if (here.repo && !here.repo.shared)
244
263
  add("warn", "team", "this repo has no origin remote", "DELIVERED must name a commit a teammate can fetch; add a remote before delivering", "repo has no origin remote");
245
264
  const nonce = "doctor-selftest";
@@ -251,6 +270,9 @@ export async function doctor(projectDir) {
251
270
  if (profileInitialized(prof)) {
252
271
  const owns = reservedDomains(teamProfile(prof, cfg.spaceId, cfg.space));
253
272
  add("pass", "profile", `${prof.human.display_name} — ${prof.human.title}${owns.length ? `; you personally decide: ${owns.join(", ")}` : "; no human-required domains yet"}`, undefined, `published, ${owns.length} human-required domain${owns.length === 1 ? "" : "s"}`);
273
+ const vague = Object.keys(teamProfile(prof, cfg.spaceId, cfg.space).authority).filter((d) => isCatchAllDomain(d));
274
+ if (vague.length)
275
+ add("warn", "profile", `your profile claims ${vague.map((d) => `"${d}"`).join(", ")} — not a decision area, so no decision is ever routed by it`, "running pantheon setup drops it — name the areas you decide personally: pantheon setup --decides deployment,pricing (or --decides none); it is never published either way", "claims a catch-all decision area");
254
276
  }
255
277
  else {
256
278
  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>", "none published");
@@ -305,6 +327,9 @@ export async function doctor(projectDir) {
305
327
  if (!inRepo && folderHere) {
306
328
  add("pass", "git", "a plain folder — messages, questions and decisions work here; code is handed over from a repo", undefined, "plain folder, no git needed");
307
329
  }
330
+ else if (!inRepo && !repoBoundToTeam(cfg)) {
331
+ add("info", "git", "not a git repo — fine: questions, answers and decisions work anywhere; only handing over code needs one", undefined, "no repo; not needed for messages and decisions");
332
+ }
308
333
  else if (!inRepo) {
309
334
  add("warn", "git", `${projectDir} is not a git repo`, "DELIVERED requires a pushed branch + commit, so the work needs to live in a repo you both clone", "not a git repo");
310
335
  }
@@ -321,6 +346,8 @@ export async function doctor(projectDir) {
321
346
  agents.length
322
347
  ? add("pass", "agent cli", agents.join(", "), undefined, agents.join(", "))
323
348
  : add("warn", "agent cli", "found none of claude / codex / cursor-agent / copilot / kiro-cli on PATH", "fine if you only drive agents through a GUI (Conductor, Cursor, VS Code, the Kiro IDE), but `pantheon worker` needs a CLI to spawn", "none of claude / codex / cursor-agent / copilot / kiro-cli on PATH");
349
+ if (!inWokenRun())
350
+ requestReadinessRecheck();
324
351
  if (agents.length) {
325
352
  try {
326
353
  let unsandboxedOk = false;
@@ -491,7 +518,9 @@ export async function doctor(projectDir) {
491
518
  }
492
519
  client.pending
493
520
  ? add("fail", "membership", "registered but not admitted", `ask an admitted member to run: pantheon admit ${cfg.member} (confirm fingerprint ${fingerprint(cfg.publicKey)} with them by voice first)`, "registered, not admitted")
494
- : ((r) => add("fail", "membership", `refused: ${r?.message ?? client.lastError ?? "unknown"}`, (r?.fix ?? "run pantheon status for the relay's reason").replace("<you>", cfg.member), `refused: ${r?.code ?? String(client.lastError ?? "unknown").split(/[\s:]/)[0]}`))(refusalOf(client.lastError ?? ""));
521
+ : removalRefusal(client.lastError)
522
+ ? ((r) => add("fail", "membership", `removed from "${cfg.space}"${r.by ? ` by ${r.by}` : ""} — this machine's worker, alerts and menu bar stopped connecting to it (they check now and then, at most every 6 hours)`, `to come back, you need a new invite from someone in "${cfg.space}" (pantheon join <invite>) or for a member to let you back in (pantheon admit ${cfg.member}); to forget it here: pantheon leave "${cfg.space}"`, "refused: removed from the team"))(removalRefusal(client.lastError))
523
+ : ((r) => add("fail", "membership", `refused: ${r?.message ?? client.lastError ?? "unknown"}`, (r?.fix ?? "run pantheon status for the relay's reason").replace("<you>", cfg.member), `refused: ${r?.code ?? String(client.lastError ?? "unknown").split(/[\s:]/)[0]}`))(refusalOf(client.lastError ?? ""));
495
524
  client.close();
496
525
  return out;
497
526
  }
@@ -620,10 +649,18 @@ export async function doctor(projectDir) {
620
649
  else
621
650
  add("pass", "commands", `woken runs: ${cs.summary}`, undefined, cs.level === "off" ? "woken runs edit files only" : `woken runs may run commands (${cs.allowBash ? "--allow-bash" : "granted here"})`);
622
651
  }
652
+ {
653
+ const { loadPolicy, effectiveRunMinutes, describeRunMinutes, effectiveIdleSeconds, describeIdleSeconds } = await import("./worker-policy.js");
654
+ const wf = workerFlagRunMinutes();
655
+ const lim = wf !== null ? { minutes: wf, from: "flag" } : effectiveRunMinutes({ policy: loadPolicy() });
656
+ add("pass", "run limit", `${describeRunMinutes(lim)} — a run still going then is stopped, tried once more, then handed to you`, undefined, `${lim.minutes} min (${lim.from === "default" ? "default" : lim.from === "flag" ? "worker flag" : "set here"})`);
657
+ const idle = effectiveIdleSeconds({ policy: loadPolicy() });
658
+ add("pass", "idle wait", `${idle.seconds ? `${describeIdleSeconds(idle.seconds)} of quiet in your session before the worker takes a request` : "none — the worker takes a request as soon as no turn is in flight in your session"} (${idle.from === "default" ? "the default" : "set on this machine"}; pantheon policy set idle-seconds <0-900>)`, undefined, `${idle.seconds}s (${idle.from === "default" ? "default" : "set here"})`);
659
+ }
623
660
  if (cs.level !== "off" && host.platform === "linux") {
624
- const missing = ["bwrap", "socat"].filter((b) => !onPathNamed(b, "linux"));
625
- if (missing.length)
626
- add("warn", "sandbox", `${missing.join(" and ")} not found — Claude Code's sandbox cannot start, so every woken command run will be refused`, "install them: sudo apt install bubblewrap socat (Debian/Ubuntu) or sudo dnf install bubblewrap socat (Fedora)", `sandbox tools missing: ${missing.join(", ")}`);
661
+ const r = sandboxDoctorRow(probeLinuxSandbox(), { container: host.kind === "container" });
662
+ if (r)
663
+ add(r.level, "sandbox", r.detail, r.fix, r.share);
627
664
  }
628
665
  }
629
666
  if (host.platform === "linux" && !host.ephemeral) {
@@ -673,6 +710,11 @@ export async function doctor(projectDir) {
673
710
  broken.length
674
711
  ? add("warn", "personas", `${personas.map((p) => p.name).join(", ")} — ${broken.map((p) => `${p.name}: ${p.warnings[0]}`).join("; ")}`, "a persona line that does not parse grants nothing and filters nothing — pantheon persona show <name>", `${personas.length}, ${broken.length} that do not parse`)
675
712
  : add("pass", "personas", `${personas.map((p) => p.name).join(", ")} — the worker only takes work a persona covers`, undefined, `${personas.length}`);
713
+ for (const p of personas) {
714
+ const n = olderTeammatesNote(p);
715
+ if (n)
716
+ add("warn", "personas", `${p.name} (committed in the repo) ${n}`, undefined, "a committed persona uses a key older versions do not know");
717
+ }
676
718
  }
677
719
  const open = [...client.goals.values()].filter((g) => g.state !== "CLOSED");
678
720
  const stateLine = `${client.head} acts in the ledger, ${open.length} open goal(s) — restored across relay restarts`;
package/dist/file-lock.js CHANGED
@@ -147,11 +147,11 @@ export function brokenStateFix(broken) {
147
147
  parts.push(`repair or delete ${files.join(", ")} (one made read-only or locked: make it writable again; one that is a folder or not a file: delete it)`);
148
148
  return parts.join("; ");
149
149
  }
150
- export function withFileLock(f, fn) {
150
+ export function withFileLock(f, fn, waitMs = WAIT_MS) {
151
151
  mkdirSync(dirname(f), { recursive: true, mode: 0o700 });
152
152
  const lock = `${f}.lock`;
153
153
  const token = `${process.pid} ${randomBytes(12).toString("hex")}`;
154
- const deadline = Date.now() + WAIT_MS;
154
+ const deadline = Date.now() + waitMs;
155
155
  const made = `${lock}.${randomBytes(6).toString("hex")}.new`;
156
156
  writeFileSync(made, token, { mode: 0o600, flag: "wx" });
157
157
  try {
package/dist/git-safe.js CHANGED
@@ -168,12 +168,16 @@ function hooksHash(common) {
168
168
  return h.digest("hex");
169
169
  }
170
170
  function cfgwtHash(gitdir) {
171
+ let body;
171
172
  try {
172
- return createHash("sha256").update(readFileSync(join(gitdir, "config.worktree"))).digest("hex");
173
+ body = readFileSync(join(gitdir, "config.worktree"));
173
174
  }
174
175
  catch {
175
176
  return null;
176
177
  }
178
+ if (/^\uFEFF?[ \t\r\n]*$/.test(body.toString("utf8")))
179
+ return null;
180
+ return createHash("sha256").update(body).digest("hex");
177
181
  }
178
182
  function inspect(worktree, a, snapshot = false) {
179
183
  const common = a.common;
@@ -12,7 +12,9 @@ export const CONTINUED_FILE = "continued.json";
12
12
  export const CURSOR_FILE = "worker-cursor.json";
13
13
  export const PINS_FILE = "keys.json";
14
14
  export const NONCES_FILE = "nonces.json";
15
- export const GUARDED_FILES = [POLICY_FILE, CONFIG_FILE, PROFILE_FILE, HUD_FILE, CONTINUED_FILE, CURSOR_FILE, PINS_FILE, NONCES_FILE];
15
+ export const REMOVED_FILE = "removed.json";
16
+ export const RECHECK_FILE = "readiness-recheck.stamp";
17
+ export const GUARDED_FILES = [POLICY_FILE, CONFIG_FILE, PROFILE_FILE, HUD_FILE, CONTINUED_FILE, CURSOR_FILE, PINS_FILE, NONCES_FILE, REMOVED_FILE, RECHECK_FILE];
16
18
  const sha = (b) => createHash("sha256").update(b).digest("hex");
17
19
  const read = (f) => { try {
18
20
  return statSync(f).isFile() ? readFileSync(f) : null;
@@ -195,8 +197,42 @@ export function narrowPolicy(before, now, keepAsks = askedHere) {
195
197
  out.wakeTeamFlag = flags;
196
198
  if ("wakeNo" in now || Object.keys(wakeNo).length)
197
199
  out.wakeNo = wakeNo;
200
+ const minutes = (x) => (typeof x === "number" && Number.isInteger(x) && x >= RUN_MINUTES_MIN && x <= RUN_MINUTES_MAX ? x : RUN_MINUTES_DEFAULT);
201
+ if (!same(b.runMinutes, now.runMinutes) && minutes(now.runMinutes) > minutes(b.runMinutes)) {
202
+ if ("runMinutes" in b)
203
+ out.runMinutes = b.runMinutes;
204
+ else
205
+ delete out.runMinutes;
206
+ keys.push("runMinutes raised");
207
+ }
208
+ if (!same(b.idleSeconds, now.idleSeconds)) {
209
+ if ("idleSeconds" in b)
210
+ out.idleSeconds = b.idleSeconds;
211
+ else
212
+ delete out.idleSeconds;
213
+ keys.push("idleSeconds changed");
214
+ }
198
215
  return { policy: out, keys };
199
216
  }
217
+ export const RUN_MINUTES_DEFAULT = 60, RUN_MINUTES_MIN = 5, RUN_MINUTES_MAX = 180;
218
+ export function narrowRemoved(before, now) {
219
+ const b = obj(before), n = obj(now);
220
+ const value = {};
221
+ const keys = [];
222
+ for (const [k, v] of Object.entries(n)) {
223
+ if (!(k in b)) {
224
+ keys.push(`${k} added`);
225
+ continue;
226
+ }
227
+ if (!same(b[k], v)) {
228
+ keys.push(`${k} changed`);
229
+ value[k] = b[k];
230
+ continue;
231
+ }
232
+ value[k] = v;
233
+ }
234
+ return { value, keys };
235
+ }
200
236
  export function narrowConfig(before, now) {
201
237
  const keys = [];
202
238
  const out = { ...now };
@@ -444,7 +480,7 @@ export function restoreHome(s) {
444
480
  continue;
445
481
  }
446
482
  const locked = (fn) => {
447
- if (!(f.rel === PINS_FILE || f.rel === NONCES_FILE || f.rel === CONTINUED_FILE))
483
+ if (!(f.rel === PINS_FILE || f.rel === NONCES_FILE || f.rel === CONTINUED_FILE || f.rel === REMOVED_FILE))
448
484
  return fn();
449
485
  let ran = false;
450
486
  try {
@@ -462,6 +498,25 @@ export function restoreHome(s) {
462
498
  catch {
463
499
  writeFileSync(file, text, { mode: 0o600 });
464
500
  } };
501
+ if (f.rel === RECHECK_FILE) {
502
+ if (f.bytes)
503
+ writeFileSync(file, f.bytes, { mode: 0o600 });
504
+ else
505
+ rmSync(file, { force: true });
506
+ out.push({ rel: f.rel, what: f.bytes ? "reverted" : "removed", keys: [], quiet: true });
507
+ continue;
508
+ }
509
+ if (f.rel === REMOVED_FILE) {
510
+ locked(() => {
511
+ const n = narrowRemoved(a, parse(read(file)));
512
+ if (n.keys.length) {
513
+ put(JSON.stringify(n.value, null, 2) + "\n");
514
+ trustWrite(file);
515
+ out.push({ rel: f.rel, what: "reverted", keys: n.keys, quiet: true });
516
+ }
517
+ });
518
+ continue;
519
+ }
465
520
  if (f.rel === PINS_FILE || f.rel === NONCES_FILE) {
466
521
  locked(() => {
467
522
  const cur = parse(read(file));
@@ -16,6 +16,8 @@ export const PANTHEON_HOME_ENTRIES = [
16
16
  { name: "worker-policy.json", what: "the worker's wake policy", identity: true },
17
17
  { name: "spend.json", what: "the spend ledger", identity: true },
18
18
  { name: "personas", what: "the worker's personas", dir: true, identity: true },
19
+ { name: "removed.json", what: "the teams this machine was removed from", identity: true },
20
+ { name: "readiness-recheck.stamp", what: "the mark that has the worker look again for an agent it can run" },
19
21
  { name: "worker-state.json", what: "the worker's last state" },
20
22
  { name: "worker-run.json", what: "the worker's record of a run in flight" },
21
23
  { name: "run-slot.json", what: "the worker's run slot" },
@@ -0,0 +1,71 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
3
+ import { homedir, platform } from "node:os";
4
+ import { join } from "node:path";
5
+ import { onPathNamed } from "./which.js";
6
+ export function sandboxProbeArgs(weakerNested = false) {
7
+ return ["--unshare-user", "--unshare-pid", "--unshare-net", "--cap-drop", "ALL", "--die-with-parent", "--new-session",
8
+ "--ro-bind", "/", "/", ...(weakerNested ? ["--bind", "/proc", "/proc"] : ["--proc", "/proc"]), "--dev", "/dev", "true"];
9
+ }
10
+ export const SANDBOX_PROBE_ARGS = sandboxProbeArgs(false);
11
+ export function claudeWeakerNested(home = homedir()) {
12
+ try {
13
+ const j = JSON.parse(readFileSync(join(home, ".claude", "settings.json"), "utf8"));
14
+ return j?.sandbox?.enableWeakerNestedSandbox === true;
15
+ }
16
+ catch {
17
+ return false;
18
+ }
19
+ }
20
+ export const SANDBOX_PROBE_TIMEOUT_MS = 5_000;
21
+ const realRun = (cmd, args, timeoutMs) => {
22
+ const r = spawnSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "ignore", "pipe"], timeout: timeoutMs, killSignal: "SIGKILL" });
23
+ return { status: r.status, stderr: String(r.stderr ?? ""), timedOut: !!r.error && /ETIMEDOUT/.test(String(r.error.code ?? r.error.message)) };
24
+ };
25
+ export function probeLinuxSandbox(o = {}) {
26
+ if ((o.os ?? platform()) !== "linux")
27
+ return null;
28
+ const onPath = o.onPath ?? ((n) => onPathNamed(n, "linux"));
29
+ const missing = ["bwrap", "socat"].filter((b) => !onPath(b));
30
+ if (missing.length)
31
+ return { ok: false, missing };
32
+ let r;
33
+ try {
34
+ r = (o.run ?? realRun)("bwrap", sandboxProbeArgs(o.weakerNested ?? claudeWeakerNested()), SANDBOX_PROBE_TIMEOUT_MS);
35
+ }
36
+ catch (e) {
37
+ return { ok: false, error: e.message.split("\n")[0].slice(0, 200) };
38
+ }
39
+ if (r.status === 0)
40
+ return { ok: true };
41
+ const line = r.stderr.split("\n").map((l) => l.trim()).find(Boolean);
42
+ return { ok: false, error: r.timedOut ? `bwrap did not finish within ${SANDBOX_PROBE_TIMEOUT_MS / 1000} s` : (line ?? `bwrap exited ${r.status ?? "on a signal"}`).replace(/[\u0000-\u001f\u007f-\u009f]/g, " ").slice(0, 200) };
43
+ }
44
+ const NAMESPACE_RE = /Operation not permitted|mount proc|setting up uid map|No permissions to create new namespace|user namespace|uid_map|Permission denied/i;
45
+ export function sandboxAdvice(p, where) {
46
+ if (p.missing?.length) {
47
+ return {
48
+ says: `${p.missing.join(" and ")} not found — Claude Code's sandbox cannot start, so every woken command run will be refused`,
49
+ fix: "install them: sudo apt install bubblewrap socat (Debian/Ubuntu) or sudo dnf install bubblewrap socat (Fedora)",
50
+ };
51
+ }
52
+ const says = `Claude Code's sandbox cannot start here (${p.error ?? "bubblewrap failed"}) — every woken command run is refused, so runs can edit files but not test, commit or push`;
53
+ if (where.container) {
54
+ return { says, fix: "this looks like a container, which likely forbids the namespaces and /proc mount the sandbox needs: run the container privileged (or with user namespaces allowed), or run the worker on a VM or a real machine. Pantheon never runs commands outside the sandbox instead." };
55
+ }
56
+ if (NAMESPACE_RE.test(p.error ?? "")) {
57
+ return { says, fix: "likely cause: unprivileged user namespaces are restricted (Ubuntu 23.10+/24.04 do this with AppArmor). To lift it: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 — and to keep it after a reboot, put kernel.apparmor_restrict_unprivileged_userns=0 in /etc/sysctl.d/60-userns.conf. A narrower route is an AppArmor profile that allows bwrap to create user namespaces. Then run pantheon doctor again." };
58
+ }
59
+ return { says, fix: "run the probe yourself to see more: bwrap " + SANDBOX_PROBE_ARGS.join(" ") + " — then pantheon doctor again" };
60
+ }
61
+ export function sandboxDoctorRow(p, where) {
62
+ if (!p)
63
+ return null;
64
+ if (p.ok)
65
+ return { level: "pass", detail: "Claude Code's sandbox likely starts here (bubblewrap: user, pid and network namespaces, no capabilities, its /proc)", share: "sandbox likely starts" };
66
+ const a = sandboxAdvice(p, where);
67
+ return p.missing?.length
68
+ ? { level: "warn", detail: a.says, fix: a.fix, share: `sandbox tools missing: ${p.missing.join(", ")}` }
69
+ : { level: "fail", detail: a.says, fix: a.fix, share: `sandbox cannot start${where.container ? " (container)" : ""}` };
70
+ }
71
+ //# sourceMappingURL=linux-sandbox.js.map
package/dist/machine.js CHANGED
@@ -162,7 +162,25 @@ function bareProc(kind) {
162
162
  }
163
163
  return { kind, running: false, managedBy: svc.managedBy ?? "none", ...svc };
164
164
  }
165
+ export function runBy(p) {
166
+ if ((p.managedBy === "launchd" || p.managedBy === "systemd") && p.label)
167
+ return `service ${p.label}`;
168
+ if (p.managedBy === "host")
169
+ return `background host (${p.label ?? "the Pantheon task"})`;
170
+ return "started by hand";
171
+ }
165
172
  export function workerProc() { return bareProc("worker"); }
173
+ export function workerFlagRunMinutes(home = CONFIG_DIR, running = () => workerProc().running) {
174
+ try {
175
+ const s = JSON.parse(readFileSync(join(home, "worker-state.json"), "utf8"));
176
+ if (typeof s.run_minutes !== "number" || !Number.isFinite(s.run_minutes))
177
+ return null;
178
+ return running() ? s.run_minutes : null;
179
+ }
180
+ catch {
181
+ return null;
182
+ }
183
+ }
166
184
  export function notifierProc() { return bareProc("notifier"); }
167
185
  export function menubarProc() { return bareProc("menubar"); }
168
186
  const BARE_PATTERN = {
package/dist/mcp.js CHANGED
@@ -23,6 +23,7 @@ import { protocol, sessionInstructions } from "./mcp-instructions.js";
23
23
  import { recentRuns } from "./spend.js";
24
24
  import { refusalOf } from "./refusal.js";
25
25
  import { signedRead } from "./reads.js";
26
+ import { pickupEstimate } from "./pickup.js";
26
27
  const ACT_HELP = {
27
28
  NOTE: "FYI. Creates no obligation.",
28
29
  ASK: "A question a named peer owes you an answer to. Requires `to`.",
@@ -282,10 +283,8 @@ export async function runMcp() {
282
283
  const open = [...client.goals.values()].filter((g) => g.state !== "CLOSED");
283
284
  const reach = (p) => {
284
285
  const head = client.head;
285
- const w = p.worker;
286
- const worker = w?.state === "holding"
287
- ? `; 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` : ""}`
288
- : w?.state === "running" ? `; their worker is running${w.detail ? ` (${w.detail})` : ""}` : "";
286
+ const est = p.worker ? pickupEstimate(p, { now: client.relayNow() }) : null;
287
+ const worker = est && est.kind !== "soon" && est.kind !== "offline" ? `; ${est.says}` : "";
289
288
  return reachText(p, head) + worker;
290
289
  };
291
290
  const reachText = (p, head) => {
@@ -438,6 +437,14 @@ export async function runMcp() {
438
437
  return text(fmtAll(acts) +
439
438
  (work.length ? `\n\nYOURS TO HANDLE — pantheon_claim first:\n${work.map((o) => ` ${o.id} ${o.kind} — ${o.body}`).join("\n")}` : ""));
440
439
  }));
440
+ const pickupLines = (to) => {
441
+ const mayWake = client.members.find((m) => m.member === cfg.member)?.may_wake;
442
+ const lines = to.filter((t) => t !== cfg.member).map((t) => {
443
+ const peer = client.roster.find((m) => m.id === t) ?? { id: t, sessions: [] };
444
+ return ` ${pickupEstimate(peer, { now: client.relayNow(), ...(mayWake !== undefined ? { mayWake } : {}) }).says}`;
445
+ });
446
+ return lines.length ? `\n\nwhen it will be picked up (tell your human if it is not soon):\n${lines.join("\n")}` : "";
447
+ };
441
448
  server.registerTool("pantheon_send", {
442
449
  title: "Send an act",
443
450
  description: `Say one thing to your peers, as a typed act. Acts:\n` +
@@ -512,7 +519,8 @@ export async function runMcp() {
512
519
  ? `\n\nnote: this machine could not identify the request ${ref} (it was never read here and the relay did not serve it), so it is not recorded as answered — a copy of it arriving later would not be recognised. If one does, check with its sender before acting on it.`
513
520
  : "";
514
521
  const linkNote = privateLinkNote(body, summary);
515
- return text(`sent ${act} — id=${r.id} seq=${r.seq}${survived}${unrecorded}${linkNote ? `\n\nnote: ${linkNote}` : ""}\n\nNow call pantheon_wait to hear back.`);
522
+ const pickup = act === "ASK" ? pickupLines(to ?? []) : "";
523
+ return text(`sent ${act} — id=${r.id} seq=${r.seq}${pickup}${survived}${unrecorded}${linkNote ? `\n\nnote: ${linkNote}` : ""}\n\nNow call pantheon_wait to hear back.`);
516
524
  }));
517
525
  server.registerTool("pantheon_open_goal", {
518
526
  title: "Open a goal",