648 lines
21 KiB
Bash
Executable File
648 lines
21 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
. "$ROOT_DIR/script/validation_helpers.sh"
|
|
apc_require_host_ui_opt_in "overlay non-mouse validation"
|
|
APP_NAME="${APC_OVERLAY_APP_NAME:-AgentPetCompanion}"
|
|
APP_BUNDLE="$ROOT_DIR/dist/AgentPetCompanion.app"
|
|
APP_BINARY="$APP_BUNDLE/Contents/MacOS/AgentPetCompanion"
|
|
PETCORE_BINARY="$APP_BUNDLE/Contents/Resources/bin/petcore"
|
|
PETCORE_CLI="${1:-$APP_BUNDLE/Contents/Resources/bin/petcore-cli}"
|
|
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/apc-overlay-non-mouse.XXXXXX")"
|
|
apc_use_isolated_home "$TMP_DIR"
|
|
OWNED_PROTOCOL="$APC_HOME/run/validation-owned-runtime.json"
|
|
APP_LOG="$TMP_DIR/app.log"
|
|
RUN_ID="overlay_nomouse_$(date -u +%Y%m%dT%H%M%SZ)_$$"
|
|
ORIGINAL_BEHAVIOR_JSON=""
|
|
ORIGINAL_PLACEMENT_JSON=""
|
|
|
|
restore_state() {
|
|
if [[ -x "$PETCORE_CLI" && -n "$ORIGINAL_BEHAVIOR_JSON" ]]; then
|
|
"$PETCORE_CLI" behavior set-json --value-json "$ORIGINAL_BEHAVIOR_JSON" >/dev/null 2>&1 || true
|
|
fi
|
|
if [[ -x "$PETCORE_CLI" && -n "$ORIGINAL_PLACEMENT_JSON" ]]; then
|
|
PLACEMENT="$ORIGINAL_PLACEMENT_JSON" python3 - <<'PY' | while read -r x y display_width_pt display_id; do
|
|
import json
|
|
import os
|
|
|
|
placement = json.loads(os.environ["PLACEMENT"])
|
|
print(
|
|
placement["x"],
|
|
placement["y"],
|
|
placement["display_width_pt"],
|
|
placement.get("display_id", "main"),
|
|
)
|
|
PY
|
|
"$PETCORE_CLI" overlay placement set \
|
|
--x "$x" \
|
|
--y "$y" \
|
|
--display-width-pt "$display_width_pt" \
|
|
--display-id "$display_id" >/dev/null 2>&1 || true
|
|
done
|
|
fi
|
|
}
|
|
|
|
cleanup() {
|
|
restore_state
|
|
apc_stop_owned_runtime "$PETCORE_CLI" "$PETCORE_BINARY" "$OWNED_PROTOCOL"
|
|
rm -rf "$TMP_DIR"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
if [[ ! -x "$APP_BINARY" || ! -x "$PETCORE_BINARY" || ! -x "$PETCORE_CLI" ]]; then
|
|
"$ROOT_DIR/script/build_app_bundle.sh" >/dev/null
|
|
fi
|
|
|
|
apc_start_owned_runtime \
|
|
"$APP_BINARY" \
|
|
"$PETCORE_CLI" \
|
|
"$PETCORE_BINARY" \
|
|
"$APP_LOG" \
|
|
"$OWNED_PROTOCOL"
|
|
|
|
wait_snapshot() {
|
|
local snapshot=""
|
|
for _ in {1..80}; do
|
|
snapshot="$("$PETCORE_CLI" snapshot 2>/dev/null || true)"
|
|
if SNAPSHOT="$snapshot" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
try:
|
|
data = json.loads(os.environ.get("SNAPSHOT", "{}"))
|
|
except json.JSONDecodeError:
|
|
sys.exit(1)
|
|
|
|
required = {"behavior", "overlay_placement", "events"}
|
|
sys.exit(0 if required.issubset(data.keys()) else 1)
|
|
PY
|
|
then
|
|
printf '%s\n' "$snapshot"
|
|
return 0
|
|
fi
|
|
sleep 0.25
|
|
done
|
|
return 1
|
|
}
|
|
|
|
SNAPSHOT="$(wait_snapshot)"
|
|
ORIGINAL_BEHAVIOR_JSON="$(SNAPSHOT="$SNAPSHOT" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
|
|
behavior = dict(json.loads(os.environ["SNAPSHOT"])["behavior"])
|
|
# The snapshot keeps this legacy read-only field for mixed-version clients, but
|
|
# current behavior patches must omit it because pointer passthrough is invariant.
|
|
behavior.pop("mouse_passthrough", None)
|
|
print(json.dumps(behavior, ensure_ascii=False))
|
|
PY
|
|
)"
|
|
ORIGINAL_PLACEMENT_JSON="$(SNAPSHOT="$SNAPSHOT" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
|
|
print(json.dumps(json.loads(os.environ["SNAPSHOT"])["overlay_placement"], ensure_ascii=False))
|
|
PY
|
|
)"
|
|
|
|
ENABLED_BEHAVIOR_JSON="$(SNAPSHOT="$SNAPSHOT" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
|
|
data = json.loads(os.environ["SNAPSHOT"])
|
|
behavior = dict(data["behavior"])
|
|
behavior.pop("mouse_passthrough", None)
|
|
behavior["enabled"] = True
|
|
behavior["status_bubble"] = True
|
|
behavior["click_menu"] = True
|
|
behavior["auto_hide"] = False
|
|
behavior["group_sessions_by_agent"] = True
|
|
for key in ["codex", "claude_code", "pi", "opencode"]:
|
|
behavior.setdefault("sources", {})[key] = True
|
|
for key in ["start", "thinking", "plan", "tool", "waiting", "done", "failed"]:
|
|
behavior.setdefault("events", {})[key] = True
|
|
print(json.dumps(behavior, ensure_ascii=False))
|
|
PY
|
|
)"
|
|
"$PETCORE_CLI" behavior set-json --value-json "$ENABLED_BEHAVIOR_JSON" >/dev/null
|
|
|
|
read -r MIN_X MIN_Y MAX_X MAX_Y MID_X MID_Y <<<"$(swift - <<'SWIFT'
|
|
import AppKit
|
|
let frame = (NSScreen.main ?? NSScreen.screens.first)?.visibleFrame ?? .zero
|
|
print(Int(frame.minX), Int(frame.minY), Int(frame.maxX), Int(frame.maxY), Int(frame.midX), Int(frame.midY))
|
|
SWIFT
|
|
)"
|
|
|
|
BOTTOM_X=$((MAX_X - 170))
|
|
BOTTOM_Y=$((MIN_Y + 110))
|
|
TOP_X=$((MAX_X - 170))
|
|
TOP_Y=$((MAX_Y - 90))
|
|
|
|
ingest_event() {
|
|
local source="$1"
|
|
local event_type="$2"
|
|
local title="$3"
|
|
local detail="$4"
|
|
"$PETCORE_CLI" agent ingest \
|
|
--id "${RUN_ID}_${source}_${event_type}" \
|
|
--source "$source" \
|
|
--event-type "$event_type" \
|
|
--title "$title" \
|
|
--detail "$detail" >/dev/null
|
|
}
|
|
|
|
ingest_event codex tool "执行工具" "OK"
|
|
ingest_event claude_code waiting "等待确认" "这是一条较长的 Claude Code 无鼠标验证消息 ${RUN_ID},用来确认最大宽度、自动换行以及最多两行截断。"
|
|
ingest_event pi start "开始处理" "Pi 已开始 ${RUN_ID}"
|
|
ingest_event opencode done "已完成" "OpenCode 已完成 ${RUN_ID}"
|
|
|
|
validate_overlay_ax() {
|
|
local expected_vertical_relation="$1"
|
|
APP_NAME="$APP_NAME" APP_PID="$APC_OWNED_APP_PID" RUN_ID="$RUN_ID" EXPECTED_VERTICAL_RELATION="$expected_vertical_relation" swift - <<'SWIFT'
|
|
import AppKit
|
|
import ApplicationServices
|
|
import CoreGraphics
|
|
import Foundation
|
|
|
|
let appName = ProcessInfo.processInfo.environment["APP_NAME"] ?? "AgentPetCompanion"
|
|
let appPID = Int32(ProcessInfo.processInfo.environment["APP_PID"] ?? "") ?? -1
|
|
let runID = ProcessInfo.processInfo.environment["RUN_ID"] ?? ""
|
|
let relation = ProcessInfo.processInfo.environment["EXPECTED_VERTICAL_RELATION"] ?? "above"
|
|
|
|
guard let app = NSRunningApplication(processIdentifier: appPID),
|
|
(app.executableURL?.lastPathComponent == appName || app.localizedName == appName) else {
|
|
fputs("overlay non-mouse validation failed: app is not running\n", stderr)
|
|
exit(1)
|
|
}
|
|
|
|
let axApp = AXUIElementCreateApplication(app.processIdentifier)
|
|
|
|
func copy(_ element: AXUIElement, _ attr: String) -> AnyObject? {
|
|
var value: AnyObject?
|
|
return AXUIElementCopyAttributeValue(element, attr as CFString, &value) == .success ? value : nil
|
|
}
|
|
|
|
func string(_ element: AXUIElement, _ attr: String) -> String {
|
|
(copy(element, attr) as? String) ?? ""
|
|
}
|
|
|
|
func point(_ element: AXUIElement, _ attr: String) -> CGPoint? {
|
|
guard let value = copy(element, attr) else { return nil }
|
|
var point = CGPoint.zero
|
|
guard AXValueGetValue(value as! AXValue, .cgPoint, &point) else { return nil }
|
|
return point
|
|
}
|
|
|
|
func size(_ element: AXUIElement, _ attr: String) -> CGSize? {
|
|
guard let value = copy(element, attr) else { return nil }
|
|
var size = CGSize.zero
|
|
guard AXValueGetValue(value as! AXValue, .cgSize, &size) else { return nil }
|
|
return size
|
|
}
|
|
|
|
struct AXNode {
|
|
let role: String
|
|
let identifier: String
|
|
let title: String
|
|
let value: String
|
|
let description: String
|
|
let frame: CGRect?
|
|
|
|
var strings: [String] {
|
|
[title, value, description].filter { !$0.isEmpty }
|
|
}
|
|
}
|
|
|
|
func collectNodes(_ element: AXUIElement, into nodes: inout [AXNode]) {
|
|
let position = point(element, kAXPositionAttribute)
|
|
let nodeSize = size(element, kAXSizeAttribute)
|
|
nodes.append(AXNode(
|
|
role: string(element, kAXRoleAttribute),
|
|
identifier: string(element, kAXIdentifierAttribute),
|
|
title: string(element, kAXTitleAttribute),
|
|
value: string(element, kAXValueAttribute),
|
|
description: string(element, kAXDescriptionAttribute),
|
|
frame: position.flatMap { origin in nodeSize.map { CGRect(origin: origin, size: $0) } }
|
|
))
|
|
if let children = copy(element, kAXChildrenAttribute) as? [AXUIElement] {
|
|
for child in children {
|
|
collectNodes(child, into: &nodes)
|
|
}
|
|
}
|
|
}
|
|
|
|
guard let windows = copy(axApp, kAXWindowsAttribute) as? [AXUIElement] else {
|
|
fputs("overlay non-mouse validation failed: AX windows are unavailable\n", stderr)
|
|
exit(1)
|
|
}
|
|
|
|
var bubbleWindow: (element: AXUIElement, frame: CGRect, nodes: [AXNode])?
|
|
var emptyWindows: [(AXUIElement, CGRect)] = []
|
|
|
|
for window in windows {
|
|
let title = string(window, kAXTitleAttribute)
|
|
guard let position = point(window, kAXPositionAttribute),
|
|
let windowSize = size(window, kAXSizeAttribute) else { continue }
|
|
let frame = CGRect(origin: position, size: windowSize)
|
|
if title.isEmpty {
|
|
emptyWindows.append((window, frame))
|
|
}
|
|
var nodes: [AXNode] = []
|
|
collectNodes(window, into: &nodes)
|
|
let values = nodes.flatMap(\.strings)
|
|
let containsClaudeGroup = nodes.contains {
|
|
$0.identifier == "overlay.group.agent-claude_code"
|
|
} || values.contains("Claude Code")
|
|
let containsWaitingSession = nodes.contains {
|
|
$0.identifier.hasPrefix(
|
|
"overlay.session.session-claude_code-"
|
|
)
|
|
}
|
|
if containsClaudeGroup && containsWaitingSession {
|
|
bubbleWindow = (window, frame, nodes)
|
|
}
|
|
}
|
|
|
|
guard let bubble = bubbleWindow else {
|
|
fputs("overlay non-mouse validation failed: canonical Claude waiting bubble was not found\n", stderr)
|
|
for (index, window) in windows.enumerated() {
|
|
var diagnosticNodes: [AXNode] = []
|
|
collectNodes(window, into: &diagnosticNodes)
|
|
let summary = diagnosticNodes.prefix(80).map { node in
|
|
[node.role, node.identifier, node.title, node.value, node.description]
|
|
.filter { !$0.isEmpty }
|
|
.joined(separator: ":")
|
|
}.filter { !$0.isEmpty }.joined(separator: " | ")
|
|
let windowFrame = point(window, kAXPositionAttribute).flatMap { origin in
|
|
size(window, kAXSizeAttribute).map { CGRect(origin: origin, size: $0) }
|
|
} ?? .zero
|
|
fputs("AX window \(index) title=\(string(window, kAXTitleAttribute)) frame=\(windowFrame) nodes=\(summary)\n", stderr)
|
|
}
|
|
exit(1)
|
|
}
|
|
|
|
var petCandidates: [CGRect] = []
|
|
for (_, frame) in emptyWindows {
|
|
let compact = frame.width >= 28
|
|
&& frame.width <= 220
|
|
&& frame.height >= 40
|
|
&& frame.height <= 260
|
|
if compact && frame != bubble.frame {
|
|
petCandidates.append(frame)
|
|
}
|
|
}
|
|
let sortedPetCandidates = petCandidates.sorted { lhs, rhs in
|
|
(lhs.width * lhs.height) < (rhs.width * rhs.height)
|
|
}
|
|
guard let pet = sortedPetCandidates.first else {
|
|
fputs("overlay non-mouse validation failed: compact pet panel was not found\n", stderr)
|
|
exit(1)
|
|
}
|
|
|
|
let values = bubble.nodes.flatMap(\.strings)
|
|
let requiredAgentHeaders = ["Codex", "Claude Code", "Pi Coding Agent", "OpenCode"]
|
|
let missingAgentHeaders = requiredAgentHeaders.filter { !values.contains($0) }
|
|
if !missingAgentHeaders.isEmpty {
|
|
fputs("overlay non-mouse validation failed: grouped bubble is missing agent headers \(missingAgentHeaders)\n", stderr)
|
|
exit(1)
|
|
}
|
|
|
|
guard let waitingNode = bubble.nodes.first(where: {
|
|
$0.identifier.hasPrefix("overlay.session.session-claude_code-")
|
|
}) else {
|
|
fputs("overlay non-mouse validation failed: semantic Claude waiting session is missing\n", stderr)
|
|
exit(1)
|
|
}
|
|
|
|
func containsInOrder(
|
|
_ text: String,
|
|
candidateGroups: [[String]]
|
|
) -> Bool {
|
|
let value = text as NSString
|
|
var cursor = 0
|
|
for candidates in candidateGroups {
|
|
let remaining = NSRange(
|
|
location: cursor,
|
|
length: max(0, value.length - cursor)
|
|
)
|
|
let matches = candidates.map {
|
|
value.range(of: $0, options: [], range: remaining)
|
|
}.filter { $0.location != NSNotFound }
|
|
guard let match = matches.min(by: { $0.location < $1.location }) else {
|
|
return false
|
|
}
|
|
cursor = match.location + match.length
|
|
}
|
|
return true
|
|
}
|
|
|
|
let waitingReadingOrder: [[String]] = [
|
|
["Claude Code"],
|
|
["Claude 会话", "Claude session", "Claude Code 会话", "Claude Code session"],
|
|
["等待你操作", "Waiting for You"],
|
|
[
|
|
"请回到 Agent 完成确认、回答或决策。",
|
|
"Return to the agent to approve, answer, or decide.",
|
|
],
|
|
[
|
|
"返回会话", "Return to Session",
|
|
"打开 Claude Code", "Open Claude Code",
|
|
"没有安全可用的跳转目标", "No safe destination is available",
|
|
],
|
|
]
|
|
guard waitingNode.strings.contains(where: {
|
|
containsInOrder($0, candidateGroups: waitingReadingOrder)
|
|
}) else {
|
|
fputs(
|
|
"overlay non-mouse validation failed: VoiceOver order is not Agent → session → status → message → action for Claude waiting; synthetic AX strings=\(waitingNode.strings)\n",
|
|
stderr
|
|
)
|
|
exit(1)
|
|
}
|
|
|
|
if !runID.isEmpty && values.contains(where: { $0.contains(runID) }) {
|
|
fputs("overlay non-mouse validation failed: raw event copy leaked into the grouped bubble\n", stderr)
|
|
exit(1)
|
|
}
|
|
|
|
if !(bubble.frame.width >= 108 && bubble.frame.width <= 360
|
|
&& bubble.frame.height >= 70 && bubble.frame.height <= 680) {
|
|
fputs("overlay non-mouse validation failed: grouped bubble frame is outside the supported bounds \(bubble.frame)\n", stderr)
|
|
exit(1)
|
|
}
|
|
|
|
guard let waitingFrame = waitingNode.frame,
|
|
waitingFrame.width >= 80,
|
|
waitingFrame.height >= 28,
|
|
waitingFrame.width <= bubble.frame.width,
|
|
waitingFrame.height <= 100 else {
|
|
fputs("overlay non-mouse validation failed: waiting session has unexpected AX frame \(String(describing: waitingNode.frame))\n", stderr)
|
|
exit(1)
|
|
}
|
|
|
|
let bubbleAbovePet = bubble.frame.midY < pet.midY
|
|
if relation == "above", !bubbleAbovePet {
|
|
fputs("overlay non-mouse validation failed: bubble should be above pet near bottom edge\n", stderr)
|
|
exit(1)
|
|
}
|
|
if relation == "below", bubbleAbovePet {
|
|
fputs("overlay non-mouse validation failed: bubble should move below pet near top edge\n", stderr)
|
|
exit(1)
|
|
}
|
|
|
|
print("Overlay AX non-mouse check ok: relation=\(relation) bubble=\(Int(bubble.frame.width))x\(Int(bubble.frame.height)) pet=\(Int(pet.width))x\(Int(pet.height))")
|
|
SWIFT
|
|
}
|
|
|
|
wait_for_overlay_ax() {
|
|
local expected_vertical_relation="$1"
|
|
local attempt
|
|
local log_path="$TMP_DIR/overlay-ax-${expected_vertical_relation}.log"
|
|
|
|
for attempt in {1..120}; do
|
|
if validate_overlay_ax "$expected_vertical_relation" >"$log_path" 2>&1; then
|
|
cat "$log_path"
|
|
return 0
|
|
fi
|
|
sleep 0.25
|
|
done
|
|
|
|
cat "$log_path" >&2
|
|
return 1
|
|
}
|
|
|
|
validate_single_compact_bubble() {
|
|
APP_NAME="$APP_NAME" APP_PID="$APC_OWNED_APP_PID" RUN_ID="$RUN_ID" swift - <<'SWIFT'
|
|
import AppKit
|
|
import ApplicationServices
|
|
import CoreGraphics
|
|
import Foundation
|
|
|
|
let appName = ProcessInfo.processInfo.environment["APP_NAME"] ?? "AgentPetCompanion"
|
|
let appPID = Int32(ProcessInfo.processInfo.environment["APP_PID"] ?? "") ?? -1
|
|
let runID = ProcessInfo.processInfo.environment["RUN_ID"] ?? ""
|
|
|
|
guard let app = NSRunningApplication(processIdentifier: appPID),
|
|
(app.executableURL?.lastPathComponent == appName || app.localizedName == appName) else {
|
|
fputs("overlay short-bubble validation failed: app is not running\n", stderr)
|
|
exit(1)
|
|
}
|
|
|
|
let axApp = AXUIElementCreateApplication(app.processIdentifier)
|
|
|
|
func copy(_ element: AXUIElement, _ attr: String) -> AnyObject? {
|
|
var value: AnyObject?
|
|
return AXUIElementCopyAttributeValue(element, attr as CFString, &value) == .success ? value : nil
|
|
}
|
|
|
|
func string(_ element: AXUIElement, _ attr: String) -> String {
|
|
(copy(element, attr) as? String) ?? ""
|
|
}
|
|
|
|
func point(_ element: AXUIElement, _ attr: String) -> CGPoint? {
|
|
guard let value = copy(element, attr) else { return nil }
|
|
var point = CGPoint.zero
|
|
guard AXValueGetValue(value as! AXValue, .cgPoint, &point) else { return nil }
|
|
return point
|
|
}
|
|
|
|
func size(_ element: AXUIElement, _ attr: String) -> CGSize? {
|
|
guard let value = copy(element, attr) else { return nil }
|
|
var size = CGSize.zero
|
|
guard AXValueGetValue(value as! AXValue, .cgSize, &size) else { return nil }
|
|
return size
|
|
}
|
|
|
|
struct AXNode {
|
|
let role: String
|
|
let identifier: String
|
|
let title: String
|
|
let value: String
|
|
let description: String
|
|
let frame: CGRect?
|
|
|
|
var strings: [String] {
|
|
[title, value, description].filter { !$0.isEmpty }
|
|
}
|
|
}
|
|
|
|
func collectNodes(_ element: AXUIElement, into nodes: inout [AXNode]) {
|
|
let position = point(element, kAXPositionAttribute)
|
|
let nodeSize = size(element, kAXSizeAttribute)
|
|
nodes.append(AXNode(
|
|
role: string(element, kAXRoleAttribute),
|
|
identifier: string(element, kAXIdentifierAttribute),
|
|
title: string(element, kAXTitleAttribute),
|
|
value: string(element, kAXValueAttribute),
|
|
description: string(element, kAXDescriptionAttribute),
|
|
frame: position.flatMap { origin in nodeSize.map { CGRect(origin: origin, size: $0) } }
|
|
))
|
|
if let children = copy(element, kAXChildrenAttribute) as? [AXUIElement] {
|
|
for child in children {
|
|
collectNodes(child, into: &nodes)
|
|
}
|
|
}
|
|
}
|
|
|
|
guard let windows = copy(axApp, kAXWindowsAttribute) as? [AXUIElement] else {
|
|
fputs("overlay short-bubble validation failed: AX windows are unavailable\n", stderr)
|
|
exit(1)
|
|
}
|
|
|
|
var candidates: [(frame: CGRect, nodes: [AXNode])] = []
|
|
for window in windows where string(window, kAXTitleAttribute).isEmpty {
|
|
guard let position = point(window, kAXPositionAttribute),
|
|
let windowSize = size(window, kAXSizeAttribute) else { continue }
|
|
var nodes: [AXNode] = []
|
|
collectNodes(window, into: &nodes)
|
|
let values = nodes.flatMap(\.strings)
|
|
let containsCodexGroup = nodes.contains {
|
|
$0.identifier == "overlay.group.agent-codex"
|
|
} || values.contains("Codex")
|
|
let containsDoneSession = nodes.contains {
|
|
$0.identifier.hasPrefix("overlay.session.session-codex-")
|
|
}
|
|
if containsCodexGroup && containsDoneSession {
|
|
candidates.append((CGRect(origin: position, size: windowSize), nodes))
|
|
}
|
|
}
|
|
|
|
guard let bubble = candidates.sorted(by: { $0.frame.width < $1.frame.width }).first else {
|
|
fputs("overlay short-bubble validation failed: compact Codex done bubble not found\n", stderr)
|
|
exit(1)
|
|
}
|
|
|
|
let values = bubble.nodes.flatMap(\.strings)
|
|
let forbidden = ["Claude Code", "Pi Coding Agent", "OpenCode"].filter(values.contains)
|
|
if !forbidden.isEmpty {
|
|
fputs("overlay short-bubble validation failed: single-agent bubble contains extra agents \(forbidden)\n", stderr)
|
|
exit(1)
|
|
}
|
|
|
|
guard let doneNode = bubble.nodes.first(where: {
|
|
$0.identifier.hasPrefix("overlay.session.session-codex-")
|
|
}) else {
|
|
fputs("overlay short-bubble validation failed: semantic Codex done session is missing\n", stderr)
|
|
exit(1)
|
|
}
|
|
|
|
func containsInOrder(
|
|
_ text: String,
|
|
candidateGroups: [[String]]
|
|
) -> Bool {
|
|
let value = text as NSString
|
|
var cursor = 0
|
|
for candidates in candidateGroups {
|
|
let remaining = NSRange(
|
|
location: cursor,
|
|
length: max(0, value.length - cursor)
|
|
)
|
|
let matches = candidates.map {
|
|
value.range(of: $0, options: [], range: remaining)
|
|
}.filter { $0.location != NSNotFound }
|
|
guard let match = matches.min(by: { $0.location < $1.location }) else {
|
|
return false
|
|
}
|
|
cursor = match.location + match.length
|
|
}
|
|
return true
|
|
}
|
|
|
|
let doneReadingOrder: [[String]] = [
|
|
["Codex"],
|
|
["Codex 会话", "Codex session"],
|
|
["已完成", "Completed"],
|
|
["Agent 已完成任务。", "The agent finished the task."],
|
|
[
|
|
"返回会话", "Return to Session",
|
|
"打开 Codex", "Open Codex",
|
|
"没有安全可用的跳转目标", "No safe destination is available",
|
|
],
|
|
]
|
|
guard doneNode.strings.contains(where: {
|
|
containsInOrder($0, candidateGroups: doneReadingOrder)
|
|
}) else {
|
|
fputs(
|
|
"overlay short-bubble validation failed: VoiceOver order is not Agent → session → status → message → action for Codex done\n",
|
|
stderr
|
|
)
|
|
exit(1)
|
|
}
|
|
|
|
if !runID.isEmpty && values.contains(where: { $0.contains(runID) }) {
|
|
fputs("overlay short-bubble validation failed: raw completion copy leaked into the bubble\n", stderr)
|
|
exit(1)
|
|
}
|
|
|
|
if !(bubble.frame.width >= 108 && bubble.frame.width <= 360
|
|
&& bubble.frame.height >= 70 && bubble.frame.height <= 130) {
|
|
fputs("overlay short-bubble validation failed: single-agent bubble is outside the supported bounds, frame=\(bubble.frame)\n", stderr)
|
|
exit(1)
|
|
}
|
|
|
|
guard let doneFrame = doneNode.frame,
|
|
doneFrame.width >= 80,
|
|
doneFrame.height >= 28,
|
|
doneFrame.width <= bubble.frame.width,
|
|
doneFrame.height <= 100 else {
|
|
fputs("overlay short-bubble validation failed: done session metrics unexpected, frame=\(String(describing: doneNode.frame))\n", stderr)
|
|
exit(1)
|
|
}
|
|
|
|
print("Overlay single-agent non-mouse check ok: bubble=\(Int(bubble.frame.width))x\(Int(bubble.frame.height))")
|
|
SWIFT
|
|
}
|
|
|
|
wait_for_single_compact_bubble() {
|
|
local attempt
|
|
local log_path="$TMP_DIR/overlay-ax-compact.log"
|
|
|
|
for attempt in {1..120}; do
|
|
if validate_single_compact_bubble >"$log_path" 2>&1; then
|
|
cat "$log_path"
|
|
return 0
|
|
fi
|
|
sleep 0.25
|
|
done
|
|
|
|
cat "$log_path" >&2
|
|
return 1
|
|
}
|
|
|
|
"$PETCORE_CLI" overlay placement set \
|
|
--x "$BOTTOM_X" \
|
|
--y "$BOTTOM_Y" \
|
|
--display-width-pt 112 \
|
|
--display-id overlay-nomouse-bottom >/dev/null
|
|
sleep 1.5
|
|
wait_for_overlay_ax above
|
|
|
|
"$PETCORE_CLI" overlay placement set \
|
|
--x "$TOP_X" \
|
|
--y "$TOP_Y" \
|
|
--display-width-pt 112 \
|
|
--display-id overlay-nomouse-top >/dev/null
|
|
sleep 1.5
|
|
wait_for_overlay_ax below
|
|
|
|
CODEX_ONLY_BEHAVIOR_JSON="$(SNAPSHOT="$(wait_snapshot)" python3 - <<'PY'
|
|
import json
|
|
import os
|
|
|
|
data = json.loads(os.environ["SNAPSHOT"])
|
|
behavior = dict(data["behavior"])
|
|
behavior.pop("mouse_passthrough", None)
|
|
for key in ["codex", "claude_code", "pi", "opencode"]:
|
|
behavior.setdefault("sources", {})[key] = key == "codex"
|
|
print(json.dumps(behavior, ensure_ascii=False))
|
|
PY
|
|
)"
|
|
"$PETCORE_CLI" behavior set-json --value-json "$CODEX_ONLY_BEHAVIOR_JSON" >/dev/null
|
|
ingest_event codex done "完成" "私密完成负载 ${RUN_ID}"
|
|
sleep 1.5
|
|
wait_for_single_compact_bubble
|
|
|
|
echo "Overlay non-mouse validation ok: $RUN_ID"
|