@join-pantheon/cli 0.33.1 → 0.33.2

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.
Files changed (44) hide show
  1. package/README.md +6 -5
  2. package/dist/accounts.js +10 -0
  3. package/dist/agents.js +11 -0
  4. package/dist/board.js +55 -9
  5. package/dist/claude-confine.js +1 -1
  6. package/dist/cli.js +556 -89
  7. package/dist/client.js +8 -4
  8. package/dist/config.js +18 -4
  9. package/dist/continued.js +5 -0
  10. package/dist/desktop-app.js +103 -0
  11. package/dist/doctor.js +84 -32
  12. package/dist/folder-guard.js +12 -0
  13. package/dist/folder-snapshot.js +432 -0
  14. package/dist/install.js +15 -8
  15. package/dist/machine.js +84 -4
  16. package/dist/managed.js +2 -2
  17. package/dist/mcp.js +5 -3
  18. package/dist/network.js +156 -0
  19. package/dist/notice-linux.js +6 -5
  20. package/dist/notice-windows.js +2 -2
  21. package/dist/notice.js +13 -0
  22. package/dist/notifier.js +29 -2
  23. package/dist/notify.js +11 -0
  24. package/dist/onboard-defaults.js +37 -1
  25. package/dist/pins.js +7 -1
  26. package/dist/plans.js +23 -0
  27. package/dist/protocol.js +5 -0
  28. package/dist/quickstart.js +17 -11
  29. package/dist/refusal.js +4 -4
  30. package/dist/relay.js +332 -22
  31. package/dist/repo-find.js +6 -3
  32. package/dist/roster-file.js +50 -5
  33. package/dist/tray.js +12 -1
  34. package/dist/uninstall.js +38 -3
  35. package/dist/uri-handler.js +3 -2
  36. package/dist/verify-act.js +17 -0
  37. package/dist/worker-fleet.js +140 -5
  38. package/dist/worker.js +201 -63
  39. package/installer/install.ps1 +251 -15
  40. package/installer/install.sh +147 -18
  41. package/package.json +1 -1
  42. package/helper/macos/prebuilt/manifest.json +0 -29
  43. package/helper/macos/prebuilt/pantheon-hud +0 -0
  44. package/helper/macos/prebuilt/pantheon-tray +0 -0
@@ -29,6 +29,7 @@ export class Allowlist {
29
29
  name = null;
30
30
  extra = {};
31
31
  repos = new Map();
32
+ recoveries = new Map();
32
33
  file;
33
34
  constructor(dir, space) {
34
35
  this.file = join(dir, `${space}.members.json`);
@@ -69,7 +70,13 @@ export class Allowlist {
69
70
  this.removedAt = doc.removed_at;
70
71
  if (legacyRemoval && this.removedAt === null)
71
72
  this.removedAt = Date.now();
72
- const known = new Set(["members", "invites", "tokenHash", "retiredTokenHashes", "removed_at", "id", "name", "repos", "founder", "founder_guessed"]);
73
+ const known = new Set(["members", "invites", "tokenHash", "retiredTokenHashes", "removed_at", "id", "name", "repos", "founder", "founder_guessed", "recoveries"]);
74
+ if (Array.isArray(doc.recoveries)) {
75
+ for (const r of doc.recoveries) {
76
+ if (r && typeof r.member === "string" && typeof r.pubkey === "string" && typeof r.due_at === "number")
77
+ this.recoveries.set(r.member, r);
78
+ }
79
+ }
73
80
  if (!Array.isArray(raw))
74
81
  for (const [k, v] of Object.entries(raw))
75
82
  if (!known.has(k))
@@ -123,6 +130,7 @@ export class Allowlist {
123
130
  ...(this.retiredHashes.length ? { retiredTokenHashes: this.retiredHashes } : {}),
124
131
  ...(this.removedAt !== null ? { removed_at: this.removedAt } : {}),
125
132
  ...(this.repos.size ? { repos: [...this.repos.values()] } : {}),
133
+ ...(this.recoveries.size ? { recoveries: [...this.recoveries.values()] } : {}),
126
134
  }, null, 2) + "\n");
127
135
  }
128
136
  peekId() { return this.id; }
@@ -211,11 +219,12 @@ export class Allowlist {
211
219
  get hasToken() {
212
220
  return this.tokenHash !== null;
213
221
  }
214
- mintInvite(by, mayWake, ttlMinutes, token, spaceToken) {
222
+ mintInvite(by, mayWake, ttlMinutes, token, spaceToken, recoveryFor) {
215
223
  const inv = {
216
224
  token, created_by: by, created_at: Date.now(),
217
225
  expires_at: Date.now() + ttlMinutes * 60_000, may_wake: mayWake,
218
226
  ...(spaceToken ? { space_token: spaceToken } : {}),
227
+ ...(recoveryFor ? { recovery_for: recoveryFor } : {}),
219
228
  };
220
229
  this.invites.set(token, inv);
221
230
  this.flush();
@@ -231,7 +240,16 @@ export class Allowlist {
231
240
  if (inv.used_by)
232
241
  return { ok: false, why: `that invite was already used by "${inv.used_by}" — ask for a fresh one` };
233
242
  if (Date.now() > inv.expires_at)
234
- return { ok: false, why: "that invite has expired — ask for a fresh one" };
243
+ return { ok: false, why: inv.recovery_for ? "that recovery link has expired — run pantheon recover again on the new machine" : "that invite has expired — ask for a fresh one" };
244
+ if (inv.recovery_for) {
245
+ if (member !== inv.recovery_for || !prior?.admitted || prior.publicKey !== publicKey) {
246
+ return { ok: false, why: `that link is for ${inv.recovery_for}'s new device after a recovery, and only that device can use it` };
247
+ }
248
+ inv.used_by = member;
249
+ inv.used_at = Date.now();
250
+ this.flush();
251
+ return { ok: true, entry: prior };
252
+ }
235
253
  const existing = this.entries.get(member);
236
254
  const removedAt = this.removedWhen(member);
237
255
  if (removedAt !== null && (inv.created_at <= removedAt || (this.founderName && inv.created_by !== this.founderName))) {
@@ -244,7 +262,8 @@ export class Allowlist {
244
262
  why: `"${member}" founded this team and is registered with a different key — a founder's key cannot be replaced`,
245
263
  fix: [
246
264
  `If you ARE "${member}" on a new machine: restore your original ~/.pantheon/config.json if you still have it — the key is in it — then redeem this same invite again.`,
247
- ` Without it, nobody can let you back in under "${member}": a team's founder is never removed, so the old key cannot be retired.`,
265
+ ` Without it, on a relay that verifies email (the hosted one): run pantheon recover <team> in a terminal here: it signs you in with your email and moves your seat to this machine.`,
266
+ ` Otherwise nobody can let you back in under "${member}": a team's founder is never removed, so the old key cannot be retired.`,
248
267
  ` You can join under another name instead (--me <new-name>, this same invite), or start a new team: pantheon start --space <name>.`,
249
268
  ` If you are not "${member}", join under your own name: --me <your-name>.`,
250
269
  ].join("\n"),
@@ -256,6 +275,7 @@ export class Allowlist {
256
275
  fix: [
257
276
  `If you are not "${member}", join under your own name: --me <your-name> (this same invite is still unused).`,
258
277
  ` If you ARE "${member}" on a new machine: restore your original ~/.pantheon/config.json if you still have it — the key is in it.`,
278
+ ` Or, on a relay that verifies email (the hosted one): run pantheon recover <team> in a terminal here: it signs you in with your email and moves your seat to this machine.`,
259
279
  ` Failing that, a teammate who has confirmed it is you retires the old key (this removes whoever holds "${member}" now): pantheon admit ${member} --forget`,
260
280
  ` Then redeem this same invite again.`,
261
281
  ].join("\n"),
@@ -273,7 +293,7 @@ export class Allowlist {
273
293
  return { ok: true, entry };
274
294
  }
275
295
  openInvites() {
276
- return [...this.invites.values()].filter((i) => !i.used_by && Date.now() < i.expires_at);
296
+ return [...this.invites.values()].filter((i) => !i.used_by && !i.recovery_for && Date.now() < i.expires_at);
277
297
  }
278
298
  findInvite(token) {
279
299
  const exact = this.invites.get(token);
@@ -418,6 +438,31 @@ export class Allowlist {
418
438
  this.flush();
419
439
  return true;
420
440
  }
441
+ recoveryOf(member) { return this.recoveries.get(member) ?? null; }
442
+ recoveryList() { return [...this.recoveries.values()]; }
443
+ requestRecovery(r) {
444
+ this.recoveries.set(r.member, r);
445
+ this.flush();
446
+ }
447
+ cancelRecovery(member) {
448
+ const r = this.recoveries.get(member) ?? null;
449
+ if (r) {
450
+ this.recoveries.delete(member);
451
+ this.flush();
452
+ }
453
+ return r;
454
+ }
455
+ moveSeat(member, pubkey, move) {
456
+ const e = this.entries.get(member);
457
+ if (!e || !e.admitted)
458
+ return null;
459
+ e.moved = { from_fp: fingerprint(e.publicKey), ...move };
460
+ e.publicKey = pubkey;
461
+ this.recoveries.delete(member);
462
+ this.cancelInvitesBy(member);
463
+ this.flush();
464
+ return e;
465
+ }
421
466
  describe() {
422
467
  if (this.empty)
423
468
  return "(no members yet)";
package/dist/tray.js CHANGED
@@ -86,6 +86,16 @@ export function arrangeBoard(items, o) {
86
86
  }
87
87
  return out.filter((i) => i.lane).sort(boardOrder);
88
88
  }
89
+ export function localWakeAsk(ref) {
90
+ if (!ref)
91
+ return null;
92
+ try {
93
+ return loadPolicy().wakeAsks?.[ref]?.member ?? null;
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
89
99
  export function localChoices(ref) {
90
100
  if (!ref)
91
101
  return null;
@@ -192,7 +202,8 @@ async function trayStatusOne(cfg) {
192
202
  const choices = localChoices(o.ref);
193
203
  const r = row(o, "decision", !from || from === cfg.member ? "your agent" : from, decisionPrompt(o.id, decisionTitle(o.body)));
194
204
  const own = new RegExp(`^${cfg.member.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")} (?:escalated|needs a decision):\\s*`, "i");
195
- return { ...r, detail: o.body.replace(own, ""), decider: cfg.member, ...(choices ? { choices } : {}) };
205
+ const wake = localWakeAsk(o.ref);
206
+ return { ...r, detail: o.body.replace(own, ""), decider: cfg.member, ...(choices ? { choices } : {}), ...(wake ? { wake } : {}) };
196
207
  }),
197
208
  ...owed.map((o) => row(o, "owed", "you", owedPrompt(decisionTitle(o.body), o.goal))),
198
209
  ...waiting.map((o) => {
package/dist/uninstall.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { execFileSync, spawnSync } from "node:child_process";
2
- import { existsSync, lstatSync, readFileSync, readdirSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
2
+ import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync, rmdirSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { platform } from "node:os";
4
4
  import { posix, win32 } from "node:path";
5
5
  import { expectedLauncher, windowsManagedRoots } from "./managed.js";
@@ -7,6 +7,40 @@ export const BEGIN_MARK = "# >>> pantheon >>>";
7
7
  export const END_MARK = "# <<< pantheon <<<";
8
8
  export const PROFILES = [".profile", ".bash_profile", ".bash_login", ".bashrc", ".zshrc", ".zprofile"];
9
9
  export const FISH_CONF = ".config/fish/conf.d/pantheon.fish";
10
+ export const ZSH_PROFILES = [".zshrc", ".zprofile"];
11
+ export function zshDir(home, env = process.env, ask = () => askZsh(env)) {
12
+ const z = (ask() ?? env.ZDOTDIR ?? "").replace(/\/+$/, "");
13
+ if (!z)
14
+ return home;
15
+ const rz = realDir(z), rh = realDir(home);
16
+ return rz && rh && rz.startsWith(`${rh === "/" ? "" : rh}/`) ? posix.join(home, rz.slice(rh.length)) : home;
17
+ }
18
+ function realDir(p) {
19
+ let d = posix.resolve(p);
20
+ const rest = [];
21
+ for (;;) {
22
+ try {
23
+ return posix.join(realpathSync(d), ...rest);
24
+ }
25
+ catch { }
26
+ const up = posix.dirname(d);
27
+ if (up === d)
28
+ return null;
29
+ rest.unshift(posix.basename(d));
30
+ d = up;
31
+ }
32
+ }
33
+ function askZsh(env) {
34
+ const mark = "__pantheon_zdotdir__";
35
+ try {
36
+ const out = execFileSync("zsh", ["-c", `printf "\\n${mark}%s\\n" "\${ZDOTDIR:-}"`], { encoding: "utf8", env, stdio: ["ignore", "pipe", "ignore"], timeout: 10_000 });
37
+ const line = out.split("\n").filter((l) => l.startsWith(mark)).at(-1);
38
+ return line === undefined ? null : line.slice(mark.length);
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
10
44
  export function withoutPathBlock(text) {
11
45
  const lines = text.match(/[^\n]*\n|[^\n]+$/g) ?? [];
12
46
  const bare = (l) => l.replace(/\n$/, "").replace(/\r$/, "");
@@ -161,8 +195,9 @@ export function removeManagedInstall(o) {
161
195
  }
162
196
  }
163
197
  else {
164
- for (const name of PROFILES) {
165
- const f = p.join(o.home, name);
198
+ const zdot = o.zdotdir ?? zshDir(o.home, env);
199
+ const files = [...PROFILES.map((n) => p.join(o.home, n)), ...(p.resolve(zdot) !== p.resolve(o.home) ? ZSH_PROFILES.map((n) => p.join(zdot, n)) : [])];
200
+ for (const f of files) {
166
201
  if (!fs.exists(f))
167
202
  continue;
168
203
  let text;
@@ -5,7 +5,7 @@ import { join, win32 } from "node:path";
5
5
  export const URI_SCHEME = "pantheon";
6
6
  export const URI_HOSTS = ["board", "done", "explain", "details"];
7
7
  const FORMS = {
8
- board: { required: [], optional: [] },
8
+ board: { required: [], optional: ["focus"] },
9
9
  done: { required: ["decision", "k"], optional: ["act", "space"] },
10
10
  explain: { required: ["decision", "k"], optional: ["act", "space"] },
11
11
  details: { required: ["act", "k"], optional: ["decision", "space"] },
@@ -15,6 +15,7 @@ export const URI_VALUE = {
15
15
  act: /^a_[A-Za-z0-9]{6,48}$/,
16
16
  space: /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/,
17
17
  k: /^[0-9a-f]{32}$/,
18
+ focus: /^[ao]_[A-Za-z0-9]{6,48}$/,
18
19
  };
19
20
  export const MAX_URI = 400;
20
21
  function canonical(host, params) {
@@ -201,7 +202,7 @@ export async function handleUri(args, d) {
201
202
  if (d.openBoard) {
202
203
  let ok = false;
203
204
  try {
204
- ok = await d.openBoard();
205
+ ok = await d.openBoard(p.params.focus);
205
206
  }
206
207
  catch {
207
208
  ok = false;
@@ -90,6 +90,23 @@ export function verifyGoal(g, teams, pinnedFp, memberKey, at = Date.now()) {
90
90
  at = Math.max(sig.ts, g.opened_at || 0);
91
91
  return signer(sig, team, g.opened_by, pinnedFp, memberKey, at, "goal");
92
92
  }
93
+ export function vouchedMove(m, newFp, teams, pinOf, admitted = () => true) {
94
+ const mv = m.moved;
95
+ if (!mv || mv.how !== "confirmed" || typeof mv.by !== "string" || mv.by === m.member)
96
+ return null;
97
+ const v = mv.vouch;
98
+ if (!v || typeof v !== "object" || v.from !== mv.by || v.act !== "NOTE")
99
+ return null;
100
+ const r = v.meta?.recovery;
101
+ if (!r || r.action !== "confirm" || r.member !== m.member || r.fp !== newFp)
102
+ return null;
103
+ if (r.from_fp !== pinOf(m.member)?.fp || !admitted(mv.by))
104
+ return null;
105
+ const pin = pinOf(mv.by);
106
+ if (!pin || pin.pending)
107
+ return null;
108
+ return verifyAct(v, teams, pin.fp, pin.key).trust === "verified" ? mv.by : null;
109
+ }
93
110
  export const trusted = (t) => t === "verified" || t === "verified-run";
94
111
  export function trustLabel(r, from) {
95
112
  switch (r.trust) {
@@ -1,13 +1,20 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { join, resolve, sep } from "node:path";
3
- import { holdsRepo } from "./repo-find.js";
3
+ import { holdsRepo, repoInside } from "./repo-find.js";
4
4
  import { CONFIG_DIR, configFor, loadAll, repoInfo } from "./config.js";
5
5
  import { Worker, workspaceOf } from "./worker.js";
6
+ import { folderReaderLine } from "./agents.js";
6
7
  export class WorkerFleet {
7
8
  opts;
8
9
  dupSaid = new Set();
9
10
  slots = new Map();
10
11
  stopped = false;
12
+ unservedSaid = new Set();
13
+ idleSaid = "";
14
+ keeper = null;
15
+ alertDue = new Map();
16
+ alertRun = null;
17
+ alertFailSaid = new Map();
11
18
  constructor(opts) {
12
19
  this.opts = opts;
13
20
  }
@@ -32,6 +39,13 @@ export class WorkerFleet {
32
39
  continue;
33
40
  }
34
41
  const ws = teamWorkspaces(f, m.id);
42
+ for (const u of unservedFolders(f, m.id, ws)) {
43
+ const key = `${m.id}|${u.path}|${u.why}`;
44
+ if (this.unservedSaid.has(key))
45
+ continue;
46
+ this.unservedSaid.add(key);
47
+ this.opts.log(`fleet: [${m.name}] ${u.path} is bound but not served: ${u.why}`);
48
+ }
35
49
  const home = ws.find((w) => w.kind === "repo") ?? ws[0];
36
50
  if (!home)
37
51
  continue;
@@ -39,11 +53,16 @@ export class WorkerFleet {
39
53
  wanted.set(m.id, { path: home.path, cfg: configFor(f, m, repo) });
40
54
  void several;
41
55
  }
56
+ if (wanted.size)
57
+ this.stopKeeper();
58
+ else
59
+ this.startKeeper(f);
60
+ this.alertChecks(f, Object.values(f.spaces ?? {}).filter((m) => (idCount.get(m.id) ?? 0) === 1 && !wanted.has(m.id) && this.keeper?.id !== m.id));
42
61
  for (const [id, w] of wanted) {
43
62
  if (this.slots.has(id))
44
63
  continue;
45
64
  const home = this.opts.home ?? CONFIG_DIR;
46
- const { pollMs: _p, readConfig: _r, makeWorker: _m, home: _h, ...rest } = this.opts;
65
+ const { pollMs: _p, readConfig: _r, makeWorker: _m, home: _h, alertsEveryMs: _a, ...rest } = this.opts;
47
66
  const wopts = {
48
67
  ...rest,
49
68
  project: w.path,
@@ -60,6 +79,8 @@ export class WorkerFleet {
60
79
  started.push(w.cfg.space);
61
80
  }
62
81
  }
82
+ else
83
+ this.stopKeeper();
63
84
  for (const [id, s] of this.slots) {
64
85
  if (wanted.has(id))
65
86
  continue;
@@ -69,6 +90,65 @@ export class WorkerFleet {
69
90
  }
70
91
  return { started, stopped };
71
92
  }
93
+ startKeeper(f) {
94
+ const teams = Object.values(f.spaces ?? {});
95
+ const m = (f.default_space ? f.spaces?.[f.default_space] : undefined) ?? teams[0];
96
+ if (!m) {
97
+ this.stopKeeper();
98
+ return;
99
+ }
100
+ if (this.keeper?.id === m.id)
101
+ return;
102
+ this.stopKeeper();
103
+ const home = this.opts.home ?? CONFIG_DIR;
104
+ const { pollMs: _p, readConfig: _r, makeWorker: _m, home: _h, alertsEveryMs: _a, ...rest } = this.opts;
105
+ const cfg = configFor(f, m, null);
106
+ const wopts = {
107
+ ...rest, housekeeping: true, once: false, project: home, workspaces: () => [],
108
+ scratch: join(home, "worktrees", encodeURIComponent(m.id)), home,
109
+ log: (l) => this.opts.log(`[${cfg.space}] ${l}`),
110
+ };
111
+ const worker = (this.opts.makeWorker ?? ((c, o) => new Worker(c, o)))(cfg, wopts);
112
+ const done = worker.run().catch((e) => this.opts.log(`[${cfg.space}] ! upkeep stopped: ${e.message}`))
113
+ .finally(() => { if (this.keeper?.worker === worker)
114
+ this.keeper = null; });
115
+ this.keeper = { id: m.id, worker, done, path: home, space: cfg.space };
116
+ }
117
+ alertChecks(f, teams) {
118
+ const ids = new Set(teams.map((m) => m.id));
119
+ for (const id of this.alertDue.keys())
120
+ if (!ids.has(id))
121
+ this.alertDue.delete(id);
122
+ if (this.alertRun || this.stopped)
123
+ return;
124
+ const now = Date.now();
125
+ const m = teams.filter((t) => (this.alertDue.get(t.id) ?? 0) <= now).sort((a, b) => (this.alertDue.get(a.id) ?? 0) - (this.alertDue.get(b.id) ?? 0))[0];
126
+ if (!m)
127
+ return;
128
+ this.alertDue.set(m.id, now + (this.opts.alertsEveryMs ?? 5 * 60_000));
129
+ const home = this.opts.home ?? CONFIG_DIR;
130
+ const { pollMs: _p, readConfig: _r, makeWorker: _m, home: _h, alertsEveryMs: _a, ...rest } = this.opts;
131
+ const cfg = configFor(f, m, null);
132
+ const wopts = {
133
+ ...rest, alertsOnly: true, once: false, project: home, workspaces: () => [],
134
+ scratch: join(home, "worktrees", encodeURIComponent(m.id)), home,
135
+ log: (l) => this.opts.log(`[${cfg.space}] ${l}`),
136
+ };
137
+ const worker = (this.opts.makeWorker ?? ((c, o) => new Worker(c, o)))(cfg, wopts);
138
+ this.alertRun = worker.run()
139
+ .then(() => { this.alertFailSaid.delete(m.id); })
140
+ .catch((e) => { const why = e.message.split("\n")[0] ?? ""; if (this.alertFailSaid.get(m.id) !== why) {
141
+ this.alertFailSaid.set(m.id, why);
142
+ this.opts.log(`[${cfg.space}] could not look for key changes or seat moves: ${why}`);
143
+ } })
144
+ .finally(() => { this.alertRun = null; });
145
+ }
146
+ stopKeeper() {
147
+ if (!this.keeper)
148
+ return;
149
+ this.keeper.worker.stop();
150
+ this.keeper = null;
151
+ }
72
152
  async run() {
73
153
  const poll = this.opts.pollMs ?? 5000;
74
154
  while (!this.stopped) {
@@ -77,18 +157,27 @@ export class WorkerFleet {
77
157
  this.opts.log(`fleet: worker for "${s}" started`);
78
158
  for (const s of stopped)
79
159
  this.opts.log(`fleet: worker for "${s}" stopped — binding gone`);
80
- if (!this.slots.size && !started.length)
81
- this.opts.log(`fleet: nothing bound on this machine — bind a repo or a docs folder with \`pantheon use <team> --here\` from inside it`);
160
+ const idle = !this.slots.size && !started.length
161
+ ? (this.unservedSaid.size
162
+ ? `fleet: nothing this worker can serve — the folders bound here are not opened to teammates' runs (said above, with why)`
163
+ : `fleet: nothing bound on this machine — bind a repo or a docs folder with \`pantheon use <team> --here\` from inside it; it is served as soon as it is, no restart`)
164
+ : "";
165
+ if (idle && idle !== this.idleSaid)
166
+ this.opts.log(idle);
167
+ this.idleSaid = idle;
82
168
  await new Promise((r) => setTimeout(r, poll));
83
169
  if (this.opts.once && this.slots.size === 0)
84
170
  break;
85
171
  }
172
+ const keeper = this.keeper;
173
+ this.stopKeeper();
86
174
  for (const s of this.slots.values())
87
175
  s.worker.stop();
88
- await Promise.allSettled([...this.slots.values()].map((s) => s.done));
176
+ await Promise.allSettled([...this.slots.values()].map((s) => s.done).concat(keeper ? [keeper.done] : [], this.alertRun ? [this.alertRun] : []));
89
177
  }
90
178
  stop() {
91
179
  this.stopped = true;
180
+ this.keeper?.worker.stop();
92
181
  for (const s of this.slots.values())
93
182
  s.worker.stop();
94
183
  }
@@ -102,4 +191,50 @@ export function teamWorkspaces(f, spaceId) {
102
191
  const seen = new Set();
103
192
  return [...repos, ...folders].filter((w) => (seen.has(w.path) ? false : (seen.add(w.path), true)));
104
193
  }
194
+ export function unservedFolders(f, spaceId, served = teamWorkspaces(f, spaceId), inside = (d) => repoInside(d)) {
195
+ const all = Object.values(f?.bindings ?? {}).map((b) => resolve(b.path));
196
+ const out = [];
197
+ for (const [k, b] of Object.entries(f?.bindings ?? {})) {
198
+ if (b.space !== spaceId || !k.startsWith("d_"))
199
+ continue;
200
+ if (served.some((w) => resolve(w.path) === resolve(b.path)))
201
+ continue;
202
+ if (!existsSync(b.path)) {
203
+ out.push({ path: b.path, why: "it is not there any more — bind the folder again from where it is now (pantheon use <team> --here)" });
204
+ continue;
205
+ }
206
+ const nested = all.find((p) => p !== resolve(b.path) && p.startsWith(`${resolve(b.path)}${sep}`));
207
+ if (nested) {
208
+ out.push({ path: b.path, why: `another bound folder is inside it (${nested}), so it is not opened whole — the one inside is served` });
209
+ continue;
210
+ }
211
+ const r = inside(b.path);
212
+ if (r === "too-large")
213
+ out.push({ path: b.path, why: "it is too large to check for repos inside it (over 20,000 files and folders), so it is not opened to teammates' runs — bind the folder of documents itself, not one above it" });
214
+ else if (r)
215
+ out.push({ path: b.path, why: `it holds a git repo (${r}), and a folder with repos in it is never opened to a teammate's run — bind the repo itself (pantheon use <team> --here inside it), or a folder of documents only` });
216
+ }
217
+ return out;
218
+ }
219
+ export function workerServesReport(f, available) {
220
+ const out = [];
221
+ const teams = Object.values(f?.spaces ?? {});
222
+ let folders = false;
223
+ for (const m of teams) {
224
+ const ws = teamWorkspaces(f, m.id);
225
+ const lead = teams.length > 1 ? `${m.name}: ` : "";
226
+ if (ws.length)
227
+ out.push({ line: `serves ${lead}${ws.map((w) => `${w.name}${w.kind === "folder" ? " (folder)" : ""}`).join(", ")}`, warn: false });
228
+ for (const u of unservedFolders(f, m.id, ws))
229
+ out.push({ line: `not served ${lead}${u.path} — ${u.why}`, warn: true });
230
+ folders ||= ws.some((w) => w.kind === "folder");
231
+ }
232
+ if (teams.length && !out.length)
233
+ out.push({ line: "serves nothing yet — teammates' requests wait for you. Bind a repo or docs folder from inside it: pantheon use <team> --here (served at once, no restart)", warn: true });
234
+ if (folders) {
235
+ const r = folderReaderLine(available);
236
+ out.push({ line: `folders ${r.line}`, warn: !r.ok });
237
+ }
238
+ return out;
239
+ }
105
240
  //# sourceMappingURL=worker-fleet.js.map