agentix-cli 0.34.0 → 0.36.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 (91) hide show
  1. package/apps/mac-helper/Sources/AgentXHelper/Presence.swift +294 -0
  2. package/apps/mac-helper/Sources/AgentXHelper/PresenceTag.swift +147 -0
  3. package/apps/mac-helper/Sources/AgentXHelper/main.swift +9 -1
  4. package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +33 -19
  5. package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +9 -0
  6. package/apps/mac-voice/Sources/AgentXVoice/main.swift +111 -62
  7. package/dist/{App-QHPZXA42.js → App-WY6GOC3O.js} +2 -2
  8. package/dist/{ChatApp-EAHHH365.js → ChatApp-H7W5V2QL.js} +2 -2
  9. package/dist/{agent-KBBO7OZS.js → agent-6MW3J5FN.js} +2 -2
  10. package/dist/{board-dashboard-74OLMLAD.js → board-dashboard-FKT5GEYE.js} +4 -4
  11. package/dist/{chunk-CY24RXE4.js → chunk-2IK2SIFN.js} +2 -2
  12. package/dist/{chunk-33G4TIZT.js → chunk-3CGB43RZ.js} +221 -202
  13. package/dist/chunk-3CGB43RZ.js.map +1 -0
  14. package/dist/{chunk-IUNCX6BG.js → chunk-4LDSMWKQ.js} +2 -2
  15. package/dist/{chunk-5H5L75RN.js → chunk-AOVH27BF.js} +2 -2
  16. package/dist/{chunk-QXAUKNMW.js → chunk-AXYREZLV.js} +2 -2
  17. package/dist/{chunk-JDC35PWM.js → chunk-BTMNOTTO.js} +2 -2
  18. package/dist/{chunk-U5P7XGCB.js → chunk-CHWSVTY7.js} +2 -2
  19. package/dist/{chunk-MJYE5ADH.js → chunk-COY76JUG.js} +2 -2
  20. package/dist/{chunk-IVAGDL5M.js → chunk-CRKOJZVK.js} +2 -2
  21. package/dist/{chunk-PG7BAZIZ.js → chunk-DPNBGANY.js} +2 -2
  22. package/dist/chunk-EBWWNNJF.js +13 -0
  23. package/dist/chunk-EBWWNNJF.js.map +1 -0
  24. package/dist/{chunk-6WGTLDME.js → chunk-EXBIM6WZ.js} +133 -142
  25. package/dist/chunk-EXBIM6WZ.js.map +1 -0
  26. package/dist/{chunk-UJF5JUFJ.js → chunk-GM5PFIJU.js} +2 -2
  27. package/dist/{chunk-QKNAGTJK.js → chunk-TRAVSDZN.js} +2 -2
  28. package/dist/{chunk-X6HLI6XB.js → chunk-YAER4PD6.js} +2 -2
  29. package/dist/{chunk-ZMFUBIEQ.js → chunk-ZA7FEJKR.js} +5 -5
  30. package/dist/cli.js +1 -1
  31. package/dist/client-OC7COXM6.js +2 -0
  32. package/dist/config-KAMUYXO3.js +2 -0
  33. package/dist/config-mutator-SMGYCOYW.js +2 -0
  34. package/dist/doctor-RNQDJCCI.js +2 -0
  35. package/dist/index.d.ts +133 -0
  36. package/dist/index.js +1 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/program-Q3URXEEE.js +2 -0
  39. package/dist/query-PINMYFMF.js +2 -0
  40. package/dist/{registry-3RN7I6YW.js → registry-LANHP7WG.js} +2 -2
  41. package/dist/{request-planner-JX7M3PTD.js → request-planner-S4YWZEOG.js} +2 -2
  42. package/dist/{review-candidates-UTS4RX4U.js → review-candidates-3E3GW4K7.js} +2 -2
  43. package/dist/{routing-D7APOAP6.js → routing-QT4Z2ORA.js} +2 -2
  44. package/dist/scheduler-JUYBJLLK.js +2 -0
  45. package/dist/{seat-UA2ROH3V.js → seat-VFYCLEMH.js} +2 -2
  46. package/dist/{select-WQXHYEPD.js → select-SIJHCD54.js} +2 -2
  47. package/dist/wiki-NY6V6OAT.js +2 -0
  48. package/package.json +1 -1
  49. package/dist/chunk-33G4TIZT.js.map +0 -1
  50. package/dist/chunk-6WGTLDME.js.map +0 -1
  51. package/dist/chunk-SPJWXIAV.js +0 -13
  52. package/dist/chunk-SPJWXIAV.js.map +0 -1
  53. package/dist/client-CAGQYJML.js +0 -2
  54. package/dist/config-NPLE42XY.js +0 -2
  55. package/dist/config-mutator-FFTODESU.js +0 -2
  56. package/dist/doctor-X4KHQFRF.js +0 -2
  57. package/dist/program-ISXECD4J.js +0 -2
  58. package/dist/query-XLJ6I34S.js +0 -2
  59. package/dist/scheduler-EGR5AJAQ.js +0 -2
  60. package/dist/wiki-RNANPNUX.js +0 -2
  61. /package/dist/{App-QHPZXA42.js.map → App-WY6GOC3O.js.map} +0 -0
  62. /package/dist/{ChatApp-EAHHH365.js.map → ChatApp-H7W5V2QL.js.map} +0 -0
  63. /package/dist/{agent-KBBO7OZS.js.map → agent-6MW3J5FN.js.map} +0 -0
  64. /package/dist/{board-dashboard-74OLMLAD.js.map → board-dashboard-FKT5GEYE.js.map} +0 -0
  65. /package/dist/{chunk-CY24RXE4.js.map → chunk-2IK2SIFN.js.map} +0 -0
  66. /package/dist/{chunk-IUNCX6BG.js.map → chunk-4LDSMWKQ.js.map} +0 -0
  67. /package/dist/{chunk-5H5L75RN.js.map → chunk-AOVH27BF.js.map} +0 -0
  68. /package/dist/{chunk-QXAUKNMW.js.map → chunk-AXYREZLV.js.map} +0 -0
  69. /package/dist/{chunk-JDC35PWM.js.map → chunk-BTMNOTTO.js.map} +0 -0
  70. /package/dist/{chunk-U5P7XGCB.js.map → chunk-CHWSVTY7.js.map} +0 -0
  71. /package/dist/{chunk-MJYE5ADH.js.map → chunk-COY76JUG.js.map} +0 -0
  72. /package/dist/{chunk-IVAGDL5M.js.map → chunk-CRKOJZVK.js.map} +0 -0
  73. /package/dist/{chunk-PG7BAZIZ.js.map → chunk-DPNBGANY.js.map} +0 -0
  74. /package/dist/{chunk-UJF5JUFJ.js.map → chunk-GM5PFIJU.js.map} +0 -0
  75. /package/dist/{chunk-QKNAGTJK.js.map → chunk-TRAVSDZN.js.map} +0 -0
  76. /package/dist/{chunk-X6HLI6XB.js.map → chunk-YAER4PD6.js.map} +0 -0
  77. /package/dist/{chunk-ZMFUBIEQ.js.map → chunk-ZA7FEJKR.js.map} +0 -0
  78. /package/dist/{client-CAGQYJML.js.map → client-OC7COXM6.js.map} +0 -0
  79. /package/dist/{config-NPLE42XY.js.map → config-KAMUYXO3.js.map} +0 -0
  80. /package/dist/{config-mutator-FFTODESU.js.map → config-mutator-SMGYCOYW.js.map} +0 -0
  81. /package/dist/{doctor-X4KHQFRF.js.map → doctor-RNQDJCCI.js.map} +0 -0
  82. /package/dist/{program-ISXECD4J.js.map → program-Q3URXEEE.js.map} +0 -0
  83. /package/dist/{query-XLJ6I34S.js.map → query-PINMYFMF.js.map} +0 -0
  84. /package/dist/{registry-3RN7I6YW.js.map → registry-LANHP7WG.js.map} +0 -0
  85. /package/dist/{request-planner-JX7M3PTD.js.map → request-planner-S4YWZEOG.js.map} +0 -0
  86. /package/dist/{review-candidates-UTS4RX4U.js.map → review-candidates-3E3GW4K7.js.map} +0 -0
  87. /package/dist/{routing-D7APOAP6.js.map → routing-QT4Z2ORA.js.map} +0 -0
  88. /package/dist/{scheduler-EGR5AJAQ.js.map → scheduler-JUYBJLLK.js.map} +0 -0
  89. /package/dist/{seat-UA2ROH3V.js.map → seat-VFYCLEMH.js.map} +0 -0
  90. /package/dist/{select-WQXHYEPD.js.map → select-SIJHCD54.js.map} +0 -0
  91. /package/dist/{wiki-RNANPNUX.js.map → wiki-NY6V6OAT.js.map} +0 -0
@@ -0,0 +1,294 @@
1
+ import AppKit
2
+
3
+ /// An agent's own cursor on screen: its colour, its initial, its name.
4
+ ///
5
+ /// Drawn, never driven. `point` moves the person's real mouse, which is
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 arrow
8
+ /// that glides to what the agent means, highlights it, and says a line in
9
+ /// a bubble, while the real pointer stays exactly where the person left it.
10
+ ///
11
+ /// Driven over stdin, one JSON object per line, like the HUD:
12
+ ///
13
+ /// {"cmd":"move","x":120,"y":80,"w":90,"h":24,"highlight":true}
14
+ /// {"cmd":"say","text":"The export button is top right."}
15
+ /// {"cmd":"clear"} drop the highlight
16
+ /// {"cmd":"park"} rest in the bottom-right corner, no highlight
17
+ /// {"cmd":"ping"} nothing; keeps an idle overlay alive
18
+ ///
19
+ /// Coordinates are accessibility coordinates (top-left origin, global),
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.
30
+ enum Presence {
31
+
32
+ // MARK: Geometry
33
+
34
+ /// Accessibility → AppKit. Global AX coordinates are measured from the
35
+ /// top of the PRIMARY display, whichever screen the point is on.
36
+ static func toAppKit(_ p: CGPoint) -> CGPoint {
37
+ let h = NSScreen.screens.first?.frame.maxY ?? 0
38
+ return CGPoint(x: p.x, y: h - p.y)
39
+ }
40
+
41
+ static func toAppKit(_ r: CGRect) -> CGRect {
42
+ let o = toAppKit(CGPoint(x: r.minX, y: r.maxY))
43
+ return CGRect(x: o.x, y: o.y, width: r.width, height: r.height)
44
+ }
45
+
46
+ // MARK: Windows
47
+
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 {
51
+ let p = NSPanel(contentRect: rect, styleMask: [.borderless, .nonactivatingPanel],
52
+ backing: .buffered, defer: false)
53
+ p.level = .floating
54
+ p.isOpaque = false
55
+ p.backgroundColor = .clear
56
+ p.hasShadow = false
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
60
+ p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
61
+ return p
62
+ }
63
+
64
+ /// The arrow alone: the system cursor's silhouette, in the agent's colour.
65
+ private final class ArrowView: NSView {
66
+ let color: NSColor
67
+ static let size = NSSize(width: 30, height: 40)
68
+ /// The arrow tip, in view coordinates (bottom-left origin).
69
+ static let tip = NSPoint(x: 4, y: size.height - 4)
70
+
71
+ init(color: NSColor) {
72
+ self.color = color
73
+ super.init(frame: NSRect(origin: .zero, size: ArrowView.size))
74
+ }
75
+ required init?(coder: NSCoder) { fatalError() }
76
+
77
+ override func draw(_ dirtyRect: NSRect) {
78
+ let t = ArrowView.tip
79
+ let arrow = NSBezierPath()
80
+ arrow.move(to: t)
81
+ arrow.line(to: NSPoint(x: t.x, y: t.y - 26))
82
+ arrow.line(to: NSPoint(x: t.x + 7, y: t.y - 20))
83
+ arrow.line(to: NSPoint(x: t.x + 12, y: t.y - 30))
84
+ arrow.line(to: NSPoint(x: t.x + 16, y: t.y - 28))
85
+ arrow.line(to: NSPoint(x: t.x + 11, y: t.y - 18))
86
+ arrow.line(to: NSPoint(x: t.x + 20, y: t.y - 18))
87
+ arrow.close()
88
+ NSGraphicsContext.current?.saveGraphicsState()
89
+ let shadow = NSShadow()
90
+ shadow.shadowBlurRadius = 4
91
+ shadow.shadowOffset = NSSize(width: 0, height: -1)
92
+ shadow.shadowColor = NSColor.black.withAlphaComponent(0.35)
93
+ shadow.set()
94
+ color.setFill(); arrow.fill()
95
+ NSGraphicsContext.current?.restoreGraphicsState()
96
+ NSColor.white.setStroke(); arrow.lineWidth = 1.5; arrow.stroke()
97
+ }
98
+ }
99
+
100
+ // MARK: Pinned position
101
+
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
+ }
113
+
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)!)
124
+ }
125
+ }
126
+
127
+ // MARK: Behaviour
128
+
129
+ private final class Controller {
130
+ let arrow: NSPanel
131
+ let tagPanel: NSPanel
132
+ let tag: PresenceTag
133
+ let ring: NSPanel
134
+ let color: NSColor
135
+ let posFile: String?
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?
139
+ var timer: Timer?
140
+
141
+ init(color: NSColor, initial: String, name: String, posFile: String?) {
142
+ self.color = color
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
149
+ ring = Presence.panel(.zero)
150
+ let ringView = NSView()
151
+ ringView.wantsLayer = true
152
+ ringView.layer?.borderWidth = 3
153
+ ringView.layer?.borderColor = color.cgColor
154
+ ringView.layer?.cornerRadius = 8
155
+ ringView.layer?.backgroundColor = color.withAlphaComponent(0.10).cgColor
156
+ ring.contentView = ringView
157
+ pinned = Presence.loadPin(posFile)
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
+ }
166
+ place(at)
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
+ }
176
+ }
177
+
178
+ static func parking() -> CGPoint {
179
+ let v = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
180
+ return CGPoint(x: v.maxX - 380, y: v.minY + 170)
181
+ }
182
+
183
+ func place(_ tip: CGPoint) {
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))
194
+ }
195
+
196
+ /// Glide with minimum-jerk easing; duration follows distance, as in
197
+ /// Pointer, so a long move reads as a reach and a short one as a nudge.
198
+ func glide(to target: CGPoint) {
199
+ timer?.invalidate()
200
+ let from = at
201
+ let d = hypot(target.x - from.x, target.y - from.y)
202
+ let duration = min(0.9, max(0.2, 0.12 + d / 2200))
203
+ let start = Date()
204
+ timer = Timer.scheduledTimer(withTimeInterval: 1.0 / 60, repeats: true) { [weak self] t in
205
+ guard let self else { t.invalidate(); return }
206
+ let u = min(1, Date().timeIntervalSince(start) / duration)
207
+ let e = 10 * pow(u, 3) - 15 * pow(u, 4) + 6 * pow(u, 5)
208
+ self.at = CGPoint(x: from.x + (target.x - from.x) * e, y: from.y + (target.y - from.y) * e)
209
+ self.place(self.at)
210
+ if u >= 1 { t.invalidate() }
211
+ }
212
+ }
213
+
214
+ func highlight(_ rect: CGRect?) {
215
+ guard let rect else { ring.orderOut(nil); return }
216
+ ring.setFrame(Presence.toAppKit(rect).insetBy(dx: -6, dy: -6), display: true)
217
+ ring.orderFrontRegardless()
218
+ arrow.orderFrontRegardless()
219
+ tagPanel.orderFrontRegardless()
220
+ }
221
+
222
+ func handle(_ obj: [String: Any]) {
223
+ switch obj["cmd"] as? String {
224
+ case "move":
225
+ guard let x = obj["x"] as? Double, let y = obj["y"] as? Double else { return }
226
+ let w = obj["w"] as? Double ?? 0, h = obj["h"] as? Double ?? 0
227
+ let rect = CGRect(x: x, y: y, width: w, height: h)
228
+ // Aim just inside the control's lower-left third, so the
229
+ // arrow points AT it rather than covering its label.
230
+ let aim = CGPoint(x: x + min(w * 0.5, 24), y: y + h * 0.7)
231
+ glide(to: Presence.toAppKit(aim))
232
+ highlight((obj["highlight"] as? Bool ?? false) && w > 0 && h > 0 ? rect : nil)
233
+ case "say":
234
+ tag.setBubble(obj["text"] as? String ?? "")
235
+ place(at)
236
+ case "clear":
237
+ highlight(nil)
238
+ case "park":
239
+ highlight(nil)
240
+ glide(to: Controller.parking())
241
+ default:
242
+ break
243
+ }
244
+ }
245
+
246
+ func fadeOut(_ done: @escaping () -> Void) {
247
+ ring.orderOut(nil)
248
+ NSAnimationContext.runAnimationGroup { ctx in
249
+ ctx.duration = 0.25
250
+ arrow.animator().alphaValue = 0
251
+ tagPanel.animator().alphaValue = 0
252
+ } completionHandler: { done() }
253
+ }
254
+ }
255
+
256
+ static func color(hex: String?) -> NSColor {
257
+ guard let hex, hex.count == 7, hex.hasPrefix("#"), let v = UInt32(hex.dropFirst(), radix: 16) else {
258
+ return Brand.primaryBright
259
+ }
260
+ return NSColor(srgbRed: CGFloat((v >> 16) & 0xff) / 255, green: CGFloat((v >> 8) & 0xff) / 255,
261
+ blue: CGFloat(v & 0xff) / 255, alpha: 1)
262
+ }
263
+
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) {
267
+ let app = NSApplication.shared
268
+ app.setActivationPolicy(.accessory)
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
+ }
279
+ DispatchQueue(label: "tn.acme.agentx.presence.stdin").async {
280
+ while let line = readLine(strippingNewline: true) {
281
+ guard let data = line.data(using: .utf8),
282
+ let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { continue }
283
+ DispatchQueue.main.async { lastCommand = Date(); ctl.handle(obj) }
284
+ }
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() }
291
+ }
292
+ app.run()
293
+ }
294
+ }
@@ -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
+ }
@@ -256,10 +256,18 @@ case "hud":
256
256
  // Long-lived: reads update lines on stdin until EOF.
257
257
  HUD.run()
258
258
 
259
+ case "presence":
260
+ // Long-lived: the agent's own drawn cursor. Never moves the real mouse.
261
+ let name = flag("name") ?? "Agent"
262
+ Presence.run(name: name, initial: flag("initial") ?? String(name.prefix(1)).uppercased(),
263
+ colorHex: flag("color"), parent: flag("parent").flatMap { pid_t($0) },
264
+ idle: flag("idle").flatMap { TimeInterval($0) } ?? 60,
265
+ posFile: flag("pos-file"))
266
+
259
267
  case "trusted":
260
268
  let payload = ["ok": true, "trusted": AXTree.trusted()] as [String: Any]
261
269
  FileHandle.standardOutput.write(try! JSONSerialization.data(withJSONObject: payload))
262
270
 
263
271
  default:
264
- fail("unknown verb \"\(verb)\" — expected read, screens, point, click, type, key, scroll, drag, ocr, capture, hittest, focused, hud or trusted")
272
+ fail("unknown verb \"\(verb)\" — expected read, screens, point, click, type, key, scroll, drag, ocr, capture, hittest, focused, hud, presence or trusted")
265
273
  }
@@ -25,6 +25,8 @@ enum AgentClient {
25
25
  /// The agent that answered and its ElevenLabs voice, when it has one.
26
26
  let agentID: String?
27
27
  let voiceID: String?
28
+ /// Presence mode the daemon chose for this turn (talk, teach, …).
29
+ var presenceMode: String? = nil
28
30
  }
29
31
 
30
32
  /// What, if anything, to say about the step now running.
@@ -52,28 +54,37 @@ enum AgentClient {
52
54
  return (try? JSONDecoder().decode(Reply.self, from: data))?.say
53
55
  }
54
56
 
55
- // MARK: Talk mode
57
+ // MARK: The door
56
58
  //
57
- // While two agents talk out loud (`agentx talk`), Option-Space is the
58
- // door: pressing it hushes them at once, and what is said goes to the
59
- // 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.
60
63
 
61
- /// Silence a running talk. Returns whether one is running, so the
62
- /// caller knows where the words about to be spoken should go. Never
63
- /// throws: no daemon means no talk.
64
- static func talkHush() async -> Bool {
65
- guard let (data, _) = try? await post("/talk/hush", [:], timeout: 2) else { return false }
66
- struct Reply: Decodable { let active: Bool? }
67
- 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)
68
70
  }
69
71
 
70
- /// Hand the listener's words to the talk; "stop" ends it.
71
- static func talkDoor(_ text: String) async throws {
72
- let (_, response) = try await post("/talk/door", ["text": text], timeout: 5)
73
- if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
74
- throw NSError(domain: "AgentXVoice", code: http.statusCode,
75
- userInfo: [NSLocalizedDescriptionKey: "The talk has ended"])
76
- }
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)
77
88
  }
78
89
 
79
90
  private static func post(_ path: String, _ body: [String: Any], timeout: TimeInterval) async throws -> (Data, URLResponse) {
@@ -103,9 +114,11 @@ enum AgentClient {
103
114
  struct UiMedia: Decodable { let type: String; let url: String; let caption: String? }
104
115
  struct Ui: Decodable { let buttons: [UiButton]?; let media: UiMedia? }
105
116
  struct Voice: Decodable { let elevenlabsVoiceId: String? }
117
+ struct Presence: Decodable { let mode: String? }
106
118
  struct Reply: Decodable {
107
119
  let agentId: String?
108
120
  let voice: Voice?
121
+ let presence: Presence?
109
122
  let text: String?
110
123
  let full: String?
111
124
  let ui: Ui?
@@ -144,6 +157,7 @@ enum AgentClient {
144
157
 
145
158
  return Answer(text: text, written: reply?.full,
146
159
  buttons: buttons + extra, imageURL: image,
147
- durationMs: reply?.duration, agentID: reply?.agentId, voiceID: voiceID)
160
+ durationMs: reply?.duration, agentID: reply?.agentId, voiceID: voiceID,
161
+ presenceMode: reply?.presence?.mode)
148
162
  }
149
163
  }
@@ -72,6 +72,9 @@ enum Speech {
72
72
 
73
73
  // MARK: Text to speech
74
74
 
75
+ /// Silence whatever this app is saying (an answer or a step line).
76
+ static func stop() { Player.shared.stop() }
77
+
75
78
  /// `voiceID` is the answering agent's voice; nil means the global default.
76
79
  static func speak(_ text: String, voiceID: String? = nil) async {
77
80
  guard !text.isEmpty else { return }
@@ -154,6 +157,12 @@ final class Player: NSObject, AVAudioPlayerDelegate {
154
157
  func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
155
158
  finished?.resume(); finished = nil; self.player = nil
156
159
  }
160
+
161
+ /// Cut the line off now; the `play` awaiting it returns.
162
+ func stop() {
163
+ player?.stop()
164
+ finished?.resume(); finished = nil; player = nil
165
+ }
157
166
  }
158
167
 
159
168
  /// Logs to stderr AND to a file.