@join-pantheon/cli 0.24.1 → 0.25.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.
@@ -2,6 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join } from "node:path";
3
3
  import { CONFIG_PATH } from "./config.js";
4
4
  export const ASK_COOLDOWN_MS = 24 * 60 * 60 * 1_000;
5
+ export const ASK_EXPIRY_MS = 14 * 24 * 60 * 60 * 1_000;
5
6
  export function policyHome(home) {
6
7
  return home ?? process.env.PANTHEON_HOME ?? dirname(CONFIG_PATH);
7
8
  }
@@ -41,7 +42,8 @@ export function revoke(p, cap) {
41
42
  export function mayAsk(p, cap, now = Date.now()) {
42
43
  if (p.grants[cap])
43
44
  return false;
44
- if (p.asks[cap])
45
+ const open = p.asks[cap];
46
+ if (open && now - open.at < ASK_EXPIRY_MS)
45
47
  return false;
46
48
  const deniedAt = p.denied[cap];
47
49
  return deniedAt === undefined || now - deniedAt >= ASK_COOLDOWN_MS;
package/dist/worker.js CHANGED
@@ -14,6 +14,7 @@ import { loadContinued, markContinued } from "./continued.js";
14
14
  import { loadProfile, reservedDecisionsNote } from "./profile.js";
15
15
  import { clip } from "./brief.js";
16
16
  import { composeWorker } from "./notice.js";
17
+ import { diagnoseRun, gitProbe } from "./run-failure.js";
17
18
  import { installedTools, preferredTool } from "./notifier.js";
18
19
  import { loadPersonas, matchPersona, personaByName, personaMatches, effectivePolicy, personaBrief, describePersona, } from "./persona.js";
19
20
  import { isContinuationFor, LIMITS, shouldDeferToHuman } from "./protocol.js";
@@ -180,6 +181,13 @@ Act, reply if a reply is owed, then finish.`;
180
181
  }
181
182
  let fleetLock = null;
182
183
  const CONTINUATION_HORIZON_MS = 24 * 60 * 60_000;
184
+ export function answerWindow(askSeq, hint, fullScanDue) {
185
+ if (askSeq !== null)
186
+ return Math.max(0, askSeq - 1);
187
+ if (fullScanDue)
188
+ return 0;
189
+ return Math.max(0, hint);
190
+ }
183
191
  export class Worker {
184
192
  cfg;
185
193
  opts;
@@ -492,10 +500,13 @@ export class Worker {
492
500
  this.done.push(job);
493
501
  this.client?.reportWorkerState("idle");
494
502
  if (candidate && !job.ok) {
495
- this.failedRuns.set(candidate.id, (this.failedRuns.get(candidate.id) ?? 0) + 1);
503
+ const failures = (this.failedRuns.get(candidate.id) ?? 0) + 1;
504
+ this.failedRuns.set(candidate.id, failures);
496
505
  if (this.failedRuns.size > 200) {
497
506
  this.failedRuns.delete(this.failedRuns.keys().next().value);
498
507
  }
508
+ if (failures >= 2 && job.diagnosis)
509
+ await this.reportStuck(client, candidate, job.diagnosis);
499
510
  }
500
511
  this.running = false;
501
512
  if (fleetLock === this)
@@ -537,11 +548,22 @@ export class Worker {
537
548
  const trigger = client.recent(act.id) ?? `(${act.act} ${act.id} is not in this session's buffer)`;
538
549
  return this.runJob(client, o, persona, policy, (branch) => buildContinuationBrief(act, trigger, branch, persona), false);
539
550
  }
551
+ commandGrant(persona) {
552
+ if (persona?.allowBash === false)
553
+ return false;
554
+ const policy = loadPolicy(this.opts.home);
555
+ if (!useGrant(policy, "run_commands"))
556
+ return false;
557
+ savePolicy(policy, this.opts.home);
558
+ this.opts.log(` bash on for this run — your human granted run_commands`);
559
+ return true;
560
+ }
540
561
  async runJob(client, o, persona, policy, makeBrief, requireReply) {
541
562
  const goal = o.goal ?? "adhoc";
542
563
  if (this.opts.allowBash && !policy.allowBash && persona) {
543
564
  this.opts.log(` bash off for this run — persona "${persona.name}" says allow_bash: false`);
544
565
  }
566
+ const allowBash = policy.allowBash || this.commandGrant(persona);
545
567
  let branch = branchName(this.cfg.space, goal, this.client?.goals.get(goal)?.title);
546
568
  let worktree = join(this.opts.scratch, goal);
547
569
  let baseRef = "", baseSha = "";
@@ -566,14 +588,14 @@ export class Worker {
566
588
  const plan = headlessSpawn({
567
589
  backend: { id: "claude-code", label: "Claude Code", binaries: ["claude"] },
568
590
  binary: this.opts.agentCmd,
569
- prompt: brief, worktree, cfg: this.cfg, branch, allowBash: policy.allowBash, runHome, ...(baseSha ? { base: baseSha } : {}), goal,
591
+ prompt: brief, worktree, cfg: this.cfg, branch, allowBash, runHome, ...(baseSha ? { base: baseSha } : {}), goal,
570
592
  });
571
593
  materializeSpawnFiles(worktree, plan);
572
594
  ({ command, args, env } = { command: plan.command, args: plan.args, env: { ...env, ...plan.env } });
573
595
  }
574
596
  else {
575
597
  const { backend, binary } = resolveAgent(policy.agent ?? this.opts.agent);
576
- const plan = headlessSpawn({ backend, binary, prompt: brief, worktree, cfg: this.cfg, branch, allowBash: policy.allowBash, runHome });
598
+ const plan = headlessSpawn({ backend, binary, prompt: brief, worktree, cfg: this.cfg, branch, allowBash, runHome });
577
599
  materializeSpawnFiles(worktree, plan);
578
600
  ({ command, args, env } = { command: plan.command, args: plan.args, env: { ...env, ...plan.env } });
579
601
  this.opts.log(` spawn ${command} ${args.slice(0, 3).join(" ")}…`);
@@ -607,18 +629,56 @@ export class Worker {
607
629
  }, this.opts.home);
608
630
  const sent = client.mineSince(startedAt, o.goal);
609
631
  const quiet = ok && !requireReply && sent.length === 0;
632
+ const diagnosis = ok ? undefined : diagnoseRun({ exited, replied, allowBash, branch, project: this.opts.project, worktree, detail, runLog }, this.opts.probe ?? gitProbe);
610
633
  if (this.opts.notify && !quiet && (!ok || loadProfile(this.cfg).notifications?.worker_done)) {
611
634
  try {
612
635
  const reply = sent[sent.length - 1] ?? null;
613
- this.opts.notify(composeWorker({ ok, triggeredBy, obligationBody: o.body, goal: o.goal ? client.goals.get(o.goal) : undefined, reply, obligationRef: o.ref }, (() => { const tools = installedTools(); return { tools, preferred: preferredTool(loadProfile(this.cfg), tools, this.opts.home) }; })()));
636
+ this.opts.notify(composeWorker({ ok, triggeredBy, obligationBody: o.body, goal: o.goal ? client.goals.get(o.goal) : undefined, reply, obligationRef: o.ref, ...(diagnosis ? { diagnosis } : {}) }, (() => { const tools = installedTools(); return { tools, preferred: preferredTool(loadProfile(this.cfg), tools, this.opts.home) }; })()));
614
637
  }
615
638
  catch { }
616
639
  }
617
640
  this.opts.log(quiet ? `< read ${o.id} on ${branch} — the thread was already settled, nothing sent`
618
641
  : ok ? `< handled ${o.id} on ${branch}`
619
- : !exited ? `! run failed for ${o.id}: ${detail.split("\n")[0]} (see ${runLog})`
620
- : `! run exited cleanly but sent no act for ${o.id} — see ${runLog}`);
621
- return { obligation: o, branch, worktree, ok, detail };
642
+ : `! ${o.id} stopped [${diagnosis.cause}]: ${diagnosis.says} (transcript: ${runLog})`);
643
+ return { obligation: o, branch, worktree, ok, detail, ...(diagnosis ? { diagnosis } : {}) };
644
+ }
645
+ async reportStuck(client, o, d) {
646
+ const key = `stuck:${o.id}`;
647
+ const space = this.spaceKey();
648
+ if (loadContinued(space, this.opts.home).has(key))
649
+ return;
650
+ const policy = loadPolicy(this.opts.home);
651
+ const asking = !!d.capability && mayAsk(policy, d.capability);
652
+ const what = clip(o.body, 120);
653
+ const lines = [
654
+ `Two runs on this machine could not finish ${o.kind === "CONTINUE" ? "a follow-up" : `the ${o.kind}`} it owes: ${what}`,
655
+ ``,
656
+ `WHY IT STOPPED. ${d.says}`,
657
+ `WHAT UNBLOCKS IT. ${d.fix}`,
658
+ ...(d.dir && d.lines?.length ? [``, `On this machine, in ${d.dir}:`, ...d.lines.map((l) => ` ${l}`)] : []),
659
+ ``,
660
+ `Nothing was sent to the teammate who asked, and the request is still open on them.`,
661
+ `It will not be retried unattended — a live session here, or you, has to move it.`,
662
+ ];
663
+ if (asking) {
664
+ lines.push(``, `MAY THE WORKER RUN COMMANDS ON THIS MACHINE? Answer "allow always", "allow once", or "deny".`, `Today it may edit files only, which is why it could not commit or push. Allowing it lets a woken`, `run execute tests and git in its own isolated worktree — never in your checkout.`);
665
+ }
666
+ try {
667
+ const sent = await client.send({
668
+ act: "ESCALATE", goal: o.goal, ref: o.ref, after_seq: client.readSeq,
669
+ summary: clip(`Stuck on you: ${d.says}`, 190),
670
+ body: lines.join("\n"),
671
+ });
672
+ markContinued(space, key, this.opts.home);
673
+ if (asking && d.capability) {
674
+ recordAsk(policy, d.capability, sent.id);
675
+ savePolicy(policy, this.opts.home);
676
+ }
677
+ this.opts.log(`* escalated ${o.id}: ${d.cause}${asking ? ` (and asked for ${d.capability})` : ""}`);
678
+ }
679
+ catch (e) {
680
+ this.opts.log(`! could not report the stuck obligation on the ledger: ${e.message}`);
681
+ }
622
682
  }
623
683
  enqueueContinuations(client, arrived) {
624
684
  this.loadContinued();
@@ -678,13 +738,18 @@ export class Worker {
678
738
  const home = this.opts.home;
679
739
  {
680
740
  const policy = loadPolicy(home);
681
- const open = policy.asks.self_upgrade;
682
741
  let changed = false;
683
- const fromStream = arrived.find((a) => a.act === "ANSWER" && a.ref && open && a.ref === open.act);
684
- const body = fromStream?.body ?? (open ? await this.fetchGrantAnswer(open.act) : null);
685
- if (open && body !== null) {
742
+ for (const cap of Object.keys(policy.asks)) {
743
+ const open = policy.asks[cap];
744
+ if (!open)
745
+ continue;
746
+ const fromStream = arrived.find((a) => a.act === "ANSWER" && a.ref === open.act);
747
+ const body = fromStream?.body ?? await this.fetchGrantAnswer(open.act, client.head - 200);
748
+ if (body === null)
749
+ continue;
686
750
  const applied = applyAnswer(policy, open.act, body);
687
751
  if (applied) {
752
+ this.askAt.delete(open.act);
688
753
  changed = true;
689
754
  this.opts.log(`* grant request answered: ${applied.cap} -> ${applied.verdict}`);
690
755
  }
@@ -756,10 +821,39 @@ export class Worker {
756
821
  }
757
822
  }
758
823
  }
759
- async fetchGrantAnswer(askActId) {
824
+ askAt = new Map();
825
+ lastFullScan = 0;
826
+ async actSeq(id) {
827
+ try {
828
+ const base = this.cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
829
+ const q = `space=${encodeURIComponent(this.cfg.space)}&token=${encodeURIComponent(this.cfg.token)}`;
830
+ const res = await fetch(`${base}/act?${q}&id=${encodeURIComponent(id)}`, { signal: AbortSignal.timeout(3000) });
831
+ if (!res.ok)
832
+ return null;
833
+ const j = await res.json();
834
+ return typeof j.act?.seq === "number" ? j.act.seq : null;
835
+ }
836
+ catch {
837
+ return null;
838
+ }
839
+ }
840
+ async fetchGrantAnswer(askActId, since = 0) {
841
+ let seq = this.askAt.get(askActId) ?? null;
842
+ if (seq === null) {
843
+ seq = await this.actSeq(askActId);
844
+ if (seq !== null)
845
+ this.askAt.set(askActId, seq);
846
+ }
847
+ let due = false;
848
+ if (seq === null) {
849
+ due = Date.now() - this.lastFullScan > 5 * 60_000;
850
+ if (due)
851
+ this.lastFullScan = Date.now();
852
+ }
853
+ const from = answerWindow(seq, since, due);
760
854
  try {
761
855
  const base = this.cfg.relay.replace(/^ws/, "http").replace(/\/+$/, "");
762
- const url = `${base}/history?space=${encodeURIComponent(this.cfg.space)}&token=${encodeURIComponent(this.cfg.token)}&since=0`;
856
+ const url = `${base}/history?space=${encodeURIComponent(this.cfg.space)}&token=${encodeURIComponent(this.cfg.token)}&since=${from}`;
763
857
  const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
764
858
  const j = await res.json();
765
859
  const ans = (j.acts ?? []).find((a) => a.act === "ANSWER" && a.ref === askActId);
@@ -9,6 +9,8 @@
9
9
  * (the choice becomes the default), through a .command file
10
10
  * Terminal runs — no automation permission needed
11
11
  * Show details prints the act and the decision in Terminal
12
+ * Fix it here (a failed run) opens a Terminal where the work is, steps shown
13
+ * Done (decisions) answers it as handled, silently — the reminders stop
12
14
  * Dismiss / × closes
13
15
  *
14
16
  * It owns no state. Everything it needs arrives as one JSON document:
@@ -25,13 +27,15 @@ import AppKit
25
27
  let args = CommandLine.arguments
26
28
 
27
29
  if args.count == 2, args[1] == "version" {
28
- print("2")
30
+ print("4")
29
31
  exit(0)
30
32
  }
31
33
 
32
34
  struct Tool: Decodable { let id: String; let label: String; let kind: String?; let bundle: String?; let deeplink: String? }
33
35
  struct Primary: Decodable { let label: String; let `default`: String; let tools: [Tool]; let prompt: String }
34
36
  struct Details: Decodable { let act: String?; let decision: String? }
37
+ /// The one thing a person could do about a failed run: where, and which steps.
38
+ struct Fix: Decodable { let label: String; let dir: String; let lines: [String]; let why: String }
35
39
  struct Notice: Decodable {
36
40
  let kind: String
37
41
  let who: String
@@ -43,8 +47,11 @@ struct Notice: Decodable {
43
47
  let sound: Bool
44
48
  let primary: Primary?
45
49
  let details: Details?
50
+ let fix: Fix?
46
51
  /** where to write the chosen action and which .command files to open — supplied by the CLI */
47
52
  let bin_dir: String
53
+ /** the pantheon CLI to run silently for one-click actions — supplied by the CLI, since this helper has no PATH worth trusting */
54
+ let pantheon: String?
48
55
  }
49
56
 
50
57
  var notice: Notice
@@ -138,6 +145,31 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
138
145
  act(["action": "details", "act": notice.details?.act as Any, "decision": notice.details?.decision as Any].compactMapValues { $0 is NSNull ? nil : $0 }, command: "pantheon-details.command")
139
146
  }
140
147
 
148
+ /// Done: write the action, run the CLI without a Terminal, close. The CLI answers the decision as handled.
149
+ @objc func markDone(_ sender: Any?) {
150
+ guard !notice.bin_dir.isEmpty, let decision = notice.details?.decision else { NSApp.terminate(nil); return }
151
+ let dir = URL(fileURLWithPath: notice.bin_dir)
152
+ let payload: [String: Any] = ["action": "done", "decision": decision, "act": notice.details?.act as Any].compactMapValues { $0 is NSNull ? nil : $0 }
153
+ if let data = try? JSONSerialization.data(withJSONObject: payload) {
154
+ try? data.write(to: dir.appendingPathComponent(".hud-action.json"), options: .atomic)
155
+ }
156
+ let bin = (notice.pantheon?.isEmpty == false) ? notice.pantheon! : "pantheon"
157
+ let quoted = "'" + bin.replacingOccurrences(of: "'", with: "'\\''") + "'"
158
+ let p = Process()
159
+ p.executableURL = URL(fileURLWithPath: "/bin/sh")
160
+ p.arguments = ["-lc", "\(quoted) explain --queued >/dev/null 2>&1"] // login shell: PATH with node, as the user's own terminal has
161
+ try? p.run()
162
+ NSApp.terminate(nil)
163
+ }
164
+
165
+ /// Fix it here: open a Terminal where the work is, with the steps shown. The
166
+ /// CLI asks before running anything — a run that just failed is the wrong
167
+ /// moment to take another unattended action on someone's machine.
168
+ @objc func fixIt(_ sender: Any?) {
169
+ guard let f = notice.fix else { NSApp.terminate(nil); return }
170
+ act(["action": "fix", "dir": f.dir, "lines": f.lines, "why": f.why], command: "pantheon-details.command")
171
+ }
172
+
141
173
  @objc func legacyOpen(_ sender: Any?) {
142
174
  if !legacyPrompt.isEmpty { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(legacyPrompt, forType: .string) }
143
175
  if let b = legacyBundle, let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: b) {
@@ -260,6 +292,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
260
292
  constraints += [d.leadingAnchor.constraint(equalTo: pull.trailingAnchor, constant: 14), d.centerYAnchor.constraint(equalTo: pull.centerYAnchor)]
261
293
  trailing = d
262
294
  }
295
+ if notice.fix != nil {
296
+ let fix = NSButton(title: notice.fix!.label, target: self, action: #selector(fixIt(_:)))
297
+ fix.isBordered = false; fix.font = .systemFont(ofSize: 12, weight: .medium); fix.contentTintColor = .controlAccentColor
298
+ fix.toolTip = notice.fix!.why
299
+ fix.translatesAutoresizingMaskIntoConstraints = false
300
+ root.addSubview(fix)
301
+ constraints += [fix.leadingAnchor.constraint(equalTo: trailing.trailingAnchor, constant: 14), fix.centerYAnchor.constraint(equalTo: pull.centerYAnchor)]
302
+ trailing = fix
303
+ }
304
+ if notice.details?.decision != nil {
305
+ // A decision can be settled from the banner itself: one click, no terminal, no more reminders.
306
+ let done = NSButton(title: "Done", target: self, action: #selector(markDone(_:)))
307
+ done.isBordered = false; done.font = .systemFont(ofSize: 12, weight: .medium); done.contentTintColor = .controlAccentColor
308
+ done.setAccessibilityLabel("Mark this decision handled")
309
+ done.translatesAutoresizingMaskIntoConstraints = false
310
+ root.addSubview(done)
311
+ constraints += [done.leadingAnchor.constraint(equalTo: trailing.trailingAnchor, constant: 14), done.centerYAnchor.constraint(equalTo: pull.centerYAnchor)]
312
+ trailing = done
313
+ }
263
314
  let dis = NSButton(title: "Dismiss", target: self, action: #selector(dismiss(_:)))
264
315
  dis.isBordered = false; dis.font = .systemFont(ofSize: 12); dis.contentTintColor = .secondaryLabelColor
265
316
  dis.translatesAutoresizingMaskIntoConstraints = false