@join-pantheon/cli 0.19.1 → 0.20.0

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.
@@ -1,4 +1,5 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
2
+ import { randomBytes } from "node:crypto";
2
3
  import { createHash, timingSafeEqual } from "node:crypto";
3
4
  import { dirname, join } from "node:path";
4
5
  import { fingerprint } from "./identity.js";
@@ -9,6 +10,8 @@ export class Allowlist {
9
10
  entries = new Map();
10
11
  invites = new Map();
11
12
  tokenHash = null;
13
+ id = null;
14
+ repos = new Map();
12
15
  file;
13
16
  constructor(dir, space) {
14
17
  this.file = join(dir, `${space}.members.json`);
@@ -20,18 +23,41 @@ export class Allowlist {
20
23
  for (const i of doc.invites ?? [])
21
24
  this.invites.set(i.token, i);
22
25
  this.tokenHash = doc.tokenHash ?? null;
26
+ this.id = doc.id ?? null;
27
+ for (const r of doc.repos ?? [])
28
+ this.repos.set(r.fingerprint, r);
23
29
  }
24
30
  }
25
31
  flush() {
26
32
  mkdirSync(dirname(this.file), { recursive: true });
27
33
  const tmp = `${this.file}.tmp`;
28
34
  writeFileSync(tmp, JSON.stringify({
35
+ ...(this.id ? { id: this.id } : {}),
29
36
  members: [...this.entries.values()],
30
37
  invites: [...this.invites.values()],
31
38
  ...(this.tokenHash ? { tokenHash: this.tokenHash } : {}),
39
+ ...(this.repos.size ? { repos: [...this.repos.values()] } : {}),
32
40
  }, null, 2) + "\n", { mode: 0o600 });
33
41
  renameSync(tmp, this.file);
34
42
  }
43
+ peekId() { return this.id; }
44
+ spaceId() {
45
+ if (!this.id) {
46
+ this.id = `sp_${randomBytes(9).toString("base64url")}`;
47
+ this.flush();
48
+ }
49
+ return this.id;
50
+ }
51
+ repoList() { return [...this.repos.values()]; }
52
+ repoCount() { return this.repos.size; }
53
+ hasRepo(fingerprint) { return this.repos.has(fingerprint); }
54
+ addRepo(fingerprint, origin, by) {
55
+ if (this.repos.has(fingerprint))
56
+ return false;
57
+ this.repos.set(fingerprint, { fingerprint, origin, added_by: by, at: Date.now() });
58
+ this.flush();
59
+ return true;
60
+ }
35
61
  tokenMatches(token) {
36
62
  if (!this.tokenHash)
37
63
  return null;
package/dist/store.js CHANGED
@@ -1,9 +1,11 @@
1
1
  import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
+ import { emptyRefusals } from "./admin-stats.js";
3
4
  export class StateStore {
4
5
  file;
5
6
  timer = null;
6
7
  dirty = false;
8
+ refusals = emptyRefusals();
7
9
  constructor(dir, space) {
8
10
  this.file = join(dir, `${space}.state.json`);
9
11
  }
@@ -23,6 +25,8 @@ export class StateStore {
23
25
  }
24
26
  for (const p of snap.profiles ?? [])
25
27
  profiles.set(p.member, p);
28
+ if (snap.refusals)
29
+ this.refusals = { total: snap.refusals.total ?? 0, by_code: snap.refusals.by_code ?? {}, recent: snap.refusals.recent ?? [], stamps: snap.refusals.stamps ?? [] };
26
30
  }
27
31
  catch {
28
32
  }
@@ -46,6 +50,7 @@ export class StateStore {
46
50
  goals: [...goals.values()],
47
51
  obligations: [...obligations.values()],
48
52
  profiles: [...profiles.values()],
53
+ ...(this.refusals.total ? { refusals: this.refusals } : {}),
49
54
  saved_at: Date.now(),
50
55
  };
51
56
  mkdirSync(dirname(this.file), { recursive: true });
package/dist/tray.js CHANGED
@@ -3,7 +3,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "n
3
3
  import { platform } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
- import { CONFIG_DIR, load } from "./config.js";
6
+ import { CONFIG_DIR, load, loadAll, configFor } from "./config.js";
7
7
  import { workerProc, notifierProc } from "./machine.js";
8
8
  import { agentHookStates } from "./notify.js";
9
9
  import { requireDeveloperTools } from "./notifier.js";
@@ -72,6 +72,27 @@ async function getJson(url) {
72
72
  }
73
73
  }
74
74
  export async function trayStatus(cfg = load()) {
75
+ const f = loadAll();
76
+ const ms = Object.values(f?.spaces ?? {});
77
+ if (f && ms.length > 1) {
78
+ const parts = await Promise.all(ms.map((m) => trayStatusOne(configFor(f, m, null))));
79
+ const ok = parts.filter((p) => p.configured);
80
+ if (!ok.length)
81
+ return parts[0];
82
+ const [first] = ok;
83
+ return {
84
+ ...first,
85
+ space: ok.map((p) => p.space).join(" · "),
86
+ relay_ok: ok.every((p) => p.relay_ok),
87
+ online: ok.reduce((n, p) => n + (p.online ?? 0), 0),
88
+ members: ok.reduce((n, p) => n + (p.members ?? 0), 0),
89
+ decisions: ok.flatMap((p) => (p.decisions ?? []).map((d) => ({ ...d, body: `[${p.space}] ${d.body}` }))),
90
+ decisions_prompt: allDecisionsPrompt(ok.reduce((n, p) => n + (p.decisions?.length ?? 0), 0)),
91
+ };
92
+ }
93
+ return trayStatusOne(cfg);
94
+ }
95
+ async function trayStatusOne(cfg) {
75
96
  if (!cfg)
76
97
  return { configured: false, version: VERSION };
77
98
  const base = cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
@@ -0,0 +1,84 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CONFIG_DIR, configFor, loadAll, repoInfo } from "./config.js";
4
+ import { Worker } from "./worker.js";
5
+ export class WorkerFleet {
6
+ opts;
7
+ slots = new Map();
8
+ stopped = false;
9
+ constructor(opts) {
10
+ this.opts = opts;
11
+ }
12
+ get running() {
13
+ return [...this.slots.values()].map((s) => ({ space: s.space, path: s.path }));
14
+ }
15
+ reconcile() {
16
+ const f = (this.opts.readConfig ?? loadAll)();
17
+ const started = [], stopped = [];
18
+ const wanted = new Map();
19
+ if (f) {
20
+ const several = Object.keys(f.spaces ?? {}).length > 1;
21
+ for (const b of Object.values(f.bindings ?? {})) {
22
+ const m = f.spaces?.[b.space];
23
+ if (!m || !existsSync(join(b.path, ".git")))
24
+ continue;
25
+ if (wanted.has(m.id))
26
+ continue;
27
+ const repo = repoInfo(b.path);
28
+ wanted.set(m.id, { path: b.path, cfg: configFor(f, m, repo) });
29
+ void several;
30
+ }
31
+ for (const [id, w] of wanted) {
32
+ if (this.slots.has(id))
33
+ continue;
34
+ const home = this.opts.home ?? CONFIG_DIR;
35
+ const { pollMs: _p, readConfig: _r, makeWorker: _m, home: _h, ...rest } = this.opts;
36
+ const wopts = {
37
+ ...rest,
38
+ project: w.path,
39
+ scratch: join(home, "worktrees", encodeURIComponent(id)),
40
+ home,
41
+ requirePersonaSpaces: several,
42
+ log: (l) => this.opts.log(`[${w.cfg.space}] ${l}`),
43
+ };
44
+ const worker = (this.opts.makeWorker ?? ((c, o) => new Worker(c, o)))(w.cfg, wopts);
45
+ const done = worker.run().catch((e) => this.opts.log(`[${w.cfg.space}] ! worker stopped: ${e.message}`)).finally(() => { if (this.slots.get(id)?.worker === worker)
46
+ this.slots.delete(id); });
47
+ this.slots.set(id, { worker, done, path: w.path, space: w.cfg.space });
48
+ started.push(w.cfg.space);
49
+ }
50
+ }
51
+ for (const [id, s] of this.slots) {
52
+ if (wanted.has(id))
53
+ continue;
54
+ s.worker.stop();
55
+ this.slots.delete(id);
56
+ stopped.push(s.space);
57
+ }
58
+ return { started, stopped };
59
+ }
60
+ async run() {
61
+ const poll = this.opts.pollMs ?? 5000;
62
+ while (!this.stopped) {
63
+ const { started, stopped } = this.reconcile();
64
+ for (const s of started)
65
+ this.opts.log(`fleet: worker for "${s}" started`);
66
+ for (const s of stopped)
67
+ this.opts.log(`fleet: worker for "${s}" stopped — binding gone`);
68
+ if (!this.slots.size && !started.length)
69
+ this.opts.log(`fleet: no bound repos on this machine — bind one with \`pantheon use <team> --here\` from its checkout`);
70
+ await new Promise((r) => setTimeout(r, poll));
71
+ if (this.opts.once && this.slots.size === 0)
72
+ break;
73
+ }
74
+ for (const s of this.slots.values())
75
+ s.worker.stop();
76
+ await Promise.allSettled([...this.slots.values()].map((s) => s.done));
77
+ }
78
+ stop() {
79
+ this.stopped = true;
80
+ for (const s of this.slots.values())
81
+ s.worker.stop();
82
+ }
83
+ }
84
+ //# sourceMappingURL=worker-fleet.js.map
package/dist/worker.js CHANGED
@@ -23,8 +23,7 @@ const isRepo = (cwd) => {
23
23
  return false;
24
24
  }
25
25
  };
26
- export function prepareWorktree(project, scratch, goal, log) {
27
- const branch = `pantheon/${goal}`;
26
+ export function prepareWorktree(project, scratch, goal, log, branch = `pantheon/${goal}`) {
28
27
  const worktree = join(scratch, goal);
29
28
  if (existsSync(join(worktree, ".git")))
30
29
  return { branch, worktree };
@@ -136,12 +135,35 @@ DO THIS
136
135
  There is no human in this session. Do not ask for approval; you will not get an answer.
137
136
  Act, reply if a reply is owed, then finish.`;
138
137
  }
138
+ let fleetLock = null;
139
139
  export class Worker {
140
140
  cfg;
141
141
  opts;
142
142
  running = false;
143
143
  done = [];
144
144
  stopped = false;
145
+ personasOnDisk = 0;
146
+ personas() {
147
+ const all = loadPersonas(this.opts.project, this.opts.home);
148
+ this.personasOnDisk = all.length;
149
+ const mine = all.filter((p) => {
150
+ if (p.spaces && p.spaces.length)
151
+ return p.spaces.includes(this.cfg.space) || (!!this.cfg.spaceId && p.spaces.includes(this.cfg.spaceId));
152
+ if (p.scope === "user" && this.opts.requirePersonaSpaces)
153
+ return false;
154
+ return true;
155
+ });
156
+ for (const p of all) {
157
+ if (mine.includes(p) || this.warnedPersona.has(p.file))
158
+ continue;
159
+ this.warnedPersona.add(p.file);
160
+ this.opts.log(p.spaces?.length
161
+ ? ` persona ${p.name} skipped — its spaces: line does not name "${this.cfg.space}"`
162
+ : ` persona ${p.name} skipped — a user-scope persona on a machine in several teams needs a spaces: line`);
163
+ }
164
+ return mine;
165
+ }
166
+ warnedPersona = new Set();
145
167
  skipLogged = new Set();
146
168
  contQueue = [];
147
169
  contDone = new Set();
@@ -160,16 +182,19 @@ export class Worker {
160
182
  if (this.runHomeDir)
161
183
  return this.runHomeDir;
162
184
  const base = this.opts.home ?? process.env.PANTHEON_HOME ?? join(homedir(), ".pantheon");
163
- const dir = join(base, "run-homes", encodeURIComponent(this.cfg.space));
185
+ const dir = join(base, "run-homes", encodeURIComponent(this.cfg.spaceId ?? this.cfg.space));
164
186
  mkdirSync(dir, { recursive: true, mode: 0o700 });
165
187
  writeFileSync(join(dir, "config.json"), JSON.stringify(this.cfg, null, 2) + "\n", { mode: 0o600 });
166
188
  this.runHomeDir = dir;
167
189
  return dir;
168
190
  }
169
191
  selectPersona(client, o) {
170
- const personas = loadPersonas(this.opts.project, this.opts.home);
171
- if (!personas.length && !this.opts.persona)
172
- return {};
192
+ const personas = this.personas();
193
+ if (!personas.length && !this.opts.persona) {
194
+ return this.personasOnDisk
195
+ ? { skip: `personas exist on this machine but none covers "${this.cfg.space}" — add \`spaces: ${this.cfg.space}\` to one, or pantheon persona init default in this repo` }
196
+ : {};
197
+ }
173
198
  const ctx = {
174
199
  ...(client.recentFrom(o.ref) ? { from: client.recentFrom(o.ref) } : {}),
175
200
  kind: o.kind,
@@ -207,7 +232,7 @@ export class Worker {
207
232
  this.opts.log(` scratch ${resolve(this.opts.scratch)}`);
208
233
  this.opts.log(` agent ${agentLabel}`);
209
234
  this.opts.log(` policy ${this.opts.maxRunsPerHour} runs/hour, one at a time, ${this.opts.allowBash ? "bash allowed" : "edits only (bash gated)"}`);
210
- const personas = loadPersonas(this.opts.project, this.opts.home);
235
+ const personas = this.personas();
211
236
  if (this.opts.persona && !personaByName(personas, this.opts.persona)) {
212
237
  client.close();
213
238
  throw new Error(`persona "${this.opts.persona}" not found — create it with: pantheon persona init ${this.opts.persona}`);
@@ -236,6 +261,10 @@ export class Worker {
236
261
  await this.maintain(client, arrived);
237
262
  if (this.running)
238
263
  continue;
264
+ if (fleetLock && fleetLock !== this) {
265
+ this.writeState("watching", `waiting: a run for "${fleetLock.cfg.space}" is in progress`);
266
+ continue;
267
+ }
239
268
  const me = client.roster.find((m) => m.id === this.cfg.member);
240
269
  const liveInteractive = me?.sessions.some((s) => s.role === "interactive") ?? false;
241
270
  const grace = liveInteractive ? this.opts.graceMs : Math.min(this.opts.graceMs, 2_000);
@@ -349,6 +378,7 @@ export class Worker {
349
378
  }
350
379
  }
351
380
  this.running = true;
381
+ fleetLock = this;
352
382
  this.writeState("running", candidate ? `${candidate.kind} ${candidate.id}` : `CONTINUE ${continuation.id}`);
353
383
  const job = candidate
354
384
  ? await this.handle(client, candidate, persona, policy)
@@ -361,6 +391,8 @@ export class Worker {
361
391
  }
362
392
  }
363
393
  this.running = false;
394
+ if (fleetLock === this)
395
+ fleetLock = null;
364
396
  this.writeState("watching", "waiting for work");
365
397
  if (this.opts.once)
366
398
  break;
@@ -399,10 +431,10 @@ export class Worker {
399
431
  if (this.opts.allowBash && !policy.allowBash && persona) {
400
432
  this.opts.log(` bash off for this run — persona "${persona.name}" says allow_bash: false`);
401
433
  }
402
- let branch = `pantheon/${goal}`;
434
+ let branch = `pantheon/${this.cfg.space}/${goal}`;
403
435
  let worktree = join(this.opts.scratch, goal);
404
436
  try {
405
- ({ branch, worktree } = prepareWorktree(this.opts.project, this.opts.scratch, goal, this.opts.log));
437
+ ({ branch, worktree } = prepareWorktree(this.opts.project, this.opts.scratch, goal, this.opts.log, branch));
406
438
  }
407
439
  catch (e) {
408
440
  const detail = `could not prepare a worktree: ${e.message}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@join-pantheon/cli",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
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": {