@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.
- package/README.md +24 -0
- package/dist/accounts.js +61 -0
- package/dist/admin-stats.js +116 -0
- package/dist/agents.js +2 -0
- package/dist/cli.js +337 -89
- package/dist/client.js +31 -1
- package/dist/config.js +229 -23
- package/dist/doctor.js +13 -1
- package/dist/mcp.js +24 -15
- package/dist/notifier.js +1 -1
- package/dist/persona.js +4 -1
- package/dist/profile.js +28 -4
- package/dist/protocol.js +10 -1
- package/dist/quickstart.js +35 -6
- package/dist/relay.js +524 -39
- package/dist/roster-file.js +26 -0
- package/dist/store.js +5 -0
- package/dist/tray.js +22 -1
- package/dist/worker-fleet.js +84 -0
- package/dist/worker.js +41 -9
- package/package.json +1 -1
package/dist/roster-file.js
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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.
|
|
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": {
|