@join-pantheon/cli 0.31.1 → 0.31.2-rc.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.
@@ -82,6 +82,8 @@ struct Item {
82
82
  var context: String = ""
83
83
  /// a session of this person's has claimed it (handling: an agent is on it, not just queued)
84
84
  var claimed: Bool = false
85
+ /// a delivery this person's agent committed to — theirs, not a teammate's request
86
+ var promise: Bool = false
85
87
 
86
88
  var isDecision: Bool { kind == "decision" }
87
89
  /// A tray-status from before lanes — or with a lane this helper does not know —
@@ -104,7 +106,7 @@ struct Item {
104
106
  // which is the one fact a reader could already guess.
105
107
  // Who has it, by where the board put it: a card is on this person; a handled row is an agent's.
106
108
  if kind == "owed" {
107
- parts.append(effectiveLane == "handling" ? (claimed ? "your agent is on it" : "your worker picks it up") : "waiting on you")
109
+ parts.append(promise ? "your agent committed to it" : effectiveLane == "handling" ? (claimed ? "your agent is on it" : "your worker picks it up") : "waiting on you")
108
110
  } else if !who.isEmpty { parts.append(who) }
109
111
  if when > 0 { parts.append(Item.age(when)) }
110
112
  if showSpace && !space.isEmpty { parts.append(space) }
@@ -186,7 +188,8 @@ func fetchState() -> TrayState {
186
188
  },
187
189
  lane: i["lane"] as? String ?? "",
188
190
  context: i["context"] as? String ?? "",
189
- claimed: i["claimed"] as? Bool ?? false
191
+ claimed: i["claimed"] as? Bool ?? false,
192
+ promise: i["promise"] as? Bool ?? false
190
193
  ))
191
194
  }
192
195
  for d in (j["decisions"] as? [[String: Any]] ?? []) {
@@ -427,6 +430,48 @@ final class HeaderView: NSView {
427
430
  }
428
431
  }
429
432
 
433
+ /// A team's section on a board that spans several teams: a mark in the team's own
434
+ /// colour, its name, and how much of it needs this person — so each team reads as
435
+ /// its own block, not a suffix on every row.
436
+ final class BoardSpacer: NSView {
437
+ init(height: CGFloat, width: CGFloat) { super.init(frame: NSRect(x: 0, y: 0, width: width, height: height)); autoresizingMask = [.width] }
438
+ required init?(coder: NSCoder) { return nil }
439
+ }
440
+
441
+ final class TeamHeaderView: NSView {
442
+ private let name: String
443
+ private let detail: String
444
+ private let tint: NSColor
445
+ init(name: String, detail: String, tint: NSColor, width: CGFloat) {
446
+ self.name = name; self.detail = detail; self.tint = tint
447
+ super.init(frame: NSRect(x: 0, y: 0, width: width, height: 30))
448
+ autoresizingMask = [.width]
449
+ setAccessibilityRole(.staticText)
450
+ setAccessibilityLabel("\(name): \(detail)")
451
+ }
452
+ required init?(coder: NSCoder) { return nil }
453
+ static let tints: [NSColor] = [
454
+ NSColor(calibratedRed: 0.32, green: 0.74, blue: 0.76, alpha: 1),
455
+ NSColor(calibratedRed: 0.85, green: 0.74, blue: 0.42, alpha: 1),
456
+ NSColor(calibratedRed: 0.89, green: 0.57, blue: 0.42, alpha: 1),
457
+ NSColor(calibratedRed: 0.62, green: 0.56, blue: 0.86, alpha: 1),
458
+ NSColor(calibratedRed: 0.52, green: 0.78, blue: 0.52, alpha: 1),
459
+ ]
460
+ override func draw(_ dirtyRect: NSRect) {
461
+ let band = NSRect(x: 6, y: bounds.minY + 2, width: bounds.width - 12, height: bounds.height - 4)
462
+ tint.withAlphaComponent(0.10).setFill()
463
+ NSBezierPath(roundedRect: band, xRadius: 6, yRadius: 6).fill()
464
+ tint.setFill()
465
+ NSBezierPath(roundedRect: NSRect(x: 12, y: bounds.midY - 6, width: 4, height: 12), xRadius: 2, yRadius: 2).fill()
466
+ let para = NSMutableParagraphStyle(); para.lineBreakMode = .byTruncatingTail
467
+ NSAttributedString(string: name.uppercased(), attributes: [.font: NSFont.systemFont(ofSize: 11.5, weight: .bold), .kern: 0.6, .foregroundColor: NSColor.labelColor, .paragraphStyle: para])
468
+ .draw(in: NSRect(x: 24, y: bounds.midY - 8, width: bounds.width * 0.6, height: 16))
469
+ let r = NSMutableParagraphStyle(); r.alignment = .right
470
+ NSAttributedString(string: detail, attributes: [.font: NSFont.systemFont(ofSize: 12), .foregroundColor: NSColor.secondaryLabelColor, .paragraphStyle: r])
471
+ .draw(in: NSRect(x: bounds.maxX - 14 - 220, y: bounds.midY - 8, width: 220, height: 16))
472
+ }
473
+ }
474
+
430
475
  /*
431
476
  * The board: an opaque, dark panel under the icon.
432
477
  *
@@ -643,7 +688,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
643
688
  let panel = makeBoardPanel()
644
689
  boardPanel = panel
645
690
  rebuildBoard()
646
- guard let view = panel.contentView, let rep = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return }
691
+ var target: NSView? = panel.contentView
692
+ // PANTHEON_TRAY_PANEL=sent draws the first card's detail panel just after a
693
+ // reply was sent — the state that overlapped (2 Oct) — instead of the board.
694
+ if ProcessInfo.processInfo.environment["PANTHEON_TRAY_PANEL"] == "sent", let first = state.items.first(where: { $0.isDecision }) {
695
+ showDetail(first)
696
+ panelReplyManually()
697
+ setStatus("Answered — it is no longer waiting on you.")
698
+ exitReplyMode()
699
+ panelRetract?.isHidden = false
700
+ panelStatusRow?.isHidden = false
701
+ refitPanel()
702
+ target = detailPanel?.contentView
703
+ }
704
+ // PANTHEON_TRAY_PANEL=open draws the first card's panel once its full text has loaded.
705
+ if ProcessInfo.processInfo.environment["PANTHEON_TRAY_PANEL"] == "open", let first = state.items.first(where: { $0.isDecision }) {
706
+ showDetail(first)
707
+ RunLoop.main.run(until: Date().addingTimeInterval(2.5))
708
+ target = detailPanel?.contentView
709
+ }
710
+ guard let view = target, let rep = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return }
647
711
  view.cacheDisplay(in: view.bounds, to: rep)
648
712
  try? rep.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path))
649
713
  }
@@ -709,26 +773,40 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
709
773
  if !state.relayOk { note("Could not reach: \(state.unavailableSpaces.joined(separator: ", "))") }
710
774
  if !state.decisionsOk { note("Some of this could not be checked — the list may be incomplete") }
711
775
  sep()
712
- for it in cards { rows.append(RowView(item: it, width: w, showSpace: multi, onOpen: openRow, onCopy: copyRow)) }
713
776
  // The heading already says nothing needs you: said again only what it does not.
714
777
  if cards.isEmpty && state.decisionsOk && handling.isEmpty && waiting.isEmpty && older.isEmpty && putAway.isEmpty {
715
778
  note("All clear — nothing open for your agents either")
716
779
  }
717
- // The rest, counted: a line each, opened in place. (A plain loop: a local
718
- // closure capturing both `rows` and a weak self crashed the optimised build.)
719
- let lanes: [(key: String, title: String, items: [Item])] = [
720
- ("handling", "Your agents are handling · \(handling.count)", handling),
721
- ("waiting", "Waiting on teammates · \(waiting.count)", waiting),
722
- ("older", "Older than a week · \(older.count)", older),
723
- ("later", "Later · \(putAway.count)", putAway),
724
- ]
725
- if lanes.contains(where: { !$0.items.isEmpty }) { sep() }
726
- for lane in lanes where !lane.items.isEmpty {
727
- let open = boardOpen.contains(lane.key)
728
- let key = lane.key
729
- rows.append(ActionRow(lane.title, hint: open ? "hide" : "show", width: w, onClick: { [weak self] in self?.toggleBoardLine(key) }))
730
- // Indented under their line, so an opened lane never reads as more cards.
731
- if open { for it in lane.items { rows.append(RowView(item: it, width: w, inTeamSection: true, showSpace: multi, onOpen: openRow, onCopy: copyRow)) } }
780
+ // One block per team when there are several (Sid, 2 Oct: "it should
781
+ // differentiate each space — here it's all dumped together"): the team's
782
+ // header in its own colour, its cards, then its own counted lines. One team
783
+ // keeps the plain board. (Plain loops: a local closure capturing both `rows`
784
+ // and a weak self crashed the optimised build.)
785
+ let teams: [String] = multi ? state.spaces : [""]
786
+ for (ti, team) in teams.enumerated() {
787
+ let mine = { (xs: [Item]) in multi ? xs.filter { $0.space == team } : xs }
788
+ let tCards = mine(cards), tHandling = mine(handling), tWaiting = mine(waiting), tOlder = mine(older), tLater = mine(putAway)
789
+ if multi {
790
+ let restCount = tHandling.count + tWaiting.count + tOlder.count + tLater.count
791
+ let detail = !tCards.isEmpty ? "\(tCards.count) need\(tCards.count == 1 ? "s" : "") you" : restCount > 0 ? "nothing needs you" : "all clear"
792
+ if ti > 0 { rows.append(BoardSpacer(height: 6, width: w)) }
793
+ rows.append(TeamHeaderView(name: team, detail: detail, tint: TeamHeaderView.tints[ti % TeamHeaderView.tints.count], width: w))
794
+ }
795
+ for it in tCards { rows.append(RowView(item: it, width: w, showSpace: false, onOpen: openRow, onCopy: copyRow)) }
796
+ let lanes: [(key: String, title: String, items: [Item])] = [
797
+ ("handling", "Your agents are handling · \(tHandling.count)", tHandling),
798
+ ("waiting", "Waiting on teammates · \(tWaiting.count)", tWaiting),
799
+ ("older", "Older than a week · \(tOlder.count)", tOlder),
800
+ ("later", "Later · \(tLater.count)", tLater),
801
+ ]
802
+ if !multi && lanes.contains(where: { !$0.items.isEmpty }) { sep() }
803
+ for lane in lanes where !lane.items.isEmpty {
804
+ let key = multi ? "\(lane.key)|\(team)" : lane.key
805
+ let open = boardOpen.contains(key)
806
+ rows.append(ActionRow(lane.title, hint: open ? "hide" : "show", width: w, onClick: { [weak self] in self?.toggleBoardLine(key) }))
807
+ // Indented under their line, so an opened lane never reads as more cards.
808
+ if open { for it in lane.items { rows.append(RowView(item: it, width: w, inTeamSection: true, showSpace: false, onOpen: openRow, onCopy: copyRow)) } }
809
+ }
732
810
  }
733
811
  // The prompt it hands over asks about every question waiting on this person,
734
812
  // put away or not, so the count is of all of them.
@@ -940,6 +1018,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
940
1018
  private var panelText: NSTextView?
941
1019
  private var panelBodyHeight: NSLayoutConstraint?
942
1020
  private var panelHeadline: NSTextField?
1021
+ private var panelEyebrow: NSTextField?
1022
+ /// The decision's own stated options ("(a) … (b) …"), as one-click answers.
1023
+ private var panelOptions: NSStackView?
1024
+ private var panelOptionList: [(key: String, text: String)] = []
943
1025
  private var panelRendered: NSAttributedString? // the act laid out for a person
944
1026
  private var panelRaw: String = "" // the act exactly as it was sent
945
1027
  private var panelShowingRaw = false
@@ -1036,12 +1118,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1036
1118
  guard let tv = panelText else { return }
1037
1119
  tv.textStorage?.setAttributedString(text)
1038
1120
  panelBodyHeight?.constant = bodyHeight(text, width: width)
1039
- if let p = detailPanel {
1040
- p.contentView?.layoutSubtreeIfNeeded()
1041
- let h = min(max((p.contentView?.fittingSize.height ?? 420) + 30, 300), 680)
1042
- let f = p.frame
1043
- p.setFrame(NSRect(x: f.minX, y: f.maxY - h, width: f.width, height: h), display: true, animate: false)
1121
+ refitPanel()
1122
+ }
1123
+
1124
+ /// The panel is measured again whenever a row shows or hides — the reply box,
1125
+ /// the status line, Retract, the buttons coming back. It was sized once, when it
1126
+ /// opened, so after a send the status row and the returning buttons outgrew it
1127
+ /// and AppKit drew them on top of each other (2 Oct). The top edge stays put; past
1128
+ /// the tallest panel, the body gives up height before anything can overlap.
1129
+ private func refitPanel() {
1130
+ guard let p = detailPanel, let v = p.contentView else { return }
1131
+ v.layoutSubtreeIfNeeded()
1132
+ var need = v.fittingSize.height + 30
1133
+ if need > 680, let bh = panelBodyHeight {
1134
+ bh.constant = max(80, bh.constant - (need - 680))
1135
+ v.layoutSubtreeIfNeeded()
1136
+ need = v.fittingSize.height + 30
1044
1137
  }
1138
+ let h = min(max(need, 300), 680)
1139
+ let f = p.frame
1140
+ p.setFrame(NSRect(x: f.minX, y: f.maxY - h, width: f.width, height: h), display: true, animate: false)
1045
1141
  }
1046
1142
 
1047
1143
  /**
@@ -1052,7 +1148,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1052
1148
  private func loadDetail(_ item: Item, width: CGFloat) {
1053
1149
  guard !item.ref.isEmpty else { return }
1054
1150
  DispatchQueue.global().async {
1055
- let r = run(pantheonBin, ["tray-detail", item.ref], timeout: 20)
1151
+ // The item's own team, so a decision in another team is found and shown whole.
1152
+ let r = run(pantheonBin, ["tray-detail", item.ref] + (item.space.isEmpty ? [] : ["--space", item.space]), timeout: 20)
1056
1153
  guard let out = r?.out, r?.code == 0,
1057
1154
  let data = out.data(using: .utf8),
1058
1155
  let j = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
@@ -1061,8 +1158,34 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1061
1158
  let rendered = self.render(blocks)
1062
1159
  let raw = j["raw"] as? String ?? ""
1063
1160
  let headline = j["headline"] as? String ?? ""
1161
+ let asker = j["asker"] as? String ?? ""
1162
+ let options: [(key: String, text: String)] = (j["options"] as? [[String: Any]] ?? []).compactMap { o in
1163
+ guard let k = o["key"] as? String, let t = o["text"] as? String else { return nil }
1164
+ return (key: k, text: t)
1165
+ }
1064
1166
  DispatchQueue.main.async {
1065
1167
  guard self.panelItem?.id == item.id else { return }
1168
+ // Who is asking, said plainly: "from your worker", never "sid escalated" to sid.
1169
+ if !asker.isEmpty, let eye = self.panelEyebrow, !eye.stringValue.contains("FROM ") { eye.stringValue += " · FROM " + asker.uppercased() }
1170
+ if item.isDecision && item.choices.isEmpty && options.count >= 2, let box = self.panelOptions {
1171
+ self.panelOptionList = options
1172
+ box.arrangedSubviews.forEach { $0.removeFromSuperview() }
1173
+ let note = NSTextField(labelWithString: "Answer with one of its options:")
1174
+ note.font = .systemFont(ofSize: 12, weight: .medium)
1175
+ note.textColor = .secondaryLabelColor
1176
+ box.addArrangedSubview(note)
1177
+ for (n, o) in options.enumerated() {
1178
+ let title = "(\(o.key)) " + (o.text.count > 78 ? String(o.text.prefix(77)) + "…" : o.text)
1179
+ let b = NSButton(title: title, target: self, action: #selector(self.panelChooseOption(_:)))
1180
+ b.bezelStyle = .rounded
1181
+ b.tag = n
1182
+ b.keyEquivalent = ""
1183
+ b.toolTip = "Sends \"(\(o.key)) \(o.text)\" as your answer"
1184
+ box.addArrangedSubview(b)
1185
+ }
1186
+ box.isHidden = false
1187
+ self.refitPanel()
1188
+ }
1066
1189
  self.panelRendered = rendered
1067
1190
  self.panelRaw = raw
1068
1191
  self.panelRawButton?.isHidden = raw.isEmpty
@@ -1119,6 +1242,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1119
1242
  if item.when > 0 { eyebrow += " · " + Item.age(item.when) }
1120
1243
  if !item.space.isEmpty { eyebrow += " · " + item.space }
1121
1244
  let eye = label(eyebrow, size: 11, weight: .semibold, color: .secondaryLabelColor, width: inner)
1245
+ panelEyebrow = eye
1122
1246
  stack.addArrangedSubview(eye)
1123
1247
  let headline = label(item.headline, size: 17, weight: .semibold, color: .labelColor, width: inner)
1124
1248
  panelHeadline = headline
@@ -1275,6 +1399,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1275
1399
  rows.spacing = 8
1276
1400
  rows.translatesAutoresizingMaskIntoConstraints = false
1277
1401
 
1402
+ // The decision's own options, filled in once its full text has loaded.
1403
+ if item.isDecision && item.choices.isEmpty {
1404
+ let opts = NSStackView()
1405
+ opts.orientation = .vertical
1406
+ opts.alignment = .leading
1407
+ opts.spacing = 6
1408
+ opts.isHidden = true
1409
+ panelOptions = opts
1410
+ rows.addArrangedSubview(opts)
1411
+ }
1412
+
1278
1413
  // A question this machine asked its own person: answered in one click, here.
1279
1414
  if item.isDecision && !item.choices.isEmpty {
1280
1415
  let row0 = NSStackView()
@@ -1360,6 +1495,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1360
1495
  panelStatus?.stringValue = text
1361
1496
  panelStatus?.isHidden = text.isEmpty
1362
1497
  panelStatusRow?.isHidden = text.isEmpty && (panelRetract?.isHidden ?? true)
1498
+ refitPanel()
1363
1499
  }
1364
1500
 
1365
1501
  @objc private func panelClose() {
@@ -1380,6 +1516,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1380
1516
  panelReplyKind = nil
1381
1517
  panelSendButton = nil
1382
1518
  panelRetract = nil
1519
+ panelEyebrow = nil
1520
+ panelOptions = nil
1521
+ panelOptionList = []
1383
1522
  panelLastSent = ""
1384
1523
  panelRaw = ""
1385
1524
  panelShowingRaw = false
@@ -1405,7 +1544,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1405
1544
  panelReplyRow?.isHidden = false
1406
1545
  if let c = panelBodyHeight, c.constant > 140 { c.constant -= 20 }
1407
1546
  panelRetract?.isHidden = true
1408
- setStatus("")
1547
+ setStatus("") // refits
1409
1548
  if let tv = panelReply { detailPanel?.makeFirstResponder(tv) }
1410
1549
  }
1411
1550
  @objc private func panelCancelReply() { exitReplyMode() }
@@ -1416,6 +1555,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1416
1555
  panelReplyButton?.isHidden = false
1417
1556
  panelReplyButton?.keyEquivalent = "\r"
1418
1557
  if let c = panelBodyHeight, c.constant > 0 { c.constant += 20 }
1558
+ refitPanel()
1419
1559
  }
1420
1560
 
1421
1561
  /// What a person may send on this item. Answer only on their own decision:
@@ -1452,6 +1592,30 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1452
1592
  }
1453
1593
  }
1454
1594
 
1595
+ /// One of the decision's stated options, sent as its ANSWER — the same local path a typed answer takes.
1596
+ @objc private func panelChooseOption(_ sender: NSButton) {
1597
+ guard let item = panelItem, sender.tag >= 0, sender.tag < panelOptionList.count else { return }
1598
+ let o = panelOptionList[sender.tag]
1599
+ var payload: [String: Any] = ["action": "reply", "kind": "ANSWER", "body": "(\(o.key)) \(o.text)", "goal": item.goal, "space": item.space, "decision": item.id]
1600
+ if !item.ref.isEmpty { payload["act"] = item.ref }
1601
+ guard writeAction(payload) else { setStatus("Could not write the action. Use Reply manually instead."); return }
1602
+ setStatus("Answering…")
1603
+ panelOptions?.arrangedSubviews.forEach { ($0 as? NSButton)?.isEnabled = false }
1604
+ DispatchQueue.global().async {
1605
+ let r = run(pantheonBin, ["explain", "--queued"], timeout: 25)
1606
+ DispatchQueue.main.async {
1607
+ let out = (r?.out ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
1608
+ if r?.code == 0 {
1609
+ self.setStatus("Answered (\(o.key)) — it is no longer waiting on you.")
1610
+ self.refresh()
1611
+ } else {
1612
+ self.panelOptions?.arrangedSubviews.forEach { ($0 as? NSButton)?.isEnabled = true }
1613
+ self.setStatus(out.hasPrefix("not sent: ") ? String(out.dropFirst("not sent: ".count)) : "the relay could not be reached")
1614
+ }
1615
+ }
1616
+ }
1617
+ }
1618
+
1455
1619
  /// Send the words in the box as the chosen act. The CLI does the sending and
1456
1620
  /// prints "sent KIND id=… seq=…" or "not sent: why" on stdout; both are shown.
1457
1621
  @objc private func panelSend(_ sender: Any?) {
@@ -1481,7 +1645,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1481
1645
  self.setStatus(what)
1482
1646
  self.exitReplyMode()
1483
1647
  self.panelRetract?.isHidden = false
1484
- DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [weak self] in self?.panelRetract?.isHidden = true }
1648
+ self.panelStatusRow?.isHidden = false
1649
+ self.refitPanel()
1650
+ DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [weak self] in
1651
+ self?.panelRetract?.isHidden = true
1652
+ self?.panelStatusRow?.isHidden = (self?.panelStatus?.stringValue.isEmpty ?? true)
1653
+ self?.refitPanel()
1654
+ }
1485
1655
  self.refresh()
1486
1656
  } else {
1487
1657
  let why = out.hasPrefix("not sent: ") ? String(out.dropFirst("not sent: ".count)) : "the relay could not be reached"
@@ -1507,7 +1677,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate {
1507
1677
  if r?.code == 0 {
1508
1678
  self.panelLastSent = ""
1509
1679
  self.panelRetract?.isHidden = true
1510
- self.setStatus("Retracted.")
1680
+ self.setStatus("Retracted.") // refits
1511
1681
  self.refresh()
1512
1682
  } else {
1513
1683
  let out = (r?.out ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@join-pantheon/cli",
3
- "version": "0.31.1",
3
+ "version": "0.31.2-rc.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": {