@join-pantheon/cli 0.19.1 → 0.21.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 +38 -1
- package/dist/accounts.js +167 -1
- package/dist/admin-stats.js +116 -0
- package/dist/agents.js +6 -0
- package/dist/cli.js +417 -94
- package/dist/client.js +52 -7
- package/dist/config.js +243 -23
- package/dist/doctor.js +22 -1
- package/dist/mcp.js +67 -24
- package/dist/notifier.js +27 -7
- package/dist/notify.js +1 -1
- package/dist/pending.js +3 -1
- package/dist/persona.js +4 -1
- package/dist/plans.js +46 -0
- package/dist/profile.js +33 -4
- package/dist/protocol.js +22 -1
- package/dist/quickstart.js +37 -7
- package/dist/relay.js +732 -49
- package/dist/roster-file.js +39 -0
- package/dist/setup-guide.js +238 -0
- package/dist/store.js +5 -0
- package/dist/tray.js +22 -1
- package/dist/untrusted.js +16 -3
- package/dist/worker-fleet.js +84 -0
- package/dist/worker.js +116 -19
- package/package.json +2 -2
package/dist/worker.js
CHANGED
|
@@ -10,6 +10,7 @@ import { applyAnswer, loadPolicy, mayAsk, needsSelfUpgrade, recordAsk, savePolic
|
|
|
10
10
|
import { agentHookStates, repairHook } from "./notify.js";
|
|
11
11
|
import { fence, assess } from "./untrusted.js";
|
|
12
12
|
import { allowed, recordRun } from "./spend.js";
|
|
13
|
+
import { loadProfile } from "./profile.js";
|
|
13
14
|
import { clip, workerDoneNotice } from "./brief.js";
|
|
14
15
|
import { loadPersonas, matchPersona, personaByName, personaMatches, effectivePolicy, personaBrief, describePersona, } from "./persona.js";
|
|
15
16
|
import { isContinuationFor, LIMITS, shouldDeferToHuman } from "./protocol.js";
|
|
@@ -23,26 +24,64 @@ const isRepo = (cwd) => {
|
|
|
23
24
|
return false;
|
|
24
25
|
}
|
|
25
26
|
};
|
|
26
|
-
export function prepareWorktree(project, scratch, goal, log) {
|
|
27
|
-
const branch = `pantheon/${goal}`;
|
|
27
|
+
export function prepareWorktree(project, scratch, goal, log, branch = `pantheon/${goal}`, base = null) {
|
|
28
28
|
const worktree = join(scratch, goal);
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
29
|
+
const reuse = existsSync(join(worktree, ".git"));
|
|
30
|
+
if (!reuse)
|
|
31
|
+
mkdirSync(scratch, { recursive: true });
|
|
32
32
|
if (!isRepo(project)) {
|
|
33
|
+
if (reuse)
|
|
34
|
+
return { branch, worktree, baseRef: "", baseSha: "" };
|
|
33
35
|
mkdirSync(worktree, { recursive: true });
|
|
34
36
|
log(` scratch ${worktree} — ${project} is not a git repo, so there is no worktree and nothing to deliver`);
|
|
35
|
-
return { branch: "", worktree };
|
|
37
|
+
return { branch: "", worktree, baseRef: "", baseSha: "" };
|
|
36
38
|
}
|
|
39
|
+
if (!reuse) {
|
|
40
|
+
try {
|
|
41
|
+
git(project, "fetch", "--quiet", "origin");
|
|
42
|
+
}
|
|
43
|
+
catch { }
|
|
44
|
+
}
|
|
45
|
+
const exists = (ref) => { try {
|
|
46
|
+
git(project, "rev-parse", "--verify", "--quiet", `${ref}^{commit}`);
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return false;
|
|
51
|
+
} };
|
|
52
|
+
const remoteHead = () => { try {
|
|
53
|
+
return git(project, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD");
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return null;
|
|
57
|
+
} };
|
|
58
|
+
const candidates = [base ? `origin/${base}` : null, base, remoteHead()].filter((c) => !!c);
|
|
59
|
+
const baseRef = candidates.find(exists) ?? git(project, "rev-parse", "--abbrev-ref", "HEAD");
|
|
60
|
+
const baseSha = git(project, "rev-parse", baseRef);
|
|
61
|
+
if (reuse)
|
|
62
|
+
return { branch, worktree, baseRef, baseSha };
|
|
37
63
|
const has = git(project, "branch", "--list", branch).length > 0;
|
|
38
|
-
const base = git(project, "rev-parse", "--abbrev-ref", "HEAD");
|
|
39
64
|
if (has)
|
|
40
65
|
git(project, "worktree", "add", worktree, branch);
|
|
41
66
|
else
|
|
42
|
-
git(project, "worktree", "add", worktree, "-b", branch,
|
|
67
|
+
git(project, "worktree", "add", worktree, "-b", branch, baseRef);
|
|
43
68
|
excludeRunFiles(worktree);
|
|
44
|
-
log(` worktree ${worktree} on ${branch} (from ${
|
|
45
|
-
return { branch, worktree };
|
|
69
|
+
log(` worktree ${worktree} on ${branch} (from ${baseRef} ${baseSha.slice(0, 7)})`);
|
|
70
|
+
return { branch, worktree, baseRef, baseSha };
|
|
71
|
+
}
|
|
72
|
+
export function branchName(space, goalId, title) {
|
|
73
|
+
const slug = (title ?? "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "");
|
|
74
|
+
return `pantheon/${space}/${slug ? `${slug}-${goalId.slice(-4)}` : goalId}`;
|
|
75
|
+
}
|
|
76
|
+
export function baseNote(baseRef, baseSha) {
|
|
77
|
+
if (!baseSha)
|
|
78
|
+
return "";
|
|
79
|
+
return `
|
|
80
|
+
|
|
81
|
+
YOUR BRANCH STARTS AT ${baseSha.slice(0, 12)} (${baseRef}, fetched from origin just now).
|
|
82
|
+
Keep it to this one purpose: no unrelated commits may ride along, and never rebase it onto
|
|
83
|
+
another branch's unmerged work. When you DELIVER, name this base too (pantheon_send base=${baseSha.slice(0, 12)})
|
|
84
|
+
so your peer can check that nothing else came with it.`;
|
|
46
85
|
}
|
|
47
86
|
const RUN_DROPPINGS = [".pantheon-task.md", ".pantheon-run.log", ".pantheon-mcp.json", ".pantheon/"];
|
|
48
87
|
export function excludeRunFiles(worktree) {
|
|
@@ -136,22 +175,50 @@ DO THIS
|
|
|
136
175
|
There is no human in this session. Do not ask for approval; you will not get an answer.
|
|
137
176
|
Act, reply if a reply is owed, then finish.`;
|
|
138
177
|
}
|
|
178
|
+
let fleetLock = null;
|
|
139
179
|
export class Worker {
|
|
140
180
|
cfg;
|
|
141
181
|
opts;
|
|
142
182
|
running = false;
|
|
143
183
|
done = [];
|
|
144
184
|
stopped = false;
|
|
185
|
+
personasOnDisk = 0;
|
|
186
|
+
personas() {
|
|
187
|
+
const all = loadPersonas(this.opts.project, this.opts.home);
|
|
188
|
+
this.personasOnDisk = all.length;
|
|
189
|
+
const mine = all.filter((p) => {
|
|
190
|
+
if (p.spaces && p.spaces.length)
|
|
191
|
+
return p.spaces.includes(this.cfg.space) || (!!this.cfg.spaceId && p.spaces.includes(this.cfg.spaceId));
|
|
192
|
+
if (p.scope === "user" && this.opts.requirePersonaSpaces)
|
|
193
|
+
return false;
|
|
194
|
+
return true;
|
|
195
|
+
});
|
|
196
|
+
for (const p of all) {
|
|
197
|
+
if (mine.includes(p) || this.warnedPersona.has(p.file))
|
|
198
|
+
continue;
|
|
199
|
+
this.warnedPersona.add(p.file);
|
|
200
|
+
this.opts.log(p.spaces?.length
|
|
201
|
+
? ` persona ${p.name} skipped — its spaces: line does not name "${this.cfg.space}"`
|
|
202
|
+
: ` persona ${p.name} skipped — a user-scope persona on a machine in several teams needs a spaces: line`);
|
|
203
|
+
}
|
|
204
|
+
return mine;
|
|
205
|
+
}
|
|
206
|
+
warnedPersona = new Set();
|
|
145
207
|
skipLogged = new Set();
|
|
146
208
|
contQueue = [];
|
|
147
209
|
contDone = new Set();
|
|
148
210
|
failedRuns = new Map();
|
|
149
211
|
lastState = "";
|
|
150
212
|
busyObservedSince = null;
|
|
213
|
+
client = null;
|
|
214
|
+
lastHold = "";
|
|
151
215
|
constructor(cfg, opts) {
|
|
152
216
|
this.cfg = cfg;
|
|
153
217
|
this.opts = opts;
|
|
154
218
|
}
|
|
219
|
+
repoBase() {
|
|
220
|
+
return this.client?.repos.find((r) => r.fingerprint === this.cfg.repo)?.base ?? null;
|
|
221
|
+
}
|
|
155
222
|
get jobs() {
|
|
156
223
|
return this.done;
|
|
157
224
|
}
|
|
@@ -160,16 +227,19 @@ export class Worker {
|
|
|
160
227
|
if (this.runHomeDir)
|
|
161
228
|
return this.runHomeDir;
|
|
162
229
|
const base = this.opts.home ?? process.env.PANTHEON_HOME ?? join(homedir(), ".pantheon");
|
|
163
|
-
const dir = join(base, "run-homes", encodeURIComponent(this.cfg.space));
|
|
230
|
+
const dir = join(base, "run-homes", encodeURIComponent(this.cfg.spaceId ?? this.cfg.space));
|
|
164
231
|
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
165
232
|
writeFileSync(join(dir, "config.json"), JSON.stringify(this.cfg, null, 2) + "\n", { mode: 0o600 });
|
|
166
233
|
this.runHomeDir = dir;
|
|
167
234
|
return dir;
|
|
168
235
|
}
|
|
169
236
|
selectPersona(client, o) {
|
|
170
|
-
const personas =
|
|
171
|
-
if (!personas.length && !this.opts.persona)
|
|
172
|
-
return
|
|
237
|
+
const personas = this.personas();
|
|
238
|
+
if (!personas.length && !this.opts.persona) {
|
|
239
|
+
return this.personasOnDisk
|
|
240
|
+
? { 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` }
|
|
241
|
+
: {};
|
|
242
|
+
}
|
|
173
243
|
const ctx = {
|
|
174
244
|
...(client.recentFrom(o.ref) ? { from: client.recentFrom(o.ref) } : {}),
|
|
175
245
|
kind: o.kind,
|
|
@@ -193,6 +263,7 @@ export class Worker {
|
|
|
193
263
|
async run() {
|
|
194
264
|
const client = new PantheonClient(this.cfg, undefined, { role: "worker" });
|
|
195
265
|
await client.connect();
|
|
266
|
+
this.client = client;
|
|
196
267
|
const agentLabel = this.opts.agentCmd
|
|
197
268
|
? this.opts.agentCmd
|
|
198
269
|
: (() => { try {
|
|
@@ -207,7 +278,7 @@ export class Worker {
|
|
|
207
278
|
this.opts.log(` scratch ${resolve(this.opts.scratch)}`);
|
|
208
279
|
this.opts.log(` agent ${agentLabel}`);
|
|
209
280
|
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 =
|
|
281
|
+
const personas = this.personas();
|
|
211
282
|
if (this.opts.persona && !personaByName(personas, this.opts.persona)) {
|
|
212
283
|
client.close();
|
|
213
284
|
throw new Error(`persona "${this.opts.persona}" not found — create it with: pantheon persona init ${this.opts.persona}`);
|
|
@@ -236,6 +307,10 @@ export class Worker {
|
|
|
236
307
|
await this.maintain(client, arrived);
|
|
237
308
|
if (this.running)
|
|
238
309
|
continue;
|
|
310
|
+
if (fleetLock && fleetLock !== this) {
|
|
311
|
+
this.writeState("watching", `waiting: a run for "${fleetLock.cfg.space}" is in progress`);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
239
314
|
const me = client.roster.find((m) => m.id === this.cfg.member);
|
|
240
315
|
const liveInteractive = me?.sessions.some((s) => s.role === "interactive") ?? false;
|
|
241
316
|
const grace = liveInteractive ? this.opts.graceMs : Math.min(this.opts.graceMs, 2_000);
|
|
@@ -295,6 +370,12 @@ export class Worker {
|
|
|
295
370
|
: me.work?.idle_since
|
|
296
371
|
? `your session went idle ${Math.round((Date.now() - me.work.idle_since) / 1000)}s ago (< ${Math.round(this.opts.idleMs / 1000)}s)`
|
|
297
372
|
: "you have a live session and no activity signal (no wake-up hook on this machine — pantheon hook install)";
|
|
373
|
+
const until = !me.work?.busy && me.work?.idle_since ? me.work.idle_since + this.opts.idleMs : undefined;
|
|
374
|
+
const holdKey = `${me.work?.busy ? "busy" : "idle"}:${until ?? 0}`;
|
|
375
|
+
if (this.lastHold !== holdKey) {
|
|
376
|
+
this.lastHold = holdKey;
|
|
377
|
+
this.client?.reportWorkerState("holding", me.work?.busy ? "a turn is in flight in their live session" : me.work?.idle_since ? `their session went idle recently; waits ${Math.round(this.opts.idleMs / 1000)}s of quiet` : "their live session gave no activity signal", until);
|
|
378
|
+
}
|
|
298
379
|
if (!this.skipLogged.has(`defer:${id}`)) {
|
|
299
380
|
this.skipLogged.add(`defer:${id}`);
|
|
300
381
|
this.opts.log(`- holding ${id}: ${why} — the human wins while present`);
|
|
@@ -349,11 +430,13 @@ export class Worker {
|
|
|
349
430
|
}
|
|
350
431
|
}
|
|
351
432
|
this.running = true;
|
|
433
|
+
fleetLock = this;
|
|
352
434
|
this.writeState("running", candidate ? `${candidate.kind} ${candidate.id}` : `CONTINUE ${continuation.id}`);
|
|
353
435
|
const job = candidate
|
|
354
436
|
? await this.handle(client, candidate, persona, policy)
|
|
355
437
|
: await this.handleContinuation(client, continuation, persona, policy);
|
|
356
438
|
this.done.push(job);
|
|
439
|
+
this.client?.reportWorkerState("idle");
|
|
357
440
|
if (candidate && !job.ok) {
|
|
358
441
|
this.failedRuns.set(candidate.id, (this.failedRuns.get(candidate.id) ?? 0) + 1);
|
|
359
442
|
if (this.failedRuns.size > 200) {
|
|
@@ -361,6 +444,8 @@ export class Worker {
|
|
|
361
444
|
}
|
|
362
445
|
}
|
|
363
446
|
this.running = false;
|
|
447
|
+
if (fleetLock === this)
|
|
448
|
+
fleetLock = null;
|
|
364
449
|
this.writeState("watching", "waiting for work");
|
|
365
450
|
if (this.opts.once)
|
|
366
451
|
break;
|
|
@@ -380,6 +465,8 @@ export class Worker {
|
|
|
380
465
|
}
|
|
381
466
|
async handle(client, o, persona, policy) {
|
|
382
467
|
this.opts.log(`> woken by ${o.kind} ${o.id} (${o.body})${persona ? ` — persona "${persona.name}"` : ""}`);
|
|
468
|
+
this.lastHold = "";
|
|
469
|
+
this.client?.reportWorkerState("running", `${o.kind} ${o.id}`);
|
|
383
470
|
const trigger = client.recent(o.ref) ?? `(the triggering act ${o.ref} is not in this session's buffer)`;
|
|
384
471
|
return this.runJob(client, o, persona, policy, (branch) => buildBrief(o, trigger, branch, persona), true);
|
|
385
472
|
}
|
|
@@ -391,6 +478,8 @@ export class Worker {
|
|
|
391
478
|
body: `${act.act} from ${act.from}: ${act.body.split("\n")[0]?.slice(0, 120) ?? ""}`,
|
|
392
479
|
};
|
|
393
480
|
this.opts.log(`> continuing from ${act.act} ${act.id} by ${act.from}${persona ? ` — persona "${persona.name}"` : ""}`);
|
|
481
|
+
this.lastHold = "";
|
|
482
|
+
this.client?.reportWorkerState("running", `${act.act} ${act.id}`);
|
|
394
483
|
const trigger = client.recent(act.id) ?? `(${act.act} ${act.id} is not in this session's buffer)`;
|
|
395
484
|
return this.runJob(client, o, persona, policy, (branch) => buildContinuationBrief(act, trigger, branch, persona), false);
|
|
396
485
|
}
|
|
@@ -399,17 +488,18 @@ export class Worker {
|
|
|
399
488
|
if (this.opts.allowBash && !policy.allowBash && persona) {
|
|
400
489
|
this.opts.log(` bash off for this run — persona "${persona.name}" says allow_bash: false`);
|
|
401
490
|
}
|
|
402
|
-
let branch =
|
|
491
|
+
let branch = branchName(this.cfg.space, goal, this.client?.goals.get(goal)?.title);
|
|
403
492
|
let worktree = join(this.opts.scratch, goal);
|
|
493
|
+
let baseRef = "", baseSha = "";
|
|
404
494
|
try {
|
|
405
|
-
({ branch, worktree } = prepareWorktree(this.opts.project, this.opts.scratch, goal, this.opts.log));
|
|
495
|
+
({ branch, worktree, baseRef, baseSha } = prepareWorktree(this.opts.project, this.opts.scratch, goal, this.opts.log, branch, this.repoBase()));
|
|
406
496
|
}
|
|
407
497
|
catch (e) {
|
|
408
498
|
const detail = `could not prepare a worktree: ${e.message}`;
|
|
409
499
|
this.opts.log(`! ${detail}`);
|
|
410
500
|
return { obligation: o, branch, worktree, ok: false, detail };
|
|
411
501
|
}
|
|
412
|
-
const brief = makeBrief(branch);
|
|
502
|
+
const brief = makeBrief(branch) + baseNote(baseRef, baseSha);
|
|
413
503
|
writeFileSync(join(worktree, ".pantheon-task.md"), brief);
|
|
414
504
|
let command;
|
|
415
505
|
let args;
|
|
@@ -420,7 +510,7 @@ export class Worker {
|
|
|
420
510
|
const plan = headlessSpawn({
|
|
421
511
|
backend: { id: "claude-code", label: "Claude Code", binaries: ["claude"] },
|
|
422
512
|
binary: this.opts.agentCmd,
|
|
423
|
-
prompt: brief, worktree, cfg: this.cfg, branch, allowBash: policy.allowBash, runHome,
|
|
513
|
+
prompt: brief, worktree, cfg: this.cfg, branch, allowBash: policy.allowBash, runHome, ...(baseSha ? { base: baseSha } : {}), goal,
|
|
424
514
|
});
|
|
425
515
|
materializeSpawnFiles(worktree, plan);
|
|
426
516
|
({ command, args, env } = { command: plan.command, args: plan.args, env: { ...env, ...plan.env } });
|
|
@@ -466,6 +556,13 @@ export class Worker {
|
|
|
466
556
|
}
|
|
467
557
|
catch { }
|
|
468
558
|
}
|
|
559
|
+
else if (ok && this.opts.notify && loadProfile(this.cfg).notifications?.worker_done) {
|
|
560
|
+
try {
|
|
561
|
+
const n = workerDoneNotice({ ok, triggeredBy, summary });
|
|
562
|
+
this.opts.notify({ title: n.title, subtitle: n.subtitle, body: n.body, eyebrow: "Pantheon · worker finished" });
|
|
563
|
+
}
|
|
564
|
+
catch { }
|
|
565
|
+
}
|
|
469
566
|
this.opts.log(ok ? `< handled ${o.id} on ${branch}`
|
|
470
567
|
: !exited ? `! run failed for ${o.id}: ${detail.split("\n")[0]} (see ${runLog})`
|
|
471
568
|
: `! run exited cleanly but sent no act for ${o.id} — see ${runLog}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@join-pantheon/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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": {
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"build": "rm -rf dist && tsc -p .",
|
|
14
14
|
"dev": "tsc -p . --watch",
|
|
15
15
|
"relay": "node dist/cli.js relay",
|
|
16
|
-
"test": "node --test dist/test/*.test.js",
|
|
16
|
+
"test": "PANTHEON_HOME=\"$(mktemp -d)\" node --test --test-force-exit dist/test/*.test.js",
|
|
17
17
|
"prepare": "npm run build",
|
|
18
18
|
"prepublishOnly": "npm run build && node --test dist/test/*.test.js",
|
|
19
19
|
"start": "node dist/cli.js relay"
|