agentix-cli 0.35.0 → 0.37.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.
Files changed (93) hide show
  1. package/apps/mac-helper/Sources/AgentXHelper/Presence.swift +117 -89
  2. package/apps/mac-helper/Sources/AgentXHelper/PresenceTag.swift +147 -0
  3. package/apps/mac-helper/Sources/AgentXHelper/main.swift +3 -1
  4. package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +33 -25
  5. package/apps/mac-voice/Sources/AgentXVoice/Config.swift +4 -0
  6. package/apps/mac-voice/Sources/AgentXVoice/Progress.swift +4 -4
  7. package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +81 -13
  8. package/apps/mac-voice/Sources/AgentXVoice/main.swift +114 -68
  9. package/dist/{App-K5J52F7O.js → App-TW5N7JY2.js} +2 -2
  10. package/dist/{ChatApp-LRRAPZH7.js → ChatApp-MRGX7JBT.js} +2 -2
  11. package/dist/{agent-XEXAYH5J.js → agent-FQHWBQP2.js} +2 -2
  12. package/dist/{board-dashboard-CBOESBMD.js → board-dashboard-2YEYVCUS.js} +4 -4
  13. package/dist/{chunk-PBVEYPZB.js → chunk-3SKFX6G3.js} +2 -2
  14. package/dist/{chunk-DXXWYYZI.js → chunk-4ZGX4L6Q.js} +196 -185
  15. package/dist/chunk-4ZGX4L6Q.js.map +1 -0
  16. package/dist/{chunk-WRBW4A2U.js → chunk-ADRAW342.js} +2 -2
  17. package/dist/{chunk-3PL3WLIR.js → chunk-AKIYB3IJ.js} +2 -2
  18. package/dist/{chunk-JSZRGMSA.js → chunk-EJWIRM52.js} +2 -2
  19. package/dist/{chunk-ZSNTLADV.js → chunk-FHF4RA4Q.js} +2 -2
  20. package/dist/{chunk-SYJUZ3F2.js → chunk-GL7VC26K.js} +2 -2
  21. package/dist/{chunk-3TNK7VGH.js → chunk-HEV3AVFJ.js} +2 -2
  22. package/dist/{chunk-FLNN3KSX.js → chunk-IEE475O2.js} +2 -2
  23. package/dist/{chunk-WEN2P4RS.js → chunk-JLEADOCC.js} +2 -2
  24. package/dist/{chunk-GYBYKREN.js → chunk-MC6I6ZLY.js} +2 -2
  25. package/dist/{chunk-4QNZI4WE.js → chunk-N6APC3DF.js} +5 -5
  26. package/dist/{chunk-LLKILWWO.js → chunk-RRLIZLBH.js} +2 -2
  27. package/dist/{chunk-LQFEH3SA.js → chunk-SR6TVIIS.js} +2 -2
  28. package/dist/{chunk-HPY6OOFO.js → chunk-UORMWVLP.js} +150 -142
  29. package/dist/chunk-UORMWVLP.js.map +1 -0
  30. package/dist/chunk-WFKS4O34.js +13 -0
  31. package/dist/chunk-WFKS4O34.js.map +1 -0
  32. package/dist/cli.js +1 -1
  33. package/dist/client-4KD2UA75.js +2 -0
  34. package/dist/config-WN3R3BPU.js +2 -0
  35. package/dist/config-mutator-2FIUL4DA.js +2 -0
  36. package/dist/doctor-BNO46EOR.js +2 -0
  37. package/dist/index.d.ts +129 -1
  38. package/dist/index.js +1 -1
  39. package/dist/index.js.map +1 -1
  40. package/dist/program-Z6EK44PV.js +2 -0
  41. package/dist/query-TKUDIOBZ.js +2 -0
  42. package/dist/{registry-GWN34G4J.js → registry-TDYF7HGH.js} +2 -2
  43. package/dist/{request-planner-V7T6BQU7.js → request-planner-DF74JHQB.js} +2 -2
  44. package/dist/{review-candidates-7QKNMRGU.js → review-candidates-SEAGMKI6.js} +2 -2
  45. package/dist/{routing-BHQXFUSH.js → routing-JKWL3L3Q.js} +2 -2
  46. package/dist/scheduler-7CYBDKQ7.js +2 -0
  47. package/dist/{seat-U57NWHKC.js → seat-C6TOB63Q.js} +2 -2
  48. package/dist/{select-JL3JJJP4.js → select-YB5KF27O.js} +2 -2
  49. package/dist/wiki-7FCINSKJ.js +2 -0
  50. package/package.json +1 -1
  51. package/dist/chunk-DXXWYYZI.js.map +0 -1
  52. package/dist/chunk-HPY6OOFO.js.map +0 -1
  53. package/dist/chunk-LA6AKS4N.js +0 -13
  54. package/dist/chunk-LA6AKS4N.js.map +0 -1
  55. package/dist/client-APAWETTA.js +0 -2
  56. package/dist/config-S3VHHVYW.js +0 -2
  57. package/dist/config-mutator-5B2YWZMK.js +0 -2
  58. package/dist/doctor-SCECRUJ7.js +0 -2
  59. package/dist/program-XJEQAWN3.js +0 -2
  60. package/dist/query-EDW72Q4O.js +0 -2
  61. package/dist/scheduler-LY4RBPB6.js +0 -2
  62. package/dist/wiki-AQ4K3F7V.js +0 -2
  63. /package/dist/{App-K5J52F7O.js.map → App-TW5N7JY2.js.map} +0 -0
  64. /package/dist/{ChatApp-LRRAPZH7.js.map → ChatApp-MRGX7JBT.js.map} +0 -0
  65. /package/dist/{agent-XEXAYH5J.js.map → agent-FQHWBQP2.js.map} +0 -0
  66. /package/dist/{board-dashboard-CBOESBMD.js.map → board-dashboard-2YEYVCUS.js.map} +0 -0
  67. /package/dist/{chunk-PBVEYPZB.js.map → chunk-3SKFX6G3.js.map} +0 -0
  68. /package/dist/{chunk-WRBW4A2U.js.map → chunk-ADRAW342.js.map} +0 -0
  69. /package/dist/{chunk-3PL3WLIR.js.map → chunk-AKIYB3IJ.js.map} +0 -0
  70. /package/dist/{chunk-JSZRGMSA.js.map → chunk-EJWIRM52.js.map} +0 -0
  71. /package/dist/{chunk-ZSNTLADV.js.map → chunk-FHF4RA4Q.js.map} +0 -0
  72. /package/dist/{chunk-SYJUZ3F2.js.map → chunk-GL7VC26K.js.map} +0 -0
  73. /package/dist/{chunk-3TNK7VGH.js.map → chunk-HEV3AVFJ.js.map} +0 -0
  74. /package/dist/{chunk-FLNN3KSX.js.map → chunk-IEE475O2.js.map} +0 -0
  75. /package/dist/{chunk-WEN2P4RS.js.map → chunk-JLEADOCC.js.map} +0 -0
  76. /package/dist/{chunk-GYBYKREN.js.map → chunk-MC6I6ZLY.js.map} +0 -0
  77. /package/dist/{chunk-4QNZI4WE.js.map → chunk-N6APC3DF.js.map} +0 -0
  78. /package/dist/{chunk-LLKILWWO.js.map → chunk-RRLIZLBH.js.map} +0 -0
  79. /package/dist/{chunk-LQFEH3SA.js.map → chunk-SR6TVIIS.js.map} +0 -0
  80. /package/dist/{client-APAWETTA.js.map → client-4KD2UA75.js.map} +0 -0
  81. /package/dist/{config-S3VHHVYW.js.map → config-WN3R3BPU.js.map} +0 -0
  82. /package/dist/{config-mutator-5B2YWZMK.js.map → config-mutator-2FIUL4DA.js.map} +0 -0
  83. /package/dist/{doctor-SCECRUJ7.js.map → doctor-BNO46EOR.js.map} +0 -0
  84. /package/dist/{program-XJEQAWN3.js.map → program-Z6EK44PV.js.map} +0 -0
  85. /package/dist/{query-EDW72Q4O.js.map → query-TKUDIOBZ.js.map} +0 -0
  86. /package/dist/{registry-GWN34G4J.js.map → registry-TDYF7HGH.js.map} +0 -0
  87. /package/dist/{request-planner-V7T6BQU7.js.map → request-planner-DF74JHQB.js.map} +0 -0
  88. /package/dist/{review-candidates-7QKNMRGU.js.map → review-candidates-SEAGMKI6.js.map} +0 -0
  89. /package/dist/{routing-BHQXFUSH.js.map → routing-JKWL3L3Q.js.map} +0 -0
  90. /package/dist/{scheduler-LY4RBPB6.js.map → scheduler-7CYBDKQ7.js.map} +0 -0
  91. /package/dist/{seat-U57NWHKC.js.map → seat-C6TOB63Q.js.map} +0 -0
  92. /package/dist/{select-JL3JJJP4.js.map → select-YB5KF27O.js.map} +0 -0
  93. /package/dist/{wiki-AQ4K3F7V.js.map → wiki-7FCINSKJ.js.map} +0 -0
@@ -4,7 +4,7 @@ import AppKit
4
4
  ///
5
5
  /// Drawn, never driven. `point` moves the person's real mouse, which is
6
6
  /// right for a lesson the person watches and wrong for one they take part
7
- /// in — their hand is on that mouse. This cursor is a click-through window
7
+ /// in — their hand is on that mouse. This cursor is a click-through arrow
8
8
  /// that glides to what the agent means, highlights it, and says a line in
9
9
  /// a bubble, while the real pointer stays exactly where the person left it.
10
10
  ///
@@ -14,9 +14,19 @@ import AppKit
14
14
  /// {"cmd":"say","text":"The export button is top right."}
15
15
  /// {"cmd":"clear"} drop the highlight
16
16
  /// {"cmd":"park"} rest in the bottom-right corner, no highlight
17
+ /// {"cmd":"ping"} nothing; keeps an idle overlay alive
17
18
  ///
18
19
  /// Coordinates are accessibility coordinates (top-left origin, global),
19
- /// the same as `read`, `ocr` and `point`. EOF fades everything out.
20
+ /// the same as `read`, `ocr` and `point`.
21
+ ///
22
+ /// Only the arrow and highlight are click-through. The name tag and bubble
23
+ /// (PresenceTag) can be dragged out of the way; with `--pos-file` the spot
24
+ /// is remembered for this agent and the tag stays there on later turns.
25
+ ///
26
+ /// It never outlives its owner: EOF, the owner's death (`--parent`, or
27
+ /// being re-parented to launchd) or `--idle` seconds without a command
28
+ /// fade everything out and exit. One process draws one cursor, on one
29
+ /// screen; it follows across Spaces but is never duplicated per display.
20
30
  enum Presence {
21
31
 
22
32
  // MARK: Geometry
@@ -35,73 +45,37 @@ enum Presence {
35
45
 
36
46
  // MARK: Windows
37
47
 
38
- private static func panel(_ rect: NSRect) -> NSPanel {
48
+ /// Floating, not screen-saver level: above the windows the person works
49
+ /// in, below menus, alerts and Spotlight.
50
+ fileprivate static func panel(_ rect: NSRect, clickThrough: Bool = true) -> NSPanel {
39
51
  let p = NSPanel(contentRect: rect, styleMask: [.borderless, .nonactivatingPanel],
40
52
  backing: .buffered, defer: false)
41
- p.level = .screenSaver
53
+ p.level = .floating
42
54
  p.isOpaque = false
43
55
  p.backgroundColor = .clear
44
56
  p.hasShadow = false
45
- // Click-through: the person's clicks land on the app underneath.
46
- p.ignoresMouseEvents = true
57
+ // The arrow and the highlight are click-through: the person's clicks
58
+ // land on the app underneath. Only the name tag takes the mouse.
59
+ p.ignoresMouseEvents = clickThrough
47
60
  p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
48
61
  return p
49
62
  }
50
63
 
51
- /// The arrow, the initial badge, the name, and the speech bubble.
52
- private final class AvatarView: NSView {
64
+ /// The arrow alone: the system cursor's silhouette, in the agent's colour.
65
+ private final class ArrowView: NSView {
53
66
  let color: NSColor
54
- let initial: String
55
- let name: String
56
- let bubble = NSTextField(wrappingLabelWithString: "")
57
- /// The bubble's background, so the text gets padding.
58
- let box = NSView()
59
- /// Wide on both sides of the tip, so the bubble can sit to the left
60
- /// when the target is near the right edge of the screen.
61
- static let size = NSSize(width: 720, height: 170)
67
+ static let size = NSSize(width: 30, height: 40)
62
68
  /// The arrow tip, in view coordinates (bottom-left origin).
63
- static let tip = NSPoint(x: 360, y: size.height - 6)
64
- var bubbleLeft = false { didSet { if bubbleLeft != oldValue { layoutBubble() } } }
69
+ static let tip = NSPoint(x: 4, y: size.height - 4)
65
70
 
66
- init(color: NSColor, initial: String, name: String) {
67
- self.color = color; self.initial = initial; self.name = name
68
- super.init(frame: NSRect(origin: .zero, size: AvatarView.size))
69
- wantsLayer = true
70
- bubble.font = Brand.body(size: 13)
71
- bubble.textColor = Brand.ink
72
- bubble.maximumNumberOfLines = 4
73
- box.wantsLayer = true
74
- box.layer?.backgroundColor = Brand.paper.cgColor
75
- box.layer?.cornerRadius = Brand.Radius.md
76
- box.layer?.borderWidth = 2
77
- box.layer?.borderColor = color.cgColor
78
- box.isHidden = true
79
- box.addSubview(bubble)
80
- addSubview(box)
71
+ init(color: NSColor) {
72
+ self.color = color
73
+ super.init(frame: NSRect(origin: .zero, size: ArrowView.size))
81
74
  }
82
75
  required init?(coder: NSCoder) { fatalError() }
83
76
 
84
- /// Under the badge and name, never over them.
85
- func setBubble(_ text: String) {
86
- box.isHidden = text.isEmpty
87
- bubble.stringValue = text
88
- let textW: CGFloat = 300
89
- bubble.preferredMaxLayoutWidth = textW
90
- let fit = bubble.sizeThatFits(NSSize(width: textW, height: 90))
91
- let w = min(textW, ceil(fit.width)), h = min(90, ceil(fit.height))
92
- bubble.frame = NSRect(x: 10, y: 6, width: w, height: h)
93
- box.setFrameSize(NSSize(width: w + 20, height: h + 12))
94
- layoutBubble()
95
- }
96
-
97
- func layoutBubble() {
98
- let t = AvatarView.tip, s = box.frame.size
99
- box.setFrameOrigin(NSPoint(x: bubbleLeft ? t.x - s.width - 4 : t.x + 16, y: t.y - 58 - s.height))
100
- }
101
-
102
77
  override func draw(_ dirtyRect: NSRect) {
103
- let t = AvatarView.tip
104
- // The arrow: the system cursor's silhouette, in the agent's colour.
78
+ let t = ArrowView.tip
105
79
  let arrow = NSBezierPath()
106
80
  arrow.move(to: t)
107
81
  arrow.line(to: NSPoint(x: t.x, y: t.y - 26))
@@ -120,43 +94,58 @@ enum Presence {
120
94
  color.setFill(); arrow.fill()
121
95
  NSGraphicsContext.current?.restoreGraphicsState()
122
96
  NSColor.white.setStroke(); arrow.lineWidth = 1.5; arrow.stroke()
97
+ }
98
+ }
123
99
 
124
- // The badge with the initial, and the name pill beside it.
125
- let badge = NSRect(x: t.x + 16, y: t.y - 50, width: 24, height: 24)
126
- color.setFill(); NSBezierPath(ovalIn: badge).fill()
127
- NSColor.white.setStroke()
128
- let ring = NSBezierPath(ovalIn: badge.insetBy(dx: 0.75, dy: 0.75)); ring.lineWidth = 1.5; ring.stroke()
129
- draw(initial, in: badge, size: 11, weight: .bold, color: .white)
100
+ // MARK: Pinned position
130
101
 
131
- let attrs: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: 11, weight: .semibold)]
132
- let nameW = (name as NSString).size(withAttributes: attrs).width + 14
133
- let pill = NSRect(x: badge.maxX + 4, y: badge.minY + 3, width: nameW, height: 18)
134
- color.setFill(); NSBezierPath(roundedRect: pill, xRadius: 9, yRadius: 9).fill()
135
- draw(name, in: pill, size: 11, weight: .semibold, color: .white)
136
- }
102
+ /// Where the person dragged this agent's tag: its top-left, AppKit
103
+ /// coordinates, as {"x":…,"y":…}. Ignored if no screen shows it any more.
104
+ static func loadPin(_ file: String?) -> NSPoint? {
105
+ guard let file, let data = FileManager.default.contents(atPath: file),
106
+ let o = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
107
+ let x = o["x"] as? Double, let y = o["y"] as? Double else { return nil }
108
+ let p = NSPoint(x: x, y: y)
109
+ // The badge row hangs below the top-left; it must be on a screen.
110
+ let row = NSPoint(x: x + 12, y: y - 12)
111
+ return NSScreen.screens.contains(where: { $0.frame.contains(row) }) ? p : nil
112
+ }
137
113
 
138
- private func draw(_ s: String, in r: NSRect, size: CGFloat, weight: NSFont.Weight, color: NSColor) {
139
- let a: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: size, weight: weight), .foregroundColor: color]
140
- let sz = (s as NSString).size(withAttributes: a)
141
- (s as NSString).draw(at: NSPoint(x: r.midX - sz.width / 2, y: r.midY - sz.height / 2), withAttributes: a)
114
+ static func savePin(_ p: NSPoint?, to file: String?) {
115
+ guard let file else { return }
116
+ guard let p else { try? FileManager.default.removeItem(atPath: file); return }
117
+ let url = URL(fileURLWithPath: file)
118
+ do {
119
+ try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
120
+ let data = try JSONSerialization.data(withJSONObject: ["x": Double(p.x), "y": Double(p.y)])
121
+ try data.write(to: url, options: .atomic)
122
+ } catch {
123
+ FileHandle.standardError.write("presence: could not save position: \(error)\n".data(using: .utf8)!)
142
124
  }
143
125
  }
144
126
 
145
127
  // MARK: Behaviour
146
128
 
147
129
  private final class Controller {
148
- let avatar: NSPanel
149
- let view: AvatarView
130
+ let arrow: NSPanel
131
+ let tagPanel: NSPanel
132
+ let tag: PresenceTag
150
133
  let ring: NSPanel
151
134
  let color: NSColor
135
+ let posFile: String?
152
136
  var at: CGPoint // tip position, AppKit coordinates
137
+ /// The tag's top-left once the person has dragged it; nil rides with the arrow.
138
+ var pinned: NSPoint?
153
139
  var timer: Timer?
154
140
 
155
- init(color: NSColor, initial: String, name: String) {
141
+ init(color: NSColor, initial: String, name: String, posFile: String?) {
156
142
  self.color = color
157
- view = AvatarView(color: color, initial: initial, name: name)
158
- avatar = Presence.panel(NSRect(origin: .zero, size: AvatarView.size))
159
- avatar.contentView = view
143
+ self.posFile = posFile
144
+ arrow = Presence.panel(NSRect(origin: .zero, size: ArrowView.size))
145
+ arrow.contentView = ArrowView(color: color)
146
+ tag = PresenceTag(color: color, initial: initial, name: name)
147
+ tagPanel = Presence.panel(NSRect(origin: .zero, size: tag.frame.size), clickThrough: false)
148
+ tagPanel.contentView = tag
160
149
  ring = Presence.panel(.zero)
161
150
  let ringView = NSView()
162
151
  ringView.wantsLayer = true
@@ -165,11 +154,25 @@ enum Presence {
165
154
  ringView.layer?.cornerRadius = 8
166
155
  ringView.layer?.backgroundColor = color.withAlphaComponent(0.10).cgColor
167
156
  ring.contentView = ringView
157
+ pinned = Presence.loadPin(posFile)
168
158
  at = Controller.parking()
159
+ tag.onPin = { [weak self] p in self?.pinned = p; Presence.savePin(p, to: posFile) }
160
+ tag.onUnpin = { [weak self] in
161
+ guard let self else { return }
162
+ self.pinned = nil
163
+ Presence.savePin(nil, to: posFile)
164
+ self.place(self.at)
165
+ }
169
166
  place(at)
170
- avatar.alphaValue = 0
171
- avatar.orderFrontRegardless()
172
- NSAnimationContext.runAnimationGroup { $0.duration = 0.2; avatar.animator().alphaValue = 1 }
167
+ for p in [arrow, tagPanel] {
168
+ p.alphaValue = 0
169
+ p.orderFrontRegardless()
170
+ }
171
+ NSAnimationContext.runAnimationGroup { ctx in
172
+ ctx.duration = 0.2
173
+ arrow.animator().alphaValue = 1
174
+ tagPanel.animator().alphaValue = 1
175
+ }
173
176
  }
174
177
 
175
178
  static func parking() -> CGPoint {
@@ -178,9 +181,16 @@ enum Presence {
178
181
  }
179
182
 
180
183
  func place(_ tip: CGPoint) {
181
- avatar.setFrameOrigin(NSPoint(x: tip.x - AvatarView.tip.x, y: tip.y - AvatarView.tip.y))
182
- let screen = NSScreen.screens.first(where: { $0.frame.contains(tip) }) ?? NSScreen.main
183
- view.bubbleLeft = (screen?.visibleFrame.maxX ?? .greatestFiniteMagnitude) - tip.x < 360
184
+ arrow.setFrameOrigin(NSPoint(x: tip.x - ArrowView.tip.x, y: tip.y - ArrowView.tip.y))
185
+ if tag.dragged { return }
186
+ let size = tagPanel.frame.size
187
+ var top = pinned ?? NSPoint(x: tip.x + 20, y: tip.y - 26)
188
+ if pinned == nil {
189
+ // Near the right edge the tag sits to the left of the arrow.
190
+ let screen = NSScreen.screens.first(where: { $0.frame.contains(tip) }) ?? NSScreen.main
191
+ if let edge = screen?.visibleFrame.maxX, top.x + size.width > edge { top.x = tip.x - 4 - size.width }
192
+ }
193
+ tagPanel.setFrameOrigin(NSPoint(x: top.x, y: top.y - size.height))
184
194
  }
185
195
 
186
196
  /// Glide with minimum-jerk easing; duration follows distance, as in
@@ -205,7 +215,8 @@ enum Presence {
205
215
  guard let rect else { ring.orderOut(nil); return }
206
216
  ring.setFrame(Presence.toAppKit(rect).insetBy(dx: -6, dy: -6), display: true)
207
217
  ring.orderFrontRegardless()
208
- avatar.orderFrontRegardless()
218
+ arrow.orderFrontRegardless()
219
+ tagPanel.orderFrontRegardless()
209
220
  }
210
221
 
211
222
  func handle(_ obj: [String: Any]) {
@@ -220,7 +231,8 @@ enum Presence {
220
231
  glide(to: Presence.toAppKit(aim))
221
232
  highlight((obj["highlight"] as? Bool ?? false) && w > 0 && h > 0 ? rect : nil)
222
233
  case "say":
223
- view.setBubble(obj["text"] as? String ?? "")
234
+ tag.setBubble(obj["text"] as? String ?? "")
235
+ place(at)
224
236
  case "clear":
225
237
  highlight(nil)
226
238
  case "park":
@@ -235,7 +247,8 @@ enum Presence {
235
247
  ring.orderOut(nil)
236
248
  NSAnimationContext.runAnimationGroup { ctx in
237
249
  ctx.duration = 0.25
238
- avatar.animator().alphaValue = 0
250
+ arrow.animator().alphaValue = 0
251
+ tagPanel.animator().alphaValue = 0
239
252
  } completionHandler: { done() }
240
253
  }
241
254
  }
@@ -248,18 +261,33 @@ enum Presence {
248
261
  blue: CGFloat(v & 0xff) / 255, alpha: 1)
249
262
  }
250
263
 
251
- /// Read commands until stdin closes.
252
- static func run(name: String, initial: String, colorHex: String?) {
264
+ /// Read commands until stdin closes, the owner dies, or it goes idle.
265
+ static func run(name: String, initial: String, colorHex: String?, parent: pid_t?, idle: TimeInterval,
266
+ posFile: String? = nil) {
253
267
  let app = NSApplication.shared
254
268
  app.setActivationPolicy(.accessory)
255
- let ctl = Controller(color: color(hex: colorHex), initial: initial, name: name)
269
+ let ctl = Controller(color: color(hex: colorHex), initial: initial, name: name, posFile: posFile)
270
+ var lastCommand = Date()
271
+ var quitting = false
272
+ // Main thread only. The fade is a courtesy: exit follows regardless.
273
+ func quit() {
274
+ if quitting { return }
275
+ quitting = true
276
+ ctl.fadeOut { exit(0) }
277
+ DispatchQueue.main.asyncAfter(deadline: .now() + 1) { exit(0) }
278
+ }
256
279
  DispatchQueue(label: "tn.acme.agentx.presence.stdin").async {
257
280
  while let line = readLine(strippingNewline: true) {
258
281
  guard let data = line.data(using: .utf8),
259
282
  let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue }
260
- DispatchQueue.main.async { ctl.handle(obj) }
283
+ DispatchQueue.main.async { lastCommand = Date(); ctl.handle(obj) }
261
284
  }
262
- DispatchQueue.main.async { ctl.fadeOut { app.terminate(nil) } }
285
+ DispatchQueue.main.async { quit() }
286
+ }
287
+ Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in
288
+ let orphaned = getppid() == 1 || (parent.map { kill($0, 0) != 0 && errno == ESRCH } ?? false)
289
+ let idled = idle > 0 && Date().timeIntervalSince(lastCommand) > idle
290
+ if orphaned || idled { quit() }
263
291
  }
264
292
  app.run()
265
293
  }
@@ -0,0 +1,147 @@
1
+ import AppKit
2
+
3
+ /// The part of an agent's presence the person can touch: the initial
4
+ /// badge, the name, and the speech bubble under them.
5
+ ///
6
+ /// It sits in its own panel, apart from the click-through arrow, so it can
7
+ /// take the mouse without the arrow ever stealing a click:
8
+ ///
9
+ /// drag anywhere move it; it stays there (pinned) for this agent
10
+ /// double-click bubble dismiss the bubble for the rest of this overlay
11
+ /// double-click name unpin; it rides next to the arrow again
12
+ ///
13
+ /// The view is top-anchored: the badge row stays put as the bubble comes
14
+ /// and goes, and the window grows or shrinks underneath it.
15
+ final class PresenceTag: NSView {
16
+ let color: NSColor
17
+ let initial: String
18
+ let name: String
19
+ let bubble = NSTextField(wrappingLabelWithString: "")
20
+ /// The bubble's background, so the text gets padding.
21
+ let box = NSView()
22
+
23
+ static let rowHeight: CGFloat = 24
24
+ static let gap: CGFloat = 8
25
+
26
+ /// Called with the window's new top-left once a drag ends.
27
+ var onPin: ((NSPoint) -> Void)?
28
+ var onUnpin: (() -> Void)?
29
+ /// A double-click on the bubble hides it; later lines stay hidden too.
30
+ private(set) var dismissed = false
31
+
32
+ private var dragFrom: NSPoint?
33
+ private var windowFrom: NSPoint = .zero
34
+ /// True mid-drag, so a glide doesn't pull the tag out of the hand.
35
+ private(set) var dragged = false
36
+
37
+ init(color: NSColor, initial: String, name: String) {
38
+ self.color = color; self.initial = initial; self.name = name
39
+ super.init(frame: .zero)
40
+ wantsLayer = true
41
+ bubble.font = Brand.body(size: 13)
42
+ bubble.textColor = Brand.ink
43
+ bubble.maximumNumberOfLines = 4
44
+ box.wantsLayer = true
45
+ box.layer?.backgroundColor = Brand.paper.cgColor
46
+ box.layer?.cornerRadius = Brand.Radius.md
47
+ box.layer?.borderWidth = 2
48
+ box.layer?.borderColor = color.cgColor
49
+ box.isHidden = true
50
+ box.addSubview(bubble)
51
+ addSubview(box)
52
+ setFrameSize(fittingSize)
53
+ }
54
+ required init?(coder: NSCoder) { fatalError() }
55
+
56
+ private static let nameAttrs: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: 11, weight: .semibold)]
57
+ private var rowWidth: CGFloat { 24 + 4 + (name as NSString).size(withAttributes: PresenceTag.nameAttrs).width + 14 }
58
+
59
+ override var fittingSize: NSSize {
60
+ guard !box.isHidden else { return NSSize(width: rowWidth, height: PresenceTag.rowHeight) }
61
+ return NSSize(width: max(rowWidth, box.frame.width),
62
+ height: PresenceTag.rowHeight + PresenceTag.gap + box.frame.height)
63
+ }
64
+
65
+ func setBubble(_ text: String) {
66
+ box.isHidden = text.isEmpty || dismissed
67
+ bubble.stringValue = text
68
+ let textW: CGFloat = 300
69
+ bubble.preferredMaxLayoutWidth = textW
70
+ let fit = bubble.sizeThatFits(NSSize(width: textW, height: 90))
71
+ let w = min(textW, ceil(fit.width)), h = min(90, ceil(fit.height))
72
+ bubble.frame = NSRect(x: 10, y: 6, width: w, height: h)
73
+ box.frame = NSRect(x: 0, y: 0, width: w + 20, height: h + 12)
74
+ refit()
75
+ }
76
+
77
+ /// Resize the window to the content, keeping its top-left where it is.
78
+ private func refit() {
79
+ let size = fittingSize
80
+ setFrameSize(size)
81
+ needsDisplay = true
82
+ guard let w = window else { return }
83
+ let top = NSPoint(x: w.frame.minX, y: w.frame.maxY)
84
+ w.setFrame(NSRect(x: top.x, y: top.y - size.height, width: size.width, height: size.height), display: true)
85
+ }
86
+
87
+ override func draw(_ dirtyRect: NSRect) {
88
+ let badge = NSRect(x: 0, y: bounds.maxY - 24, width: 24, height: 24)
89
+ color.setFill(); NSBezierPath(ovalIn: badge).fill()
90
+ NSColor.white.setStroke()
91
+ let ring = NSBezierPath(ovalIn: badge.insetBy(dx: 0.75, dy: 0.75)); ring.lineWidth = 1.5; ring.stroke()
92
+ draw(initial, in: badge, size: 11, weight: .bold)
93
+
94
+ let nameW = (name as NSString).size(withAttributes: PresenceTag.nameAttrs).width + 14
95
+ let pill = NSRect(x: badge.maxX + 4, y: badge.minY + 3, width: nameW, height: 18)
96
+ color.setFill(); NSBezierPath(roundedRect: pill, xRadius: 9, yRadius: 9).fill()
97
+ draw(name, in: pill, size: 11, weight: .semibold)
98
+ }
99
+
100
+ private func draw(_ s: String, in r: NSRect, size: CGFloat, weight: NSFont.Weight) {
101
+ let a: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: size, weight: weight), .foregroundColor: NSColor.white]
102
+ let sz = (s as NSString).size(withAttributes: a)
103
+ (s as NSString).draw(at: NSPoint(x: r.midX - sz.width / 2, y: r.midY - sz.height / 2), withAttributes: a)
104
+ }
105
+
106
+ // MARK: Mouse
107
+
108
+ /// The label would swallow clicks meant for the drag.
109
+ override func hitTest(_ point: NSPoint) -> NSView? {
110
+ frame.contains(point) ? self : nil
111
+ }
112
+ /// The panel never activates this app, so the first click must count.
113
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
114
+
115
+ override func mouseDown(with event: NSEvent) {
116
+ if event.clickCount == 2 {
117
+ let p = convert(event.locationInWindow, from: nil)
118
+ if !box.isHidden && box.frame.contains(p) {
119
+ dismissed = true
120
+ box.isHidden = true
121
+ refit()
122
+ } else if p.y >= bounds.maxY - PresenceTag.rowHeight {
123
+ onUnpin?()
124
+ }
125
+ dragFrom = nil
126
+ return
127
+ }
128
+ dragFrom = NSEvent.mouseLocation
129
+ windowFrom = window?.frame.origin ?? .zero
130
+ dragged = false
131
+ }
132
+
133
+ override func mouseDragged(with event: NSEvent) {
134
+ guard let from = dragFrom, let w = window else { return }
135
+ let now = NSEvent.mouseLocation
136
+ let dx = now.x - from.x, dy = now.y - from.y
137
+ if !dragged && hypot(dx, dy) < 3 { return }
138
+ dragged = true
139
+ w.setFrameOrigin(NSPoint(x: windowFrom.x + dx, y: windowFrom.y + dy))
140
+ }
141
+
142
+ override func mouseUp(with event: NSEvent) {
143
+ defer { dragFrom = nil; dragged = false }
144
+ guard dragged, let w = window else { return }
145
+ onPin?(NSPoint(x: w.frame.minX, y: w.frame.maxY))
146
+ }
147
+ }
@@ -260,7 +260,9 @@ case "presence":
260
260
  // Long-lived: the agent's own drawn cursor. Never moves the real mouse.
261
261
  let name = flag("name") ?? "Agent"
262
262
  Presence.run(name: name, initial: flag("initial") ?? String(name.prefix(1)).uppercased(),
263
- colorHex: flag("color"))
263
+ colorHex: flag("color"), parent: flag("parent").flatMap { pid_t($0) },
264
+ idle: flag("idle").flatMap { TimeInterval($0) } ?? 60,
265
+ posFile: flag("pos-file"))
264
266
 
265
267
  case "trusted":
266
268
  let payload = ["ok": true, "trusted": AXTree.trusted()] as [String: Any]
@@ -22,9 +22,9 @@ enum AgentClient {
22
22
  let buttons: [(String, String)]
23
23
  let imageURL: String?
24
24
  let durationMs: Int?
25
- /// The agent that answered and its ElevenLabs voice, when it has one.
25
+ /// The agent that answered and how it sounds.
26
26
  let agentID: String?
27
- let voiceID: String?
27
+ let voice: VoiceChoice?
28
28
  /// Presence mode the daemon chose for this turn (talk, teach, …).
29
29
  var presenceMode: String? = nil
30
30
  }
@@ -54,28 +54,37 @@ enum AgentClient {
54
54
  return (try? JSONDecoder().decode(Reply.self, from: data))?.say
55
55
  }
56
56
 
57
- // MARK: Talk mode
57
+ // MARK: The door
58
58
  //
59
- // While two agents talk out loud (`agentx talk`), Option-Space is the
60
- // door: pressing it hushes them at once, and what is said goes to the
61
- // talk instead of /ask. See src/daemon/voice-talk-api.ts.
59
+ // Option-Space is one door for everything spoken: a talk, a live
60
+ // lesson, task narration, an agent's bubble. Pressing it hushes all of
61
+ // them at once; what is then said goes to the activity that was
62
+ // speaking, which answers it first. See src/daemon/voice-talk-api.ts.
62
63
 
63
- /// Silence a running talk. Returns whether one is running, so the
64
- /// caller knows where the words about to be spoken should go. Never
65
- /// throws: no daemon means no talk.
66
- static func talkHush() async -> Bool {
67
- guard let (data, _) = try? await post("/talk/hush", [:], timeout: 2) else { return false }
68
- struct Reply: Decodable { let active: Bool? }
69
- return (try? JSONDecoder().decode(Reply.self, from: data))?.active ?? false
64
+ /// What was speaking when the door opened.
65
+ struct Hushed {
66
+ /// "talk", "lesson" or "narration"; nil when nothing was.
67
+ let kind: String?
68
+ let agentID: String?
69
+ static let nothing = Hushed(kind: nil, agentID: nil)
70
70
  }
71
71
 
72
- /// Hand the listener's words to the talk; "stop" ends it.
73
- static func talkDoor(_ text: String) async throws {
74
- let (_, response) = try await post("/talk/door", ["text": text], timeout: 5)
75
- if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
76
- throw NSError(domain: "AgentXVoice", code: http.statusCode,
77
- userInfo: [NSLocalizedDescriptionKey: "The talk has ended"])
78
- }
72
+ /// Silence everything the daemon is saying. Never throws: no daemon
73
+ /// means nothing was speaking.
74
+ static func hush() async -> Hushed {
75
+ guard let (data, _) = try? await post("/voice/hush", [:], timeout: 2) else { return .nothing }
76
+ struct Reply: Decodable { let kind: String?; let agentId: String? }
77
+ let r = try? JSONDecoder().decode(Reply.self, from: data)
78
+ return Hushed(kind: r?.kind, agentID: r?.agentId)
79
+ }
80
+
81
+ /// Hand the listener's words through the door. True when an activity
82
+ /// took them (a talk or lesson answers; "stop" ends it); false means
83
+ /// nothing did, and they are an ordinary question.
84
+ static func door(_ text: String) async -> Bool {
85
+ guard let (_, response) = try? await post("/voice/door", ["text": text], timeout: 5),
86
+ let http = response as? HTTPURLResponse else { return false }
87
+ return (200..<300).contains(http.statusCode)
79
88
  }
80
89
 
81
90
  private static func post(_ path: String, _ body: [String: Any], timeout: TimeInterval) async throws -> (Data, URLResponse) {
@@ -104,11 +113,10 @@ enum AgentClient {
104
113
  struct UiButton: Decodable { let label: String; let url: String }
105
114
  struct UiMedia: Decodable { let type: String; let url: String; let caption: String? }
106
115
  struct Ui: Decodable { let buttons: [UiButton]?; let media: UiMedia? }
107
- struct Voice: Decodable { let elevenlabsVoiceId: String? }
108
116
  struct Presence: Decodable { let mode: String? }
109
117
  struct Reply: Decodable {
110
118
  let agentId: String?
111
- let voice: Voice?
119
+ let voice: VoiceChoice?
112
120
  let presence: Presence?
113
121
  let text: String?
114
122
  let full: String?
@@ -117,7 +125,7 @@ enum AgentClient {
117
125
  let duration: Int?
118
126
  }
119
127
  let reply = try? JSONDecoder().decode(Reply.self, from: data)
120
- let voiceID = reply?.voice?.elevenlabsVoiceId
128
+ let voice = reply?.voice
121
129
 
122
130
  // 202 means accepted-but-busy: the daemon has already written a
123
131
  // speakable explanation into `text`. Falling through to the error
@@ -126,7 +134,7 @@ enum AgentClient {
126
134
  if let http = response as? HTTPURLResponse, http.statusCode == 202,
127
135
  let queuedText = reply?.text, !queuedText.isEmpty {
128
136
  return Answer(text: queuedText, written: nil, buttons: [], imageURL: nil,
129
- durationMs: reply?.duration, agentID: reply?.agentId, voiceID: voiceID)
137
+ durationMs: reply?.duration, agentID: reply?.agentId, voice: voice)
130
138
  }
131
139
 
132
140
  if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
@@ -148,7 +156,7 @@ enum AgentClient {
148
156
 
149
157
  return Answer(text: text, written: reply?.full,
150
158
  buttons: buttons + extra, imageURL: image,
151
- durationMs: reply?.duration, agentID: reply?.agentId, voiceID: voiceID,
159
+ durationMs: reply?.duration, agentID: reply?.agentId, voice: voice,
152
160
  presenceMode: reply?.presence?.mode)
153
161
  }
154
162
  }
@@ -10,6 +10,10 @@ enum Config {
10
10
  static let daemonURL = env("AGENTX_DAEMON_URL") ?? "http://127.0.0.1:18800"
11
11
  static let agentID = env("AGENTX_VOICE_AGENT") ?? "secretary-agent"
12
12
 
13
+ /// Who speaks before the daemon has said which voice an agent uses:
14
+ /// "system" (free macOS voices, the default) or "elevenlabs".
15
+ static let voiceProvider = env("AGENTX_VOICE_PROVIDER") ?? "system"
16
+
13
17
  /// ElevenLabs default voice, used when the answering agent has none
14
18
  /// configured. Override with AGENTX_VOICE_ID.
15
19
  static let voiceID = env("AGENTX_VOICE_ID") ?? "21m00Tcm4TlvDq8ikWAM"
@@ -17,7 +17,7 @@ final class Progress: NSObject, URLSessionDataDelegate {
17
17
  private var buffer = Data()
18
18
  private let agentID: String
19
19
  /// The step label, and the working agent's voice id when it has one.
20
- private let onStep: (String, String?) -> Void
20
+ private let onStep: (String, VoiceChoice?) -> Void
21
21
 
22
22
  /// Every touch of `buffer`, `task` and `session` happens here.
23
23
  ///
@@ -31,7 +31,7 @@ final class Progress: NSObject, URLSessionDataDelegate {
31
31
  private let queue = DispatchQueue(label: "tn.acme.agentx.voice.progress")
32
32
  private var stopped = false
33
33
 
34
- init(agentID: String, onStep: @escaping (String, String?) -> Void) {
34
+ init(agentID: String, onStep: @escaping (String, VoiceChoice?) -> Void) {
35
35
  self.agentID = agentID
36
36
  self.onStep = onStep
37
37
  }
@@ -113,8 +113,8 @@ final class Progress: NSObject, URLSessionDataDelegate {
113
113
  // and narrating theirs would be both confusing and a privacy leak.
114
114
  guard (obj["agentId"] as? String) == agentID else { return }
115
115
  guard let label = summarise(obj) else { return }
116
- let voiceID = (obj["voice"] as? [String: Any])?["elevenlabsVoiceId"] as? String
117
- DispatchQueue.main.async { self.onStep(label, voiceID) }
116
+ let voice = VoiceChoice(json: obj["voice"])
117
+ DispatchQueue.main.async { self.onStep(label, voice) }
118
118
  }
119
119
 
120
120
  /// Turn a step event into something worth showing in 180 points of