Move office and desktop state under plugin storage
Migrate retired /usr/_office and /usr/_desktop trees from plugin startup into /usr/plugins/<plugin>. Update office document storage, desktop session/runtime paths, and context-scoped screenshots to use the plugin-owned state layout. Add focused tests for retired-state migration and the new path behavior.
Alessandro committed
May 12, 2026 at 16:21 UTC
68c3b8b022cd15781a5db764e3348d83e40fe4ef
15 files changed
+393
-62
helpers/state_migration.py
new
+78
@@ -0,0 +1,78 @@
1
+from __future__ import annotations
2
+
3
+import shutil
4
+from pathlib import Path
5
+
6
+
7
+def migrate_retired_state_tree(
8
+ *,
9
+ source: Path,
10
+ destination: Path,
11
+ owner: str,
12
+ migrated: list[str],
13
+ warnings: list[str],
14
+ errors: list[str],
15
+) -> None:
16
+ """Move retired plugin state into its plugin-owned state directory.
17
+
18
+ Existing destination data wins. Colliding source entries are preserved under
19
+ a suffixed name in the destination instead of overwriting live data.
20
+ """
21
+
22
+ if not source.exists() and not source.is_symlink():
23
+ return
24
+ if _same_path(source, destination):
25
+ return
26
+
27
+ try:
28
+ if source.is_dir() and not source.is_symlink():
29
+ destination.mkdir(parents=True, exist_ok=True)
30
+ for child in list(source.iterdir()):
31
+ try:
32
+ _move_path(child, destination / child.name, migrated)
33
+ except Exception as exc:
34
+ errors.append(f"{owner} state migration failed for {child}: {exc}")
35
+ _remove_empty_dir(source, owner=owner, warnings=warnings)
36
+ return
37
+
38
+ _move_path(source, destination, migrated)
39
+ except Exception as exc:
40
+ errors.append(f"{owner} state migration failed from {source} to {destination}: {exc}")
41
+
42
+
43
+def _move_path(source: Path, target: Path, migrated: list[str]) -> None:
44
+ if source.is_dir() and not source.is_symlink() and target.is_dir() and not target.is_symlink():
45
+ for child in list(source.iterdir()):
46
+ _move_path(child, target / child.name, migrated)
47
+ source.rmdir()
48
+ return
49
+
50
+ final_target = target
51
+ if target.exists() or target.is_symlink():
52
+ final_target = _next_conflict_path(target)
53
+ final_target.parent.mkdir(parents=True, exist_ok=True)
54
+ shutil.move(str(source), str(final_target))
55
+ migrated.append(f"{source} -> {final_target}")
56
+
57
+
58
+def _next_conflict_path(path: Path) -> Path:
59
+ candidate = path.with_name(f"{path.name}.retired")
60
+ counter = 2
61
+ while candidate.exists() or candidate.is_symlink():
62
+ candidate = path.with_name(f"{path.name}.retired-{counter}")
63
+ counter += 1
64
+ return candidate
65
+
66
+
67
+def _remove_empty_dir(path: Path, *, owner: str, warnings: list[str]) -> None:
68
+ try:
69
+ path.rmdir()
70
+ except OSError:
71
+ warnings.append(f"Retired {owner} state directory was not empty after migration: {path}")
72
+
73
+
74
+def _same_path(left: Path, right: Path) -> bool:
75
+ try:
76
+ return left.resolve(strict=False) == right.resolve(strict=False)
77
+ except OSError:
78
+ return False
helpers/virtual_desktop.py
+1
-1
@@ -15,7 +15,7 @@ from urllib.parse import quote, urlencode
15
from helpers import files
16
17
18
-STATE_DIR = Path(files.get_abs_path("usr", "_desktop", "virtual_desktop"))
18
+STATE_DIR = Path(files.get_abs_path("usr", "plugins", "_desktop", "virtual_desktop"))
19
DEFAULT_WIDTH = 1440
20
DEFAULT_HEIGHT = 900
21
MAX_WIDTH = 1920
plugins/_desktop/api/desktop_session.py
+1
@@ -148,6 +148,7 @@ class DesktopSession(ApiHandler):
148
def _state(self, input: dict) -> dict:
149
return desktop_session.get_manager().state(
150
include_screenshot=bool(input.get("include_screenshot") is True),
151
+ context_id=str(input.get("ctxid") or input.get("context_id") or ""),
152
)
153
154
def _shutdown(self, input: dict) -> dict:
plugins/_desktop/extensions/python/startup_migration/_20_desktop_routes.py
+3
-1
@@ -43,5 +43,7 @@ def _prepare_runtime_safely() -> None:
43
def _log_runtime_preparation_result(result: dict[str, Any]) -> None:
44
if result.get("errors"):
45
PrintStyle.warning("Desktop runtime preparation reported errors:", result["errors"])
46
- elif result.get("installed") or result.get("removed"):
46
+ elif result.get("warnings"):
47
+ PrintStyle.warning("Desktop runtime preparation reported warnings:", result["warnings"])
48
+ elif result.get("installed") or result.get("removed") or result.get("migrated"):
49
PrintStyle.info("Desktop runtime prepared:", result)
plugins/_desktop/helpers/desktop_session.py
+22
-4
@@ -23,13 +23,16 @@ from plugins._office.helpers import document_store, libreoffice
23
24
25
OFFICIAL_EXTENSIONS = {"odt", "ods", "odp", "docx", "xlsx", "pptx"}
26
+PLUGIN_NAME = "_desktop"
27
SYSTEM_SESSION_ID = "agent-zero-desktop"
28
SYSTEM_FILE_ID = "system-desktop"
29
SYSTEM_TITLE = "Desktop"
29
-STATE_DIR = Path(files.get_abs_path("usr", "_desktop"))
30
+STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME))
31
+RETIRED_STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME))
32
SESSION_DIR = STATE_DIR / "sessions"
33
PROFILE_DIR = STATE_DIR / "profiles"
34
LEGACY_SESSION_DIRS = (
35
+ RETIRED_STATE_DIR / "sessions",
36
Path(files.get_abs_path("tmp", "_office", "desktop", "sessions")),
37
)
38
DISPLAY_BASE = 120
@@ -283,10 +286,13 @@ class DesktopSessionManager:
286
"url_intents": url_intents,
287
}
288
286
- def state(self, *, include_screenshot: bool = False) -> dict[str, Any]:
289
+ def state(self, *, include_screenshot: bool = False, context_id: str = "") -> dict[str, Any]:
290
with self._lock:
291
self._reap_dead_locked()
289
- return desktop_state.collect_state(include_screenshot=include_screenshot)
292
+ return desktop_state.collect_state(
293
+ include_screenshot=include_screenshot,
294
+ context_id=context_id,
295
+ )
296
297
def claim_url_intents(self, session_id: str = SYSTEM_SESSION_ID) -> list[dict[str, Any]]:
298
session = self.get(session_id) or self.get(SYSTEM_SESSION_ID)
@@ -558,7 +564,9 @@ class DesktopSessionManager:
564
xpra_port=xpra_port,
565
token=SYSTEM_SESSION_ID,
566
url=_xpra_url(SYSTEM_SESSION_ID),
561
- profile_dir=Path(payload.get("profile_dir") or PROFILE_DIR / SYSTEM_SESSION_ID),
567
+ profile_dir=_state_path_from_retired_root(
568
+ Path(payload.get("profile_dir") or PROFILE_DIR / SYSTEM_SESSION_ID)
569
+ ),
570
width=int(payload.get("width") or DEFAULT_SCREEN_WIDTH),
571
height=int(payload.get("height") or DEFAULT_SCREEN_HEIGHT),
572
process_ids=process_ids,
@@ -1614,6 +1622,16 @@ def collect_desktop_status() -> dict[str, Any]:
1622
}
1623
1624
1625
+def _state_path_from_retired_root(path: Path) -> Path:
1626
+ try:
1627
+ relative = path.resolve(strict=False).relative_to(
1628
+ RETIRED_STATE_DIR.resolve(strict=False)
1629
+ )
1630
+ except ValueError:
1631
+ return path
1632
+ return STATE_DIR / relative
1633
+
1634
+
1635
def _runtime_preparation_status() -> dict[str, Any]:
1636
try:
1637
from plugins._desktop import hooks
plugins/_desktop/helpers/desktop_state.py
+62
-17
@@ -14,23 +14,40 @@ PROJECT_ROOT = Path(__file__).resolve().parents[3]
14
15
16
SESSION_ID = "agent-zero-desktop"
17
+PLUGIN_NAME = "_desktop"
18
BASE_DIR = Path(os.environ.get("A0_BASE_DIR") or ("/a0" if Path("/a0").exists() else PROJECT_ROOT))
18
-STATE_DIR = BASE_DIR / "usr" / "_desktop"
19
+STATE_DIR = BASE_DIR / "usr" / "plugins" / PLUGIN_NAME
20
+RETIRED_STATE_DIR = BASE_DIR / "usr" / PLUGIN_NAME
21
SESSION_DIR = STATE_DIR / "sessions"
22
PROFILE_DIR = STATE_DIR / "profiles"
23
SCREENSHOT_DIR = STATE_DIR / "screenshots"
24
RECENT_SCREENSHOT_SECONDS = 600
25
+_SAFE_CONTEXT_RE = re.compile(r"[^a-zA-Z0-9_.-]+")
26
27
28
def session_manifest_path(session_id: str = SESSION_ID) -> Path:
29
return Path(os.environ.get("A0_DESKTOP_MANIFEST") or SESSION_DIR / f"{session_id}.json")
30
31
32
+def context_screenshot_dir(context_id: str = "") -> Path:
33
+ return SCREENSHOT_DIR / _safe_context_id(context_id)
34
+
35
+
36
+def _safe_context_id(context_id: str = "") -> str:
37
+ raw = str(context_id or os.environ.get("A0_DESKTOP_CONTEXT_ID") or "default")
38
+ return _SAFE_CONTEXT_RE.sub("_", raw).strip("._") or "default"
39
+
40
+
41
def session_manifest_exists(session_id: str = SESSION_ID) -> bool:
42
return session_manifest_path(session_id).exists()
43
44
33
-def collect_state(*, include_screenshot: bool = False, screenshot_path: str | Path | None = None) -> dict[str, Any]:
45
+def collect_state(
46
+ *,
47
+ include_screenshot: bool = False,
48
+ screenshot_path: str | Path | None = None,
49
+ context_id: str = "",
50
+) -> dict[str, Any]:
51
errors: list[str] = []
52
env_info = resolve_environment(errors=errors)
53
display = env_info["display"]
@@ -46,10 +63,16 @@ def collect_state(*, include_screenshot: bool = False, screenshot_path: str | Pa
63
pointer = collect_pointer(env, capabilities, errors)
64
active_window = collect_active_window(env, capabilities, errors)
65
windows = collect_windows(env, capabilities, errors)
49
- screenshot = latest_screenshot()
66
+ screenshot = latest_screenshot(context_id=context_id)
67
68
if include_screenshot:
52
- screenshot = capture_screenshot(env, capabilities, path=screenshot_path, errors=errors)
69
+ screenshot = capture_screenshot(
70
+ env,
71
+ capabilities,
72
+ path=screenshot_path,
73
+ errors=errors,
74
+ context_id=context_id,
75
+ )
76
77
return stable_state(
78
display=display,
@@ -70,6 +93,7 @@ def capture_screenshot(
93
*,
94
path: str | Path | None = None,
95
errors: list[str] | None = None,
96
+ context_id: str = "",
97
) -> dict[str, Any]:
98
local_errors = errors if errors is not None else []
99
capabilities = capabilities or collect_capabilities()
@@ -85,9 +109,10 @@ def capture_screenshot(
109
local_errors.append(message)
110
return {"ok": False, "path": "", "format": "", "captured_at": "", "error": message}
111
88
- SCREENSHOT_DIR.mkdir(parents=True, exist_ok=True)
112
+ screenshot_dir = context_screenshot_dir(context_id)
113
+ screenshot_dir.mkdir(parents=True, exist_ok=True)
114
timestamp = time.strftime("%Y%m%d-%H%M%S")
90
- target = Path(path) if path else SCREENSHOT_DIR / f"desktop-{timestamp}.png"
115
+ target = Path(path) if path else screenshot_dir / f"desktop-{timestamp}.png"
116
target.parent.mkdir(parents=True, exist_ok=True)
117
raw_path = target.with_suffix(".xwd")
118
@@ -291,21 +316,33 @@ def resolve_environment(*, errors: list[str] | None = None, session_id: str = SE
316
display = ""
317
local_errors.append("Desktop DISPLAY is unavailable; the persistent Desktop session is not running.")
318
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
319
+ profile_dir = _state_path_from_retired_root(
320
+ Path(
321
+ os.environ.get("A0_DESKTOP_PROFILE")
322
+ or os.environ.get("A0_DESKTOP_HOME")
323
+ or payload.get("profile_dir")
324
+ or os.environ.get("HOME")
325
+ or PROFILE_DIR / session_id
326
+ )
327
)
328
329
return {
330
"display": display,
304
- "profile_dir": profile_dir,
331
+ "profile_dir": str(profile_dir),
332
"manifest": str(manifest),
333
}
334
335
336
+def _state_path_from_retired_root(path: Path) -> Path:
337
+ try:
338
+ relative = path.resolve(strict=False).relative_to(
339
+ RETIRED_STATE_DIR.resolve(strict=False)
340
+ )
341
+ except ValueError:
342
+ return path
343
+ return STATE_DIR / relative
344
+
345
+
346
def display_env(*, display: str, profile_dir: str) -> dict[str, str]:
347
env = {
348
**os.environ,
@@ -480,12 +517,13 @@ def parse_xprop(output: str) -> dict[str, str]:
517
return values
518
519
483
-def latest_screenshot() -> dict[str, Any]:
484
- if not SCREENSHOT_DIR.exists():
520
+def latest_screenshot(*, context_id: str = "") -> dict[str, Any]:
521
+ screenshot_dir = context_screenshot_dir(context_id)
522
+ if not screenshot_dir.exists():
523
return {"ok": False, "path": "", "format": "", "captured_at": "", "recent": False}
524
candidates = [
525
path
488
- for path in SCREENSHOT_DIR.iterdir()
526
+ for path in screenshot_dir.iterdir()
527
if path.is_file() and path.suffix.lower() in {".png", ".jpg", ".jpeg", ".xwd"}
528
]
529
if not candidates:
@@ -640,19 +678,25 @@ def main(argv: list[str] | None = None) -> int:
678
state_parser = subparsers.add_parser("state")
679
state_parser.add_argument("--json", action="store_true")
680
state_parser.add_argument("--screenshot", action="store_true")
681
+ state_parser.add_argument("--context-id", default="")
682
683
observe_parser = subparsers.add_parser("observe")
684
observe_parser.add_argument("--json", action="store_true")
685
observe_parser.add_argument("--screenshot", action="store_true")
686
+ observe_parser.add_argument("--context-id", default="")
687
688
screenshot_parser = subparsers.add_parser("screenshot")
689
screenshot_parser.add_argument("path", nargs="?")
690
screenshot_parser.add_argument("--json", action="store_true")
691
+ screenshot_parser.add_argument("--context-id", default="")
692
693
args = parser.parse_args(argv)
694
command = args.command or "state"
695
if command in {"state", "observe"}:
655
- payload = collect_state(include_screenshot=bool(args.screenshot))
696
+ payload = collect_state(
697
+ include_screenshot=bool(args.screenshot),
698
+ context_id=str(args.context_id or ""),
699
+ )
700
print(json.dumps(payload, sort_keys=True))
701
return 0 if payload.get("ok") else 1
702
@@ -664,6 +708,7 @@ def main(argv: list[str] | None = None) -> int:
708
collect_capabilities(),
709
path=args.path,
710
errors=errors,
711
+ context_id=str(args.context_id or ""),
712
)
713
if args.json:
714
print(json.dumps(payload, sort_keys=True))
plugins/_desktop/hooks.py
+55
-1
@@ -9,7 +9,7 @@ import urllib.request
9
from pathlib import Path
10
from typing import Any
11
12
-from helpers import system_packages
12
+from helpers import files, state_migration, system_packages
13
14
LIBREOFFICE_RUNTIME_PACKAGES = (
15
"libreoffice-core",
@@ -61,6 +61,9 @@ OPTIONAL_RUNTIME_PACKAGES = (
61
RETIRED_RUNTIME_PACKAGES = (
62
"firefox-esr",
63
)
64
+PLUGIN_NAME = "_desktop"
65
+STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME))
66
+RETIRED_STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME))
67
_preparation_lock = threading.RLock()
68
_preparation_state: dict[str, Any] = {
69
"preparing": False,
@@ -81,8 +84,13 @@ def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
84
try:
85
installed: list[str] = []
86
removed: list[str] = []
87
+ migrated: list[str] = []
88
+ warnings: list[str] = []
89
errors: list[str] = []
90
91
+ _migrate_retired_plugin_state(migrated, warnings, errors)
92
+ _migrate_unscoped_screenshots(migrated, warnings, errors)
93
+
94
retired_packages = _installed_packages(RETIRED_RUNTIME_PACKAGES)
95
if retired_packages:
96
_purge_packages(removed, errors, installed_packages=retired_packages)
@@ -95,6 +103,8 @@ def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
103
"skipped": False,
104
"removed": removed,
105
"installed": installed,
106
+ "migrated": migrated,
107
+ "warnings": warnings,
108
"errors": errors,
109
}
110
return result
@@ -117,6 +127,50 @@ def runtime_preparation_status() -> dict[str, Any]:
127
}
128
129
130
+def _migrate_retired_plugin_state(
131
+ migrated: list[str],
132
+ warnings: list[str],
133
+ errors: list[str],
134
+) -> None:
135
+ state_migration.migrate_retired_state_tree(
136
+ source=RETIRED_STATE_DIR,
137
+ destination=STATE_DIR,
138
+ owner="Desktop",
139
+ migrated=migrated,
140
+ warnings=warnings,
141
+ errors=errors,
142
+ )
143
+
144
+
145
+def _migrate_unscoped_screenshots(
146
+ migrated: list[str],
147
+ warnings: list[str],
148
+ errors: list[str],
149
+) -> None:
150
+ screenshots_dir = STATE_DIR / "screenshots"
151
+ if not screenshots_dir.exists():
152
+ return
153
+ legacy_screenshots = [
154
+ path
155
+ for path in screenshots_dir.iterdir()
156
+ if path.is_file() and path.suffix.lower() in {".png", ".jpg", ".jpeg", ".xwd"}
157
+ ]
158
+ if not legacy_screenshots:
159
+ return
160
+
161
+ context_dir = screenshots_dir / "default"
162
+ context_dir.mkdir(parents=True, exist_ok=True)
163
+ for screenshot in legacy_screenshots:
164
+ state_migration.migrate_retired_state_tree(
165
+ source=screenshot,
166
+ destination=context_dir / screenshot.name,
167
+ owner="Desktop screenshot",
168
+ migrated=migrated,
169
+ warnings=warnings,
170
+ errors=errors,
171
+ )
172
+
173
+
174
def _begin_runtime_preparation() -> None:
175
with _preparation_lock:
176
if not _preparation_state["active_count"]:
plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
+10
-7
@@ -3,8 +3,8 @@ set -euo pipefail
3
4
SESSION="${A0_DESKTOP_SESSION:-agent-zero-desktop}"
5
BASE_DIR="${A0_BASE_DIR:-/a0}"
6
-PROFILE_DIR="${A0_DESKTOP_PROFILE:-$BASE_DIR/usr/_desktop/profiles/$SESSION}"
7
-MANIFEST="${A0_DESKTOP_MANIFEST:-$BASE_DIR/usr/_desktop/sessions/$SESSION.json}"
6
+PROFILE_DIR="${A0_DESKTOP_PROFILE:-$BASE_DIR/usr/plugins/_desktop/profiles/$SESSION}"
7
+MANIFEST="${A0_DESKTOP_MANIFEST:-$BASE_DIR/usr/plugins/_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)}"
@@ -55,10 +55,12 @@ 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]
58
+ state --json [--screenshot] [--context-id ID]
59
+ Return structured Desktop state as JSON.
60
+ observe --json [--screenshot] [--context-id ID]
61
Return structured state, optionally with a fresh screenshot.
61
- screenshot [PATH] Capture the Desktop to PATH, or to the default screenshot directory.
62
+ screenshot [PATH] [--context-id ID]
63
+ Capture the Desktop to PATH, or to the default screenshot directory.
64
active-window Print the active window name.
65
geometry PATTERN Print the first matching visible window geometry.
66
wait-window PATTERN Wait for a visible matching window and print its id.
@@ -295,7 +297,8 @@ case "$command_name" in
297
echo "state currently requires --json." >&2
298
exit 2
299
fi
298
- desktop_state state --json
300
+ shift
301
+ desktop_state state --json "$@"
302
;;
303
observe)
304
if [ "${1:-}" != "--json" ]; then
@@ -310,7 +313,7 @@ case "$command_name" in
313
shift
314
desktop_state screenshot --json "$@"
315
elif [ "$#" -gt 0 ]; then
313
- desktop_state screenshot "$1"
316
+ desktop_state screenshot "$@"
317
else
318
desktop_state screenshot
319
fi
plugins/_office/api/office_session.py
+4
-1
@@ -221,7 +221,10 @@ class OfficeSession(ApiHandler):
221
222
def _desktop_state(self, input: dict) -> dict:
223
include_screenshot = bool(input.get("include_screenshot") is True)
224
- return desktop_session.get_manager().state(include_screenshot=include_screenshot)
224
+ return desktop_session.get_manager().state(
225
+ include_screenshot=include_screenshot,
226
+ context_id=str(input.get("ctxid") or input.get("context_id") or ""),
227
+ )
228
229
def _desktop_shutdown(self, input: dict) -> dict:
230
save_first = input.get("save_first") is not False
plugins/_office/extensions/python/startup_migration/_20_office_routes.py
+3
-1
@@ -41,5 +41,7 @@ def _prepare_runtime_safely() -> None:
41
def _log_runtime_preparation_result(result: dict[str, Any]) -> None:
42
if result.get("errors"):
43
PrintStyle.warning("Office document runtime preparation reported errors:", result["errors"])
44
- elif result.get("installed") or result.get("removed"):
44
+ elif result.get("warnings"):
45
+ PrintStyle.warning("Office document runtime preparation reported warnings:", result["warnings"])
46
+ elif result.get("installed") or result.get("removed") or result.get("migrated"):
47
PrintStyle.info("Office document runtime prepared:", result)
plugins/_office/helpers/document_store.py
+1
-1
@@ -40,7 +40,7 @@ ODF_MIMETYPES = {
40
"odp": "application/vnd.oasis.opendocument.presentation",
41
}
42
43
-STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME, "documents"))
43
+STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "documents"))
44
DB_PATH = STATE_DIR / "documents.sqlite3"
45
BACKUP_DIR = STATE_DIR / "backups"
46
WORKDIR = Path(files.get_abs_path("usr", "workdir"))
plugins/_office/hooks.py
+24
-4
@@ -7,14 +7,16 @@ import subprocess
7
from pathlib import Path
8
from typing import Any
9
10
-from helpers import files, system_packages
10
+from helpers import files, state_migration, system_packages
11
12
13
PROJECT_ROOT = Path(__file__).resolve().parents[2]
14
-STATE_DIR = Path(files.get_abs_path("usr", "_office"))
14
+PLUGIN_NAME = "_office"
15
+STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME))
16
+RETIRED_STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME))
17
DOCUMENT_STATE_DIR = STATE_DIR / "documents"
18
LEGACY_DOCUMENT_STATE_DIRS = [
17
- Path(files.get_abs_path("usr", "plugins", "_office", "documents")),
19
+ RETIRED_STATE_DIR / "documents",
20
Path(files.get_abs_path("usr", "state", "_office", "documents")),
21
Path(files.get_abs_path("usr", "state", "office", "documents")),
22
]
@@ -84,6 +86,7 @@ def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
86
warnings: list[str] = []
87
errors: list[str] = []
88
89
+ _migrate_retired_plugin_state(migrated, warnings, errors)
90
_migrate_legacy_document_state(migrated, warnings, errors)
91
92
retired_web_paths = [
@@ -123,7 +126,7 @@ def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
126
127
_retire_supervisor_program(errors)
128
_ensure_runtime_dependencies(installed, errors)
126
- _ensure_desktop_runtime_compat(installed, removed, warnings, errors)
129
+ _ensure_desktop_runtime_compat(installed, removed, migrated, warnings, errors)
130
return {
131
"ok": not errors,
132
"skipped": not cleanup_needed,
@@ -209,9 +212,25 @@ def _migrate_legacy_document_state(
212
)
213
214
215
+def _migrate_retired_plugin_state(
216
+ migrated: list[str],
217
+ warnings: list[str],
218
+ errors: list[str],
219
+) -> None:
220
+ state_migration.migrate_retired_state_tree(
221
+ source=RETIRED_STATE_DIR,
222
+ destination=STATE_DIR,
223
+ owner="Office",
224
+ migrated=migrated,
225
+ warnings=warnings,
226
+ errors=errors,
227
+ )
228
+
229
+
230
def _ensure_desktop_runtime_compat(
231
installed: list[str],
232
removed: list[str],
233
+ migrated: list[str],
234
warnings: list[str],
235
errors: list[str],
236
) -> None:
@@ -239,6 +258,7 @@ def _ensure_desktop_runtime_compat(
258
return
259
installed.extend(str(item) for item in result.get("installed") or [])
260
removed.extend(str(item) for item in result.get("removed") or [])
261
+ migrated.extend(str(item) for item in result.get("migrated") or [])
262
warnings.extend(str(item) for item in result.get("warnings") or [])
263
errors.extend(str(item) for item in result.get("errors") or [])
264
tests/test_office_canvas_setup.py
+5
-5
@@ -184,8 +184,8 @@ def test_desktop_plugin_owns_routes_runtime_surface_and_state_paths():
184
assert 'data-surface-id="desktop"' in desktop_main
185
assert "virtual_desktop.session_url" in desktop_session
186
assert 'owner="desktop"' in desktop_session
187
- assert 'STATE_DIR = Path(files.get_abs_path("usr", "_desktop"))' in desktop_session
188
- assert 'STATE_DIR = BASE_DIR / "usr" / "_desktop"' in desktop_state
187
+ assert 'STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME))' in desktop_session
188
+ assert 'STATE_DIR = BASE_DIR / "usr" / "plugins" / PLUGIN_NAME' in desktop_state
189
assert "> x-component > div[x-data] > .office-panel" in desktop_web_panel
190
assert ".office-state-line > span:not(.material-symbols-outlined)" in desktop_web_panel
191
@@ -201,7 +201,7 @@ def test_plugin_owned_runtime_state_paths_are_declared():
201
docker_playwright = read("docker", "run", "fs", "ins", "install_playwright.sh")
202
203
assert 'PLUGIN_NAME = "_office"' in office_documents
204
- assert 'STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME, "documents"))' in office_documents
204
+ assert 'STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME, "documents"))' in office_documents
205
assert 'PLAYWRIGHT_CACHE_DIR = ("tmp", "playwright")' in browser_playwright
206
assert '"usr", "plugins", "_browser", "playwright"' in browser_playwright
207
assert "Path(files.get_abs_path(*PLAYWRIGHT_CACHE_DIR))" in browser_playwright
@@ -335,8 +335,8 @@ def test_office_and_desktop_skills_are_rehomed_and_renamed():
335
desktopctl = (desktop_skills / "linux-desktop" / "scripts" / "desktopctl.sh").read_text(encoding="utf-8")
336
assert "/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh" in desktop_skill
337
assert "Open in Desktop action" in desktop_skill
338
- assert "$BASE_DIR/usr/_desktop/profiles/$SESSION" in desktopctl
339
- assert "$BASE_DIR/usr/_desktop/sessions/$SESSION.json" in desktopctl
338
+ assert "$BASE_DIR/usr/plugins/_desktop/profiles/$SESSION" in desktopctl
339
+ assert "$BASE_DIR/usr/plugins/_desktop/sessions/$SESSION.json" in desktopctl
340
341
342
def test_skill_catalog_and_connector_boundaries_are_static_guarded():
tests/test_office_desktop_state.py
+47
@@ -189,6 +189,53 @@ def test_desktop_state_screenshot_capture_uses_xwd_and_pillow_when_available(tmp
189
assert not (tmp_path / "shot.xwd").exists()
190
191
192
+def test_desktop_state_default_screenshot_path_is_context_scoped(tmp_path, monkeypatch):
193
+ monkeypatch.setattr(desktop_state, "SCREENSHOT_DIR", tmp_path)
194
+ capabilities = {"xwd": "/usr/bin/xwd"}
195
+ env = {"DISPLAY": ":120"}
196
+
197
+ def fake_run(command, *, env, timeout):
198
+ raw_path = Path(command[command.index("-out") + 1])
199
+ raw_path.write_bytes(b"xwd")
200
+ return _completed(command)
201
+
202
+ image_module = types.ModuleType("PIL.Image")
203
+
204
+ class FakeImage:
205
+ width = 320
206
+ height = 240
207
+
208
+ def __enter__(self):
209
+ return self
210
+
211
+ def __exit__(self, *_args):
212
+ return False
213
+
214
+ def save(self, target):
215
+ Path(target).write_bytes(b"png")
216
+
217
+ image_module.open = lambda _path: FakeImage()
218
+ pil_module = types.ModuleType("PIL")
219
+ pil_module.Image = image_module
220
+
221
+ monkeypatch.setattr(desktop_state, "run", fake_run)
222
+ monkeypatch.setitem(sys.modules, "PIL", pil_module)
223
+ monkeypatch.setitem(sys.modules, "PIL.Image", image_module)
224
+
225
+ screenshot = desktop_state.capture_screenshot(
226
+ env,
227
+ capabilities,
228
+ errors=[],
229
+ context_id="ctx/id",
230
+ )
231
+
232
+ path = Path(screenshot["path"])
233
+ assert screenshot["ok"] is True
234
+ assert path.parent == tmp_path / "ctx_id"
235
+ assert path.name.startswith("desktop-")
236
+ assert desktop_state.latest_screenshot(context_id="ctx/id")["path"] == str(path)
237
+
238
+
239
def test_xwd_fallback_parser_handles_truecolor_pixels(tmp_path, monkeypatch):
240
raw_path = tmp_path / "shot.xwd"
241
target = tmp_path / "shot.png"
tests/test_office_document_store.py
+77
-19
@@ -607,12 +607,12 @@ def test_office_session_desktop_state_action_defaults_without_screenshot(monkeyp
607
calls = []
608
609
class FakeManager:
610
- def state(self, *, include_screenshot=False):
611
- calls.append(include_screenshot)
610
+ def state(self, *, include_screenshot=False, context_id=""):
611
+ calls.append((include_screenshot, context_id))
612
return {
613
"ok": True,
614
"display": ":120",
615
- "profile_dir": "/a0/usr/_desktop/profiles/agent-zero-desktop",
615
+ "profile_dir": "/a0/usr/plugins/_desktop/profiles/agent-zero-desktop",
616
"size": {"width": 1440, "height": 900},
617
"pointer": {"x": 0, "y": 0, "screen": 0, "window": 0},
618
"active_window": None,
@@ -633,7 +633,7 @@ def test_office_session_desktop_state_action_defaults_without_screenshot(monkeyp
633
634
assert default_result["ok"] is True
635
assert screenshot_result["ok"] is True
636
- assert calls == [False, True]
636
+ assert calls == [(False, ""), (True, "")]
637
monkeypatch.delitem(sys.modules, "plugins._office.api.office_session", raising=False)
638
api_package = sys.modules.get("plugins._office.api")
639
if api_package is not None:
@@ -1135,11 +1135,17 @@ def test_desktop_session_removes_stale_lock_file(tmp_path):
1135
1136
1137
def _isolate_office_cleanup_hook(monkeypatch, tmp_path):
1138
+ state_dir = tmp_path / "usr" / "plugins" / "_office"
1139
+ retired_state_dir = tmp_path / "usr" / "_office"
1140
monkeypatch.setattr(hooks, "RETIRED_WEB_APT_SOURCE_FILE", tmp_path / "missing.sources")
1141
monkeypatch.setattr(hooks, "RETIRED_WEB_APT_KEYRING_FILE", tmp_path / "missing.gpg")
1142
monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", tmp_path / "missing.conf")
1143
monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", [])
1142
- monkeypatch.setattr(hooks, "CLEANUP_MARKER", tmp_path / "state" / "cleanup.done")
1144
+ monkeypatch.setattr(hooks, "STATE_DIR", state_dir)
1145
+ monkeypatch.setattr(hooks, "RETIRED_STATE_DIR", retired_state_dir)
1146
+ monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", state_dir / "documents")
1147
+ monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [])
1148
+ monkeypatch.setattr(hooks, "CLEANUP_MARKER", state_dir / "cleanup.done")
1149
monkeypatch.setattr(hooks, "_installed_retired_web_packages", lambda: [])
1150
monkeypatch.setattr(hooks, "_installed_packages", lambda packages: [])
1151
monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None)
@@ -1148,10 +1154,31 @@ def _isolate_office_cleanup_hook(monkeypatch, tmp_path):
1154
monkeypatch.setattr(hooks.shutil, "which", lambda name: "")
1155
1156
1157
+def test_cleanup_hook_moves_retired_office_state_to_plugin_state(tmp_path, monkeypatch):
1158
+ _isolate_office_cleanup_hook(monkeypatch, tmp_path)
1159
+ retired_state = tmp_path / "usr" / "_office"
1160
+ plugin_state = tmp_path / "usr" / "plugins" / "_office"
1161
+ (retired_state / "documents" / "backups").mkdir(parents=True)
1162
+ (retired_state / "documents" / "documents.sqlite3").write_text("db\n", encoding="utf-8")
1163
+ (retired_state / "documents" / "backups" / "draft.md").write_text("backup\n", encoding="utf-8")
1164
+ (retired_state / "stale-cleanup-v3.done").write_text("ok\n", encoding="utf-8")
1165
+ plugin_state.mkdir(parents=True)
1166
+
1167
+ monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, migrated, warnings, errors: None)
1168
+
1169
+ result = hooks.cleanup_stale_runtime_state(force=True)
1170
+
1171
+ assert result["ok"] is True
1172
+ assert (plugin_state / "documents" / "documents.sqlite3").read_text(encoding="utf-8") == "db\n"
1173
+ assert (plugin_state / "documents" / "backups" / "draft.md").read_text(encoding="utf-8") == "backup\n"
1174
+ assert (plugin_state / "stale-cleanup-v3.done").read_text(encoding="utf-8") == "ok\n"
1175
+ assert not retired_state.exists()
1176
+
1177
+
1178
def test_cleanup_hook_migrates_legacy_document_state_without_removing_source(tmp_path, monkeypatch):
1179
_isolate_office_cleanup_hook(monkeypatch, tmp_path)
1153
- legacy_documents = tmp_path / "usr" / "plugins" / "_office" / "documents"
1154
- document_state = tmp_path / "usr" / "_office" / "documents"
1180
+ legacy_documents = tmp_path / "usr" / "state" / "_office" / "documents"
1181
+ document_state = tmp_path / "usr" / "plugins" / "_office" / "documents"
1182
legacy_documents.mkdir(parents=True)
1183
(legacy_documents / "documents.sqlite3").write_text("legacy-db\n", encoding="utf-8")
1184
(legacy_documents / "backups").mkdir()
@@ -1159,7 +1186,7 @@ def test_cleanup_hook_migrates_legacy_document_state_without_removing_source(tmp
1186
1187
monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", document_state)
1188
monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [legacy_documents])
1162
- monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, warnings, errors: None)
1189
+ monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, migrated, warnings, errors: None)
1190
1191
result = hooks.cleanup_stale_runtime_state(force=True)
1192
@@ -1173,7 +1200,7 @@ def test_cleanup_hook_migrates_legacy_document_state_without_removing_source(tmp
1200
def test_cleanup_hook_prefers_existing_new_document_state_without_merge(tmp_path, monkeypatch):
1201
_isolate_office_cleanup_hook(monkeypatch, tmp_path)
1202
legacy_documents = tmp_path / "legacy-documents"
1176
- document_state = tmp_path / "usr" / "_office" / "documents"
1203
+ document_state = tmp_path / "usr" / "plugins" / "_office" / "documents"
1204
legacy_documents.mkdir(parents=True)
1205
document_state.mkdir(parents=True)
1206
(legacy_documents / "documents.sqlite3").write_text("legacy-db\n", encoding="utf-8")
@@ -1181,7 +1208,7 @@ def test_cleanup_hook_prefers_existing_new_document_state_without_merge(tmp_path
1208
1209
monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", document_state)
1210
monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [legacy_documents])
1184
- monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, warnings, errors: None)
1211
+ monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, migrated, warnings, errors: None)
1212
1213
result = hooks.cleanup_stale_runtime_state(force=True)
1214
@@ -1201,19 +1228,22 @@ def test_office_hook_desktop_compat_forwards_runtime_result(monkeypatch):
1228
lambda: {
1229
"installed": ["xpra-server"],
1230
"removed": ["firefox-esr"],
1231
+ "migrated": ["desktop state"],
1232
"warnings": ["desktop warning"],
1233
"errors": ["desktop error"],
1234
},
1235
)
1236
installed = []
1237
removed = []
1238
+ migrated = []
1239
warnings = []
1240
errors = []
1241
1213
- hooks._ensure_desktop_runtime_compat(installed, removed, warnings, errors)
1242
+ hooks._ensure_desktop_runtime_compat(installed, removed, migrated, warnings, errors)
1243
1244
assert installed == ["xpra-server"]
1245
assert removed == ["firefox-esr"]
1246
+ assert migrated == ["desktop state"]
1247
assert warnings == ["desktop warning"]
1248
assert errors == ["desktop error"]
1249
@@ -1266,14 +1296,15 @@ def test_installed_retired_web_packages_discovers_collabora_split_packages(monke
1296
1297
def test_cleanup_hook_delegates_desktop_runtime_for_legacy_self_update(tmp_path, monkeypatch):
1298
_isolate_office_cleanup_hook(monkeypatch, tmp_path)
1269
- monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "_office" / "documents")
1299
+ monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "plugins" / "_office" / "documents")
1300
monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [])
1301
calls = []
1302
1273
- def fake_desktop_compat(installed, removed, warnings, errors):
1303
+ def fake_desktop_compat(installed, removed, migrated, warnings, errors):
1304
calls.append("desktop")
1305
installed.append("xpra-server")
1306
removed.append("firefox-esr")
1307
+ migrated.append("desktop state migrated")
1308
warnings.append("desktop runtime prepared through office compatibility hook")
1309
1310
monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", fake_desktop_compat)
@@ -1283,6 +1314,7 @@ def test_cleanup_hook_delegates_desktop_runtime_for_legacy_self_update(tmp_path,
1314
assert calls == ["desktop"]
1315
assert result["installed"] == ["xpra-server"]
1316
assert result["removed"] == ["firefox-esr"]
1317
+ assert result["migrated"] == ["desktop state migrated"]
1318
assert result["warnings"] == ["desktop runtime prepared through office compatibility hook"]
1319
1320
@@ -1310,12 +1342,12 @@ def test_cleanup_hook_removes_stale_runtime_state_idempotently(tmp_path, monkeyp
1342
monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", supervisor)
1343
monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", [runtime_dir, legacy_cool_dir, legacy_collabora_dir])
1344
monkeypatch.setattr(hooks, "CLEANUP_MARKER", marker)
1313
- monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "_office" / "documents")
1345
+ monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "plugins" / "_office" / "documents")
1346
monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [])
1347
monkeypatch.setattr(hooks, "_installed_retired_web_packages", lambda: [])
1348
monkeypatch.setattr(hooks, "_installed_packages", lambda packages: [])
1349
monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None)
1318
- monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, warnings, errors: None)
1350
+ monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, migrated, warnings, errors: None)
1351
1352
def fake_ensure(installed, errors):
1353
assert not source.exists()
@@ -1397,7 +1429,7 @@ def test_cleanup_hook_reruns_when_stale_packages_exist_after_old_marker(tmp_path
1429
monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", tmp_path / "missing.conf")
1430
monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", [])
1431
monkeypatch.setattr(hooks, "CLEANUP_MARKER", marker)
1400
- monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "_office" / "documents")
1432
+ monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "plugins" / "_office" / "documents")
1433
monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [])
1434
retired_web_packages = [
1435
"coolwsd",
@@ -1409,7 +1441,7 @@ def test_cleanup_hook_reruns_when_stale_packages_exist_after_old_marker(tmp_path
1441
monkeypatch.setattr(hooks, "_installed_packages", lambda packages: [])
1442
monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", lambda installed, errors: None)
1443
monkeypatch.setattr(hooks, "_kill_old_processes", lambda errors: None)
1412
- monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, warnings, errors: None)
1444
+ monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, migrated, warnings, errors: None)
1445
1446
def fake_purge(removed, errors, **kwargs):
1447
removed.extend(kwargs["installed_packages"])
@@ -1433,12 +1465,12 @@ def test_cleanup_hook_removes_retired_supervisor_program_after_marker(tmp_path,
1465
monkeypatch.setattr(hooks, "RETIRED_WEB_SUPERVISOR_FILE", tmp_path / "missing.conf")
1466
monkeypatch.setattr(hooks, "RETIRED_WEB_RUNTIME_DIRS", [])
1467
monkeypatch.setattr(hooks, "CLEANUP_MARKER", marker)
1436
- monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "_office" / "documents")
1468
+ monkeypatch.setattr(hooks, "DOCUMENT_STATE_DIR", tmp_path / "usr" / "plugins" / "_office" / "documents")
1469
monkeypatch.setattr(hooks, "LEGACY_DOCUMENT_STATE_DIRS", [])
1470
monkeypatch.setattr(hooks, "_installed_retired_web_packages", lambda: [])
1471
monkeypatch.setattr(hooks, "_installed_packages", lambda packages: [])
1472
monkeypatch.setattr(hooks, "_ensure_runtime_dependencies", lambda installed, errors: None)
1441
- monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, warnings, errors: None)
1473
+ monkeypatch.setattr(hooks, "_ensure_desktop_runtime_compat", lambda installed, removed, migrated, warnings, errors: None)
1474
monkeypatch.setattr(hooks.shutil, "which", lambda name: "/usr/bin/supervisorctl" if name == "supervisorctl" else "")
1475
1476
def fake_supervisorctl(*args):
@@ -1515,6 +1547,32 @@ def test_desktop_runtime_packages_include_libreoffice_for_desktop_status():
1547
assert "libreoffice-impress" in desktop_hooks.RUNTIME_PACKAGES
1548
1549
1550
+def test_desktop_cleanup_moves_retired_state_to_plugin_state(tmp_path, monkeypatch):
1551
+ retired_state = tmp_path / "usr" / "_desktop"
1552
+ plugin_state = tmp_path / "usr" / "plugins" / "_desktop"
1553
+ (retired_state / "profiles" / "agent-zero-desktop").mkdir(parents=True)
1554
+ (retired_state / "profiles" / "agent-zero-desktop" / "profile.txt").write_text("profile\n", encoding="utf-8")
1555
+ (retired_state / "sessions").mkdir()
1556
+ (retired_state / "sessions" / "agent-zero-desktop.json").write_text("{}\n", encoding="utf-8")
1557
+ (retired_state / "screenshots").mkdir()
1558
+ (retired_state / "screenshots" / "desktop.png").write_bytes(b"png")
1559
+ plugin_state.mkdir(parents=True)
1560
+
1561
+ monkeypatch.setattr(desktop_hooks, "STATE_DIR", plugin_state)
1562
+ monkeypatch.setattr(desktop_hooks, "RETIRED_STATE_DIR", retired_state)
1563
+ monkeypatch.setattr(desktop_hooks, "_installed_packages", lambda packages: [])
1564
+ monkeypatch.setattr(desktop_hooks, "_ensure_runtime_dependencies", lambda installed, errors: None)
1565
+ monkeypatch.setattr(desktop_hooks, "_cleanup_desktop_sessions", lambda errors: None)
1566
+
1567
+ result = desktop_hooks.cleanup_stale_runtime_state(force=True)
1568
+
1569
+ assert result["ok"] is True
1570
+ assert (plugin_state / "profiles" / "agent-zero-desktop" / "profile.txt").read_text(encoding="utf-8") == "profile\n"
1571
+ assert (plugin_state / "sessions" / "agent-zero-desktop.json").read_text(encoding="utf-8") == "{}\n"
1572
+ assert (plugin_state / "screenshots" / "default" / "desktop.png").read_bytes() == b"png"
1573
+ assert not retired_state.exists()
1574
+
1575
+
1576
def test_cleanup_hook_installs_missing_desktop_session_dependencies(monkeypatch):
1577
calls = []
1578
installed_state = {"xpra": False}