agentix-cli 0.27.0 → 0.29.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 (123) hide show
  1. package/README.md +93 -118
  2. package/apps/mac-helper/Sources/AgentXHelper/AXTree.swift +159 -0
  3. package/apps/mac-helper/Sources/AgentXHelper/Brand.swift +63 -0
  4. package/apps/mac-helper/Sources/AgentXHelper/Focus.swift +97 -0
  5. package/apps/mac-helper/Sources/AgentXHelper/HUD.swift +179 -0
  6. package/apps/mac-helper/Sources/AgentXHelper/OCR.swift +117 -0
  7. package/apps/mac-helper/Sources/AgentXHelper/Pointer.swift +309 -0
  8. package/apps/mac-helper/Sources/AgentXHelper/Screens.swift +67 -0
  9. package/apps/mac-helper/Sources/AgentXHelper/Typer.swift +185 -0
  10. package/apps/mac-helper/Sources/AgentXHelper/Vision.swift +201 -0
  11. package/apps/mac-helper/Sources/AgentXHelper/main.swift +265 -0
  12. package/apps/mac-helper/build.sh +39 -0
  13. package/apps/mac-voice/Resources/Info.plist +18 -0
  14. package/apps/mac-voice/Sources/AgentXVoice/AgentClient.swift +108 -0
  15. package/apps/mac-voice/Sources/AgentXVoice/Brand.swift +123 -0
  16. package/apps/mac-voice/Sources/AgentXVoice/Config.swift +68 -0
  17. package/apps/mac-voice/Sources/AgentXVoice/Hold.swift +74 -0
  18. package/apps/mac-voice/Sources/AgentXVoice/Hotkey.swift +76 -0
  19. package/apps/mac-voice/Sources/AgentXVoice/Markdown.swift +140 -0
  20. package/apps/mac-voice/Sources/AgentXVoice/Panel.swift +275 -0
  21. package/apps/mac-voice/Sources/AgentXVoice/Progress.swift +147 -0
  22. package/apps/mac-voice/Sources/AgentXVoice/Recorder.swift +139 -0
  23. package/apps/mac-voice/Sources/AgentXVoice/ResultCard.swift +162 -0
  24. package/apps/mac-voice/Sources/AgentXVoice/SmartPaste.swift +72 -0
  25. package/apps/mac-voice/Sources/AgentXVoice/Speech.swift +192 -0
  26. package/apps/mac-voice/Sources/AgentXVoice/StepLabel.swift +159 -0
  27. package/apps/mac-voice/Sources/AgentXVoice/main.swift +380 -0
  28. package/apps/mac-voice/build.sh +32 -0
  29. package/dist/{App-U52IPUK2.js → App-IEQXWJ7B.js} +2 -2
  30. package/dist/{ChatApp-XP4OMHPL.js → ChatApp-NWOSAP5U.js} +2 -2
  31. package/dist/{agent-V2CA4TXD.js → agent-LS4CZPJ6.js} +2 -2
  32. package/dist/{board-dashboard-TTMAYRLG.js → board-dashboard-6QQCKFB3.js} +8 -10
  33. package/dist/board-dashboard-6QQCKFB3.js.map +1 -0
  34. package/dist/{chunk-DCDQZR7I.js → chunk-3QVOWDHZ.js} +6 -6
  35. package/dist/chunk-3QVOWDHZ.js.map +1 -0
  36. package/dist/{chunk-FIEJDZVC.js → chunk-4KXBUORV.js} +2 -2
  37. package/dist/{chunk-UFAGYX73.js → chunk-6F2IKJ2F.js} +8 -8
  38. package/dist/chunk-6F2IKJ2F.js.map +1 -0
  39. package/dist/chunk-AE2LDMZU.js +2 -0
  40. package/dist/chunk-AE2LDMZU.js.map +1 -0
  41. package/dist/{chunk-VTE7L6LO.js → chunk-DI7FMCDK.js} +2 -2
  42. package/dist/{chunk-AX2PWSRT.js → chunk-F5AEXRPR.js} +2 -2
  43. package/dist/{chunk-CJQQIRPD.js → chunk-GMNKJXTR.js} +7 -7
  44. package/dist/chunk-GMNKJXTR.js.map +1 -0
  45. package/dist/{chunk-L2IZPR6P.js → chunk-JIOLJIBM.js} +2 -2
  46. package/dist/{chunk-IB3F7VBE.js → chunk-JKYMXLYL.js} +3 -3
  47. package/dist/chunk-JKYMXLYL.js.map +1 -0
  48. package/dist/{chunk-LNRB5MCU.js → chunk-LNWX4MPX.js} +151 -134
  49. package/dist/chunk-LNWX4MPX.js.map +1 -0
  50. package/dist/{chunk-WZ72HX5C.js → chunk-OTXDKKEU.js} +145 -138
  51. package/dist/chunk-OTXDKKEU.js.map +1 -0
  52. package/dist/{chunk-IBUSGCLQ.js → chunk-SBCZLZG3.js} +2 -2
  53. package/dist/{chunk-2T5W3ETS.js → chunk-SC5DIKBB.js} +2 -2
  54. package/dist/{chunk-BPIRO4YD.js → chunk-UYSNGBDT.js} +2 -2
  55. package/dist/chunk-XPXO5K4L.js +164 -0
  56. package/dist/chunk-XPXO5K4L.js.map +1 -0
  57. package/dist/{chunk-GDWJUGB5.js → chunk-Z2Y24ETP.js} +2 -2
  58. package/dist/cli.js +1 -1
  59. package/dist/cli.js.map +1 -1
  60. package/dist/client-5Z3URGPY.js +2 -0
  61. package/dist/config-ORSGLVRW.js +2 -0
  62. package/dist/config-mutator-JB3S2E7H.js +2 -0
  63. package/dist/doctor-FS2BX6H4.js +2 -0
  64. package/dist/index.d.ts +63 -42
  65. package/dist/index.js +12 -19
  66. package/dist/index.js.map +1 -1
  67. package/dist/program-I4J63BQ7.js +2 -0
  68. package/dist/query-R7EI3VGX.js +2 -0
  69. package/dist/registry-UJIJBLUR.js +2 -0
  70. package/dist/request-planner-BKT6RZKX.js +2 -0
  71. package/dist/request-planner-BKT6RZKX.js.map +1 -0
  72. package/dist/{review-candidates-AEUXRQQC.js → review-candidates-F3OFYTCZ.js} +2 -2
  73. package/dist/routing-S5WSFIAD.js +2 -0
  74. package/dist/scheduler-DSAOMV4O.js +2 -0
  75. package/dist/{seat-VZ7AVO27.js → seat-ZVHXNAEN.js} +2 -2
  76. package/dist/{select-F5I5PO2O.js → select-L7C52URW.js} +2 -2
  77. package/dist/wiki-5YQI4YKM.js +2 -0
  78. package/dist/wiki-5YQI4YKM.js.map +1 -0
  79. package/package.json +14 -3
  80. package/scripts/postinstall.mjs +80 -0
  81. package/dist/board-dashboard-TTMAYRLG.js.map +0 -1
  82. package/dist/chunk-CJQQIRPD.js.map +0 -1
  83. package/dist/chunk-DCDQZR7I.js.map +0 -1
  84. package/dist/chunk-IB3F7VBE.js.map +0 -1
  85. package/dist/chunk-LNRB5MCU.js.map +0 -1
  86. package/dist/chunk-OPHIXXGI.js +0 -157
  87. package/dist/chunk-OPHIXXGI.js.map +0 -1
  88. package/dist/chunk-UFAGYX73.js.map +0 -1
  89. package/dist/chunk-WZ72HX5C.js.map +0 -1
  90. package/dist/client-PX25MD47.js +0 -2
  91. package/dist/config-HL5F36V4.js +0 -2
  92. package/dist/config-mutator-HBX53OMJ.js +0 -2
  93. package/dist/doctor-PBAH6XL3.js +0 -2
  94. package/dist/program-YGXVCV2Y.js +0 -2
  95. package/dist/query-ZKTFJMNE.js +0 -2
  96. package/dist/registry-PSZDPFZK.js +0 -2
  97. package/dist/routing-4PV66BK6.js +0 -2
  98. package/dist/routing-4PV66BK6.js.map +0 -1
  99. package/dist/scheduler-GZZAN6ZG.js +0 -2
  100. package/dist/wiki-N5SMP3DT.js +0 -2
  101. /package/dist/{App-U52IPUK2.js.map → App-IEQXWJ7B.js.map} +0 -0
  102. /package/dist/{ChatApp-XP4OMHPL.js.map → ChatApp-NWOSAP5U.js.map} +0 -0
  103. /package/dist/{agent-V2CA4TXD.js.map → agent-LS4CZPJ6.js.map} +0 -0
  104. /package/dist/{chunk-FIEJDZVC.js.map → chunk-4KXBUORV.js.map} +0 -0
  105. /package/dist/{chunk-VTE7L6LO.js.map → chunk-DI7FMCDK.js.map} +0 -0
  106. /package/dist/{chunk-AX2PWSRT.js.map → chunk-F5AEXRPR.js.map} +0 -0
  107. /package/dist/{chunk-L2IZPR6P.js.map → chunk-JIOLJIBM.js.map} +0 -0
  108. /package/dist/{chunk-IBUSGCLQ.js.map → chunk-SBCZLZG3.js.map} +0 -0
  109. /package/dist/{chunk-2T5W3ETS.js.map → chunk-SC5DIKBB.js.map} +0 -0
  110. /package/dist/{chunk-BPIRO4YD.js.map → chunk-UYSNGBDT.js.map} +0 -0
  111. /package/dist/{chunk-GDWJUGB5.js.map → chunk-Z2Y24ETP.js.map} +0 -0
  112. /package/dist/{client-PX25MD47.js.map → client-5Z3URGPY.js.map} +0 -0
  113. /package/dist/{config-HL5F36V4.js.map → config-ORSGLVRW.js.map} +0 -0
  114. /package/dist/{config-mutator-HBX53OMJ.js.map → config-mutator-JB3S2E7H.js.map} +0 -0
  115. /package/dist/{doctor-PBAH6XL3.js.map → doctor-FS2BX6H4.js.map} +0 -0
  116. /package/dist/{program-YGXVCV2Y.js.map → program-I4J63BQ7.js.map} +0 -0
  117. /package/dist/{query-ZKTFJMNE.js.map → query-R7EI3VGX.js.map} +0 -0
  118. /package/dist/{registry-PSZDPFZK.js.map → registry-UJIJBLUR.js.map} +0 -0
  119. /package/dist/{review-candidates-AEUXRQQC.js.map → review-candidates-F3OFYTCZ.js.map} +0 -0
  120. /package/dist/{scheduler-GZZAN6ZG.js.map → routing-S5WSFIAD.js.map} +0 -0
  121. /package/dist/{seat-VZ7AVO27.js.map → scheduler-DSAOMV4O.js.map} +0 -0
  122. /package/dist/{wiki-N5SMP3DT.js.map → seat-ZVHXNAEN.js.map} +0 -0
  123. /package/dist/{select-F5I5PO2O.js.map → select-L7C52URW.js.map} +0 -0
@@ -0,0 +1,275 @@
1
+ import AppKit
2
+
3
+ /// The floating widget itself.
4
+ ///
5
+ /// NSPanel with .nonactivatingPanel so pressing the hotkey never pulls
6
+ /// focus out of whatever you were doing — the entire point of a voice
7
+ /// assistant is that you don't stop working to use it. It floats above
8
+ /// normal windows, joins every Space, and is deliberately small.
9
+ final class Panel: NSPanel {
10
+ private let label = NSTextField(labelWithString: "")
11
+ private let orb = NSView()
12
+
13
+ /// Scrolls text too long for the pill instead of truncating it.
14
+ ///
15
+ /// Step text is routinely wider than 180 points, and an ellipsis hides
16
+ /// exactly the part that makes it reassuring.
17
+ ///
18
+ /// The first version rotated the STRING by one character every 0.18s.
19
+ /// That flickers, and unavoidably: every tick re-lays out the whole
20
+ /// text, and the motion is quantised to a character width, so it jumps
21
+ /// rather than slides. Instead the label now moves inside a clipping
22
+ /// view, animated by Core Animation — continuous, sub-pixel, composited
23
+ /// off the main thread, and it never touches the text once set.
24
+ private var clip: NSView?
25
+ private var marqueeText = ""
26
+
27
+ private var pressedAt: NSPoint?
28
+
29
+ /// A click acts on mouse UP, and only when the pointer has not moved.
30
+ ///
31
+ /// The panel is draggable by its background, so acting on mouse DOWN
32
+ /// would fire every time someone repositioned it — you could not move
33
+ /// the widget without talking to it.
34
+ override func mouseDown(with event: NSEvent) {
35
+ pressedAt = NSEvent.mouseLocation
36
+ super.mouseDown(with: event)
37
+ }
38
+
39
+ /// Right-click opens the only menu the widget has.
40
+ ///
41
+ /// A hold has to be reversible in one gesture from wherever you are,
42
+ /// and it has to SHOW — a switch that silences your notifications
43
+ /// without saying so is how people miss things for a day and blame the
44
+ /// software.
45
+ override func rightMouseDown(with event: NSEvent) {
46
+ let menu = NSMenu()
47
+ let holding = Hold.isOn
48
+ let item = NSMenuItem(
49
+ title: holding ? "Delivering notifications" : "Hold notifications",
50
+ action: #selector(toggleHold), keyEquivalent: "")
51
+ item.target = self
52
+ item.state = holding ? .on : .off
53
+ menu.addItem(item)
54
+ menu.addItem(.separator())
55
+ let quit = NSMenuItem(title: "Quit AgentX Voice",
56
+ action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q")
57
+ menu.addItem(quit)
58
+ NSMenu.popUpContextMenu(menu, with: event, for: contentView ?? NSView())
59
+ }
60
+
61
+ @objc private func toggleHold() {
62
+ let nowOn = Hold.toggle()
63
+ onHoldChanged?(nowOn)
64
+ }
65
+
66
+ /// Told when the hold flips, so the app can redraw and flush.
67
+ var onHoldChanged: ((Bool) -> Void)?
68
+
69
+ override func mouseUp(with event: NSEvent) {
70
+ defer { pressedAt = nil }
71
+ if let start = pressedAt {
72
+ let now = NSEvent.mouseLocation
73
+ let moved = hypot(now.x - start.x, now.y - start.y)
74
+ if moved < 4 { onClick?() }
75
+ }
76
+ super.mouseUp(with: event)
77
+ }
78
+
79
+ enum State {
80
+ case idle, listening, thinking, speaking
81
+ /// Live activity from the daemon, with seconds elapsed — the
82
+ /// difference between "it's working" and "it's hung".
83
+ case working(String, Int)
84
+ /// What is being said right now, scrolled in full.
85
+ ///
86
+ /// "Speaking" told you the state and not the content, which is the
87
+ /// wrong half: you already know it is speaking, you can hear it.
88
+ /// What you cannot do is re-read a sentence that has gone past, or
89
+ /// follow it at all in a noisy room.
90
+ case saying(String)
91
+ case error(String)
92
+
93
+ var text: String {
94
+ switch self {
95
+ case .idle: return Hold.isOn ? "notifications held" : "hold ⌥space"
96
+ case .listening: return "Listening"
97
+ case .thinking: return "Thinking"
98
+ case .speaking: return "Speaking"
99
+ case .saying(let text): return text
100
+ // "·" is the system's own separator glyph.
101
+ case .working(let what, let secs): return "\(what) · \(secs)s"
102
+ case .error(let m): return m
103
+ }
104
+ }
105
+ var color: NSColor {
106
+ switch self {
107
+ // Teal carries every ACTIVE state, so the widget reads as one
108
+ // thing doing work rather than a traffic light. System reds
109
+ // and greens were macOS's voice, not the product's, and they
110
+ // shift with the user's accent-colour setting.
111
+ case .idle: return Hold.isOn ? Brand.warn : .tertiaryLabelColor
112
+ case .listening: return Brand.accent
113
+ case .thinking, .working: return Brand.primaryBright
114
+ case .speaking, .saying: return Brand.accentDeep
115
+ case .error: return Brand.alert
116
+ }
117
+ }
118
+
119
+ /// Idle is an invitation and belongs in meta type; everything else
120
+ /// is a running commentary and belongs in body type.
121
+ var isMeta: Bool { if case .idle = self { return true }; return false }
122
+ }
123
+
124
+ /// Called when the pill is clicked. Set by the app.
125
+ ///
126
+ /// The hotkey is faster once you know it, and invisible until then.
127
+ /// A widget whose only affordance is a chord you have to be told about
128
+ /// is a widget most people never use — "HOLD ⌥SPACE" is printed on it
129
+ /// precisely because there was nothing to click.
130
+ var onClick: (() -> Void)?
131
+
132
+ init() {
133
+ super.init(contentRect: NSRect(x: 0, y: 0, width: 230, height: 54),
134
+ styleMask: [.borderless, .nonactivatingPanel],
135
+ backing: .buffered, defer: false)
136
+ isFloatingPanel = true
137
+ level = .floating
138
+ collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
139
+ isOpaque = false
140
+ backgroundColor = .clear
141
+ hasShadow = true
142
+ isMovableByWindowBackground = true
143
+ hidesOnDeactivate = false
144
+
145
+ let blur = NSVisualEffectView(frame: NSRect(origin: .zero, size: NSSize(width: 230, height: 54)))
146
+ blur.material = .hudWindow
147
+ blur.blendingMode = .behindWindow
148
+ blur.state = .active
149
+ blur.wantsLayer = true
150
+ blur.layer?.cornerRadius = Brand.Radius.lg
151
+ blur.layer?.masksToBounds = true
152
+ blur.autoresizingMask = [.width, .height]
153
+ contentView = blur
154
+
155
+ // An opaque tint over the blur, and it is not a style preference.
156
+ //
157
+ // `blendingMode = .behindWindow` composites whatever is behind the
158
+ // pill, so the editor's own panel dividers were coming through it
159
+ // as a faint rectangle around the widget — it read as a border the
160
+ // widget was drawing, when it was the window behind. Translucency
161
+ // is pleasant over wallpaper and actively confusing over ruled UI.
162
+ //
163
+ // This keeps enough blur to feel native while making the pill read
164
+ // as one solid object wherever it is parked.
165
+ let tint = NSView(frame: blur.bounds)
166
+ tint.wantsLayer = true
167
+ tint.layer?.backgroundColor = NSColor.windowBackgroundColor
168
+ .withAlphaComponent(0.72).cgColor
169
+ tint.layer?.cornerRadius = Brand.Radius.lg
170
+ tint.autoresizingMask = [.width, .height]
171
+ blur.addSubview(tint)
172
+
173
+ orb.wantsLayer = true
174
+ orb.layer?.cornerRadius = 4
175
+ orb.frame = NSRect(x: 18, y: 22, width: 8, height: 8)
176
+ blur.addSubview(orb)
177
+
178
+ // A clipping window the label slides behind.
179
+ let clipView = NSView(frame: NSRect(x: 38, y: 17, width: 180, height: 20))
180
+ clipView.wantsLayer = true
181
+ clipView.layer?.masksToBounds = true
182
+ label.frame = NSRect(x: 0, y: 0, width: 180, height: 20)
183
+ label.font = Brand.body(size: 12)
184
+ label.lineBreakMode = .byClipping
185
+ label.wantsLayer = true
186
+ clipView.addSubview(label)
187
+ blur.addSubview(clipView)
188
+ clip = clipView
189
+
190
+ render(.idle)
191
+ positionBottomRight()
192
+ }
193
+
194
+ /// Bottom-right, clear of the Dock — out of the way but visible.
195
+ private func positionBottomRight() {
196
+ guard let screen = NSScreen.main else { return }
197
+ let v = screen.visibleFrame
198
+ setFrameOrigin(NSPoint(x: v.maxX - frame.width - 24, y: v.minY + 24))
199
+ }
200
+
201
+ /// AppKit is not thread-safe. Marking this explicitly means a stray
202
+ /// call from a delegate queue is a compile error rather than heap
203
+ /// corruption that traps somewhere unrelated an hour later.
204
+ @MainActor
205
+ func render(_ state: State) {
206
+ orb.layer?.backgroundColor = state.color.cgColor
207
+ // A soft halo on the dot while active: the same --nq-ring-accent
208
+ // idea, and the only ornament on the pill.
209
+ orb.layer?.shadowColor = state.color.cgColor
210
+ orb.layer?.shadowOpacity = state.isMeta ? 0 : 0.55
211
+ orb.layer?.shadowRadius = 5
212
+ orb.layer?.shadowOffset = .zero
213
+
214
+ if state.isMeta {
215
+ stopMarquee()
216
+ label.attributedStringValue = Brand.metaString(state.text, color: state.color)
217
+ label.frame.origin.x = 0
218
+ } else {
219
+ label.textColor = state.color
220
+ setText(state.text)
221
+ }
222
+ }
223
+
224
+ /// Fits, or scrolls. Identical text is left alone so a per-second tick
225
+ /// cannot restart the animation and make it stutter in place.
226
+ @MainActor
227
+ private func setText(_ text: String) {
228
+ guard let clipView = clip else { label.stringValue = text; return }
229
+ let font = label.font ?? NSFont.systemFont(ofSize: 12)
230
+ let width = (text as NSString).size(withAttributes: [.font: font]).width
231
+
232
+ if width <= clipView.bounds.width {
233
+ stopMarquee()
234
+ label.stringValue = text
235
+ label.frame.size.width = clipView.bounds.width
236
+ label.frame.origin.x = 0
237
+ return
238
+ }
239
+ if text == marqueeText { return }
240
+ marqueeText = text
241
+ startMarquee(text: text, textWidth: width, in: clipView)
242
+ }
243
+
244
+ @MainActor
245
+ private func startMarquee(text: String, textWidth: CGFloat, in clipView: NSView) {
246
+ label.layer?.removeAnimation(forKey: "marquee")
247
+
248
+ // Gap so the loop point reads as a pause rather than words colliding.
249
+ let gap: CGFloat = 40
250
+ let travel = textWidth + gap
251
+ label.stringValue = text
252
+ label.frame = NSRect(x: 0, y: 0, width: textWidth + gap, height: clipView.bounds.height)
253
+
254
+ let slide = CABasicAnimation(keyPath: "position.x")
255
+ slide.fromValue = label.layer?.position.x ?? 0
256
+ slide.byValue = -travel
257
+ // Speed proportional to length, so a long step is not punishingly
258
+ // slow and a short one is not a blur. ~40 points a second reads at
259
+ // a glance without demanding attention.
260
+ slide.duration = CFTimeInterval(travel / 40)
261
+ slide.repeatCount = .infinity
262
+ slide.isRemovedOnCompletion = false
263
+ label.layer?.add(slide, forKey: "marquee")
264
+ }
265
+
266
+ @MainActor
267
+ private func stopMarquee() {
268
+ label.layer?.removeAnimation(forKey: "marquee")
269
+ marqueeText = ""
270
+ }
271
+
272
+ // Borderless panels refuse key status unless told otherwise; without
273
+ // this the widget cannot show a caret or take any future text input.
274
+ override var canBecomeKey: Bool { true }
275
+ }
@@ -0,0 +1,147 @@
1
+ import Foundation
2
+
3
+ /// Live activity from the daemon while a turn runs.
4
+ ///
5
+ /// A voice assistant that goes silent for ten seconds reads as broken, and
6
+ /// the honest fix is not a spinner — it is saying what is actually
7
+ /// happening. The daemon already publishes `task:step` on its /events SSE
8
+ /// with a step name, action and input summary, so the widget can narrate
9
+ /// real work instead of inventing reassurance.
10
+ ///
11
+ /// Deliberately best-effort: if the stream drops or the shape changes, the
12
+ /// turn still completes and the answer is still spoken. Progress is
13
+ /// decoration over a working path, never a dependency of it.
14
+ final class Progress: NSObject, URLSessionDataDelegate {
15
+ private var task: URLSessionDataTask?
16
+ private var session: URLSession?
17
+ private var buffer = Data()
18
+ private let agentID: String
19
+ private let onStep: (String) -> Void
20
+
21
+ /// Every touch of `buffer`, `task` and `session` happens here.
22
+ ///
23
+ /// URLSession delivers on its own queue while stop() is called from the
24
+ /// main thread at the end of a turn, and both mutated `buffer`. Two
25
+ /// threads mutating a Data value is not a race you get to lose
26
+ /// gracefully — it corrupts the heap, and the process traps later in
27
+ /// unrelated code (a CATransaction flush, in the crash that prompted
28
+ /// this). Serialising is the fix; a lock around only removeAll() would
29
+ /// not be, because the append path mutates it too.
30
+ private let queue = DispatchQueue(label: "tn.acme.agentx.voice.progress")
31
+ private var stopped = false
32
+
33
+ init(agentID: String, onStep: @escaping (String) -> Void) {
34
+ self.agentID = agentID
35
+ self.onStep = onStep
36
+ }
37
+
38
+ func start() {
39
+ queue.async { [weak self] in self?.startLocked() }
40
+ }
41
+
42
+ private func startLocked() {
43
+ guard !stopped else { return }
44
+ guard let url = URL(string: "\(Config.daemonURL)/events?type=task") else { return }
45
+ var req = URLRequest(url: url)
46
+ req.setValue("text/event-stream", forHTTPHeaderField: "Accept")
47
+ req.timeoutInterval = 3600
48
+ let cfg = URLSessionConfiguration.default
49
+ cfg.timeoutIntervalForRequest = 3600
50
+ // Deliver on our own serial queue rather than an arbitrary one, so
51
+ // didReceive and stop() cannot interleave.
52
+ let opQueue = OperationQueue()
53
+ opQueue.maxConcurrentOperationCount = 1
54
+ opQueue.underlyingQueue = queue
55
+ let s = URLSession(configuration: cfg, delegate: self, delegateQueue: opQueue)
56
+ session = s
57
+ task = s.dataTask(with: req)
58
+ task?.resume()
59
+ }
60
+
61
+ func stop() {
62
+ queue.async { [weak self] in
63
+ guard let self, !self.stopped else { return }
64
+ self.stopped = true
65
+ self.task?.cancel(); self.task = nil
66
+ // invalidateAndCancel breaks the session's strong reference to
67
+ // this delegate; without it the object leaks for the life of
68
+ // the app and keeps receiving events after the turn ended.
69
+ self.session?.invalidateAndCancel(); self.session = nil
70
+ self.buffer.removeAll()
71
+ }
72
+ }
73
+
74
+ deinit { task?.cancel(); session?.invalidateAndCancel() }
75
+
76
+ // Already on `queue` — see the delegateQueue wiring in startLocked().
77
+ func urlSession(_ s: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
78
+ guard !stopped else { return }
79
+ buffer.append(data)
80
+ // SSE frames are separated by a blank line. Anything not yet
81
+ // terminated stays buffered for the next packet.
82
+ while let range = buffer.range(of: Data("\n\n".utf8)) {
83
+ let frame = buffer.subdata(in: buffer.startIndex..<range.lowerBound)
84
+ buffer.removeSubrange(buffer.startIndex..<range.upperBound)
85
+ handle(String(data: frame, encoding: .utf8) ?? "")
86
+ }
87
+ }
88
+
89
+ private func handle(_ frame: String) {
90
+ var event = "message", payload = ""
91
+ for line in frame.split(separator: "\n", omittingEmptySubsequences: false) {
92
+ if line.hasPrefix("event:") { event = line.dropFirst(6).trimmingCharacters(in: .whitespaces) }
93
+ if line.hasPrefix("data:") { payload += line.dropFirst(5).trimmingCharacters(in: .whitespaces) }
94
+ }
95
+ guard event.contains("task"), !payload.isEmpty,
96
+ let data = payload.data(using: .utf8),
97
+ let outer = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
98
+ else { return }
99
+
100
+ // broadcastSSE wraps every frame as {time, message}, where
101
+ // `message` is the payload re-encoded as a STRING. So the step is
102
+ // one JSON level deeper than the frame appears to be — unwrap it,
103
+ // while still accepting a flat frame in case that ever changes.
104
+ var obj = outer
105
+ if let inner = outer["message"] as? String,
106
+ let innerData = inner.data(using: .utf8),
107
+ let parsed = try? JSONSerialization.jsonObject(with: innerData) as? [String: Any] {
108
+ obj = parsed
109
+ }
110
+
111
+ // Only this widget's agent. Other agents are busy on their own work
112
+ // and narrating theirs would be both confusing and a privacy leak.
113
+ guard (obj["agentId"] as? String) == agentID else { return }
114
+ guard let label = summarise(obj) else { return }
115
+ DispatchQueue.main.async { self.onStep(label) }
116
+ }
117
+
118
+ /// Turn a step event into something worth showing in 180 points of
119
+ /// label. Prefers the human-facing action over the internal step name.
120
+ private func summarise(_ obj: [String: Any]) -> String? {
121
+ // Only the start of a step is worth announcing. A tool_result
122
+ // arrives a beat later and would just repeat what was said.
123
+ if (obj["name"] as? String) == "tool_result" { return nil }
124
+
125
+ let tool = (obj["action"] as? String) ?? (obj["name"] as? String) ?? ""
126
+ guard !tool.isEmpty, tool != "tool_use" else { return nil }
127
+
128
+ // inputSummary is the tool's raw arguments as JSON. The useful
129
+ // part is one field — a command, a path, a query — and showing the
130
+ // braces instead would waste the whole label.
131
+ var detail = ""
132
+ if let input = obj["inputSummary"] as? String,
133
+ let d = input.data(using: .utf8),
134
+ let args = try? JSONSerialization.jsonObject(with: d) as? [String: Any] {
135
+ for key in ["command", "file_path", "path", "query", "pattern", "url", "description"] {
136
+ if let v = args[key] as? String, !v.isEmpty { detail = v; break }
137
+ }
138
+ }
139
+ // Raw tool names never reach the screen. The marquee used to show
140
+ // things like "mcp__agentx__agentx_task", which is unreadable to
141
+ // anyone outside this codebase and appears during exactly the long
142
+ // waits the widget exists to explain.
143
+ guard let text = StepLabel.describe(tool: tool, detail: detail) else { return nil }
144
+ // Long text is fine — the panel marquees it rather than truncating.
145
+ return text.count > 90 ? String(text.prefix(88)) + "…" : text
146
+ }
147
+ }
@@ -0,0 +1,139 @@
1
+ import AVFoundation
2
+
3
+ /// Microphone capture that yields exactly what a speech API wants: 16 kHz
4
+ /// mono 16-bit PCM in a RIFF container.
5
+ ///
6
+ /// The hardware input is whatever the device feels like (commonly 48 kHz
7
+ /// float32, and on some Macs the format is only valid AFTER the engine
8
+ /// starts), so the tap installs with the input node's own format and an
9
+ /// AVAudioConverter does the rest. Converting in the tap rather than at
10
+ /// the end keeps memory flat for long utterances.
11
+ final class Recorder {
12
+ private let engine = AVAudioEngine()
13
+ private var converter: AVAudioConverter?
14
+ private var pcm = Data()
15
+ private let lock = NSLock()
16
+ private(set) var isRecording = false
17
+
18
+ /// Loudness of the most recent buffer, 0…1, smoothed.
19
+ ///
20
+ /// Exists so the widget can tell talking from silence without a second
21
+ /// audio tap. Hands-free listening needs to know when you have
22
+ /// FINISHED a sentence, and the only honest signal for that is the
23
+ /// microphone going quiet — a fixed timeout either cuts people off
24
+ /// mid-thought or leaves the mic open staring at them.
25
+ ///
26
+ /// Read from the main thread while the audio thread writes it. A
27
+ /// Float write is atomic on every platform this runs on, and a reader
28
+ /// that occasionally sees the previous buffer's value is choosing
29
+ /// between two adjacent 100ms windows — which changes nothing.
30
+ private(set) var level: Float = 0
31
+
32
+ /// 16 kHz mono int16 — the format both ElevenLabs and whisper prefer,
33
+ /// and small enough that a 30-second utterance is under 1 MB.
34
+ private let target = AVAudioFormat(
35
+ commonFormat: .pcmFormatInt16, sampleRate: 16_000, channels: 1, interleaved: true)!
36
+
37
+ enum RecorderError: LocalizedError {
38
+ case micDenied
39
+ case tooShort
40
+ var errorDescription: String? {
41
+ switch self {
42
+ case .micDenied: return "Microphone access denied. Grant it in System Settings › Privacy & Security › Microphone."
43
+ case .tooShort: return "Didn't catch that — hold the hotkey while you speak."
44
+ }
45
+ }
46
+ }
47
+
48
+ func requestPermission(_ done: @escaping (Bool) -> Void) {
49
+ switch AVCaptureDevice.authorizationStatus(for: .audio) {
50
+ case .authorized: done(true)
51
+ case .notDetermined: AVCaptureDevice.requestAccess(for: .audio) { ok in
52
+ DispatchQueue.main.async { done(ok) } }
53
+ default: done(false)
54
+ }
55
+ }
56
+
57
+ func start() throws {
58
+ level = 0
59
+ guard !isRecording else { return }
60
+ lock.lock(); pcm.removeAll(keepingCapacity: true); lock.unlock()
61
+
62
+ let input = engine.inputNode
63
+ let inputFormat = input.inputFormat(forBus: 0)
64
+ guard inputFormat.sampleRate > 0 else { throw RecorderError.micDenied }
65
+ converter = AVAudioConverter(from: inputFormat, to: target)
66
+
67
+ input.installTap(onBus: 0, bufferSize: 4096, format: inputFormat) { [weak self] buf, _ in
68
+ self?.append(buf)
69
+ }
70
+ engine.prepare()
71
+ try engine.start()
72
+ isRecording = true
73
+ }
74
+
75
+ /// Stops and returns a WAV, or nil when nothing usable was captured.
76
+ func stop() -> Data? {
77
+ guard isRecording else { return nil }
78
+ engine.inputNode.removeTap(onBus: 0)
79
+ engine.stop()
80
+ isRecording = false
81
+
82
+ lock.lock(); let body = pcm; lock.unlock()
83
+ // 16 kHz * 2 bytes = 32 kB/s; under a quarter second is a misfire,
84
+ // not speech, and sending it just wastes an API call.
85
+ guard body.count > 8_000 else { return nil }
86
+ return wav(body)
87
+ }
88
+
89
+ private func append(_ buffer: AVAudioPCMBuffer) {
90
+ guard let converter else { return }
91
+ let ratio = target.sampleRate / buffer.format.sampleRate
92
+ let capacity = AVAudioFrameCount(Double(buffer.frameLength) * ratio) + 1024
93
+ guard let out = AVAudioPCMBuffer(pcmFormat: target, frameCapacity: capacity) else { return }
94
+
95
+ var supplied = false
96
+ var error: NSError?
97
+ converter.convert(to: out, error: &error) { _, status in
98
+ if supplied { status.pointee = .noDataNow; return nil }
99
+ supplied = true
100
+ status.pointee = .haveData
101
+ return buffer
102
+ }
103
+ guard error == nil, out.frameLength > 0,
104
+ let channel = out.int16ChannelData?[0] else { return }
105
+
106
+ // RMS over the buffer, in the same pass that copies it.
107
+ var sum: Float = 0
108
+ for i in 0..<Int(out.frameLength) {
109
+ let sample = Float(channel[i]) / 32768.0
110
+ sum += sample * sample
111
+ }
112
+ let rms = (sum / Float(max(Int(out.frameLength), 1))).squareRoot()
113
+ // Attack fast, release slow: a level that drops instantly makes a
114
+ // pause between words look like the end of a sentence.
115
+ level = rms > level ? rms : level * 0.82 + rms * 0.18
116
+
117
+ let bytes = Int(out.frameLength) * MemoryLayout<Int16>.size
118
+ lock.lock()
119
+ pcm.append(UnsafeBufferPointer(start: channel, count: Int(out.frameLength)).withMemoryRebound(to: UInt8.self) {
120
+ Data(bytes: $0.baseAddress!, count: bytes)
121
+ })
122
+ lock.unlock()
123
+ }
124
+
125
+ /// Minimal RIFF/WAVE header for 16-bit mono PCM.
126
+ private func wav(_ body: Data) -> Data {
127
+ let rate = UInt32(target.sampleRate), channels: UInt16 = 1, bits: UInt16 = 16
128
+ let byteRate = rate * UInt32(channels) * UInt32(bits / 8)
129
+ var d = Data()
130
+ func str(_ s: String) { d.append(s.data(using: .ascii)!) }
131
+ func u32(_ v: UInt32) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } }
132
+ func u16(_ v: UInt16) { withUnsafeBytes(of: v.littleEndian) { d.append(contentsOf: $0) } }
133
+ str("RIFF"); u32(UInt32(36 + body.count)); str("WAVE")
134
+ str("fmt "); u32(16); u16(1); u16(channels); u32(rate); u32(byteRate)
135
+ u16(channels * bits / 8); u16(bits)
136
+ str("data"); u32(UInt32(body.count)); d.append(body)
137
+ return d
138
+ }
139
+ }