1use serde::Serialize;
2use serde::de::DeserializeOwned;
3
4use crate::backend::process::{ProcessFailure, ProcessRequest, ProcessRunner};
5use crate::error::CliError;
6use crate::model::{
7 AxActionPerformRequest, AxActionPerformResult, AxAttrGetRequest, AxAttrGetResult,
8 AxAttrSetRequest, AxAttrSetResult, AxClickRequest, AxClickResult, AxListRequest, AxListResult,
9 AxSelector, AxSessionListResult, AxSessionStartRequest, AxSessionStartResult,
10 AxSessionStopRequest, AxSessionStopResult, AxTypeRequest, AxTypeResult, AxWatchPollRequest,
11 AxWatchPollResult, AxWatchStartRequest, AxWatchStartResult, AxWatchStopRequest,
12 AxWatchStopResult,
13};
14use crate::test_mode;
15
16use super::AxBackendAdapter;
17
18const AX_LIST_TEST_MODE_ENV: &str = "AGENTS_MACOS_AGENT_AX_LIST_JSON";
19const AX_CLICK_TEST_MODE_ENV: &str = "AGENTS_MACOS_AGENT_AX_CLICK_JSON";
20const AX_TYPE_TEST_MODE_ENV: &str = "AGENTS_MACOS_AGENT_AX_TYPE_JSON";
21const AX_ATTR_GET_TEST_MODE_ENV: &str = "AGENTS_MACOS_AGENT_AX_ATTR_GET_JSON";
22const AX_ATTR_SET_TEST_MODE_ENV: &str = "AGENTS_MACOS_AGENT_AX_ATTR_SET_JSON";
23const AX_ACTION_PERFORM_TEST_MODE_ENV: &str = "AGENTS_MACOS_AGENT_AX_ACTION_PERFORM_JSON";
24const AX_SESSION_START_TEST_MODE_ENV: &str = "AGENTS_MACOS_AGENT_AX_SESSION_START_JSON";
25const AX_SESSION_LIST_TEST_MODE_ENV: &str = "AGENTS_MACOS_AGENT_AX_SESSION_LIST_JSON";
26const AX_SESSION_STOP_TEST_MODE_ENV: &str = "AGENTS_MACOS_AGENT_AX_SESSION_STOP_JSON";
27const AX_WATCH_START_TEST_MODE_ENV: &str = "AGENTS_MACOS_AGENT_AX_WATCH_START_JSON";
28const AX_WATCH_POLL_TEST_MODE_ENV: &str = "AGENTS_MACOS_AGENT_AX_WATCH_POLL_JSON";
29const AX_WATCH_STOP_TEST_MODE_ENV: &str = "AGENTS_MACOS_AGENT_AX_WATCH_STOP_JSON";
30const BACKEND_UNAVAILABLE_HINT_PREFIX: &str = "Hammerspoon backend unavailable";
31
32macro_rules! hs_ax_script_with_targeting_prelude {
33 ($operation:literal, $body:literal) => {
34 concat!(
35 r#"
36local json = hs.json
37local appmod = hs.application
38local ax = hs.axuielement
39
40local function fail(message)
41 error(message, 0)
42end
43
44local function safe(callable, fallback)
45 local ok, value = pcall(callable)
46 if ok then return value end
47 return fallback
48end
49
50local function normalize(value)
51 if value == nil then return "" end
52 return tostring(value)
53end
54
55local function asTable(value)
56 if type(value) == "table" then return value end
57 return {}
58end
59
60local function ensureState()
61 _G.__codex_macos_agent_ax = _G.__codex_macos_agent_ax or { sessions = {}, watchers = {} }
62 return _G.__codex_macos_agent_ax
63end
64
65local function resolveTarget(rawTarget)
66 local target = rawTarget or {}
67 local state = ensureState()
68 if target.session_id and tostring(target.session_id) ~= "" then
69 local session = state.sessions[tostring(target.session_id)]
70 if not session then
71 fail("session_id does not exist")
72 end
73 return {
74 session_id = tostring(target.session_id),
75 app = target.app or session.app,
76 bundle_id = target.bundle_id or session.bundle_id,
77 pid = session.pid,
78 window_title_contains = target.window_title_contains or session.window_title_contains,
79 }
80 end
81 return target
82end
83
84local function attr(element, name, fallback)
85 local value = safe(function() return element:attributeValue(name) end, fallback)
86 if value == nil then return fallback end
87 return value
88end
89
90local function boolAttr(element, name, fallback)
91 local value = attr(element, name, fallback)
92 if type(value) == "boolean" then return value end
93 if value == nil then return fallback end
94 return tostring(value):lower() == "true"
95end
96
97local function children(element)
98 return asTable(attr(element, "AXChildren", {}))
99end
100
101local function resolveApp(target)
102 target = resolveTarget(target)
103
104 if target.pid then
105 local byPid = appmod.applicationForPID(tonumber(target.pid))
106 if byPid then return byPid, target end
107 end
108
109 if target.app and tostring(target.app) ~= "" then
110 local found = appmod.find(tostring(target.app))
111 if found then return found, target end
112 end
113
114 if target.bundle_id and tostring(target.bundle_id) ~= "" then
115 local apps = appmod.applicationsForBundleID(tostring(target.bundle_id))
116 if type(apps) == "table" and #apps > 0 then
117 return apps[1], target
118 end
119 end
120
121 return appmod.frontmostApplication(), target
122end
123
124local function rootsForApp(app, target)
125 local appElement = ax.applicationElement(app)
126 if not appElement then
127 fail("unable to resolve target app process")
128 end
129
130 local roots = asTable(attr(appElement, "AXWindows", {}))
131 if #roots == 0 then
132 roots = children(appElement)
133 end
134 local windowFilter = target and target.window_title_contains and string.lower(tostring(target.window_title_contains)) or nil
135 if not windowFilter then
136 return roots
137 end
138
139 local filtered = {}
140 for _, root in ipairs(roots) do
141 local title = string.lower(normalize(attr(root, "AXTitle", "")))
142 if string.find(title, windowFilter, 1, true) then
143 table.insert(filtered, root)
144 end
145 end
146 return filtered
147end
148
149local function copyPath(path)
150 local out = {}
151 for i, value in ipairs(path) do
152 out[i] = value
153 end
154 return out
155end
156
157local function cliPayloadArg(defaultValue)
158 local args = (_cli and _cli.args) or {}
159 local raw = args[1]
160 if raw == "--" then
161 raw = args[2]
162 end
163 if raw == nil or tostring(raw) == "" then
164 return defaultValue
165 end
166 return raw
167end
168
169"#,
170 $body
171 )
172 };
173}
174
175const AX_LIST_HS_SCRIPT: &str = hs_ax_script_with_targeting_prelude!(
176 "ax.list",
177 r#"
178local function frameFor(element)
179 local pos = attr(element, "AXPosition", nil)
180 local size = attr(element, "AXSize", nil)
181 if type(pos) ~= "table" or type(size) ~= "table" then return nil end
182
183 local x = tonumber(pos.x or pos[1])
184 local y = tonumber(pos.y or pos[2])
185 local width = tonumber(size.w or size.width or size[1])
186 local height = tonumber(size.h or size.height or size[2])
187
188 if not x or not y or not width or not height then return nil end
189 return { x = x, y = y, width = width, height = height }
190end
191
192local function actionNames(element)
193 local raw = safe(function() return element:actionNames() end, nil)
194 if type(raw) ~= "table" then return {} end
195 local out = {}
196 for _, name in ipairs(raw) do
197 local text = normalize(name)
198 if text ~= "" then table.insert(out, text) end
199 end
200 return out
201end
202
203local function valuePreview(element)
204 local value = attr(element, "AXValue", nil)
205 if value == nil then return nil end
206 local text = normalize(value)
207 if #text > 160 then
208 text = string.sub(text, 1, 160) .. "..."
209 end
210 return text
211end
212
213local function parsePayload()
214 local raw = cliPayloadArg("{}")
215 local payload = json.decode(raw)
216 if type(payload) ~= "table" then
217 fail("invalid payload JSON")
218 end
219 return payload
220end
221
222local payload = parsePayload()
223local roleFilter = payload.role and string.lower(tostring(payload.role)) or nil
224local titleFilter = payload.title_contains and string.lower(tostring(payload.title_contains)) or nil
225local identifierFilter = payload.identifier_contains and string.lower(tostring(payload.identifier_contains)) or nil
226local valueFilter = payload.value_contains and string.lower(tostring(payload.value_contains)) or nil
227local subroleFilter = payload.subrole and string.lower(tostring(payload.subrole)) or nil
228local maxDepth = payload.max_depth and tonumber(payload.max_depth) or nil
229local limit = payload.limit and tonumber(payload.limit) or nil
230local focusedFilter = payload.focused
231local enabledFilter = payload.enabled
232
233local app, resolvedTarget = resolveApp(payload.target)
234if not app then
235 fail("unable to resolve target app process for ax.list")
236end
237
238local roots = rootsForApp(app, resolvedTarget)
239local nodes = {}
240
241local function nodeFrom(element, path)
242 local role = normalize(attr(element, "AXRole", ""))
243 local title = normalize(attr(element, "AXTitle", ""))
244 local identifier = normalize(attr(element, "AXIdentifier", ""))
245 local subrole = normalize(attr(element, "AXSubrole", ""))
246
247 local node = {
248 node_id = table.concat(path, "."),
249 role = role,
250 subrole = subrole ~= "" and subrole or nil,
251 title = title ~= "" and title or nil,
252 identifier = identifier ~= "" and identifier or nil,
253 value_preview = valuePreview(element),
254 enabled = boolAttr(element, "AXEnabled", true),
255 focused = boolAttr(element, "AXFocused", false),
256 frame = frameFor(element),
257 actions = actionNames(element),
258 path = copyPath(path),
259 }
260
261 return node
262end
263
264local function matches(node)
265 if roleFilter and string.lower(node.role or "") ~= roleFilter then
266 return false
267 end
268
269 if titleFilter then
270 local title = string.lower(node.title or "")
271 local identifier = string.lower(node.identifier or "")
272 if not string.find(title, titleFilter, 1, true) and not string.find(identifier, titleFilter, 1, true) then
273 return false
274 end
275 end
276
277 if identifierFilter and not string.find(string.lower(node.identifier or ""), identifierFilter, 1, true) then
278 return false
279 end
280
281 if valueFilter and not string.find(string.lower(node.value_preview or ""), valueFilter, 1, true) then
282 return false
283 end
284
285 if subroleFilter and string.lower(node.subrole or "") ~= subroleFilter then
286 return false
287 end
288
289 if focusedFilter ~= nil and node.focused ~= focusedFilter then
290 return false
291 end
292
293 if enabledFilter ~= nil and node.enabled ~= enabledFilter then
294 return false
295 end
296
297 return true
298end
299
300local function visit(element, path, depth)
301 if limit and #nodes >= limit then
302 return
303 end
304
305 local node = nodeFrom(element, path)
306 if matches(node) then
307 table.insert(nodes, node)
308 end
309
310 if maxDepth and depth >= maxDepth then
311 return
312 end
313
314 for index, child in ipairs(children(element)) do
315 local childPath = copyPath(path)
316 table.insert(childPath, tostring(index))
317 visit(child, childPath, depth + 1)
318 if limit and #nodes >= limit then
319 return
320 end
321 end
322end
323
324for rootIndex, root in ipairs(roots) do
325 visit(root, { tostring(rootIndex) }, 0)
326 if limit and #nodes >= limit then
327 break
328 end
329end
330
331return json.encode({ nodes = nodes, warnings = {} })
332"#
333);
334
335const AX_CLICK_HS_SCRIPT: &str = hs_ax_script_with_targeting_prelude!(
336 "ax.click",
337 r#"
338local function nodeFrom(element, path)
339 local role = normalize(attr(element, "AXRole", ""))
340 local title = normalize(attr(element, "AXTitle", ""))
341 local identifier = normalize(attr(element, "AXIdentifier", ""))
342 local subrole = normalize(attr(element, "AXSubrole", ""))
343 local value = normalize(attr(element, "AXValue", ""))
344 local focused = normalize(attr(element, "AXFocused", "false"))
345 local enabled = normalize(attr(element, "AXEnabled", "true"))
346 return {
347 node_id = table.concat(path, "."),
348 role = role,
349 title = title,
350 identifier = identifier,
351 subrole = subrole,
352 value_preview = value,
353 focused = string.lower(focused) == "true",
354 enabled = string.lower(enabled) == "true",
355 }
356end
357
358local function frameCenter(element)
359 local pos = attr(element, "AXPosition", nil)
360 local size = attr(element, "AXSize", nil)
361 if type(pos) ~= "table" or type(size) ~= "table" then return nil end
362
363 local x = tonumber(pos.x or pos[1])
364 local y = tonumber(pos.y or pos[2])
365 local width = tonumber(size.w or size.width or size[1])
366 local height = tonumber(size.h or size.height or size[2])
367
368 if not x or not y or not width or not height then return nil end
369 return {
370 x = math.floor(x + width / 2),
371 y = math.floor(y + height / 2),
372 }
373end
374
375local function resolveByNodeId(roots, nodeId)
376 local parts = {}
377 for segment in string.gmatch(tostring(nodeId), "[^.]+") do
378 table.insert(parts, tonumber(segment))
379 end
380 if #parts == 0 then return nil end
381
382 local rootIndex = parts[1]
383 if not rootIndex or rootIndex < 1 or rootIndex > #roots then
384 return nil
385 end
386
387 local element = roots[rootIndex]
388 local path = { tostring(rootIndex) }
389
390 for i = 2, #parts do
391 local childIndex = parts[i]
392 local directChildren = children(element)
393 if not childIndex or childIndex < 1 or childIndex > #directChildren then
394 return nil
395 end
396 element = directChildren[childIndex]
397 table.insert(path, tostring(childIndex))
398 end
399
400 return {
401 element = element,
402 node = nodeFrom(element, path),
403 }
404end
405
406local function parsePayload()
407 local raw = cliPayloadArg("{}")
408 local payload = json.decode(raw)
409 if type(payload) ~= "table" then
410 fail("invalid payload JSON")
411 end
412 return payload
413end
414
415local payload = parsePayload()
416local selector = payload.selector or {}
417local roleFilter = selector.role and string.lower(tostring(selector.role)) or nil
418local titleFilter = selector.title_contains and string.lower(tostring(selector.title_contains)) or nil
419local identifierFilter = selector.identifier_contains and string.lower(tostring(selector.identifier_contains)) or nil
420local valueFilter = selector.value_contains and string.lower(tostring(selector.value_contains)) or nil
421local subroleFilter = selector.subrole and string.lower(tostring(selector.subrole)) or nil
422local focusedFilter = selector.focused
423local enabledFilter = selector.enabled
424local nth = selector.nth and tonumber(selector.nth) or nil
425local allowCoordinateFallback = payload.allow_coordinate_fallback and true or false
426
427local app, resolvedTarget = resolveApp(payload.target)
428if not app then
429 fail("unable to resolve target app process for ax.click")
430end
431
432local roots = rootsForApp(app, resolvedTarget)
433local matches = {}
434
435if selector.node_id then
436 local byId = resolveByNodeId(roots, selector.node_id)
437 if byId then
438 table.insert(matches, byId)
439 end
440else
441 local function walk(element, path)
442 local node = nodeFrom(element, path)
443 local roleMatch = (not roleFilter) or (string.lower(node.role or "") == roleFilter)
444 local title = string.lower(node.title or "")
445 local identifier = string.lower(node.identifier or "")
446 local value = string.lower(node.value_preview or "")
447 local subrole = string.lower(node.subrole or "")
448 local titleMatch = (not titleFilter) or string.find(title, titleFilter, 1, true) or string.find(identifier, titleFilter, 1, true)
449 local identifierMatch = (not identifierFilter) or string.find(identifier, identifierFilter, 1, true)
450 local valueMatch = (not valueFilter) or string.find(value, valueFilter, 1, true)
451 local subroleMatch = (not subroleFilter) or subrole == subroleFilter
452 local focusedMatch = (focusedFilter == nil) or (node.focused == focusedFilter)
453 local enabledMatch = (enabledFilter == nil) or (node.enabled == enabledFilter)
454 if roleMatch and titleMatch and identifierMatch and valueMatch and subroleMatch and focusedMatch and enabledMatch then
455 table.insert(matches, { element = element, node = node })
456 end
457
458 for index, child in ipairs(children(element)) do
459 local childPath = copyPath(path)
460 table.insert(childPath, tostring(index))
461 walk(child, childPath)
462 end
463 end
464
465 for rootIndex, root in ipairs(roots) do
466 walk(root, { tostring(rootIndex) })
467 end
468end
469
470if #matches == 0 then
471 fail("selector returned zero AX matches")
472end
473
474local selected
475if selector.node_id then
476 selected = matches[1]
477elseif nth then
478 if nth < 1 or nth > #matches then
479 fail("selector nth is out of range")
480 end
481 selected = matches[nth]
482else
483 if #matches ~= 1 then
484 fail("selector is ambiguous; add --nth or narrow role/title filters")
485 end
486 selected = matches[1]
487end
488
489local actions = asTable(safe(function() return selected.element:actionNames() end, {}))
490local actionToRun = nil
491for _, name in ipairs(actions) do
492 local value = normalize(name)
493 if value == "AXPress" or value == "AXConfirm" then
494 actionToRun = value
495 break
496 end
497end
498
499local result = {
500 node_id = selected.node.node_id,
501 matched_count = #matches,
502 action = "ax-press",
503 used_coordinate_fallback = false,
504}
505
506local performOk = false
507if actionToRun then
508 performOk = safe(function()
509 selected.element:performAction(actionToRun)
510 return true
511 end, false)
512end
513
514if not performOk then
515 if not allowCoordinateFallback then
516 fail("AXPress action unavailable")
517 end
518 local center = frameCenter(selected.element)
519 if not center then
520 fail("coordinate fallback requested but AXPosition/AXSize unavailable")
521 end
522 result.action = "ax-press-fallback"
523 result.used_coordinate_fallback = true
524 result.fallback_x = center.x
525 result.fallback_y = center.y
526end
527
528return json.encode(result)
529"#
530);
531
532const AX_TYPE_HS_SCRIPT: &str = hs_ax_script_with_targeting_prelude!(
533 "ax.type",
534 r#"
535local eventtap = hs.eventtap
536local pasteboard = hs.pasteboard
537
538local function nodeFrom(element, path)
539 local role = normalize(attr(element, "AXRole", ""))
540 local title = normalize(attr(element, "AXTitle", ""))
541 local identifier = normalize(attr(element, "AXIdentifier", ""))
542 local subrole = normalize(attr(element, "AXSubrole", ""))
543 local value = normalize(attr(element, "AXValue", ""))
544 return {
545 node_id = table.concat(path, "."),
546 role = role,
547 title = title,
548 identifier = identifier,
549 subrole = subrole,
550 value_preview = value,
551 focused = boolAttr(element, "AXFocused", false),
552 enabled = boolAttr(element, "AXEnabled", true),
553 }
554end
555
556local function resolveByNodeId(roots, nodeId)
557 local parts = {}
558 for segment in string.gmatch(tostring(nodeId), "[^.]+") do
559 table.insert(parts, tonumber(segment))
560 end
561 if #parts == 0 then return nil end
562
563 local rootIndex = parts[1]
564 if not rootIndex or rootIndex < 1 or rootIndex > #roots then
565 return nil
566 end
567
568 local element = roots[rootIndex]
569 local path = { tostring(rootIndex) }
570
571 for i = 2, #parts do
572 local childIndex = parts[i]
573 local directChildren = children(element)
574 if not childIndex or childIndex < 1 or childIndex > #directChildren then
575 return nil
576 end
577 element = directChildren[childIndex]
578 table.insert(path, tostring(childIndex))
579 end
580
581 return {
582 element = element,
583 node = nodeFrom(element, path),
584 }
585end
586
587local function parsePayload()
588 local raw = cliPayloadArg("{}")
589 local payload = json.decode(raw)
590 if type(payload) ~= "table" then
591 fail("invalid payload JSON")
592 end
593 return payload
594end
595
596local payload = parsePayload()
597local selector = payload.selector or {}
598local roleFilter = selector.role and string.lower(tostring(selector.role)) or nil
599local titleFilter = selector.title_contains and string.lower(tostring(selector.title_contains)) or nil
600local identifierFilter = selector.identifier_contains and string.lower(tostring(selector.identifier_contains)) or nil
601local valueFilter = selector.value_contains and string.lower(tostring(selector.value_contains)) or nil
602local subroleFilter = selector.subrole and string.lower(tostring(selector.subrole)) or nil
603local focusedFilter = selector.focused
604local enabledFilter = selector.enabled
605local nth = selector.nth and tonumber(selector.nth) or nil
606local text = payload.text and tostring(payload.text) or ""
607local allowKeyboardFallback = payload.allow_keyboard_fallback and true or false
608local clearFirst = payload.clear_first and true or false
609local submit = payload.submit and true or false
610local paste = payload.paste and true or false
611
612if text == "" then
613 fail("text cannot be empty")
614end
615
616local app, resolvedTarget = resolveApp(payload.target)
617if not app then
618 fail("unable to resolve target app process for ax.type")
619end
620safe(function() app:activate() end, nil)
621
622local roots = rootsForApp(app, resolvedTarget)
623local matches = {}
624
625if selector.node_id then
626 local byId = resolveByNodeId(roots, selector.node_id)
627 if byId then
628 table.insert(matches, byId)
629 end
630else
631 local function walk(element, path)
632 local node = nodeFrom(element, path)
633 local roleMatch = (not roleFilter) or (string.lower(node.role or "") == roleFilter)
634 local title = string.lower(node.title or "")
635 local identifier = string.lower(node.identifier or "")
636 local value = string.lower(node.value_preview or "")
637 local subrole = string.lower(node.subrole or "")
638 local titleMatch = (not titleFilter) or string.find(title, titleFilter, 1, true) or string.find(identifier, titleFilter, 1, true)
639 local identifierMatch = (not identifierFilter) or string.find(identifier, identifierFilter, 1, true)
640 local valueMatch = (not valueFilter) or string.find(value, valueFilter, 1, true)
641 local subroleMatch = (not subroleFilter) or subrole == subroleFilter
642 local focusedMatch = (focusedFilter == nil) or (node.focused == focusedFilter)
643 local enabledMatch = (enabledFilter == nil) or (node.enabled == enabledFilter)
644 if roleMatch and titleMatch and identifierMatch and valueMatch and subroleMatch and focusedMatch and enabledMatch then
645 table.insert(matches, { element = element, node = node })
646 end
647
648 for index, child in ipairs(children(element)) do
649 local childPath = copyPath(path)
650 table.insert(childPath, tostring(index))
651 walk(child, childPath)
652 end
653 end
654
655 for rootIndex, root in ipairs(roots) do
656 walk(root, { tostring(rootIndex) })
657 end
658end
659
660if #matches == 0 then
661 fail("selector returned zero AX matches")
662end
663
664local selected
665if selector.node_id then
666 selected = matches[1]
667elseif nth then
668 if nth < 1 or nth > #matches then
669 fail("selector nth is out of range")
670 end
671 selected = matches[nth]
672else
673 if #matches ~= 1 then
674 fail("selector is ambiguous; add --nth or narrow selector filters")
675 end
676 selected = matches[1]
677end
678
679local appliedVia = "ax-set-value"
680local usedKeyboardFallback = false
681
682local function applyPaste()
683 pasteboard.setContents(text)
684 eventtap.keyStroke({"cmd"}, "v", 0)
685end
686
687local appliedOk = safe(function()
688 safe(function() selected.element:setAttributeValue("AXFocused", true) end, nil)
689 if clearFirst then
690 safe(function() selected.element:setAttributeValue("AXValue", "") end, nil)
691 end
692 if paste then
693 applyPaste()
694 appliedVia = "ax-paste"
695 else
696 selected.element:setAttributeValue("AXValue", text)
697 appliedVia = "ax-set-value"
698 end
699 return true
700end, false)
701
702if not appliedOk then
703 if not allowKeyboardFallback then
704 fail("AX value set failed")
705 end
706
707 usedKeyboardFallback = true
708 if paste then
709 applyPaste()
710 appliedVia = "keyboard-paste-fallback"
711 else
712 eventtap.keyStrokes(text)
713 appliedVia = "keyboard-keystroke-fallback"
714 end
715end
716
717if submit then
718 eventtap.keyStroke({}, "return", 0)
719end
720
721return json.encode({
722 node_id = selected.node.node_id,
723 matched_count = #matches,
724 applied_via = appliedVia,
725 text_length = string.len(text),
726 submitted = submit,
727 used_keyboard_fallback = usedKeyboardFallback,
728})
729"#
730);
731
732const AX_ATTR_GET_HS_SCRIPT: &str = hs_ax_script_with_targeting_prelude!(
733 "ax.attr.get",
734 r#"
735local function resolveApp(target)
736 target = resolveTarget(target)
737
738 if target.pid then
739 local byPid = appmod.applicationForPID(tonumber(target.pid))
740 if byPid then return byPid end
741 end
742
743 if target.app and tostring(target.app) ~= "" then
744 local found = appmod.find(tostring(target.app))
745 if found then return found, target end
746 end
747
748 if target.bundle_id and tostring(target.bundle_id) ~= "" then
749 local apps = appmod.applicationsForBundleID(tostring(target.bundle_id))
750 if type(apps) == "table" and #apps > 0 then
751 return apps[1], target
752 end
753 end
754
755 return appmod.frontmostApplication(), target
756end
757
758local function nodeFrom(element, path)
759 local role = normalize(attr(element, "AXRole", ""))
760 local title = normalize(attr(element, "AXTitle", ""))
761 local identifier = normalize(attr(element, "AXIdentifier", ""))
762 local subrole = normalize(attr(element, "AXSubrole", ""))
763 local value = normalize(attr(element, "AXValue", ""))
764 return {
765 node_id = table.concat(path, "."),
766 role = role,
767 title = title,
768 identifier = identifier,
769 subrole = subrole,
770 value_preview = value,
771 focused = boolAttr(element, "AXFocused", false),
772 enabled = boolAttr(element, "AXEnabled", true),
773 }
774end
775
776local function matches(node, selector)
777 selector = selector or {}
778
779 if selector.role and string.lower(node.role or "") ~= string.lower(tostring(selector.role)) then
780 return false
781 end
782 if selector.title_contains and not string.find(string.lower(node.title or ""), string.lower(tostring(selector.title_contains)), 1, true) then
783 return false
784 end
785 if selector.identifier_contains and not string.find(string.lower(node.identifier or ""), string.lower(tostring(selector.identifier_contains)), 1, true) then
786 return false
787 end
788 if selector.value_contains and not string.find(string.lower(node.value_preview or ""), string.lower(tostring(selector.value_contains)), 1, true) then
789 return false
790 end
791 if selector.subrole and string.lower(node.subrole or "") ~= string.lower(tostring(selector.subrole)) then
792 return false
793 end
794 if selector.focused ~= nil and node.focused ~= selector.focused then
795 return false
796 end
797 if selector.enabled ~= nil and node.enabled ~= selector.enabled then
798 return false
799 end
800 return true
801end
802
803local function resolveByNodeId(roots, nodeId)
804 local parts = {}
805 for segment in string.gmatch(tostring(nodeId), "[^.]+") do
806 table.insert(parts, tonumber(segment))
807 end
808 if #parts == 0 then return nil end
809
810 local rootIndex = parts[1]
811 if not rootIndex or rootIndex < 1 or rootIndex > #roots then
812 return nil
813 end
814
815 local element = roots[rootIndex]
816 local path = { tostring(rootIndex) }
817 for i = 2, #parts do
818 local childIndex = parts[i]
819 local directChildren = children(element)
820 if not childIndex or childIndex < 1 or childIndex > #directChildren then
821 return nil
822 end
823 element = directChildren[childIndex]
824 table.insert(path, tostring(childIndex))
825 end
826 return { element = element, node = nodeFrom(element, path) }
827end
828
829local function collectMatches(roots, selector)
830 local matchesOut = {}
831
832 if selector.node_id then
833 local byId = resolveByNodeId(roots, selector.node_id)
834 if byId then
835 table.insert(matchesOut, byId)
836 end
837 return matchesOut
838 end
839
840 local function walk(element, path)
841 local node = nodeFrom(element, path)
842 if matches(node, selector) then
843 table.insert(matchesOut, { element = element, node = node })
844 end
845 for index, child in ipairs(children(element)) do
846 local childPath = copyPath(path)
847 table.insert(childPath, tostring(index))
848 walk(child, childPath)
849 end
850 end
851
852 for rootIndex, root in ipairs(roots) do
853 walk(root, { tostring(rootIndex) })
854 end
855 return matchesOut
856end
857
858local function selectOne(matchesOut, selector)
859 if #matchesOut == 0 then
860 fail("selector returned zero AX matches")
861 end
862
863 if selector.node_id then
864 return matchesOut[1], #matchesOut
865 end
866
867 local nth = selector.nth and tonumber(selector.nth) or nil
868 if nth then
869 if nth < 1 or nth > #matchesOut then
870 fail("selector nth is out of range")
871 end
872 return matchesOut[nth], #matchesOut
873 end
874
875 if #matchesOut ~= 1 then
876 fail("selector is ambiguous; add --nth or narrow selector filters")
877 end
878
879 return matchesOut[1], #matchesOut
880end
881
882local function sanitize(value, depth)
883 depth = depth or 0
884 if depth > 6 then
885 return tostring(value)
886 end
887
888 local kind = type(value)
889 if kind == "nil" then
890 return json.null
891 end
892 if kind == "string" or kind == "number" or kind == "boolean" then
893 return value
894 end
895 if kind == "table" then
896 local out = {}
897 local count = 0
898 local maxIndex = 0
899 local isArray = true
900 for key, _ in pairs(value) do
901 count = count + 1
902 if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then
903 isArray = false
904 break
905 end
906 if key > maxIndex then maxIndex = key end
907 end
908 if isArray and maxIndex ~= count then
909 isArray = false
910 end
911 if isArray then
912 for i = 1, maxIndex do
913 out[i] = sanitize(value[i], depth + 1)
914 end
915 else
916 for key, child in pairs(value) do
917 out[tostring(key)] = sanitize(child, depth + 1)
918 end
919 end
920 return out
921 end
922 return tostring(value)
923end
924
925local function parsePayload()
926 local raw = cliPayloadArg("{}")
927 local payload = json.decode(raw)
928 if type(payload) ~= "table" then
929 fail("invalid payload JSON")
930 end
931 return payload
932end
933
934local payload = parsePayload()
935local selector = payload.selector or {}
936local app, target = resolveApp(payload.target)
937if not app then
938 fail("unable to resolve target app process for ax.attr.get")
939end
940
941local roots = rootsForApp(app, target)
942local matchesOut = collectMatches(roots, selector)
943local selected, matchedCount = selectOne(matchesOut, selector)
944local name = normalize(payload.name)
945if name == "" then
946 fail("attribute name cannot be empty")
947end
948local value = sanitize(attr(selected.element, name, nil), 0)
949
950return json.encode({
951 node_id = selected.node.node_id,
952 matched_count = matchedCount,
953 name = name,
954 value = value,
955})
956"#
957);
958
959const AX_ATTR_SET_HS_SCRIPT: &str = hs_ax_script_with_targeting_prelude!(
960 "ax.attr.set",
961 r#"
962local function nodeFrom(element, path)
963 return {
964 node_id = table.concat(path, "."),
965 role = normalize(attr(element, "AXRole", "")),
966 title = normalize(attr(element, "AXTitle", "")),
967 identifier = normalize(attr(element, "AXIdentifier", "")),
968 subrole = normalize(attr(element, "AXSubrole", "")),
969 value_preview = normalize(attr(element, "AXValue", "")),
970 focused = boolAttr(element, "AXFocused", false),
971 enabled = boolAttr(element, "AXEnabled", true),
972 }
973end
974
975local function matches(node, selector)
976 selector = selector or {}
977 if selector.role and string.lower(node.role or "") ~= string.lower(tostring(selector.role)) then return false end
978 if selector.title_contains and not string.find(string.lower(node.title or ""), string.lower(tostring(selector.title_contains)), 1, true) then return false end
979 if selector.identifier_contains and not string.find(string.lower(node.identifier or ""), string.lower(tostring(selector.identifier_contains)), 1, true) then return false end
980 if selector.value_contains and not string.find(string.lower(node.value_preview or ""), string.lower(tostring(selector.value_contains)), 1, true) then return false end
981 if selector.subrole and string.lower(node.subrole or "") ~= string.lower(tostring(selector.subrole)) then return false end
982 if selector.focused ~= nil and node.focused ~= selector.focused then return false end
983 if selector.enabled ~= nil and node.enabled ~= selector.enabled then return false end
984 return true
985end
986
987local function resolveByNodeId(roots, nodeId)
988 local parts = {}
989 for segment in string.gmatch(tostring(nodeId), "[^.]+") do table.insert(parts, tonumber(segment)) end
990 if #parts == 0 then return nil end
991
992 local rootIndex = parts[1]
993 if not rootIndex or rootIndex < 1 or rootIndex > #roots then return nil end
994
995 local element = roots[rootIndex]
996 local path = { tostring(rootIndex) }
997 for i = 2, #parts do
998 local childIndex = parts[i]
999 local directChildren = children(element)
1000 if not childIndex or childIndex < 1 or childIndex > #directChildren then return nil end
1001 element = directChildren[childIndex]
1002 table.insert(path, tostring(childIndex))
1003 end
1004 return { element = element, node = nodeFrom(element, path) }
1005end
1006
1007local function collectMatches(roots, selector)
1008 local matchesOut = {}
1009
1010 if selector.node_id then
1011 local byId = resolveByNodeId(roots, selector.node_id)
1012 if byId then table.insert(matchesOut, byId) end
1013 return matchesOut
1014 end
1015
1016 local function walk(element, path)
1017 local node = nodeFrom(element, path)
1018 if matches(node, selector) then table.insert(matchesOut, { element = element, node = node }) end
1019 for index, child in ipairs(children(element)) do
1020 local childPath = copyPath(path)
1021 table.insert(childPath, tostring(index))
1022 walk(child, childPath)
1023 end
1024 end
1025
1026 for rootIndex, root in ipairs(roots) do
1027 walk(root, { tostring(rootIndex) })
1028 end
1029 return matchesOut
1030end
1031
1032local function selectOne(matchesOut, selector)
1033 if #matchesOut == 0 then fail("selector returned zero AX matches") end
1034 if selector.node_id then return matchesOut[1], #matchesOut end
1035
1036 local nth = selector.nth and tonumber(selector.nth) or nil
1037 if nth then
1038 if nth < 1 or nth > #matchesOut then fail("selector nth is out of range") end
1039 return matchesOut[nth], #matchesOut
1040 end
1041 if #matchesOut ~= 1 then fail("selector is ambiguous; add --nth or narrow selector filters") end
1042 return matchesOut[1], #matchesOut
1043end
1044
1045local function parsePayload()
1046 local raw = cliPayloadArg("{}")
1047 local payload = json.decode(raw)
1048 if type(payload) ~= "table" then fail("invalid payload JSON") end
1049 return payload
1050end
1051
1052local payload = parsePayload()
1053local name = normalize(payload.name)
1054if name == "" then fail("attribute name cannot be empty") end
1055
1056local app, target = resolveApp(payload.target)
1057if not app then fail("unable to resolve target app process for ax.attr.set") end
1058
1059local roots = rootsForApp(app, target)
1060local matchesOut = collectMatches(roots, payload.selector or {})
1061local selected, matchedCount = selectOne(matchesOut, payload.selector or {})
1062
1063local applied = safe(function()
1064 selected.element:setAttributeValue(name, payload.value)
1065 return true
1066end, false)
1067if not applied then
1068 fail("failed to set AX attribute value")
1069end
1070
1071local valueType = type(payload.value)
1072if payload.value == json.null then
1073 valueType = "null"
1074end
1075
1076return json.encode({
1077 node_id = selected.node.node_id,
1078 matched_count = matchedCount,
1079 name = name,
1080 applied = true,
1081 value_type = valueType,
1082})
1083"#
1084);
1085
1086const AX_ACTION_PERFORM_HS_SCRIPT: &str = hs_ax_script_with_targeting_prelude!(
1087 "ax.action.perform",
1088 r#"
1089local function nodeFrom(element, path)
1090 return {
1091 node_id = table.concat(path, "."),
1092 role = normalize(attr(element, "AXRole", "")),
1093 title = normalize(attr(element, "AXTitle", "")),
1094 identifier = normalize(attr(element, "AXIdentifier", "")),
1095 subrole = normalize(attr(element, "AXSubrole", "")),
1096 value_preview = normalize(attr(element, "AXValue", "")),
1097 focused = boolAttr(element, "AXFocused", false),
1098 enabled = boolAttr(element, "AXEnabled", true),
1099 }
1100end
1101local function matches(node, selector)
1102 selector = selector or {}
1103 if selector.role and string.lower(node.role or "") ~= string.lower(tostring(selector.role)) then return false end
1104 if selector.title_contains and not string.find(string.lower(node.title or ""), string.lower(tostring(selector.title_contains)), 1, true) then return false end
1105 if selector.identifier_contains and not string.find(string.lower(node.identifier or ""), string.lower(tostring(selector.identifier_contains)), 1, true) then return false end
1106 if selector.value_contains and not string.find(string.lower(node.value_preview or ""), string.lower(tostring(selector.value_contains)), 1, true) then return false end
1107 if selector.subrole and string.lower(node.subrole or "") ~= string.lower(tostring(selector.subrole)) then return false end
1108 if selector.focused ~= nil and node.focused ~= selector.focused then return false end
1109 if selector.enabled ~= nil and node.enabled ~= selector.enabled then return false end
1110 return true
1111end
1112local function resolveByNodeId(roots, nodeId)
1113 local parts = {}
1114 for segment in string.gmatch(tostring(nodeId), "[^.]+") do table.insert(parts, tonumber(segment)) end
1115 if #parts == 0 then return nil end
1116 local rootIndex = parts[1]
1117 if not rootIndex or rootIndex < 1 or rootIndex > #roots then return nil end
1118 local element = roots[rootIndex]
1119 local path = { tostring(rootIndex) }
1120 for i = 2, #parts do
1121 local childIndex = parts[i]
1122 local directChildren = children(element)
1123 if not childIndex or childIndex < 1 or childIndex > #directChildren then return nil end
1124 element = directChildren[childIndex]
1125 table.insert(path, tostring(childIndex))
1126 end
1127 return { element = element, node = nodeFrom(element, path) }
1128end
1129local function collectMatches(roots, selector)
1130 local matchesOut = {}
1131 if selector.node_id then
1132 local byId = resolveByNodeId(roots, selector.node_id)
1133 if byId then table.insert(matchesOut, byId) end
1134 return matchesOut
1135 end
1136 local function walk(element, path)
1137 local node = nodeFrom(element, path)
1138 if matches(node, selector) then table.insert(matchesOut, { element = element, node = node }) end
1139 for index, child in ipairs(children(element)) do
1140 local childPath = copyPath(path)
1141 table.insert(childPath, tostring(index))
1142 walk(child, childPath)
1143 end
1144 end
1145 for rootIndex, root in ipairs(roots) do walk(root, { tostring(rootIndex) }) end
1146 return matchesOut
1147end
1148local function selectOne(matchesOut, selector)
1149 if #matchesOut == 0 then fail("selector returned zero AX matches") end
1150 if selector.node_id then return matchesOut[1], #matchesOut end
1151 local nth = selector.nth and tonumber(selector.nth) or nil
1152 if nth then
1153 if nth < 1 or nth > #matchesOut then fail("selector nth is out of range") end
1154 return matchesOut[nth], #matchesOut
1155 end
1156 if #matchesOut ~= 1 then fail("selector is ambiguous; add --nth or narrow selector filters") end
1157 return matchesOut[1], #matchesOut
1158end
1159local function parsePayload()
1160 local raw = cliPayloadArg("{}")
1161 local payload = json.decode(raw)
1162 if type(payload) ~= "table" then fail("invalid payload JSON") end
1163 return payload
1164end
1165
1166local payload = parsePayload()
1167local name = normalize(payload.name)
1168if name == "" then fail("action name cannot be empty") end
1169
1170local app, target = resolveApp(payload.target)
1171if not app then fail("unable to resolve target app process for ax.action.perform") end
1172
1173local roots = rootsForApp(app, target)
1174local matchesOut = collectMatches(roots, payload.selector or {})
1175local selected, matchedCount = selectOne(matchesOut, payload.selector or {})
1176local performed = safe(function()
1177 selected.element:performAction(name)
1178 return true
1179end, false)
1180if not performed then fail("failed to perform AX action") end
1181
1182return json.encode({
1183 node_id = selected.node.node_id,
1184 matched_count = matchedCount,
1185 name = name,
1186 performed = true,
1187})
1188"#
1189);
1190
1191const AX_SESSION_START_HS_SCRIPT: &str = r#"
1192local json = hs.json
1193local appmod = hs.application
1194local timer = hs.timer
1195
1196local function fail(message) error(message, 0) end
1197local function normalize(value)
1198 if value == nil then return "" end
1199 return tostring(value)
1200end
1201local function ensureState()
1202 _G.__codex_macos_agent_ax = _G.__codex_macos_agent_ax or { sessions = {}, watchers = {} }
1203 return _G.__codex_macos_agent_ax
1204end
1205local function nowMs()
1206 return math.floor((timer.secondsSinceEpoch and timer.secondsSinceEpoch() or os.time()) * 1000)
1207end
1208local function parsePayload()
1209 local raw = cliPayloadArg("{}")
1210 local payload = json.decode(raw)
1211 if type(payload) ~= "table" then fail("invalid payload JSON") end
1212 return payload
1213end
1214local function resolveApp(target)
1215 target = target or {}
1216 if target.app and normalize(target.app) ~= "" then
1217 local found = appmod.find(normalize(target.app))
1218 if found then return found end
1219 end
1220 if target.bundle_id and normalize(target.bundle_id) ~= "" then
1221 local apps = appmod.applicationsForBundleID(normalize(target.bundle_id))
1222 if type(apps) == "table" and #apps > 0 then return apps[1] end
1223 end
1224 return appmod.frontmostApplication()
1225end
1226local function generateSessionId()
1227 return string.format("axs-%d-%d", os.time(), math.random(1000, 999999))
1228end
1229
1230local payload = parsePayload()
1231local target = payload.target or {}
1232local app = resolveApp(target)
1233if not app then fail("unable to resolve target app process for ax.session.start") end
1234
1235local state = ensureState()
1236local requestedId = normalize(payload.session_id)
1237if requestedId == "" then requestedId = normalize(target.session_id) end
1238if requestedId == "" then requestedId = generateSessionId() end
1239local existing = state.sessions[requestedId]
1240
1241local createdAt = existing and existing.created_at_ms or nowMs()
1242local info = {
1243 session_id = requestedId,
1244 app = normalize(target.app) ~= "" and normalize(target.app) or app:name(),
1245 bundle_id = normalize(target.bundle_id) ~= "" and normalize(target.bundle_id) or app:bundleID(),
1246 pid = app:pid(),
1247 window_title_contains = target.window_title_contains and tostring(target.window_title_contains) or nil,
1248 created_at_ms = createdAt,
1249}
1250state.sessions[requestedId] = info
1251
1252return json.encode({
1253 session_id = info.session_id,
1254 app = info.app,
1255 bundle_id = info.bundle_id,
1256 pid = info.pid,
1257 window_title_contains = info.window_title_contains,
1258 created_at_ms = info.created_at_ms,
1259 created = existing == nil,
1260})
1261"#;
1262
1263const AX_SESSION_LIST_HS_SCRIPT: &str = r#"
1264local json = hs.json
1265
1266local function ensureState()
1267 _G.__codex_macos_agent_ax = _G.__codex_macos_agent_ax or { sessions = {}, watchers = {} }
1268 return _G.__codex_macos_agent_ax
1269end
1270
1271local state = ensureState()
1272local sessions = {}
1273for _, session in pairs(state.sessions) do
1274 table.insert(sessions, {
1275 session_id = session.session_id,
1276 app = session.app,
1277 bundle_id = session.bundle_id,
1278 pid = session.pid,
1279 window_title_contains = session.window_title_contains,
1280 created_at_ms = session.created_at_ms or 0,
1281 })
1282end
1283table.sort(sessions, function(a, b) return (a.session_id or "") < (b.session_id or "") end)
1284return json.encode({ sessions = sessions })
1285"#;
1286
1287const AX_SESSION_STOP_HS_SCRIPT: &str = r#"
1288local json = hs.json
1289
1290local function fail(message) error(message, 0) end
1291local function normalize(value)
1292 if value == nil then return "" end
1293 return tostring(value)
1294end
1295local function ensureState()
1296 _G.__codex_macos_agent_ax = _G.__codex_macos_agent_ax or { sessions = {}, watchers = {} }
1297 return _G.__codex_macos_agent_ax
1298end
1299local function parsePayload()
1300 local raw = cliPayloadArg("{}")
1301 local payload = json.decode(raw)
1302 if type(payload) ~= "table" then fail("invalid payload JSON") end
1303 return payload
1304end
1305
1306local payload = parsePayload()
1307local sessionId = normalize(payload.session_id)
1308if sessionId == "" then fail("session_id cannot be empty") end
1309
1310local state = ensureState()
1311local removed = state.sessions[sessionId] ~= nil
1312state.sessions[sessionId] = nil
1313
1314for watchId, slot in pairs(state.watchers) do
1315 if slot and slot.session_id == sessionId then
1316 if slot.observer and slot.observer.stop then pcall(function() slot.observer:stop() end) end
1317 state.watchers[watchId] = nil
1318 end
1319end
1320
1321return json.encode({
1322 session_id = sessionId,
1323 removed = removed,
1324})
1325"#;
1326
1327const AX_WATCH_START_HS_SCRIPT: &str = r#"
1328local json = hs.json
1329local appmod = hs.application
1330local ax = hs.axuielement
1331local observermod = hs.axuielement and hs.axuielement.observer or nil
1332local timer = hs.timer
1333
1334local function fail(message) error(message, 0) end
1335local function normalize(value)
1336 if value == nil then return "" end
1337 return tostring(value)
1338end
1339local function asTable(value)
1340 if type(value) == "table" then return value end
1341 return {}
1342end
1343local function ensureState()
1344 _G.__codex_macos_agent_ax = _G.__codex_macos_agent_ax or { sessions = {}, watchers = {} }
1345 return _G.__codex_macos_agent_ax
1346end
1347local function nowMs()
1348 return math.floor((timer.secondsSinceEpoch and timer.secondsSinceEpoch() or os.time()) * 1000)
1349end
1350local function parsePayload()
1351 local raw = cliPayloadArg("{}")
1352 local payload = json.decode(raw)
1353 if type(payload) ~= "table" then fail("invalid payload JSON") end
1354 return payload
1355end
1356local function generateWatchId()
1357 return string.format("axw-%d-%d", os.time(), math.random(1000, 999999))
1358end
1359local function resolveAppFromSession(session)
1360 if session.pid then
1361 local byPid = appmod.applicationForPID(tonumber(session.pid))
1362 if byPid then return byPid end
1363 end
1364 if session.app and normalize(session.app) ~= "" then
1365 local byName = appmod.find(normalize(session.app))
1366 if byName then return byName end
1367 end
1368 if session.bundle_id and normalize(session.bundle_id) ~= "" then
1369 local apps = appmod.applicationsForBundleID(normalize(session.bundle_id))
1370 if type(apps) == "table" and #apps > 0 then return apps[1] end
1371 end
1372 return nil
1373end
1374
1375local payload = parsePayload()
1376local sessionId = normalize(payload.session_id)
1377if sessionId == "" then fail("session_id cannot be empty") end
1378local state = ensureState()
1379local session = state.sessions[sessionId]
1380if not session then fail("session_id does not exist") end
1381
1382local app = resolveAppFromSession(session)
1383if not app then fail("unable to resolve app from session") end
1384if not observermod or not observermod.new then
1385 fail("AX observer backend unavailable in Hammerspoon runtime")
1386end
1387
1388local watchId = normalize(payload.watch_id)
1389if watchId == "" then watchId = generateWatchId() end
1390
1391local events = asTable(payload.events)
1392if #events == 0 then
1393 events = { "AXFocusedUIElementChanged", "AXTitleChanged" }
1394end
1395local normalizedEvents = {}
1396for _, eventName in ipairs(events) do
1397 local value = normalize(eventName)
1398 if value ~= "" then
1399 table.insert(normalizedEvents, value)
1400 end
1401end
1402if #normalizedEvents == 0 then
1403 normalizedEvents = { "AXFocusedUIElementChanged", "AXTitleChanged" }
1404end
1405
1406local maxBuffer = tonumber(payload.max_buffer) or 256
1407if maxBuffer < 1 then maxBuffer = 1 end
1408
1409local slot = state.watchers[watchId]
1410if slot and slot.observer and slot.observer.stop then
1411 pcall(function() slot.observer:stop() end)
1412end
1413
1414local appElement = ax.applicationElement(app)
1415if not appElement then fail("unable to resolve AX application element from session") end
1416local pid = tonumber(session.pid) or app:pid()
1417if not pid then fail("unable to resolve app pid from session") end
1418
1419slot = {
1420 watch_id = watchId,
1421 session_id = sessionId,
1422 events = normalizedEvents,
1423 max_buffer = maxBuffer,
1424 dropped = 0,
1425 buffer = {},
1426 observed_pid = pid,
1427}
1428
1429local function safeAttr(element, name)
1430 local ok, value = pcall(function() return element:attributeValue(name) end)
1431 if ok then return value end
1432 return nil
1433end
1434
1435local function callback(_, element, eventName, details)
1436 local current = state.watchers[watchId]
1437 if not current then return end
1438 local evt = {
1439 watch_id = watchId,
1440 event = tostring(eventName),
1441 at_ms = nowMs(),
1442 pid = current.observed_pid,
1443 }
1444 if element then
1445 local role = safeAttr(element, "AXRole")
1446 local title = safeAttr(element, "AXTitle")
1447 local identifier = safeAttr(element, "AXIdentifier")
1448 evt.role = role and tostring(role) or nil
1449 evt.title = title and tostring(title) or nil
1450 evt.identifier = identifier and tostring(identifier) or nil
1451 end
1452 table.insert(current.buffer, evt)
1453 while #current.buffer > current.max_buffer do
1454 table.remove(current.buffer, 1)
1455 current.dropped = (current.dropped or 0) + 1
1456 end
1457end
1458
1459local observer = observermod.new(pid)
1460if not observer then fail("failed to create AX observer") end
1461observer:callback(callback)
1462
1463local registered = {}
1464for _, eventName in ipairs(normalizedEvents) do
1465 local ok = pcall(function()
1466 observer:addWatcher(appElement, eventName)
1467 end)
1468 if ok then
1469 table.insert(registered, eventName)
1470 end
1471end
1472if #registered == 0 then
1473 fail("failed to register AX notifications for observer")
1474end
1475
1476local started = pcall(function()
1477 observer:start()
1478end)
1479if not started then
1480 fail("failed to start AX observer")
1481end
1482
1483slot.observer = observer
1484slot.events = registered
1485state.watchers[watchId] = slot
1486
1487return json.encode({
1488 watch_id = watchId,
1489 session_id = sessionId,
1490 events = registered,
1491 max_buffer = maxBuffer,
1492 started = true,
1493})
1494"#;
1495
1496const AX_WATCH_POLL_HS_SCRIPT: &str = r#"
1497local json = hs.json
1498
1499local function fail(message) error(message, 0) end
1500local function normalize(value)
1501 if value == nil then return "" end
1502 return tostring(value)
1503end
1504local function ensureState()
1505 _G.__codex_macos_agent_ax = _G.__codex_macos_agent_ax or { sessions = {}, watchers = {} }
1506 return _G.__codex_macos_agent_ax
1507end
1508local function parsePayload()
1509 local raw = cliPayloadArg("{}")
1510 local payload = json.decode(raw)
1511 if type(payload) ~= "table" then fail("invalid payload JSON") end
1512 return payload
1513end
1514
1515local payload = parsePayload()
1516local watchId = normalize(payload.watch_id)
1517if watchId == "" then fail("watch_id cannot be empty") end
1518local limit = tonumber(payload.limit) or 50
1519if limit < 1 then limit = 1 end
1520local drain = payload.drain
1521if drain == nil then drain = true end
1522
1523local state = ensureState()
1524local slot = state.watchers[watchId]
1525if not slot then fail("watch_id does not exist") end
1526
1527local events = {}
1528local available = #slot.buffer
1529local take = math.min(available, limit)
1530for i = 1, take do
1531 table.insert(events, slot.buffer[i])
1532end
1533
1534if drain then
1535 for _ = 1, take do
1536 table.remove(slot.buffer, 1)
1537 end
1538end
1539
1540local running = false
1541if slot.observer and slot.observer.isRunning then
1542 local ok, value = pcall(function() return slot.observer:isRunning() end)
1543 if ok then running = value and true or false end
1544end
1545
1546return json.encode({
1547 watch_id = watchId,
1548 events = events,
1549 dropped = slot.dropped or 0,
1550 running = running,
1551})
1552"#;
1553
1554const AX_WATCH_STOP_HS_SCRIPT: &str = r#"
1555local json = hs.json
1556
1557local function fail(message) error(message, 0) end
1558local function normalize(value)
1559 if value == nil then return "" end
1560 return tostring(value)
1561end
1562local function ensureState()
1563 _G.__codex_macos_agent_ax = _G.__codex_macos_agent_ax or { sessions = {}, watchers = {} }
1564 return _G.__codex_macos_agent_ax
1565end
1566local function parsePayload()
1567 local raw = cliPayloadArg("{}")
1568 local payload = json.decode(raw)
1569 if type(payload) ~= "table" then fail("invalid payload JSON") end
1570 return payload
1571end
1572
1573local payload = parsePayload()
1574local watchId = normalize(payload.watch_id)
1575if watchId == "" then fail("watch_id cannot be empty") end
1576local state = ensureState()
1577local slot = state.watchers[watchId]
1578if not slot then
1579 return json.encode({ watch_id = watchId, stopped = false, drained = 0 })
1580end
1581
1582local drained = #(slot.buffer or {})
1583if slot.observer and slot.observer.stop then
1584 pcall(function() slot.observer:stop() end)
1585end
1586state.watchers[watchId] = nil
1587return json.encode({
1588 watch_id = watchId,
1589 stopped = true,
1590 drained = drained,
1591})
1592"#;
1593
1594#[derive(Debug, Default, Clone, Copy)]
1595pub struct HammerspoonAxBackend;
1596
1597impl AxBackendAdapter for HammerspoonAxBackend {
1598 fn list(
1599 &self,
1600 runner: &dyn ProcessRunner,
1601 request: &AxListRequest,
1602 timeout_ms: u64,
1603 ) -> Result<AxListResult, CliError> {
1604 run_hs_json(
1605 runner,
1606 "ax.list",
1607 request,
1608 AX_LIST_HS_SCRIPT,
1609 timeout_ms.max(1),
1610 )
1611 }
1612
1613 fn click(
1614 &self,
1615 runner: &dyn ProcessRunner,
1616 request: &AxClickRequest,
1617 timeout_ms: u64,
1618 ) -> Result<AxClickResult, CliError> {
1619 if selector_is_empty(&request.selector) {
1620 return Err(
1621 CliError::ax_contract_failure("ax.click", "selector is empty")
1622 .with_operation("ax.click.hammerspoon")
1623 .with_hint("Provide --node-id or selector filters (--role/--title-contains)."),
1624 );
1625 }
1626
1627 run_hs_json(
1628 runner,
1629 "ax.click",
1630 request,
1631 AX_CLICK_HS_SCRIPT,
1632 timeout_ms.max(1),
1633 )
1634 }
1635
1636 fn type_text(
1637 &self,
1638 runner: &dyn ProcessRunner,
1639 request: &AxTypeRequest,
1640 timeout_ms: u64,
1641 ) -> Result<AxTypeResult, CliError> {
1642 if request.text.trim().is_empty() {
1643 return Err(
1644 CliError::usage("--text cannot be empty").with_operation("ax.type.hammerspoon")
1645 );
1646 }
1647 if selector_is_empty(&request.selector) {
1648 return Err(
1649 CliError::ax_contract_failure("ax.type", "selector is empty")
1650 .with_operation("ax.type.hammerspoon")
1651 .with_hint("Provide --node-id or selector filters (--role/--title-contains)."),
1652 );
1653 }
1654
1655 run_hs_json(
1656 runner,
1657 "ax.type",
1658 request,
1659 AX_TYPE_HS_SCRIPT,
1660 timeout_ms.max(1),
1661 )
1662 }
1663
1664 fn attr_get(
1665 &self,
1666 runner: &dyn ProcessRunner,
1667 request: &AxAttrGetRequest,
1668 timeout_ms: u64,
1669 ) -> Result<AxAttrGetResult, CliError> {
1670 if request.name.trim().is_empty() {
1671 return Err(
1672 CliError::usage("--name cannot be empty").with_operation("ax.attr.get.hammerspoon")
1673 );
1674 }
1675 if selector_is_empty(&request.selector) {
1676 return Err(
1677 CliError::ax_contract_failure("ax.attr.get", "selector is empty")
1678 .with_operation("ax.attr.get.hammerspoon")
1679 .with_hint("Provide --node-id or selector filters."),
1680 );
1681 }
1682
1683 run_hs_json(
1684 runner,
1685 "ax.attr.get",
1686 request,
1687 AX_ATTR_GET_HS_SCRIPT,
1688 timeout_ms.max(1),
1689 )
1690 }
1691
1692 fn attr_set(
1693 &self,
1694 runner: &dyn ProcessRunner,
1695 request: &AxAttrSetRequest,
1696 timeout_ms: u64,
1697 ) -> Result<AxAttrSetResult, CliError> {
1698 if request.name.trim().is_empty() {
1699 return Err(
1700 CliError::usage("--name cannot be empty").with_operation("ax.attr.set.hammerspoon")
1701 );
1702 }
1703 if selector_is_empty(&request.selector) {
1704 return Err(
1705 CliError::ax_contract_failure("ax.attr.set", "selector is empty")
1706 .with_operation("ax.attr.set.hammerspoon")
1707 .with_hint("Provide --node-id or selector filters."),
1708 );
1709 }
1710
1711 run_hs_json(
1712 runner,
1713 "ax.attr.set",
1714 request,
1715 AX_ATTR_SET_HS_SCRIPT,
1716 timeout_ms.max(1),
1717 )
1718 }
1719
1720 fn action_perform(
1721 &self,
1722 runner: &dyn ProcessRunner,
1723 request: &AxActionPerformRequest,
1724 timeout_ms: u64,
1725 ) -> Result<AxActionPerformResult, CliError> {
1726 if request.name.trim().is_empty() {
1727 return Err(CliError::usage("--name cannot be empty")
1728 .with_operation("ax.action.perform.hammerspoon"));
1729 }
1730 if selector_is_empty(&request.selector) {
1731 return Err(
1732 CliError::ax_contract_failure("ax.action.perform", "selector is empty")
1733 .with_operation("ax.action.perform.hammerspoon")
1734 .with_hint("Provide --node-id or selector filters."),
1735 );
1736 }
1737
1738 run_hs_json(
1739 runner,
1740 "ax.action.perform",
1741 request,
1742 AX_ACTION_PERFORM_HS_SCRIPT,
1743 timeout_ms.max(1),
1744 )
1745 }
1746
1747 fn session_start(
1748 &self,
1749 runner: &dyn ProcessRunner,
1750 request: &AxSessionStartRequest,
1751 timeout_ms: u64,
1752 ) -> Result<AxSessionStartResult, CliError> {
1753 run_hs_json(
1754 runner,
1755 "ax.session.start",
1756 request,
1757 AX_SESSION_START_HS_SCRIPT,
1758 timeout_ms.max(1),
1759 )
1760 }
1761
1762 fn session_list(
1763 &self,
1764 runner: &dyn ProcessRunner,
1765 timeout_ms: u64,
1766 ) -> Result<AxSessionListResult, CliError> {
1767 run_hs_json(
1768 runner,
1769 "ax.session.list",
1770 &serde_json::json!({}),
1771 AX_SESSION_LIST_HS_SCRIPT,
1772 timeout_ms.max(1),
1773 )
1774 }
1775
1776 fn session_stop(
1777 &self,
1778 runner: &dyn ProcessRunner,
1779 request: &AxSessionStopRequest,
1780 timeout_ms: u64,
1781 ) -> Result<AxSessionStopResult, CliError> {
1782 if request.session_id.trim().is_empty() {
1783 return Err(CliError::usage("--session-id cannot be empty")
1784 .with_operation("ax.session.stop.hammerspoon"));
1785 }
1786
1787 run_hs_json(
1788 runner,
1789 "ax.session.stop",
1790 request,
1791 AX_SESSION_STOP_HS_SCRIPT,
1792 timeout_ms.max(1),
1793 )
1794 }
1795
1796 fn watch_start(
1797 &self,
1798 runner: &dyn ProcessRunner,
1799 request: &AxWatchStartRequest,
1800 timeout_ms: u64,
1801 ) -> Result<AxWatchStartResult, CliError> {
1802 if request.session_id.trim().is_empty() {
1803 return Err(CliError::usage("--session-id cannot be empty")
1804 .with_operation("ax.watch.start.hammerspoon"));
1805 }
1806 run_hs_json(
1807 runner,
1808 "ax.watch.start",
1809 request,
1810 AX_WATCH_START_HS_SCRIPT,
1811 timeout_ms.max(1),
1812 )
1813 }
1814
1815 fn watch_poll(
1816 &self,
1817 runner: &dyn ProcessRunner,
1818 request: &AxWatchPollRequest,
1819 timeout_ms: u64,
1820 ) -> Result<AxWatchPollResult, CliError> {
1821 if request.watch_id.trim().is_empty() {
1822 return Err(CliError::usage("--watch-id cannot be empty")
1823 .with_operation("ax.watch.poll.hammerspoon"));
1824 }
1825 run_hs_json(
1826 runner,
1827 "ax.watch.poll",
1828 request,
1829 AX_WATCH_POLL_HS_SCRIPT,
1830 timeout_ms.max(1),
1831 )
1832 }
1833
1834 fn watch_stop(
1835 &self,
1836 runner: &dyn ProcessRunner,
1837 request: &AxWatchStopRequest,
1838 timeout_ms: u64,
1839 ) -> Result<AxWatchStopResult, CliError> {
1840 if request.watch_id.trim().is_empty() {
1841 return Err(CliError::usage("--watch-id cannot be empty")
1842 .with_operation("ax.watch.stop.hammerspoon"));
1843 }
1844 run_hs_json(
1845 runner,
1846 "ax.watch.stop",
1847 request,
1848 AX_WATCH_STOP_HS_SCRIPT,
1849 timeout_ms.max(1),
1850 )
1851 }
1852}
1853
1854pub fn is_backend_unavailable_error(error: &CliError) -> bool {
1855 if !error
1856 .operation()
1857 .map(|operation| operation.ends_with(".hammerspoon"))
1858 .unwrap_or(false)
1859 {
1860 return false;
1861 }
1862
1863 error
1864 .hints()
1865 .iter()
1866 .any(|hint| hint.starts_with(BACKEND_UNAVAILABLE_HINT_PREFIX))
1867}
1868
1869fn run_hs_json<Request, Response>(
1870 runner: &dyn ProcessRunner,
1871 operation: &'static str,
1872 payload: &Request,
1873 script: &'static str,
1874 timeout_ms: u64,
1875) -> Result<Response, CliError>
1876where
1877 Request: Serialize,
1878 Response: DeserializeOwned,
1879{
1880 if let Some(override_json) = test_mode_override_json(operation) {
1881 return parse_hs_output(operation, &override_json);
1882 }
1883
1884 if test_mode::enabled()
1885 && let Some(default_json) = test_mode_default_json(operation)
1886 {
1887 return parse_hs_output(operation, default_json);
1888 }
1889
1890 let payload_json = serde_json::to_string(payload).map_err(|err| {
1891 CliError::ax_payload_encode(&format!("{operation}.hammerspoon"), err.to_string())
1892 })?;
1893 let timeout_seconds = format!("{:.3}", (timeout_ms.max(1) as f64) / 1000.0);
1894
1895 let request = ProcessRequest::new(
1896 "hs",
1897 vec![
1898 "-q".to_string(),
1899 "-t".to_string(),
1900 timeout_seconds,
1901 "-c".to_string(),
1902 script.to_string(),
1903 "--".to_string(),
1904 payload_json,
1905 ],
1906 timeout_ms.max(1),
1907 );
1908
1909 let stdout = runner
1910 .run(&request)
1911 .map(|output| output.stdout)
1912 .map_err(|failure| map_hs_failure(operation, failure))?;
1913
1914 parse_hs_output(operation, &stdout)
1915}
1916
1917fn parse_hs_output<Response>(operation: &str, raw: &str) -> Result<Response, CliError>
1918where
1919 Response: DeserializeOwned,
1920{
1921 let trimmed = raw.trim();
1922 if trimmed.is_empty() {
1923 return Err(CliError::ax_parse_failure(
1924 &format!("{operation}.hammerspoon"),
1925 "empty stdout",
1926 )
1927 .with_hint(
1928 "Ensure Hammerspoon is running and `hs.ipc` is enabled in ~/.hammerspoon/init.lua.",
1929 ));
1930 }
1931
1932 serde_json::from_str(trimmed).map_err(|err| {
1933 CliError::ax_parse_failure(
1934 &format!("{operation}.hammerspoon"),
1935 format!("{err}; output preview: {}", output_preview(trimmed, 240)),
1936 )
1937 .with_hint("If backend mode is `auto`, macos-agent may fall back to JXA for AX commands.")
1938 })
1939}
1940
1941fn map_hs_failure(operation: &str, failure: ProcessFailure) -> CliError {
1942 let operation_label = format!("{operation}.hammerspoon");
1943
1944 match failure {
1945 ProcessFailure::NotFound { .. } => CliError::runtime(
1946 "hammerspoon AX backend is unavailable: missing dependency `hs` in PATH",
1947 )
1948 .with_operation(operation_label)
1949 .with_hint(
1950 "Hammerspoon backend unavailable; install Hammerspoon and ensure `hs` is in PATH.",
1951 )
1952 .with_hint("Auto mode will fall back to JXA AX backend."),
1953 ProcessFailure::Timeout { timeout_ms, .. } => CliError::timeout(
1954 &operation_label,
1955 timeout_ms,
1956 )
1957 .with_operation(operation_label)
1958 .with_hint("Hammerspoon backend unavailable; command timed out while connecting to hs IPC.")
1959 .with_hint(
1960 "Enable `require('hs.ipc')` in ~/.hammerspoon/init.lua and keep Hammerspoon running.",
1961 ),
1962 ProcessFailure::NonZero { code, stderr, .. } => {
1963 let lower = stderr.to_ascii_lowercase();
1964 let unavailable = lower.contains("message port")
1965 || lower.contains("ipc module")
1966 || lower.contains("is it running")
1967 || lower.contains("connection refused");
1968
1969 let mut error = CliError::runtime(format!(
1970 "{operation_label} failed via `hs` (exit {code}): {stderr}"
1971 ))
1972 .with_operation(operation_label);
1973
1974 if unavailable {
1975 error = error
1976 .with_hint(
1977 "Hammerspoon backend unavailable; hs cannot connect to Hammerspoon IPC.",
1978 )
1979 .with_hint(
1980 "Enable `require('hs.ipc')` in ~/.hammerspoon/init.lua and reload config.",
1981 )
1982 .with_hint("Auto mode will fall back to JXA AX backend.");
1983 }
1984
1985 error
1986 }
1987 ProcessFailure::Io { message, .. } => {
1988 CliError::runtime(format!("{operation_label} failed to run `hs`: {message}"))
1989 .with_operation(operation_label)
1990 .with_hint(
1991 "Hammerspoon backend unavailable; check hs executable and local IPC state.",
1992 )
1993 }
1994 }
1995}
1996
1997fn test_mode_override_json(operation: &str) -> Option<String> {
1998 if !test_mode::enabled() {
1999 return None;
2000 }
2001
2002 let env_name = match operation {
2003 "ax.list" => AX_LIST_TEST_MODE_ENV,
2004 "ax.click" => AX_CLICK_TEST_MODE_ENV,
2005 "ax.type" => AX_TYPE_TEST_MODE_ENV,
2006 "ax.attr.get" => AX_ATTR_GET_TEST_MODE_ENV,
2007 "ax.attr.set" => AX_ATTR_SET_TEST_MODE_ENV,
2008 "ax.action.perform" => AX_ACTION_PERFORM_TEST_MODE_ENV,
2009 "ax.session.start" => AX_SESSION_START_TEST_MODE_ENV,
2010 "ax.session.list" => AX_SESSION_LIST_TEST_MODE_ENV,
2011 "ax.session.stop" => AX_SESSION_STOP_TEST_MODE_ENV,
2012 "ax.watch.start" => AX_WATCH_START_TEST_MODE_ENV,
2013 "ax.watch.poll" => AX_WATCH_POLL_TEST_MODE_ENV,
2014 "ax.watch.stop" => AX_WATCH_STOP_TEST_MODE_ENV,
2015 _ => return None,
2016 };
2017
2018 std::env::var(env_name).ok().and_then(|raw| {
2019 let trimmed = raw.trim();
2020 if trimmed.is_empty() {
2021 None
2022 } else {
2023 Some(trimmed.to_string())
2024 }
2025 })
2026}
2027
2028fn test_mode_default_json(operation: &str) -> Option<&'static str> {
2029 match operation {
2030 "ax.list" => Some(r#"{"nodes":[],"warnings":[]}"#),
2031 "ax.click" => Some(
2032 r#"{"node_id":"test-node","matched_count":1,"action":"ax-press","used_coordinate_fallback":false}"#,
2033 ),
2034 "ax.type" => Some(
2035 r#"{"node_id":"test-node","matched_count":1,"applied_via":"ax-set-value","text_length":0,"submitted":false,"used_keyboard_fallback":false}"#,
2036 ),
2037 "ax.attr.get" => {
2038 Some(r#"{"node_id":"test-node","matched_count":1,"name":"AXRole","value":"AXButton"}"#)
2039 }
2040 "ax.attr.set" => Some(
2041 r#"{"node_id":"test-node","matched_count":1,"name":"AXValue","applied":true,"value_type":"string"}"#,
2042 ),
2043 "ax.action.perform" => {
2044 Some(r#"{"node_id":"test-node","matched_count":1,"name":"AXPress","performed":true}"#)
2045 }
2046 "ax.session.start" => Some(
2047 r#"{"session_id":"axs-test","app":"Arc","bundle_id":"company.thebrowser.Browser","pid":1001,"created_at_ms":1700000000000,"created":true}"#,
2048 ),
2049 "ax.session.list" => Some(r#"{"sessions":[]}"#),
2050 "ax.session.stop" => Some(r#"{"session_id":"axs-test","removed":true}"#),
2051 "ax.watch.start" => Some(
2052 r#"{"watch_id":"axw-test","session_id":"axs-test","events":["AXTitleChanged"],"max_buffer":64,"started":true}"#,
2053 ),
2054 "ax.watch.poll" => {
2055 Some(r#"{"watch_id":"axw-test","events":[],"dropped":0,"running":true}"#)
2056 }
2057 "ax.watch.stop" => Some(r#"{"watch_id":"axw-test","stopped":true,"drained":0}"#),
2058 _ => None,
2059 }
2060}
2061
2062fn selector_is_empty(selector: &AxSelector) -> bool {
2063 selector.node_id.is_none()
2064 && selector.role.is_none()
2065 && selector.title_contains.is_none()
2066 && selector.identifier_contains.is_none()
2067 && selector.value_contains.is_none()
2068 && selector.subrole.is_none()
2069 && selector.focused.is_none()
2070 && selector.enabled.is_none()
2071}
2072
2073fn output_preview(raw: &str, max_chars: usize) -> String {
2074 let mut preview = raw.chars().take(max_chars).collect::<String>();
2075 if raw.chars().count() > max_chars {
2076 preview.push_str("...");
2077 }
2078 preview
2079}
2080
2081#[cfg(test)]
2082mod tests {
2083 use nils_test_support::{EnvGuard, GlobalStateLock};
2084 use serde_json::json;
2085
2086 use crate::backend::AxBackendAdapter;
2087 use crate::backend::hammerspoon::{
2088 is_backend_unavailable_error, map_hs_failure, output_preview, selector_is_empty,
2089 };
2090 use crate::backend::process::ProcessFailure;
2091 use crate::model::{
2092 AxActionPerformRequest, AxAttrGetRequest, AxAttrSetRequest, AxClickRequest, AxSelector,
2093 AxSessionStartRequest, AxSessionStopRequest, AxTarget, AxTypeRequest, AxWatchPollRequest,
2094 AxWatchStartRequest, AxWatchStopRequest,
2095 };
2096
2097 fn node_selector() -> AxSelector {
2098 AxSelector {
2099 node_id: Some("1.1".to_string()),
2100 ..AxSelector::default()
2101 }
2102 }
2103
2104 #[test]
2105 fn message_port_error_is_marked_backend_unavailable() {
2106 let error = map_hs_failure(
2107 "ax.list",
2108 ProcessFailure::NonZero {
2109 program: "hs".to_string(),
2110 code: 69,
2111 stderr: "can't access Hammerspoon message port Hammerspoon; is it running with the ipc module loaded?".to_string(),
2112 },
2113 );
2114
2115 assert!(is_backend_unavailable_error(&error));
2116 }
2117
2118 #[test]
2119 fn test_mode_override_is_honored_for_click() {
2120 let lock = GlobalStateLock::new();
2121 let _mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
2122 let _override = EnvGuard::set(
2123 &lock,
2124 "AGENTS_MACOS_AGENT_AX_CLICK_JSON",
2125 r#"{"node_id":"1.1","matched_count":1,"action":"ax-press","used_coordinate_fallback":false}"#,
2126 );
2127
2128 let runner = crate::backend::process::RealProcessRunner;
2129 let request = crate::model::AxClickRequest {
2130 target: crate::model::AxTarget::default(),
2131 selector: crate::model::AxSelector {
2132 node_id: Some("1.1".to_string()),
2133 ..crate::model::AxSelector::default()
2134 },
2135 allow_coordinate_fallback: false,
2136 reselect_before_click: false,
2137 fallback_order: Vec::new(),
2138 };
2139
2140 let result = super::HammerspoonAxBackend
2141 .click(&runner, &request, 1000)
2142 .expect("click should parse test override");
2143 assert_eq!(result.node_id.as_deref(), Some("1.1"));
2144 assert_eq!(result.matched_count, 1);
2145 }
2146
2147 #[test]
2148 fn default_test_mode_fixtures_cover_all_hammerspoon_ax_operations() {
2149 let lock = GlobalStateLock::new();
2150 let _mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
2151 let backend = super::HammerspoonAxBackend;
2152 let runner = crate::backend::process::RealProcessRunner;
2153
2154 let list = backend
2155 .list(&runner, &crate::model::AxListRequest::default(), 1000)
2156 .expect("list default fixture");
2157 assert!(list.nodes.is_empty());
2158
2159 let click = backend
2160 .click(
2161 &runner,
2162 &AxClickRequest {
2163 target: AxTarget::default(),
2164 selector: node_selector(),
2165 allow_coordinate_fallback: false,
2166 reselect_before_click: false,
2167 fallback_order: Vec::new(),
2168 },
2169 1000,
2170 )
2171 .expect("click default fixture");
2172 assert_eq!(click.matched_count, 1);
2173
2174 let typ = backend
2175 .type_text(
2176 &runner,
2177 &AxTypeRequest {
2178 target: AxTarget::default(),
2179 selector: node_selector(),
2180 text: "test".to_string(),
2181 clear_first: false,
2182 submit: false,
2183 paste: false,
2184 allow_keyboard_fallback: false,
2185 },
2186 1000,
2187 )
2188 .expect("type default fixture");
2189 assert_eq!(typ.applied_via, "ax-set-value");
2190
2191 let attr_get = backend
2192 .attr_get(
2193 &runner,
2194 &AxAttrGetRequest {
2195 target: AxTarget::default(),
2196 selector: node_selector(),
2197 name: "AXRole".to_string(),
2198 },
2199 1000,
2200 )
2201 .expect("attr get default fixture");
2202 assert_eq!(attr_get.name, "AXRole");
2203
2204 let attr_set = backend
2205 .attr_set(
2206 &runner,
2207 &AxAttrSetRequest {
2208 target: AxTarget::default(),
2209 selector: node_selector(),
2210 name: "AXValue".to_string(),
2211 value: json!("hello"),
2212 },
2213 1000,
2214 )
2215 .expect("attr set default fixture");
2216 assert!(attr_set.applied);
2217
2218 let action = backend
2219 .action_perform(
2220 &runner,
2221 &AxActionPerformRequest {
2222 target: AxTarget::default(),
2223 selector: node_selector(),
2224 name: "AXPress".to_string(),
2225 },
2226 1000,
2227 )
2228 .expect("action default fixture");
2229 assert!(action.performed);
2230
2231 let session_start = backend
2232 .session_start(
2233 &runner,
2234 &AxSessionStartRequest {
2235 target: AxTarget::default(),
2236 session_id: Some("axs-test".to_string()),
2237 },
2238 1000,
2239 )
2240 .expect("session start default fixture");
2241 assert_eq!(session_start.session.session_id, "axs-test");
2242
2243 let session_list = backend
2244 .session_list(&runner, 1000)
2245 .expect("session list default fixture");
2246 assert!(session_list.sessions.is_empty());
2247
2248 let session_stop = backend
2249 .session_stop(
2250 &runner,
2251 &AxSessionStopRequest {
2252 session_id: "axs-test".to_string(),
2253 },
2254 1000,
2255 )
2256 .expect("session stop default fixture");
2257 assert!(session_stop.removed);
2258
2259 let watch_start = backend
2260 .watch_start(
2261 &runner,
2262 &AxWatchStartRequest {
2263 session_id: "axs-test".to_string(),
2264 events: vec!["AXTitleChanged".to_string()],
2265 max_buffer: 64,
2266 watch_id: Some("axw-test".to_string()),
2267 },
2268 1000,
2269 )
2270 .expect("watch start default fixture");
2271 assert_eq!(watch_start.watch_id, "axw-test");
2272
2273 let watch_poll = backend
2274 .watch_poll(
2275 &runner,
2276 &AxWatchPollRequest {
2277 watch_id: "axw-test".to_string(),
2278 limit: 10,
2279 drain: true,
2280 },
2281 1000,
2282 )
2283 .expect("watch poll default fixture");
2284 assert!(watch_poll.running);
2285
2286 let watch_stop = backend
2287 .watch_stop(
2288 &runner,
2289 &AxWatchStopRequest {
2290 watch_id: "axw-test".to_string(),
2291 },
2292 1000,
2293 )
2294 .expect("watch stop default fixture");
2295 assert!(watch_stop.stopped);
2296 }
2297
2298 #[test]
2299 fn validation_errors_are_reported_for_empty_hammerspoon_inputs() {
2300 let backend = super::HammerspoonAxBackend;
2301 let runner = crate::backend::process::RealProcessRunner;
2302
2303 let click_err = backend
2304 .click(
2305 &runner,
2306 &AxClickRequest {
2307 target: AxTarget::default(),
2308 selector: AxSelector::default(),
2309 allow_coordinate_fallback: false,
2310 reselect_before_click: false,
2311 fallback_order: Vec::new(),
2312 },
2313 1000,
2314 )
2315 .expect_err("empty click selector should fail");
2316 assert!(click_err.to_string().contains("selector is empty"));
2317
2318 let type_err = backend
2319 .type_text(
2320 &runner,
2321 &AxTypeRequest {
2322 target: AxTarget::default(),
2323 selector: node_selector(),
2324 text: " ".to_string(),
2325 clear_first: false,
2326 submit: false,
2327 paste: false,
2328 allow_keyboard_fallback: false,
2329 },
2330 1000,
2331 )
2332 .expect_err("empty text should fail");
2333 assert!(type_err.to_string().contains("--text cannot be empty"));
2334
2335 let attr_get_err = backend
2336 .attr_get(
2337 &runner,
2338 &AxAttrGetRequest {
2339 target: AxTarget::default(),
2340 selector: node_selector(),
2341 name: " ".to_string(),
2342 },
2343 1000,
2344 )
2345 .expect_err("empty attr get name should fail");
2346 assert!(attr_get_err.to_string().contains("--name cannot be empty"));
2347
2348 let attr_set_err = backend
2349 .attr_set(
2350 &runner,
2351 &AxAttrSetRequest {
2352 target: AxTarget::default(),
2353 selector: AxSelector::default(),
2354 name: "AXValue".to_string(),
2355 value: json!("hello"),
2356 },
2357 1000,
2358 )
2359 .expect_err("empty attr set selector should fail");
2360 assert!(attr_set_err.to_string().contains("selector is empty"));
2361
2362 let action_err = backend
2363 .action_perform(
2364 &runner,
2365 &AxActionPerformRequest {
2366 target: AxTarget::default(),
2367 selector: node_selector(),
2368 name: " ".to_string(),
2369 },
2370 1000,
2371 )
2372 .expect_err("empty action name should fail");
2373 assert!(action_err.to_string().contains("--name cannot be empty"));
2374
2375 let session_stop_err = backend
2376 .session_stop(
2377 &runner,
2378 &AxSessionStopRequest {
2379 session_id: " ".to_string(),
2380 },
2381 1000,
2382 )
2383 .expect_err("empty session id should fail");
2384 assert!(
2385 session_stop_err
2386 .to_string()
2387 .contains("--session-id cannot be empty")
2388 );
2389
2390 let watch_start_err = backend
2391 .watch_start(
2392 &runner,
2393 &AxWatchStartRequest {
2394 session_id: " ".to_string(),
2395 events: vec![],
2396 max_buffer: 10,
2397 watch_id: None,
2398 },
2399 1000,
2400 )
2401 .expect_err("empty watch session should fail");
2402 assert!(
2403 watch_start_err
2404 .to_string()
2405 .contains("--session-id cannot be empty")
2406 );
2407
2408 let watch_poll_err = backend
2409 .watch_poll(
2410 &runner,
2411 &AxWatchPollRequest {
2412 watch_id: " ".to_string(),
2413 limit: 10,
2414 drain: true,
2415 },
2416 1000,
2417 )
2418 .expect_err("empty watch id should fail");
2419 assert!(
2420 watch_poll_err
2421 .to_string()
2422 .contains("--watch-id cannot be empty")
2423 );
2424
2425 let watch_stop_err = backend
2426 .watch_stop(
2427 &runner,
2428 &AxWatchStopRequest {
2429 watch_id: " ".to_string(),
2430 },
2431 1000,
2432 )
2433 .expect_err("empty watch id should fail");
2434 assert!(
2435 watch_stop_err
2436 .to_string()
2437 .contains("--watch-id cannot be empty")
2438 );
2439 }
2440
2441 #[test]
2442 fn invalid_override_json_reports_parse_hint() {
2443 let lock = GlobalStateLock::new();
2444 let _mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
2445 let _override = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_AX_ATTR_GET_JSON", "not-json");
2446
2447 let backend = super::HammerspoonAxBackend;
2448 let runner = crate::backend::process::RealProcessRunner;
2449 let err = backend
2450 .attr_get(
2451 &runner,
2452 &AxAttrGetRequest {
2453 target: AxTarget::default(),
2454 selector: node_selector(),
2455 name: "AXRole".to_string(),
2456 },
2457 1000,
2458 )
2459 .expect_err("invalid json override should fail");
2460 let rendered = err.to_string();
2461 assert!(rendered.contains("output preview"));
2462 }
2463
2464 #[test]
2465 fn empty_override_value_falls_back_to_default_fixture() {
2466 let lock = GlobalStateLock::new();
2467 let _mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
2468 let _override = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_AX_SESSION_LIST_JSON", " ");
2469
2470 let backend = super::HammerspoonAxBackend;
2471 let runner = crate::backend::process::RealProcessRunner;
2472 let result = backend
2473 .session_list(&runner, 1000)
2474 .expect("default fixture should be used");
2475 assert!(result.sessions.is_empty());
2476 }
2477
2478 #[test]
2479 fn not_found_failure_is_marked_backend_unavailable() {
2480 let error = map_hs_failure(
2481 "ax.list",
2482 ProcessFailure::NotFound {
2483 program: "hs".to_string(),
2484 },
2485 );
2486 assert!(is_backend_unavailable_error(&error));
2487 }
2488
2489 #[test]
2490 fn selector_and_preview_helpers_cover_expected_cases() {
2491 assert!(selector_is_empty(&AxSelector::default()));
2492 assert!(!selector_is_empty(&AxSelector {
2493 title_contains: Some("Save".to_string()),
2494 ..AxSelector::default()
2495 }));
2496
2497 let preview = output_preview("abcdefghijklmnopqrstuvwxyz", 8);
2498 assert_eq!(preview, "abcdefgh...");
2499 }
2500
2501 #[test]
2502 fn ax_list_script_prelude_has_valid_unquoted_fail_message() {
2503 assert!(
2504 !super::AX_LIST_HS_SCRIPT
2505 .contains("fail(\"unable to resolve target app process for \"#,"),
2506 "AX list prelude should not contain broken Rust concat tokens"
2507 );
2508 assert!(
2509 super::AX_LIST_HS_SCRIPT.contains(r#"fail("unable to resolve target app process")"#),
2510 "AX list prelude should keep an actionable target resolution failure"
2511 );
2512 assert!(
2513 super::AX_LIST_HS_SCRIPT.contains("local function cliPayloadArg(defaultValue)"),
2514 "AX list script should include CLI payload normalization helper"
2515 );
2516 assert!(
2517 super::AX_LIST_HS_SCRIPT.contains("if raw == \"--\" then"),
2518 "AX list script should skip CLI `--` separator before JSON decoding"
2519 );
2520 assert!(
2521 super::AX_LIST_HS_SCRIPT.contains("local raw = cliPayloadArg(\"{}\")"),
2522 "AX list parsePayload should read normalized CLI payload argument"
2523 );
2524 }
2525}