@join-pantheon/cli 0.24.4 → 0.25.1

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
@@ -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,7 @@
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
12
13
  * Done (decisions) answers it as handled, silently — the reminders stop
13
14
  * Dismiss / × closes
14
15
  *
@@ -26,13 +27,15 @@ import AppKit
26
27
  let args = CommandLine.arguments
27
28
 
28
29
  if args.count == 2, args[1] == "version" {
29
- print("3")
30
+ print("4")
30
31
  exit(0)
31
32
  }
32
33
 
33
34
  struct Tool: Decodable { let id: String; let label: String; let kind: String?; let bundle: String?; let deeplink: String? }
34
35
  struct Primary: Decodable { let label: String; let `default`: String; let tools: [Tool]; let prompt: String }
35
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 }
36
39
  struct Notice: Decodable {
37
40
  let kind: String
38
41
  let who: String
@@ -44,6 +47,7 @@ struct Notice: Decodable {
44
47
  let sound: Bool
45
48
  let primary: Primary?
46
49
  let details: Details?
50
+ let fix: Fix?
47
51
  /** where to write the chosen action and which .command files to open — supplied by the CLI */
48
52
  let bin_dir: String
49
53
  /** the pantheon CLI to run silently for one-click actions — supplied by the CLI, since this helper has no PATH worth trusting */
@@ -158,6 +162,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
158
162
  NSApp.terminate(nil)
159
163
  }
160
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
+
161
173
  @objc func legacyOpen(_ sender: Any?) {
162
174
  if !legacyPrompt.isEmpty { NSPasteboard.general.clearContents(); NSPasteboard.general.setString(legacyPrompt, forType: .string) }
163
175
  if let b = legacyBundle, let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: b) {
@@ -280,6 +292,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
280
292
  constraints += [d.leadingAnchor.constraint(equalTo: pull.trailingAnchor, constant: 14), d.centerYAnchor.constraint(equalTo: pull.centerYAnchor)]
281
293
  trailing = d
282
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
+ }
283
304
  if notice.details?.decision != nil {
284
305
  // A decision can be settled from the banner itself: one click, no terminal, no more reminders.
285
306
  let done = NSButton(title: "Done", target: self, action: #selector(markDone(_:)))
@@ -78,7 +78,9 @@ struct Item {
78
78
  /// "praneet · 2d ago · pantheon"
79
79
  var subtitle: String {
80
80
  var parts: [String] = []
81
- if kind == "owed" { parts.append("you owe") } else if kind == "waiting" { parts.append(who) }
81
+ // Who is asking, on every kind. A decision row used to say only "11h ago",
82
+ // which is the one fact a reader could already guess.
83
+ if kind == "owed" { parts.append("you owe") } else if !who.isEmpty { parts.append(who) }
82
84
  if when > 0 { parts.append(Item.age(when)) }
83
85
  if !space.isEmpty { parts.append(space) }
84
86
  return parts.joined(separator: " · ")
@@ -179,17 +181,45 @@ final class RowView: NSView {
179
181
  private var overCopy = false
180
182
  private var copied = false
181
183
 
184
+ private let titleLines: Int
185
+ /// The menu is the board people read every day; 430 wrapped a plain sentence
186
+ /// onto two lines and truncated a decision. 520 carries ~40% more per line.
187
+ static let rowWidth: CGFloat = 520
188
+ private static let titleFont = NSFont.systemFont(ofSize: 13, weight: .medium)
189
+ private static let lineHeight: CGFloat = 17
190
+
191
+ /// How many lines this title needs, at most three. A one-line row truncated
192
+ /// mid-word ("Sid asked: Sid is asking: is this work in producti…") hid the
193
+ /// only part worth reading; two lines carried most titles, and a decision
194
+ /// ("Praneet wants the unattended worker able to do everything overlapping
195
+ /// Pantheo…") still lost its point at the end of the second. Three lines,
196
+ /// taken only when needed, carry every real title whole.
197
+ static func lines(for text: String, width: CGFloat) -> Int {
198
+ let para = NSMutableParagraphStyle()
199
+ para.lineBreakMode = .byWordWrapping
200
+ let h = (text as NSString).boundingRect(
201
+ with: NSSize(width: width, height: .greatestFiniteMagnitude),
202
+ options: [.usesLineFragmentOrigin, .usesFontLeading],
203
+ attributes: [.font: titleFont, .paragraphStyle: para]).height
204
+ return max(1, min(3, Int(ceil(h / lineHeight))))
205
+ }
206
+
182
207
  init(item: Item, width: CGFloat, onOpen: @escaping (Item) -> Void, onCopy: @escaping (Item) -> Void) {
183
208
  self.item = item
184
209
  self.onOpen = onOpen
185
210
  self.onCopy = onCopy
186
- super.init(frame: NSRect(x: 0, y: 0, width: width, height: item.subtitle.isEmpty ? 30 : 42))
211
+ self.titleLines = RowView.lines(for: item.title, width: width - RowView.textInsets)
212
+ let textHeight = CGFloat(titleLines) * RowView.lineHeight
213
+ super.init(frame: NSRect(x: 0, y: 0, width: width, height: textHeight + (item.subtitle.isEmpty ? 13 : 29)))
187
214
  setAccessibilityRole(.button)
188
215
  setAccessibilityLabel("\(item.title). \(item.subtitle). Return opens it; the button on the right copies the question.")
189
216
  }
190
217
  required init?(coder: NSCoder) { return nil }
191
218
 
192
- private var copyRect: NSRect { NSRect(x: bounds.maxX - 42, y: bounds.midY - 12, width: 28, height: 24) }
219
+ /// Left inset plus what the copy control needs on the right: 28 for the control, 8 clear of the text, 8 to the edge.
220
+ static let textInsets: CGFloat = 12 + 44
221
+ private var copyRect: NSRect { NSRect(x: bounds.maxX - 36, y: bounds.midY - 12, width: 28, height: 24) }
222
+
193
223
 
194
224
  override func updateTrackingAreas() {
195
225
  super.updateTrackingAreas()
@@ -227,17 +257,25 @@ final class RowView: NSView {
227
257
  let fg: NSColor = selected ? .selectedMenuItemTextColor : .labelColor
228
258
  let dim: NSColor = selected ? NSColor.selectedMenuItemTextColor.withAlphaComponent(0.7) : .secondaryLabelColor
229
259
  let para = NSMutableParagraphStyle()
230
- para.lineBreakMode = .byTruncatingTail
231
- let textWidth = bounds.width - 14 - 48
260
+ para.lineBreakMode = .byWordWrapping // break between words, not inside one
261
+ para.lineSpacing = 0
262
+ let flat = NSMutableParagraphStyle()
263
+ flat.lineBreakMode = .byTruncatingTail
264
+ let textWidth = bounds.width - RowView.textInsets
232
265
  let hasSub = !item.subtitle.isEmpty
233
- let titleY = hasSub ? bounds.midY - 1 : bounds.midY - 8
234
- NSAttributedString(string: item.title, attributes: [
235
- .font: NSFont.systemFont(ofSize: 13, weight: .medium), .foregroundColor: fg, .paragraphStyle: para,
236
- ]).draw(in: NSRect(x: 14, y: titleY, width: textWidth, height: 18))
266
+ let titleHeight = CGFloat(titleLines) * RowView.lineHeight
267
+ let titleY = bounds.maxY - 7 - titleHeight
268
+ let cell = NSTextFieldCell() // gives the last visible line its ellipsis
269
+ cell.attributedStringValue = NSAttributedString(string: item.title, attributes: [
270
+ .font: RowView.titleFont, .foregroundColor: fg, .paragraphStyle: para,
271
+ ])
272
+ cell.truncatesLastVisibleLine = true
273
+ cell.wraps = true
274
+ cell.drawInterior(withFrame: NSRect(x: 12, y: titleY, width: textWidth, height: titleHeight), in: self)
237
275
  if hasSub {
238
276
  NSAttributedString(string: item.subtitle, attributes: [
239
- .font: NSFont.systemFont(ofSize: 11), .foregroundColor: dim, .paragraphStyle: para,
240
- ]).draw(in: NSRect(x: 14, y: bounds.midY - 17, width: textWidth, height: 15))
277
+ .font: NSFont.systemFont(ofSize: 11), .foregroundColor: dim, .paragraphStyle: flat,
278
+ ]).draw(in: NSRect(x: 14, y: bounds.minY + 6, width: textWidth, height: 14))
241
279
  }
242
280
 
243
281
  // the copy control
@@ -406,7 +444,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
406
444
  addRows(menu, theirs.count == 1 ? "Waiting on \(who)" : "Waiting on \(who) · \(theirs.count)", theirs)
407
445
  }
408
446
  if !decisions.isEmpty {
409
- menu.addItem(makeItem("\(decisions.count > 1 ? "Review all \(decisions.count) decisions" : "Review the decision") in \(state.surfaceName)", #selector(openAllDecisions)))
447
+ menu.addItem(makeItem(decisions.count > 1 ? "Review all \(decisions.count) in \(state.surfaceName)" : "Review it in \(state.surfaceName)", #selector(openAllDecisions)))
410
448
  }
411
449
  }
412
450
  menu.addItem(.separator())
@@ -826,7 +864,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
826
864
  for item in items.prefix(6) {
827
865
  let mi = NSMenuItem(title: item.title, action: nil, keyEquivalent: "")
828
866
  mi.toolTip = item.detail
829
- mi.view = RowView(item: item, width: 360,
867
+ mi.view = RowView(item: item, width: RowView.rowWidth,
830
868
  onOpen: { [weak self] it in self?.showDetail(it) },
831
869
  onCopy: { [weak self] it in self?.copyPrompt(it) })
832
870
  menu.addItem(mi)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@join-pantheon/cli",
3
- "version": "0.24.4",
3
+ "version": "0.25.1",
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": {