Skip to main content

macos_agent/backend/
hammerspoon.rs

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}