@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/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
- if (existsSync(join(worktree, ".git")))
30
- return { branch, worktree };
31
- mkdirSync(scratch, { recursive: true });
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, base);
67
+ git(project, "worktree", "add", worktree, "-b", branch, baseRef);
43
68
  excludeRunFiles(worktree);
44
- log(` worktree ${worktree} on ${branch} (from ${base})`);
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 = loadPersonas(this.opts.project, this.opts.home);
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 = loadPersonas(this.opts.project, this.opts.home);
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 = `pantheon/${goal}`;
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.19.1",
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"