Improve Linux Desktop state controls
Add a desktop_state helper, expanded desktopctl observe-act-verify commands, backend desktop_state support, Extra prompt state, and Xpra bridge diagnostics for the built-in Linux Desktop. Update the Linux Desktop skill so agents prefer structured/app-native/keyboard workflows, treat coordinate clicks as last resort, and verify terminal or CLI-agent work with fresh final screenshots. Cover the behavior with focused Office desktop state, canvas setup, and office_session tests.
Alessandro committed
May 5, 2026 at 11:20 UTC
78570e5689403b33fe576c3282ae6802e2e730ce
11 files changed
+1398
-13
plugins/_office/api/office_session.py
+6
@@ -61,6 +61,8 @@ class OfficeSession(ApiHandler):
61
return self._desktop_save(input)
62
if action == "desktop_sync":
63
return self._desktop_sync(input)
64
+ if action == "desktop_state":
65
+ return self._desktop_state(input)
66
return {"ok": False, "error": f"Unsupported office session action: {action}"}
67
68
async def _open_document(self, doc: dict, input: dict, request: Request) -> dict:
@@ -190,6 +192,10 @@ class OfficeSession(ApiHandler):
192
file_id=str(input.get("file_id") or ""),
193
)
194
195
+ def _desktop_state(self, input: dict) -> dict:
196
+ include_screenshot = bool(input.get("include_screenshot") is True)
197
+ return libreoffice_desktop.get_manager().state(include_screenshot=include_screenshot)
198
+
199
def _origin(self, request: Request) -> str:
200
origin = request.headers.get("Origin") or request.host_url.rstrip("/")
201
return origin.rstrip("/")
plugins/_office/helpers/canvas_context.py
+20
-1
@@ -2,13 +2,15 @@ from __future__ import annotations
2
3
from typing import Any
4
5
+from plugins._office.helpers import desktop_state
6
from plugins._office.helpers import document_store
7
8
9
def build_context(max_items: int = 6) -> str:
10
documents = document_store.get_open_documents(limit=max_items)
11
+ desktop_context = build_desktop_context()
12
if not documents:
11
- return ""
13
+ return desktop_context
14
15
lines = [
16
"These document artifacts have active canvas sessions. Content is omitted; load skill `office-artifacts` for edit workflow, then use document_artifact:read before content-sensitive edits.",
@@ -18,6 +20,8 @@ def build_context(max_items: int = 6) -> str:
20
lines.append(
21
"Use document_artifact:edit with file_id or path for saved edits; tool results refresh the document canvas."
22
)
23
+ if desktop_context:
24
+ lines.extend(["", desktop_context])
25
return "\n".join(lines)
26
27
@@ -29,3 +33,18 @@ def format_document_line(doc: dict[str, Any]) -> str:
33
f"size={doc.get('size', 0)} bytes, last_modified={doc.get('last_modified', '')}, "
34
f"open_sessions={doc.get('open_sessions', 1)})"
35
)
36
+
37
+
38
+def build_desktop_context() -> str:
39
+ if not desktop_state.session_manifest_exists():
40
+ return ""
41
+ try:
42
+ return desktop_state.compact_prompt_context(
43
+ desktop_state.collect_state(include_screenshot=False),
44
+ )
45
+ except Exception as exc:
46
+ return (
47
+ "[DESKTOP STATE]\n"
48
+ f"- unavailable={exc}\n"
49
+ "- next=Open the Desktop canvas manually, then run plugins/_office/skills/linux-desktop/scripts/desktopctl.sh observe --json."
50
+ )
plugins/_office/helpers/desktop_state.py
new
+680
@@ -0,0 +1,680 @@
1
+from __future__ import annotations
2
+
3
+import argparse
4
+import json
5
+import os
6
+import re
7
+import shutil
8
+import subprocess
9
+import time
10
+from pathlib import Path
11
+from typing import Any
12
+
13
+PROJECT_ROOT = Path(__file__).resolve().parents[3]
14
+
15
+
16
+SESSION_ID = "agent-zero-desktop"
17
+BASE_DIR = Path(os.environ.get("A0_BASE_DIR") or ("/a0" if Path("/a0").exists() else PROJECT_ROOT))
18
+STATE_DIR = BASE_DIR / "tmp" / "_office" / "desktop"
19
+SESSION_DIR = STATE_DIR / "sessions"
20
+PROFILE_DIR = STATE_DIR / "profiles"
21
+SCREENSHOT_DIR = STATE_DIR / "screenshots"
22
+RECENT_SCREENSHOT_SECONDS = 600
23
+
24
+
25
+def session_manifest_path(session_id: str = SESSION_ID) -> Path:
26
+ return Path(os.environ.get("A0_DESKTOP_MANIFEST") or SESSION_DIR / f"{session_id}.json")
27
+
28
+
29
+def session_manifest_exists(session_id: str = SESSION_ID) -> bool:
30
+ return session_manifest_path(session_id).exists()
31
+
32
+
33
+def collect_state(*, include_screenshot: bool = False, screenshot_path: str | Path | None = None) -> dict[str, Any]:
34
+ errors: list[str] = []
35
+ env_info = resolve_environment(errors=errors)
36
+ display = env_info["display"]
37
+ profile_dir = env_info["profile_dir"]
38
+ env = display_env(display=display, profile_dir=profile_dir)
39
+
40
+ capabilities = collect_capabilities()
41
+ for name in ("xdotool", "xrandr", "xwininfo", "xprop"):
42
+ if not capabilities.get(name):
43
+ errors.append(f"{name} is not installed; install Office runtime dependencies through the _office plugin hook.")
44
+
45
+ size = collect_display_size(env, capabilities, errors)
46
+ pointer = collect_pointer(env, capabilities, errors)
47
+ active_window = collect_active_window(env, capabilities, errors)
48
+ windows = collect_windows(env, capabilities, errors)
49
+ screenshot = latest_screenshot()
50
+
51
+ if include_screenshot:
52
+ screenshot = capture_screenshot(env, capabilities, path=screenshot_path, errors=errors)
53
+
54
+ return stable_state(
55
+ display=display,
56
+ profile_dir=profile_dir,
57
+ size=size,
58
+ pointer=pointer,
59
+ active_window=active_window,
60
+ windows=windows,
61
+ screenshot=screenshot,
62
+ capabilities=capabilities,
63
+ errors=errors,
64
+ )
65
+
66
+
67
+def capture_screenshot(
68
+ env: dict[str, str] | None = None,
69
+ capabilities: dict[str, str] | None = None,
70
+ *,
71
+ path: str | Path | None = None,
72
+ errors: list[str] | None = None,
73
+) -> dict[str, Any]:
74
+ local_errors = errors if errors is not None else []
75
+ capabilities = capabilities or collect_capabilities()
76
+ if not env:
77
+ env_errors: list[str] = []
78
+ env_info = resolve_environment(errors=env_errors)
79
+ local_errors.extend(env_errors)
80
+ env = display_env(display=env_info["display"], profile_dir=env_info["profile_dir"])
81
+
82
+ xwd = capabilities.get("xwd") or shutil.which("xwd") or ""
83
+ if not xwd:
84
+ message = "xwd is not installed; install x11-apps through the _office plugin hook."
85
+ local_errors.append(message)
86
+ return {"ok": False, "path": "", "format": "", "captured_at": "", "error": message}
87
+
88
+ SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
89
+ timestamp = time.strftime("%Y%m%d-%H%M%S")
90
+ target = Path(path) if path else SCREENSHOT_DIR / f"desktop-{timestamp}.png"
91
+ target.parent.mkdir(parents=True, exist_ok=True)
92
+ raw_path = target.with_suffix(".xwd")
93
+
94
+ result = run([xwd, "-root", "-silent", "-out", str(raw_path)], env=env, timeout=8)
95
+ if result.returncode != 0:
96
+ detail = command_output(result) or "xwd screenshot capture failed."
97
+ local_errors.append(detail)
98
+ raw_path.unlink(missing_ok=True)
99
+ return {"ok": False, "path": "", "format": "", "captured_at": "", "error": detail}
100
+
101
+ if target.suffix.lower() == ".xwd":
102
+ return {
103
+ "ok": True,
104
+ "path": str(raw_path),
105
+ "format": "xwd",
106
+ "captured_at": iso_now(),
107
+ "recent": True,
108
+ "error": "",
109
+ }
110
+
111
+ try:
112
+ from PIL import Image
113
+
114
+ with Image.open(raw_path) as image:
115
+ image.save(target)
116
+ width = int(image.width)
117
+ height = int(image.height)
118
+ raw_path.unlink(missing_ok=True)
119
+ return {
120
+ "ok": True,
121
+ "path": str(target),
122
+ "format": target.suffix.lower().lstrip(".") or "png",
123
+ "width": width,
124
+ "height": height,
125
+ "captured_at": iso_now(),
126
+ "recent": True,
127
+ "error": "",
128
+ }
129
+ except Exception as exc:
130
+ try:
131
+ converted = convert_xwd_to_image(raw_path, target)
132
+ raw_path.unlink(missing_ok=True)
133
+ return {
134
+ "ok": True,
135
+ "path": str(target),
136
+ "format": target.suffix.lower().lstrip(".") or "png",
137
+ "width": converted["width"],
138
+ "height": converted["height"],
139
+ "captured_at": iso_now(),
140
+ "recent": True,
141
+ "error": "",
142
+ }
143
+ except Exception as fallback_exc:
144
+ message = f"Pillow could not convert the XWD screenshot: {exc}; fallback parser failed: {fallback_exc}"
145
+ local_errors.append(message)
146
+ return {
147
+ "ok": True,
148
+ "path": str(raw_path),
149
+ "format": "xwd",
150
+ "captured_at": iso_now(),
151
+ "recent": True,
152
+ "error": message,
153
+ }
154
+
155
+
156
+def convert_xwd_to_image(raw_path: Path, target: Path) -> dict[str, int]:
157
+ from PIL import Image
158
+
159
+ data = raw_path.read_bytes()
160
+ header, endian = parse_xwd_header(data)
161
+ width = header["pixmap_width"]
162
+ height = header["pixmap_height"]
163
+ bytes_per_line = header["bytes_per_line"]
164
+ bits_per_pixel = header["bits_per_pixel"]
165
+ image_byte_order = "little" if header["byte_order"] == 0 else "big"
166
+ color_table_size = header["ncolors"] * 12
167
+ pixel_offset = header["header_size"] + color_table_size
168
+ bytes_per_pixel = max((bits_per_pixel + 7) // 8, 1)
169
+ if width > 0 and bytes_per_line % width == 0:
170
+ bytes_per_pixel = max(bytes_per_pixel, bytes_per_line // width)
171
+ if width <= 0 or height <= 0 or bytes_per_line <= 0:
172
+ raise ValueError("invalid XWD dimensions")
173
+ if pixel_offset + (height * bytes_per_line) > len(data):
174
+ raise ValueError("truncated XWD pixel data")
175
+
176
+ red_mask = header["red_mask"]
177
+ green_mask = header["green_mask"]
178
+ blue_mask = header["blue_mask"]
179
+ red_shift, red_bits = mask_shift_and_bits(red_mask)
180
+ green_shift, green_bits = mask_shift_and_bits(green_mask)
181
+ blue_shift, blue_bits = mask_shift_and_bits(blue_mask)
182
+ if min(red_bits, green_bits, blue_bits) <= 0:
183
+ raise ValueError("unsupported XWD visual masks")
184
+
185
+ pixels: list[tuple[int, int, int]] = []
186
+ for row in range(height):
187
+ row_start = pixel_offset + (row * bytes_per_line)
188
+ for column in range(width):
189
+ start = row_start + (column * bytes_per_pixel)
190
+ pixel_bytes = data[start : start + bytes_per_pixel]
191
+ if len(pixel_bytes) < bytes_per_pixel:
192
+ raise ValueError("truncated XWD pixel")
193
+ pixel = int.from_bytes(pixel_bytes, image_byte_order, signed=False)
194
+ pixels.append(
195
+ (
196
+ scale_channel((pixel & red_mask) >> red_shift, red_bits),
197
+ scale_channel((pixel & green_mask) >> green_shift, green_bits),
198
+ scale_channel((pixel & blue_mask) >> blue_shift, blue_bits),
199
+ ),
200
+ )
201
+
202
+ image = Image.new("RGB", (width, height))
203
+ image.putdata(pixels)
204
+ image.save(target)
205
+ return {"width": width, "height": height}
206
+
207
+
208
+def parse_xwd_header(data: bytes) -> tuple[dict[str, int], str]:
209
+ if len(data) < 100:
210
+ raise ValueError("XWD header is too short")
211
+ field_names = (
212
+ "header_size",
213
+ "file_version",
214
+ "pixmap_format",
215
+ "pixmap_depth",
216
+ "pixmap_width",
217
+ "pixmap_height",
218
+ "xoffset",
219
+ "byte_order",
220
+ "bitmap_unit",
221
+ "bitmap_bit_order",
222
+ "bitmap_pad",
223
+ "bits_per_pixel",
224
+ "bytes_per_line",
225
+ "visual_class",
226
+ "red_mask",
227
+ "green_mask",
228
+ "blue_mask",
229
+ "bits_per_rgb",
230
+ "colormap_entries",
231
+ "ncolors",
232
+ "window_width",
233
+ "window_height",
234
+ "window_x",
235
+ "window_y",
236
+ "window_bdrwidth",
237
+ )
238
+ for endian in ("big", "little"):
239
+ values = [int.from_bytes(data[index : index + 4], endian, signed=False) for index in range(0, 100, 4)]
240
+ header = dict(zip(field_names, values, strict=True))
241
+ if 100 <= header["header_size"] <= len(data) and header["file_version"] == 7:
242
+ return header, endian
243
+ raise ValueError("unsupported XWD header")
244
+
245
+
246
+def mask_shift_and_bits(mask: int) -> tuple[int, int]:
247
+ if mask <= 0:
248
+ return 0, 0
249
+ shift = 0
250
+ value = mask
251
+ while value and value & 1 == 0:
252
+ shift += 1
253
+ value >>= 1
254
+ bits = 0
255
+ while value & 1:
256
+ bits += 1
257
+ value >>= 1
258
+ return shift, bits
259
+
260
+
261
+def scale_channel(value: int, bits: int) -> int:
262
+ if bits >= 8:
263
+ return max(0, min(255, value >> (bits - 8)))
264
+ max_value = (1 << bits) - 1
265
+ return 0 if max_value <= 0 else round((value / max_value) * 255)
266
+
267
+
268
+def resolve_environment(*, errors: list[str] | None = None, session_id: str = SESSION_ID) -> dict[str, str]:
269
+ local_errors = errors if errors is not None else []
270
+ manifest = session_manifest_path(session_id)
271
+ payload: dict[str, Any] = {}
272
+ if manifest.exists():
273
+ try:
274
+ payload = json.loads(manifest.read_text(encoding="utf-8"))
275
+ except Exception as exc:
276
+ local_errors.append(f"Desktop session manifest is unreadable: {exc}")
277
+ elif not (os.environ.get("A0_DESKTOP_DISPLAY") or os.environ.get("DISPLAY")):
278
+ local_errors.append(f"Desktop session manifest not found at {manifest}; open the Desktop canvas before GUI control.")
279
+
280
+ display_value = str(
281
+ os.environ.get("A0_DESKTOP_DISPLAY")
282
+ or payload.get("display")
283
+ or os.environ.get("DISPLAY")
284
+ or ""
285
+ ).strip()
286
+ if display_value.startswith(":"):
287
+ display = display_value
288
+ elif display_value:
289
+ display = f":{display_value}"
290
+ else:
291
+ display = ""
292
+ local_errors.append("Desktop DISPLAY is unavailable; the persistent Desktop session is not running.")
293
+
294
+ profile_dir = str(
295
+ os.environ.get("A0_DESKTOP_PROFILE")
296
+ or os.environ.get("A0_DESKTOP_HOME")
297
+ or payload.get("profile_dir")
298
+ or os.environ.get("HOME")
299
+ or PROFILE_DIR / session_id
300
+ )
301
+
302
+ return {
303
+ "display": display,
304
+ "profile_dir": profile_dir,
305
+ "manifest": str(manifest),
306
+ }
307
+
308
+
309
+def display_env(*, display: str, profile_dir: str) -> dict[str, str]:
310
+ env = {
311
+ **os.environ,
312
+ "HOME": profile_dir,
313
+ "XDG_CONFIG_HOME": os.environ.get("XDG_CONFIG_HOME") or str(Path(profile_dir) / ".config"),
314
+ "XDG_DATA_HOME": os.environ.get("XDG_DATA_HOME") or str(Path(profile_dir) / ".local" / "share"),
315
+ "XDG_CACHE_HOME": os.environ.get("XDG_CACHE_HOME") or str(Path(profile_dir) / ".cache"),
316
+ "XDG_CURRENT_DESKTOP": os.environ.get("XDG_CURRENT_DESKTOP") or "XFCE",
317
+ }
318
+ if display:
319
+ env["DISPLAY"] = display
320
+ xauthority = os.environ.get("A0_DESKTOP_XAUTHORITY") or str(Path(profile_dir) / ".Xauthority")
321
+ if Path(xauthority).exists():
322
+ env["XAUTHORITY"] = xauthority
323
+ return env
324
+
325
+
326
+def collect_capabilities() -> dict[str, str]:
327
+ return {
328
+ name: shutil.which(name) or ""
329
+ for name in (
330
+ "xdotool",
331
+ "xrandr",
332
+ "xwininfo",
333
+ "xprop",
334
+ "xwd",
335
+ "xclip",
336
+ )
337
+ }
338
+
339
+
340
+def collect_display_size(env: dict[str, str], capabilities: dict[str, str], errors: list[str]) -> dict[str, int]:
341
+ if not capabilities.get("xrandr"):
342
+ return {"width": 0, "height": 0}
343
+ result = run([capabilities["xrandr"], "-q"], env=env, timeout=4)
344
+ if result.returncode != 0:
345
+ errors.append(command_output(result) or "xrandr could not read the Desktop display.")
346
+ return {"width": 0, "height": 0}
347
+ match = re.search(r"\bcurrent\s+(\d+)\s+x\s+(\d+)", result.stdout)
348
+ if not match:
349
+ errors.append("xrandr output did not include the current Desktop size.")
350
+ return {"width": 0, "height": 0}
351
+ return {"width": int(match.group(1)), "height": int(match.group(2))}
352
+
353
+
354
+def collect_pointer(env: dict[str, str], capabilities: dict[str, str], errors: list[str]) -> dict[str, int]:
355
+ if not capabilities.get("xdotool"):
356
+ return {"x": 0, "y": 0, "screen": 0, "window": 0}
357
+ result = run([capabilities["xdotool"], "getmouselocation", "--shell"], env=env, timeout=3)
358
+ if result.returncode != 0:
359
+ errors.append(command_output(result) or "xdotool could not read the pointer location.")
360
+ return {"x": 0, "y": 0, "screen": 0, "window": 0}
361
+ values = parse_shell_values(result.stdout)
362
+ return {
363
+ "x": int_value(values.get("X")),
364
+ "y": int_value(values.get("Y")),
365
+ "screen": int_value(values.get("SCREEN")),
366
+ "window": int_value(values.get("WINDOW")),
367
+ }
368
+
369
+
370
+def collect_active_window(env: dict[str, str], capabilities: dict[str, str], errors: list[str]) -> dict[str, Any] | None:
371
+ if not capabilities.get("xdotool"):
372
+ return None
373
+ result = run([capabilities["xdotool"], "getactivewindow"], env=env, timeout=3)
374
+ if result.returncode != 0:
375
+ errors.append(command_output(result) or "xdotool could not read the active window.")
376
+ return None
377
+ window_id = result.stdout.strip().splitlines()[0] if result.stdout.strip() else ""
378
+ if not window_id:
379
+ return None
380
+ return collect_window(env, capabilities, window_id, errors)
381
+
382
+
383
+def collect_windows(env: dict[str, str], capabilities: dict[str, str], errors: list[str]) -> list[dict[str, Any]]:
384
+ if not capabilities.get("xdotool"):
385
+ return []
386
+ result = run([capabilities["xdotool"], "search", "--onlyvisible", "--name", "."], env=env, timeout=4)
387
+ if result.returncode != 0:
388
+ detail = command_output(result)
389
+ if detail:
390
+ errors.append(detail)
391
+ return []
392
+ windows: list[dict[str, Any]] = []
393
+ seen: set[str] = set()
394
+ for window_id in result.stdout.splitlines():
395
+ window_id = window_id.strip()
396
+ if not window_id or window_id in seen:
397
+ continue
398
+ seen.add(window_id)
399
+ windows.append(collect_window(env, capabilities, window_id, errors))
400
+ return windows
401
+
402
+
403
+def collect_window(
404
+ env: dict[str, str],
405
+ capabilities: dict[str, str],
406
+ window_id: str,
407
+ errors: list[str],
408
+) -> dict[str, Any]:
409
+ props = collect_window_props(env, capabilities, window_id)
410
+ geometry = collect_window_geometry(env, capabilities, window_id)
411
+ return {
412
+ "id": str(window_id),
413
+ "title": props.get("title", ""),
414
+ "class": props.get("class", ""),
415
+ "name": props.get("name", ""),
416
+ "pid": int_value(props.get("pid")),
417
+ "geometry": geometry,
418
+ }
419
+
420
+
421
+def collect_window_geometry(env: dict[str, str], capabilities: dict[str, str], window_id: str) -> dict[str, int]:
422
+ geometry = {"x": 0, "y": 0, "width": 0, "height": 0}
423
+ if not capabilities.get("xwininfo"):
424
+ return geometry
425
+ result = run([capabilities["xwininfo"], "-id", str(window_id)], env=env, timeout=3)
426
+ if result.returncode != 0:
427
+ return geometry
428
+ patterns = {
429
+ "x": r"Absolute upper-left X:\s*(-?\d+)",
430
+ "y": r"Absolute upper-left Y:\s*(-?\d+)",
431
+ "width": r"Width:\s*(\d+)",
432
+ "height": r"Height:\s*(\d+)",
433
+ }
434
+ for key, pattern in patterns.items():
435
+ match = re.search(pattern, result.stdout)
436
+ if match:
437
+ geometry[key] = int(match.group(1))
438
+ return geometry
439
+
440
+
441
+def collect_window_props(env: dict[str, str], capabilities: dict[str, str], window_id: str) -> dict[str, str]:
442
+ props = {"title": "", "class": "", "name": "", "pid": ""}
443
+ xdotool = capabilities.get("xdotool")
444
+ if xdotool:
445
+ result = run([xdotool, "getwindowname", str(window_id)], env=env, timeout=3)
446
+ if result.returncode == 0:
447
+ props["title"] = result.stdout.strip()
448
+ xprop = capabilities.get("xprop")
449
+ if not xprop:
450
+ return props
451
+ result = run([xprop, "-id", str(window_id), "WM_CLASS", "WM_NAME", "_NET_WM_NAME", "_NET_WM_PID"], env=env, timeout=3)
452
+ if result.returncode != 0:
453
+ return props
454
+ parsed = parse_xprop(result.stdout)
455
+ title = parsed.get("_NET_WM_NAME") or parsed.get("WM_NAME") or props["title"]
456
+ props["title"] = title
457
+ props["class"] = parsed.get("WM_CLASS_CLASS", "")
458
+ props["name"] = parsed.get("WM_CLASS_NAME", "")
459
+ props["pid"] = parsed.get("_NET_WM_PID", "")
460
+ return props
461
+
462
+
463
+def parse_xprop(output: str) -> dict[str, str]:
464
+ values: dict[str, str] = {}
465
+ for line in output.splitlines():
466
+ if "=" not in line:
467
+ continue
468
+ key, raw_value = line.split("=", 1)
469
+ key = key.strip().split("(", 1)[0]
470
+ raw_value = raw_value.strip()
471
+ quoted = re.findall(r'"([^"]*)"', raw_value)
472
+ if key == "WM_CLASS" and quoted:
473
+ values["WM_CLASS_NAME"] = quoted[0]
474
+ values["WM_CLASS_CLASS"] = quoted[-1]
475
+ continue
476
+ if quoted:
477
+ values[key] = quoted[-1]
478
+ continue
479
+ match = re.search(r"-?\d+", raw_value)
480
+ values[key] = match.group(0) if match else raw_value
481
+ return values
482
+
483
+
484
+def latest_screenshot() -> dict[str, Any]:
485
+ if not SCREENSHOT_DIR.exists():
486
+ return {"ok": False, "path": "", "format": "", "captured_at": "", "recent": False}
487
+ candidates = [
488
+ path
489
+ for path in SCREENSHOT_DIR.iterdir()
490
+ if path.is_file() and path.suffix.lower() in {".png", ".jpg", ".jpeg", ".xwd"}
491
+ ]
492
+ if not candidates:
493
+ return {"ok": False, "path": "", "format": "", "captured_at": "", "recent": False}
494
+ latest = max(candidates, key=lambda item: item.stat().st_mtime)
495
+ age = max(0.0, time.time() - latest.stat().st_mtime)
496
+ return {
497
+ "ok": True,
498
+ "path": str(latest),
499
+ "format": latest.suffix.lower().lstrip("."),
500
+ "captured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(latest.stat().st_mtime)),
501
+ "recent": age <= RECENT_SCREENSHOT_SECONDS,
502
+ }
503
+
504
+
505
+def stable_state(
506
+ *,
507
+ display: str,
508
+ profile_dir: str,
509
+ size: dict[str, int] | None = None,
510
+ pointer: dict[str, int] | None = None,
511
+ active_window: dict[str, Any] | None = None,
512
+ windows: list[dict[str, Any]] | None = None,
513
+ screenshot: dict[str, Any] | None = None,
514
+ capabilities: dict[str, str] | None = None,
515
+ errors: list[str] | None = None,
516
+) -> dict[str, Any]:
517
+ clean_errors = [str(error) for error in errors or [] if str(error)]
518
+ return {
519
+ "ok": not clean_errors,
520
+ "display": display,
521
+ "profile_dir": profile_dir,
522
+ "size": size or {"width": 0, "height": 0},
523
+ "pointer": pointer or {"x": 0, "y": 0, "screen": 0, "window": 0},
524
+ "active_window": active_window,
525
+ "windows": windows or [],
526
+ "screenshot": screenshot or {"ok": False, "path": "", "format": "", "captured_at": "", "recent": False},
527
+ "capabilities": capabilities or collect_capabilities(),
528
+ "errors": clean_errors,
529
+ }
530
+
531
+
532
+def compact_prompt_context(state: dict[str, Any] | None = None) -> str:
533
+ state = state if state is not None else collect_state(include_screenshot=False)
534
+ if not state.get("display"):
535
+ return ""
536
+ lines = ["[DESKTOP STATE]"]
537
+ size = state.get("size") or {}
538
+ pointer = state.get("pointer") or {}
539
+ lines.append(
540
+ f"- display={state.get('display', '')} size={size.get('width', 0)}x{size.get('height', 0)} "
541
+ f"pointer={pointer.get('x', 0)},{pointer.get('y', 0)}"
542
+ )
543
+ active = state.get("active_window") or {}
544
+ if active:
545
+ lines.append(
546
+ f"- active={active.get('title', '') or '<untitled>'} "
547
+ f"class={active.get('class', '') or active.get('name', '')}"
548
+ )
549
+ visible = []
550
+ for window in state.get("windows") or []:
551
+ title = window.get("title") or "<untitled>"
552
+ window_class = window.get("class") or window.get("name") or ""
553
+ visible.append(f"{title} ({window_class})" if window_class else title)
554
+ if len(visible) >= 5:
555
+ break
556
+ if visible:
557
+ lines.append("- visible=" + "; ".join(visible))
558
+ screenshot = state.get("screenshot") or {}
559
+ if screenshot.get("recent") and screenshot.get("path"):
560
+ lines.append(f"- recent_screenshot={screenshot['path']}")
561
+ lines.append(
562
+ "- next=plugins/_office/skills/linux-desktop/scripts/desktopctl.sh observe --json --screenshot "
563
+ "before any coordinate action; prefer focus/key/paste/save/app-native helpers first."
564
+ )
565
+ lines.append(
566
+ "- verify=for terminal/CLI-agent output, use the screenshot path from a fresh final "
567
+ "observe --json --screenshot captured after the response appears."
568
+ )
569
+ if state.get("errors"):
570
+ lines.append("- errors=" + "; ".join(str(item) for item in state["errors"][:2]))
571
+ return "\n".join(lines)
572
+
573
+
574
+def parse_shell_values(output: str) -> dict[str, str]:
575
+ values: dict[str, str] = {}
576
+ for line in output.splitlines():
577
+ if "=" not in line:
578
+ continue
579
+ key, value = line.split("=", 1)
580
+ values[key.strip()] = value.strip().strip('"')
581
+ return values
582
+
583
+
584
+def int_value(value: Any, default: int = 0) -> int:
585
+ try:
586
+ return int(str(value).strip())
587
+ except (TypeError, ValueError):
588
+ return default
589
+
590
+
591
+def run(command: list[str], *, env: dict[str, str], timeout: float) -> subprocess.CompletedProcess[str]:
592
+ try:
593
+ return subprocess.run(
594
+ command,
595
+ check=False,
596
+ capture_output=True,
597
+ text=True,
598
+ timeout=timeout,
599
+ env=env,
600
+ )
601
+ except OSError as exc:
602
+ return subprocess.CompletedProcess(command, 127, "", str(exc))
603
+ except subprocess.TimeoutExpired as exc:
604
+ stdout = exc.stdout.decode("utf-8", errors="replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "")
605
+ stderr = exc.stderr.decode("utf-8", errors="replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "")
606
+ return subprocess.CompletedProcess(command, 124, stdout, stderr or "command timed out")
607
+
608
+
609
+def command_output(result: subprocess.CompletedProcess[str]) -> str:
610
+ return (result.stderr or result.stdout or "").strip()
611
+
612
+
613
+def image_width(path: Path) -> int:
614
+ try:
615
+ from PIL import Image
616
+
617
+ with Image.open(path) as image:
618
+ return int(image.width)
619
+ except Exception:
620
+ return 0
621
+
622
+
623
+def image_height(path: Path) -> int:
624
+ try:
625
+ from PIL import Image
626
+
627
+ with Image.open(path) as image:
628
+ return int(image.height)
629
+ except Exception:
630
+ return 0
631
+
632
+
633
+def iso_now() -> str:
634
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
635
+
636
+
637
+def main(argv: list[str] | None = None) -> int:
638
+ parser = argparse.ArgumentParser(description="Observe the Agent Zero persistent Linux Desktop state.")
639
+ subparsers = parser.add_subparsers(dest="command")
640
+
641
+ state_parser = subparsers.add_parser("state")
642
+ state_parser.add_argument("--json", action="store_true")
643
+ state_parser.add_argument("--screenshot", action="store_true")
644
+
645
+ observe_parser = subparsers.add_parser("observe")
646
+ observe_parser.add_argument("--json", action="store_true")
647
+ observe_parser.add_argument("--screenshot", action="store_true")
648
+
649
+ screenshot_parser = subparsers.add_parser("screenshot")
650
+ screenshot_parser.add_argument("path", nargs="?")
651
+ screenshot_parser.add_argument("--json", action="store_true")
652
+
653
+ args = parser.parse_args(argv)
654
+ command = args.command or "state"
655
+ if command in {"state", "observe"}:
656
+ payload = collect_state(include_screenshot=bool(args.screenshot))
657
+ print(json.dumps(payload, sort_keys=True))
658
+ return 0 if payload.get("ok") else 1
659
+
660
+ if command == "screenshot":
661
+ errors: list[str] = []
662
+ env_info = resolve_environment(errors=errors)
663
+ payload = capture_screenshot(
664
+ display_env(display=env_info["display"], profile_dir=env_info["profile_dir"]),
665
+ collect_capabilities(),
666
+ path=args.path,
667
+ errors=errors,
668
+ )
669
+ if args.json:
670
+ print(json.dumps(payload, sort_keys=True))
671
+ else:
672
+ print(payload.get("path") or payload.get("error") or "")
673
+ return 0 if payload.get("ok") else 1
674
+
675
+ parser.print_help()
676
+ return 2
677
+
678
+
679
+if __name__ == "__main__":
680
+ raise SystemExit(main())
plugins/_office/helpers/libreoffice_desktop.py
+7
-1
@@ -17,7 +17,7 @@ from pathlib import Path
17
from typing import Any
18
19
from helpers import files, virtual_desktop
20
-from plugins._office.helpers import document_store, libreoffice
20
+from plugins._office.helpers import desktop_state, document_store, libreoffice
21
22
23
OFFICIAL_EXTENSIONS = {"odt", "ods", "odp", "docx", "xlsx", "pptx"}
@@ -213,6 +213,11 @@ class LibreOfficeDesktopManager:
213
"url_intents": url_intents,
214
}
215
216
+ def state(self, *, include_screenshot: bool = False) -> dict[str, Any]:
217
+ with self._lock:
218
+ self._reap_dead_locked()
219
+ return desktop_state.collect_state(include_screenshot=include_screenshot)
220
+
221
def claim_url_intents(self, session_id: str = SYSTEM_SESSION_ID) -> list[dict[str, Any]]:
222
session = self.get(session_id) or self.get(SYSTEM_SESSION_ID)
223
if not session:
@@ -1069,6 +1074,7 @@ fi
1074
"path": session.path,
1075
"display": session.display,
1076
"xpra_port": session.xpra_port,
1077
+ "profile_dir": str(session.profile_dir),
1078
"owner_pid": os.getpid(),
1079
"pids": {name: process.pid for name, process in session.processes.items()},
1080
}
plugins/_office/hooks.py
+4
@@ -60,9 +60,13 @@ RUNTIME_PACKAGES = (
60
"libglib2.0-bin",
61
"xfce4-terminal",
62
"x11-xserver-utils",
63
+ "x11-utils",
64
+ "x11-apps",
65
"xdotool",
66
+ "xclip",
67
"xauth",
68
"dbus-x11",
69
+ "python3-pil",
70
"fonts-dejavu",
71
"fonts-liberation",
72
"fonts-crosextra-caladea",
plugins/_office/skills/linux-desktop/SKILL.md
+43
-10
@@ -23,14 +23,24 @@ Use the Desktop as a full Linux GUI when the user explicitly needs a visual work
23
24
## Operating Model
25
26
-1. Prefer `document_artifact` for creating, reading, and editing Markdown, ODT, ODS, ODP, DOCX, XLSX, and PPTX files.
27
-2. Treat Markdown as first-class. For writing, notes, reports, and drafts with no explicit binary Office requirement, create Markdown and use the custom Markdown editor when the user opens the canvas.
28
-3. Treat ODF as first-class for LibreOffice office work: ODT in Writer, ODS in Calc, ODP in Impress. Use DOCX/XLSX/PPTX only for explicit Microsoft compatibility.
29
-4. Use the Desktop only when the user asks for the Desktop, a GUI app, binary Office visual work, or visual confirmation.
30
-5. Never open the Desktop/canvas automatically from a tool result if the user has not opened it. Offer the explicit Open in canvas action instead.
31
-6. Launch common apps from the Desktop icons, the header buttons, or `scripts/desktopctl.sh`.
32
-7. Use the external Agent Zero Browser for web browsing. Do not launch an operating-system browser in this version.
33
-8. Verify GUI work by observing the desktop state, checking window titles, and saving the file before reporting success.
26
+The Desktop is an observe-act-verify control surface. Use this decision hierarchy:
27
+
28
+1. Prefer structured tools such as `document_artifact` for deterministic file creation, reads, and edits.
29
+2. Prefer app-native helpers for visible live edits, such as `desktopctl.sh calc-set-cell` for Calc/UNO spreadsheet changes.
30
+3. Prefer launcher commands, window focus, keyboard shortcuts, menus, paste, and save commands.
31
+4. Use coordinate clicks only as a last resort, and only after a fresh Desktop observation.
32
+5. After any GUI action, verify through Desktop state, active window titles, screenshots, saved file state, or exported output.
33
+6. For terminal or CLI-agent work, verify against a fresh final `observe --json --screenshot` captured after the command has finished or visibly returned to an input prompt. Do not report from an earlier screenshot path.
34
+
35
+Keep these standing rules:
36
+
37
+1. Treat Markdown as first-class. For writing, notes, reports, and drafts with no explicit binary Office requirement, create Markdown and use the custom Markdown editor when the user opens the canvas.
38
+2. Treat ODF as first-class for LibreOffice office work: ODT in Writer, ODS in Calc, ODP in Impress. Use DOCX/XLSX/PPTX only for explicit Microsoft compatibility.
39
+3. Use the Desktop only when the user asks for the Desktop, a GUI app, binary Office visual work, or visual confirmation.
40
+4. Never open the Desktop/canvas automatically from a tool result if the user has not opened it. Offer the explicit Open in canvas action instead.
41
+5. Launch common apps from the Desktop icons, the header buttons, or `scripts/desktopctl.sh`.
42
+6. Use the external Agent Zero Browser for web browsing. Do not launch an operating-system browser in this version.
43
+7. Verify GUI work by observing the desktop state, checking window titles, and saving the file before reporting success. If exact terminal text matters, load or inspect the screenshot path returned by the final observation, not a screenshot captured before the text appeared.
44
45
## Control Flow
46
@@ -38,7 +48,10 @@ Use the helper script when the Desktop is already open and you need reliable app
48
49
```bash
50
plugins/_office/skills/linux-desktop/scripts/desktopctl.sh check
51
+plugins/_office/skills/linux-desktop/scripts/desktopctl.sh state --json
52
+plugins/_office/skills/linux-desktop/scripts/desktopctl.sh observe --json --screenshot
53
plugins/_office/skills/linux-desktop/scripts/desktopctl.sh launch calc
54
+plugins/_office/skills/linux-desktop/scripts/desktopctl.sh wait-window LibreOffice
55
plugins/_office/skills/linux-desktop/scripts/desktopctl.sh windows LibreOffice
56
plugins/_office/skills/linux-desktop/scripts/desktopctl.sh focus LibreOffice
57
plugins/_office/skills/linux-desktop/scripts/desktopctl.sh key ctrl+s
@@ -55,6 +68,9 @@ plugins/_office/skills/linux-desktop/scripts/desktopctl.sh launch impress
68
plugins/_office/skills/linux-desktop/scripts/desktopctl.sh launch terminal
69
plugins/_office/skills/linux-desktop/scripts/desktopctl.sh launch settings
70
plugins/_office/skills/linux-desktop/scripts/desktopctl.sh open-path /a0/usr/workdir
71
+plugins/_office/skills/linux-desktop/scripts/desktopctl.sh focus "LibreOffice"
72
+plugins/_office/skills/linux-desktop/scripts/desktopctl.sh paste-text "Text to insert"
73
+plugins/_office/skills/linux-desktop/scripts/desktopctl.sh key ctrl+s
74
```
75
76
For live spreadsheet coworking, use the Calc helper instead of hand-written UNO snippets:
@@ -65,13 +81,17 @@ plugins/_office/skills/linux-desktop/scripts/desktopctl.sh calc-set-cell /a0/usr
81
82
This opens the workbook in the visible Desktop Calc session if needed, changes the cell through LibreOffice, saves the workbook, and verifies the `.xlsx` on disk. Because the edit happens through the running LibreOffice session, the user can see the sheet update without refreshing the Desktop surface.
83
68
-For coordinate actions after observing the Desktop:
84
+For coordinate actions, clicks are explicitly last resort. First try `launch`, `open-path`, `wait-window`, `focus`, `key`, `paste-text`, `save`, or an app-native helper. If a coordinate action is still necessary, base it on a fresh screenshot observation and verify immediately afterward:
85
86
```bash
87
+plugins/_office/skills/linux-desktop/scripts/desktopctl.sh observe --json --screenshot
88
plugins/_office/skills/linux-desktop/scripts/desktopctl.sh click 120 180
89
plugins/_office/skills/linux-desktop/scripts/desktopctl.sh dblclick 120 180
90
+plugins/_office/skills/linux-desktop/scripts/desktopctl.sh right-click 120 180
91
+plugins/_office/skills/linux-desktop/scripts/desktopctl.sh drag 120 180 400 180
92
+plugins/_office/skills/linux-desktop/scripts/desktopctl.sh scroll down 3
93
plugins/_office/skills/linux-desktop/scripts/desktopctl.sh type "Text to enter"
74
-plugins/_office/skills/linux-desktop/scripts/desktopctl.sh location
94
+plugins/_office/skills/linux-desktop/scripts/desktopctl.sh observe --json
95
```
96
97
When browser automation is available, the higher-level QA flow is:
@@ -82,6 +102,19 @@ When browser automation is available, the higher-level QA flow is:
102
4. Cross-check with `desktopctl.sh location` and `desktopctl.sh windows PATTERN`.
103
5. Capture the browser screenshot as visual evidence.
104
105
+## Terminal And CLI Agent Verification
106
+
107
+Terminal apps are visual state, not structured logs. When the task depends on exact terminal output, follow this stricter loop:
108
+
109
+1. Run `desktopctl.sh observe --json --screenshot` immediately before acting to record the starting window and screenshot path.
110
+2. Use `focus`, `paste-text` or `type`, and `key Return` to drive the terminal. Prefer CLI-native commands and keyboard input over clicks.
111
+3. Wait until the CLI has visibly produced a response or returned to an input prompt.
112
+4. Run a new final `desktopctl.sh observe --json --screenshot`.
113
+5. Verify exact text only from the screenshot path returned by that final observation, or from a newer screenshot. Never use an earlier screenshot path as final evidence.
114
+6. If the final screenshot is cropped, stale, or unreadable, capture another screenshot or report the result as unverified with that specific reason.
115
+
116
+For nested CLI agents, a successful proof requires both the input prompt and the nested agent's visible response in the final screenshot, or another deterministic saved transcript produced by the CLI itself.
117
+
118
## Desktop Locations
119
120
The Desktop exposes stable folders for common user work:
plugins/_office/skills/linux-desktop/scripts/desktopctl.sh
+227
@@ -6,6 +6,8 @@ BASE_DIR="${A0_BASE_DIR:-/a0}"
6
PROFILE_DIR="${A0_DESKTOP_PROFILE:-$BASE_DIR/tmp/_office/desktop/profiles/$SESSION}"
7
MANIFEST="${A0_DESKTOP_MANIFEST:-$BASE_DIR/tmp/_office/desktop/sessions/$SESSION.json}"
8
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
+DESKTOP_STATE_HELPER="$SCRIPT_DIR/../../../helpers/desktop_state.py"
10
+DESKTOP_STATE_PYTHON="${A0_DESKTOP_STATE_PYTHON:-$(command -v /usr/bin/python3 || command -v python3 || true)}"
11
12
display_from_manifest() {
13
if [ ! -f "$MANIFEST" ] || ! command -v python3 >/dev/null 2>&1; then
@@ -34,6 +36,10 @@ esac
36
37
export XAUTHORITY="${A0_DESKTOP_XAUTHORITY:-$PROFILE_DIR/.Xauthority}"
38
export HOME="${A0_DESKTOP_HOME:-$PROFILE_DIR}"
39
+export A0_DESKTOP_SESSION="$SESSION"
40
+export A0_DESKTOP_MANIFEST="$MANIFEST"
41
+export A0_DESKTOP_PROFILE="$PROFILE_DIR"
42
+export A0_DESKTOP_DISPLAY="$DISPLAY"
43
export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
44
export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
45
export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
@@ -49,9 +55,21 @@ Usage: desktopctl.sh <command> [args]
55
Commands:
56
env Print the X11 environment used for the Desktop.
57
check Verify that xdotool can reach the Desktop display.
58
+ state --json Return structured Desktop state as JSON.
59
+ observe --json [--screenshot]
60
+ Return structured state, optionally with a fresh screenshot.
61
+ screenshot [PATH] Capture the Desktop to PATH, or to the default screenshot directory.
62
+ active-window Print the active window name.
63
+ geometry PATTERN Print the first matching visible window geometry.
64
+ wait-window PATTERN Wait for a visible matching window and print its id.
65
location Print the current X pointer location.
66
windows [PATTERN] List visible window names matching PATTERN.
67
focus PATTERN Focus the first visible window matching PATTERN.
68
+ scroll DIRECTION [UNITS] Scroll up, down, left, or right; UNITS defaults to 5 clicks.
69
+ drag X1 Y1 X2 Y2 Drag from X1,Y1 to X2,Y2 in Desktop coordinates.
70
+ right-click X Y Move and right-click at X,Y in Desktop coordinates.
71
+ paste-text TEXT Put TEXT on the Desktop clipboard and paste it with an app-native shortcut.
72
+ sequence FILE|- Run a newline-delimited command sequence.
73
key KEY... Send one or more xdotool key names.
74
type TEXT Type text into the focused window.
75
click X Y Move and click at X,Y in Desktop coordinates.
@@ -79,6 +97,14 @@ ensure_display() {
97
fi
98
}
99
100
+desktop_state() {
101
+ if [ ! -f "$DESKTOP_STATE_HELPER" ]; then
102
+ echo "Desktop state helper not found: $DESKTOP_STATE_HELPER" >&2
103
+ exit 2
104
+ fi
105
+ "$DESKTOP_STATE_PYTHON" "$DESKTOP_STATE_HELPER" "$@"
106
+}
107
+
108
run_detached() {
109
( "$@" >/tmp/a0-desktopctl.log 2>&1 & )
110
}
@@ -98,6 +124,128 @@ first_window() {
124
xdotool search --onlyvisible --name "$pattern" 2>/dev/null | head -n 1 || true
125
}
126
127
+active_window_id() {
128
+ xdotool getactivewindow 2>/dev/null || true
129
+}
130
+
131
+active_window_class() {
132
+ window_id="$(active_window_id)"
133
+ if [ -z "$window_id" ]; then
134
+ return 0
135
+ fi
136
+ if command -v xprop >/dev/null 2>&1; then
137
+ xprop -id "$window_id" WM_CLASS 2>/dev/null | awk -F'"' '/WM_CLASS/ { print $(NF - 1); exit }'
138
+ fi
139
+}
140
+
141
+active_window_class_lower() {
142
+ active_window_class | tr '[:upper:]' '[:lower:]'
143
+}
144
+
145
+active_window_is_terminal() {
146
+ window_class="$(active_window_class_lower)"
147
+ case "$window_class" in
148
+ *terminal*|xterm|uxterm|rxvt|urxvt|kitty|alacritty|wezterm|konsole)
149
+ return 0
150
+ ;;
151
+ *)
152
+ return 1
153
+ ;;
154
+ esac
155
+}
156
+
157
+paste_key_for_active_window() {
158
+ printf '%s\n' "${A0_DESKTOP_PASTE_KEY:-ctrl+v}"
159
+}
160
+
161
+window_geometry() {
162
+ window_id="$1"
163
+ if command -v xwininfo >/dev/null 2>&1; then
164
+ xwininfo -id "$window_id" 2>/dev/null | awk '
165
+ /Absolute upper-left X:/ { x=$4 }
166
+ /Absolute upper-left Y:/ { y=$4 }
167
+ /Width:/ { w=$2 }
168
+ /Height:/ { h=$2 }
169
+ END { if (w != "") printf "X=%s\nY=%s\nWIDTH=%s\nHEIGHT=%s\n", x, y, w, h }
170
+ '
171
+ else
172
+ xdotool getwindowgeometry --shell "$window_id"
173
+ fi
174
+}
175
+
176
+wait_window() {
177
+ pattern="$1"
178
+ timeout="${2:-15}"
179
+ end=$((SECONDS + timeout))
180
+ while [ "$SECONDS" -le "$end" ]; do
181
+ window_id="$(first_window "$pattern")"
182
+ if [ -n "$window_id" ]; then
183
+ printf '%s\n' "$window_id"
184
+ return 0
185
+ fi
186
+ sleep 0.25
187
+ done
188
+ echo "Timed out waiting for visible window: $pattern" >&2
189
+ return 1
190
+}
191
+
192
+scroll_desktop() {
193
+ direction="$1"
194
+ units="${2:-5}"
195
+ case "$direction" in
196
+ up) button=4 ;;
197
+ down) button=5 ;;
198
+ left) button=6 ;;
199
+ right) button=7 ;;
200
+ *)
201
+ echo "scroll direction must be up, down, left, or right." >&2
202
+ exit 2
203
+ ;;
204
+ esac
205
+ xdotool click --repeat "$units" "$button"
206
+}
207
+
208
+paste_text() {
209
+ text="$*"
210
+ if active_window_is_terminal; then
211
+ xdotool type --delay "${A0_DESKTOP_PASTE_TYPE_DELAY_MS:-${A0_DESKTOP_TYPE_DELAY_MS:-4}}" -- "$text"
212
+ return
213
+ fi
214
+ if command -v xclip >/dev/null 2>&1; then
215
+ printf '%s' "$text" | xclip -selection clipboard
216
+ xdotool key --clearmodifiers "$(paste_key_for_active_window)"
217
+ return
218
+ fi
219
+ xdotool type --delay "${A0_DESKTOP_TYPE_DELAY_MS:-1}" -- "$text"
220
+}
221
+
222
+run_sequence_line() {
223
+ line="$1"
224
+ [ -z "$line" ] && return 0
225
+ case "$line" in
226
+ \#*) return 0 ;;
227
+ esac
228
+ # shellcheck disable=SC2086
229
+ "$0" $line
230
+}
231
+
232
+run_sequence() {
233
+ source_file="$1"
234
+ if [ "$source_file" = "-" ]; then
235
+ while IFS= read -r line; do
236
+ run_sequence_line "$line"
237
+ done
238
+ return
239
+ fi
240
+ if [ ! -f "$source_file" ]; then
241
+ echo "sequence requires an existing FILE or - for stdin." >&2
242
+ exit 2
243
+ fi
244
+ while IFS= read -r line || [ -n "$line" ]; do
245
+ run_sequence_line "$line"
246
+ done < "$source_file"
247
+}
248
+
249
launch_app() {
250
app="${1:-}"
251
soffice="${SOFFICE:-$(command -v soffice || true)}"
@@ -142,6 +290,55 @@ case "$command_name" in
290
ensure_display
291
xdotool getmouselocation --shell
292
;;
293
+ state)
294
+ if [ "${1:-}" != "--json" ]; then
295
+ echo "state currently requires --json." >&2
296
+ exit 2
297
+ fi
298
+ desktop_state state --json
299
+ ;;
300
+ observe)
301
+ if [ "${1:-}" != "--json" ]; then
302
+ echo "observe currently requires --json." >&2
303
+ exit 2
304
+ fi
305
+ shift
306
+ desktop_state observe --json "$@"
307
+ ;;
308
+ screenshot)
309
+ if [ "${1:-}" = "--json" ]; then
310
+ shift
311
+ desktop_state screenshot --json "$@"
312
+ elif [ "$#" -gt 0 ]; then
313
+ desktop_state screenshot "$1"
314
+ else
315
+ desktop_state screenshot
316
+ fi
317
+ ;;
318
+ active-window)
319
+ ensure_display
320
+ window_id="$(active_window_id)"
321
+ if [ -z "$window_id" ]; then
322
+ echo "No active window." >&2
323
+ exit 1
324
+ fi
325
+ xdotool getwindowname "$window_id"
326
+ ;;
327
+ geometry)
328
+ ensure_display
329
+ pattern="${1:?geometry requires a window name pattern}"
330
+ window_id="$(first_window "$pattern")"
331
+ if [ -z "$window_id" ]; then
332
+ echo "No visible window matched: $pattern" >&2
333
+ exit 1
334
+ fi
335
+ window_geometry "$window_id"
336
+ ;;
337
+ wait-window)
338
+ ensure_display
339
+ pattern="${1:?wait-window requires a window name pattern}"
340
+ wait_window "$pattern" "${2:-15}"
341
+ ;;
342
location)
343
ensure_display
344
xdotool getmouselocation --shell
@@ -161,6 +358,36 @@ case "$command_name" in
358
fi
359
xdotool windowactivate --sync "$window_id"
360
;;
361
+ scroll)
362
+ ensure_display
363
+ scroll_desktop "${1:?scroll requires DIRECTION}" "${2:-5}"
364
+ ;;
365
+ drag)
366
+ ensure_display
367
+ x1="${1:?drag requires X1}"
368
+ y1="${2:?drag requires Y1}"
369
+ x2="${3:?drag requires X2}"
370
+ y2="${4:?drag requires Y2}"
371
+ xdotool mousemove --sync "$x1" "$y1" mousedown 1 mousemove --sync "$x2" "$y2" mouseup 1
372
+ ;;
373
+ right-click)
374
+ ensure_display
375
+ x="${1:?right-click requires X}"
376
+ y="${2:?right-click requires Y}"
377
+ xdotool mousemove --sync "$x" "$y" click 3
378
+ ;;
379
+ paste-text)
380
+ ensure_display
381
+ if [ "$#" -eq 0 ]; then
382
+ echo "paste-text requires TEXT." >&2
383
+ exit 2
384
+ fi
385
+ paste_text "$@"
386
+ ;;
387
+ sequence)
388
+ source_file="${1:?sequence requires FILE or -}"
389
+ run_sequence "$source_file"
390
+ ;;
391
key)
392
ensure_display
393
if [ "$#" -eq 0 ]; then
plugins/_office/webui/office-store.js
+97
-1
@@ -228,6 +228,9 @@ const model = {
228
_desktopPrimeTimer: null,
229
_desktopPrimeAttempts: 0,
230
_desktopKeyboardActive: false,
231
+ _desktopBridgeReady: false,
232
+ _desktopKeyboardCaptureState: { ready: false, active: false, capture: false, focused: false },
233
+ _desktopLastState: null,
234
_desktopKeyboardCleanup: null,
235
_desktopClipboardCleanup: null,
236
_desktopStarting: null,
@@ -1007,7 +1010,9 @@ const model = {
1010
} catch {
1011
target.focus?.({ preventScroll: true });
1012
}
1010
- return Boolean(document.activeElement === target || target.contentDocument?.hasFocus?.());
1013
+ const focused = Boolean(document.activeElement === target || target.contentDocument?.hasFocus?.());
1014
+ this.updateDesktopKeyboardCaptureState(target);
1015
+ return focused;
1016
},
1017
1018
updateDesktopMonitor() {
@@ -1015,6 +1020,8 @@ const model = {
1020
this.stopDesktopMonitor();
1021
this.stopDesktopResizeObserver();
1022
this._desktopKeyboardActive = false;
1023
+ this._desktopBridgeReady = false;
1024
+ this.updateDesktopKeyboardCaptureState();
1025
return;
1026
}
1027
const sessionId = this.session?.desktop_session_id || this.session?.session_id || "";
@@ -1216,6 +1223,7 @@ const model = {
1223
this.installXpraDesktopWheelBridge(remoteWindow, xpraWindow);
1224
if (requestRefresh && xpraWindow.wid != null) client.request_refresh?.(xpraWindow.wid);
1225
}
1226
+ this.installXpraDesktopAgentBridge(frame, remoteWindow, remoteDocument, client, container);
1227
return true;
1228
} catch (error) {
1229
console.warn("Xpra desktop viewport prime skipped", error);
@@ -1223,6 +1231,94 @@ const model = {
1231
}
1232
},
1233
1234
+ installXpraDesktopAgentBridge(frame, remoteWindow, remoteDocument, client, container) {
1235
+ if (!frame || !remoteWindow || !remoteDocument || !client) return null;
1236
+ const store = this;
1237
+ const finite = (value, fallback = 0) => {
1238
+ const number = Number(value);
1239
+ return Number.isFinite(number) ? number : fallback;
1240
+ };
1241
+ const metrics = () => {
1242
+ const desktopWidth = Math.max(1, finite(client.desktop_width || container?.clientWidth || remoteWindow.innerWidth, 1));
1243
+ const desktopHeight = Math.max(1, finite(client.desktop_height || container?.clientHeight || remoteWindow.innerHeight, 1));
1244
+ const clientWidth = Math.max(1, finite(container?.clientWidth || remoteWindow.innerWidth, desktopWidth));
1245
+ const clientHeight = Math.max(1, finite(container?.clientHeight || remoteWindow.innerHeight, desktopHeight));
1246
+ return {
1247
+ desktopWidth,
1248
+ desktopHeight,
1249
+ clientWidth,
1250
+ clientHeight,
1251
+ scaleX: clientWidth / desktopWidth,
1252
+ scaleY: clientHeight / desktopHeight,
1253
+ };
1254
+ };
1255
+ const bridge = frame.__agentZeroDesktopBridge || {};
1256
+ Object.assign(bridge, {
1257
+ ready: true,
1258
+ state: async (options = {}) => {
1259
+ const result = await callOffice("desktop_state", {
1260
+ include_screenshot: options.includeScreenshot === true || options.include_screenshot === true,
1261
+ });
1262
+ store._desktopLastState = result;
1263
+ return result;
1264
+ },
1265
+ focus: (options = {}) => store.focusDesktopFrame(frame, { ...options, arm: options.arm !== false }),
1266
+ requestRefresh: () => {
1267
+ for (const xpraWindow of Object.values(client.id_to_window || {})) {
1268
+ if (xpraWindow?.wid != null) client.request_refresh?.(xpraWindow.wid);
1269
+ }
1270
+ return true;
1271
+ },
1272
+ desktopToClient: (x, y) => {
1273
+ const value = metrics();
1274
+ return {
1275
+ x: Math.round(finite(x) * value.scaleX),
1276
+ y: Math.round(finite(y) * value.scaleY),
1277
+ scale_x: value.scaleX,
1278
+ scale_y: value.scaleY,
1279
+ };
1280
+ },
1281
+ clientToDesktop: (x, y) => {
1282
+ const value = metrics();
1283
+ return {
1284
+ x: Math.round(finite(x) / value.scaleX),
1285
+ y: Math.round(finite(y) / value.scaleY),
1286
+ scale_x: value.scaleX,
1287
+ scale_y: value.scaleY,
1288
+ };
1289
+ },
1290
+ diagnostics: () => store.desktopBridgeDiagnostics(frame),
1291
+ });
1292
+ frame.agentZeroDesktop = bridge;
1293
+ frame.__agentZeroDesktopBridge = bridge;
1294
+ remoteWindow.agentZeroDesktop = bridge;
1295
+ remoteWindow.__agentZeroDesktopBridge = bridge;
1296
+ this._desktopBridgeReady = true;
1297
+ this.updateDesktopKeyboardCaptureState(frame);
1298
+ return bridge;
1299
+ },
1300
+
1301
+ desktopBridgeDiagnostics(frame = null) {
1302
+ return {
1303
+ ready: this._desktopBridgeReady,
1304
+ keyboard: this.updateDesktopKeyboardCaptureState(frame),
1305
+ lastStateOk: this._desktopLastState?.ok ?? null,
1306
+ };
1307
+ },
1308
+
1309
+ updateDesktopKeyboardCaptureState(frame = null) {
1310
+ const target = this.desktopFrame(frame);
1311
+ const client = target?.contentWindow?.client;
1312
+ const state = {
1313
+ ready: Boolean(target?.__agentZeroDesktopBridge || target?.contentWindow?.__agentZeroDesktopBridge),
1314
+ active: Boolean(this._desktopKeyboardActive),
1315
+ capture: Boolean(client?.capture_keyboard),
1316
+ focused: Boolean(target && (document.activeElement === target || target.contentDocument?.hasFocus?.())),
1317
+ };
1318
+ this._desktopKeyboardCaptureState = state;
1319
+ return state;
1320
+ },
1321
+
1322
normalizeXpraDesktopWindow(xpraWindow, width, height) {
1323
if (!xpraWindow) return;
1324
const normalizedWidth = Math.max(1, Math.round(Number(width || 0)));
tests/test_office_canvas_setup.py
+62
@@ -72,6 +72,14 @@ def test_document_canvas_uses_markdown_editor_and_official_libreoffice_desktop_f
72
assert "primeXpraDesktopFrame" in store
73
assert "normalizeXpraDesktopWindow" in store
74
assert "installXpraDesktopWheelBridge" in store
75
+ assert "installXpraDesktopAgentBridge" in store
76
+ assert "agentZeroDesktop" in store
77
+ assert 'callOffice("desktop_state"' in store
78
+ assert "desktopToClient" in store
79
+ assert "clientToDesktop" in store
80
+ assert "requestRefresh" in store
81
+ assert "_desktopBridgeReady" in store
82
+ assert "_desktopKeyboardCaptureState" in store
83
assert "installXpraDesktopKeyboardBridge" in store
84
assert "focusDesktopFrame" in store
85
assert "_desktopKeyboardActive" in store
@@ -227,6 +235,10 @@ def test_official_libreoffice_desktop_route_and_packages_are_declared():
235
linux_desktopctl = (
236
PROJECT_ROOT / "plugins" / "_office" / "skills" / "linux-desktop" / "scripts" / "desktopctl.sh"
237
).read_text(encoding="utf-8")
238
+ desktop_state_helper = (
239
+ PROJECT_ROOT / "plugins" / "_office" / "helpers" / "desktop_state.py"
240
+ ).read_text(encoding="utf-8")
241
+ hooks_py = (PROJECT_ROOT / "plugins" / "_office" / "hooks.py").read_text(encoding="utf-8")
242
linux_calc_helper = (
243
PROJECT_ROOT / "plugins" / "_office" / "skills" / "linux-desktop" / "scripts" / "calc_set_cell.py"
244
).read_text(encoding="utf-8")
@@ -320,10 +332,42 @@ def test_official_libreoffice_desktop_route_and_packages_are_declared():
332
assert "/a0/usr/projects" in linux_desktop_skill
333
assert "desktopctl.sh" in linux_desktop_skill
334
assert "calc-set-cell" in linux_desktop_skill
335
+ assert "Clicks are explicitly last resort" in linux_desktop_skill or "clicks are explicitly last resort" in linux_desktop_skill
336
+ assert "fresh Desktop observation" in linux_desktop_skill
337
+ assert "observe --json --screenshot" in linux_desktop_skill
338
+ assert "Terminal And CLI Agent Verification" in linux_desktop_skill
339
+ assert "Do not report from an earlier screenshot path" in linux_desktop_skill
340
+ assert "screenshot path returned by that final observation" in linux_desktop_skill
341
assert "xdotool" in linux_desktopctl
342
assert "agent-zero-desktop" in linux_desktopctl
343
assert "launch_app" in linux_desktopctl
344
+ assert "paste_key_for_active_window" in linux_desktopctl
345
+ assert "active_window_is_terminal" in linux_desktopctl
346
+ assert "WM_CLASS" in linux_desktopctl
347
+ for command in (
348
+ "state)",
349
+ "observe)",
350
+ "screenshot)",
351
+ "active-window)",
352
+ "geometry)",
353
+ "wait-window)",
354
+ "scroll)",
355
+ "drag)",
356
+ "right-click)",
357
+ "paste-text)",
358
+ "sequence)",
359
+ ):
360
+ assert command in linux_desktopctl
361
assert "calc_set_cell.py" in linux_desktopctl
362
+ assert "collect_state" in desktop_state_helper
363
+ assert "compact_prompt_context" in desktop_state_helper
364
+ assert "fresh final" in desktop_state_helper
365
+ assert "xwd" in desktop_state_helper
366
+ assert "PIL" in desktop_state_helper
367
+ assert '"x11-utils"' in hooks_py
368
+ assert '"x11-apps"' in hooks_py
369
+ assert '"xclip"' in hooks_py
370
+ assert '"python3-pil"' in hooks_py
371
assert "wait_for_document" in linux_calc_helper
372
assert "document.store()" in linux_calc_helper
373
assert "read_xlsx_cell" in linux_calc_helper
@@ -419,6 +463,8 @@ def test_office_skills_preserve_markdown_first_and_opt_in_desktop_policy():
463
assert "Download and Open in canvas actions" in office_skill
464
assert "method: \"create\"" in office_skill
465
assert "The Desktop is opt-in" in desktop_skill
466
+ assert "coordinate clicks only as a last resort" in desktop_skill
467
+ assert "After any GUI action, verify" in desktop_skill
468
assert "custom Markdown editor" in desktop_skill
469
assert "Never open the Desktop/canvas automatically" in desktop_skill
470
assert "persistent Desktop runtime during initial startup" in desktop_skill
@@ -432,3 +478,19 @@ def test_office_skills_preserve_markdown_first_and_opt_in_desktop_policy():
478
assert "must not open the canvas automatically" in excel_skill
479
assert '"format": "odp"' in presentation_skill
480
assert "must not open the canvas automatically" in presentation_skill
481
+
482
+
483
+def test_office_extra_prompt_includes_existing_desktop_state_without_opening_canvas():
484
+ canvas_context = (
485
+ PROJECT_ROOT / "plugins" / "_office" / "helpers" / "canvas_context.py"
486
+ ).read_text(encoding="utf-8")
487
+ prompt = (
488
+ PROJECT_ROOT / "plugins" / "_office" / "prompts" / "agent.extras.office_canvas.md"
489
+ ).read_text(encoding="utf-8")
490
+
491
+ assert "build_desktop_context" in canvas_context
492
+ assert "session_manifest_exists" in canvas_context
493
+ assert "collect_state(include_screenshot=False)" in canvas_context
494
+ assert "compact_prompt_context" in canvas_context
495
+ assert "ensure_system_desktop" not in canvas_context
496
+ assert "[DOCUMENT CANVAS]" in prompt
tests/test_office_desktop_state.py
new
+201
@@ -0,0 +1,201 @@
1
+from __future__ import annotations
2
+
3
+import subprocess
4
+import struct
5
+import sys
6
+import types
7
+from pathlib import Path
8
+
9
+PROJECT_ROOT = Path(__file__).resolve().parents[1]
10
+if str(PROJECT_ROOT) not in sys.path:
11
+ sys.path.insert(0, str(PROJECT_ROOT))
12
+
13
+from plugins._office.helpers import desktop_state
14
+
15
+
16
+def _completed(command, returncode=0, stdout="", stderr=""):
17
+ return subprocess.CompletedProcess(command, returncode, stdout, stderr)
18
+
19
+
20
+def test_desktop_state_collects_x11_state_from_mocked_tools(tmp_path, monkeypatch):
21
+ session_dir = tmp_path / "sessions"
22
+ profile_dir = tmp_path / "profiles" / desktop_state.SESSION_ID
23
+ session_dir.mkdir(parents=True)
24
+ profile_dir.mkdir(parents=True)
25
+ (session_dir / f"{desktop_state.SESSION_ID}.json").write_text(
26
+ '{"display": 120, "profile_dir": "%s"}' % profile_dir,
27
+ encoding="utf-8",
28
+ )
29
+
30
+ monkeypatch.setattr(desktop_state, "SESSION_DIR", session_dir)
31
+ monkeypatch.setattr(desktop_state, "PROFILE_DIR", tmp_path / "profiles")
32
+ monkeypatch.setattr(desktop_state, "SCREENSHOT_DIR", tmp_path / "screenshots")
33
+ monkeypatch.setattr(
34
+ desktop_state.shutil,
35
+ "which",
36
+ lambda name: f"/usr/bin/{name}"
37
+ if name in {"xdotool", "xrandr", "xwininfo", "xprop", "xwd", "xclip"}
38
+ else "",
39
+ )
40
+
41
+ def fake_run(command, **kwargs):
42
+ name = Path(command[0]).name
43
+ if name == "xrandr":
44
+ return _completed(command, stdout="Screen 0: current 1440 x 900, maximum 1920 x 1080\n")
45
+ if name == "xdotool" and command[1:3] == ["getmouselocation", "--shell"]:
46
+ return _completed(command, stdout="X=12\nY=34\nSCREEN=0\nWINDOW=111\n")
47
+ if name == "xdotool" and command[1] == "getactivewindow":
48
+ return _completed(command, stdout="111\n")
49
+ if name == "xdotool" and command[1] == "search":
50
+ return _completed(command, stdout="111\n222\n")
51
+ if name == "xdotool" and command[1] == "getwindowname":
52
+ return _completed(command, stdout={"111": "LibreOffice Calc", "222": "Terminal"}[command[2]] + "\n")
53
+ if name == "xwininfo":
54
+ geometry = {
55
+ "111": (5, 7, 800, 600),
56
+ "222": (20, 30, 640, 480),
57
+ }[command[2]]
58
+ return _completed(
59
+ command,
60
+ stdout=(
61
+ f" Absolute upper-left X: {geometry[0]}\n"
62
+ f" Absolute upper-left Y: {geometry[1]}\n"
63
+ f" Width: {geometry[2]}\n"
64
+ f" Height: {geometry[3]}\n"
65
+ ),
66
+ )
67
+ if name == "xprop":
68
+ window_id = command[2]
69
+ if window_id == "111":
70
+ return _completed(
71
+ command,
72
+ stdout='WM_CLASS(STRING) = "libreoffice", "libreoffice-calc"\n_NET_WM_PID(CARDINAL) = 4242\n',
73
+ )
74
+ return _completed(
75
+ command,
76
+ stdout='WM_CLASS(STRING) = "xfce4-terminal", "Xfce4-terminal"\n_NET_WM_PID(CARDINAL) = 4343\n',
77
+ )
78
+ raise AssertionError(f"unexpected command: {command}")
79
+
80
+ monkeypatch.setattr(desktop_state.subprocess, "run", fake_run)
81
+
82
+ state = desktop_state.collect_state()
83
+
84
+ assert state["ok"] is True
85
+ assert state["display"] == ":120"
86
+ assert state["profile_dir"] == str(profile_dir)
87
+ assert state["size"] == {"width": 1440, "height": 900}
88
+ assert state["pointer"]["x"] == 12
89
+ assert state["active_window"]["title"] == "LibreOffice Calc"
90
+ assert state["active_window"]["class"] == "libreoffice-calc"
91
+ assert state["active_window"]["geometry"]["width"] == 800
92
+ assert [window["title"] for window in state["windows"]] == ["LibreOffice Calc", "Terminal"]
93
+
94
+
95
+def test_desktop_state_screenshot_capture_uses_xwd_and_pillow_when_available(tmp_path, monkeypatch):
96
+ monkeypatch.setattr(desktop_state, "SCREENSHOT_DIR", tmp_path)
97
+ capabilities = {"xwd": "/usr/bin/xwd"}
98
+ env = {"DISPLAY": ":120"}
99
+
100
+ def fake_run(command, *, env, timeout):
101
+ raw_path = Path(command[command.index("-out") + 1])
102
+ raw_path.write_bytes(b"xwd")
103
+ return _completed(command)
104
+
105
+ image_module = types.ModuleType("PIL.Image")
106
+
107
+ class FakeImage:
108
+ width = 320
109
+ height = 240
110
+
111
+ def __enter__(self):
112
+ return self
113
+
114
+ def __exit__(self, *_args):
115
+ return False
116
+
117
+ def save(self, target):
118
+ Path(target).write_bytes(b"png")
119
+
120
+ image_module.open = lambda _path: FakeImage()
121
+ pil_module = types.ModuleType("PIL")
122
+ pil_module.Image = image_module
123
+
124
+ monkeypatch.setattr(desktop_state, "run", fake_run)
125
+ monkeypatch.setitem(sys.modules, "PIL", pil_module)
126
+ monkeypatch.setitem(sys.modules, "PIL.Image", image_module)
127
+
128
+ screenshot = desktop_state.capture_screenshot(env, capabilities, path=tmp_path / "shot.png", errors=[])
129
+
130
+ assert screenshot["ok"] is True
131
+ assert screenshot["path"] == str(tmp_path / "shot.png")
132
+ assert screenshot["format"] == "png"
133
+ assert (tmp_path / "shot.png").read_bytes() == b"png"
134
+ assert not (tmp_path / "shot.xwd").exists()
135
+
136
+
137
+def test_xwd_fallback_parser_handles_truecolor_pixels(tmp_path, monkeypatch):
138
+ raw_path = tmp_path / "shot.xwd"
139
+ target = tmp_path / "shot.png"
140
+ header_values = [
141
+ 100, # header_size
142
+ 7, # file_version
143
+ 2, # pixmap_format
144
+ 24, # pixmap_depth
145
+ 2, # pixmap_width
146
+ 1, # pixmap_height
147
+ 0, # xoffset
148
+ 1, # byte_order: MSBFirst for pixel bytes
149
+ 32, # bitmap_unit
150
+ 1, # bitmap_bit_order
151
+ 32, # bitmap_pad
152
+ 32, # bits_per_pixel
153
+ 8, # bytes_per_line
154
+ 4, # visual_class: TrueColor
155
+ 0x00FF0000, # red_mask
156
+ 0x0000FF00, # green_mask
157
+ 0x000000FF, # blue_mask
158
+ 8, # bits_per_rgb
159
+ 256, # colormap_entries
160
+ 0, # ncolors
161
+ 2, # window_width
162
+ 1, # window_height
163
+ 0, # window_x
164
+ 0, # window_y
165
+ 0, # window_bdrwidth
166
+ ]
167
+ raw_path.write_bytes(
168
+ struct.pack(">25I", *header_values)
169
+ + bytes.fromhex("00ff0000")
170
+ + bytes.fromhex("0000ff00")
171
+ )
172
+
173
+ captured: dict[str, object] = {}
174
+ image_module = types.ModuleType("PIL.Image")
175
+
176
+ class FakeOutputImage:
177
+ def putdata(self, pixels):
178
+ captured["pixels"] = list(pixels)
179
+
180
+ def save(self, path):
181
+ Path(path).write_bytes(b"fallback-png")
182
+
183
+ def fake_new(mode, size):
184
+ captured["mode"] = mode
185
+ captured["size"] = size
186
+ return FakeOutputImage()
187
+
188
+ image_module.new = fake_new
189
+ pil_module = types.ModuleType("PIL")
190
+ pil_module.Image = image_module
191
+
192
+ monkeypatch.setitem(sys.modules, "PIL", pil_module)
193
+ monkeypatch.setitem(sys.modules, "PIL.Image", image_module)
194
+
195
+ converted = desktop_state.convert_xwd_to_image(raw_path, target)
196
+
197
+ assert converted == {"width": 2, "height": 1}
198
+ assert captured["mode"] == "RGB"
199
+ assert captured["size"] == (2, 1)
200
+ assert captured["pixels"] == [(255, 0, 0), (0, 255, 0)]
201
+ assert target.read_bytes() == b"fallback-png"
tests/test_office_document_store.py
+51
@@ -503,6 +503,57 @@ def test_official_libreoffice_desktop_status_and_url_contract(tmp_path, monkeypa
503
assert "printing=true" in url
504
505
506
+def test_office_session_desktop_state_action_defaults_without_screenshot(monkeypatch):
507
+ api_module = types.ModuleType("helpers.api")
508
+
509
+ class ApiHandler:
510
+ def __init__(self, app=None, thread_lock=None):
511
+ self.app = app
512
+ self.thread_lock = thread_lock
513
+
514
+ api_module.ApiHandler = ApiHandler
515
+ api_module.Request = object
516
+ monkeypatch.setitem(sys.modules, "helpers.api", api_module)
517
+ monkeypatch.delitem(sys.modules, "plugins._office.api.office_session", raising=False)
518
+
519
+ from plugins._office.api import office_session
520
+
521
+ calls = []
522
+
523
+ class FakeManager:
524
+ def state(self, *, include_screenshot=False):
525
+ calls.append(include_screenshot)
526
+ return {
527
+ "ok": True,
528
+ "display": ":120",
529
+ "profile_dir": "/a0/tmp/_office/desktop/profiles/agent-zero-desktop",
530
+ "size": {"width": 1440, "height": 900},
531
+ "pointer": {"x": 0, "y": 0, "screen": 0, "window": 0},
532
+ "active_window": None,
533
+ "windows": [],
534
+ "screenshot": {"ok": False, "path": ""},
535
+ "capabilities": {},
536
+ "errors": [],
537
+ }
538
+
539
+ monkeypatch.setattr(office_session.libreoffice_desktop, "get_manager", lambda: FakeManager())
540
+ handler = office_session.OfficeSession(app=None, thread_lock=None)
541
+ request = types.SimpleNamespace(headers={}, host_url="http://localhost:32080")
542
+
543
+ default_result = asyncio.run(handler.process({"action": "desktop_state"}, request))
544
+ screenshot_result = asyncio.run(
545
+ handler.process({"action": "desktop_state", "include_screenshot": True}, request),
546
+ )
547
+
548
+ assert default_result["ok"] is True
549
+ assert screenshot_result["ok"] is True
550
+ assert calls == [False, True]
551
+ monkeypatch.delitem(sys.modules, "plugins._office.api.office_session", raising=False)
552
+ api_package = sys.modules.get("plugins._office.api")
553
+ if api_package is not None:
554
+ monkeypatch.delattr(api_package, "office_session", raising=False)
555
+
556
+
557
def test_official_libreoffice_desktop_manager_opens_binary_session(office_state, tmp_path, monkeypatch):
558
class FakeProcess:
559
pid = 4242