Leon4gr45 commited on
Commit
0e9afe0
·
verified ·
1 Parent(s): f680f3e

Upload folder using huggingface_hub (part 5)

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. plugins/_context_window/extensions/python/message_loop_prompts_after/_99_capture_context_usage.py +8 -0
  2. plugins/_context_window/extensions/webui/apply_snapshot_before/refresh-context-window.js +42 -0
  3. plugins/_context_window/extensions/webui/interface-controls-end/context-window.html +40 -0
  4. plugins/_context_window/extensions/webui/model-context-strip-end/context-window.html +241 -0
  5. plugins/_context_window/helpers/__init__.py +1 -0
  6. plugins/_context_window/helpers/usage.py +278 -0
  7. plugins/_context_window/plugin.yaml +8 -0
  8. plugins/_context_window/tests/__init__.py +1 -0
  9. plugins/_context_window/tests/test_context_window.py +434 -0
  10. plugins/_context_window/webui/context-window-store.js +160 -0
  11. plugins/_context_window/webui/thumbnail.webp +0 -0
  12. plugins/_desktop/AGENTS.md +33 -0
  13. plugins/_desktop/api/desktop_session.py +160 -0
  14. plugins/_desktop/assets/desktop/README.md +142 -0
  15. plugins/_desktop/extensions/python/message_loop_prompts_after/_55_include_desktop_state.py +19 -0
  16. plugins/_desktop/extensions/python/startup_migration/_20_desktop_routes.py +49 -0
  17. plugins/_desktop/extensions/webui/right-canvas-panels/desktop-panel.html +15 -0
  18. plugins/_desktop/extensions/webui/right-canvas-toolbar-start/desktop-new-menu.html +38 -0
  19. plugins/_desktop/extensions/webui/right_canvas_register_surfaces/register-desktop.js +49 -0
  20. plugins/_desktop/extensions/webui/surfaces_register/register-desktop.js +49 -0
  21. plugins/_desktop/helpers/desktop_routes.py +6 -0
  22. plugins/_desktop/helpers/desktop_session.py +2601 -0
  23. plugins/_desktop/helpers/desktop_state.py +893 -0
  24. plugins/_desktop/helpers/prompt_context.py +18 -0
  25. plugins/_desktop/hooks.py +456 -0
  26. plugins/_desktop/plugin.yaml +5 -0
  27. plugins/_desktop/prompts/agent.extras.desktop_state.md +1 -0
  28. plugins/_desktop/skills/linux-desktop/SKILL.md +202 -0
  29. plugins/_desktop/skills/linux-desktop/scripts/calc_set_cell.py +315 -0
  30. plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh +459 -0
  31. plugins/_desktop/webui/desktop-panel.html +734 -0
  32. plugins/_desktop/webui/desktop-store.js +0 -0
  33. plugins/_desktop/webui/main.html +21 -0
  34. plugins/_desktop/webui/thumbnail.jpg +0 -0
  35. plugins/_discovery/AGENTS.md +31 -0
  36. plugins/_discovery/extensions/python/banners/10_discovery_cards.py +154 -0
  37. plugins/_discovery/extensions/webui/initFw_end/auto-modal.js +136 -0
  38. plugins/_discovery/extensions/webui/onboarding-success-end/discovery-cards.html +597 -0
  39. plugins/_discovery/extensions/webui/welcome-actions-end/discovery-cards.html +557 -0
  40. plugins/_discovery/plugin.yaml +8 -0
  41. plugins/_discovery/webui/assets/cli-connector-preview.png +0 -0
  42. plugins/_discovery/webui/assets/hero-openai-oauth.png +0 -0
  43. plugins/_discovery/webui/assets/hero-plugin-hub.png +0 -0
  44. plugins/_discovery/webui/assets/thumb-email.png +0 -0
  45. plugins/_discovery/webui/assets/thumb-telegram.png +0 -0
  46. plugins/_discovery/webui/assets/thumb-whatsapp.png +0 -0
  47. plugins/_discovery/webui/discovery-store.js +190 -0
  48. plugins/_discovery/webui/thumbnail.jpg +0 -0
  49. plugins/_document_query/AGENTS.md +31 -0
  50. plugins/_document_query/README.md +62 -0
plugins/_context_window/extensions/python/message_loop_prompts_after/_99_capture_context_usage.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from agent import LoopData
2
+ from helpers.extension import Extension
3
+ from plugins._context_window.helpers.usage import capture_context
4
+
5
+
6
+ class CaptureContextUsage(Extension):
7
+ def execute(self, loop_data: LoopData | None = None, **kwargs):
8
+ capture_context(self.agent, loop_data)
plugins/_context_window/extensions/webui/apply_snapshot_before/refresh-context-window.js ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { store as contextWindowStore } from "/plugins/_context_window/webui/context-window-store.js";
2
+
3
+ const OVERRIDE_REVISION_KEY = "_model_config_override_revision";
4
+ let lastContextId = "";
5
+ let lastRevision = null;
6
+ let lastGenerationKey = "";
7
+
8
+ function latestGenerationKey(logs) {
9
+ if (!Array.isArray(logs)) return "";
10
+ for (let index = logs.length - 1; index >= 0; index--) {
11
+ const item = logs[index];
12
+ if (item?.type !== "agent" || Number(item.agentno || 0) !== 0) continue;
13
+ return `${item.no ?? ""}:${item.id ?? ""}`;
14
+ }
15
+ return "";
16
+ }
17
+
18
+ export default async function refreshContextWindow(ctx) {
19
+ const snapshot = ctx?.snapshot;
20
+ const contextId = String(snapshot?.context || "");
21
+ if (!contextId) {
22
+ lastContextId = "";
23
+ lastRevision = null;
24
+ lastGenerationKey = "";
25
+ return;
26
+ }
27
+
28
+ const contexts = Array.isArray(snapshot?.contexts) ? snapshot.contexts : [];
29
+ const active = contexts.find(item => item?.id === contextId) || null;
30
+ const revision = active?.[OVERRIDE_REVISION_KEY] || null;
31
+ const generationKey = latestGenerationKey(snapshot?.logs);
32
+ if (
33
+ contextId === lastContextId
34
+ && revision === lastRevision
35
+ && (!generationKey || generationKey === lastGenerationKey)
36
+ ) return;
37
+
38
+ lastContextId = contextId;
39
+ lastRevision = revision;
40
+ if (generationKey) lastGenerationKey = generationKey;
41
+ await contextWindowStore.refresh(contextId);
42
+ }
plugins/_context_window/extensions/webui/interface-controls-end/context-window.html ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script type="module">
2
+ import { store } from "/plugins/_context_window/webui/context-window-store.js";
3
+ </script>
4
+
5
+ <div x-data>
6
+ <template x-if="$store.settings.uiVisibility">
7
+ <div class="ui-visibility-row">
8
+ <div class="ui-visibility-control">
9
+ <x-icon aria-hidden="true" name="donut_large"></x-icon>
10
+ <div>
11
+ <div class="field-title">Context window</div>
12
+ <div class="ui-visibility-state" x-text="$store.settings.uiControlVisibilityLabel('contextWindowUsage')"></div>
13
+ </div>
14
+ </div>
15
+
16
+ <div class="ui-device-selector" aria-label="Context window visibility">
17
+ <button type="button"
18
+ class="ui-device-button"
19
+ :class="{ 'is-visible': $store.settings.isUiControlVisible('contextWindowUsage', 'mobile') }"
20
+ aria-label="Show context window on mobile"
21
+ :aria-pressed="$store.settings.isUiControlVisible('contextWindowUsage', 'mobile').toString()"
22
+ :title="$store.settings.isUiControlVisible('contextWindowUsage', 'mobile') ? 'Shown on mobile' : 'Hidden on mobile'"
23
+ @click="$store.settings.toggleUiControl('contextWindowUsage', 'mobile')">
24
+ <x-icon aria-hidden="true" name="smartphone"></x-icon>
25
+ <span>Mobile</span>
26
+ </button>
27
+ <button type="button"
28
+ class="ui-device-button"
29
+ :class="{ 'is-visible': $store.settings.isUiControlVisible('contextWindowUsage', 'desktop') }"
30
+ aria-label="Show context window on desktop"
31
+ :aria-pressed="$store.settings.isUiControlVisible('contextWindowUsage', 'desktop').toString()"
32
+ :title="$store.settings.isUiControlVisible('contextWindowUsage', 'desktop') ? 'Shown on desktop' : 'Hidden on desktop'"
33
+ @click="$store.settings.toggleUiControl('contextWindowUsage', 'desktop')">
34
+ <x-icon aria-hidden="true" name="desktop_windows"></x-icon>
35
+ <span>Desktop</span>
36
+ </button>
37
+ </div>
38
+ </div>
39
+ </template>
40
+ </div>
plugins/_context_window/extensions/webui/model-context-strip-end/context-window.html ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script type="module">
2
+ import { store } from "/plugins/_context_window/webui/context-window-store.js";
3
+ </script>
4
+
5
+ <div x-data>
6
+ <template x-if="$store.contextWindow && $store.preferences.isUiControlVisible('contextWindowUsage')">
7
+ <div class="context-window-anchor"
8
+ x-create="$store.contextWindow.onMount($watch)"
9
+ x-destroy="$store.contextWindow.cleanup()"
10
+ @click.outside="$store.contextWindow.open = false"
11
+ @keydown.escape.window="$store.contextWindow.open = false">
12
+ <button type="button"
13
+ class="context-window-button"
14
+ :aria-expanded="$store.contextWindow.open"
15
+ :aria-label="$store.contextWindow.usage.ariaLabel"
16
+ @click="$store.contextWindow.toggle()">
17
+ <svg viewBox="0 0 36 36" aria-hidden="true">
18
+ <circle class="context-window-ring-track" cx="18" cy="18" r="15.5"></circle>
19
+ <circle class="context-window-ring-value" cx="18" cy="18" r="15.5" pathLength="100"
20
+ :stroke-dasharray="$store.contextWindow.usage.ringDasharray"></circle>
21
+ </svg>
22
+ <span x-text="$store.contextWindow.usage.ringLabel"></span>
23
+ </button>
24
+
25
+ <div class="context-window-popover"
26
+ x-show="$store.contextWindow.open"
27
+ x-transition.opacity
28
+ style="display: none;"
29
+ role="dialog"
30
+ aria-label="Context window usage">
31
+ <div class="context-window-header">
32
+ <strong>Context window</strong>
33
+ <div class="context-window-summary">
34
+ <span class="context-window-summary-tokens" x-text="$store.contextWindow.usage.summaryTokens"></span>
35
+ <span class="context-window-summary-percent" x-text="$store.contextWindow.usage.summaryPercent"></span>
36
+ </div>
37
+ </div>
38
+ <div class="context-window-meter" aria-hidden="true">
39
+ <span :style="$store.contextWindow.usage.meterStyle"></span>
40
+ </div>
41
+ <template x-if="$store.contextWindow.usage.hasBreakdown">
42
+ <div class="context-window-rows">
43
+ <template x-for="row in $store.contextWindow.usage.rows" :key="row.key">
44
+ <div class="context-window-row">
45
+ <span x-text="row.label"></span>
46
+ <span class="context-window-value context-window-tokens" x-text="row.tokensLabel"></span>
47
+ <span class="context-window-value" x-text="row.percentLabel"></span>
48
+ </div>
49
+ </template>
50
+ </div>
51
+ </template>
52
+ <template x-if="$store.contextWindow.usage.missingBreakdown">
53
+ <div class="context-window-empty">Breakdown available after the next message.</div>
54
+ </template>
55
+ <template x-if="$store.contextWindow.usage.provider.hasData">
56
+ <div class="context-window-provider">
57
+ <template x-if="$store.contextWindow.usage.provider.price.hasData">
58
+ <div class="context-window-provider-row context-window-provider-price">
59
+ <span>Price</span>
60
+ <span class="context-window-value" x-text="$store.contextWindow.usage.provider.price.label"></span>
61
+ </div>
62
+ </template>
63
+ <template x-if="$store.contextWindow.usage.provider.cache.hasData">
64
+ <div class="context-window-provider-row">
65
+ <span>Cache hit</span>
66
+ <span class="context-window-value" x-text="$store.contextWindow.usage.provider.cache.label"></span>
67
+ </div>
68
+ </template>
69
+ <template x-if="$store.contextWindow.usage.provider.tokens">
70
+ <div class="context-window-provider-row">
71
+ <span>Tokens In/Out</span>
72
+ <span class="context-window-value" x-text="$store.contextWindow.usage.provider.tokens"></span>
73
+ </div>
74
+ </template>
75
+ </div>
76
+ </template>
77
+ </div>
78
+ </div>
79
+ </template>
80
+ </div>
81
+
82
+ <style>
83
+ .context-window-anchor {
84
+ position: static;
85
+ display: flex;
86
+ align-items: center;
87
+ }
88
+ .context-window-button {
89
+ position: relative;
90
+ display: grid;
91
+ place-items: center;
92
+ width: 1.8rem;
93
+ height: 1.8rem;
94
+ padding: 0;
95
+ border: 0;
96
+ border-radius: 50%;
97
+ background: transparent;
98
+ color: var(--color-text);
99
+ cursor: pointer;
100
+ opacity: .82;
101
+ }
102
+ .context-window-button:hover {
103
+ background: var(--color-background-hover);
104
+ opacity: 1;
105
+ }
106
+ .context-window-button svg {
107
+ position: absolute;
108
+ inset: 2px;
109
+ width: calc(100% - 4px);
110
+ height: calc(100% - 4px);
111
+ transform: rotate(-90deg);
112
+ fill: none;
113
+ stroke-width: 3;
114
+ }
115
+ .context-window-ring-track {
116
+ stroke: color-mix(in srgb, var(--color-text) 18%, transparent);
117
+ }
118
+ .context-window-ring-value {
119
+ stroke: var(--color-highlight);
120
+ stroke-linecap: round;
121
+ transition: stroke-dasharray .18s ease;
122
+ }
123
+ .context-window-button > span {
124
+ position: relative;
125
+ z-index: 1;
126
+ font-family: var(--font-family-main);
127
+ font-size: .42rem;
128
+ font-weight: 600;
129
+ letter-spacing: -.03em;
130
+ }
131
+ .context-window-popover {
132
+ position: absolute;
133
+ right: 0;
134
+ bottom: calc(100% + 6px);
135
+ z-index: 1050;
136
+ width: min(19rem, calc(100vw - 2rem));
137
+ padding: .9rem;
138
+ border: 1px solid var(--color-border);
139
+ border-radius: 12px;
140
+ background-color: color-mix(in srgb, var(--color-panel) 90%, black 10%);
141
+ box-shadow: 0 16px 38px rgba(0, 0, 0, .3);
142
+ }
143
+ .context-window-header,
144
+ .context-window-row,
145
+ .context-window-provider-row {
146
+ display: grid;
147
+ align-items: center;
148
+ }
149
+ .context-window-header {
150
+ gap: .4rem;
151
+ font-size: .86rem;
152
+ }
153
+ .context-window-summary,
154
+ .context-window-value {
155
+ font-family: var(--font-family-main);
156
+ font-variant-numeric: tabular-nums;
157
+ }
158
+ .context-window-summary {
159
+ display: flex;
160
+ align-items: baseline;
161
+ justify-content: space-between;
162
+ gap: .75rem;
163
+ color: color-mix(in srgb, var(--color-text) 72%, transparent);
164
+ font-size: .76rem;
165
+ }
166
+ .context-window-summary-percent {
167
+ color: var(--color-text);
168
+ }
169
+ .context-window-summary-tokens,
170
+ .context-window-tokens {
171
+ font-family: var(--font-family-code);
172
+ }
173
+ .context-window-provider {
174
+ margin-top: .7rem;
175
+ padding-top: .7rem;
176
+ border-top: 1px solid var(--color-border);
177
+ }
178
+ .context-window-provider-row {
179
+ grid-template-columns: minmax(0, 1fr) auto;
180
+ gap: .75rem;
181
+ min-height: 1.35rem;
182
+ color: color-mix(in srgb, var(--color-text) 72%, transparent);
183
+ font-size: .76rem;
184
+ }
185
+ .context-window-provider-price {
186
+ margin-bottom: .25rem;
187
+ color: var(--color-text);
188
+ font-size: .82rem;
189
+ }
190
+ .context-window-provider-price .context-window-value {
191
+ font-size: 1rem;
192
+ font-weight: 600;
193
+ }
194
+ .context-window-meter {
195
+ height: .45rem;
196
+ margin: .4rem 0 .75rem;
197
+ overflow: hidden;
198
+ border-radius: 999px;
199
+ background: color-mix(in srgb, var(--color-text) 9%, transparent);
200
+ }
201
+ .context-window-meter > span {
202
+ display: block;
203
+ height: 100%;
204
+ border-radius: inherit;
205
+ background: var(--color-highlight);
206
+ transition: width .18s ease;
207
+ }
208
+ .context-window-rows {
209
+ display: grid;
210
+ gap: .3rem;
211
+ }
212
+ .context-window-empty {
213
+ padding: .35rem 0 .15rem;
214
+ color: color-mix(in srgb, var(--color-text) 62%, transparent);
215
+ font-size: .8rem;
216
+ }
217
+ .context-window-row {
218
+ grid-template-columns: minmax(0, 1fr) auto 3rem;
219
+ gap: .5rem;
220
+ min-height: 1.5rem;
221
+ color: color-mix(in srgb, var(--color-text) 78%, transparent);
222
+ font-size: .8rem;
223
+ }
224
+ .context-window-value {
225
+ color: var(--color-text);
226
+ text-align: right;
227
+ }
228
+ .context-window-tokens {
229
+ color: color-mix(in srgb, var(--color-text) 62%, transparent);
230
+ }
231
+ @media (max-width: 25rem) {
232
+ .context-window-popover {
233
+ right: 1.25rem;
234
+ width: min(17rem, calc(100vw - 3rem));
235
+ }
236
+ }
237
+ .light-mode .context-window-popover {
238
+ background: var(--color-panel);
239
+ box-shadow: 0 4px 14px rgba(0, 0, 0, .12);
240
+ }
241
+ </style>
plugins/_context_window/helpers/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
plugins/_context_window/helpers/usage.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import math
3
+ from typing import Any
4
+
5
+ from helpers import files, history, skills, tokens
6
+ from helpers.llm_result import result_from_metadata
7
+
8
+
9
+ PARTS_KEY = "context_window_usage"
10
+ CACHE_KEY = "_context_window_usage_cache"
11
+ PROVIDER_USAGE_KEY = "context_window_provider_usage"
12
+ USAGE_KEYS = (
13
+ "messages",
14
+ "system_tools",
15
+ "skills",
16
+ "mcp_tools",
17
+ "system_prompt",
18
+ "extras",
19
+ )
20
+ MEASURED_KEYS = tuple(key for key in USAGE_KEYS if key != "system_prompt")
21
+
22
+
23
+ def reset(agent: Any) -> None:
24
+ params = _temporary_params(agent)
25
+ if params is not None:
26
+ params[PARTS_KEY] = {}
27
+
28
+
29
+ def discard(agent: Any) -> None:
30
+ params = _temporary_params(agent)
31
+ if params is not None:
32
+ params.pop(PARTS_KEY, None)
33
+
34
+
35
+ def record_prompt(agent: Any, key: str, prompt: Any) -> None:
36
+ parts = _parts(agent)
37
+ if parts is None or key not in MEASURED_KEYS:
38
+ return
39
+ text = files.remove_code_fences(str(prompt or ""), language="json")
40
+ parts[key] = _cached_tokens(agent, f"prompt:{key}", text)
41
+
42
+
43
+ def capture_context(agent: Any, loop_data: Any) -> None:
44
+ parts = _parts(agent)
45
+ if parts is None or loop_data is None:
46
+ return
47
+
48
+ output = list(getattr(loop_data, "history_output", None) or [])
49
+ parts["_history_output"] = output
50
+ skill_output = [message for message in output if skills.skill_instruction_name(message)]
51
+ skill_tokens = _output_tokens(agent, "history_skills", skill_output)
52
+ parts["messages"] = max(_history_tokens(agent, output) - skill_tokens, 0)
53
+ parts["skills"] = parts.get("skills", 0) + skill_tokens
54
+
55
+ protocol_values = {
56
+ **getattr(loop_data, "protocol_persistent", {}),
57
+ **getattr(loop_data, "protocol_temporary", {}),
58
+ }
59
+ extras_values = {
60
+ **getattr(loop_data, "extras_persistent", {}),
61
+ **getattr(loop_data, "extras_temporary", {}),
62
+ }
63
+ protocol = agent._build_context_message(
64
+ "agent.context.protocol.md",
65
+ "protocol",
66
+ protocol_values,
67
+ include_empty=False,
68
+ )
69
+ extras = agent._build_context_message(
70
+ "agent.context.extras.md",
71
+ "extras",
72
+ extras_values,
73
+ include_empty=True,
74
+ )
75
+ parts["extras"] = _output_tokens(agent, "extras", protocol + extras)
76
+
77
+
78
+ def finalize(agent: Any) -> None:
79
+ params = _temporary_params(agent)
80
+ parts = params.pop(PARTS_KEY, None) if params is not None else None
81
+ window = agent.get_data(agent.DATA_NAME_CTX_WINDOW) if agent else None
82
+ if not isinstance(parts, dict) or not isinstance(window, dict):
83
+ return
84
+
85
+ history_output = parts.pop("_history_output", None)
86
+ total = _non_negative_int(window.get("tokens"))
87
+ usage = {key: _non_negative_int(parts.get(key)) for key in MEASURED_KEYS}
88
+ measured_total = sum(usage.values())
89
+ if total and measured_total >= total and isinstance(history_output, list):
90
+ message_output = [
91
+ message
92
+ for message in history_output
93
+ if not skills.skill_instruction_name(message)
94
+ ]
95
+ usage["messages"] = _output_tokens(
96
+ agent, "history_messages", message_output
97
+ )
98
+ measured_total = sum(usage.values())
99
+ if measured_total > total and measured_total:
100
+ usage = _scale_to_total(usage, total, measured_total)
101
+ measured_total = total
102
+ usage["system_prompt"] = total - measured_total
103
+ usage = {key: usage.get(key, 0) for key in USAGE_KEYS}
104
+
105
+ updated = dict(window)
106
+ updated["usage"] = usage
107
+ agent.set_data(agent.DATA_NAME_CTX_WINDOW, updated)
108
+
109
+
110
+ def usage_snapshot(value: Any) -> dict[str, int]:
111
+ if not isinstance(value, dict):
112
+ return {}
113
+ return {key: _non_negative_int(value.get(key)) for key in USAGE_KEYS}
114
+
115
+
116
+ def capture_provider_usage(agent: Any, result: Any) -> None:
117
+ if agent is None or result is None or not hasattr(result, "usage"):
118
+ return
119
+
120
+ snapshot = provider_usage_snapshot(getattr(result, "usage", None))
121
+ agent.set_data(
122
+ PROVIDER_USAGE_KEY,
123
+ snapshot if snapshot else {"available": False},
124
+ )
125
+
126
+
127
+ def latest_provider_usage(agent: Any) -> dict[str, int | float]:
128
+ data = getattr(agent, "data", None)
129
+ if isinstance(data, dict) and PROVIDER_USAGE_KEY in data:
130
+ stored = data.get(PROVIDER_USAGE_KEY)
131
+ if isinstance(stored, dict) and stored.get("available") is False:
132
+ return {}
133
+ return provider_usage_snapshot(stored)
134
+
135
+ all_messages = getattr(getattr(agent, "history", None), "all_messages", None)
136
+ if not callable(all_messages):
137
+ return {}
138
+ for message in reversed(all_messages()):
139
+ if not getattr(message, "ai", False):
140
+ continue
141
+ result = result_from_metadata(getattr(message, "metadata", None))
142
+ if result:
143
+ return provider_usage_snapshot(result.usage)
144
+ return {}
145
+
146
+
147
+ def provider_usage_snapshot(value: Any) -> dict[str, int | float]:
148
+ if not isinstance(value, dict):
149
+ return {}
150
+
151
+ input_details = {
152
+ **_mapping(value.get("prompt_tokens_details")),
153
+ **_mapping(value.get("input_tokens_details")),
154
+ }
155
+ result: dict[str, int | float] = {}
156
+ fields = {
157
+ "input_tokens": (value.get("input_tokens"), value.get("prompt_tokens")),
158
+ "cached_tokens": (
159
+ input_details.get("cached_tokens"),
160
+ input_details.get("cache_read_tokens"),
161
+ value.get("cache_read_input_tokens"),
162
+ value.get("cached_tokens"),
163
+ ),
164
+ "output_tokens": (
165
+ value.get("output_tokens"),
166
+ value.get("completion_tokens"),
167
+ ),
168
+ }
169
+ for key, values in fields.items():
170
+ number = _optional_non_negative_int(*values)
171
+ if number is not None:
172
+ result[key] = number
173
+
174
+ cost = _optional_non_negative_float(
175
+ value.get("cost"), value.get("response_cost")
176
+ )
177
+ if cost is not None:
178
+ result["cost"] = cost
179
+ return result
180
+
181
+
182
+ def _parts(agent: Any) -> dict[str, Any] | None:
183
+ params = _temporary_params(agent)
184
+ value = params.get(PARTS_KEY) if params is not None else None
185
+ return value if isinstance(value, dict) else None
186
+
187
+
188
+ def _temporary_params(agent: Any) -> dict[str, Any] | None:
189
+ loop_data = getattr(agent, "loop_data", None)
190
+ params = getattr(loop_data, "params_temporary", None)
191
+ return params if isinstance(params, dict) else None
192
+
193
+
194
+ def _history_tokens(agent: Any, output: list[history.OutputMessage]) -> int:
195
+ get_tokens = getattr(getattr(agent, "history", None), "get_tokens", None)
196
+ if callable(get_tokens):
197
+ return _non_negative_int(get_tokens())
198
+ return _count_output_tokens(output)
199
+
200
+
201
+ def _output_tokens(
202
+ agent: Any, cache_key: str, output: list[history.OutputMessage]
203
+ ) -> int:
204
+ text = history.output_text(output, ai_label="assistant", human_label="user")
205
+ return _cached_tokens(agent, cache_key, text)
206
+
207
+
208
+ def _count_output_tokens(output: list[history.OutputMessage]) -> int:
209
+ text = history.output_text(output, ai_label="assistant", human_label="user")
210
+ return tokens.approximate_prompt_tokens(text)
211
+
212
+
213
+ def _cached_tokens(agent: Any, key: str, text: str) -> int:
214
+ cache = _cache(agent)
215
+ digest = hashlib.sha256(text.encode("utf-8")).hexdigest()
216
+ cached = cache.get(key) if cache is not None else None
217
+ if isinstance(cached, tuple) and len(cached) == 2 and cached[0] == digest:
218
+ return _non_negative_int(cached[1])
219
+
220
+ count = tokens.approximate_prompt_tokens(text)
221
+ if cache is not None:
222
+ cache[key] = (digest, count)
223
+ return count
224
+
225
+
226
+ def _cache(agent: Any) -> dict[str, tuple[str, int]] | None:
227
+ data = getattr(agent, "data", None)
228
+ if not isinstance(data, dict):
229
+ return None
230
+ cache = data.get(CACHE_KEY)
231
+ if not isinstance(cache, dict):
232
+ cache = {}
233
+ data[CACHE_KEY] = cache
234
+ return cache
235
+
236
+
237
+ def _non_negative_int(value: Any) -> int:
238
+ try:
239
+ return max(int(value or 0), 0)
240
+ except (TypeError, ValueError):
241
+ return 0
242
+
243
+
244
+ def _mapping(value: Any) -> dict[str, Any]:
245
+ return value if isinstance(value, dict) else {}
246
+
247
+
248
+ def _optional_non_negative_int(*values: Any) -> int | None:
249
+ for value in values:
250
+ if value is None:
251
+ continue
252
+ try:
253
+ return max(int(value), 0)
254
+ except (TypeError, ValueError):
255
+ continue
256
+ return None
257
+
258
+
259
+ def _optional_non_negative_float(*values: Any) -> float | None:
260
+ for value in values:
261
+ if value is None:
262
+ continue
263
+ try:
264
+ number = float(value)
265
+ except (TypeError, ValueError):
266
+ continue
267
+ if math.isfinite(number):
268
+ return max(number, 0)
269
+ return None
270
+
271
+
272
+ def _scale_to_total(values: dict[str, int], total: int, current: int) -> dict[str, int]:
273
+ scaled = {key: value * total // current for key, value in values.items()}
274
+ remainder = total - sum(scaled.values())
275
+ order = sorted(values, key=lambda key: values[key] * total % current, reverse=True)
276
+ for key in order[:remainder]:
277
+ scaled[key] += 1
278
+ return scaled
plugins/_context_window/plugin.yaml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ name: _context_window
2
+ title: Context Window
3
+ description: Shows token usage for the active chat context window.
4
+ version: 0.1.0
5
+ settings_sections: []
6
+ per_project_config: false
7
+ per_agent_config: false
8
+ always_enabled: true
plugins/_context_window/tests/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+
plugins/_context_window/tests/test_context_window.py ADDED
@@ -0,0 +1,434 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from pathlib import Path
3
+ from types import SimpleNamespace
4
+
5
+ import pytest
6
+ from langchain_core.messages import HumanMessage, SystemMessage
7
+
8
+
9
+ ROOT = Path(__file__).resolve().parents[3]
10
+ if str(ROOT) not in sys.path:
11
+ sys.path.insert(0, str(ROOT))
12
+
13
+ import models
14
+ from agent import Agent, LoopData
15
+ from helpers import extension, extract_tools, history, litellm_transport
16
+ from helpers.llm_result import LLMResult
17
+ from plugins._context_window.api.context_window import ContextWindow
18
+ from plugins._context_window.helpers import usage
19
+
20
+
21
+ class _Log:
22
+ def set_progress(self, _message: str) -> None:
23
+ pass
24
+
25
+
26
+ @pytest.mark.asyncio
27
+ async def test_usage_follows_prompt_sources_and_reconciles_to_total(monkeypatch):
28
+ agent = object.__new__(Agent)
29
+ loop_data = LoopData()
30
+ agent.loop_data = loop_data
31
+ agent.context = SimpleNamespace(log=_Log())
32
+ agent.history = history.History(agent)
33
+ agent.data = {}
34
+ agent.history.add_message(False, "User asks a question.")
35
+ agent.history.add_message(True, "Assistant answers.")
36
+ agent.history.add_message(
37
+ False,
38
+ {
39
+ "tool_name": "skills_tool",
40
+ "tool_result": "Skill instructions without a special heading.",
41
+ "skill_instructions": {
42
+ "name": "test-skill",
43
+ "content_included": True,
44
+ },
45
+ },
46
+ )
47
+
48
+ system_parts = {
49
+ "system_prompt": "Main instructions without a special heading.",
50
+ "system_tools": "Tool definitions without a special heading.",
51
+ "mcp_tools": "Remote definitions without a special heading.",
52
+ "skills": "Available skill names without a special heading.",
53
+ }
54
+
55
+ async def get_system_prompt(_loop_data):
56
+ for key in ("system_tools", "mcp_tools", "skills"):
57
+ usage.record_prompt(agent, key, system_parts[key])
58
+ return list(system_parts.values())
59
+
60
+ def read_prompt(prompt_file: str, **kwargs) -> str:
61
+ if prompt_file == "agent.context.protocol.md":
62
+ return "[PROTOCOL]\n" + kwargs["protocol"]
63
+ if prompt_file == "agent.context.extras.md":
64
+ return "[EXTRAS]\n" + kwargs["extras"]
65
+ raise AssertionError(f"Unexpected prompt: {prompt_file}")
66
+
67
+ async def call_extensions(extension_point: str, agent=None, **kwargs):
68
+ if extension_point == "message_loop_prompts_after":
69
+ current = kwargs["loop_data"]
70
+ current.protocol_persistent["project"] = "Project instructions."
71
+ current.extras_temporary["time"] = "Current time."
72
+ usage.capture_context(agent, current)
73
+
74
+ agent.get_system_prompt = get_system_prompt
75
+ agent.read_prompt = read_prompt
76
+ monkeypatch.setattr(extension, "call_extensions_async", call_extensions)
77
+ monkeypatch.setattr(history.History, "_get_max_embeds", lambda self: 0)
78
+
79
+ usage.reset(agent)
80
+ await Agent.prepare_prompt.__wrapped__(agent, loop_data)
81
+ usage.finalize(agent)
82
+
83
+ window = agent.get_data(Agent.DATA_NAME_CTX_WINDOW)
84
+ breakdown = window["usage"]
85
+ assert tuple(breakdown) == usage.USAGE_KEYS
86
+ assert sum(breakdown.values()) == window["tokens"]
87
+ assert all(breakdown[key] > 0 for key in usage.USAGE_KEYS)
88
+ assert usage.PARTS_KEY not in loop_data.params_temporary
89
+ assert "history_messages" not in agent.data[usage.CACHE_KEY]
90
+
91
+
92
+ @pytest.mark.asyncio
93
+ async def test_api_returns_only_counts_and_effective_limit(monkeypatch):
94
+ agent = SimpleNamespace(
95
+ DATA_NAME_CTX_WINDOW="ctx_window",
96
+ get_data=lambda _key: {
97
+ "text": "private prompt",
98
+ "tokens": 120,
99
+ "usage": {"messages": 42},
100
+ },
101
+ )
102
+ handler = object.__new__(ContextWindow)
103
+ handler.use_context = lambda _context_id: SimpleNamespace(
104
+ streaming_agent=None,
105
+ agent0=agent,
106
+ )
107
+ monkeypatch.setattr(
108
+ "plugins._context_window.api.context_window.get_chat_model_config",
109
+ lambda _agent: {"ctx_length": 128_000},
110
+ )
111
+
112
+ result = await handler.process({"context": "ctx-1"}, SimpleNamespace())
113
+
114
+ assert result == {
115
+ "tokens": 120,
116
+ "context_window": 128_000,
117
+ "usage": {
118
+ "messages": 42,
119
+ "system_tools": 0,
120
+ "skills": 0,
121
+ "mcp_tools": 0,
122
+ "system_prompt": 0,
123
+ "extras": 0,
124
+ },
125
+ "provider_usage": {},
126
+ }
127
+ assert "text" not in result
128
+
129
+
130
+ def test_webui_and_accounting_are_plugin_owned():
131
+ model_switcher = (
132
+ ROOT
133
+ / "plugins/_model_config/extensions/webui/chat-input-progress-start/model-switcher.html"
134
+ ).read_text(encoding="utf-8")
135
+ model_store = (ROOT / "plugins/_model_config/webui/switcher-mixin.js").read_text(
136
+ encoding="utf-8"
137
+ )
138
+ component = (
139
+ ROOT
140
+ / "plugins/_context_window/extensions/webui/model-context-strip-end/context-window.html"
141
+ ).read_text(encoding="utf-8")
142
+ context_store = (
143
+ ROOT / "plugins/_context_window/webui/context-window-store.js"
144
+ ).read_text(encoding="utf-8")
145
+ helper = (ROOT / "plugins/_context_window/helpers/usage.py").read_text(
146
+ encoding="utf-8"
147
+ )
148
+ refresh_hook = (
149
+ ROOT
150
+ / "plugins/_context_window/extensions/webui/apply_snapshot_before/refresh-context-window.js"
151
+ ).read_text(encoding="utf-8")
152
+
153
+ assert 'id="model-context-strip-end"' in model_switcher
154
+ assert "contextWindowUsage" not in model_switcher
155
+ assert "contextUsage" not in model_store
156
+ assert "Context window" in component
157
+ assert "position: static" in component
158
+ assert "width: min(19rem, calc(100vw - 2rem))" in component
159
+ assert "right: 1.25rem" in component
160
+ assert "width: min(17rem, calc(100vw - 3rem))" in component
161
+ assert 'label: "Free space"' in context_store
162
+ assert "Last model call" not in component
163
+ assert ">Price<" in component
164
+ assert ">Cache hit<" in component
165
+ assert ">Tokens In/Out<" in component
166
+ assert "context-window-cache-meter" not in component
167
+ assert "price: {" in context_store
168
+ assert "hasData: cost !== null" in context_store
169
+ assert 'label: cost === null ? "" : formatCost(cost)' in context_store
170
+ assert "usage.provider.price.hasData" in component
171
+ assert "usage.provider.price.label" in component
172
+ assert 'value < 0.001 ? "<$0.001"' in context_store
173
+ assert "maximumSignificantDigits: 3" in context_store
174
+ assert "border-top: 1px solid var(--color-border)" in component
175
+ assert " → " in context_store
176
+ assert "summaryTokens" in context_store
177
+ assert 'summaryPercent: `${percentLabel} used`' in context_store
178
+ assert "formatTokens(output)} tok" not in context_store
179
+ assert "context-window-summary-tokens" in component
180
+ assert "context-window-summary-percent" in component
181
+ assert "font-family: var(--font-family-main)" in component
182
+ assert "<details" not in component
183
+ assert "Reasoning" not in component
184
+ assert "Images sent" not in component
185
+ assert "provider did not split out their token cost" not in context_store
186
+ assert "cached / input" in context_store
187
+ assert "Math.round(cachePercent)" in context_store
188
+ assert "context-window-dot" not in component
189
+ assert "dotStyle" not in context_store
190
+ assert "Breakdown available after the next message." in component
191
+ assert "startswith(" not in helper
192
+ assert "rpartition(" not in helper
193
+ assert 'item?.type !== "agent"' in refresh_hook
194
+ assert "Number(item.agentno || 0) !== 0" in refresh_hook
195
+ assert "generationKey === lastGenerationKey" in refresh_hook
196
+
197
+
198
+ def test_source_prompt_extensions_are_registered():
199
+ expected = {
200
+ "_functions/agent/Agent/prepare_prompt/start": "ResetContextUsage",
201
+ "_functions/agent/Agent/prepare_prompt/end": "StoreContextUsage",
202
+ "_functions/agent/Agent/call_chat_model_turn/end": "RecordProviderUsage",
203
+ "_functions/models/LiteLLMChatWrapper/unified_turn/start": "DrainProviderUsage",
204
+ "_functions/models/LiteLLMChatWrapper/unified_turn/end": "RestoreProviderResponse",
205
+ "message_loop_prompts_after": "CaptureContextUsage",
206
+ }
207
+ for point, class_name in expected.items():
208
+ classes = extension._get_extension_classes(point) # type: ignore[attr-defined]
209
+ assert any(cls.__name__ == class_name for cls in classes)
210
+
211
+ system_prompt_classes = {
212
+ cls.__name__: cls
213
+ for cls in extension._get_extension_classes("system_prompt") # type: ignore[attr-defined]
214
+ }
215
+ for owner, recorder in {
216
+ "ToolsPrompt": "RecordSystemToolsUsage",
217
+ "MCPToolsPrompt": "RecordMcpToolsUsage",
218
+ "SkillsPrompt": "RecordSkillsUsage",
219
+ }.items():
220
+ builder = system_prompt_classes[owner].execute.__globals__["build_prompt"]
221
+ module = builder.__wrapped__.__module__.replace(".", "/")
222
+ point = f"_functions/{module}/build_prompt/end"
223
+ classes = extension._get_extension_classes(point) # type: ignore[attr-defined]
224
+ assert any(cls.__name__ == recorder for cls in classes)
225
+
226
+
227
+ @pytest.mark.asyncio
228
+ async def test_chat_stream_drains_terminal_provider_usage(monkeypatch):
229
+ response = '{"tool_name":"response","tool_args":{"text":"done"}}'
230
+ chunks = [
231
+ {"choices": [{"delta": {"content": response}, "message": {}}]},
232
+ {"choices": [{"delta": {"content": " ignored"}, "message": {}}]},
233
+ {
234
+ "choices": [],
235
+ "usage": {
236
+ "prompt_tokens": 12_000,
237
+ "prompt_tokens_details": {"cached_tokens": 9_000},
238
+ "completion_tokens": 80,
239
+ },
240
+ "_hidden_params": {"response_cost": 0.0042},
241
+ },
242
+ ]
243
+ consumed = []
244
+
245
+ async def stream():
246
+ for chunk in chunks:
247
+ consumed.append(chunk)
248
+ yield chunk
249
+
250
+ async def fake_acompletion(*args, **kwargs):
251
+ assert kwargs["stream_options"] == {"include_usage": True}
252
+ return stream()
253
+
254
+ async def fake_rate_limiter(*args, **kwargs):
255
+ return None
256
+
257
+ callback_calls = []
258
+
259
+ async def response_callback(chunk: str, full: str):
260
+ callback_calls.append((chunk, full))
261
+ return full if extract_tools.extract_tool_request(full) else None
262
+
263
+ monkeypatch.setattr(litellm_transport, "acompletion", fake_acompletion)
264
+ monkeypatch.setattr(models, "apply_rate_limiter", fake_rate_limiter)
265
+ wrapper = models.LiteLLMChatWrapper(
266
+ model="test-model",
267
+ provider="openrouter",
268
+ model_config=None,
269
+ api_base="https://openrouter.ai/api/v1",
270
+ )
271
+
272
+ result = await wrapper.unified_turn(
273
+ messages=[
274
+ SystemMessage(content="stable instructions"),
275
+ HumanMessage(content="question"),
276
+ ],
277
+ response_callback=response_callback,
278
+ explicit_caching=True,
279
+ )
280
+
281
+ assert consumed == chunks
282
+ assert callback_calls == [
283
+ (response, response),
284
+ (" ignored", response + " ignored"),
285
+ ]
286
+ assert result.response == response
287
+ assert result.output_items[0].type == "message"
288
+ assert result.usage == {
289
+ "prompt_tokens": 12_000,
290
+ "prompt_tokens_details": {"cached_tokens": 9_000},
291
+ "completion_tokens": 80,
292
+ "cost": 0.0042,
293
+ }
294
+
295
+
296
+ def test_prompt_fragment_cache_is_bounded_and_content_addressed(monkeypatch):
297
+ calls = []
298
+ agent = SimpleNamespace(data={}, loop_data=LoopData())
299
+ monkeypatch.setattr(
300
+ usage.tokens,
301
+ "approximate_prompt_tokens",
302
+ lambda text: calls.append(text) or len(text),
303
+ )
304
+
305
+ usage.reset(agent)
306
+ usage.record_prompt(agent, "system_tools", "same prompt")
307
+ usage.record_prompt(agent, "system_tools", "same prompt")
308
+ usage.record_prompt(agent, "system_tools", "changed prompt")
309
+
310
+ assert calls == ["same prompt", "changed prompt"]
311
+ cache = agent.data[usage.CACHE_KEY]
312
+ assert len(cache) == 1
313
+ assert cache["prompt:system_tools"][1] == len("changed prompt")
314
+ assert all(len(value[0]) == 64 for value in cache.values())
315
+
316
+
317
+ def test_history_ledger_changes_without_invalidating_fragment_cache(monkeypatch):
318
+ calls = []
319
+ history_tokens = 1_000
320
+ agent = SimpleNamespace(
321
+ data={},
322
+ loop_data=LoopData(),
323
+ history=SimpleNamespace(get_tokens=lambda: history_tokens),
324
+ _build_context_message=lambda *args, **kwargs: [],
325
+ )
326
+ skill_message = {
327
+ "ai": False,
328
+ "content": {
329
+ "tool_name": "skills_tool",
330
+ "tool_result": "Loaded skill body.",
331
+ "skill_instructions": {
332
+ "name": "test-skill",
333
+ "content_included": True,
334
+ },
335
+ },
336
+ }
337
+ loop_data = SimpleNamespace(
338
+ history_output=[skill_message],
339
+ protocol_persistent={},
340
+ protocol_temporary={},
341
+ extras_persistent={},
342
+ extras_temporary={},
343
+ )
344
+ monkeypatch.setattr(
345
+ usage.tokens,
346
+ "approximate_prompt_tokens",
347
+ lambda text: calls.append(text) or len(text),
348
+ )
349
+
350
+ usage.reset(agent)
351
+ usage.record_prompt(agent, "system_tools", "stable tools")
352
+ usage.capture_context(agent, loop_data)
353
+ first = dict(agent.loop_data.params_temporary[usage.PARTS_KEY])
354
+
355
+ history_tokens = 400
356
+ agent.loop_data.params_temporary = {}
357
+ usage.reset(agent)
358
+ usage.record_prompt(agent, "system_tools", "stable tools")
359
+ usage.capture_context(agent, loop_data)
360
+ second = agent.loop_data.params_temporary[usage.PARTS_KEY]
361
+
362
+ assert first["messages"] == 1_000 - first["skills"]
363
+ assert second["messages"] == 400 - second["skills"]
364
+ assert calls.count("stable tools") == 1
365
+ assert len(agent.data[usage.CACHE_KEY]) == 3
366
+
367
+
368
+ def test_rendered_history_fallback_preserves_system_prompt_bucket(monkeypatch):
369
+ data = {}
370
+ output = [{"ai": False, "content": "short message"}]
371
+ agent = SimpleNamespace(
372
+ DATA_NAME_CTX_WINDOW="ctx_window",
373
+ data=data,
374
+ loop_data=LoopData(),
375
+ history=SimpleNamespace(get_tokens=lambda: 10_000),
376
+ _build_context_message=lambda *args, **kwargs: [],
377
+ get_data=lambda key: data.get(key),
378
+ set_data=lambda key, value: data.__setitem__(key, value),
379
+ )
380
+ loop_data = SimpleNamespace(
381
+ history_output=output,
382
+ protocol_persistent={},
383
+ protocol_temporary={},
384
+ extras_persistent={},
385
+ extras_temporary={},
386
+ )
387
+ monkeypatch.setattr(
388
+ usage.tokens,
389
+ "approximate_prompt_tokens",
390
+ lambda text: len(text),
391
+ )
392
+
393
+ usage.reset(agent)
394
+ usage.capture_context(agent, loop_data)
395
+ data[agent.DATA_NAME_CTX_WINDOW] = {"tokens": 100}
396
+ usage.finalize(agent)
397
+
398
+ breakdown = data[agent.DATA_NAME_CTX_WINDOW]["usage"]
399
+ assert breakdown["messages"] == len("user: short message")
400
+ assert breakdown["system_prompt"] > 0
401
+ assert sum(breakdown.values()) == 100
402
+
403
+
404
+ def test_provider_usage_is_optional():
405
+ data = {}
406
+ agent = SimpleNamespace(
407
+ data=data,
408
+ history=SimpleNamespace(all_messages=lambda: []),
409
+ set_data=lambda key, value: data.__setitem__(key, value),
410
+ )
411
+ result = LLMResult.from_chat(
412
+ response="done",
413
+ usage={
414
+ "prompt_tokens": 12_000,
415
+ "prompt_tokens_details": {"cached_tokens": 9_000},
416
+ "completion_tokens": 80,
417
+ "cost": 0.0123,
418
+ },
419
+ )
420
+
421
+ usage.capture_provider_usage(agent, result)
422
+
423
+ assert usage.latest_provider_usage(agent) == {
424
+ "input_tokens": 12_000,
425
+ "cached_tokens": 9_000,
426
+ "output_tokens": 80,
427
+ "cost": 0.0123,
428
+ }
429
+
430
+ usage.capture_provider_usage(agent, LLMResult.from_chat(response="no usage"))
431
+ assert usage.latest_provider_usage(agent) == {}
432
+ assert usage.provider_usage_snapshot(
433
+ {"input_tokens": 100, "cached_tokens": None, "cost": None}
434
+ ) == {"input_tokens": 100}
plugins/_context_window/webui/context-window-store.js ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createStore } from "/js/AlpineStore.js";
2
+ import { callJsonApi } from "/js/api.js";
3
+ import { store as chatsStore } from "/components/sidebar/chats/chats-store.js";
4
+ import { store as preferencesStore } from "/components/sidebar/bottom/preferences/preferences-store.js";
5
+
6
+ const API_PATH = "/plugins/_context_window/context_window";
7
+ const ROWS = [
8
+ { key: "messages", label: "Messages" },
9
+ { key: "system_tools", label: "System tools" },
10
+ { key: "skills", label: "Skills" },
11
+ { key: "mcp_tools", label: "MCP tools" },
12
+ { key: "system_prompt", label: "System prompt" },
13
+ { key: "extras", label: "Extras" },
14
+ ];
15
+ const COST_FORMATTER = new Intl.NumberFormat("en-US", {
16
+ style: "currency",
17
+ currency: "USD",
18
+ maximumSignificantDigits: 3,
19
+ });
20
+
21
+ preferencesStore.registerUiControlVisibility("contextWindowUsage", {
22
+ mobile: true,
23
+ desktop: true,
24
+ });
25
+
26
+ function formatTokens(value) {
27
+ const amount = Math.max(Number(value) || 0, 0);
28
+ for (const [size, suffix] of [[1_000_000, "M"], [1_000, "K"]]) {
29
+ if (amount >= size) return `${(amount / size).toFixed(1).replace(/\.0$/, "")}${suffix}`;
30
+ }
31
+ return String(Math.round(amount));
32
+ }
33
+
34
+ function formatPercent(value) {
35
+ const rounded = Math.round(Math.max(Number(value) || 0, 0) * 10) / 10;
36
+ return `${Number.isInteger(rounded) ? rounded.toFixed(0) : rounded.toFixed(1)}%`;
37
+ }
38
+
39
+ function optionalNumber(value, key) {
40
+ if (!value || !Object.prototype.hasOwnProperty.call(value, key)) return null;
41
+ const number = Number(value[key]);
42
+ return Number.isFinite(number) && number >= 0 ? number : null;
43
+ }
44
+
45
+ function formatCost(value) {
46
+ if (value === 0) return "$0";
47
+ return value < 0.001 ? "<$0.001" : COST_FORMATTER.format(value);
48
+ }
49
+
50
+ function buildProviderUsage(value = {}) {
51
+ const input = optionalNumber(value, "input_tokens");
52
+ const cached = optionalNumber(value, "cached_tokens");
53
+ const output = optionalNumber(value, "output_tokens");
54
+ const cost = optionalNumber(value, "cost");
55
+
56
+ const tokenSummary = input === null && output === null
57
+ ? ""
58
+ : `${input === null ? "–" : formatTokens(input)} → ${output === null ? "–" : formatTokens(output)}`;
59
+
60
+ const cachePercent = input > 0 && cached !== null
61
+ ? Math.min((cached / input) * 100, 100)
62
+ : null;
63
+ return {
64
+ hasData: cost !== null || cachePercent !== null || Boolean(tokenSummary),
65
+ price: {
66
+ hasData: cost !== null,
67
+ label: cost === null ? "" : formatCost(cost),
68
+ },
69
+ cache: {
70
+ hasData: cachePercent !== null,
71
+ label: cachePercent === null ? "" : `${Math.round(cachePercent)}%`,
72
+ },
73
+ tokens: tokenSummary,
74
+ };
75
+ }
76
+
77
+ function buildUsage(data = {}) {
78
+ const tokens = Math.max(Number(data.tokens) || 0, 0);
79
+ const contextWindow = Math.max(Number(data.context_window) || 0, 0);
80
+ const breakdown = data.usage && typeof data.usage === "object" ? data.usage : {};
81
+ const percent = contextWindow > 0 ? (tokens / contextWindow) * 100 : 0;
82
+ const rows = ROWS.map(row => {
83
+ const rowTokens = Math.max(Number(breakdown[row.key]) || 0, 0);
84
+ const rowPercent = contextWindow > 0 ? (rowTokens / contextWindow) * 100 : 0;
85
+ return {
86
+ ...row,
87
+ tokensLabel: formatTokens(rowTokens),
88
+ percentLabel: formatPercent(rowPercent),
89
+ };
90
+ });
91
+ const hasBreakdown = rows.some(row => Number(breakdown[row.key]) > 0);
92
+ if (hasBreakdown) {
93
+ const freeTokens = Math.max(contextWindow - tokens, 0);
94
+ const freePercent = contextWindow > 0 ? (freeTokens / contextWindow) * 100 : 0;
95
+ rows.push({
96
+ key: "free_space",
97
+ label: "Free space",
98
+ tokensLabel: formatTokens(freeTokens),
99
+ percentLabel: formatPercent(freePercent),
100
+ });
101
+ }
102
+ const percentLabel = formatPercent(percent);
103
+ return {
104
+ rows: hasBreakdown ? rows : [],
105
+ hasBreakdown,
106
+ missingBreakdown: !hasBreakdown,
107
+ ariaLabel: `Context window ${percentLabel} used`,
108
+ ringLabel: contextWindow ? `${Math.round(percent)}%` : "–",
109
+ ringDasharray: `${Math.min(percent, 100)} 100`,
110
+ summaryTokens: `${formatTokens(tokens)}/${contextWindow ? formatTokens(contextWindow) : "–"} tokens`,
111
+ summaryPercent: `${percentLabel} used`,
112
+ meterStyle: `width:${Math.min(percent, 100)}%`,
113
+ provider: buildProviderUsage(data.provider_usage),
114
+ };
115
+ }
116
+
117
+ const model = {
118
+ usage: buildUsage(),
119
+ loadSeq: 0,
120
+ open: false,
121
+
122
+ get contextId() {
123
+ return chatsStore?.getSelectedChatId?.() || globalThis.getContext?.() || "";
124
+ },
125
+
126
+ async onMount(watch) {
127
+ await this.refresh();
128
+ watch("$store.chats.selected", value => this.refresh(value || ""));
129
+ watch("$store.chats.selectedContext?.running", (running, previous) => {
130
+ if (previous && !running) void this.refresh();
131
+ });
132
+ },
133
+
134
+ cleanup() {
135
+ this.open = false;
136
+ this.loadSeq += 1;
137
+ },
138
+
139
+ toggle() {
140
+ this.open = !this.open;
141
+ if (this.open) void this.refresh();
142
+ },
143
+
144
+ async refresh(contextId = this.contextId) {
145
+ const requestSeq = ++this.loadSeq;
146
+ if (!contextId) {
147
+ this.usage = buildUsage();
148
+ return this.usage;
149
+ }
150
+ try {
151
+ const data = await callJsonApi(API_PATH, { context: contextId });
152
+ if (requestSeq === this.loadSeq) this.usage = buildUsage(data);
153
+ } catch (error) {
154
+ if (requestSeq === this.loadSeq) console.error("Context window load failed:", error);
155
+ }
156
+ return this.usage;
157
+ },
158
+ };
159
+
160
+ export const store = createStore("contextWindow", model);
plugins/_context_window/webui/thumbnail.webp ADDED
plugins/_desktop/AGENTS.md ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Desktop Plugin DOX
2
+
3
+ ## Purpose
4
+
5
+ - Own the Agent Zero Linux desktop runtime, Xpra/Xfce session integration, and live desktop surface.
6
+
7
+ ## Ownership
8
+
9
+ - `helpers/` owns desktop routes, session lifecycle, state, and prompt context.
10
+ - `api/desktop_session.py` owns desktop session API behavior.
11
+ - `hooks.py` owns route registration and plugin lifecycle hooks.
12
+ - `prompts/`, `assets/`, `skills/`, `extensions/`, and `webui/` own desktop context, assets, skill guidance, hooks, and panel UI.
13
+
14
+ ## Local Contracts
15
+
16
+ - Preserve session startup, cleanup, and route protection for desktop access.
17
+ - Keep the Xpra server, client modules, and GTK introspection runtime present as one compatible stack; Xpra shadow sessions import all three even when users connect only through HTML5.
18
+ - Keep desktop state injected into prompts accurate and bounded.
19
+ - Do not expose desktop routes without the expected auth protections.
20
+ - Keep Desktop host visibility tied to an attached modal or canvas host; modal cleanup may preserve the iframe in keepalive, but must not leave stale modal mode behind.
21
+ - Keep LibreOffice Writer as the default handler for Markdown and plain text files; keep Agent Zero Editor available as a secondary Open With target through the desktop intent bridge.
22
+
23
+ ## Work Guidance
24
+
25
+ - Coordinate runtime and panel changes so live UI reflects actual desktop session state.
26
+
27
+ ## Verification
28
+
29
+ - Smoke-test desktop startup, panel connection, route access, and session cleanup after changes.
30
+
31
+ ## Child DOX Index
32
+
33
+ No child DOX files.
plugins/_desktop/api/desktop_session.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from helpers.api import ApiHandler, Request
4
+ from plugins._desktop.helpers import desktop_session
5
+ from plugins._office.helpers import document_store
6
+ from plugins._office.helpers import libreoffice
7
+
8
+
9
+ class DesktopSession(ApiHandler):
10
+ async def process(self, input: dict, request: Request) -> dict:
11
+ action = str(input.get("action") or "desktop").lower().strip()
12
+
13
+ if action == "status":
14
+ return desktop_session.collect_desktop_status()
15
+ if action in {"desktop", "open", "session"}:
16
+ return self._desktop()
17
+ if action in {"open_document", "document"}:
18
+ return self._open_document(input, request)
19
+ if action in {"save", "desktop_save"}:
20
+ return self._save(input)
21
+ if action in {"sync", "desktop_sync", "heartbeat"}:
22
+ return self._sync(input)
23
+ if action in {"state", "desktop_state"}:
24
+ return self._state(input)
25
+ if action in {"shutdown", "desktop_shutdown"}:
26
+ return self._shutdown(input)
27
+ return {"ok": False, "error": f"Unsupported desktop session action: {action}"}
28
+
29
+ def _desktop(self) -> dict:
30
+ desktop = desktop_session.get_manager().ensure_system_desktop()
31
+ if not desktop.get("available"):
32
+ return {
33
+ "ok": False,
34
+ "error": desktop.get("error") or "Desktop session is unavailable.",
35
+ "status": desktop.get("status") or {},
36
+ "desktop": desktop,
37
+ "libreoffice": libreoffice.collect_status(),
38
+ }
39
+ document = {
40
+ "file_id": desktop_session.SYSTEM_FILE_ID,
41
+ "path": desktop["path"],
42
+ "basename": desktop["title"],
43
+ "title": desktop["title"],
44
+ "extension": "desktop",
45
+ "size": 0,
46
+ "version": 0,
47
+ }
48
+ return {
49
+ "ok": True,
50
+ "session_id": desktop["session_id"],
51
+ "desktop_session_id": desktop["session_id"],
52
+ "file_id": desktop_session.SYSTEM_FILE_ID,
53
+ "title": desktop["title"],
54
+ "extension": "desktop",
55
+ "path": desktop["path"],
56
+ "text": "",
57
+ "document": document,
58
+ "version": 0,
59
+ "desktop": desktop,
60
+ "store_session_id": "",
61
+ "mode": "desktop",
62
+ }
63
+
64
+ def _open_document(self, input: dict, request: Request) -> dict:
65
+ context_id = str(input.get("ctxid") or input.get("context_id") or "").strip()
66
+ file_id = str(input.get("file_id") or "").strip()
67
+ try:
68
+ doc = (
69
+ document_store.get_document(file_id)
70
+ if file_id
71
+ else document_store.register_document(str(input.get("path") or ""), context_id=context_id)
72
+ )
73
+ except Exception as exc:
74
+ return {"ok": False, "error": str(exc)}
75
+
76
+ ext = str(doc.get("extension") or "").lower()
77
+ if ext in document_store.EDITOR_TEXT_EXTENSIONS:
78
+ return {
79
+ "ok": False,
80
+ "error": "Text documents use the Editor surface.",
81
+ "requires_editor": True,
82
+ "document": _public_doc(doc),
83
+ }
84
+ if ext not in desktop_session.OFFICIAL_EXTENSIONS:
85
+ return {"ok": False, "error": f".{ext} documents do not use the Desktop surface."}
86
+
87
+ store_session = document_store.create_session(
88
+ doc["file_id"],
89
+ user_id=str(input.get("user_id") or "agent-zero-user"),
90
+ permission="write",
91
+ origin=self._origin(request),
92
+ )
93
+ desktop = desktop_session.get_manager().open(doc, refresh=input.get("refresh") is True)
94
+ if not desktop.get("available"):
95
+ document_store.close_session(session_id=store_session["session_id"])
96
+ return {
97
+ "ok": False,
98
+ "error": desktop.get("error") or desktop.get("reason") or "Desktop session is unavailable.",
99
+ "desktop": desktop,
100
+ "libreoffice": libreoffice.collect_status(),
101
+ }
102
+ return {
103
+ "ok": True,
104
+ "session_id": desktop["session_id"],
105
+ "desktop_session_id": desktop["session_id"],
106
+ "file_id": doc["file_id"],
107
+ "title": doc["basename"],
108
+ "extension": doc["extension"],
109
+ "path": doc["path"],
110
+ "text": "",
111
+ "document": _public_doc(doc),
112
+ "version": document_store.item_version(doc),
113
+ "desktop": desktop,
114
+ "store_session_id": store_session["session_id"],
115
+ "mode": "edit",
116
+ }
117
+
118
+ def _save(self, input: dict) -> dict:
119
+ session_id = str(input.get("desktop_session_id") or input.get("session_id") or "").strip()
120
+ if not session_id:
121
+ return {"ok": False, "error": "desktop_session_id is required."}
122
+ return desktop_session.get_manager().save(
123
+ session_id,
124
+ file_id=str(input.get("file_id") or ""),
125
+ )
126
+
127
+ def _sync(self, input: dict) -> dict:
128
+ return desktop_session.get_manager().sync(
129
+ session_id=str(input.get("desktop_session_id") or input.get("session_id") or ""),
130
+ file_id=str(input.get("file_id") or ""),
131
+ )
132
+
133
+ def _state(self, input: dict) -> dict:
134
+ return desktop_session.get_manager().state(
135
+ include_screenshot=bool(input.get("include_screenshot") is True),
136
+ context_id=str(input.get("ctxid") or input.get("context_id") or ""),
137
+ )
138
+
139
+ def _shutdown(self, input: dict) -> dict:
140
+ return desktop_session.get_manager().shutdown_system_desktop(
141
+ save_first=input.get("save_first") is not False,
142
+ source=str(input.get("source") or "api"),
143
+ )
144
+
145
+ def _origin(self, request: Request) -> str:
146
+ origin = request.headers.get("Origin") or request.host_url.rstrip("/")
147
+ return origin.rstrip("/")
148
+
149
+
150
+ def _public_doc(doc: dict) -> dict:
151
+ return {
152
+ "file_id": doc["file_id"],
153
+ "path": document_store.display_path(doc["path"]),
154
+ "basename": doc["basename"],
155
+ "title": doc["basename"],
156
+ "extension": doc["extension"],
157
+ "size": doc["size"],
158
+ "version": document_store.item_version(doc),
159
+ "last_modified": doc["last_modified"],
160
+ }
plugins/_desktop/assets/desktop/README.md ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Agent Zero Desktop
2
+
3
+ This Desktop is Agent Zero's habitat: a visible, stateful Linux workspace where humans and agents can share files, launch tools, inspect results, and turn reasoning into observable work.
4
+
5
+ You can choose your harness, but now you and A0 have a habitat.
6
+
7
+ Here, the model reasons, the environment remembers, the user grants permission, and the work leaves artifacts that can be opened, tested, revised, and trusted. It is a small workshop for the larger idea: agents become more useful when they have a safe world to act in, not only more instructions to carry.
8
+
9
+ With gratitude to the open-source foundations that make this affordance possible:
10
+
11
+ - Xpra, for carrying a live Linux desktop through the browser, making this desktop environment usable by AI agents.
12
+ - Kali Linux, for the practical Linux craft and tool culture that make capable workspaces feel natural.
13
+ - LibreOffice, for giving this habitat a serious office bench: documents, spreadsheets, presentations, and decades of open document stewardship.
14
+ - Xfce, for a fast, humane desktop that stays out of the way and lets the work breathe.
15
+ - Jan Tomášek, for creating Agent Zero and making this whole workshop possible.
16
+
17
+ Agent Zero stands on this craft with respect. Open source is not just code we consume; it is shared affordance, accumulated care, and an invitation to build systems that are powerful without becoming opaque.
18
+
19
+ ## Open the Terminal
20
+
21
+ Double-click **Terminal** on this Desktop. You will land in the Agent Zero workdir, where agent CLIs can inspect files, edit projects, and run commands inside the Linux environment.
22
+
23
+ Before installing an agent, make a small safety habit:
24
+
25
+ ```bash
26
+ pwd
27
+ node -v || true
28
+ npm -v || true
29
+ python3 --version || true
30
+ curl --version | head -1 || true
31
+ ```
32
+
33
+ Agent CLIs are powerful. Install from official sources, avoid `sudo npm install -g` unless the project explicitly requires it, and keep API keys in your shell or provider login flow rather than pasting secrets into documents.
34
+
35
+ ## Install Your Favorite Agent
36
+
37
+ These commands were checked against official docs or project READMEs on 2026-05-02. Package names matter.
38
+
39
+ ### OpenAI Codex
40
+
41
+ ```bash
42
+ npm i -g @openai/codex
43
+ codex
44
+ ```
45
+
46
+ ### Claude Code
47
+
48
+ Recommended native installer:
49
+
50
+ ```bash
51
+ curl -fsSL https://claude.ai/install.sh | bash
52
+ source ~/.bashrc
53
+ claude
54
+ ```
55
+
56
+ NPM alternative:
57
+
58
+ ```bash
59
+ npm install -g @anthropic-ai/claude-code
60
+ claude
61
+ ```
62
+
63
+ ### Gemini CLI
64
+
65
+ ```bash
66
+ npm install -g @google/gemini-cli
67
+ gemini
68
+ ```
69
+
70
+ ### Aider
71
+
72
+ ```bash
73
+ curl -LsSf https://aider.chat/install.sh | sh
74
+ source ~/.bashrc
75
+ aider
76
+ ```
77
+
78
+ ### OpenCode
79
+
80
+ ```bash
81
+ curl -sL https://opencode.ai/install | bash
82
+ opencode
83
+ ```
84
+
85
+ NPM alternative:
86
+
87
+ ```bash
88
+ npm i -g opencode
89
+ opencode
90
+ ```
91
+
92
+ ### Goose
93
+
94
+ ```bash
95
+ curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | bash
96
+ source ~/.bashrc
97
+ goose
98
+ ```
99
+
100
+ ### OpenHands CLI
101
+
102
+ ```bash
103
+ python3 -m pip install uv
104
+ uv tool install openhands --python 3.12
105
+ openhands
106
+ ```
107
+
108
+ ### Qwen Code
109
+
110
+ ```bash
111
+ npm install -g @qwen-code/qwen-code@latest
112
+ qwen
113
+ ```
114
+
115
+ ### Cursor Agent
116
+
117
+ ```bash
118
+ curl https://cursor.com/install -fsS | bash
119
+ source ~/.bashrc
120
+ cursor-agent
121
+ ```
122
+
123
+ ### Hermes Agent
124
+
125
+ ```bash
126
+ curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash
127
+ source ~/.bashrc
128
+ hermes
129
+ ```
130
+
131
+ ### OpenClaw
132
+
133
+ OpenClaw recommends Node 24, or Node 22.14+.
134
+
135
+ ```bash
136
+ npm install -g openclaw@latest
137
+ openclaw onboard --install-daemon
138
+ ```
139
+
140
+ If in doubt, start with Codex or Claude Code for coding, Aider for a lighter patch-focused loop, Goose or OpenHands for a broader local agent, and Hermes or OpenClaw when you want the agent to grow into more of a persistent runtime working together with Agent Zero.
141
+
142
+ Official references: [Codex](https://developers.openai.com/codex/cli), [Claude Code](https://code.claude.com/docs/en/getting-started), [Gemini CLI](https://google-gemini.github.io/gemini-cli/docs/get-started/), [Aider](https://aider.chat/docs/install.html), [OpenCode](https://www.opencode.live/), [Goose](https://goose-docs.ai/docs/getting-started/installation/), [OpenHands](https://docs.openhands.dev/openhands/usage/cli/installation), [Qwen Code](https://github.com/QwenLM/qwen-code), [Cursor Agent](https://docs.cursor.com/en/cli/installation), [Hermes Agent](https://github.com/NousResearch/hermes-agent), [OpenClaw](https://github.com/openclaw/openclaw).
plugins/_desktop/extensions/python/message_loop_prompts_after/_55_include_desktop_state.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from agent import LoopData
4
+ from helpers.extension import Extension
5
+ from plugins._desktop.helpers import prompt_context
6
+
7
+
8
+ class IncludeDesktopState(Extension):
9
+ async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
10
+ context_id = str(getattr(getattr(self.agent, "context", None), "id", "") or "")
11
+ context = prompt_context.build_context(context_id=context_id)
12
+ if not context:
13
+ loop_data.extras_temporary.pop("desktop_state", None)
14
+ return
15
+
16
+ loop_data.extras_temporary["desktop_state"] = self.agent.read_prompt(
17
+ "agent.extras.desktop_state.md",
18
+ desktop_state=context,
19
+ ) if self.agent else context
plugins/_desktop/extensions/python/startup_migration/_20_desktop_routes.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from typing import Any
5
+
6
+ from helpers.extension import Extension
7
+ from helpers.print_style import PrintStyle
8
+ from helpers import virtual_desktop_routes
9
+ from plugins._desktop import hooks
10
+
11
+
12
+ _startup_preparation_thread: threading.Thread | None = None
13
+
14
+
15
+ class DesktopStartup(Extension):
16
+ def execute(self, **kwargs):
17
+ virtual_desktop_routes.install_route_hooks()
18
+ _start_background_runtime_preparation()
19
+
20
+
21
+ def _start_background_runtime_preparation() -> threading.Thread:
22
+ global _startup_preparation_thread
23
+
24
+ if _startup_preparation_thread and _startup_preparation_thread.is_alive():
25
+ return _startup_preparation_thread
26
+
27
+ _startup_preparation_thread = threading.Thread(
28
+ target=_prepare_runtime_safely,
29
+ name="a0-desktop-runtime-preparation",
30
+ daemon=True,
31
+ )
32
+ _startup_preparation_thread.start()
33
+ return _startup_preparation_thread
34
+
35
+
36
+ def _prepare_runtime_safely() -> None:
37
+ try:
38
+ _log_runtime_preparation_result(hooks.cleanup_stale_runtime_state())
39
+ except Exception as exc:
40
+ PrintStyle.warning("Desktop runtime preparation failed:", exc)
41
+
42
+
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("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/extensions/webui/right-canvas-panels/desktop-panel.html ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div
2
+ class="right-canvas-surface-panel desktop-canvas-surface"
3
+ data-surface-id="desktop"
4
+ :class="{
5
+ 'is-active': $store.rightCanvas?.isSurfaceVisible('desktop'),
6
+ 'is-mounted': $store.rightCanvas?.isSurfaceRendered('desktop')
7
+ }"
8
+ :aria-hidden="(!$store.rightCanvas?.isSurfaceVisible('desktop')).toString()"
9
+ x-effect="(() => {
10
+ const visible = Boolean($store.rightCanvas?.isSurfaceRendered('desktop'));
11
+ (globalThis.queueMicrotask || ((callback) => globalThis.setTimeout(callback, 0)))(() => $store.desktop?.setDesktopHostVisible?.(visible));
12
+ })()"
13
+ >
14
+ <x-component path="/plugins/_desktop/webui/desktop-panel.html"></x-component>
15
+ </div>
plugins/_desktop/extensions/webui/right-canvas-toolbar-start/desktop-new-menu.html ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <div
2
+ class="office-header-actions right-canvas-desktop-actions"
3
+ x-data="{ open: false }"
4
+ x-show="$store.rightCanvas?.isSurfaceActive('desktop')"
5
+ @click.outside="open = false"
6
+ @keydown.escape.window="open = false"
7
+ style="display: none;"
8
+ >
9
+ <button
10
+ type="button"
11
+ class="office-header-new-button"
12
+ aria-haspopup="menu"
13
+ :aria-expanded="open.toString()"
14
+ @click.stop="open = !open"
15
+ >
16
+ <x-icon aria-hidden="true" name="add"></x-icon>
17
+ <span>New</span>
18
+ <x-icon class="office-new-chevron" aria-hidden="true" name="expand_more"></x-icon>
19
+ </button>
20
+ <div class="office-new-menu" role="menu" x-show="open" @click.stop style="display: none;">
21
+ <button type="button" class="office-new-menu-item" role="menuitem" @click="open = false; $store.desktop?.runNewMenuAction('open')">
22
+ <x-icon aria-hidden="true" name="folder_open"></x-icon>
23
+ <span>Open</span>
24
+ </button>
25
+ <button type="button" class="office-new-menu-item" role="menuitem" @click="open = false; $store.desktop?.runNewMenuAction('writer')">
26
+ <x-icon aria-hidden="true" name="description"></x-icon>
27
+ <span>Writer</span>
28
+ </button>
29
+ <button type="button" class="office-new-menu-item" role="menuitem" @click="open = false; $store.desktop?.runNewMenuAction('spreadsheet')">
30
+ <x-icon aria-hidden="true" name="table_chart"></x-icon>
31
+ <span>Spreadsheet</span>
32
+ </button>
33
+ <button type="button" class="office-new-menu-item" role="menuitem" @click="open = false; $store.desktop?.runNewMenuAction('presentation')">
34
+ <x-icon aria-hidden="true" name="co_present"></x-icon>
35
+ <span>Presentation</span>
36
+ </button>
37
+ </div>
38
+ </div>
plugins/_desktop/extensions/webui/right_canvas_register_surfaces/register-desktop.js ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { store as desktopStore } from "/plugins/_desktop/webui/desktop-store.js";
2
+
3
+ function waitForElement(selector, timeoutMs = 10000) {
4
+ const found = document.querySelector(selector);
5
+ if (found) return Promise.resolve(found);
6
+ return new Promise((resolve) => {
7
+ const timeout = globalThis.setTimeout(() => {
8
+ observer.disconnect();
9
+ resolve(document.querySelector(selector));
10
+ }, timeoutMs);
11
+ const observer = new MutationObserver(() => {
12
+ const element = document.querySelector(selector);
13
+ if (!element) return;
14
+ globalThis.clearTimeout(timeout);
15
+ observer.disconnect();
16
+ resolve(element);
17
+ });
18
+ observer.observe(document.body, { childList: true, subtree: true });
19
+ });
20
+ }
21
+
22
+ export default async function registerDesktopSurface(surfaces) {
23
+ surfaces.registerSurface({
24
+ id: "desktop",
25
+ title: "Desktop",
26
+ icon: "desktop_windows",
27
+ order: 20,
28
+ modalPath: "/plugins/_desktop/webui/main.html",
29
+ async beginDockHandoff() {
30
+ desktopStore.beforeDesktopHostHandoff?.();
31
+ },
32
+ async finishDockHandoff(payload = {}) {
33
+ if (payload.opened !== false) desktopStore.afterDesktopHostShown?.({ source: "dock" });
34
+ },
35
+ async cancelDockHandoff() {
36
+ desktopStore.cancelDesktopHostHandoff?.();
37
+ },
38
+ async open(payload = {}) {
39
+ const panel = await waitForElement('[data-surface-id="desktop"] .office-panel');
40
+ if (!panel) throw new Error("Desktop surface panel did not mount.");
41
+ await desktopStore.onMount?.(panel, { mode: "canvas" });
42
+ await desktopStore.onOpen?.(payload);
43
+ desktopStore.afterDesktopHostShown?.({ source: payload?.source || "canvas" });
44
+ },
45
+ async close(payload = {}) {
46
+ desktopStore.beforeHostHidden?.({ unloadDesktop: payload?.reason === "mobile" });
47
+ },
48
+ });
49
+ }
plugins/_desktop/extensions/webui/surfaces_register/register-desktop.js ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { store as desktopStore } from "/plugins/_desktop/webui/desktop-store.js";
2
+
3
+ function waitForElement(selector, timeoutMs = 10000) {
4
+ const found = document.querySelector(selector);
5
+ if (found) return Promise.resolve(found);
6
+ return new Promise((resolve) => {
7
+ const timeout = globalThis.setTimeout(() => {
8
+ observer.disconnect();
9
+ resolve(document.querySelector(selector));
10
+ }, timeoutMs);
11
+ const observer = new MutationObserver(() => {
12
+ const element = document.querySelector(selector);
13
+ if (!element) return;
14
+ globalThis.clearTimeout(timeout);
15
+ observer.disconnect();
16
+ resolve(element);
17
+ });
18
+ observer.observe(document.body, { childList: true, subtree: true });
19
+ });
20
+ }
21
+
22
+ export default async function registerDesktopSurface(surfaces) {
23
+ surfaces.registerSurface({
24
+ id: "desktop",
25
+ title: "Desktop",
26
+ icon: "desktop_windows",
27
+ order: 20,
28
+ modalPath: "/plugins/_desktop/webui/main.html",
29
+ async beginDockHandoff() {
30
+ desktopStore.beforeDesktopHostHandoff?.();
31
+ },
32
+ async finishDockHandoff(payload = {}) {
33
+ if (payload.opened !== false) desktopStore.afterDesktopHostShown?.({ source: "dock" });
34
+ },
35
+ async cancelDockHandoff() {
36
+ desktopStore.cancelDesktopHostHandoff?.();
37
+ },
38
+ async open(payload = {}) {
39
+ const panel = await waitForElement('[data-surface-id="desktop"] .office-panel');
40
+ if (!panel) throw new Error("Desktop surface panel did not mount.");
41
+ await desktopStore.onMount?.(panel, { mode: "canvas" });
42
+ await desktopStore.onOpen?.(payload);
43
+ desktopStore.afterDesktopHostShown?.({ source: payload?.source || "canvas" });
44
+ },
45
+ async close(payload = {}) {
46
+ desktopStore.beforeHostHidden?.({ unloadDesktop: payload?.reason === "mobile" });
47
+ },
48
+ });
49
+ }
plugins/_desktop/helpers/desktop_routes.py ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from helpers.virtual_desktop_routes import VirtualDesktopGateway, install_route_hooks, is_installed
4
+
5
+
6
+ __all__ = ["VirtualDesktopGateway", "install_route_hooks", "is_installed"]
plugins/_desktop/helpers/desktop_session.py ADDED
@@ -0,0 +1,2601 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import atexit
4
+ import fcntl
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import shutil
9
+ import socket
10
+ import subprocess
11
+ import threading
12
+ import time
13
+ import uuid
14
+ import xml.etree.ElementTree as ET
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ from helpers import files, virtual_desktop
20
+ from helpers.localization import Localization
21
+ from plugins._desktop.helpers import desktop_state
22
+ 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"
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
39
+ XPRA_PORT_BASE = 14500
40
+ MAX_SESSIONS = 12
41
+ DEFAULT_SCREEN_WIDTH = virtual_desktop.DEFAULT_WIDTH
42
+ DEFAULT_SCREEN_HEIGHT = virtual_desktop.DEFAULT_HEIGHT
43
+ MAX_SCREEN_WIDTH = virtual_desktop.MAX_WIDTH
44
+ MAX_SCREEN_HEIGHT = virtual_desktop.MAX_HEIGHT
45
+ BLOCKING_DIALOG_TITLES = ("Remote Files", "File Services")
46
+ DISPLAY_START_TIMEOUT_SECONDS = 30.0
47
+ PORT_START_TIMEOUT_SECONDS = 30.0
48
+ STARTUP_GRACE_SECONDS = 45
49
+ RUNTIME_INSTALL_MESSAGE = (
50
+ "Installing Agent Zero Desktop runtime dependencies. "
51
+ "This can take a few minutes after an update."
52
+ )
53
+ HIDDEN_XPRA_DESKTOP_ENTRIES = (
54
+ "xpra.desktop",
55
+ "xpra-gui.desktop",
56
+ "xpra-launcher.desktop",
57
+ "xpra-shadow.desktop",
58
+ "xpra-start.desktop",
59
+ )
60
+ HIDDEN_XFCE_MENU_ENTRIES = (
61
+ ("exo-mail-reader.desktop", "Mail Reader"),
62
+ ("exo-web-browser.desktop", "Web Browser"),
63
+ ("xfce4-mail-reader.desktop", "Mail Reader"),
64
+ ("xfce4-web-browser.desktop", "Web Browser"),
65
+ ("xfce4-session-logout.desktop", "Log Out"),
66
+ ("xfce4-lock-screen.desktop", "Lock Screen"),
67
+ ("xflock4.desktop", "Lock Screen"),
68
+ ("xfce4-switch-user.desktop", "Switch User"),
69
+ )
70
+ DESKTOP_README_SOURCE = Path(__file__).resolve().parents[1] / "assets" / "desktop" / "README.md"
71
+ DESKTOP_FOLDER_LINKS = (
72
+ ("Projects", ("usr", "projects")),
73
+ ("Skills", ("usr", "skills")),
74
+ ("Agents", ("usr", "agents")),
75
+ ("Downloads", ("usr", "downloads")),
76
+ )
77
+ URL_INTENT_MAX_ITEMS = 50
78
+ URL_INTENT_MAX_LENGTH = 8192
79
+ URL_HANDLER_DESKTOP_ID = "agent-zero-browser.desktop"
80
+ EDITOR_HANDLER_DESKTOP_ID = "agent-zero-editor.desktop"
81
+ WRITER_HANDLER_DESKTOP_ID = "libreoffice-writer.desktop"
82
+ SHUTDOWN_HANDLER_DESKTOP_ID = "agent-zero-shutdown.desktop"
83
+ SHUTDOWN_PANEL_LAUNCHER_ID = SHUTDOWN_HANDLER_DESKTOP_ID
84
+ SHUTDOWN_CONFIRM_SECONDS = 8
85
+ OOR_NS = "http://openoffice.org/2001/registry"
86
+ XS_NS = "http://www.w3.org/2001/XMLSchema"
87
+ XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
88
+
89
+
90
+ @dataclass
91
+ class DesktopSession:
92
+ session_id: str
93
+ file_id: str
94
+ extension: str
95
+ path: str
96
+ title: str
97
+ display: int
98
+ xpra_port: int
99
+ token: str
100
+ url: str
101
+ profile_dir: Path
102
+ width: int = DEFAULT_SCREEN_WIDTH
103
+ height: int = DEFAULT_SCREEN_HEIGHT
104
+ processes: dict[str, subprocess.Popen[Any]] = field(default_factory=dict)
105
+ process_ids: dict[str, int] = field(default_factory=dict)
106
+ owns_processes: bool = True
107
+ started_at: float = field(default_factory=time.time)
108
+ timezone: str = field(default_factory=lambda: Localization.get().get_timezone())
109
+
110
+ def alive(self) -> bool:
111
+ return _running(self.processes.get("xpra")) or _pid_is_running(self.process_ids.get("xpra", 0))
112
+
113
+ def public(self, doc: dict[str, Any] | None = None) -> dict[str, Any]:
114
+ title = str(doc.get("basename") or "") if doc else self.title
115
+ path = str(doc.get("path") or "") if doc else self.path
116
+ extension = str(doc.get("extension") or "") if doc else self.extension
117
+ file_id = str(doc.get("file_id") or "") if doc else self.file_id
118
+ return {
119
+ "available": True,
120
+ "session_id": self.session_id,
121
+ "file_id": file_id,
122
+ "extension": extension,
123
+ "title": title,
124
+ "path": document_store.display_path(path),
125
+ "url": self.url,
126
+ "token": self.token,
127
+ "display": f":{self.display}",
128
+ "desktop_path": virtual_desktop.SESSION_PATH,
129
+ "width": self.width,
130
+ "height": self.height,
131
+ "started_at": self.started_at,
132
+ }
133
+
134
+
135
+ class DesktopSessionManager:
136
+ def __init__(self) -> None:
137
+ self._lock = threading.RLock()
138
+ self._sessions: dict[str, DesktopSession] = {}
139
+
140
+ def ensure_system_desktop(self) -> dict[str, Any]:
141
+ try:
142
+ with self._lock:
143
+ self._reap_dead_locked()
144
+ session = self._ensure_system_desktop_locked()
145
+ return session.public()
146
+ except Exception as exc:
147
+ status = collect_desktop_status()
148
+ return {
149
+ "available": False,
150
+ "error": str(exc),
151
+ "status": status,
152
+ }
153
+
154
+ def open(self, doc: dict[str, Any], *, refresh: bool = False) -> dict[str, Any]:
155
+ ext = str(doc.get("extension") or "").lower()
156
+ if ext not in OFFICIAL_EXTENSIONS:
157
+ return {"available": False, "reason": f".{ext} does not use the LibreOffice desktop surface."}
158
+
159
+ with self._lock:
160
+ self._reap_dead_locked()
161
+ try:
162
+ session = self._ensure_system_desktop_locked()
163
+ except Exception as exc:
164
+ status = collect_desktop_status()
165
+ return {
166
+ "available": False,
167
+ "error": str(exc),
168
+ "status": status,
169
+ }
170
+ refreshed = False
171
+ try:
172
+ if refresh and session.file_id == str(doc.get("file_id") or ""):
173
+ refreshed = self._reload_document_locked(session, doc)
174
+ else:
175
+ self._open_document_locked(session, doc)
176
+ except Exception as exc:
177
+ return {
178
+ "available": False,
179
+ "error": str(exc),
180
+ "status": collect_desktop_status(),
181
+ }
182
+ session.file_id = str(doc["file_id"])
183
+ session.extension = ext
184
+ session.path = str(doc["path"])
185
+ session.title = str(doc["basename"])
186
+ self._write_manifest(session)
187
+ public = session.public(doc)
188
+ public["refreshed"] = refreshed
189
+ return public
190
+
191
+ def refresh_document(self, file_id: str) -> dict[str, Any]:
192
+ normalized = str(file_id or "").strip()
193
+ if not normalized:
194
+ return {"ok": True, "refreshed": False}
195
+ try:
196
+ doc = document_store.get_document(normalized)
197
+ except Exception:
198
+ return {"ok": False, "refreshed": False, "error": "Document not found."}
199
+
200
+ ext = str(doc.get("extension") or "").lower()
201
+ if ext not in OFFICIAL_EXTENSIONS:
202
+ return {"ok": True, "refreshed": False}
203
+
204
+ with self._lock:
205
+ self._reap_dead_locked()
206
+ session = self._find_by_file_id_locked(normalized)
207
+ if not session:
208
+ existing = self._load_system_desktop_from_manifest_locked()
209
+ if existing:
210
+ self._sessions[existing.session_id] = existing
211
+ self._register_virtual_desktop(existing)
212
+ if existing.file_id == normalized:
213
+ session = existing
214
+ if not session:
215
+ return {"ok": True, "refreshed": False}
216
+ refreshed = self._reload_document_locked(session, doc)
217
+ session.file_id = str(doc["file_id"])
218
+ session.extension = ext
219
+ session.path = str(doc["path"])
220
+ session.title = str(doc["basename"])
221
+ self._write_manifest(session)
222
+ return {"ok": True, "refreshed": refreshed, "desktop": session.public(doc)}
223
+
224
+ def save(self, session_id: str, file_id: str = "") -> dict[str, Any]:
225
+ with self._lock:
226
+ session = self.require(session_id)
227
+ doc = self._document_for_save(session, file_id)
228
+ if not doc:
229
+ return {
230
+ "ok": True,
231
+ "session_id": session.session_id,
232
+ "document": None,
233
+ "changed": False,
234
+ }
235
+
236
+ xdotool = shutil.which("xdotool")
237
+ if not xdotool:
238
+ updated = document_store.register_document(doc["path"])
239
+ return {
240
+ "ok": False,
241
+ "error": "xdotool is not installed; use LibreOffice's Save control inside the canvas.",
242
+ "document": _public_doc(updated),
243
+ }
244
+
245
+ window_id = self._office_window_id_locked(
246
+ session,
247
+ title=str(doc.get("basename") or Path(doc["path"]).name),
248
+ fallback=False,
249
+ )
250
+ if not window_id:
251
+ return {
252
+ "ok": False,
253
+ "error": f"LibreOffice window not found for {doc['basename']}.",
254
+ "document": _public_doc(doc),
255
+ }
256
+
257
+ result = subprocess.run(
258
+ [
259
+ xdotool,
260
+ "windowactivate",
261
+ "--sync",
262
+ window_id,
263
+ "key",
264
+ "--clearmodifiers",
265
+ "ctrl+s",
266
+ ],
267
+ check=False,
268
+ capture_output=True,
269
+ text=True,
270
+ timeout=8,
271
+ env=self._display_env(session),
272
+ )
273
+ time.sleep(0.8)
274
+ updated = document_store.register_document(doc["path"])
275
+ if result.returncode != 0:
276
+ detail = (result.stderr or result.stdout or "").strip()
277
+ return {
278
+ "ok": False,
279
+ "error": detail or "LibreOffice desktop save shortcut failed.",
280
+ "document": _public_doc(updated),
281
+ }
282
+ return {
283
+ "ok": True,
284
+ "session_id": session.session_id,
285
+ "document": _public_doc(updated),
286
+ "changed": updated.get("sha256") != doc.get("sha256"),
287
+ }
288
+
289
+ def sync(self, session_id: str = "", file_id: str = "") -> dict[str, Any]:
290
+ session = self.get(session_id) if session_id else self._find_by_file_id(file_id)
291
+ if not session:
292
+ return {"ok": False, "error": "LibreOffice desktop session not found."}
293
+ if not _url_bridge_script_path(session).exists():
294
+ try:
295
+ self._prepare_desktop_url_bridge(session)
296
+ self._refresh_xfce_desktop(session)
297
+ except Exception:
298
+ pass
299
+ url_intents = self.claim_url_intents(session.session_id)
300
+ shutdown_request = self.claim_shutdown_request(session.session_id)
301
+ if shutdown_request:
302
+ return self.shutdown_system_desktop(
303
+ save_first=True,
304
+ source=str(shutdown_request.get("source") or "tray"),
305
+ )
306
+ doc = self._document_for_save(session, file_id)
307
+ if not doc:
308
+ return {
309
+ "ok": True,
310
+ "session_id": session.session_id,
311
+ "desktop": session.public(),
312
+ "url_intents": url_intents,
313
+ }
314
+ updated = document_store.register_document(doc["path"])
315
+ return {
316
+ "ok": True,
317
+ "session_id": session.session_id,
318
+ "document": _public_doc(updated),
319
+ "url_intents": url_intents,
320
+ }
321
+
322
+ def state(self, *, include_screenshot: bool = False, context_id: str = "") -> dict[str, Any]:
323
+ with self._lock:
324
+ self._reap_dead_locked()
325
+ return desktop_state.collect_state(
326
+ include_screenshot=include_screenshot,
327
+ context_id=context_id,
328
+ )
329
+
330
+ def claim_url_intents(self, session_id: str = SYSTEM_SESSION_ID) -> list[dict[str, Any]]:
331
+ session = self.get(session_id) or self.get(SYSTEM_SESSION_ID)
332
+ if not session:
333
+ return []
334
+ return _claim_url_intents(session)
335
+
336
+ def claim_shutdown_request(self, session_id: str = SYSTEM_SESSION_ID) -> dict[str, Any] | None:
337
+ session = self.get(session_id) or self.get(SYSTEM_SESSION_ID)
338
+ if not session:
339
+ return None
340
+ return _claim_shutdown_request(session)
341
+
342
+ def shutdown_system_desktop(self, *, save_first: bool = True, source: str = "api") -> dict[str, Any]:
343
+ with self._lock:
344
+ session = self._sessions.get(SYSTEM_SESSION_ID)
345
+ if not session:
346
+ _remove_system_manifest()
347
+ return {
348
+ "ok": True,
349
+ "closed": 0,
350
+ "session_id": SYSTEM_SESSION_ID,
351
+ "shutdown": True,
352
+ "intentional_shutdown": True,
353
+ "source": source,
354
+ }
355
+
356
+ save_result = None
357
+ if save_first:
358
+ try:
359
+ save_result = self.save(session.session_id)
360
+ except Exception as exc:
361
+ save_result = {"ok": False, "error": str(exc)}
362
+
363
+ with self._lock:
364
+ if self._sessions.get(SYSTEM_SESSION_ID) is session:
365
+ self._sessions.pop(SYSTEM_SESSION_ID, None)
366
+ virtual_desktop.unregister_session(session.token)
367
+ self._terminate_session(session, include_rehydrated=True)
368
+ self._remove_manifest(session.session_id)
369
+ _clear_shutdown_request(session)
370
+ return {
371
+ "ok": True,
372
+ "closed": 1,
373
+ "session_id": session.session_id,
374
+ "shutdown": True,
375
+ "intentional_shutdown": True,
376
+ "source": source,
377
+ "save": save_result,
378
+ }
379
+
380
+ def retarget_document(self, file_id: str, doc: dict[str, Any]) -> dict[str, Any]:
381
+ session = self._find_by_file_id(file_id)
382
+ if not session:
383
+ return {"ok": True, "updated": False}
384
+ with self._lock:
385
+ session.path = str(doc["path"])
386
+ session.title = str(doc["basename"])
387
+ session.extension = str(doc["extension"])
388
+ self._write_manifest(session)
389
+ return {"ok": True, "updated": True, "desktop": session.public(doc)}
390
+
391
+ def close(self, session_id: str, save_first: bool = True) -> dict[str, Any]:
392
+ with self._lock:
393
+ normalized = str(session_id or "").strip()
394
+ session = self._sessions.get(normalized)
395
+ if not session:
396
+ return {"ok": True, "closed": 0}
397
+ if session.session_id == SYSTEM_SESSION_ID:
398
+ save_result = None
399
+ if save_first:
400
+ try:
401
+ save_result = self.save(session.session_id)
402
+ except Exception as exc:
403
+ save_result = {"ok": False, "error": str(exc)}
404
+ return {
405
+ "ok": True,
406
+ "closed": 0,
407
+ "session_id": session.session_id,
408
+ "persistent": True,
409
+ "save": save_result,
410
+ }
411
+
412
+ save_result = None
413
+ if save_first:
414
+ try:
415
+ save_result = self.save(session.session_id)
416
+ except Exception as exc:
417
+ save_result = {"ok": False, "error": str(exc)}
418
+ with self._lock:
419
+ self._sessions.pop(session.session_id, None)
420
+ virtual_desktop.unregister_session(session.token)
421
+ self._terminate_session(session, include_rehydrated=True)
422
+ self._remove_manifest(session.session_id)
423
+ return {"ok": True, "closed": 1, "session_id": session.session_id, "save": save_result}
424
+
425
+ def close_file(self, file_id: str) -> int:
426
+ return 0
427
+
428
+ def resize(self, session_id: str, width: int, height: int) -> dict[str, Any]:
429
+ session = self.get(session_id)
430
+ if not session:
431
+ return {"ok": False, "error": "LibreOffice desktop session not found."}
432
+ is_system_desktop = session.session_id == SYSTEM_SESSION_ID and session.extension == "desktop"
433
+ if is_system_desktop:
434
+ width, height = virtual_desktop.normalize_desktop_display_size(width, height)
435
+ result = virtual_desktop.resize_display(
436
+ display=session.display,
437
+ width=width,
438
+ height=height,
439
+ max_width=MAX_SCREEN_WIDTH,
440
+ max_height=MAX_SCREEN_HEIGHT,
441
+ window_class="" if is_system_desktop else "libreoffice",
442
+ keys=() if is_system_desktop else ("Escape",),
443
+ xauthority=self._xauthority(session),
444
+ home=str(session.profile_dir),
445
+ )
446
+ if result.get("ok"):
447
+ session.width = int(result["width"])
448
+ session.height = int(result["height"])
449
+ if not is_system_desktop:
450
+ self._dismiss_blocking_dialogs(session)
451
+ return result
452
+
453
+ def proxy_for_token(self, token: str) -> tuple[str, int] | None:
454
+ normalized = str(token or "").strip()
455
+ with self._lock:
456
+ session = self._sessions.get(normalized)
457
+ if not session:
458
+ session = next((item for item in self._sessions.values() if item.token == normalized), None)
459
+ if not session or not session.alive():
460
+ return None
461
+ return ("127.0.0.1", session.xpra_port)
462
+
463
+ def resize_for_token(self, token: str, width: int, height: int) -> dict[str, Any]:
464
+ normalized = str(token or "").strip()
465
+ with self._lock:
466
+ session = self._sessions.get(normalized)
467
+ if not session:
468
+ session = next((item for item in self._sessions.values() if item.token == normalized), None)
469
+ if not session:
470
+ return {"ok": False, "error": "LibreOffice desktop session not found."}
471
+ return self.resize(session.session_id, width, height)
472
+
473
+ def get(self, session_id: str) -> DesktopSession | None:
474
+ with self._lock:
475
+ session = self._sessions.get(str(session_id or "").strip())
476
+ return session if session and session.alive() else None
477
+
478
+ def require(self, session_id: str) -> DesktopSession:
479
+ session = self.get(session_id)
480
+ if not session:
481
+ raise FileNotFoundError(f"LibreOffice desktop session not found: {session_id}")
482
+ return session
483
+
484
+ def shutdown(self) -> None:
485
+ with self._lock:
486
+ sessions = list(self._sessions.values())
487
+ self._sessions.clear()
488
+ for session in sessions:
489
+ virtual_desktop.unregister_session(session.token)
490
+ if session.owns_processes:
491
+ self._terminate_session(session)
492
+ self._remove_manifest(session.session_id)
493
+
494
+ def sync_timezone(self, timezone: str | None = None) -> dict[str, Any]:
495
+ target_timezone = str(timezone or Localization.get().get_timezone()).strip()
496
+ if not target_timezone:
497
+ target_timezone = Localization.get().get_timezone()
498
+
499
+ with self._lock:
500
+ self._reap_dead_locked()
501
+ session = self._sessions.get(SYSTEM_SESSION_ID)
502
+ if not session or not session.alive():
503
+ return {"ok": True, "restarted": False, "reason": "no_active_desktop"}
504
+ if session.timezone == target_timezone:
505
+ return {"ok": True, "restarted": False, "timezone": target_timezone}
506
+
507
+ replacement = self._restart_system_desktop_for_timezone_locked(session)
508
+ return {
509
+ "ok": True,
510
+ "restarted": True,
511
+ "session_id": replacement.session_id,
512
+ "timezone": replacement.timezone,
513
+ }
514
+
515
+ def _document_for_save(self, session: DesktopSession, file_id: str = "") -> dict[str, Any] | None:
516
+ normalized = str(file_id or "").strip()
517
+ if normalized == SYSTEM_FILE_ID:
518
+ return None
519
+ if normalized and normalized != SYSTEM_FILE_ID:
520
+ return document_store.get_document(normalized)
521
+ if session.file_id and session.file_id != SYSTEM_FILE_ID:
522
+ try:
523
+ return document_store.get_document(session.file_id)
524
+ except Exception:
525
+ path = Path(session.path)
526
+ if path.is_file():
527
+ return document_store.register_document(path)
528
+ return None
529
+
530
+ def _restart_system_desktop_for_timezone_locked(self, session: DesktopSession) -> DesktopSession:
531
+ try:
532
+ doc = self._document_for_save(session, session.file_id)
533
+ except Exception:
534
+ doc = None
535
+ if doc:
536
+ try:
537
+ self.save(session.session_id, str(doc.get("file_id") or ""))
538
+ except Exception:
539
+ pass
540
+
541
+ virtual_desktop.unregister_session(session.token)
542
+ self._sessions.pop(session.session_id, None)
543
+ self._terminate_session(session, include_rehydrated=True)
544
+ self._remove_manifest(session.session_id)
545
+
546
+ replacement = self._ensure_system_desktop_locked()
547
+ if doc:
548
+ self._open_document_locked(replacement, doc)
549
+ replacement.file_id = str(doc["file_id"])
550
+ replacement.extension = str(doc["extension"])
551
+ replacement.path = str(doc["path"])
552
+ replacement.title = str(doc["basename"])
553
+ self._write_manifest(replacement)
554
+ return replacement
555
+
556
+ def _register_virtual_desktop(self, session: DesktopSession) -> None:
557
+ virtual_desktop.register_session(
558
+ token=session.token,
559
+ host="127.0.0.1",
560
+ port=session.xpra_port,
561
+ owner="desktop",
562
+ title=session.title,
563
+ resize=lambda width, height, session_id=session.session_id: self.resize(session_id, width, height),
564
+ )
565
+
566
+ def _ensure_system_desktop_locked(self) -> DesktopSession:
567
+ target_timezone = Localization.get().get_timezone()
568
+ existing = self._sessions.get(SYSTEM_SESSION_ID)
569
+ if existing and existing.alive():
570
+ if existing.timezone != target_timezone:
571
+ return self._restart_system_desktop_for_timezone_locked(existing)
572
+ self._prepare_desktop_url_bridge(existing)
573
+ self._refresh_xfce_desktop(existing)
574
+ return existing
575
+
576
+ existing = self._load_system_desktop_from_manifest_locked()
577
+ if existing:
578
+ self._sessions[existing.session_id] = existing
579
+ self._register_virtual_desktop(existing)
580
+ if existing.timezone != target_timezone:
581
+ return self._restart_system_desktop_for_timezone_locked(existing)
582
+ self._prepare_desktop_url_bridge(existing)
583
+ self._refresh_xfce_desktop(existing)
584
+ return existing
585
+
586
+ status = collect_desktop_status()
587
+ if not status["healthy"]:
588
+ raise RuntimeError(status["message"])
589
+
590
+ display, xpra_port = self._allocate_endpoint_locked()
591
+ profile_dir = PROFILE_DIR / SYSTEM_SESSION_ID
592
+ session = DesktopSession(
593
+ session_id=SYSTEM_SESSION_ID,
594
+ file_id=SYSTEM_FILE_ID,
595
+ extension="desktop",
596
+ path=str(document_store.document_binary_home()),
597
+ title=SYSTEM_TITLE,
598
+ display=display,
599
+ xpra_port=xpra_port,
600
+ token=SYSTEM_SESSION_ID,
601
+ url=_xpra_url(SYSTEM_SESSION_ID),
602
+ profile_dir=profile_dir,
603
+ timezone=target_timezone,
604
+ )
605
+ try:
606
+ self._prepare_profile(session)
607
+ self._prepare_desktop_launchers(session)
608
+ self._spawn_desktop_locked(session)
609
+ except Exception:
610
+ self._terminate_session(session)
611
+ raise
612
+ self._sessions[session.session_id] = session
613
+ self._register_virtual_desktop(session)
614
+ self._write_manifest(session)
615
+ return session
616
+
617
+ def _load_system_desktop_from_manifest_locked(self) -> DesktopSession | None:
618
+ manifest = SESSION_DIR / f"{SYSTEM_SESSION_ID}.json"
619
+ if not manifest.exists():
620
+ return None
621
+ try:
622
+ payload = json.loads(manifest.read_text(encoding="utf-8"))
623
+ display = int(payload.get("display") or 0)
624
+ xpra_port = int(payload.get("xpra_port") or 0)
625
+ process_ids = {
626
+ str(name): pid
627
+ for name, value in dict(payload.get("pids") or {}).items()
628
+ if (pid := _coerce_pid(value))
629
+ }
630
+ if not display or not xpra_port:
631
+ return None
632
+ if not _pid_is_running(process_ids.get("xpra", 0)):
633
+ return None
634
+ if not _port_is_accepting("127.0.0.1", xpra_port):
635
+ return None
636
+
637
+ path = str(payload.get("path") or document_store.document_binary_home())
638
+ file_id = str(payload.get("file_id") or SYSTEM_FILE_ID)
639
+ extension = str(payload.get("extension") or "").lower()
640
+ if not extension:
641
+ extension = "desktop" if file_id == SYSTEM_FILE_ID else Path(path).suffix.lower().lstrip(".")
642
+ title = str(payload.get("title") or "")
643
+ if not title:
644
+ title = SYSTEM_TITLE if file_id == SYSTEM_FILE_ID else Path(path).name or SYSTEM_TITLE
645
+ return DesktopSession(
646
+ session_id=SYSTEM_SESSION_ID,
647
+ file_id=file_id,
648
+ extension=extension,
649
+ path=path,
650
+ title=title,
651
+ display=display,
652
+ xpra_port=xpra_port,
653
+ token=SYSTEM_SESSION_ID,
654
+ url=_xpra_url(SYSTEM_SESSION_ID),
655
+ profile_dir=_state_path_from_retired_root(
656
+ Path(payload.get("profile_dir") or PROFILE_DIR / SYSTEM_SESSION_ID)
657
+ ),
658
+ width=int(payload.get("width") or DEFAULT_SCREEN_WIDTH),
659
+ height=int(payload.get("height") or DEFAULT_SCREEN_HEIGHT),
660
+ process_ids=process_ids,
661
+ owns_processes=False,
662
+ started_at=float(payload.get("started_at") or time.time()),
663
+ timezone=str(payload.get("timezone") or Localization.get().get_timezone()),
664
+ )
665
+ except Exception:
666
+ return None
667
+
668
+ def _spawn_desktop_locked(self, session: DesktopSession) -> None:
669
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
670
+ SESSION_DIR.mkdir(parents=True, exist_ok=True)
671
+ session.profile_dir.mkdir(parents=True, exist_ok=True)
672
+
673
+ xpra = _require_binary("xpra")
674
+ xvfb = _require_binary("Xvfb")
675
+ _require_binary("xfce4-session")
676
+ _require_binary("dbus-launch")
677
+ xfce_launcher = self._prepare_xfce_launcher(session)
678
+
679
+ session.processes["xvfb"] = subprocess.Popen(
680
+ _xvfb_command(xvfb, session),
681
+ stdin=subprocess.DEVNULL,
682
+ stdout=subprocess.DEVNULL,
683
+ stderr=subprocess.DEVNULL,
684
+ env=self._session_env(session),
685
+ )
686
+ self._wait_for_display(session)
687
+ self._set_display_size(session, session.width, session.height)
688
+ self._prepare_root_window(session)
689
+ session.processes["xfce"] = subprocess.Popen(
690
+ [str(xfce_launcher)],
691
+ stdin=subprocess.DEVNULL,
692
+ stdout=subprocess.DEVNULL,
693
+ stderr=subprocess.DEVNULL,
694
+ env=self._display_env(session),
695
+ )
696
+ self._wait_for_xfce(session)
697
+ session.processes["xpra"] = subprocess.Popen(
698
+ _xpra_shadow_command(xpra, session),
699
+ stdin=subprocess.DEVNULL,
700
+ stdout=subprocess.DEVNULL,
701
+ stderr=subprocess.DEVNULL,
702
+ env=self._display_env(session),
703
+ )
704
+ _wait_for_port(
705
+ "127.0.0.1",
706
+ session.xpra_port,
707
+ timeout=PORT_START_TIMEOUT_SECONDS,
708
+ process=session.processes.get("xpra"),
709
+ )
710
+ self._refresh_xfce_desktop(session)
711
+
712
+ def _restart_xpra_shadow(self, session: DesktopSession) -> None:
713
+ xpra = _require_binary("xpra")
714
+ process = session.processes.get("xpra")
715
+ if process:
716
+ _terminate_process(process)
717
+ session.processes["xpra"] = subprocess.Popen(
718
+ _xpra_shadow_command(xpra, session),
719
+ stdin=subprocess.DEVNULL,
720
+ stdout=subprocess.DEVNULL,
721
+ stderr=subprocess.DEVNULL,
722
+ env=self._display_env(session),
723
+ )
724
+ _wait_for_port(
725
+ "127.0.0.1",
726
+ session.xpra_port,
727
+ timeout=PORT_START_TIMEOUT_SECONDS,
728
+ process=session.processes.get("xpra"),
729
+ )
730
+
731
+ def _open_document_locked(self, session: DesktopSession, doc: dict[str, Any]) -> None:
732
+ soffice = libreoffice.find_soffice()
733
+ if not soffice:
734
+ raise RuntimeError("LibreOffice is not installed in this runtime.")
735
+ path = str(doc["path"])
736
+ self._remove_stale_lock_file(session, path=path)
737
+ process_key = f"soffice-{doc['file_id']}"
738
+ session.processes[process_key] = subprocess.Popen(
739
+ [
740
+ soffice,
741
+ "--norestore",
742
+ "--nofirststartwizard",
743
+ "--nolockcheck",
744
+ f"-env:UserInstallation=file://{session.profile_dir}",
745
+ path,
746
+ ],
747
+ cwd=str(Path(path).parent),
748
+ stdin=subprocess.DEVNULL,
749
+ stdout=subprocess.DEVNULL,
750
+ stderr=subprocess.DEVNULL,
751
+ env=self._display_env(session),
752
+ )
753
+ self._fit_office_window(session, process=session.processes[process_key])
754
+ window_id = self._wait_for_office_window_locked(
755
+ session,
756
+ title=str(doc.get("basename") or ""),
757
+ process=session.processes[process_key],
758
+ )
759
+ if not window_id:
760
+ raise RuntimeError(
761
+ f"LibreOffice did not show {doc.get('basename') or 'the document'} "
762
+ f"on desktop :{session.display}.",
763
+ )
764
+ self._fit_office_window_id_locked(
765
+ session,
766
+ window_id,
767
+ env=self._display_env(session),
768
+ keys=("Escape",),
769
+ )
770
+
771
+ def _reload_document_locked(self, session: DesktopSession, doc: dict[str, Any]) -> bool:
772
+ if self._close_document_window_locked(session, doc):
773
+ self._open_document_locked(session, doc)
774
+ return True
775
+ if self._send_reload_shortcut_locked(session, doc):
776
+ return True
777
+ self._open_document_locked(session, doc)
778
+ return False
779
+
780
+ def _close_document_window_locked(self, session: DesktopSession, doc: dict[str, Any]) -> bool:
781
+ xdotool = shutil.which("xdotool")
782
+ if not xdotool:
783
+ return False
784
+ title = str(doc.get("basename") or Path(str(doc.get("path") or "")).name or "").strip()
785
+ if not title:
786
+ return False
787
+ window_id = self._office_window_id_locked(session, title=title, fallback=False)
788
+ if not window_id:
789
+ return False
790
+ env = self._display_env(session)
791
+ self._fit_office_window_id_locked(session, window_id, env=env, keys=("Escape",))
792
+ for command in (
793
+ [xdotool, "key", "--clearmodifiers", "alt+F4"],
794
+ [xdotool, "windowclose", window_id],
795
+ ):
796
+ try:
797
+ subprocess.run(
798
+ command,
799
+ check=False,
800
+ stdout=subprocess.DEVNULL,
801
+ stderr=subprocess.DEVNULL,
802
+ timeout=3,
803
+ env=env,
804
+ )
805
+ except (OSError, subprocess.TimeoutExpired):
806
+ continue
807
+ if self._wait_for_window_closed_locked(session, window_id):
808
+ return True
809
+ self._dismiss_blocking_dialogs(session)
810
+ return self._wait_for_window_closed_locked(session, window_id, timeout_seconds=1.5)
811
+
812
+ def _send_reload_shortcut_locked(self, session: DesktopSession, doc: dict[str, Any]) -> bool:
813
+ xdotool = shutil.which("xdotool")
814
+ if not xdotool:
815
+ return False
816
+ window_id = self._office_window_id_locked(
817
+ session,
818
+ title=str(doc.get("basename") or ""),
819
+ )
820
+ if not window_id:
821
+ return False
822
+ env = self._display_env(session)
823
+ self._fit_office_window_id_locked(session, window_id, env=env, keys=("Escape",))
824
+ try:
825
+ result = subprocess.run(
826
+ [xdotool, "key", "--clearmodifiers", "ctrl+shift+r"],
827
+ check=False,
828
+ capture_output=True,
829
+ text=True,
830
+ timeout=4,
831
+ env=env,
832
+ )
833
+ except (OSError, subprocess.TimeoutExpired):
834
+ return False
835
+ time.sleep(0.8)
836
+ self._dismiss_blocking_dialogs(session)
837
+ self._fit_office_window_id_locked(session, window_id, env=env, keys=("Escape",))
838
+ return result.returncode == 0
839
+
840
+ def _office_window_id_locked(
841
+ self,
842
+ session: DesktopSession,
843
+ *,
844
+ title: str = "",
845
+ fallback: bool = True,
846
+ ) -> str:
847
+ xdotool = shutil.which("xdotool")
848
+ if not xdotool:
849
+ return ""
850
+ env = self._display_env(session)
851
+ title = str(title or "").strip()
852
+ try:
853
+ result = subprocess.run(
854
+ [xdotool, "search", "--onlyvisible", "--class", "libreoffice"],
855
+ check=False,
856
+ capture_output=True,
857
+ text=True,
858
+ timeout=2,
859
+ env=env,
860
+ )
861
+ window_ids = [line.strip() for line in result.stdout.splitlines() if line.strip()]
862
+ except (OSError, subprocess.TimeoutExpired):
863
+ window_ids = []
864
+ if title:
865
+ for window_id in reversed(window_ids):
866
+ try:
867
+ result = subprocess.run(
868
+ [xdotool, "getwindowname", window_id],
869
+ check=False,
870
+ capture_output=True,
871
+ text=True,
872
+ timeout=2,
873
+ env=env,
874
+ )
875
+ except (OSError, subprocess.TimeoutExpired):
876
+ continue
877
+ if title.casefold() in result.stdout.strip().casefold():
878
+ return window_id
879
+ if fallback and window_ids:
880
+ return window_ids[-1]
881
+ if fallback:
882
+ try:
883
+ result = subprocess.run(
884
+ [xdotool, "search", "--onlyvisible", "--name", "LibreOffice"],
885
+ check=False,
886
+ capture_output=True,
887
+ text=True,
888
+ timeout=2,
889
+ env=env,
890
+ )
891
+ window_ids = [line.strip() for line in result.stdout.splitlines() if line.strip()]
892
+ except (OSError, subprocess.TimeoutExpired):
893
+ window_ids = []
894
+ if window_ids:
895
+ return window_ids[-1]
896
+ return ""
897
+
898
+ def _wait_for_office_window_locked(
899
+ self,
900
+ session: DesktopSession,
901
+ *,
902
+ title: str = "",
903
+ process: subprocess.Popen[Any] | None = None,
904
+ timeout_seconds: float = 20.0,
905
+ ) -> str:
906
+ deadline = time.time() + timeout_seconds
907
+ last_fallback = ""
908
+ title = str(title or "").strip()
909
+ while time.time() < deadline:
910
+ window_id = self._office_window_id_locked(session, title=title, fallback=False)
911
+ if window_id:
912
+ return window_id
913
+ last_fallback = self._office_window_id_locked(session, fallback=True) or last_fallback
914
+ if last_fallback and not title:
915
+ return last_fallback
916
+ if process and process.poll() is not None and last_fallback:
917
+ return last_fallback
918
+ time.sleep(0.25)
919
+ return self._office_window_id_locked(session, title=title, fallback=True) or last_fallback
920
+
921
+ def _wait_for_window_closed_locked(
922
+ self,
923
+ session: DesktopSession,
924
+ window_id: str,
925
+ *,
926
+ timeout_seconds: float = 6.0,
927
+ ) -> bool:
928
+ deadline = time.time() + timeout_seconds
929
+ while time.time() < deadline:
930
+ if not self._window_exists_locked(session, window_id):
931
+ return True
932
+ time.sleep(0.2)
933
+ return not self._window_exists_locked(session, window_id)
934
+
935
+ def _window_exists_locked(self, session: DesktopSession, window_id: str) -> bool:
936
+ xdotool = shutil.which("xdotool")
937
+ if not xdotool or not window_id:
938
+ return False
939
+ try:
940
+ result = subprocess.run(
941
+ [xdotool, "getwindowname", str(window_id)],
942
+ check=False,
943
+ stdout=subprocess.DEVNULL,
944
+ stderr=subprocess.DEVNULL,
945
+ timeout=2,
946
+ env=self._display_env(session),
947
+ )
948
+ except (OSError, subprocess.TimeoutExpired):
949
+ return False
950
+ return result.returncode == 0
951
+
952
+ def _fit_office_window_id_locked(
953
+ self,
954
+ session: DesktopSession,
955
+ window_id: str,
956
+ *,
957
+ env: dict[str, str],
958
+ keys: tuple[str, ...] = (),
959
+ ) -> None:
960
+ xdotool = shutil.which("xdotool")
961
+ if not xdotool or not window_id:
962
+ return
963
+ for command in (
964
+ [xdotool, "windowactivate", window_id],
965
+ [
966
+ xdotool,
967
+ "windowmove",
968
+ window_id,
969
+ "0",
970
+ "0",
971
+ "windowsize",
972
+ window_id,
973
+ str(session.width),
974
+ str(session.height),
975
+ ],
976
+ ):
977
+ try:
978
+ subprocess.run(
979
+ command,
980
+ check=False,
981
+ stdout=subprocess.DEVNULL,
982
+ stderr=subprocess.DEVNULL,
983
+ timeout=2,
984
+ env=env,
985
+ )
986
+ except (OSError, subprocess.TimeoutExpired):
987
+ continue
988
+ for key in keys:
989
+ try:
990
+ subprocess.run(
991
+ [xdotool, "key", "--clearmodifiers", key],
992
+ check=False,
993
+ stdout=subprocess.DEVNULL,
994
+ stderr=subprocess.DEVNULL,
995
+ timeout=2,
996
+ env=env,
997
+ )
998
+ except (OSError, subprocess.TimeoutExpired):
999
+ continue
1000
+
1001
+ def _prepare_profile(self, session: DesktopSession) -> None:
1002
+ user_dir = session.profile_dir / "user"
1003
+ user_dir.mkdir(parents=True, exist_ok=True)
1004
+ registry = user_dir / "registrymodifications.xcu"
1005
+ _write_libreoffice_registry_defaults(registry, document_store.document_home())
1006
+
1007
+ def _prepare_desktop_launchers(self, session: DesktopSession) -> None:
1008
+ soffice = libreoffice.find_soffice()
1009
+ if not soffice:
1010
+ raise RuntimeError("LibreOffice is not installed in this runtime.")
1011
+ workdir_home = document_store.document_home()
1012
+ workdir_home.mkdir(parents=True, exist_ok=True)
1013
+ documents_home = document_store.document_binary_home()
1014
+ documents_home.mkdir(parents=True, exist_ok=True)
1015
+ downloads_home = Path(files.get_abs_path("usr", "downloads"))
1016
+ downloads_home.mkdir(parents=True, exist_ok=True)
1017
+
1018
+ desktop_dir = session.profile_dir / "Desktop"
1019
+ desktop_dir.mkdir(parents=True, exist_ok=True)
1020
+ _install_desktop_readme(desktop_dir)
1021
+ _remove_path_if_owned(desktop_dir / "Browser.desktop")
1022
+ _remove_path_if_owned(desktop_dir / "Files.desktop")
1023
+ config_dir = session.profile_dir / ".config"
1024
+ config_dir.mkdir(parents=True, exist_ok=True)
1025
+ _remove_path_if_owned(config_dir / "xfce4" / "panel")
1026
+ data_dir = session.profile_dir / ".local" / "share"
1027
+ data_dir.mkdir(parents=True, exist_ok=True)
1028
+ applications_dir = data_dir / "applications"
1029
+ applications_dir.mkdir(parents=True, exist_ok=True)
1030
+ cache_dir = session.profile_dir / ".cache"
1031
+ cache_dir.mkdir(parents=True, exist_ok=True)
1032
+ (config_dir / "user-dirs.dirs").write_text(
1033
+ "\n".join(
1034
+ [
1035
+ 'XDG_DESKTOP_DIR="$HOME/Desktop"',
1036
+ f'XDG_DOCUMENTS_DIR="{workdir_home}"',
1037
+ f'XDG_DOWNLOAD_DIR="{downloads_home}"',
1038
+ f'XDG_TEMPLATES_DIR="{workdir_home}"',
1039
+ f'XDG_PUBLICSHARE_DIR="{workdir_home}"',
1040
+ f'XDG_MUSIC_DIR="{workdir_home}"',
1041
+ f'XDG_PICTURES_DIR="{downloads_home}"',
1042
+ f'XDG_VIDEOS_DIR="{workdir_home}"',
1043
+ "",
1044
+ ],
1045
+ ),
1046
+ encoding="utf-8",
1047
+ )
1048
+ xfce_conf_dir = config_dir / "xfce4" / "xfconf" / "xfce-perchannel-xml"
1049
+ xfce_conf_dir.mkdir(parents=True, exist_ok=True)
1050
+ (xfce_conf_dir / "xfce4-desktop.xml").write_text(
1051
+ f"""<?xml version="1.1" encoding="UTF-8"?>
1052
+
1053
+ <channel name="xfce4-desktop" version="1.0">
1054
+ <property name="last-settings-migration-version" type="uint" value="1"/>
1055
+ <property name="backdrop" type="empty">
1056
+ <property name="screen0" type="empty">
1057
+ <property name="monitor0" type="empty">
1058
+ <property name="image-path" type="string" value="{_xml_attr(str(downloads_home))}"/>
1059
+ </property>
1060
+ </property>
1061
+ </property>
1062
+ <property name="desktop-icons" type="empty">
1063
+ <property name="style" type="int" value="2"/>
1064
+ <property name="file-icons" type="empty">
1065
+ <property name="show-home" type="bool" value="false"/>
1066
+ <property name="show-filesystem" type="bool" value="false"/>
1067
+ <property name="show-removable" type="bool" value="false"/>
1068
+ <property name="show-trash" type="bool" value="false"/>
1069
+ </property>
1070
+ </property>
1071
+ </channel>
1072
+ """,
1073
+ encoding="utf-8",
1074
+ )
1075
+ _write_thunar_defaults(xfce_conf_dir / "thunar.xml")
1076
+ self._hide_xpra_desktop_entries(applications_dir)
1077
+ self._hide_xfce_menu_entries(applications_dir)
1078
+ self._prepare_desktop_url_bridge(session)
1079
+
1080
+ base_args = (
1081
+ soffice,
1082
+ "--norestore",
1083
+ "--nofirststartwizard",
1084
+ "--nolockcheck",
1085
+ f"-env:UserInstallation=file://{session.profile_dir}",
1086
+ )
1087
+ office_launchers = (
1088
+ ("LibreOffice Writer", "libreoffice-writer", "--writer", "Office;WordProcessor;"),
1089
+ ("LibreOffice Calc", "libreoffice-calc", "--calc", "Office;Spreadsheet;"),
1090
+ ("LibreOffice Impress", "libreoffice-impress", "--impress", "Office;Presentation;"),
1091
+ )
1092
+ for name, icon, mode, categories in office_launchers:
1093
+ _write_desktop_launcher(
1094
+ desktop_dir / f"{name}.desktop",
1095
+ name=name,
1096
+ exec_line=_desktop_exec(*base_args, mode),
1097
+ icon=icon,
1098
+ categories=categories,
1099
+ try_exec=soffice,
1100
+ working_dir=workdir_home,
1101
+ )
1102
+
1103
+ terminal = shutil.which("xfce4-terminal") or "xfce4-terminal"
1104
+ settings = shutil.which("xfce4-settings-manager") or "xfce4-settings-manager"
1105
+ desktop_apps = (
1106
+ {
1107
+ "filename": "Terminal.desktop",
1108
+ "name": "Terminal",
1109
+ "exec": _desktop_exec(terminal, f"--working-directory={workdir_home}"),
1110
+ "try_exec": terminal,
1111
+ "icon": _desktop_icon(
1112
+ "/usr/share/icons/hicolor/128x128/apps/org.xfce.terminal.png",
1113
+ "/usr/share/icons/hicolor/scalable/apps/org.xfce.terminal.svg",
1114
+ "org.xfce.terminal",
1115
+ "utilities-terminal",
1116
+ ),
1117
+ "categories": "System;TerminalEmulator;",
1118
+ },
1119
+ {
1120
+ "filename": "Settings.desktop",
1121
+ "name": "Settings",
1122
+ "exec": _desktop_exec(settings),
1123
+ "try_exec": settings,
1124
+ "icon": _desktop_icon(
1125
+ "/usr/share/icons/hicolor/128x128/apps/org.xfce.settings.manager.png",
1126
+ "/usr/share/icons/hicolor/scalable/apps/org.xfce.settings.manager.svg",
1127
+ "org.xfce.settings.manager",
1128
+ "preferences-system",
1129
+ ),
1130
+ "categories": "Settings;DesktopSettings;",
1131
+ },
1132
+ )
1133
+ for app in desktop_apps:
1134
+ _write_desktop_launcher(
1135
+ desktop_dir / str(app["filename"]),
1136
+ name=str(app["name"]),
1137
+ exec_line=str(app["exec"]),
1138
+ icon=str(app["icon"]),
1139
+ categories=str(app["categories"]),
1140
+ try_exec=str(app["try_exec"]),
1141
+ )
1142
+ _ensure_desktop_folder_link(desktop_dir, "Workdir", workdir_home)
1143
+ for label, target_parts in DESKTOP_FOLDER_LINKS:
1144
+ _ensure_desktop_folder_link(desktop_dir, label, Path(files.get_abs_path(*target_parts)))
1145
+
1146
+ self._trust_desktop_launchers(session, desktop_dir)
1147
+ self._prepare_xfce_panel_config(session)
1148
+ self._prepare_xfce_profile_autostart(session)
1149
+
1150
+ def _prepare_desktop_url_bridge(self, session: DesktopSession) -> None:
1151
+ desktop_dir = session.profile_dir / "Desktop"
1152
+ config_dir = session.profile_dir / ".config"
1153
+ data_dir = session.profile_dir / ".local" / "share"
1154
+ applications_dir = data_dir / "applications"
1155
+ desktop_dir.mkdir(parents=True, exist_ok=True)
1156
+ config_dir.mkdir(parents=True, exist_ok=True)
1157
+ applications_dir.mkdir(parents=True, exist_ok=True)
1158
+
1159
+ browser_bridge = _write_url_bridge_script(session)
1160
+ editor_bridge = _write_editor_bridge_script(session)
1161
+ shutdown_bridge = _write_shutdown_bridge_script(session)
1162
+ helpers_rc = config_dir / "xfce4" / "helpers.rc"
1163
+ helpers_rc.parent.mkdir(parents=True, exist_ok=True)
1164
+ helpers_rc.write_text(
1165
+ "\n".join(
1166
+ [
1167
+ "TerminalEmulator=xfce4-terminal",
1168
+ "FileManager=thunar",
1169
+ "WebBrowser=agent-zero-browser",
1170
+ "",
1171
+ ],
1172
+ ),
1173
+ encoding="utf-8",
1174
+ )
1175
+ _write_xfce_browser_helper(
1176
+ config_dir / "xfce4" / "helpers" / "agent-zero-browser.desktop",
1177
+ browser_bridge,
1178
+ )
1179
+ _write_mimeapps_defaults(config_dir / "mimeapps.list", URL_HANDLER_DESKTOP_ID, EDITOR_HANDLER_DESKTOP_ID)
1180
+ _write_mimeapps_defaults(
1181
+ data_dir / "applications" / "mimeapps.list",
1182
+ URL_HANDLER_DESKTOP_ID,
1183
+ EDITOR_HANDLER_DESKTOP_ID,
1184
+ )
1185
+ _write_desktop_launcher(
1186
+ applications_dir / URL_HANDLER_DESKTOP_ID,
1187
+ name="Agent Zero Browser",
1188
+ exec_line=_desktop_exec(browser_bridge, "%U"),
1189
+ icon="web-browser",
1190
+ categories="Network;WebBrowser;",
1191
+ try_exec=str(browser_bridge),
1192
+ mime_types=_url_handler_mime_types(),
1193
+ no_display=True,
1194
+ )
1195
+ _write_desktop_launcher(
1196
+ applications_dir / EDITOR_HANDLER_DESKTOP_ID,
1197
+ name="Agent Zero Editor",
1198
+ exec_line=_desktop_exec(editor_bridge, "%F"),
1199
+ icon="accessories-text-editor",
1200
+ categories="Utility;TextEditor;",
1201
+ try_exec=str(editor_bridge),
1202
+ mime_types=_editor_text_handler_mime_types(),
1203
+ )
1204
+ _write_desktop_launcher(
1205
+ applications_dir / SHUTDOWN_HANDLER_DESKTOP_ID,
1206
+ name="Shutdown Desktop",
1207
+ exec_line=_desktop_exec(shutdown_bridge),
1208
+ icon="system-shutdown",
1209
+ categories="System;",
1210
+ try_exec=str(shutdown_bridge),
1211
+ no_display=True,
1212
+ )
1213
+ _write_desktop_launcher(
1214
+ config_dir / "xfce4" / "panel" / "launcher-9" / SHUTDOWN_HANDLER_DESKTOP_ID,
1215
+ name="Shutdown Desktop",
1216
+ exec_line=_desktop_exec(shutdown_bridge),
1217
+ icon="system-shutdown",
1218
+ categories="System;",
1219
+ try_exec=str(shutdown_bridge),
1220
+ )
1221
+ _write_desktop_launcher(
1222
+ desktop_dir / "Browser.desktop",
1223
+ name="Browser",
1224
+ exec_line=_desktop_exec(browser_bridge),
1225
+ icon="web-browser",
1226
+ categories="Network;WebBrowser;",
1227
+ try_exec=str(browser_bridge),
1228
+ )
1229
+ _write_desktop_launcher(
1230
+ desktop_dir / "Editor.desktop",
1231
+ name="Editor",
1232
+ exec_line=_desktop_exec(editor_bridge),
1233
+ icon="accessories-text-editor",
1234
+ categories="Utility;TextEditor;",
1235
+ try_exec=str(editor_bridge),
1236
+ )
1237
+ self._trust_desktop_launchers(session, desktop_dir)
1238
+
1239
+ def _hide_xpra_desktop_entries(self, applications_dir: Path) -> None:
1240
+ for filename in HIDDEN_XPRA_DESKTOP_ENTRIES:
1241
+ _write_hidden_application_entry(applications_dir / filename, "Xpra")
1242
+
1243
+ def _hide_xfce_menu_entries(self, applications_dir: Path) -> None:
1244
+ for filename, name in HIDDEN_XFCE_MENU_ENTRIES:
1245
+ _write_hidden_application_entry(applications_dir / filename, name)
1246
+
1247
+ def _prepare_xfce_panel_config(self, session: DesktopSession) -> None:
1248
+ panel_xml = (
1249
+ session.profile_dir
1250
+ / ".config"
1251
+ / "xfce4"
1252
+ / "xfconf"
1253
+ / "xfce-perchannel-xml"
1254
+ / "xfce4-panel.xml"
1255
+ )
1256
+ panel_xml.parent.mkdir(parents=True, exist_ok=True)
1257
+
1258
+ root = ET.Element("channel", {"name": "xfce4-panel", "version": "1.0"})
1259
+ ET.SubElement(root, "property", {"name": "configver", "type": "int", "value": "2"})
1260
+
1261
+ panels = ET.SubElement(root, "property", {"name": "panels", "type": "array"})
1262
+ ET.SubElement(panels, "value", {"type": "int", "value": "1"})
1263
+ panel = ET.SubElement(panels, "property", {"name": "panel-1", "type": "empty"})
1264
+ for name, prop_type, value in (
1265
+ ("position", "string", "p=6;x=0;y=0"),
1266
+ ("length", "uint", "100"),
1267
+ ("position-locked", "bool", "true"),
1268
+ ("size", "uint", "24"),
1269
+ ("mode", "uint", "0"),
1270
+ ("autohide-behavior", "uint", "0"),
1271
+ ("disable-struts", "bool", "false"),
1272
+ ("nrows", "uint", "1"),
1273
+ ):
1274
+ ET.SubElement(panel, "property", {"name": name, "type": prop_type, "value": value})
1275
+ plugin_ids = ET.SubElement(panel, "property", {"name": "plugin-ids", "type": "array"})
1276
+ for plugin_id in ("1", "2", "3", "4", "5", "6", "7", "8", "9"):
1277
+ ET.SubElement(plugin_ids, "value", {"type": "int", "value": plugin_id})
1278
+
1279
+ plugins = ET.SubElement(root, "property", {"name": "plugins", "type": "empty"})
1280
+ ET.SubElement(plugins, "property", {"name": "plugin-1", "type": "string", "value": "applicationsmenu"})
1281
+ ET.SubElement(plugins, "property", {"name": "plugin-2", "type": "string", "value": "tasklist"})
1282
+ tasklist = _xfce_property(plugins, "plugin-2", "string", "tasklist")
1283
+ ET.SubElement(tasklist, "property", {"name": "flat-buttons", "type": "bool", "value": "true"})
1284
+ ET.SubElement(tasklist, "property", {"name": "show-handle", "type": "bool", "value": "false"})
1285
+ ET.SubElement(tasklist, "property", {"name": "show-labels", "type": "bool", "value": "true"})
1286
+ separator = ET.SubElement(plugins, "property", {"name": "plugin-3", "type": "string", "value": "separator"})
1287
+ ET.SubElement(separator, "property", {"name": "expand", "type": "bool", "value": "true"})
1288
+ ET.SubElement(separator, "property", {"name": "style", "type": "uint", "value": "0"})
1289
+ ET.SubElement(plugins, "property", {"name": "plugin-4", "type": "string", "value": "pager"})
1290
+ ET.SubElement(plugins, "property", {"name": "plugin-5", "type": "string", "value": "systray"})
1291
+ ET.SubElement(plugins, "property", {"name": "plugin-6", "type": "string", "value": "separator"})
1292
+ ET.SubElement(plugins, "property", {"name": "plugin-7", "type": "string", "value": "clock"})
1293
+ ET.SubElement(plugins, "property", {"name": "plugin-8", "type": "string", "value": "separator"})
1294
+ shutdown = ET.SubElement(plugins, "property", {"name": "plugin-9", "type": "string", "value": "launcher"})
1295
+ shutdown_items = ET.SubElement(shutdown, "property", {"name": "items", "type": "array"})
1296
+ ET.SubElement(shutdown_items, "value", {"type": "string", "value": SHUTDOWN_PANEL_LAUNCHER_ID})
1297
+
1298
+ tree = ET.ElementTree(root)
1299
+ try:
1300
+ ET.indent(tree, space=" ")
1301
+ except AttributeError:
1302
+ pass
1303
+ tree.write(panel_xml, encoding="utf-8", xml_declaration=True)
1304
+
1305
+ def _prepare_xfce_profile_autostart(self, session: DesktopSession) -> None:
1306
+ script = session.profile_dir / "prepare-xfce-profile.sh"
1307
+ script.write_text(
1308
+ """#!/bin/sh
1309
+ set -eu
1310
+ export HOME="${HOME:-%s}"
1311
+ export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
1312
+ export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"
1313
+ export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"
1314
+ export XDG_CONFIG_DIRS="/etc/xdg${XDG_CONFIG_DIRS:+:$XDG_CONFIG_DIRS}"
1315
+ export XDG_DATA_DIRS="/usr/local/share:/usr/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
1316
+ export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-XFCE}"
1317
+ mkdir -p "$HOME/Desktop" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME" "$XDG_CACHE_HOME"
1318
+ if command -v xfconf-query >/dev/null 2>&1; then
1319
+ xfconf-query -c thunar -p /last-show-hidden -n -t bool -s true >/dev/null 2>&1 || true
1320
+ xfconf-query -c xfce4-desktop -p /desktop-icons/style -n -t int -s 2 >/dev/null 2>&1 || true
1321
+ xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-home -n -t bool -s false >/dev/null 2>&1 || true
1322
+ xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-filesystem -n -t bool -s false >/dev/null 2>&1 || true
1323
+ xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-removable -n -t bool -s false >/dev/null 2>&1 || true
1324
+ xfconf-query -c xfce4-desktop -p /desktop-icons/file-icons/show-trash -n -t bool -s false >/dev/null 2>&1 || true
1325
+ fi
1326
+ for launcher in "$HOME"/Desktop/*.desktop; do
1327
+ [ -f "$launcher" ] || continue
1328
+ chmod +x "$launcher" 2>/dev/null || true
1329
+ if command -v gio >/dev/null 2>&1; then
1330
+ checksum="$(sha256sum "$launcher" 2>/dev/null | cut -d " " -f 1)"
1331
+ gio set "$launcher" metadata::trusted true >/dev/null 2>&1 || true
1332
+ if [ -n "$checksum" ]; then
1333
+ gio set -t string "$launcher" metadata::xfce-exe-checksum "$checksum" >/dev/null 2>&1 || true
1334
+ fi
1335
+ fi
1336
+ done
1337
+ if command -v xfdesktop >/dev/null 2>&1; then
1338
+ timeout 4 xfdesktop --reload >/dev/null 2>&1 || true
1339
+ fi
1340
+ """ % str(session.profile_dir),
1341
+ encoding="utf-8",
1342
+ )
1343
+ try:
1344
+ script.chmod(0o700)
1345
+ except OSError:
1346
+ pass
1347
+
1348
+ autostart_dir = session.profile_dir / ".config" / "autostart"
1349
+ autostart_dir.mkdir(parents=True, exist_ok=True)
1350
+ autostart = autostart_dir / "agent-zero-desktop.desktop"
1351
+ autostart.write_text(
1352
+ "\n".join(
1353
+ [
1354
+ "[Desktop Entry]",
1355
+ "Type=Application",
1356
+ "Name=Agent Zero desktop profile",
1357
+ f"Exec={script}",
1358
+ "Terminal=false",
1359
+ "OnlyShowIn=XFCE;",
1360
+ "X-GNOME-Autostart-enabled=true",
1361
+ "",
1362
+ ],
1363
+ ),
1364
+ encoding="utf-8",
1365
+ )
1366
+
1367
+ def _prepare_xfce_launcher(self, session: DesktopSession) -> Path:
1368
+ launcher = session.profile_dir / "start-xfce.sh"
1369
+ launcher.write_text(
1370
+ "\n".join(
1371
+ [
1372
+ "#!/bin/sh",
1373
+ 'export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"',
1374
+ 'export XDG_DATA_HOME="${XDG_DATA_HOME:-$HOME/.local/share}"',
1375
+ 'export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$HOME/.cache}"',
1376
+ 'export XDG_CONFIG_DIRS="/etc/xdg${XDG_CONFIG_DIRS:+:$XDG_CONFIG_DIRS}"',
1377
+ 'export XDG_DATA_DIRS="/usr/local/share:/usr/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"',
1378
+ 'export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-XFCE}"',
1379
+ (
1380
+ "exec dbus-launch --exit-with-session sh -c "
1381
+ f"'\"{session.profile_dir / 'prepare-xfce-profile.sh'}\" >/dev/null 2>&1 || true; exec xfce4-session'"
1382
+ ),
1383
+ "",
1384
+ ],
1385
+ ),
1386
+ encoding="utf-8",
1387
+ )
1388
+ try:
1389
+ launcher.chmod(0o700)
1390
+ except OSError:
1391
+ pass
1392
+ return launcher
1393
+
1394
+ def _prepare_root_window(self, session: DesktopSession) -> None:
1395
+ xsetroot = shutil.which("xsetroot")
1396
+ if not xsetroot:
1397
+ return
1398
+ subprocess.run(
1399
+ [xsetroot, "-solid", "#20242a"],
1400
+ check=False,
1401
+ stdout=subprocess.DEVNULL,
1402
+ stderr=subprocess.DEVNULL,
1403
+ timeout=2,
1404
+ env=self._display_env(session),
1405
+ )
1406
+
1407
+ def _fit_office_window(
1408
+ self,
1409
+ session: DesktopSession,
1410
+ *,
1411
+ process: subprocess.Popen[Any] | None = None,
1412
+ ) -> None:
1413
+ virtual_desktop.fit_window_until(
1414
+ display=session.display,
1415
+ width=session.width,
1416
+ height=session.height,
1417
+ window_class="libreoffice",
1418
+ keys=("Escape",),
1419
+ settle_seconds=4,
1420
+ timeout_seconds=10,
1421
+ process=process,
1422
+ xauthority=self._xauthority(session),
1423
+ home=str(session.profile_dir),
1424
+ )
1425
+ self._dismiss_blocking_dialogs(session)
1426
+
1427
+ def _set_display_size(self, session: DesktopSession, width: int, height: int) -> dict[str, Any]:
1428
+ result = virtual_desktop.resize_display(
1429
+ display=session.display,
1430
+ width=width,
1431
+ height=height,
1432
+ max_width=MAX_SCREEN_WIDTH,
1433
+ max_height=MAX_SCREEN_HEIGHT,
1434
+ window_class="",
1435
+ keys=(),
1436
+ xauthority=self._xauthority(session),
1437
+ home=str(session.profile_dir),
1438
+ )
1439
+ if result.get("ok"):
1440
+ session.width = int(result["width"])
1441
+ session.height = int(result["height"])
1442
+ return result
1443
+
1444
+ def _dismiss_blocking_dialogs(self, session: DesktopSession) -> None:
1445
+ virtual_desktop.close_windows(
1446
+ display=session.display,
1447
+ names=BLOCKING_DIALOG_TITLES,
1448
+ xauthority=self._xauthority(session),
1449
+ home=str(session.profile_dir),
1450
+ )
1451
+
1452
+ def _refresh_xfce_desktop(self, session: DesktopSession) -> None:
1453
+ xfdesktop = shutil.which("xfdesktop")
1454
+ if not xfdesktop:
1455
+ return
1456
+ env = self._xfce_process_env(session, "xfdesktop")
1457
+ try:
1458
+ subprocess.run(
1459
+ [xfdesktop, "--reload"],
1460
+ check=False,
1461
+ stdout=subprocess.DEVNULL,
1462
+ stderr=subprocess.DEVNULL,
1463
+ timeout=4,
1464
+ env=env,
1465
+ )
1466
+ except (OSError, subprocess.TimeoutExpired):
1467
+ return
1468
+
1469
+ def _trust_desktop_launchers(self, session: DesktopSession, desktop_dir: Path) -> None:
1470
+ gio = shutil.which("gio")
1471
+ if not gio:
1472
+ return
1473
+ env = self._xfce_process_env(session, "xfdesktop")
1474
+ for launcher in desktop_dir.glob("*.desktop"):
1475
+ try:
1476
+ launcher.chmod(0o755)
1477
+ checksum = hashlib.sha256(launcher.read_bytes()).hexdigest()
1478
+ except OSError:
1479
+ continue
1480
+ for command in (
1481
+ [gio, "set", str(launcher), "metadata::trusted", "true"],
1482
+ [gio, "set", "-t", "string", str(launcher), "metadata::xfce-exe-checksum", checksum],
1483
+ ):
1484
+ try:
1485
+ subprocess.run(
1486
+ command,
1487
+ check=False,
1488
+ stdout=subprocess.DEVNULL,
1489
+ stderr=subprocess.DEVNULL,
1490
+ timeout=4,
1491
+ env=env,
1492
+ )
1493
+ except (OSError, subprocess.TimeoutExpired):
1494
+ continue
1495
+
1496
+ def _xfce_process_env(self, session: DesktopSession, command_name: str) -> dict[str, str]:
1497
+ env = self._display_env(session)
1498
+ proc = Path("/proc")
1499
+ for candidate in proc.iterdir():
1500
+ if not candidate.name.isdigit():
1501
+ continue
1502
+ try:
1503
+ if (candidate / "comm").read_text(encoding="utf-8").strip() != command_name:
1504
+ continue
1505
+ process_env = self._read_process_env(candidate)
1506
+ except OSError:
1507
+ continue
1508
+ if process_env.get("HOME") != str(session.profile_dir):
1509
+ continue
1510
+ if process_env.get("DISPLAY") != f":{session.display}":
1511
+ continue
1512
+ for key, value in process_env.items():
1513
+ if (
1514
+ key in {"DBUS_SESSION_BUS_ADDRESS", "DISPLAY", "HOME", "XAUTHORITY"}
1515
+ or key.startswith("XDG_")
1516
+ ):
1517
+ env[key] = value
1518
+ break
1519
+ return env
1520
+
1521
+ def _read_process_env(self, proc_dir: Path) -> dict[str, str]:
1522
+ raw = (proc_dir / "environ").read_bytes()
1523
+ env: dict[str, str] = {}
1524
+ for item in raw.split(b"\0"):
1525
+ if not item or b"=" not in item:
1526
+ continue
1527
+ key, value = item.split(b"=", 1)
1528
+ env[key.decode("utf-8", errors="ignore")] = value.decode("utf-8", errors="ignore")
1529
+ return env
1530
+
1531
+ def _session_env(self, session: DesktopSession) -> dict[str, str]:
1532
+ env = {
1533
+ **os.environ,
1534
+ "HOME": str(session.profile_dir),
1535
+ "LANG": os.environ.get("LANG") or "C.UTF-8",
1536
+ "TZ": session.timezone or Localization.get().get_timezone(),
1537
+ }
1538
+ browser_bridge = _url_bridge_script_path(session)
1539
+ if browser_bridge.exists():
1540
+ env["BROWSER"] = str(browser_bridge)
1541
+ env.setdefault("XDG_RUNTIME_DIR", str(STATE_DIR / "xdg-runtime"))
1542
+ runtime_dir = Path(env["XDG_RUNTIME_DIR"])
1543
+ runtime_dir.mkdir(parents=True, exist_ok=True)
1544
+ try:
1545
+ runtime_dir.chmod(0o700)
1546
+ except OSError:
1547
+ pass
1548
+ return env
1549
+
1550
+ def _display_env(self, session: DesktopSession) -> dict[str, str]:
1551
+ env = {
1552
+ **self._session_env(session),
1553
+ "DISPLAY": f":{session.display}",
1554
+ "SAL_USE_VCLPLUGIN": os.environ.get("SAL_USE_VCLPLUGIN") or "gtk3",
1555
+ }
1556
+ xauthority = self._xauthority(session)
1557
+ if xauthority:
1558
+ env["XAUTHORITY"] = xauthority
1559
+ return env
1560
+
1561
+ def _xauthority(self, session: DesktopSession) -> str:
1562
+ path = session.profile_dir / ".Xauthority"
1563
+ return str(path) if path.exists() else ""
1564
+
1565
+ def _allocate_endpoint_locked(self) -> tuple[int, int]:
1566
+ used_displays = {session.display for session in self._sessions.values()}
1567
+ used_ports = {session.xpra_port for session in self._sessions.values()}
1568
+ for offset in range(MAX_SESSIONS):
1569
+ display = DISPLAY_BASE + offset
1570
+ port = XPRA_PORT_BASE + offset
1571
+ if display in used_displays or port in used_ports:
1572
+ continue
1573
+ if _port_is_free(port):
1574
+ return display, port
1575
+ raise RuntimeError("No LibreOffice desktop slots are available.")
1576
+
1577
+ def _find_by_file_id_locked(self, file_id: str) -> DesktopSession | None:
1578
+ for session in self._sessions.values():
1579
+ if session.file_id == file_id and session.alive():
1580
+ return session
1581
+ return None
1582
+
1583
+ def _find_by_file_id(self, file_id: str) -> DesktopSession | None:
1584
+ with self._lock:
1585
+ return self._find_by_file_id_locked(str(file_id or "").strip())
1586
+
1587
+ def _reap_dead_locked(self) -> None:
1588
+ for session_id, session in list(self._sessions.items()):
1589
+ if not session.alive():
1590
+ self._terminate_session(session)
1591
+ virtual_desktop.unregister_session(session.token)
1592
+ self._sessions.pop(session_id, None)
1593
+ self._remove_manifest(session_id)
1594
+
1595
+ def _wait_for_display(self, session: DesktopSession) -> None:
1596
+ deadline = time.time() + DISPLAY_START_TIMEOUT_SECONDS
1597
+ while time.time() < deadline:
1598
+ process = session.processes.get("xvfb") or session.processes.get("xpra")
1599
+ if process and process.poll() is not None:
1600
+ raise RuntimeError("The LibreOffice X display exited before it was ready.")
1601
+ try:
1602
+ if virtual_desktop.current_display_size(
1603
+ session.display,
1604
+ xauthority=self._xauthority(session),
1605
+ home=str(session.profile_dir),
1606
+ ):
1607
+ return
1608
+ except (OSError, subprocess.TimeoutExpired):
1609
+ pass
1610
+ time.sleep(0.1)
1611
+ raise TimeoutError("Timed out waiting for the LibreOffice X display.")
1612
+
1613
+ def _wait_for_xfce(self, session: DesktopSession) -> None:
1614
+ deadline = time.time() + STARTUP_GRACE_SECONDS
1615
+ while time.time() < deadline:
1616
+ process = session.processes.get("xfce")
1617
+ if process and process.poll() is not None:
1618
+ raise RuntimeError("The XFCE desktop session exited before it was ready.")
1619
+ if virtual_desktop.has_window(
1620
+ display=session.display,
1621
+ name="xfce4-panel",
1622
+ xauthority=self._xauthority(session),
1623
+ home=str(session.profile_dir),
1624
+ ):
1625
+ return
1626
+ time.sleep(0.25)
1627
+
1628
+ def _write_manifest(self, session: DesktopSession) -> None:
1629
+ SESSION_DIR.mkdir(parents=True, exist_ok=True)
1630
+ pids = dict(session.process_ids)
1631
+ pids.update({name: process.pid for name, process in session.processes.items()})
1632
+ payload = {
1633
+ "session_id": session.session_id,
1634
+ "file_id": session.file_id,
1635
+ "extension": session.extension,
1636
+ "path": session.path,
1637
+ "title": session.title,
1638
+ "display": session.display,
1639
+ "xpra_port": session.xpra_port,
1640
+ "profile_dir": str(session.profile_dir),
1641
+ "width": session.width,
1642
+ "height": session.height,
1643
+ "started_at": session.started_at,
1644
+ "timezone": session.timezone,
1645
+ "owner_pid": os.getpid(),
1646
+ "pids": pids,
1647
+ }
1648
+ manifest = SESSION_DIR / f"{session.session_id}.json"
1649
+ tmp = manifest.with_name(f".{manifest.name}.{uuid.uuid4().hex}.tmp")
1650
+ try:
1651
+ tmp.write_text(json.dumps(payload), encoding="utf-8")
1652
+ os.replace(tmp, manifest)
1653
+ finally:
1654
+ tmp.unlink(missing_ok=True)
1655
+
1656
+ def _remove_manifest(self, session_id: str) -> None:
1657
+ (SESSION_DIR / f"{session_id}.json").unlink(missing_ok=True)
1658
+
1659
+ def _terminate_session(self, session: DesktopSession, *, include_rehydrated: bool = False) -> None:
1660
+ process_names = [name for name in session.processes if name.startswith("soffice")]
1661
+ process_names.extend(["xfce", "xpra", "xvfb"])
1662
+ terminated_pids: set[int] = set()
1663
+ for name in process_names:
1664
+ process = session.processes.get(name)
1665
+ if not process:
1666
+ continue
1667
+ if process.pid:
1668
+ terminated_pids.add(process.pid)
1669
+ _terminate_process(process)
1670
+ if session.owns_processes or include_rehydrated:
1671
+ for name, pid in session.process_ids.items():
1672
+ if pid in terminated_pids:
1673
+ continue
1674
+ if name.startswith("soffice") or name in {"xfce", "xpra", "xvfb"}:
1675
+ _kill_pid(pid)
1676
+ self._remove_stale_lock_file(session)
1677
+
1678
+ def _remove_stale_lock_file(self, session: DesktopSession, *, path: str | Path | None = None) -> None:
1679
+ path = Path(path or session.path)
1680
+ if not path.name:
1681
+ return
1682
+ lock_file = path.with_name(f".~lock.{path.name}#")
1683
+ try:
1684
+ lock_file.unlink(missing_ok=True)
1685
+ except OSError:
1686
+ pass
1687
+
1688
+
1689
+ def collect_desktop_status() -> dict[str, Any]:
1690
+ desktop = virtual_desktop.collect_status()
1691
+ binaries = {
1692
+ **desktop["binaries"],
1693
+ "soffice": libreoffice.find_soffice(),
1694
+ "thunar": shutil.which("thunar") or "",
1695
+ "xfce4-terminal": shutil.which("xfce4-terminal") or "",
1696
+ "xfce4-settings-manager": shutil.which("xfce4-settings-manager") or "",
1697
+ "gio": shutil.which("gio") or "",
1698
+ }
1699
+ missing = [
1700
+ name
1701
+ for name in (
1702
+ "soffice",
1703
+ "thunar",
1704
+ "xfce4-terminal",
1705
+ "xfce4-settings-manager",
1706
+ "gio",
1707
+ )
1708
+ if not binaries[name]
1709
+ ]
1710
+ missing.extend(
1711
+ name
1712
+ for name in ("xpra", "Xvfb", "xfce4-session", "dbus-launch", "xrandr", "xdotool")
1713
+ if not binaries.get(name)
1714
+ )
1715
+ if not desktop.get("xpra_html_root"):
1716
+ missing.append("xpra-html5")
1717
+ if desktop.get("binaries", {}).get("xpra") and desktop.get("packages", {}).get("xpra-x11") is False:
1718
+ missing.append("xpra-x11")
1719
+ healthy = not missing
1720
+ preparation = _runtime_preparation_status()
1721
+ installing = bool(preparation.get("preparing")) and not healthy
1722
+ return {
1723
+ "ok": True,
1724
+ "healthy": healthy,
1725
+ "state": "healthy" if healthy else "installing" if installing else "missing",
1726
+ "installing": installing,
1727
+ "missing": missing,
1728
+ "preparation": preparation,
1729
+ "binaries": binaries,
1730
+ "xpra_html_root": str(desktop.get("xpra_html_root") or ""),
1731
+ "message": (
1732
+ "Agent Zero Desktop sessions are available."
1733
+ if healthy
1734
+ else RUNTIME_INSTALL_MESSAGE
1735
+ if installing
1736
+ else f"Agent Zero Desktop sessions need: {', '.join(missing)}."
1737
+ ),
1738
+ }
1739
+
1740
+
1741
+ def _state_path_from_retired_root(path: Path) -> Path:
1742
+ try:
1743
+ relative = path.resolve(strict=False).relative_to(
1744
+ RETIRED_STATE_DIR.resolve(strict=False)
1745
+ )
1746
+ except ValueError:
1747
+ return path
1748
+ return STATE_DIR / relative
1749
+
1750
+
1751
+ def _runtime_preparation_status() -> dict[str, Any]:
1752
+ try:
1753
+ from plugins._desktop import hooks
1754
+
1755
+ return hooks.runtime_preparation_status()
1756
+ except Exception:
1757
+ return {"preparing": False, "active_count": 0, "started_at": 0.0, "completed_at": 0.0}
1758
+
1759
+
1760
+ def cleanup_stale_runtime_state() -> dict[str, Any]:
1761
+ killed: list[int] = []
1762
+ errors: list[str] = []
1763
+ seen_dirs: set[Path] = set()
1764
+ for session_dir in (SESSION_DIR, *LEGACY_SESSION_DIRS):
1765
+ if session_dir in seen_dirs:
1766
+ continue
1767
+ seen_dirs.add(session_dir)
1768
+ if session_dir.exists():
1769
+ for manifest in session_dir.glob("*.json"):
1770
+ try:
1771
+ payload = json.loads(manifest.read_text(encoding="utf-8"))
1772
+ owner_pid = _coerce_pid(payload.get("owner_pid"))
1773
+ if owner_pid and _pid_is_running(owner_pid):
1774
+ continue
1775
+ for pid in dict(payload.get("pids") or {}).values():
1776
+ pid_int = _coerce_pid(pid)
1777
+ if not pid_int:
1778
+ continue
1779
+ if _kill_pid(pid_int):
1780
+ killed.append(pid_int)
1781
+ manifest.unlink(missing_ok=True)
1782
+ except Exception as exc:
1783
+ errors.append(str(exc))
1784
+ return {"ok": not errors, "killed": killed, "errors": errors}
1785
+
1786
+
1787
+ def get_manager() -> DesktopSessionManager:
1788
+ global _manager
1789
+ try:
1790
+ return _manager
1791
+ except NameError:
1792
+ _manager = DesktopSessionManager()
1793
+ atexit.register(_manager.shutdown)
1794
+ return _manager
1795
+
1796
+
1797
+ def _xpra_url(token: str) -> str:
1798
+ return virtual_desktop.session_url(token, title="Desktop")
1799
+
1800
+
1801
+ def _xvfb_command(xvfb: str, session: DesktopSession) -> list[str]:
1802
+ return [
1803
+ xvfb,
1804
+ f":{session.display}",
1805
+ "-screen",
1806
+ "0",
1807
+ f"{MAX_SCREEN_WIDTH}x{MAX_SCREEN_HEIGHT}x24",
1808
+ "+extension",
1809
+ "GLX",
1810
+ "+extension",
1811
+ "RANDR",
1812
+ "+extension",
1813
+ "RENDER",
1814
+ "+extension",
1815
+ "Composite",
1816
+ "-extension",
1817
+ "DOUBLE-BUFFER",
1818
+ "-nolisten",
1819
+ "tcp",
1820
+ "-noreset",
1821
+ "-ac",
1822
+ ]
1823
+
1824
+
1825
+ def _xpra_shadow_command(xpra: str, session: DesktopSession) -> list[str]:
1826
+ return [
1827
+ xpra,
1828
+ "shadow",
1829
+ f":{session.display}",
1830
+ "--daemon=no",
1831
+ "--mdns=no",
1832
+ "--html=on",
1833
+ "--tray=no",
1834
+ "--system-tray=no",
1835
+ "--notifications=no",
1836
+ "--clipboard=yes",
1837
+ "--clipboard-direction=both",
1838
+ "--file-transfer=yes",
1839
+ "--open-files=no",
1840
+ "--open-url=no",
1841
+ "--printing=yes",
1842
+ "--audio=no",
1843
+ "--speaker=off",
1844
+ "--microphone=off",
1845
+ "--encoding=jpeg",
1846
+ "--quality=85",
1847
+ "--speed=80",
1848
+ f"--bind-tcp=127.0.0.1:{session.xpra_port}",
1849
+ "--resize-display=yes",
1850
+ f"--log-dir={session.profile_dir}",
1851
+ "--log-file=xpra.log",
1852
+ ]
1853
+
1854
+
1855
+ def _desktop_exec(*args: str | Path) -> str:
1856
+ return " ".join(_desktop_exec_arg(str(arg)) for arg in args if str(arg))
1857
+
1858
+
1859
+ def _desktop_icon(*candidates: str) -> str:
1860
+ for candidate in candidates:
1861
+ if candidate.startswith("/") and Path(candidate).exists():
1862
+ return candidate
1863
+ return next(
1864
+ (candidate for candidate in candidates if not candidate.startswith("/")),
1865
+ candidates[-1],
1866
+ )
1867
+
1868
+
1869
+ def _ensure_desktop_folder_link(desktop_dir: Path, label: str, target: Path) -> None:
1870
+ target.mkdir(parents=True, exist_ok=True)
1871
+ link = desktop_dir / label
1872
+ try:
1873
+ if link.is_symlink() or link.is_file():
1874
+ link.unlink()
1875
+ if not link.exists():
1876
+ link.symlink_to(target, target_is_directory=True)
1877
+ except OSError:
1878
+ return
1879
+
1880
+
1881
+ def _url_bridge_dir(session: DesktopSession) -> Path:
1882
+ return session.profile_dir / ".agent-zero"
1883
+
1884
+
1885
+ def _url_bridge_script_path(session: DesktopSession) -> Path:
1886
+ return _url_bridge_dir(session) / "open-url"
1887
+
1888
+
1889
+ def _editor_bridge_script_path(session: DesktopSession) -> Path:
1890
+ return _url_bridge_dir(session) / "open-editor"
1891
+
1892
+
1893
+ def _url_bridge_queue_path(session: DesktopSession) -> Path:
1894
+ return _url_bridge_dir(session) / "browser-url-intents.jsonl"
1895
+
1896
+
1897
+ def _url_bridge_lock_path(session: DesktopSession) -> Path:
1898
+ return _url_bridge_dir(session) / "browser-url-intents.lock"
1899
+
1900
+
1901
+ def _shutdown_request_path(session: DesktopSession) -> Path:
1902
+ return _url_bridge_dir(session) / "shutdown-request.json"
1903
+
1904
+
1905
+ def _shutdown_arm_path(session: DesktopSession) -> Path:
1906
+ return _url_bridge_dir(session) / "shutdown-request.arm.json"
1907
+
1908
+
1909
+ def _shutdown_lock_path(session: DesktopSession) -> Path:
1910
+ return _url_bridge_dir(session) / "shutdown-request.lock"
1911
+
1912
+
1913
+ def _write_url_bridge_script(session: DesktopSession) -> Path:
1914
+ bridge_dir = _url_bridge_dir(session)
1915
+ bridge_dir.mkdir(parents=True, exist_ok=True)
1916
+ script = _url_bridge_script_path(session)
1917
+ queue = _url_bridge_queue_path(session)
1918
+ lock = _url_bridge_lock_path(session)
1919
+ script.write_text(
1920
+ f"""#!/usr/bin/env python3
1921
+ import fcntl
1922
+ import json
1923
+ import os
1924
+ import sys
1925
+ import time
1926
+
1927
+ QUEUE_PATH = {str(queue)!r}
1928
+ LOCK_PATH = {str(lock)!r}
1929
+ MAX_URL_LENGTH = {URL_INTENT_MAX_LENGTH}
1930
+
1931
+
1932
+ def main():
1933
+ urls = [str(arg or "").strip()[:MAX_URL_LENGTH] for arg in sys.argv[1:] if str(arg or "").strip()]
1934
+ if not urls:
1935
+ urls = [""]
1936
+ os.makedirs(os.path.dirname(QUEUE_PATH), exist_ok=True)
1937
+ with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file:
1938
+ fcntl.flock(lock_file, fcntl.LOCK_EX)
1939
+ with open(QUEUE_PATH, "a", encoding="utf-8") as queue_file:
1940
+ for url in urls:
1941
+ queue_file.write(json.dumps({{
1942
+ "url": url,
1943
+ "created_at": time.time(),
1944
+ "source": "desktop",
1945
+ }}, ensure_ascii=True) + "\\n")
1946
+ queue_file.flush()
1947
+ os.fsync(queue_file.fileno())
1948
+ fcntl.flock(lock_file, fcntl.LOCK_UN)
1949
+
1950
+
1951
+ if __name__ == "__main__":
1952
+ main()
1953
+ """,
1954
+ encoding="utf-8",
1955
+ )
1956
+ try:
1957
+ script.chmod(0o755)
1958
+ except OSError:
1959
+ pass
1960
+ return script
1961
+
1962
+
1963
+ def _write_editor_bridge_script(session: DesktopSession) -> Path:
1964
+ bridge_dir = _url_bridge_dir(session)
1965
+ bridge_dir.mkdir(parents=True, exist_ok=True)
1966
+ script = _editor_bridge_script_path(session)
1967
+ queue = _url_bridge_queue_path(session)
1968
+ lock = _url_bridge_lock_path(session)
1969
+ script.write_text(
1970
+ f"""#!/usr/bin/env python3
1971
+ import fcntl
1972
+ import json
1973
+ import os
1974
+ import sys
1975
+ import time
1976
+ from urllib.parse import quote
1977
+
1978
+ QUEUE_PATH = {str(queue)!r}
1979
+ LOCK_PATH = {str(lock)!r}
1980
+ MAX_URL_LENGTH = {URL_INTENT_MAX_LENGTH}
1981
+
1982
+
1983
+ def editor_url(path):
1984
+ path = str(path or "").strip()
1985
+ if not path:
1986
+ return "a0-editor://open"
1987
+ return "a0-editor://open?path=" + quote(path[:MAX_URL_LENGTH], safe="/:")
1988
+
1989
+
1990
+ def main():
1991
+ urls = [editor_url(arg) for arg in sys.argv[1:] if str(arg or "").strip()]
1992
+ if not urls:
1993
+ urls = [editor_url("")]
1994
+ os.makedirs(os.path.dirname(QUEUE_PATH), exist_ok=True)
1995
+ with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file:
1996
+ fcntl.flock(lock_file, fcntl.LOCK_EX)
1997
+ with open(QUEUE_PATH, "a", encoding="utf-8") as queue_file:
1998
+ for url in urls:
1999
+ queue_file.write(json.dumps({{
2000
+ "url": url,
2001
+ "created_at": time.time(),
2002
+ "source": "desktop-editor",
2003
+ }}, ensure_ascii=True) + "\\n")
2004
+ queue_file.flush()
2005
+ os.fsync(queue_file.fileno())
2006
+ fcntl.flock(lock_file, fcntl.LOCK_UN)
2007
+
2008
+
2009
+ if __name__ == "__main__":
2010
+ main()
2011
+ """,
2012
+ encoding="utf-8",
2013
+ )
2014
+ try:
2015
+ script.chmod(0o755)
2016
+ except OSError:
2017
+ pass
2018
+ return script
2019
+
2020
+
2021
+ def _write_shutdown_bridge_script(session: DesktopSession) -> Path:
2022
+ bridge_dir = _url_bridge_dir(session)
2023
+ bridge_dir.mkdir(parents=True, exist_ok=True)
2024
+ script = bridge_dir / "shutdown-desktop"
2025
+ request = _shutdown_request_path(session)
2026
+ arm = _shutdown_arm_path(session)
2027
+ lock = _shutdown_lock_path(session)
2028
+ script.write_text(
2029
+ f"""#!/usr/bin/env python3
2030
+ import fcntl
2031
+ import json
2032
+ import os
2033
+ import shutil
2034
+ import subprocess
2035
+ import time
2036
+
2037
+ REQUEST_PATH = {str(request)!r}
2038
+ ARM_PATH = {str(arm)!r}
2039
+ LOCK_PATH = {str(lock)!r}
2040
+ CONFIRM_SECONDS = {SHUTDOWN_CONFIRM_SECONDS}
2041
+
2042
+
2043
+ def notify(message, timeout=None):
2044
+ if not os.environ.get("DISPLAY"):
2045
+ return
2046
+ xmessage = shutil.which("xmessage")
2047
+ if not xmessage:
2048
+ return
2049
+ try:
2050
+ subprocess.Popen(
2051
+ [
2052
+ xmessage,
2053
+ "-buttons",
2054
+ "",
2055
+ "-timeout",
2056
+ str(timeout or CONFIRM_SECONDS),
2057
+ "-center",
2058
+ message,
2059
+ ],
2060
+ stdin=subprocess.DEVNULL,
2061
+ stdout=subprocess.DEVNULL,
2062
+ stderr=subprocess.DEVNULL,
2063
+ start_new_session=True,
2064
+ )
2065
+ except OSError:
2066
+ pass
2067
+
2068
+
2069
+ def read_arm(now):
2070
+ try:
2071
+ with open(ARM_PATH, "r", encoding="utf-8") as handle:
2072
+ payload = json.load(handle)
2073
+ except (OSError, json.JSONDecodeError):
2074
+ return None
2075
+ try:
2076
+ created_at = float(payload.get("created_at"))
2077
+ except (TypeError, ValueError):
2078
+ return None
2079
+ if now - created_at > CONFIRM_SECONDS:
2080
+ return None
2081
+ return created_at
2082
+
2083
+
2084
+ def write_json_atomic(path, payload):
2085
+ tmp_path = path + ".tmp"
2086
+ with open(tmp_path, "w", encoding="utf-8") as handle:
2087
+ json.dump(payload, handle, ensure_ascii=True)
2088
+ handle.write("\\n")
2089
+ handle.flush()
2090
+ os.fsync(handle.fileno())
2091
+ os.replace(tmp_path, path)
2092
+
2093
+
2094
+ def main():
2095
+ os.makedirs(os.path.dirname(REQUEST_PATH), exist_ok=True)
2096
+ now = time.time()
2097
+ with open(LOCK_PATH, "a+", encoding="utf-8") as lock_file:
2098
+ fcntl.flock(lock_file, fcntl.LOCK_EX)
2099
+ armed_at = read_arm(now)
2100
+ if armed_at is None:
2101
+ write_json_atomic(ARM_PATH, {{"created_at": now, "source": "tray"}})
2102
+ notify(
2103
+ f"Shutdown Desktop armed. Click Shutdown Desktop again within {{CONFIRM_SECONDS}} seconds to close it.",
2104
+ CONFIRM_SECONDS,
2105
+ )
2106
+ return
2107
+ try:
2108
+ os.unlink(ARM_PATH)
2109
+ except OSError:
2110
+ pass
2111
+ payload = {{
2112
+ "created_at": now,
2113
+ "armed_at": armed_at,
2114
+ "source": "tray",
2115
+ }}
2116
+ write_json_atomic(REQUEST_PATH, payload)
2117
+ notify("Shutting down Agent Zero Desktop.", 2)
2118
+
2119
+
2120
+ if __name__ == "__main__":
2121
+ main()
2122
+ """,
2123
+ encoding="utf-8",
2124
+ )
2125
+ try:
2126
+ script.chmod(0o755)
2127
+ except OSError:
2128
+ pass
2129
+ return script
2130
+
2131
+
2132
+ def _claim_url_intents(session: DesktopSession) -> list[dict[str, Any]]:
2133
+ queue = _url_bridge_queue_path(session)
2134
+ lock = _url_bridge_lock_path(session)
2135
+ if not queue.exists():
2136
+ return []
2137
+ lock.parent.mkdir(parents=True, exist_ok=True)
2138
+ try:
2139
+ with open(lock, "a+", encoding="utf-8") as lock_file:
2140
+ fcntl.flock(lock_file, fcntl.LOCK_EX)
2141
+ try:
2142
+ raw = queue.read_text(encoding="utf-8")
2143
+ queue.write_text("", encoding="utf-8")
2144
+ finally:
2145
+ fcntl.flock(lock_file, fcntl.LOCK_UN)
2146
+ except OSError:
2147
+ return []
2148
+
2149
+ intents: list[dict[str, Any]] = []
2150
+ for line in raw.splitlines():
2151
+ try:
2152
+ payload = json.loads(line)
2153
+ except json.JSONDecodeError:
2154
+ continue
2155
+ url = str(payload.get("url") or "").strip()
2156
+ if len(url) > URL_INTENT_MAX_LENGTH:
2157
+ url = url[:URL_INTENT_MAX_LENGTH]
2158
+ created_at = payload.get("created_at")
2159
+ try:
2160
+ created_at = float(created_at)
2161
+ except (TypeError, ValueError):
2162
+ created_at = time.time()
2163
+ intents.append(
2164
+ {
2165
+ "url": url,
2166
+ "created_at": created_at,
2167
+ "source": str(payload.get("source") or "desktop"),
2168
+ },
2169
+ )
2170
+ if len(intents) >= URL_INTENT_MAX_ITEMS:
2171
+ break
2172
+ return intents
2173
+
2174
+
2175
+ def _claim_shutdown_request(session: DesktopSession) -> dict[str, Any] | None:
2176
+ request = _shutdown_request_path(session)
2177
+ if not request.exists():
2178
+ return None
2179
+ try:
2180
+ raw = request.read_text(encoding="utf-8")
2181
+ request.unlink(missing_ok=True)
2182
+ except OSError:
2183
+ return None
2184
+ try:
2185
+ payload = json.loads(raw)
2186
+ except json.JSONDecodeError:
2187
+ payload = {}
2188
+ created_at = payload.get("created_at")
2189
+ try:
2190
+ created_at = float(created_at)
2191
+ except (TypeError, ValueError):
2192
+ created_at = time.time()
2193
+ return {
2194
+ "created_at": created_at,
2195
+ "source": str(payload.get("source") or "tray"),
2196
+ }
2197
+
2198
+
2199
+ def _clear_shutdown_request(session: DesktopSession) -> None:
2200
+ request = _shutdown_request_path(session)
2201
+ arm = _shutdown_arm_path(session)
2202
+ lock = _shutdown_lock_path(session)
2203
+ request.unlink(missing_ok=True)
2204
+ request.with_suffix(request.suffix + ".tmp").unlink(missing_ok=True)
2205
+ arm.unlink(missing_ok=True)
2206
+ arm.with_suffix(arm.suffix + ".tmp").unlink(missing_ok=True)
2207
+ lock.unlink(missing_ok=True)
2208
+
2209
+
2210
+ def _remove_system_manifest() -> None:
2211
+ (SESSION_DIR / f"{SYSTEM_SESSION_ID}.json").unlink(missing_ok=True)
2212
+
2213
+
2214
+ def _url_handler_mime_types() -> tuple[str, ...]:
2215
+ return (
2216
+ "x-scheme-handler/http",
2217
+ "x-scheme-handler/https",
2218
+ "text/html",
2219
+ "application/xhtml+xml",
2220
+ )
2221
+
2222
+
2223
+ def _editor_text_handler_mime_types() -> tuple[str, ...]:
2224
+ return (
2225
+ "text/markdown",
2226
+ "text/x-markdown",
2227
+ "text/plain",
2228
+ )
2229
+
2230
+
2231
+ def _write_mimeapps_defaults(path: Path, url_desktop_id: str, editor_desktop_id: str) -> None:
2232
+ url_associations = ";".join([url_desktop_id, ""])
2233
+ # LibreOffice Writer stays the default so double-clicks keep the user (and
2234
+ # agents) inside the Desktop; the Agent Zero Editor remains an "Open With"
2235
+ # option. When Writer's desktop entry is missing, xdg falls back through
2236
+ # the added associations to the editor.
2237
+ text_associations = ";".join([WRITER_HANDLER_DESKTOP_ID, editor_desktop_id, ""])
2238
+ lines = [
2239
+ "[Default Applications]",
2240
+ *(f"{mime_type}={url_desktop_id}" for mime_type in _url_handler_mime_types()),
2241
+ *(f"{mime_type}={WRITER_HANDLER_DESKTOP_ID}" for mime_type in _editor_text_handler_mime_types()),
2242
+ "",
2243
+ "[Added Associations]",
2244
+ *(f"{mime_type}={url_associations}" for mime_type in _url_handler_mime_types()),
2245
+ *(f"{mime_type}={text_associations}" for mime_type in _editor_text_handler_mime_types()),
2246
+ "",
2247
+ ]
2248
+ path.parent.mkdir(parents=True, exist_ok=True)
2249
+ path.write_text("\n".join(lines), encoding="utf-8")
2250
+
2251
+
2252
+ def _write_xfce_browser_helper(path: Path, bridge_script: Path) -> None:
2253
+ command = _desktop_exec(bridge_script)
2254
+ command_with_parameter = _desktop_exec(bridge_script, "%s")
2255
+ path.parent.mkdir(parents=True, exist_ok=True)
2256
+ path.write_text(
2257
+ "\n".join(
2258
+ [
2259
+ "[Desktop Entry]",
2260
+ "NoDisplay=true",
2261
+ "Version=1.0",
2262
+ "Type=X-XFCE-Helper",
2263
+ "X-XFCE-Category=WebBrowser",
2264
+ f"X-XFCE-Commands={command}",
2265
+ f"X-XFCE-CommandsWithParameter={command_with_parameter}",
2266
+ "Icon=web-browser",
2267
+ "Name=Agent Zero Browser",
2268
+ "",
2269
+ ],
2270
+ ),
2271
+ encoding="utf-8",
2272
+ )
2273
+
2274
+
2275
+ def _remove_path_if_owned(path: Path) -> None:
2276
+ try:
2277
+ if path.is_symlink() or path.is_file():
2278
+ path.unlink()
2279
+ elif path.is_dir():
2280
+ shutil.rmtree(path)
2281
+ except OSError:
2282
+ return
2283
+
2284
+
2285
+ def _desktop_exec_arg(value: str) -> str:
2286
+ if not any(char.isspace() or char in '"\\' for char in value):
2287
+ return value
2288
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
2289
+ return f'"{escaped}"'
2290
+
2291
+
2292
+ def _xml_attr(value: str) -> str:
2293
+ return (
2294
+ str(value)
2295
+ .replace("&", "&amp;")
2296
+ .replace('"', "&quot;")
2297
+ .replace("<", "&lt;")
2298
+ .replace(">", "&gt;")
2299
+ )
2300
+
2301
+
2302
+ def _oor(name: str) -> str:
2303
+ return f"{{{OOR_NS}}}{name}"
2304
+
2305
+
2306
+ def _file_uri(path: str | Path) -> str:
2307
+ return Path(path).resolve(strict=False).as_uri()
2308
+
2309
+
2310
+ def _write_libreoffice_registry_defaults(registry: Path, workdir: str | Path) -> None:
2311
+ Path(workdir).mkdir(parents=True, exist_ok=True)
2312
+ ET.register_namespace("oor", OOR_NS)
2313
+ ET.register_namespace("xs", XS_NS)
2314
+ ET.register_namespace("xsi", XSI_NS)
2315
+ root = _read_libreoffice_registry(registry)
2316
+ workdir_uri = _file_uri(workdir)
2317
+ for path, prop, value in (
2318
+ ("/org.openoffice.Office.Common/Misc", "FirstRun", "false"),
2319
+ ("/org.openoffice.Setup/Office", "ooSetupInstCompleted", "true"),
2320
+ ("/org.openoffice.Setup/Office", "MigrationCompleted", "true"),
2321
+ ("/org.openoffice.Setup/Office", "OfficeRestartInProgress", "false"),
2322
+ ("/org.openoffice.Setup/L10N", "ooLocale", "en-US"),
2323
+ ("/org.openoffice.Office.Paths/Variables", "Work", workdir_uri),
2324
+ (
2325
+ "/org.openoffice.Office.Paths/Paths/org.openoffice.Office.Paths:NamedPath['Work']",
2326
+ "WritePath",
2327
+ workdir_uri,
2328
+ ),
2329
+ ):
2330
+ _set_registry_prop(root, path, prop, value)
2331
+ registry.parent.mkdir(parents=True, exist_ok=True)
2332
+ ET.ElementTree(root).write(registry, encoding="utf-8", xml_declaration=True)
2333
+
2334
+
2335
+ def _read_libreoffice_registry(registry: Path) -> ET.Element:
2336
+ if registry.exists():
2337
+ try:
2338
+ return ET.parse(registry).getroot()
2339
+ except ET.ParseError:
2340
+ pass
2341
+ return ET.Element(
2342
+ _oor("items"),
2343
+ {
2344
+ "xmlns:xs": XS_NS,
2345
+ "xmlns:xsi": XSI_NS,
2346
+ },
2347
+ )
2348
+
2349
+
2350
+ def _set_registry_prop(root: ET.Element, item_path: str, prop_name: str, value: str) -> None:
2351
+ item = _find_registry_item(root, item_path)
2352
+ if item is None:
2353
+ item = ET.SubElement(root, "item", {_oor("path"): item_path})
2354
+ prop = next((child for child in item.findall("prop") if child.get(_oor("name")) == prop_name), None)
2355
+ if prop is None:
2356
+ prop = ET.SubElement(item, "prop", {_oor("name"): prop_name, _oor("op"): "fuse"})
2357
+ else:
2358
+ prop.set(_oor("op"), "fuse")
2359
+ value_node = prop.find("value")
2360
+ if value_node is None:
2361
+ value_node = ET.SubElement(prop, "value")
2362
+ value_node.text = str(value)
2363
+
2364
+
2365
+ def _find_registry_item(root: ET.Element, item_path: str) -> ET.Element | None:
2366
+ for item in root.findall("item"):
2367
+ if item.get(_oor("path")) == item_path:
2368
+ return item
2369
+ return None
2370
+
2371
+
2372
+ def _write_desktop_launcher(
2373
+ path: Path,
2374
+ *,
2375
+ name: str,
2376
+ exec_line: str,
2377
+ icon: str,
2378
+ categories: str,
2379
+ try_exec: str = "",
2380
+ working_dir: str | Path | None = None,
2381
+ mime_types: tuple[str, ...] = (),
2382
+ no_display: bool = False,
2383
+ ) -> None:
2384
+ path.parent.mkdir(parents=True, exist_ok=True)
2385
+ lines = [
2386
+ "[Desktop Entry]",
2387
+ "Version=1.0",
2388
+ "Type=Application",
2389
+ f"Name={name}",
2390
+ f"Exec={exec_line}",
2391
+ ]
2392
+ if try_exec:
2393
+ lines.append(f"TryExec={try_exec}")
2394
+ if working_dir:
2395
+ lines.append(f"Path={working_dir}")
2396
+ if mime_types:
2397
+ lines.append(f"MimeType={';'.join(mime_types)};")
2398
+ if no_display:
2399
+ lines.append("NoDisplay=true")
2400
+ lines.extend(
2401
+ [
2402
+ f"Icon={icon}",
2403
+ "Terminal=false",
2404
+ f"Categories={categories}",
2405
+ "StartupNotify=true",
2406
+ "X-XFCE-Trusted=true",
2407
+ "",
2408
+ ],
2409
+ )
2410
+ path.write_text("\n".join(lines), encoding="utf-8")
2411
+ try:
2412
+ path.chmod(0o755)
2413
+ except OSError:
2414
+ pass
2415
+
2416
+
2417
+ def _write_hidden_application_entry(path: Path, name: str) -> None:
2418
+ path.parent.mkdir(parents=True, exist_ok=True)
2419
+ path.write_text(
2420
+ "\n".join(
2421
+ [
2422
+ "[Desktop Entry]",
2423
+ "Type=Application",
2424
+ f"Name={name}",
2425
+ "NoDisplay=true",
2426
+ "Hidden=true",
2427
+ "",
2428
+ ],
2429
+ ),
2430
+ encoding="utf-8",
2431
+ )
2432
+
2433
+
2434
+ def _write_thunar_defaults(path: Path) -> None:
2435
+ root = _read_xfce_channel(path, "thunar")
2436
+ if _find_xfce_property(root, "last-view") is None:
2437
+ _xfce_property(root, "last-view", "string", "ThunarIconView")
2438
+ _xfce_property(root, "last-show-hidden", "bool", "true")
2439
+ _write_xfce_channel(path, root)
2440
+
2441
+
2442
+ def _read_xfce_channel(path: Path, channel_name: str) -> ET.Element:
2443
+ if path.exists():
2444
+ try:
2445
+ root = ET.parse(path).getroot()
2446
+ if root.tag == "channel" and root.get("name") == channel_name:
2447
+ root.set("version", root.get("version") or "1.0")
2448
+ return root
2449
+ except (ET.ParseError, OSError):
2450
+ pass
2451
+ return ET.Element("channel", {"name": channel_name, "version": "1.0"})
2452
+
2453
+
2454
+ def _write_xfce_channel(path: Path, root: ET.Element) -> None:
2455
+ path.parent.mkdir(parents=True, exist_ok=True)
2456
+ tree = ET.ElementTree(root)
2457
+ try:
2458
+ ET.indent(tree, space=" ")
2459
+ except AttributeError:
2460
+ pass
2461
+ tree.write(path, encoding="utf-8", xml_declaration=True)
2462
+
2463
+
2464
+ def _find_xfce_property(parent: ET.Element, name: str) -> ET.Element | None:
2465
+ return next((child for child in parent.findall("property") if child.get("name") == name), None)
2466
+
2467
+
2468
+ def _install_desktop_readme(desktop_dir: Path) -> None:
2469
+ if not DESKTOP_README_SOURCE.exists():
2470
+ return
2471
+ target = desktop_dir / "README.md"
2472
+ try:
2473
+ content = DESKTOP_README_SOURCE.read_text(encoding="utf-8")
2474
+ if target.exists() and target.read_text(encoding="utf-8") == content:
2475
+ return
2476
+ target.write_text(content, encoding="utf-8")
2477
+ target.chmod(0o644)
2478
+ except OSError:
2479
+ return
2480
+
2481
+
2482
+ def _xfce_property(parent: ET.Element, name: str, property_type: str, value: str | None = None) -> ET.Element:
2483
+ for child in parent.findall("property"):
2484
+ if child.get("name") == name:
2485
+ child.set("type", property_type)
2486
+ if value is None:
2487
+ child.attrib.pop("value", None)
2488
+ else:
2489
+ child.set("value", value)
2490
+ return child
2491
+ attributes = {"name": name, "type": property_type}
2492
+ if value is not None:
2493
+ attributes["value"] = value
2494
+ return ET.SubElement(parent, "property", attributes)
2495
+
2496
+
2497
+ def _public_doc(doc: dict[str, Any]) -> dict[str, Any]:
2498
+ return {
2499
+ "file_id": doc["file_id"],
2500
+ "path": document_store.display_path(doc["path"]),
2501
+ "basename": doc["basename"],
2502
+ "extension": doc["extension"],
2503
+ "size": doc["size"],
2504
+ "version": document_store.item_version(doc),
2505
+ "last_modified": doc["last_modified"],
2506
+ }
2507
+
2508
+
2509
+ def _require_binary(name: str) -> str:
2510
+ found = shutil.which(name)
2511
+ if not found:
2512
+ raise RuntimeError(f"{name} is required for official LibreOffice desktop sessions.")
2513
+ return found
2514
+
2515
+
2516
+ def _running(process: subprocess.Popen[Any] | None) -> bool:
2517
+ return bool(process and process.poll() is None)
2518
+
2519
+
2520
+ def _wait_for_port(
2521
+ host: str,
2522
+ port: int,
2523
+ timeout: float = 15.0,
2524
+ process: subprocess.Popen[Any] | None = None,
2525
+ ) -> None:
2526
+ deadline = time.time() + timeout
2527
+ while time.time() < deadline:
2528
+ if process and process.poll() is not None:
2529
+ raise RuntimeError(f"Xpra exited before port {port} was ready.")
2530
+ try:
2531
+ with socket.create_connection((host, port), timeout=0.2):
2532
+ return
2533
+ except OSError:
2534
+ time.sleep(0.1)
2535
+ raise TimeoutError(f"Timed out waiting for Xpra port {port}.")
2536
+
2537
+
2538
+ def _port_is_free(port: int) -> bool:
2539
+ try:
2540
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe:
2541
+ probe.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
2542
+ probe.bind(("127.0.0.1", port))
2543
+ return True
2544
+ except OSError:
2545
+ return False
2546
+
2547
+
2548
+ def _port_is_accepting(host: str, port: int) -> bool:
2549
+ try:
2550
+ with socket.create_connection((host, port), timeout=0.2):
2551
+ return True
2552
+ except OSError:
2553
+ return False
2554
+
2555
+
2556
+ def _terminate_process(process: subprocess.Popen[Any]) -> None:
2557
+ if process.poll() is not None:
2558
+ return
2559
+ try:
2560
+ process.terminate()
2561
+ process.wait(timeout=2)
2562
+ return
2563
+ except Exception:
2564
+ pass
2565
+ try:
2566
+ process.kill()
2567
+ process.wait(timeout=2)
2568
+ except Exception:
2569
+ pass
2570
+
2571
+
2572
+ def _kill_pid(pid: int) -> bool:
2573
+ if pid <= 0:
2574
+ return False
2575
+ try:
2576
+ os.kill(pid, 15)
2577
+ return True
2578
+ except ProcessLookupError:
2579
+ return False
2580
+ except PermissionError:
2581
+ return False
2582
+
2583
+
2584
+ def _coerce_pid(value: Any) -> int:
2585
+ try:
2586
+ pid = int(value)
2587
+ except (TypeError, ValueError):
2588
+ return 0
2589
+ return pid if pid > 0 else 0
2590
+
2591
+
2592
+ def _pid_is_running(pid: int) -> bool:
2593
+ if pid <= 0:
2594
+ return False
2595
+ try:
2596
+ os.kill(pid, 0)
2597
+ return True
2598
+ except ProcessLookupError:
2599
+ return False
2600
+ except PermissionError:
2601
+ return True
plugins/_desktop/helpers/desktop_state.py ADDED
@@ -0,0 +1,893 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 sys
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ PROJECT_ROOT = Path(__file__).resolve().parents[3]
15
+ if str(PROJECT_ROOT) not in sys.path:
16
+ sys.path.insert(0, str(PROJECT_ROOT))
17
+
18
+
19
+ SESSION_ID = "agent-zero-desktop"
20
+ PLUGIN_NAME = "_desktop"
21
+ BASE_DIR = Path(os.environ.get("A0_BASE_DIR") or ("/a0" if Path("/a0").exists() else PROJECT_ROOT))
22
+ STATE_DIR = BASE_DIR / "usr" / "plugins" / PLUGIN_NAME
23
+ RETIRED_STATE_DIR = BASE_DIR / "usr" / PLUGIN_NAME
24
+ SESSION_DIR = STATE_DIR / "sessions"
25
+ PROFILE_DIR = STATE_DIR / "profiles"
26
+ SCREENSHOT_DIR = Path(os.environ.get("A0_DESKTOP_SCREENSHOT_DIR") or BASE_DIR / "tmp" / "desktop" / "screenshots")
27
+ RECENT_SCREENSHOT_SECONDS = 600
28
+ _SAFE_CONTEXT_RE = re.compile(r"[^a-zA-Z0-9_.-]+")
29
+ _SCREENSHOT_SUFFIXES = {".png", ".jpg", ".jpeg", ".xwd"}
30
+
31
+
32
+ def session_manifest_path(session_id: str = SESSION_ID) -> Path:
33
+ return Path(os.environ.get("A0_DESKTOP_MANIFEST") or SESSION_DIR / f"{session_id}.json")
34
+
35
+
36
+ def context_screenshot_dir(context_id: str = "") -> Path:
37
+ return SCREENSHOT_DIR / _safe_context_id(context_id)
38
+
39
+
40
+ def chat_screenshot_dir(context_id: str = "") -> Path:
41
+ return BASE_DIR / "usr" / "chats" / _safe_context_id(context_id) / "screenshots" / "desktop"
42
+
43
+
44
+ def normalize_a0_path(path: str | Path) -> str:
45
+ candidate = Path(path)
46
+ try:
47
+ relative = candidate.resolve(strict=False).relative_to(BASE_DIR.resolve(strict=False))
48
+ except ValueError:
49
+ return str(candidate)
50
+ return "/a0/" + str(relative).replace(os.sep, "/")
51
+
52
+
53
+ def _safe_context_id(context_id: str = "") -> str:
54
+ raw = str(context_id or os.environ.get("A0_DESKTOP_CONTEXT_ID") or "default")
55
+ return _SAFE_CONTEXT_RE.sub("_", raw).strip("._") or "default"
56
+
57
+
58
+ def session_manifest_exists(session_id: str = SESSION_ID) -> bool:
59
+ return session_manifest_path(session_id).exists()
60
+
61
+
62
+ def collect_state(
63
+ *,
64
+ include_screenshot: bool = False,
65
+ screenshot_path: str | Path | None = None,
66
+ context_id: str = "",
67
+ screenshot_transport: str = "ephemeral",
68
+ ) -> dict[str, Any]:
69
+ errors: list[str] = []
70
+ env_info = resolve_environment(errors=errors)
71
+ display = env_info["display"]
72
+ profile_dir = env_info["profile_dir"]
73
+ env = display_env(display=display, profile_dir=profile_dir)
74
+
75
+ capabilities = collect_capabilities()
76
+ for name in ("xdotool", "xrandr", "xwininfo", "xprop"):
77
+ if not capabilities.get(name):
78
+ errors.append(f"{name} is not installed; install Desktop runtime dependencies through the _desktop plugin hook.")
79
+
80
+ size = collect_display_size(env, capabilities, errors)
81
+ pointer = collect_pointer(env, capabilities, errors)
82
+ active_window = collect_active_window(env, capabilities, errors)
83
+ windows = collect_windows(env, capabilities, errors)
84
+ screenshot = latest_screenshot(context_id=context_id)
85
+
86
+ if include_screenshot:
87
+ screenshot = capture_screenshot(
88
+ env,
89
+ capabilities,
90
+ path=screenshot_path,
91
+ errors=errors,
92
+ context_id=context_id,
93
+ transport=screenshot_transport,
94
+ )
95
+
96
+ return stable_state(
97
+ context_id=context_id,
98
+ display=display,
99
+ profile_dir=profile_dir,
100
+ size=size,
101
+ pointer=pointer,
102
+ active_window=active_window,
103
+ windows=windows,
104
+ screenshot=screenshot,
105
+ capabilities=capabilities,
106
+ errors=errors,
107
+ )
108
+
109
+
110
+ def capture_screenshot(
111
+ env: dict[str, str] | None = None,
112
+ capabilities: dict[str, str] | None = None,
113
+ *,
114
+ path: str | Path | None = None,
115
+ errors: list[str] | None = None,
116
+ context_id: str = "",
117
+ transport: str = "ephemeral",
118
+ ) -> dict[str, Any]:
119
+ local_errors = errors if errors is not None else []
120
+ capabilities = capabilities or collect_capabilities()
121
+ if not env:
122
+ env_errors: list[str] = []
123
+ env_info = resolve_environment(errors=env_errors)
124
+ local_errors.extend(env_errors)
125
+ env = display_env(display=env_info["display"], profile_dir=env_info["profile_dir"])
126
+
127
+ xwd = capabilities.get("xwd") or shutil.which("xwd") or ""
128
+ if not xwd:
129
+ message = "xwd is not installed; install x11-apps through the _desktop plugin hook."
130
+ local_errors.append(message)
131
+ return {"ok": False, "path": "", "format": "", "captured_at": "", "error": message}
132
+
133
+ explicit_path = path is not None and str(path).strip() != ""
134
+ transport_mode = str(transport or "").strip().lower()
135
+ chat_scoped = bool(not explicit_path and transport_mode == "path" and str(context_id or "").strip())
136
+ ephemeral_ref = not explicit_path and transport_mode != "path"
137
+ screenshot_dir = chat_screenshot_dir(context_id) if chat_scoped else context_screenshot_dir(context_id)
138
+ if not explicit_path and not chat_scoped:
139
+ prune_context_screenshots(context_id=context_id)
140
+ screenshot_dir.mkdir(parents=True, exist_ok=True)
141
+ timestamp = time.strftime("%Y%m%d-%H%M%S")
142
+ millis = int((time.time() % 1) * 1000)
143
+ target = Path(path) if explicit_path else screenshot_dir / f"desktop-{timestamp}-{millis:03d}.png"
144
+ target.parent.mkdir(parents=True, exist_ok=True)
145
+ raw_path = target.with_suffix(".xwd")
146
+ safe_context = _safe_context_id(context_id)
147
+
148
+ result = run([xwd, "-root", "-silent", "-out", str(raw_path)], env=env, timeout=8)
149
+ if result.returncode != 0:
150
+ detail = command_output(result) or "xwd screenshot capture failed."
151
+ local_errors.append(detail)
152
+ raw_path.unlink(missing_ok=True)
153
+ return {"ok": False, "path": "", "format": "", "captured_at": "", "error": detail}
154
+
155
+ if target.suffix.lower() == ".xwd":
156
+ if not explicit_path and not chat_scoped:
157
+ prune_context_screenshots(context_id=context_id, keep_path=raw_path)
158
+ return {
159
+ "ok": True,
160
+ "path": str(raw_path),
161
+ "a0_path": normalize_a0_path(raw_path),
162
+ "format": "xwd",
163
+ "captured_at": iso_now(),
164
+ "recent": True,
165
+ "ephemeral": not explicit_path and not chat_scoped,
166
+ "chat_scoped": chat_scoped,
167
+ "context_id": safe_context,
168
+ "error": "",
169
+ }
170
+
171
+ try:
172
+ from PIL import Image
173
+
174
+ with Image.open(raw_path) as image:
175
+ image.save(target)
176
+ width = int(image.width)
177
+ height = int(image.height)
178
+ raw_path.unlink(missing_ok=True)
179
+ if ephemeral_ref:
180
+ return ephemeral_screenshot_result(
181
+ target,
182
+ context_id=context_id,
183
+ image_format=target.suffix.lower().lstrip(".") or "png",
184
+ width=width,
185
+ height=height,
186
+ )
187
+ if not explicit_path and not chat_scoped:
188
+ prune_context_screenshots(context_id=context_id, keep_path=target)
189
+ return {
190
+ "ok": True,
191
+ "path": str(target),
192
+ "a0_path": normalize_a0_path(target),
193
+ "format": target.suffix.lower().lstrip(".") or "png",
194
+ "width": width,
195
+ "height": height,
196
+ "captured_at": iso_now(),
197
+ "recent": True,
198
+ "ephemeral": not explicit_path and not chat_scoped,
199
+ "chat_scoped": chat_scoped,
200
+ "context_id": safe_context,
201
+ "error": "",
202
+ }
203
+ except Exception as exc:
204
+ try:
205
+ converted = convert_xwd_to_image(raw_path, target)
206
+ raw_path.unlink(missing_ok=True)
207
+ if ephemeral_ref:
208
+ return ephemeral_screenshot_result(
209
+ target,
210
+ context_id=context_id,
211
+ image_format=target.suffix.lower().lstrip(".") or "png",
212
+ width=converted["width"],
213
+ height=converted["height"],
214
+ )
215
+ if not explicit_path and not chat_scoped:
216
+ prune_context_screenshots(context_id=context_id, keep_path=target)
217
+ return {
218
+ "ok": True,
219
+ "path": str(target),
220
+ "a0_path": normalize_a0_path(target),
221
+ "format": target.suffix.lower().lstrip(".") or "png",
222
+ "width": converted["width"],
223
+ "height": converted["height"],
224
+ "captured_at": iso_now(),
225
+ "recent": True,
226
+ "ephemeral": not explicit_path and not chat_scoped,
227
+ "chat_scoped": chat_scoped,
228
+ "context_id": safe_context,
229
+ "error": "",
230
+ }
231
+ except Exception as fallback_exc:
232
+ message = f"Pillow could not convert the XWD screenshot: {exc}; fallback parser failed: {fallback_exc}"
233
+ local_errors.append(message)
234
+ if ephemeral_ref:
235
+ raw_path.unlink(missing_ok=True)
236
+ target.unlink(missing_ok=True)
237
+ return {
238
+ "ok": False,
239
+ "path": "",
240
+ "format": "",
241
+ "captured_at": iso_now(),
242
+ "recent": False,
243
+ "ephemeral": True,
244
+ "context_id": safe_context,
245
+ "error": message,
246
+ }
247
+ return {
248
+ "ok": True,
249
+ "path": str(raw_path),
250
+ "a0_path": normalize_a0_path(raw_path),
251
+ "format": "xwd",
252
+ "captured_at": iso_now(),
253
+ "recent": True,
254
+ "ephemeral": not explicit_path and not chat_scoped,
255
+ "chat_scoped": chat_scoped,
256
+ "context_id": safe_context,
257
+ "error": message,
258
+ }
259
+
260
+
261
+ def convert_xwd_to_image(raw_path: Path, target: Path) -> dict[str, int]:
262
+ from PIL import Image
263
+
264
+ data = raw_path.read_bytes()
265
+ header, _ = parse_xwd_header(data)
266
+ width = header["pixmap_width"]
267
+ height = header["pixmap_height"]
268
+ bytes_per_line = header["bytes_per_line"]
269
+ bits_per_pixel = header["bits_per_pixel"]
270
+ color_table_size = header["ncolors"] * 12
271
+ pixel_offset = header["header_size"] + color_table_size
272
+ if width <= 0 or height <= 0 or bytes_per_line <= 0:
273
+ raise ValueError("invalid XWD dimensions")
274
+ pixel_size = height * bytes_per_line
275
+ if pixel_offset + pixel_size > len(data):
276
+ raise ValueError("truncated XWD pixel data")
277
+
278
+ if (header["red_mask"], header["green_mask"], header["blue_mask"]) != (
279
+ 0x00FF0000,
280
+ 0x0000FF00,
281
+ 0x000000FF,
282
+ ):
283
+ raise ValueError("unsupported XWD visual masks")
284
+ raw_mode = {
285
+ (24, 0): "BGR",
286
+ (24, 1): "RGB",
287
+ (32, 0): "BGRX",
288
+ (32, 1): "XRGB",
289
+ }.get((bits_per_pixel, header["byte_order"]))
290
+ if not raw_mode:
291
+ raise ValueError(f"unsupported XWD pixel layout: {bits_per_pixel} bpp")
292
+
293
+ image = Image.frombytes(
294
+ "RGB",
295
+ (width, height),
296
+ data[pixel_offset : pixel_offset + pixel_size],
297
+ "raw",
298
+ raw_mode,
299
+ bytes_per_line,
300
+ 1,
301
+ )
302
+ image.save(target)
303
+ return {"width": width, "height": height}
304
+
305
+
306
+ def parse_xwd_header(data: bytes) -> tuple[dict[str, int], str]:
307
+ if len(data) < 100:
308
+ raise ValueError("XWD header is too short")
309
+ field_names = (
310
+ "header_size",
311
+ "file_version",
312
+ "pixmap_format",
313
+ "pixmap_depth",
314
+ "pixmap_width",
315
+ "pixmap_height",
316
+ "xoffset",
317
+ "byte_order",
318
+ "bitmap_unit",
319
+ "bitmap_bit_order",
320
+ "bitmap_pad",
321
+ "bits_per_pixel",
322
+ "bytes_per_line",
323
+ "visual_class",
324
+ "red_mask",
325
+ "green_mask",
326
+ "blue_mask",
327
+ "bits_per_rgb",
328
+ "colormap_entries",
329
+ "ncolors",
330
+ "window_width",
331
+ "window_height",
332
+ "window_x",
333
+ "window_y",
334
+ "window_bdrwidth",
335
+ )
336
+ for endian in ("big", "little"):
337
+ values = [int.from_bytes(data[index : index + 4], endian, signed=False) for index in range(0, 100, 4)]
338
+ header = dict(zip(field_names, values, strict=True))
339
+ if 100 <= header["header_size"] <= len(data) and header["file_version"] == 7:
340
+ return header, endian
341
+ raise ValueError("unsupported XWD header")
342
+
343
+
344
+ def resolve_environment(*, errors: list[str] | None = None, session_id: str = SESSION_ID) -> dict[str, str]:
345
+ local_errors = errors if errors is not None else []
346
+ manifest = session_manifest_path(session_id)
347
+ payload: dict[str, Any] = {}
348
+ if manifest.exists():
349
+ try:
350
+ payload = json.loads(manifest.read_text(encoding="utf-8"))
351
+ except Exception as exc:
352
+ local_errors.append(f"Desktop session manifest is unreadable: {exc}")
353
+ elif not (os.environ.get("A0_DESKTOP_DISPLAY") or os.environ.get("DISPLAY")):
354
+ local_errors.append(f"Desktop session manifest not found at {manifest}; open the Desktop canvas before GUI control.")
355
+
356
+ display_value = str(
357
+ os.environ.get("A0_DESKTOP_DISPLAY")
358
+ or payload.get("display")
359
+ or os.environ.get("DISPLAY")
360
+ or ""
361
+ ).strip()
362
+ if display_value.startswith(":"):
363
+ display = display_value
364
+ elif display_value:
365
+ display = f":{display_value}"
366
+ else:
367
+ display = ""
368
+ local_errors.append("Desktop DISPLAY is unavailable; the persistent Desktop session is not running.")
369
+
370
+ profile_dir = _state_path_from_retired_root(
371
+ Path(
372
+ os.environ.get("A0_DESKTOP_PROFILE")
373
+ or os.environ.get("A0_DESKTOP_HOME")
374
+ or payload.get("profile_dir")
375
+ or os.environ.get("HOME")
376
+ or PROFILE_DIR / session_id
377
+ )
378
+ )
379
+
380
+ return {
381
+ "display": display,
382
+ "profile_dir": str(profile_dir),
383
+ "manifest": str(manifest),
384
+ }
385
+
386
+
387
+ def _state_path_from_retired_root(path: Path) -> Path:
388
+ try:
389
+ relative = path.resolve(strict=False).relative_to(
390
+ RETIRED_STATE_DIR.resolve(strict=False)
391
+ )
392
+ except ValueError:
393
+ return path
394
+ return STATE_DIR / relative
395
+
396
+
397
+ def display_env(*, display: str, profile_dir: str) -> dict[str, str]:
398
+ env = {
399
+ **os.environ,
400
+ "HOME": profile_dir,
401
+ "XDG_CONFIG_HOME": os.environ.get("XDG_CONFIG_HOME") or str(Path(profile_dir) / ".config"),
402
+ "XDG_DATA_HOME": os.environ.get("XDG_DATA_HOME") or str(Path(profile_dir) / ".local" / "share"),
403
+ "XDG_CACHE_HOME": os.environ.get("XDG_CACHE_HOME") or str(Path(profile_dir) / ".cache"),
404
+ "XDG_CURRENT_DESKTOP": os.environ.get("XDG_CURRENT_DESKTOP") or "XFCE",
405
+ }
406
+ if display:
407
+ env["DISPLAY"] = display
408
+ xauthority = os.environ.get("A0_DESKTOP_XAUTHORITY") or str(Path(profile_dir) / ".Xauthority")
409
+ if Path(xauthority).exists():
410
+ env["XAUTHORITY"] = xauthority
411
+ return env
412
+
413
+
414
+ def collect_capabilities() -> dict[str, str]:
415
+ return {
416
+ name: shutil.which(name) or ""
417
+ for name in (
418
+ "xdotool",
419
+ "xrandr",
420
+ "xwininfo",
421
+ "xprop",
422
+ "xwd",
423
+ "xclip",
424
+ )
425
+ }
426
+
427
+
428
+ def collect_display_size(env: dict[str, str], capabilities: dict[str, str], errors: list[str]) -> dict[str, int]:
429
+ if not capabilities.get("xrandr"):
430
+ return {"width": 0, "height": 0}
431
+ result = run([capabilities["xrandr"], "-q"], env=env, timeout=4)
432
+ if result.returncode != 0:
433
+ errors.append(command_output(result) or "xrandr could not read the Desktop display.")
434
+ return {"width": 0, "height": 0}
435
+ match = re.search(r"\bcurrent\s+(\d+)\s+x\s+(\d+)", result.stdout)
436
+ if not match:
437
+ errors.append("xrandr output did not include the current Desktop size.")
438
+ return {"width": 0, "height": 0}
439
+ return {"width": int(match.group(1)), "height": int(match.group(2))}
440
+
441
+
442
+ def collect_pointer(env: dict[str, str], capabilities: dict[str, str], errors: list[str]) -> dict[str, int]:
443
+ if not capabilities.get("xdotool"):
444
+ return {"x": 0, "y": 0, "screen": 0, "window": 0}
445
+ result = run([capabilities["xdotool"], "getmouselocation", "--shell"], env=env, timeout=3)
446
+ if result.returncode != 0:
447
+ errors.append(command_output(result) or "xdotool could not read the pointer location.")
448
+ return {"x": 0, "y": 0, "screen": 0, "window": 0}
449
+ values = parse_shell_values(result.stdout)
450
+ return {
451
+ "x": int_value(values.get("X")),
452
+ "y": int_value(values.get("Y")),
453
+ "screen": int_value(values.get("SCREEN")),
454
+ "window": int_value(values.get("WINDOW")),
455
+ }
456
+
457
+
458
+ def collect_active_window(env: dict[str, str], capabilities: dict[str, str], errors: list[str]) -> dict[str, Any] | None:
459
+ if not capabilities.get("xdotool"):
460
+ return None
461
+ result = run([capabilities["xdotool"], "getactivewindow"], env=env, timeout=3)
462
+ if result.returncode != 0:
463
+ return None
464
+ window_id = result.stdout.strip().splitlines()[0] if result.stdout.strip() else ""
465
+ if not window_id:
466
+ return None
467
+ return collect_window(env, capabilities, window_id, errors)
468
+
469
+
470
+ def collect_windows(env: dict[str, str], capabilities: dict[str, str], errors: list[str]) -> list[dict[str, Any]]:
471
+ if not capabilities.get("xdotool"):
472
+ return []
473
+ result = run([capabilities["xdotool"], "search", "--onlyvisible", "--name", "."], env=env, timeout=4)
474
+ if result.returncode != 0:
475
+ detail = command_output(result)
476
+ if detail:
477
+ errors.append(detail)
478
+ return []
479
+ windows: list[dict[str, Any]] = []
480
+ seen: set[str] = set()
481
+ for window_id in result.stdout.splitlines():
482
+ window_id = window_id.strip()
483
+ if not window_id or window_id in seen:
484
+ continue
485
+ seen.add(window_id)
486
+ windows.append(collect_window(env, capabilities, window_id, errors))
487
+ return windows
488
+
489
+
490
+ def collect_window(
491
+ env: dict[str, str],
492
+ capabilities: dict[str, str],
493
+ window_id: str,
494
+ errors: list[str],
495
+ ) -> dict[str, Any]:
496
+ props = collect_window_props(env, capabilities, window_id)
497
+ geometry = collect_window_geometry(env, capabilities, window_id)
498
+ return {
499
+ "id": str(window_id),
500
+ "title": props.get("title", ""),
501
+ "class": props.get("class", ""),
502
+ "name": props.get("name", ""),
503
+ "pid": int_value(props.get("pid")),
504
+ "geometry": geometry,
505
+ }
506
+
507
+
508
+ def collect_window_geometry(env: dict[str, str], capabilities: dict[str, str], window_id: str) -> dict[str, int]:
509
+ geometry = {"x": 0, "y": 0, "width": 0, "height": 0}
510
+ if not capabilities.get("xwininfo"):
511
+ return geometry
512
+ result = run([capabilities["xwininfo"], "-id", str(window_id)], env=env, timeout=3)
513
+ if result.returncode != 0:
514
+ return geometry
515
+ patterns = {
516
+ "x": r"Absolute upper-left X:\s*(-?\d+)",
517
+ "y": r"Absolute upper-left Y:\s*(-?\d+)",
518
+ "width": r"Width:\s*(\d+)",
519
+ "height": r"Height:\s*(\d+)",
520
+ }
521
+ for key, pattern in patterns.items():
522
+ match = re.search(pattern, result.stdout)
523
+ if match:
524
+ geometry[key] = int(match.group(1))
525
+ return geometry
526
+
527
+
528
+ def collect_window_props(env: dict[str, str], capabilities: dict[str, str], window_id: str) -> dict[str, str]:
529
+ props = {"title": "", "class": "", "name": "", "pid": ""}
530
+ xdotool = capabilities.get("xdotool")
531
+ if xdotool:
532
+ result = run([xdotool, "getwindowname", str(window_id)], env=env, timeout=3)
533
+ if result.returncode == 0:
534
+ props["title"] = result.stdout.strip()
535
+ xprop = capabilities.get("xprop")
536
+ if not xprop:
537
+ return props
538
+ result = run([xprop, "-id", str(window_id), "WM_CLASS", "WM_NAME", "_NET_WM_NAME", "_NET_WM_PID"], env=env, timeout=3)
539
+ if result.returncode != 0:
540
+ return props
541
+ parsed = parse_xprop(result.stdout)
542
+ title = parsed.get("_NET_WM_NAME") or parsed.get("WM_NAME") or props["title"]
543
+ props["title"] = title
544
+ props["class"] = parsed.get("WM_CLASS_CLASS", "")
545
+ props["name"] = parsed.get("WM_CLASS_NAME", "")
546
+ props["pid"] = parsed.get("_NET_WM_PID", "")
547
+ return props
548
+
549
+
550
+ def parse_xprop(output: str) -> dict[str, str]:
551
+ values: dict[str, str] = {}
552
+ for line in output.splitlines():
553
+ if "=" not in line:
554
+ continue
555
+ key, raw_value = line.split("=", 1)
556
+ key = key.strip().split("(", 1)[0]
557
+ raw_value = raw_value.strip()
558
+ quoted = re.findall(r'"([^"]*)"', raw_value)
559
+ if key == "WM_CLASS" and quoted:
560
+ values["WM_CLASS_NAME"] = quoted[0]
561
+ values["WM_CLASS_CLASS"] = quoted[-1]
562
+ continue
563
+ if quoted:
564
+ values[key] = quoted[-1]
565
+ continue
566
+ match = re.search(r"-?\d+", raw_value)
567
+ values[key] = match.group(0) if match else raw_value
568
+ return values
569
+
570
+
571
+ def latest_screenshot(*, context_id: str = "") -> dict[str, Any]:
572
+ chat_dir = chat_screenshot_dir(context_id)
573
+ chat_latest = _latest_screenshot_from_dir(
574
+ chat_dir,
575
+ context_id=context_id,
576
+ ephemeral=False,
577
+ chat_scoped=True,
578
+ prune_older=False,
579
+ )
580
+ if chat_latest.get("ok"):
581
+ return chat_latest
582
+
583
+ prune_context_screenshots(context_id=context_id, max_age_seconds=RECENT_SCREENSHOT_SECONDS)
584
+ screenshot_dir = context_screenshot_dir(context_id)
585
+ return _latest_screenshot_from_dir(
586
+ screenshot_dir,
587
+ context_id=context_id,
588
+ ephemeral=True,
589
+ chat_scoped=False,
590
+ prune_older=True,
591
+ )
592
+
593
+
594
+ def _latest_screenshot_from_dir(
595
+ screenshot_dir: Path,
596
+ *,
597
+ context_id: str = "",
598
+ ephemeral: bool,
599
+ chat_scoped: bool,
600
+ prune_older: bool,
601
+ ) -> dict[str, Any]:
602
+ if not screenshot_dir.exists():
603
+ return {"ok": False, "path": "", "format": "", "captured_at": "", "recent": False}
604
+ candidates = [
605
+ path
606
+ for path in screenshot_dir.iterdir()
607
+ if path.is_file() and path.suffix.lower() in _SCREENSHOT_SUFFIXES
608
+ ]
609
+ if not candidates:
610
+ return {"ok": False, "path": "", "format": "", "captured_at": "", "recent": False}
611
+ latest = max(candidates, key=lambda item: item.stat().st_mtime)
612
+ if prune_older:
613
+ for candidate in candidates:
614
+ if candidate != latest:
615
+ candidate.unlink(missing_ok=True)
616
+ age = max(0.0, time.time() - latest.stat().st_mtime)
617
+ return {
618
+ "ok": True,
619
+ "path": str(latest),
620
+ "a0_path": normalize_a0_path(latest),
621
+ "format": latest.suffix.lower().lstrip("."),
622
+ "captured_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(latest.stat().st_mtime)),
623
+ "recent": age <= RECENT_SCREENSHOT_SECONDS,
624
+ "ephemeral": ephemeral,
625
+ "chat_scoped": chat_scoped,
626
+ "context_id": _safe_context_id(context_id),
627
+ }
628
+
629
+
630
+ def stable_state(
631
+ *,
632
+ display: str,
633
+ profile_dir: str,
634
+ context_id: str = "",
635
+ size: dict[str, int] | None = None,
636
+ pointer: dict[str, int] | None = None,
637
+ active_window: dict[str, Any] | None = None,
638
+ windows: list[dict[str, Any]] | None = None,
639
+ screenshot: dict[str, Any] | None = None,
640
+ capabilities: dict[str, str] | None = None,
641
+ errors: list[str] | None = None,
642
+ ) -> dict[str, Any]:
643
+ clean_errors = [str(error) for error in errors or [] if str(error)]
644
+ return {
645
+ "ok": not clean_errors,
646
+ "context_id": _safe_context_id(context_id),
647
+ "display": display,
648
+ "profile_dir": profile_dir,
649
+ "size": size or {"width": 0, "height": 0},
650
+ "pointer": pointer or {"x": 0, "y": 0, "screen": 0, "window": 0},
651
+ "active_window": active_window,
652
+ "windows": windows or [],
653
+ "screenshot": screenshot or {"ok": False, "path": "", "format": "", "captured_at": "", "recent": False},
654
+ "capabilities": capabilities or collect_capabilities(),
655
+ "errors": clean_errors,
656
+ }
657
+
658
+
659
+ def compact_prompt_context(state: dict[str, Any] | None = None) -> str:
660
+ state = state if state is not None else collect_state(include_screenshot=False)
661
+ if not state.get("display"):
662
+ return ""
663
+ lines = ["[DESKTOP STATE]"]
664
+ size = state.get("size") or {}
665
+ pointer = state.get("pointer") or {}
666
+ lines.append(
667
+ f"- display={state.get('display', '')} size={size.get('width', 0)}x{size.get('height', 0)} "
668
+ f"pointer={pointer.get('x', 0)},{pointer.get('y', 0)}"
669
+ )
670
+ active = state.get("active_window") or {}
671
+ if active:
672
+ lines.append(
673
+ f"- active={active.get('title', '') or '<untitled>'} "
674
+ f"class={active.get('class', '') or active.get('name', '')}"
675
+ )
676
+ visible = []
677
+ for window in state.get("windows") or []:
678
+ title = window.get("title") or "<untitled>"
679
+ window_class = window.get("class") or window.get("name") or ""
680
+ visible.append(f"{title} ({window_class})" if window_class else title)
681
+ if len(visible) >= 5:
682
+ break
683
+ if visible:
684
+ lines.append("- visible=" + "; ".join(visible))
685
+ screenshot = state.get("screenshot") or {}
686
+ if screenshot.get("recent") and screenshot.get("path"):
687
+ ephemeral = " ephemeral" if screenshot.get("ephemeral") else ""
688
+ screenshot_ref = screenshot.get("a0_path") or screenshot["path"]
689
+ lines.append(f"- recent_screenshot={screenshot_ref}{ephemeral}")
690
+ context_id = str(state.get("context_id") or "").strip()
691
+ if context_id:
692
+ lines.append(f"- screenshot_context={context_id}")
693
+ context_arg = f" --context-id {context_id}" if context_id else ""
694
+ lines.append(
695
+ "- next=plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh state --json"
696
+ f"{context_arg} for structured checks; use observe --json --screenshot"
697
+ f"{context_arg} "
698
+ "before coordinate or visual-OCR actions; prefer sequence/focus/key/paste/save/app-native helpers first."
699
+ )
700
+ lines.append(
701
+ "- verify=for terminal/CLI-agent output, use the screenshot path from a fresh final "
702
+ "observe --json --screenshot captured after the response appears."
703
+ )
704
+ if state.get("errors"):
705
+ lines.append("- errors=" + "; ".join(str(item) for item in state["errors"][:2]))
706
+ return "\n".join(lines)
707
+
708
+
709
+ def parse_shell_values(output: str) -> dict[str, str]:
710
+ values: dict[str, str] = {}
711
+ for line in output.splitlines():
712
+ if "=" not in line:
713
+ continue
714
+ key, value = line.split("=", 1)
715
+ values[key.strip()] = value.strip().strip('"')
716
+ return values
717
+
718
+
719
+ def int_value(value: Any, default: int = 0) -> int:
720
+ try:
721
+ return int(str(value).strip())
722
+ except (TypeError, ValueError):
723
+ return default
724
+
725
+
726
+ def run(command: list[str], *, env: dict[str, str], timeout: float) -> subprocess.CompletedProcess[str]:
727
+ try:
728
+ return subprocess.run(
729
+ command,
730
+ check=False,
731
+ capture_output=True,
732
+ text=True,
733
+ timeout=timeout,
734
+ env=env,
735
+ )
736
+ except OSError as exc:
737
+ return subprocess.CompletedProcess(command, 127, "", str(exc))
738
+ except subprocess.TimeoutExpired as exc:
739
+ stdout = exc.stdout.decode("utf-8", errors="replace") if isinstance(exc.stdout, bytes) else (exc.stdout or "")
740
+ stderr = exc.stderr.decode("utf-8", errors="replace") if isinstance(exc.stderr, bytes) else (exc.stderr or "")
741
+ return subprocess.CompletedProcess(command, 124, stdout, stderr or "command timed out")
742
+
743
+
744
+ def command_output(result: subprocess.CompletedProcess[str]) -> str:
745
+ return (result.stderr or result.stdout or "").strip()
746
+
747
+
748
+ def image_width(path: Path) -> int:
749
+ try:
750
+ from PIL import Image
751
+
752
+ with Image.open(path) as image:
753
+ return int(image.width)
754
+ except Exception:
755
+ return 0
756
+
757
+
758
+ def image_height(path: Path) -> int:
759
+ try:
760
+ from PIL import Image
761
+
762
+ with Image.open(path) as image:
763
+ return int(image.height)
764
+ except Exception:
765
+ return 0
766
+
767
+
768
+ def ephemeral_screenshot_result(
769
+ path: Path,
770
+ *,
771
+ context_id: str = "",
772
+ image_format: str = "png",
773
+ width: int = 0,
774
+ height: int = 0,
775
+ ) -> dict[str, Any]:
776
+ from helpers import ephemeral_images
777
+
778
+ mime = "image/jpeg" if image_format.lower() in {"jpg", "jpeg"} else "image/png"
779
+ safe_context = _safe_context_id(context_id)
780
+ ref = ephemeral_images.put_image_bytes(
781
+ context_id=str(context_id or "").strip(),
782
+ mime=mime,
783
+ payload=path.read_bytes(),
784
+ name=path.name,
785
+ )
786
+ path.unlink(missing_ok=True)
787
+ prune_context_screenshots(context_id=context_id)
788
+ return {
789
+ "ok": True,
790
+ "path": "",
791
+ "format": image_format,
792
+ "mime": mime,
793
+ "width": width,
794
+ "height": height,
795
+ "captured_at": iso_now(),
796
+ "recent": True,
797
+ "ephemeral": True,
798
+ "ephemeral_ref": ref,
799
+ "context_id": safe_context,
800
+ "vision_load": {
801
+ "tool_name": "vision_load",
802
+ "tool_args": {"paths": [ref]},
803
+ },
804
+ "error": "",
805
+ }
806
+
807
+
808
+ def prune_context_screenshots(
809
+ *,
810
+ context_id: str = "",
811
+ keep_path: Path | None = None,
812
+ max_age_seconds: float | None = None,
813
+ ) -> None:
814
+ screenshot_dir = context_screenshot_dir(context_id)
815
+ if not screenshot_dir.exists():
816
+ return
817
+ keep = keep_path.resolve(strict=False) if keep_path else None
818
+ now = time.time()
819
+ for candidate in screenshot_dir.iterdir():
820
+ if not candidate.is_file() or candidate.suffix.lower() not in _SCREENSHOT_SUFFIXES:
821
+ continue
822
+ if keep is not None and candidate.resolve(strict=False) == keep:
823
+ continue
824
+ if max_age_seconds is not None:
825
+ try:
826
+ if now - candidate.stat().st_mtime <= max_age_seconds:
827
+ continue
828
+ except OSError:
829
+ pass
830
+ candidate.unlink(missing_ok=True)
831
+ try:
832
+ screenshot_dir.rmdir()
833
+ except OSError:
834
+ pass
835
+
836
+
837
+ def iso_now() -> str:
838
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
839
+
840
+
841
+ def main(argv: list[str] | None = None) -> int:
842
+ parser = argparse.ArgumentParser(description="Observe the Agent Zero persistent Linux Desktop state.")
843
+ subparsers = parser.add_subparsers(dest="command")
844
+
845
+ state_parser = subparsers.add_parser("state")
846
+ state_parser.add_argument("--json", action="store_true")
847
+ state_parser.add_argument("--screenshot", action="store_true")
848
+ state_parser.add_argument("--context-id", default="")
849
+
850
+ observe_parser = subparsers.add_parser("observe")
851
+ observe_parser.add_argument("--json", action="store_true")
852
+ observe_parser.add_argument("--screenshot", action="store_true")
853
+ observe_parser.add_argument("--context-id", default="")
854
+
855
+ screenshot_parser = subparsers.add_parser("screenshot")
856
+ screenshot_parser.add_argument("path", nargs="?")
857
+ screenshot_parser.add_argument("--json", action="store_true")
858
+ screenshot_parser.add_argument("--context-id", default="")
859
+
860
+ args = parser.parse_args(argv)
861
+ command = args.command or "state"
862
+ if command in {"state", "observe"}:
863
+ payload = collect_state(
864
+ include_screenshot=bool(args.screenshot),
865
+ context_id=str(args.context_id or ""),
866
+ screenshot_transport="path",
867
+ )
868
+ print(json.dumps(payload, sort_keys=True))
869
+ return 0 if payload.get("ok") else 1
870
+
871
+ if command == "screenshot":
872
+ errors: list[str] = []
873
+ env_info = resolve_environment(errors=errors)
874
+ payload = capture_screenshot(
875
+ display_env(display=env_info["display"], profile_dir=env_info["profile_dir"]),
876
+ collect_capabilities(),
877
+ path=args.path,
878
+ errors=errors,
879
+ context_id=str(args.context_id or ""),
880
+ transport="path",
881
+ )
882
+ if args.json:
883
+ print(json.dumps(payload, sort_keys=True))
884
+ else:
885
+ print(payload.get("path") or payload.get("error") or "")
886
+ return 0 if payload.get("ok") else 1
887
+
888
+ parser.print_help()
889
+ return 2
890
+
891
+
892
+ if __name__ == "__main__":
893
+ raise SystemExit(main())
plugins/_desktop/helpers/prompt_context.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from plugins._desktop.helpers import desktop_state
4
+
5
+
6
+ def build_context(context_id: str = "") -> str:
7
+ if not desktop_state.session_manifest_exists():
8
+ return ""
9
+ try:
10
+ return desktop_state.compact_prompt_context(
11
+ desktop_state.collect_state(include_screenshot=False, context_id=context_id),
12
+ )
13
+ except Exception as exc:
14
+ return (
15
+ "[DESKTOP STATE]\n"
16
+ f"- unavailable={exc}\n"
17
+ "- next=Open the Desktop surface manually, then run plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh observe --json."
18
+ )
plugins/_desktop/hooks.py ADDED
@@ -0,0 +1,456 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import tempfile
7
+ import threading
8
+ import time
9
+ import urllib.request
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from helpers import files, state_migration, system_packages
14
+
15
+ LIBREOFFICE_RUNTIME_PACKAGES = (
16
+ "libreoffice-core",
17
+ "libreoffice-writer",
18
+ "libreoffice-calc",
19
+ "libreoffice-impress",
20
+ "libreoffice-gtk3",
21
+ "python3-uno",
22
+ )
23
+ XPRA_SOURCE_FILE = Path("/etc/apt/sources.list.d/xpra.sources")
24
+ XPRA_KEYRING_FILE = Path("/usr/share/keyrings/xpra.asc")
25
+ XPRA_KEY_URL = "https://xpra.org/xpra.asc"
26
+ XPRA_VERSION = "6.5.2-r0-1"
27
+ GTK_RUNTIME_PACKAGE = "gir1.2-gtk-3.0"
28
+ KALI_ROLLING_SOURCE = "deb http://http.kali.org/kali kali-rolling main contrib non-free non-free-firmware\n"
29
+ XPRA_VERSIONED_RUNTIME_PACKAGES = frozenset(
30
+ {
31
+ "xpra-common",
32
+ "xpra-server",
33
+ "xpra-client",
34
+ "xpra-client-gtk3",
35
+ "xpra-x11",
36
+ }
37
+ )
38
+ RUNTIME_PACKAGES = (
39
+ *LIBREOFFICE_RUNTIME_PACKAGES,
40
+ GTK_RUNTIME_PACKAGE,
41
+ "xpra-common",
42
+ "xpra-server",
43
+ "xpra-client",
44
+ "xpra-client-gtk3",
45
+ "xpra-x11",
46
+ "xpra-html5",
47
+ "xfce4-session",
48
+ "xfwm4",
49
+ "xfce4-panel",
50
+ "xfdesktop4",
51
+ "xfce4-settings",
52
+ "thunar",
53
+ "gvfs",
54
+ "libglib2.0-bin",
55
+ "xfce4-terminal",
56
+ "x11-xserver-utils",
57
+ "x11-utils",
58
+ "x11-apps",
59
+ "xdotool",
60
+ "xclip",
61
+ "xauth",
62
+ "dbus-x11",
63
+ "python3-pil",
64
+ "fonts-dejavu",
65
+ "fonts-liberation",
66
+ "fonts-crosextra-caladea",
67
+ "fonts-crosextra-carlito",
68
+ "fonts-noto-core",
69
+ "fonts-noto-cjk",
70
+ "fonts-noto-color-emoji",
71
+ )
72
+ RETIRED_RUNTIME_PACKAGES = (
73
+ "firefox-esr",
74
+ )
75
+ PLUGIN_NAME = "_desktop"
76
+ STATE_DIR = Path(files.get_abs_path("usr", "plugins", PLUGIN_NAME))
77
+ RETIRED_STATE_DIR = Path(files.get_abs_path("usr", PLUGIN_NAME))
78
+ _preparation_lock = threading.RLock()
79
+ _preparation_state: dict[str, Any] = {
80
+ "preparing": False,
81
+ "active_count": 0,
82
+ "started_at": 0.0,
83
+ "completed_at": 0.0,
84
+ "result": None,
85
+ "error": "",
86
+ }
87
+
88
+
89
+ def cleanup_stale_runtime_state(force: bool = False) -> dict[str, Any]:
90
+ """Prepare the Linux Desktop runtime and reap stale Desktop sessions."""
91
+
92
+ _begin_runtime_preparation()
93
+ result: dict[str, Any] | None = None
94
+ error = ""
95
+ try:
96
+ installed: list[str] = []
97
+ removed: list[str] = []
98
+ migrated: list[str] = []
99
+ warnings: list[str] = []
100
+ errors: list[str] = []
101
+
102
+ _migrate_retired_plugin_state(migrated, warnings, errors)
103
+ _remove_persisted_screenshots(warnings, errors)
104
+
105
+ retired_packages = _installed_packages(RETIRED_RUNTIME_PACKAGES)
106
+ if retired_packages:
107
+ _purge_packages(removed, errors, installed_packages=retired_packages)
108
+
109
+ _ensure_runtime_dependencies(installed, errors)
110
+ _cleanup_desktop_sessions(errors)
111
+
112
+ result = {
113
+ "ok": not errors,
114
+ "skipped": False,
115
+ "removed": removed,
116
+ "installed": installed,
117
+ "migrated": migrated,
118
+ "warnings": warnings,
119
+ "errors": errors,
120
+ }
121
+ return result
122
+ except Exception as exc:
123
+ error = str(exc)
124
+ raise
125
+ finally:
126
+ _finish_runtime_preparation(result=result, error=error)
127
+
128
+
129
+ def runtime_preparation_status() -> dict[str, Any]:
130
+ with _preparation_lock:
131
+ return {
132
+ "preparing": bool(_preparation_state["preparing"]),
133
+ "active_count": int(_preparation_state["active_count"]),
134
+ "started_at": float(_preparation_state["started_at"]),
135
+ "completed_at": float(_preparation_state["completed_at"]),
136
+ "result": _preparation_state["result"],
137
+ "error": str(_preparation_state["error"]),
138
+ }
139
+
140
+
141
+ def _migrate_retired_plugin_state(
142
+ migrated: list[str],
143
+ warnings: list[str],
144
+ errors: list[str],
145
+ ) -> None:
146
+ state_migration.migrate_retired_state_tree(
147
+ source=RETIRED_STATE_DIR,
148
+ destination=STATE_DIR,
149
+ owner="Desktop",
150
+ migrated=migrated,
151
+ warnings=warnings,
152
+ errors=errors,
153
+ )
154
+
155
+
156
+ def _remove_persisted_screenshots(
157
+ warnings: list[str],
158
+ errors: list[str],
159
+ ) -> None:
160
+ screenshots_dir = STATE_DIR / "screenshots"
161
+ if not screenshots_dir.exists():
162
+ return
163
+ try:
164
+ shutil.rmtree(screenshots_dir)
165
+ warnings.append(f"Removed retired persistent Desktop screenshots: {screenshots_dir}")
166
+ except Exception as exc:
167
+ errors.append(f"Failed to remove retired persistent Desktop screenshots at {screenshots_dir}: {exc}")
168
+
169
+
170
+ def _begin_runtime_preparation() -> None:
171
+ with _preparation_lock:
172
+ if not _preparation_state["active_count"]:
173
+ _preparation_state["preparing"] = True
174
+ _preparation_state["started_at"] = time.time()
175
+ _preparation_state["completed_at"] = 0.0
176
+ _preparation_state["result"] = None
177
+ _preparation_state["error"] = ""
178
+ _preparation_state["active_count"] = int(_preparation_state["active_count"]) + 1
179
+
180
+
181
+ def _finish_runtime_preparation(
182
+ *,
183
+ result: dict[str, Any] | None = None,
184
+ error: str = "",
185
+ ) -> None:
186
+ with _preparation_lock:
187
+ active_count = max(0, int(_preparation_state["active_count"]) - 1)
188
+ _preparation_state["active_count"] = active_count
189
+ if result is not None:
190
+ _preparation_state["result"] = result
191
+ if error:
192
+ _preparation_state["error"] = error
193
+ if active_count:
194
+ return
195
+ _preparation_state["preparing"] = False
196
+ _preparation_state["completed_at"] = time.time()
197
+
198
+
199
+ def _installed_packages(packages: tuple[str, ...]) -> list[str]:
200
+ if not shutil.which("dpkg-query"):
201
+ return []
202
+ return [package for package in packages if _package_installed(package)]
203
+
204
+
205
+ def _package_installed(package: str) -> bool:
206
+ result = subprocess.run(
207
+ ["dpkg-query", "-W", "-f=${Status}", package],
208
+ check=False,
209
+ text=True,
210
+ capture_output=True,
211
+ timeout=8,
212
+ )
213
+ return result.returncode == 0 and "install ok installed" in result.stdout
214
+
215
+
216
+ def _package_version(package: str) -> str:
217
+ result = subprocess.run(
218
+ ["dpkg-query", "-W", "-f=${Version}", package],
219
+ check=False,
220
+ text=True,
221
+ capture_output=True,
222
+ timeout=8,
223
+ )
224
+ return result.stdout.strip() if result.returncode == 0 else ""
225
+
226
+
227
+ def _purge_packages(
228
+ removed: list[str],
229
+ errors: list[str],
230
+ *,
231
+ installed_packages: list[str] | None = None,
232
+ ) -> None:
233
+ if os.geteuid() != 0 or not shutil.which("apt-get") or not shutil.which("dpkg-query"):
234
+ return
235
+ installed = installed_packages if installed_packages is not None else []
236
+ if not installed:
237
+ return
238
+ result = _run_apt_command(["apt-get", "purge", "-y", *installed], timeout=180)
239
+ if result.returncode == 0:
240
+ removed.extend(installed)
241
+ return
242
+ errors.append((result.stderr or result.stdout or "apt-get purge failed").strip())
243
+
244
+
245
+ def _ensure_runtime_dependencies(installed: list[str], errors: list[str]) -> None:
246
+ if os.geteuid() != 0 or not shutil.which("apt-get") or not shutil.which("dpkg-query"):
247
+ return
248
+ if not _ensure_kali_gtk_runtime(installed, errors):
249
+ return
250
+ missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)]
251
+ if not missing:
252
+ return
253
+
254
+ if not _apt_update(errors):
255
+ return
256
+
257
+ required_xpra_missing = [package for package in missing if package.startswith("xpra")]
258
+ if required_xpra_missing and not _package_candidates_available(required_xpra_missing):
259
+ previous_error_count = len(errors)
260
+ _ensure_xpra_repository(installed, errors)
261
+ if len(errors) > previous_error_count or not _apt_update(errors):
262
+ return
263
+ missing = [package for package in RUNTIME_PACKAGES if not _package_installed(package)]
264
+ if not missing:
265
+ return
266
+
267
+ if missing:
268
+ _install_runtime_packages(missing, installed, errors)
269
+
270
+
271
+ def _ensure_kali_gtk_runtime(installed: list[str], errors: list[str]) -> bool:
272
+ if (
273
+ GTK_RUNTIME_PACKAGE not in RUNTIME_PACKAGES
274
+ or _package_installed(GTK_RUNTIME_PACKAGE)
275
+ or _read_os_release().get("ID") != "kali"
276
+ ):
277
+ return True
278
+
279
+ with tempfile.TemporaryDirectory(prefix="a0-desktop-apt-") as directory:
280
+ source = Path(directory) / "kali-rolling.list"
281
+ source.write_text(KALI_ROLLING_SOURCE, encoding="utf-8")
282
+ options = [
283
+ "-o",
284
+ f"Dir::Etc::sourcelist={source}",
285
+ "-o",
286
+ "Dir::Etc::sourceparts=-",
287
+ ]
288
+ result = _run_apt_command(["apt-get", *options, "update"], timeout=300)
289
+ if result.returncode == 0:
290
+ result = _run_apt_command(
291
+ [
292
+ "apt-get",
293
+ *options,
294
+ "install",
295
+ "-y",
296
+ "--no-install-recommends",
297
+ GTK_RUNTIME_PACKAGE,
298
+ ],
299
+ timeout=900,
300
+ )
301
+ if result.returncode == 0:
302
+ installed.append(GTK_RUNTIME_PACKAGE)
303
+ return True
304
+ errors.append((result.stderr or result.stdout or "GTK runtime install failed").strip())
305
+ return False
306
+
307
+
308
+ def _install_runtime_packages(
309
+ packages: list[str],
310
+ installed: list[str],
311
+ errors: list[str],
312
+ ) -> bool:
313
+ xpra_version = ""
314
+ if any(package in XPRA_VERSIONED_RUNTIME_PACKAGES for package in packages):
315
+ xpra_version = _package_version("xpra-common") or XPRA_VERSION
316
+ package_specs = [
317
+ f"{package}={xpra_version}" if package in XPRA_VERSIONED_RUNTIME_PACKAGES else package
318
+ for package in packages
319
+ ]
320
+ result = _run_apt_command(
321
+ ["apt-get", "install", "-y", "--no-install-recommends", *package_specs],
322
+ timeout=900,
323
+ )
324
+ if result.returncode == 0:
325
+ installed.extend(packages)
326
+ return True
327
+ output = (result.stderr or result.stdout or "apt-get install failed").strip()
328
+ errors.append(output)
329
+ return False
330
+
331
+
332
+ def _apt_update(errors: list[str]) -> bool:
333
+ result = _run_apt_command(["apt-get", "update"], timeout=300)
334
+ if result.returncode == 0:
335
+ return True
336
+ errors.append((result.stderr or result.stdout or "apt-get update failed").strip())
337
+ return False
338
+
339
+
340
+ def _package_candidate_available(package: str) -> bool:
341
+ if not shutil.which("apt-cache"):
342
+ return True
343
+ result = subprocess.run(
344
+ ["apt-cache", "policy", package],
345
+ check=False,
346
+ text=True,
347
+ capture_output=True,
348
+ timeout=15,
349
+ )
350
+ if result.returncode != 0:
351
+ return True
352
+ if not result.stdout.strip():
353
+ return False
354
+ return "Candidate: (none)" not in result.stdout
355
+
356
+
357
+ def _package_candidates_available(packages: list[str]) -> bool:
358
+ return all(_package_candidate_available(package) for package in packages)
359
+
360
+
361
+ def _ensure_xpra_repository(installed: list[str], errors: list[str]) -> None:
362
+ if not _package_installed("ca-certificates"):
363
+ result = _run_apt_command(
364
+ ["apt-get", "install", "-y", "--no-install-recommends", "ca-certificates"],
365
+ timeout=180,
366
+ )
367
+ if result.returncode != 0:
368
+ errors.append((result.stderr or result.stdout or "apt-get install ca-certificates failed").strip())
369
+ return
370
+ installed.append("ca-certificates")
371
+
372
+ try:
373
+ key = _download(XPRA_KEY_URL)
374
+ XPRA_KEYRING_FILE.parent.mkdir(parents=True, exist_ok=True)
375
+ if not XPRA_KEYRING_FILE.exists() or XPRA_KEYRING_FILE.read_bytes() != key:
376
+ XPRA_KEYRING_FILE.write_bytes(key)
377
+
378
+ XPRA_SOURCE_FILE.parent.mkdir(parents=True, exist_ok=True)
379
+ source = _xpra_repository_source()
380
+ if not XPRA_SOURCE_FILE.exists() or XPRA_SOURCE_FILE.read_text(encoding="utf-8") != source:
381
+ XPRA_SOURCE_FILE.write_text(source, encoding="utf-8")
382
+ except Exception as exc:
383
+ errors.append(f"Xpra repository setup failed: {exc}")
384
+
385
+
386
+ def _download(url: str) -> bytes:
387
+ with urllib.request.urlopen(url, timeout=45) as response:
388
+ return response.read()
389
+
390
+
391
+ def _run_apt_command(command: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]:
392
+ return system_packages.run_apt_with_retries(
393
+ lambda: subprocess.run(
394
+ command,
395
+ check=False,
396
+ text=True,
397
+ capture_output=True,
398
+ timeout=timeout,
399
+ env={**os.environ, "DEBIAN_FRONTEND": "noninteractive"},
400
+ )
401
+ )
402
+
403
+
404
+ def _xpra_repository_source() -> str:
405
+ os_release = _read_os_release()
406
+ os_id = os_release.get("ID", "")
407
+ codename = os_release.get("VERSION_CODENAME", "")
408
+ arch = _dpkg_architecture()
409
+
410
+ uri = "https://xpra.org"
411
+ suite = "trixie" if os_id == "kali" or codename in {"sid", "forky"} else codename or "trixie"
412
+
413
+ return (
414
+ f"Types: deb\n"
415
+ f"URIs: {uri}\n"
416
+ f"Suites: {suite}\n"
417
+ f"Components: main\n"
418
+ f"Signed-By: {XPRA_KEYRING_FILE}\n"
419
+ f"Architectures: {arch}\n"
420
+ )
421
+
422
+
423
+ def _read_os_release() -> dict[str, str]:
424
+ path = Path("/etc/os-release")
425
+ if not path.exists():
426
+ return {}
427
+ values: dict[str, str] = {}
428
+ for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
429
+ if not line or line.startswith("#") or "=" not in line:
430
+ continue
431
+ key, value = line.split("=", 1)
432
+ values[key] = value.strip().strip('"')
433
+ return values
434
+
435
+
436
+ def _dpkg_architecture() -> str:
437
+ result = subprocess.run(
438
+ ["dpkg", "--print-architecture"],
439
+ check=False,
440
+ text=True,
441
+ capture_output=True,
442
+ timeout=8,
443
+ )
444
+ if result.returncode == 0 and result.stdout.strip():
445
+ return result.stdout.strip()
446
+ return "amd64"
447
+
448
+
449
+ def _cleanup_desktop_sessions(errors: list[str]) -> None:
450
+ try:
451
+ from plugins._desktop.helpers import desktop_session
452
+
453
+ result = desktop_session.cleanup_stale_runtime_state()
454
+ errors.extend(str(item) for item in result.get("errors") or [])
455
+ except Exception as exc:
456
+ errors.append(f"Desktop cleanup failed: {exc}")
plugins/_desktop/plugin.yaml ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ name: _desktop
2
+ title: Desktop
3
+ description: Owns the Agent Zero Linux Desktop runtime, Xpra/Xfce sessions, and live Desktop surface.
4
+ version: 1.0.0
5
+ always_enabled: false
plugins/_desktop/prompts/agent.extras.desktop_state.md ADDED
@@ -0,0 +1 @@
 
 
1
+ {{desktop_state}}
plugins/_desktop/skills/linux-desktop/SKILL.md ADDED
@@ -0,0 +1,202 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: linux-desktop
3
+ description: Use only for Agent Zero's built-in Docker/Xpra Linux Desktop, XFCE apps, LibreOffice GUI apps, file manager, terminal, or visual workflows inside the Agent Zero runtime. Not for A0 CLI /computer-use or computer_use_remote host control.
4
+ version: "0.3.0"
5
+ author: "Agent Zero Core Team"
6
+ tags: ["linux", "desktop", "xfce", "libreoffice", "gui", "files", "terminal"]
7
+ triggers:
8
+ - "Agent Zero Desktop"
9
+ - "built-in Linux Desktop"
10
+ - "Xpra Desktop"
11
+ - "XFCE Desktop"
12
+ - "LibreOffice GUI in Desktop"
13
+ - "Desktop file manager"
14
+ - "Docker desktop terminal"
15
+ allowed_tools:
16
+ - office_artifact
17
+ - code_execution_tool
18
+ ---
19
+
20
+ # Linux Desktop Interface
21
+
22
+ ## Boundary With Host Computer Use
23
+
24
+ This skill is only for the built-in Desktop surface running inside Agent Zero's Docker/runtime Xpra session. It does not control the user's host OS, real monitor, local Ubuntu/Wayland session, macOS desktop, or Windows desktop.
25
+
26
+ If the user asks for `/computer-use`, "my computer", "host machine", "local desktop", screenshots of the user's screen, hiding/minimizing a window on their screen, or any connected A0 CLI Computer Use task, do not use this skill, `desktopctl.sh`, Xpra, `code_execution_tool`, or Docker shell commands. Load `host-computer-use` and use `computer_use_remote`; if unavailable, tell the user to enable `/computer-use on`.
27
+
28
+ `desktopctl.sh` only targets the internal Agent Zero Xpra display. It cannot observe or control the user's connected host screen.
29
+
30
+ Use the Desktop as a full Linux GUI when the user explicitly needs a visual workflow, an installed desktop app, or manual layout polish that is awkward through structured file edits alone. Agent Zero may warm the persistent Desktop runtime during initial startup, but visible Desktop surface use remains opt-in. The Desktop is opt-in at the UI level: do not open a surface just because the user asks for a document. Use structured tools first for deterministic content changes, then use the Desktop for inspection, GUI-only actions, and final visual confirmation.
31
+
32
+ ## Operating Model
33
+
34
+ The Desktop is a structured-first X11 control surface. Use this decision hierarchy:
35
+
36
+ 1. Prefer structured tools such as `office_artifact` for deterministic Office file creation, reads, and edits.
37
+ 2. Prefer structured Desktop state and window commands: `check`, `state --json`, `windows`, `active-window`, `geometry`, and `wait-window`.
38
+ 3. Prefer app-native helpers for visible live edits, such as `desktopctl.sh calc-set-cell` for Calc/UNO spreadsheet changes.
39
+ 4. Prefer launcher commands, window focus, keyboard shortcuts, menus, paste, and save commands. Batch multi-step actions with `sequence`/`batch` so one helper process drives the flow.
40
+ 5. Use screenshots for visual inspection, OCR, coordinate work, and final evidence when the task depends on pixels. Do not take screenshots merely to learn window titles or readiness that structured state already reports.
41
+ 6. Use coordinate clicks only as a last resort, and only after a fresh screenshot observation.
42
+ 7. After any GUI action, verify through Desktop state, active window titles, screenshots when visually necessary, saved file state, or exported output.
43
+ 8. For terminal or CLI-agent work, prefer deterministic command output or saved transcripts. When exact visible terminal text matters, verify against a fresh final `observe --json --screenshot` captured after the command has finished or visibly returned to an input prompt. Agent-facing Desktop screenshots are ephemeral refs; `desktopctl` shell observations with `--context-id` return chat-scoped screenshot paths. Do not report from an earlier screenshot path.
44
+
45
+ Keep these standing rules:
46
+
47
+ 1. Treat Markdown as first-class, but use the separate Editor surface for it. For writing, notes, reports, and drafts with no explicit binary Office requirement, create Markdown and let the user open it in Editor instead of Desktop.
48
+ 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 OOXML compatibility.
49
+ 3. Use the Desktop only when the user asks for the Desktop, a GUI app, binary Office visual work, or visual confirmation.
50
+ 4. Never open the Desktop surface automatically from a tool result if the user has not opened it. Offer an explicit Open in Desktop action instead.
51
+ 5. Launch common apps from the Desktop icons, the header buttons, or `/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh`.
52
+ 6. Use the external Agent Zero Browser for web browsing. Do not launch an operating-system browser in this version.
53
+ 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.
54
+
55
+ ## Control Flow
56
+
57
+ Use the helper script when the Desktop is already open and you need reliable app launches, clicks, keystrokes, or window checks from the agent shell. In the live Agent Zero runtime, prefer the absolute path so the command works from any current directory:
58
+
59
+ ```bash
60
+ DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
61
+ $DESKTOP check
62
+ $DESKTOP state --json
63
+ $DESKTOP observe --json
64
+ $DESKTOP launch calc
65
+ $DESKTOP wait-window LibreOffice
66
+ $DESKTOP windows LibreOffice
67
+ $DESKTOP focus LibreOffice
68
+ $DESKTOP key ctrl+s
69
+ ```
70
+
71
+ The script targets the persistent `agent-zero-desktop` X display, sets `DISPLAY`, `XAUTHORITY`, and `HOME` to the XFCE profile, then uses `xdotool` for input. Startup normally prepares this session. If `check` fails during explicit Desktop work, report that the Desktop runtime is not ready instead of installing packages ad hoc.
72
+
73
+ If `state --json` or `observe --json` shows a reachable display and visible Desktop/window entries, the Desktop is usable even when `active_window` is `null`; a bare XFCE desktop can have no active application window. Treat missing display or unavailable `xdotool` as blockers and stop with the specific readiness message instead of repeating clicks or inventing a fallback. Use `observe --json --screenshot` only when you need pixels, and treat unavailable `xwd` as a screenshot blocker rather than a general Desktop blocker. Shell screenshots captured with `--context-id` live in the owning chat's screenshot folder; screenshots without a chat context remain temporary.
74
+
75
+ For direct app launches without coordinates:
76
+
77
+ ```bash
78
+ DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
79
+ $DESKTOP launch writer
80
+ $DESKTOP launch calc
81
+ $DESKTOP launch impress
82
+ $DESKTOP launch terminal
83
+ $DESKTOP launch settings
84
+ $DESKTOP open-path /a0/usr/workdir
85
+ $DESKTOP focus "LibreOffice"
86
+ $DESKTOP paste-text "Text to insert"
87
+ $DESKTOP key ctrl+s
88
+ ```
89
+
90
+ For multi-step window or terminal actions, batch commands through one helper process:
91
+
92
+ ```bash
93
+ DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
94
+ $DESKTOP sequence - <<'EOF'
95
+ focus Terminal
96
+ paste-text echo ready
97
+ key Return
98
+ state --json
99
+ EOF
100
+ ```
101
+
102
+ Use one command per line. `paste-text` joins the remaining words on that line, so it is suitable for ordinary command text; for complex multiline payloads, write the payload to a file or invoke `paste-text` directly.
103
+
104
+ For live spreadsheet coworking, use the Calc helper instead of hand-written UNO snippets:
105
+
106
+ ```bash
107
+ DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
108
+ $DESKTOP calc-set-cell /a0/usr/workdir/example.xlsx Sheet1 B2 "Cowork verified live"
109
+ ```
110
+
111
+ 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.
112
+
113
+ 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:
114
+
115
+ ```bash
116
+ DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
117
+ $DESKTOP observe --json --screenshot
118
+ $DESKTOP click 120 180
119
+ $DESKTOP dblclick 120 180
120
+ $DESKTOP right-click 120 180
121
+ $DESKTOP drag 120 180 400 180
122
+ $DESKTOP scroll down 3
123
+ $DESKTOP type "Text to enter"
124
+ $DESKTOP observe --json
125
+ ```
126
+
127
+ When browser automation is available, the higher-level QA flow is:
128
+
129
+ 1. Open `http://127.0.0.1:32080`.
130
+ 2. Open the Desktop surface from the UI or with `Alpine.store("rightCanvas").open("desktop")`.
131
+ 3. Use browser mouse events into the Xpra iframe for real user-path testing.
132
+ 4. Cross-check with `desktopctl.sh location` and `desktopctl.sh windows PATTERN`.
133
+ 5. Capture the browser screenshot as visual evidence.
134
+
135
+ ## Terminal And CLI Agent Verification
136
+
137
+ Terminal apps are visual state, not structured logs. When the task depends on exact terminal output, follow this stricter loop:
138
+
139
+ 1. Run `desktopctl.sh state --json` or `desktopctl.sh windows Terminal` before acting to confirm the target window. Add `observe --json --screenshot` only if the current prompt or screen contents must be read visually.
140
+ 2. Use `sequence`, `focus`, `paste-text` or `type`, and `key Return` to drive the terminal. Prefer CLI-native commands and keyboard input over clicks.
141
+ 3. Wait until the CLI has visibly produced a response or returned to an input prompt.
142
+ 4. If exact visible text matters and no deterministic transcript was saved, run a new final `desktopctl.sh observe --json --screenshot`.
143
+ 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.
144
+ 6. If the final screenshot is cropped, stale, or unreadable, capture another screenshot or report the result as unverified with that specific reason.
145
+
146
+ 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.
147
+
148
+ Guard the boundary between the shell and the target CLI carefully:
149
+
150
+ - A shell prompt such as `root@...#` means the target CLI is not currently receiving chat input. Never paste natural-language text into that shell prompt unless it is deliberately quoted as an argument to a shell command.
151
+ - If launching a CLI by name can fail, use a shell-safe fallback command and wait for the CLI's own prompt before sending the user's natural-language message.
152
+ - After a launch failure such as `command not found`, do not continue by sending the chat message. Start the fallback CLI command or report the blocker.
153
+ - A prompt like `>`, `Implement {feature}`, or a CLI-specific input box inside the terminal is different from a shell prompt. Only then should `paste-text "natural language"` followed by `key Return` be used as chat input.
154
+
155
+ Example for a nested CLI-agent smoke test:
156
+
157
+ ```bash
158
+ DESKTOP=/a0/plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh
159
+ $DESKTOP sequence - <<'EOF'
160
+ focus Terminal
161
+ paste-text TARGET_CLI="example-cli-agent"; FALLBACK_CMD=""; if command -v "$TARGET_CLI" >/dev/null 2>&1; then "$TARGET_CLI"; elif [ -n "$FALLBACK_CMD" ]; then sh -lc "$FALLBACK_CMD"; else echo "CLI agent not found: $TARGET_CLI"; fi
162
+ key Return
163
+ EOF
164
+ $DESKTOP observe --json --screenshot
165
+ # Verify the screenshot shows the target CLI prompt, not a shell prompt, before sending natural language:
166
+ $DESKTOP sequence - <<'EOF'
167
+ paste-text Reply with exactly the requested smoke-test token.
168
+ key Return
169
+ EOF
170
+ $DESKTOP observe --json --screenshot
171
+ ```
172
+
173
+ ## Desktop Locations
174
+
175
+ The Desktop exposes stable folders for common user work:
176
+
177
+ - `Workdir` -> configured Agent Zero workdir (default `/a0/usr/workdir`)
178
+ - `Projects` -> `/a0/usr/projects`
179
+ - `Skills` -> `/a0/usr/skills`
180
+ - `Agents` -> `/a0/usr/agents`
181
+ - `Downloads` -> `/a0/usr/downloads`
182
+
183
+ Use these folders when the user asks to inspect or manipulate project files, skills, agent profiles, or downloaded artifacts from the GUI.
184
+
185
+ ## App Map
186
+
187
+ - `LibreOffice Writer`: ODT word processing and DOCX compatibility layout.
188
+ - `LibreOffice Calc`: ODS spreadsheets, formulas, tables, charts, and XLSX compatibility.
189
+ - `LibreOffice Impress`: ODP presentations, slide polish, and PPTX compatibility.
190
+ - `Workdir`: graphical file management with Thunar at the configured Agent Zero workdir (default `/a0/usr/workdir`).
191
+ - `Terminal`: shell work inside the Agent Zero runtime.
192
+ - `Settings`: XFCE system settings.
193
+
194
+ ## Practical Rules
195
+
196
+ - Keep installs inside normal plugin hook or image install flows; do not install packages ad hoc just to complete one desktop action.
197
+ - The persistent Desktop can be running in the background while the canvas stays closed; that is expected and still respects user ownership of the visible UI.
198
+ - Do not treat closing a document tab as closing the whole Desktop. The Desktop is persistent while Agent Zero is running.
199
+ - Save before sync or final verification when the GUI app has edited a file.
200
+ - If a GUI action is flaky, switch to structured file editing for content and return to the Desktop only for visual confirmation.
201
+ - For live Calc edits that the user should see immediately, prefer `desktopctl.sh calc-set-cell FILE SHEET CELL VALUE`.
202
+ - For enterprise workflows, leave printing available.
plugins/_desktop/skills/linux-desktop/scripts/calc_set_cell.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Set a Calc cell through the visible LibreOffice desktop session."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import os
8
+ import shutil
9
+ import subprocess
10
+ import sys
11
+ import time
12
+ import zipfile
13
+ from pathlib import Path
14
+ import xml.etree.ElementTree as ET
15
+
16
+ for candidate in ("/usr/lib/python3/dist-packages", "/usr/lib/libreoffice/program"):
17
+ if candidate not in sys.path:
18
+ sys.path.insert(0, candidate)
19
+
20
+ try:
21
+ import uno
22
+ except Exception as exc: # pragma: no cover - exercised in the container runtime.
23
+ raise SystemExit(f"LibreOffice UNO is not available: {exc}") from exc
24
+
25
+
26
+ UNO_URL = "uno:socket,host=localhost,port=2002;urp;StarOffice.ComponentContext"
27
+ OOXML_MAIN_NS = "http://schemas.openxmlformats.org/spreadsheetml/2006/main"
28
+ OOXML_REL_NS = "http://schemas.openxmlformats.org/officeDocument/2006/relationships"
29
+ REL_PACKAGE_NS = "http://schemas.openxmlformats.org/package/2006/relationships"
30
+ NS = {"m": OOXML_MAIN_NS, "r": OOXML_REL_NS, "rel": REL_PACKAGE_NS}
31
+
32
+
33
+ def parse_args() -> argparse.Namespace:
34
+ parser = argparse.ArgumentParser(
35
+ description="Open an XLSX in visible LibreOffice Calc, edit one cell, save, and verify."
36
+ )
37
+ parser.add_argument("file")
38
+ parser.add_argument("sheet")
39
+ parser.add_argument("cell")
40
+ parser.add_argument("value", nargs=argparse.REMAINDER)
41
+ args = parser.parse_args()
42
+ if not args.value:
43
+ parser.error("value is required")
44
+ args.value = " ".join(args.value)
45
+ return args
46
+
47
+
48
+ def run_quiet(command: list[str], *, timeout: float | None = None) -> subprocess.CompletedProcess:
49
+ return subprocess.run(
50
+ command,
51
+ stdout=subprocess.DEVNULL,
52
+ stderr=subprocess.DEVNULL,
53
+ check=False,
54
+ timeout=timeout,
55
+ )
56
+
57
+
58
+ def dismiss_document_in_use_dialogs() -> None:
59
+ xdotool = shutil.which("xdotool")
60
+ if not xdotool:
61
+ return
62
+ found = subprocess.run(
63
+ [xdotool, "search", "--onlyvisible", "--name", "Document in Use"],
64
+ stdout=subprocess.PIPE,
65
+ stderr=subprocess.DEVNULL,
66
+ text=True,
67
+ check=False,
68
+ )
69
+ for window_id in found.stdout.splitlines():
70
+ if window_id.strip():
71
+ run_quiet([xdotool, "windowclose", window_id.strip()], timeout=2)
72
+
73
+
74
+ def connect_once():
75
+ local_ctx = uno.getComponentContext()
76
+ resolver = local_ctx.ServiceManager.createInstanceWithContext(
77
+ "com.sun.star.bridge.UnoUrlResolver",
78
+ local_ctx,
79
+ )
80
+ return resolver.resolve(os.environ.get("A0_DESKTOP_UNO_URL", UNO_URL))
81
+
82
+
83
+ def desktop_from_context(ctx):
84
+ return ctx.ServiceManager.createInstanceWithContext("com.sun.star.frame.Desktop", ctx)
85
+
86
+
87
+ def component_url(component) -> str:
88
+ try:
89
+ return getattr(component, "URL", "") or ""
90
+ except Exception:
91
+ return ""
92
+
93
+
94
+ def component_title(component) -> str:
95
+ try:
96
+ return getattr(component, "Title", "") or ""
97
+ except Exception:
98
+ return ""
99
+
100
+
101
+ def component_has_sheets(component) -> bool:
102
+ try:
103
+ component.getSheets()
104
+ return True
105
+ except Exception:
106
+ return False
107
+
108
+
109
+ def url_to_path(url: str) -> str:
110
+ if not url:
111
+ return ""
112
+ try:
113
+ return os.path.realpath(uno.fileUrlToSystemPath(url))
114
+ except Exception:
115
+ return ""
116
+
117
+
118
+ def find_open_document(desktop, path: Path):
119
+ target_path = os.path.realpath(str(path))
120
+ target_name = path.name
121
+ try:
122
+ enum = desktop.getComponents().createEnumeration()
123
+ except Exception:
124
+ return None
125
+ while enum.hasMoreElements():
126
+ component = enum.nextElement()
127
+ if not component_has_sheets(component):
128
+ continue
129
+ if url_to_path(component_url(component)) == target_path:
130
+ return component
131
+ if target_name and target_name in component_title(component):
132
+ return component
133
+ return None
134
+
135
+
136
+ def libreoffice_command(path: Path) -> list[str]:
137
+ soffice = os.environ.get("SOFFICE") or shutil.which("soffice") or "soffice"
138
+ home = os.environ.get("HOME", "")
139
+ user_installation = f"-env:UserInstallation=file://{home}" if home else ""
140
+ command = [
141
+ soffice,
142
+ "--norestore",
143
+ "--nofirststartwizard",
144
+ "--nolockcheck",
145
+ "--accept=socket,host=localhost,port=2002;urp;StarOffice.ComponentContext",
146
+ "--calc",
147
+ str(path),
148
+ ]
149
+ if user_installation:
150
+ command.insert(4, user_installation)
151
+ return command
152
+
153
+
154
+ def launch_visible_calc(path: Path) -> None:
155
+ subprocess.Popen(
156
+ libreoffice_command(path),
157
+ stdout=subprocess.DEVNULL,
158
+ stderr=subprocess.DEVNULL,
159
+ start_new_session=True,
160
+ )
161
+
162
+
163
+ def wait_for_document(path: Path, timeout_seconds: float):
164
+ started = False
165
+ last_error: Exception | None = None
166
+ deadline = time.time() + timeout_seconds
167
+ while time.time() < deadline:
168
+ dismiss_document_in_use_dialogs()
169
+ try:
170
+ ctx = connect_once()
171
+ desktop = desktop_from_context(ctx)
172
+ document = find_open_document(desktop, path)
173
+ if document is not None:
174
+ return document
175
+ if not started:
176
+ launch_visible_calc(path)
177
+ started = True
178
+ except Exception as exc:
179
+ last_error = exc
180
+ if not started:
181
+ launch_visible_calc(path)
182
+ started = True
183
+ time.sleep(0.5)
184
+ detail = f": {last_error!r}" if last_error else ""
185
+ raise RuntimeError(f"Timed out waiting for LibreOffice Calc to open {path}{detail}")
186
+
187
+
188
+ def focus_document_window(document) -> None:
189
+ try:
190
+ document.CurrentController.Frame.ContainerWindow.setFocus()
191
+ except Exception:
192
+ pass
193
+
194
+
195
+ def sheet_path_for_name(zip_file: zipfile.ZipFile, sheet_name: str) -> str:
196
+ workbook = ET.fromstring(zip_file.read("xl/workbook.xml"))
197
+ rel_id = None
198
+ sheets = workbook.find("m:sheets", NS)
199
+ if sheets is None:
200
+ raise RuntimeError("Workbook has no sheets collection")
201
+ for sheet in sheets:
202
+ if sheet.attrib.get("name") == sheet_name:
203
+ rel_id = sheet.attrib.get(f"{{{OOXML_REL_NS}}}id")
204
+ break
205
+ if not rel_id:
206
+ raise RuntimeError(f"Sheet not found on disk: {sheet_name}")
207
+ rels = ET.fromstring(zip_file.read("xl/_rels/workbook.xml.rels"))
208
+ target = None
209
+ for rel in rels:
210
+ if rel.attrib.get("Id") == rel_id:
211
+ target = rel.attrib.get("Target")
212
+ break
213
+ if not target:
214
+ raise RuntimeError(f"Sheet relationship not found on disk: {sheet_name}")
215
+ target = target.lstrip("/")
216
+ if target.startswith("xl/"):
217
+ return target
218
+ return f"xl/{target}"
219
+
220
+
221
+ def shared_strings(zip_file: zipfile.ZipFile) -> list[str]:
222
+ if "xl/sharedStrings.xml" not in zip_file.namelist():
223
+ return []
224
+ root = ET.fromstring(zip_file.read("xl/sharedStrings.xml"))
225
+ values: list[str] = []
226
+ for item in root.findall("m:si", NS):
227
+ parts = [
228
+ text_node.text or ""
229
+ for text_node in item.iter(f"{{{OOXML_MAIN_NS}}}t")
230
+ ]
231
+ values.append("".join(parts))
232
+ return values
233
+
234
+
235
+ def read_xlsx_cell(path: Path, sheet_name: str, cell_name: str) -> str | None:
236
+ with zipfile.ZipFile(path) as zip_file:
237
+ sheet_path = sheet_path_for_name(zip_file, sheet_name)
238
+ sheet = ET.fromstring(zip_file.read(sheet_path))
239
+ strings = shared_strings(zip_file)
240
+ for cell in sheet.iter(f"{{{OOXML_MAIN_NS}}}c"):
241
+ if cell.attrib.get("r") != cell_name:
242
+ continue
243
+ cell_type = cell.attrib.get("t")
244
+ if cell_type == "inlineStr":
245
+ return "".join(
246
+ text_node.text or ""
247
+ for text_node in cell.iter(f"{{{OOXML_MAIN_NS}}}t")
248
+ )
249
+ value_node = cell.find("m:v", NS)
250
+ raw_value = "" if value_node is None or value_node.text is None else value_node.text
251
+ if cell_type == "s" and raw_value != "":
252
+ return strings[int(raw_value)]
253
+ return raw_value
254
+ return None
255
+
256
+
257
+ def wait_for_disk_value(path: Path, sheet: str, cell: str, expected: str) -> str | None:
258
+ latest = None
259
+ for _ in range(24):
260
+ latest = read_xlsx_cell(path, sheet, cell)
261
+ if latest == expected:
262
+ return latest
263
+ time.sleep(0.25)
264
+ return latest
265
+
266
+
267
+ def is_disposed_error(exc: BaseException) -> bool:
268
+ return "DisposedException" in type(exc).__name__ or "DisposedException" in str(exc)
269
+
270
+
271
+ def edit_visible_calc_cell(path: Path, sheet_name: str, cell_name: str, value: str) -> tuple[str, str, str | None]:
272
+ last_error: Exception | None = None
273
+ for _ in range(4):
274
+ document = wait_for_document(
275
+ path,
276
+ timeout_seconds=float(os.environ.get("A0_CALC_OPEN_TIMEOUT", "30")),
277
+ )
278
+ try:
279
+ focus_document_window(document)
280
+ sheets = document.getSheets()
281
+ if not sheets.hasByName(sheet_name):
282
+ raise SystemExit(f"Sheet not found: {sheet_name}")
283
+ sheet = sheets.getByName(sheet_name)
284
+ target_cell = sheet.getCellRangeByName(cell_name)
285
+ before = target_cell.getString()
286
+ target_cell.setString(value)
287
+ document.store()
288
+ after = target_cell.getString()
289
+ disk_value = wait_for_disk_value(path, sheet_name, cell_name, value)
290
+ return before, after, disk_value
291
+ except Exception as exc:
292
+ if not is_disposed_error(exc):
293
+ raise
294
+ last_error = exc
295
+ time.sleep(1)
296
+ raise RuntimeError(f"LibreOffice UNO bridge stayed disposed while editing {path}: {last_error!r}")
297
+
298
+
299
+ def main() -> int:
300
+ args = parse_args()
301
+ path = Path(args.file).expanduser().resolve()
302
+ if not path.exists():
303
+ raise SystemExit(f"Workbook not found: {path}")
304
+
305
+ before, after, disk_value = edit_visible_calc_cell(path, args.sheet, args.cell, args.value)
306
+ if after != args.value or disk_value != args.value:
307
+ raise SystemExit(
308
+ f"Calc edit verification failed: before={before!r} after={after!r} disk={disk_value!r}"
309
+ )
310
+ print(f"updated {path} {args.sheet}!{args.cell}: {before!r} -> {after!r}")
311
+ return 0
312
+
313
+
314
+ if __name__ == "__main__":
315
+ raise SystemExit(main())
plugins/_desktop/skills/linux-desktop/scripts/desktopctl.sh ADDED
@@ -0,0 +1,459 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ 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/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)}"
11
+
12
+ display_from_manifest() {
13
+ if [ ! -f "$MANIFEST" ] || ! command -v python3 >/dev/null 2>&1; then
14
+ return 0
15
+ fi
16
+ python3 - "$MANIFEST" <<'PY'
17
+ import json
18
+ import sys
19
+
20
+ try:
21
+ with open(sys.argv[1], "r", encoding="utf-8") as handle:
22
+ value = json.load(handle).get("display", "")
23
+ except Exception:
24
+ value = ""
25
+ if value != "":
26
+ print(value)
27
+ PY
28
+ }
29
+
30
+ DISPLAY_VALUE="${A0_DESKTOP_DISPLAY:-$(display_from_manifest || true)}"
31
+ DISPLAY_VALUE="${DISPLAY_VALUE:-120}"
32
+ case "$DISPLAY_VALUE" in
33
+ :*) export DISPLAY="$DISPLAY_VALUE" ;;
34
+ *) export DISPLAY=":$DISPLAY_VALUE" ;;
35
+ 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}"
46
+ export XDG_CURRENT_DESKTOP="${XDG_CURRENT_DESKTOP:-XFCE}"
47
+
48
+ command_name="${1:-help}"
49
+ shift || true
50
+
51
+ usage() {
52
+ cat <<'EOF'
53
+ Usage: desktopctl.sh <command> [args]
54
+
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 [--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.
62
+ screenshot [PATH] [--context-id ID]
63
+ Capture the Desktop to PATH, or to the chat screenshot directory when context-id is set.
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.
67
+ location Print the current X pointer location.
68
+ windows [PATTERN] List visible window names matching PATTERN.
69
+ focus PATTERN Focus the first visible window matching PATTERN.
70
+ scroll DIRECTION [UNITS] Scroll up, down, left, or right; UNITS defaults to 5 clicks.
71
+ drag X1 Y1 X2 Y2 Drag from X1,Y1 to X2,Y2 in Desktop coordinates.
72
+ right-click X Y Move and right-click at X,Y in Desktop coordinates.
73
+ paste-text TEXT Put TEXT on the Desktop clipboard and paste it with an app-native shortcut.
74
+ sequence FILE|- Run a newline-delimited command sequence in this process.
75
+ batch FILE|- Alias for sequence.
76
+ key KEY... Send one or more xdotool key names.
77
+ type TEXT Type text into the focused window.
78
+ click X Y Move and click at X,Y in Desktop coordinates.
79
+ dblclick X Y Move and double-click at X,Y in Desktop coordinates.
80
+ launch APP Launch writer, calc, impress, terminal, settings, or workdir.
81
+ open-path [PATH] Open PATH in Thunar, defaulting to /a0/usr/workdir.
82
+ calc-set-cell FILE SHEET CELL VALUE
83
+ Open FILE in visible Calc, set SHEET!CELL, save, and verify.
84
+ save Send Ctrl+S to the focused app.
85
+ EOF
86
+ }
87
+
88
+ require_xdotool() {
89
+ if ! command -v xdotool >/dev/null 2>&1; then
90
+ echo "xdotool is required for Desktop control." >&2
91
+ exit 2
92
+ fi
93
+ }
94
+
95
+ ensure_display() {
96
+ require_xdotool
97
+ if ! xdotool getmouselocation >/dev/null 2>&1; then
98
+ echo "Desktop X display is not reachable. Open the Desktop surface first." >&2
99
+ exit 2
100
+ fi
101
+ }
102
+
103
+ desktop_state() {
104
+ if [ ! -f "$DESKTOP_STATE_HELPER" ]; then
105
+ echo "Desktop state helper not found: $DESKTOP_STATE_HELPER" >&2
106
+ exit 2
107
+ fi
108
+ "$DESKTOP_STATE_PYTHON" "$DESKTOP_STATE_HELPER" "$@"
109
+ }
110
+
111
+ run_detached() {
112
+ ( "$@" >/tmp/a0-desktopctl.log 2>&1 & )
113
+ }
114
+
115
+ close_blocking_dialogs() {
116
+ require_xdotool
117
+ for title in "Document in Use"; do
118
+ window_ids="$(xdotool search --onlyvisible --name "$title" 2>/dev/null || true)"
119
+ printf '%s\n' "$window_ids" | while read -r window_id; do
120
+ [ -n "$window_id" ] && xdotool windowclose "$window_id" >/dev/null 2>&1 || true
121
+ done
122
+ done
123
+ }
124
+
125
+ first_window() {
126
+ pattern="$1"
127
+ xdotool search --onlyvisible --name "$pattern" 2>/dev/null | head -n 1 || true
128
+ }
129
+
130
+ active_window_id() {
131
+ xdotool getactivewindow 2>/dev/null || true
132
+ }
133
+
134
+ active_window_class() {
135
+ window_id="$(active_window_id)"
136
+ if [ -z "$window_id" ]; then
137
+ return 0
138
+ fi
139
+ if command -v xprop >/dev/null 2>&1; then
140
+ xprop -id "$window_id" WM_CLASS 2>/dev/null | awk -F'"' '/WM_CLASS/ { print $(NF - 1); exit }'
141
+ fi
142
+ }
143
+
144
+ active_window_class_lower() {
145
+ active_window_class | tr '[:upper:]' '[:lower:]'
146
+ }
147
+
148
+ active_window_is_terminal() {
149
+ window_class="$(active_window_class_lower)"
150
+ case "$window_class" in
151
+ *terminal*|xterm|uxterm|rxvt|urxvt|kitty|alacritty|wezterm|konsole)
152
+ return 0
153
+ ;;
154
+ *)
155
+ return 1
156
+ ;;
157
+ esac
158
+ }
159
+
160
+ paste_key_for_active_window() {
161
+ printf '%s\n' "${A0_DESKTOP_PASTE_KEY:-ctrl+v}"
162
+ }
163
+
164
+ window_geometry() {
165
+ window_id="$1"
166
+ if command -v xwininfo >/dev/null 2>&1; then
167
+ xwininfo -id "$window_id" 2>/dev/null | awk '
168
+ /Absolute upper-left X:/ { x=$4 }
169
+ /Absolute upper-left Y:/ { y=$4 }
170
+ /Width:/ { w=$2 }
171
+ /Height:/ { h=$2 }
172
+ END { if (w != "") printf "X=%s\nY=%s\nWIDTH=%s\nHEIGHT=%s\n", x, y, w, h }
173
+ '
174
+ else
175
+ xdotool getwindowgeometry --shell "$window_id"
176
+ fi
177
+ }
178
+
179
+ wait_window() {
180
+ pattern="$1"
181
+ timeout="${2:-15}"
182
+ end=$((SECONDS + timeout))
183
+ while [ "$SECONDS" -le "$end" ]; do
184
+ window_id="$(first_window "$pattern")"
185
+ if [ -n "$window_id" ]; then
186
+ printf '%s\n' "$window_id"
187
+ return 0
188
+ fi
189
+ sleep 0.25
190
+ done
191
+ echo "Timed out waiting for visible window: $pattern" >&2
192
+ return 1
193
+ }
194
+
195
+ scroll_desktop() {
196
+ direction="$1"
197
+ units="${2:-5}"
198
+ case "$direction" in
199
+ up) button=4 ;;
200
+ down) button=5 ;;
201
+ left) button=6 ;;
202
+ right) button=7 ;;
203
+ *)
204
+ echo "scroll direction must be up, down, left, or right." >&2
205
+ exit 2
206
+ ;;
207
+ esac
208
+ xdotool click --repeat "$units" "$button"
209
+ }
210
+
211
+ paste_text() {
212
+ text="$*"
213
+ if active_window_is_terminal; then
214
+ xdotool type --delay "${A0_DESKTOP_PASTE_TYPE_DELAY_MS:-${A0_DESKTOP_TYPE_DELAY_MS:-4}}" -- "$text"
215
+ return
216
+ fi
217
+ if command -v xclip >/dev/null 2>&1; then
218
+ printf '%s' "$text" | xclip -selection clipboard
219
+ xdotool key --clearmodifiers "$(paste_key_for_active_window)"
220
+ return
221
+ fi
222
+ xdotool type --delay "${A0_DESKTOP_TYPE_DELAY_MS:-1}" -- "$text"
223
+ }
224
+
225
+ run_sequence_line() {
226
+ line="$1"
227
+ [ -z "$line" ] && return 0
228
+ case "$line" in
229
+ \#*) return 0 ;;
230
+ esac
231
+ # shellcheck disable=SC2086
232
+ dispatch_command $line
233
+ }
234
+
235
+ run_sequence() {
236
+ source_file="$1"
237
+ if [ "$source_file" = "-" ]; then
238
+ while IFS= read -r line; do
239
+ run_sequence_line "$line"
240
+ done
241
+ return
242
+ fi
243
+ if [ ! -f "$source_file" ]; then
244
+ echo "sequence requires an existing FILE or - for stdin." >&2
245
+ exit 2
246
+ fi
247
+ while IFS= read -r line || [ -n "$line" ]; do
248
+ run_sequence_line "$line"
249
+ done < "$source_file"
250
+ }
251
+
252
+ launch_app() {
253
+ app="${1:-}"
254
+ soffice="${SOFFICE:-$(command -v soffice || true)}"
255
+ soffice="${soffice:-soffice}"
256
+ case "$app" in
257
+ writer)
258
+ run_detached "$soffice" --norestore --nofirststartwizard --nolockcheck "-env:UserInstallation=file://$HOME" --writer
259
+ ;;
260
+ calc|spreadsheet)
261
+ run_detached "$soffice" --norestore --nofirststartwizard --nolockcheck "-env:UserInstallation=file://$HOME" --calc
262
+ ;;
263
+ impress|presentation)
264
+ run_detached "$soffice" --norestore --nofirststartwizard --nolockcheck "-env:UserInstallation=file://$HOME" --impress
265
+ ;;
266
+ terminal)
267
+ run_detached xfce4-terminal --working-directory=/a0/usr/workdir
268
+ ;;
269
+ settings)
270
+ run_detached xfce4-settings-manager
271
+ ;;
272
+ workdir|files|file-manager)
273
+ run_detached thunar /a0/usr/workdir
274
+ ;;
275
+ *)
276
+ echo "Unknown app: ${app:-<empty>}" >&2
277
+ echo "Expected: writer, calc, impress, terminal, settings, or workdir." >&2
278
+ exit 2
279
+ ;;
280
+ esac
281
+ }
282
+
283
+ dispatch_command() {
284
+ local command_name="${1:-help}"
285
+ shift || true
286
+
287
+ case "$command_name" in
288
+ help|-h|--help)
289
+ usage
290
+ ;;
291
+ env)
292
+ printf 'export DISPLAY=%q\n' "$DISPLAY"
293
+ printf 'export XAUTHORITY=%q\n' "$XAUTHORITY"
294
+ printf 'export HOME=%q\n' "$HOME"
295
+ ;;
296
+ check)
297
+ ensure_display
298
+ xdotool getmouselocation --shell
299
+ ;;
300
+ state)
301
+ if [ "${1:-}" != "--json" ]; then
302
+ echo "state currently requires --json." >&2
303
+ exit 2
304
+ fi
305
+ shift
306
+ desktop_state state --json "$@"
307
+ ;;
308
+ observe)
309
+ if [ "${1:-}" != "--json" ]; then
310
+ echo "observe currently requires --json." >&2
311
+ exit 2
312
+ fi
313
+ shift
314
+ desktop_state observe --json "$@"
315
+ ;;
316
+ screenshot)
317
+ if [ "${1:-}" = "--json" ]; then
318
+ shift
319
+ desktop_state screenshot --json "$@"
320
+ elif [ "$#" -gt 0 ]; then
321
+ desktop_state screenshot "$@"
322
+ else
323
+ desktop_state screenshot
324
+ fi
325
+ ;;
326
+ active-window)
327
+ ensure_display
328
+ window_id="$(active_window_id)"
329
+ if [ -z "$window_id" ]; then
330
+ echo "No active window." >&2
331
+ exit 1
332
+ fi
333
+ xdotool getwindowname "$window_id"
334
+ ;;
335
+ geometry)
336
+ ensure_display
337
+ pattern="${1:?geometry requires a window name pattern}"
338
+ window_id="$(first_window "$pattern")"
339
+ if [ -z "$window_id" ]; then
340
+ echo "No visible window matched: $pattern" >&2
341
+ exit 1
342
+ fi
343
+ window_geometry "$window_id"
344
+ ;;
345
+ wait-window)
346
+ ensure_display
347
+ pattern="${1:?wait-window requires a window name pattern}"
348
+ wait_window "$pattern" "${2:-15}"
349
+ ;;
350
+ location)
351
+ ensure_display
352
+ xdotool getmouselocation --shell
353
+ ;;
354
+ windows)
355
+ ensure_display
356
+ pattern="${1:-.}"
357
+ xdotool search --onlyvisible --name "$pattern" getwindowname %@ 2>/dev/null || true
358
+ ;;
359
+ focus)
360
+ ensure_display
361
+ pattern="${1:?focus requires a window name pattern}"
362
+ window_id="$(first_window "$pattern")"
363
+ if [ -z "$window_id" ]; then
364
+ echo "No visible window matched: $pattern" >&2
365
+ exit 1
366
+ fi
367
+ xdotool windowactivate --sync "$window_id"
368
+ ;;
369
+ scroll)
370
+ ensure_display
371
+ scroll_desktop "${1:?scroll requires DIRECTION}" "${2:-5}"
372
+ ;;
373
+ drag)
374
+ ensure_display
375
+ x1="${1:?drag requires X1}"
376
+ y1="${2:?drag requires Y1}"
377
+ x2="${3:?drag requires X2}"
378
+ y2="${4:?drag requires Y2}"
379
+ xdotool mousemove --sync "$x1" "$y1" mousedown 1 mousemove --sync "$x2" "$y2" mouseup 1
380
+ ;;
381
+ right-click)
382
+ ensure_display
383
+ x="${1:?right-click requires X}"
384
+ y="${2:?right-click requires Y}"
385
+ xdotool mousemove --sync "$x" "$y" click 3
386
+ ;;
387
+ paste-text)
388
+ ensure_display
389
+ if [ "$#" -eq 0 ]; then
390
+ echo "paste-text requires TEXT." >&2
391
+ exit 2
392
+ fi
393
+ paste_text "$@"
394
+ ;;
395
+ sequence|batch)
396
+ source_file="${1:?sequence requires FILE or -}"
397
+ run_sequence "$source_file"
398
+ ;;
399
+ key)
400
+ ensure_display
401
+ if [ "$#" -eq 0 ]; then
402
+ echo "key requires at least one xdotool key name." >&2
403
+ exit 2
404
+ fi
405
+ xdotool key --clearmodifiers "$@"
406
+ ;;
407
+ type)
408
+ ensure_display
409
+ text="$*"
410
+ xdotool type --delay "${A0_DESKTOP_TYPE_DELAY_MS:-1}" -- "$text"
411
+ ;;
412
+ click)
413
+ ensure_display
414
+ x="${1:?click requires X}"
415
+ y="${2:?click requires Y}"
416
+ xdotool mousemove --sync "$x" "$y" click 1
417
+ ;;
418
+ dblclick)
419
+ ensure_display
420
+ x="${1:?dblclick requires X}"
421
+ y="${2:?dblclick requires Y}"
422
+ xdotool mousemove --sync "$x" "$y" click --repeat 2 --delay "${A0_DESKTOP_DBLCLICK_DELAY_MS:-150}" 1
423
+ ;;
424
+ launch)
425
+ ensure_display
426
+ launch_app "${1:-}"
427
+ ;;
428
+ open-path)
429
+ ensure_display
430
+ path="${1:-/a0/usr/workdir}"
431
+ run_detached thunar "$path"
432
+ ;;
433
+ calc-set-cell)
434
+ ensure_display
435
+ file="${1:?calc-set-cell requires FILE}"
436
+ sheet="${2:?calc-set-cell requires SHEET}"
437
+ cell="${3:?calc-set-cell requires CELL}"
438
+ shift 3
439
+ if [ "$#" -eq 0 ]; then
440
+ echo "calc-set-cell requires VALUE." >&2
441
+ exit 2
442
+ fi
443
+ close_blocking_dialogs
444
+ export PYTHONPATH="${PYTHONPATH:-/usr/lib/python3/dist-packages:/usr/lib/libreoffice/program:}"
445
+ python3 "$SCRIPT_DIR/calc_set_cell.py" "$file" "$sheet" "$cell" "$@"
446
+ ;;
447
+ save)
448
+ ensure_display
449
+ xdotool key --clearmodifiers ctrl+s
450
+ ;;
451
+ *)
452
+ echo "Unknown command: $command_name" >&2
453
+ usage >&2
454
+ exit 2
455
+ ;;
456
+ esac
457
+ }
458
+
459
+ dispatch_command "$command_name" "$@"
plugins/_desktop/webui/desktop-panel.html ADDED
@@ -0,0 +1,734 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <html>
2
+ <head>
3
+ <script type="module">
4
+ import { store } from "/plugins/_desktop/webui/desktop-store.js";
5
+ </script>
6
+ </head>
7
+ <body>
8
+ <div x-data>
9
+ <template x-if="$store.desktop">
10
+ <div class="office-panel" x-create="$store.desktop.onMount($el, xAttrs($el) || {})" x-destroy="$store.desktop.cleanup()">
11
+ <div class="office-shell">
12
+ <div class="office-document-header" x-show="$store.desktop.hasActiveFile()" style="display: none;">
13
+ <div class="office-document-title" :title="$store.desktop.tabLabel($store.desktop.session)">
14
+ <x-icon class="office-document-icon" aria-hidden="true" :name="$store.desktop.tabIcon($store.desktop.session)"></x-icon>
15
+ <span class="office-document-name" x-text="$store.desktop.tabTitle($store.desktop.session)"></span>
16
+ <span class="office-document-dirty" x-show="$store.desktop.dirty" aria-hidden="true">*</span>
17
+ </div>
18
+
19
+ <button
20
+ type="button"
21
+ class="office-icon-button office-document-save-button"
22
+ title="Save"
23
+ aria-label="Save"
24
+ :class="{ 'is-primary': $store.desktop.dirty }"
25
+ :disabled="$store.desktop.saving"
26
+ @click="$store.desktop.save()"
27
+ >
28
+ <x-icon :class="{ spinning: $store.desktop.saving }" :name="$store.desktop.saving ? 'progress_activity' : 'save'"></x-icon>
29
+ </button>
30
+
31
+ <div class="office-file-actions" x-data="{ open: false }" @click.outside="open = false" @keydown.escape.window="open = false">
32
+ <button
33
+ type="button"
34
+ class="office-icon-button office-file-menu-button"
35
+ title="File actions"
36
+ aria-label="File actions"
37
+ aria-haspopup="menu"
38
+ :aria-expanded="open.toString()"
39
+ :disabled="$store.desktop.saving"
40
+ @click.stop="open = !open"
41
+ >
42
+ <x-icon name="more_vert"></x-icon>
43
+ </button>
44
+ <div class="office-new-menu office-file-menu" role="menu" x-show="open" @click.stop>
45
+ <button type="button" class="office-new-menu-item" role="menuitem" :disabled="$store.desktop.saving" @click="open = false; $store.desktop.renameActiveFile()">
46
+ <x-icon aria-hidden="true" name="edit"></x-icon>
47
+ <span>Rename</span>
48
+ </button>
49
+ <button type="button" class="office-new-menu-item" role="menuitem" :disabled="$store.desktop.loading" @click="open = false; $store.desktop.closeActiveFile()">
50
+ <x-icon aria-hidden="true" name="close"></x-icon>
51
+ <span>Close File</span>
52
+ </button>
53
+ </div>
54
+ </div>
55
+ </div>
56
+
57
+ <div class="office-state-line" x-show="$store.desktop.message || $store.desktop.error || $store.desktop.loading" style="display: none;">
58
+ <x-icon :class="{ spinning: $store.desktop.loading }" :name="$store.desktop.loading ? 'progress_activity' : ($store.desktop.error ? 'error' : 'check_circle')"></x-icon>
59
+ <span x-text="$store.desktop.error || $store.desktop.message || 'Working'"></span>
60
+ </div>
61
+
62
+ <div class="office-body">
63
+ <div class="office-editor-wrap" x-show="$store.desktop.session" style="display: none;">
64
+ <div class="office-editor-scroll" :class="{ 'is-desktop': $store.desktop.hasOfficialOffice() }" @click.self="$store.desktop.focusEditor()">
65
+ <template x-if="$store.desktop.hasOfficialOffice()">
66
+ <div
67
+ class="office-desktop-wrap"
68
+ data-office-desktop-host
69
+ x-init="$nextTick(() => $store.desktop.mountDesktopFrameHost($el))"
70
+ >
71
+ </div>
72
+ </template>
73
+ </div>
74
+ </div>
75
+
76
+ <div class="office-desktop-empty" x-show="$store.desktop.shouldShowDesktopEmptyState()" style="display: none;">
77
+ <x-icon aria-hidden="true" name="power_settings_new"></x-icon>
78
+ <span class="office-desktop-empty-title">Desktop is shut down</span>
79
+ <button type="button" class="office-icon-button office-command-button" @click="$store.desktop.restartDesktopSession()">
80
+ <x-icon aria-hidden="true" name="restart_alt"></x-icon>
81
+ <span class="office-button-label">Restart Desktop</span>
82
+ </button>
83
+ </div>
84
+ </div>
85
+ </div>
86
+ </div>
87
+ </template>
88
+ </div>
89
+
90
+ <style>
91
+ .office-panel,
92
+ .office-shell {
93
+ display: flex;
94
+ flex: 1 1 auto;
95
+ flex-direction: column;
96
+ width: 100%;
97
+ height: 100%;
98
+ min-width: 0;
99
+ min-height: 0;
100
+ background: var(--color-background);
101
+ color: var(--color-text);
102
+ }
103
+
104
+ .office-panel {
105
+ container-type: inline-size;
106
+ }
107
+
108
+ .modal-inner.office-modal {
109
+ box-sizing: border-box;
110
+ width: min(1120px, calc(100vw - 32px));
111
+ height: min(820px, calc(100vh - 32px));
112
+ min-width: min(720px, calc(100vw - 16px));
113
+ min-height: min(520px, calc(100vh - 16px));
114
+ max-width: none;
115
+ max-height: none;
116
+ resize: none;
117
+ overflow: hidden;
118
+ will-change: width, height, left, top;
119
+ }
120
+
121
+ .modal-inner.office-modal.is-resizing,
122
+ .modal-inner.office-modal.is-dragging {
123
+ user-select: none;
124
+ }
125
+
126
+ .modal-inner.office-modal.is-focus-mode {
127
+ border-radius: 6px;
128
+ }
129
+
130
+ .modal-inner.office-modal .modal-scroll {
131
+ display: flex;
132
+ flex: 1 1 auto;
133
+ min-height: 0;
134
+ max-height: none;
135
+ overflow: hidden;
136
+ padding: 0;
137
+ }
138
+
139
+ .modal-inner.office-modal .modal-header {
140
+ grid-template-columns: minmax(0, 1fr) auto auto;
141
+ }
142
+
143
+ .office-modal-input-shield {
144
+ position: absolute;
145
+ inset: 42px 0 0 0;
146
+ z-index: 4;
147
+ display: none;
148
+ background: transparent;
149
+ }
150
+
151
+ .office-modal-resizer {
152
+ position: absolute;
153
+ z-index: 5;
154
+ display: block;
155
+ touch-action: none;
156
+ }
157
+
158
+ .office-modal-resizer.is-right {
159
+ top: 42px;
160
+ right: -4px;
161
+ bottom: 12px;
162
+ width: 10px;
163
+ cursor: ew-resize;
164
+ }
165
+
166
+ .office-modal-resizer.is-bottom {
167
+ right: 12px;
168
+ bottom: -4px;
169
+ left: 0;
170
+ height: 10px;
171
+ cursor: ns-resize;
172
+ }
173
+
174
+ .office-modal-resizer.is-corner {
175
+ right: 0;
176
+ bottom: 0;
177
+ width: 22px;
178
+ height: 22px;
179
+ cursor: nwse-resize;
180
+ }
181
+
182
+ .office-modal-resizer.is-corner::after {
183
+ content: "";
184
+ position: absolute;
185
+ right: 6px;
186
+ bottom: 6px;
187
+ width: 9px;
188
+ height: 9px;
189
+ border-right: 2px solid color-mix(in srgb, var(--color-text) 42%, transparent);
190
+ border-bottom: 2px solid color-mix(in srgb, var(--color-text) 42%, transparent);
191
+ border-radius: 1px;
192
+ }
193
+
194
+ .modal-inner.office-modal.is-focus-mode .office-modal-resizer {
195
+ display: none;
196
+ }
197
+
198
+ .modal-inner.office-modal .modal-bd.office-modal-body,
199
+ .modal-inner.office-modal .modal-bd.office-modal-body > x-component,
200
+ .modal-inner.office-modal .modal-bd.office-modal-body > x-component > div[x-data],
201
+ .modal-inner.office-modal .modal-bd.office-modal-body > x-component > .office-panel,
202
+ .modal-inner.office-modal .modal-bd.office-modal-body > x-component > div[x-data] > .office-panel {
203
+ display: flex;
204
+ flex: 1 1 auto;
205
+ min-height: 0;
206
+ min-width: 0;
207
+ width: 100%;
208
+ height: 100%;
209
+ padding: 0;
210
+ }
211
+
212
+ .office-toolbar {
213
+ display: flex;
214
+ flex-direction: row;
215
+ align-items: stretch;
216
+ flex-wrap: nowrap;
217
+ min-height: 0;
218
+ padding: 6px 10px;
219
+ overflow: hidden;
220
+ border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 20%);
221
+ background: color-mix(in srgb, var(--color-background), var(--color-panel) 48%);
222
+ }
223
+
224
+ .office-toolbar-row {
225
+ display: flex;
226
+ align-items: center;
227
+ flex: 0 0 auto;
228
+ flex-wrap: nowrap;
229
+ gap: 6px;
230
+ width: 100%;
231
+ min-width: 0;
232
+ min-height: 32px;
233
+ overflow-x: auto;
234
+ overflow-y: hidden;
235
+ scrollbar-width: thin;
236
+ }
237
+
238
+ .office-tool-group {
239
+ display: flex;
240
+ align-items: center;
241
+ flex-wrap: nowrap;
242
+ flex: 0 0 auto;
243
+ gap: 4px;
244
+ min-width: 0;
245
+ }
246
+
247
+ .office-editor-tools {
248
+ gap: 3px;
249
+ }
250
+
251
+ .office-tool-actions {
252
+ justify-content: flex-end;
253
+ }
254
+
255
+ .office-toolbar-spacer {
256
+ flex: 1 1 16px;
257
+ min-width: 8px;
258
+ }
259
+
260
+ .office-toolbar-divider {
261
+ flex: 0 0 auto;
262
+ width: 1px;
263
+ height: 22px;
264
+ margin-inline: 2px;
265
+ background: color-mix(in srgb, var(--color-border), transparent 18%);
266
+ }
267
+
268
+ .office-document-header {
269
+ display: flex;
270
+ align-items: center;
271
+ gap: 10px;
272
+ min-height: 38px;
273
+ padding: 5px 10px 5px 12px;
274
+ border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 22%);
275
+ background: color-mix(in srgb, var(--color-panel), var(--color-background) 30%);
276
+ }
277
+
278
+ .office-document-title {
279
+ display: flex;
280
+ align-items: center;
281
+ gap: 7px;
282
+ min-width: 0;
283
+ flex: 1 1 auto;
284
+ color: var(--color-text);
285
+ }
286
+
287
+ .office-document-icon {
288
+ flex: 0 0 auto;
289
+ font-size: 19px;
290
+ line-height: 1;
291
+ }
292
+
293
+ .office-document-name {
294
+ min-width: 0;
295
+ overflow: hidden;
296
+ text-overflow: ellipsis;
297
+ white-space: nowrap;
298
+ font-size: 13px;
299
+ font-weight: 750;
300
+ letter-spacing: 0;
301
+ line-height: 1.2;
302
+ }
303
+
304
+ .office-document-dirty {
305
+ flex: 0 0 auto;
306
+ color: #2ca58d;
307
+ font-size: 14px;
308
+ font-weight: 800;
309
+ line-height: 1;
310
+ }
311
+
312
+ .office-file-actions {
313
+ position: relative;
314
+ display: inline-flex;
315
+ align-items: center;
316
+ flex: 0 0 auto;
317
+ }
318
+
319
+ .office-document-save-button,
320
+ .office-file-menu-button {
321
+ width: 30px;
322
+ height: 30px;
323
+ min-width: 30px;
324
+ }
325
+
326
+ .office-header-actions {
327
+ position: relative;
328
+ display: inline-flex;
329
+ align-items: center;
330
+ flex: 0 0 auto;
331
+ }
332
+
333
+ .office-header-new-button {
334
+ appearance: none;
335
+ display: inline-flex;
336
+ align-items: center;
337
+ justify-content: center;
338
+ gap: 4px;
339
+ height: 34px;
340
+ min-height: 34px;
341
+ padding: 0 9px 0 8px;
342
+ border: 1px solid transparent;
343
+ border-radius: 7px;
344
+ background: transparent;
345
+ color: var(--color-text);
346
+ cursor: pointer;
347
+ font: inherit;
348
+ font-size: 12px;
349
+ font-weight: 750;
350
+ letter-spacing: 0;
351
+ line-height: 1;
352
+ opacity: 0.82;
353
+ white-space: nowrap;
354
+ transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
355
+ }
356
+
357
+ .office-header-new-button:hover,
358
+ .office-header-actions.is-open .office-header-new-button {
359
+ opacity: 1;
360
+ border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
361
+ background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
362
+ }
363
+
364
+ .office-header-new-button .material-symbols-outlined {
365
+ font-size: 18px;
366
+ line-height: 1;
367
+ }
368
+
369
+ .office-header-new-button .office-new-chevron {
370
+ margin-left: -2px;
371
+ font-size: 16px;
372
+ opacity: 0.8;
373
+ }
374
+
375
+ .office-new-menu {
376
+ position: absolute;
377
+ top: calc(100% + 6px);
378
+ right: 0;
379
+ z-index: 100;
380
+ min-width: 184px;
381
+ padding: 5px;
382
+ border: 1px solid color-mix(in srgb, var(--color-border), transparent 10%);
383
+ border-radius: 8px;
384
+ background: color-mix(in srgb, var(--color-panel), var(--color-background) 10%);
385
+ box-shadow: 0 14px 34px rgba(0, 0, 0, 0.34);
386
+ }
387
+
388
+ .right-canvas-desktop-actions .office-new-menu {
389
+ z-index: 4000;
390
+ }
391
+
392
+ .office-new-menu[hidden] {
393
+ display: none;
394
+ }
395
+
396
+ .office-new-menu-item {
397
+ appearance: none;
398
+ display: flex;
399
+ align-items: center;
400
+ gap: 8px;
401
+ width: 100%;
402
+ height: 32px;
403
+ padding: 0 8px;
404
+ border: 1px solid transparent;
405
+ border-radius: 6px;
406
+ background: transparent;
407
+ color: var(--color-text);
408
+ cursor: pointer;
409
+ font: inherit;
410
+ font-size: 12px;
411
+ font-weight: 650;
412
+ letter-spacing: 0;
413
+ line-height: 1;
414
+ text-align: left;
415
+ white-space: nowrap;
416
+ }
417
+
418
+ .office-new-menu-item:hover {
419
+ border-color: color-mix(in srgb, var(--color-primary) 22%, transparent);
420
+ background: color-mix(in srgb, var(--color-background-hover) 76%, transparent);
421
+ }
422
+
423
+ .office-new-menu-item:disabled {
424
+ cursor: default;
425
+ opacity: 0.46;
426
+ }
427
+
428
+ .office-new-menu-item .material-symbols-outlined {
429
+ flex: 0 0 auto;
430
+ width: 18px;
431
+ font-size: 18px;
432
+ line-height: 1;
433
+ text-align: center;
434
+ }
435
+
436
+ .office-icon-button,
437
+ .office-tab,
438
+ .office-tab-close {
439
+ border: 1px solid color-mix(in srgb, var(--color-border), transparent 12%);
440
+ border-radius: 8px;
441
+ background: color-mix(in srgb, var(--color-panel), var(--color-background) 16%);
442
+ color: inherit;
443
+ transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
444
+ }
445
+
446
+ .office-icon-button {
447
+ display: inline-grid;
448
+ place-items: center;
449
+ width: 32px;
450
+ height: 32px;
451
+ min-width: 32px;
452
+ padding: 0;
453
+ }
454
+
455
+ .office-command-button {
456
+ display: inline-flex;
457
+ align-items: center;
458
+ justify-content: center;
459
+ gap: 5px;
460
+ width: auto;
461
+ max-width: 126px;
462
+ padding: 0 8px;
463
+ white-space: nowrap;
464
+ }
465
+
466
+ .office-command-button .office-button-label {
467
+ min-width: 0;
468
+ overflow: hidden;
469
+ text-overflow: ellipsis;
470
+ font-size: 11px;
471
+ font-weight: 700;
472
+ line-height: 1;
473
+ }
474
+
475
+ .office-icon-button.is-primary {
476
+ border-color: color-mix(in srgb, #2c7be5, var(--color-border) 20%);
477
+ background: color-mix(in srgb, #2c7be5, var(--color-panel) 82%);
478
+ }
479
+
480
+ .office-icon-button.is-active {
481
+ border-color: color-mix(in srgb, #2ca58d, var(--color-border) 24%);
482
+ background: color-mix(in srgb, #2ca58d, var(--color-panel) 84%);
483
+ }
484
+
485
+ .office-icon-button:hover:not(:disabled),
486
+ .office-tab:hover,
487
+ .office-tab-close:hover {
488
+ border-color: color-mix(in srgb, #2c7be5, var(--color-border) 45%);
489
+ background: color-mix(in srgb, var(--color-panel), #2c7be5 8%);
490
+ }
491
+
492
+ .office-icon-button:disabled {
493
+ cursor: default;
494
+ opacity: 0.42;
495
+ }
496
+
497
+ .office-icon-button .material-symbols-outlined,
498
+ .office-tab-icon {
499
+ font-size: 19px;
500
+ line-height: 1;
501
+ }
502
+
503
+ .office-tabs {
504
+ display: flex;
505
+ gap: 6px;
506
+ min-height: 42px;
507
+ padding: 7px 10px;
508
+ overflow-x: auto;
509
+ border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 22%);
510
+ background: color-mix(in srgb, var(--color-panel), var(--color-background) 28%);
511
+ }
512
+
513
+ .office-tab-shell {
514
+ display: grid;
515
+ grid-template-columns: minmax(0, 1fr) 28px;
516
+ align-items: center;
517
+ min-width: 150px;
518
+ max-width: 240px;
519
+ }
520
+
521
+ .office-tab-shell.is-system {
522
+ grid-template-columns: minmax(0, 1fr);
523
+ min-width: 172px;
524
+ }
525
+
526
+ .office-tab,
527
+ .office-tab-close {
528
+ height: 28px;
529
+ min-height: 28px;
530
+ border-radius: 7px;
531
+ }
532
+
533
+ .office-tab {
534
+ display: flex;
535
+ align-items: center;
536
+ gap: 6px;
537
+ min-width: 0;
538
+ border-top-right-radius: 0;
539
+ border-bottom-right-radius: 0;
540
+ padding: 0 8px;
541
+ text-align: left;
542
+ }
543
+
544
+ .office-tab-shell.is-system .office-tab {
545
+ border-radius: 7px;
546
+ }
547
+
548
+ .office-tab-close {
549
+ display: grid;
550
+ place-items: center;
551
+ border-left: 0;
552
+ border-top-left-radius: 0;
553
+ border-bottom-left-radius: 0;
554
+ padding: 0;
555
+ }
556
+
557
+ .office-tab-close .material-symbols-outlined {
558
+ font-size: 17px;
559
+ }
560
+
561
+ .office-tab-shell.is-active .office-tab,
562
+ .office-tab-shell.is-active .office-tab-close {
563
+ border-color: color-mix(in srgb, #2c7be5, var(--color-border) 36%);
564
+ background: color-mix(in srgb, #2c7be5, var(--color-panel) 88%);
565
+ }
566
+
567
+ .office-tab-shell.is-dirty .office-tab-title::after {
568
+ content: " *";
569
+ color: #2ca58d;
570
+ }
571
+
572
+ .office-tab-title {
573
+ min-width: 0;
574
+ overflow: hidden;
575
+ text-overflow: ellipsis;
576
+ white-space: nowrap;
577
+ font-size: 12px;
578
+ line-height: 1;
579
+ }
580
+
581
+ .office-state-line {
582
+ display: flex;
583
+ align-items: center;
584
+ flex: 0 0 auto;
585
+ gap: 8px;
586
+ width: 100%;
587
+ min-width: 0;
588
+ min-height: 34px;
589
+ box-sizing: border-box;
590
+ padding: 6px 12px;
591
+ border-bottom: 1px solid color-mix(in srgb, var(--color-border), transparent 28%);
592
+ color: var(--color-text-secondary);
593
+ font-size: 12px;
594
+ }
595
+
596
+ .office-state-line > .material-symbols-outlined {
597
+ flex: 0 0 auto;
598
+ }
599
+
600
+ .office-state-line > span:not(.material-symbols-outlined) {
601
+ min-width: 0;
602
+ overflow: hidden;
603
+ text-overflow: ellipsis;
604
+ white-space: nowrap;
605
+ }
606
+
607
+ .office-body {
608
+ position: relative;
609
+ display: flex;
610
+ flex: 1 1 auto;
611
+ min-height: 0;
612
+ overflow: hidden;
613
+ background: var(--color-background);
614
+ }
615
+
616
+ .office-body.is-source {
617
+ background: transparent;
618
+ }
619
+
620
+ .office-editor-wrap {
621
+ display: flex;
622
+ flex: 1 1 auto;
623
+ flex-direction: column;
624
+ min-width: 0;
625
+ min-height: 0;
626
+ }
627
+
628
+ .office-editor-scroll {
629
+ flex: 1 1 auto;
630
+ min-height: 0;
631
+ overflow: auto;
632
+ padding: 30px 24px;
633
+ }
634
+
635
+ .office-editor-scroll.is-source {
636
+ display: flex;
637
+ overflow: hidden;
638
+ padding: var(--spacing-md);
639
+ background: transparent;
640
+ }
641
+
642
+ .office-editor-scroll.is-desktop {
643
+ display: flex;
644
+ overflow: hidden;
645
+ padding: 0;
646
+ background: #1f2329;
647
+ }
648
+
649
+ .office-desktop-wrap {
650
+ display: flex;
651
+ flex: 1 1 auto;
652
+ width: 100%;
653
+ height: 100%;
654
+ min-width: 0;
655
+ min-height: 0;
656
+ aspect-ratio: auto;
657
+ background: #1f2329;
658
+ }
659
+
660
+ .office-desktop-empty {
661
+ display: grid;
662
+ flex: 1 1 auto;
663
+ place-items: center;
664
+ align-content: center;
665
+ gap: 12px;
666
+ min-width: 0;
667
+ min-height: 0;
668
+ padding: 24px;
669
+ color: var(--color-text-secondary);
670
+ text-align: center;
671
+ }
672
+
673
+ .office-desktop-empty > .material-symbols-outlined {
674
+ font-size: 32px;
675
+ color: color-mix(in srgb, var(--color-text) 68%, transparent);
676
+ }
677
+
678
+ .office-desktop-empty-title {
679
+ font-size: 13px;
680
+ font-weight: 700;
681
+ }
682
+
683
+ .office-desktop-frame {
684
+ flex: 1 1 auto;
685
+ width: 100%;
686
+ height: 100%;
687
+ min-height: 0;
688
+ aspect-ratio: auto;
689
+ border: 0;
690
+ background: #20242a;
691
+ }
692
+
693
+ .office-panel .spinning {
694
+ animation: office-spin 0.8s linear infinite;
695
+ }
696
+
697
+ @keyframes office-spin {
698
+ to { transform: rotate(360deg); }
699
+ }
700
+
701
+ @container (max-width: 680px) {
702
+ .office-toolbar {
703
+ padding-inline: 8px;
704
+ }
705
+
706
+ .office-toolbar-row {
707
+ gap: 5px;
708
+ }
709
+
710
+ .office-command-button {
711
+ width: 32px;
712
+ max-width: 32px;
713
+ padding-inline: 0;
714
+ }
715
+
716
+ .office-command-button .office-button-label {
717
+ position: absolute;
718
+ width: 1px;
719
+ height: 1px;
720
+ overflow: hidden;
721
+ clip: rect(0 0 0 0);
722
+ white-space: nowrap;
723
+ }
724
+
725
+ }
726
+
727
+ /* While the canvas is being resized, the left-edge drag sweeps the cursor across this
728
+ iframe, which captures the pointermove and freezes shrinking (widening drags away, so
729
+ it works). Disable hit-testing on the frame during the drag — right-canvas-store.js
730
+ toggles `right-canvas-resizing` on <body> for the duration of the drag. */
731
+ body.right-canvas-resizing .office-desktop-frame { pointer-events: none; }
732
+ </style>
733
+ </body>
734
+ </html>
plugins/_desktop/webui/desktop-store.js ADDED
The diff for this file is too large to render. See raw diff
 
plugins/_desktop/webui/main.html ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <html
2
+ class="surface-modal office-modal modal-no-backdrop"
3
+ data-surface-id="desktop"
4
+ data-surface-modal-path="/plugins/_desktop/webui/main.html"
5
+ data-surface-dock-title="Open Desktop in canvas"
6
+ data-surface-dock-icon="dock_to_right"
7
+ data-canvas-surface="desktop"
8
+ data-canvas-modal-path="/plugins/_desktop/webui/main.html"
9
+ data-canvas-dock-title="Open Desktop in canvas"
10
+ data-canvas-dock-icon="dock_to_right"
11
+ >
12
+ <head>
13
+ <title>Desktop</title>
14
+ <script type="module">
15
+ import { store } from "/plugins/_desktop/webui/desktop-store.js";
16
+ </script>
17
+ </head>
18
+ <body class="office-modal-body">
19
+ <x-component path="/plugins/_desktop/webui/desktop-panel.html" mode="modal"></x-component>
20
+ </body>
21
+ </html>
plugins/_desktop/webui/thumbnail.jpg ADDED
plugins/_discovery/AGENTS.md ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Plugin Discovery DOX
2
+
3
+ ## Purpose
4
+
5
+ - Own contextual plugin discovery cards and welcome-screen promotions.
6
+
7
+ ## Ownership
8
+
9
+ - `plugin.yaml` owns metadata and always-enabled status.
10
+ - `extensions/` owns backend or WebUI discovery contributions.
11
+ - `webui/discovery-store.js` owns discovery UI state and actions.
12
+
13
+ ## Local Contracts
14
+
15
+ - Discovery cards must be accurate, dismissible where appropriate, and avoid advertising already-complete setup.
16
+ - CTA actions must match supported welcome-screen action contracts.
17
+ - Keep card IDs unique and plugin-prefixed.
18
+ - The Welcome screen `welcome-actions-end` surface renders feature channel cards plus the compact OAuth account-provider card; other hero discovery cards stay out of the lower welcome grid.
19
+ - Do not hide discovery cards solely because model setup is incomplete; model setup gating belongs to the chat thread.
20
+
21
+ ## Work Guidance
22
+
23
+ - Prefer backend status checks before surfacing setup or integration prompts.
24
+
25
+ ## Verification
26
+
27
+ - Smoke-test welcome-screen discovery cards, ordering, dismissal, and CTA behavior after changes.
28
+
29
+ ## Child DOX Index
30
+
31
+ No child DOX files.
plugins/_discovery/extensions/python/banners/10_discovery_cards.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from helpers.extension import Extension
2
+ from helpers import plugins
3
+
4
+
5
+ class DiscoveryCardsExtension(Extension):
6
+ """Injects discovery cards into the banners list."""
7
+
8
+ def _oauth_summary(self) -> dict:
9
+ try:
10
+ from plugins._oauth.helpers.providers import provider_registry
11
+ from plugins._oauth.helpers.route_bootstrap import is_installed
12
+ from plugins._oauth.helpers.summary import build_oauth_status_summary
13
+
14
+ return build_oauth_status_summary(
15
+ provider_registry=provider_registry,
16
+ routes_installed=is_installed,
17
+ )
18
+ except Exception:
19
+ return {}
20
+
21
+ def _oauth_usage_windows(self, summary: dict) -> list[dict]:
22
+ windows: list[dict] = []
23
+ accounts = summary.get("oauth_accounts") if isinstance(summary, dict) else {}
24
+ connected = accounts.get("connected", []) if isinstance(accounts, dict) else []
25
+ for account in connected:
26
+ if not isinstance(account, dict):
27
+ continue
28
+ provider_name = account.get("short_name") or account.get("display_name") or "Account"
29
+ for window in account.get("usage_windows") or []:
30
+ if not isinstance(window, dict):
31
+ continue
32
+ windows.append({
33
+ **window,
34
+ "key": f'{account.get("provider_id", "oauth")}-{window.get("key", "usage")}',
35
+ "title": f'{provider_name} {window.get("title") or "Usage"}',
36
+ })
37
+ return windows[:4]
38
+
39
+ def _oauth_account_chips(self, summary: dict) -> list[dict]:
40
+ accounts = summary.get("oauth_accounts") if isinstance(summary, dict) else {}
41
+ if not isinstance(accounts, dict):
42
+ return []
43
+ connected = accounts.get("connected") or []
44
+ available = accounts.get("available") or []
45
+ source = connected if connected else available
46
+ chips = []
47
+ for account in source[:4]:
48
+ if not isinstance(account, dict):
49
+ continue
50
+ chips.append({
51
+ "provider_id": account.get("provider_id") or "",
52
+ "label": account.get("short_name") or account.get("display_name") or account.get("provider_id"),
53
+ "detail": account.get("account_label") if account.get("connected") else "Available",
54
+ "connected": bool(account.get("connected")),
55
+ })
56
+ return chips
57
+
58
+ async def execute(self, banners: list = [], frontend_context: dict = {}, **kwargs):
59
+ # Optional logic: only show specific cards if plugins aren't already configured.
60
+ # Telegram, Email, Whatsapp are built-in, so we only need to check if they've been configured.
61
+
62
+ telegram_config = plugins.get_plugin_config("_telegram_integration") or {}
63
+ email_config = plugins.get_plugin_config("_email_integration") or {}
64
+ whatsapp_config = plugins.get_plugin_config("_whatsapp_integration") or {}
65
+ oauth_summary = self._oauth_summary()
66
+ oauth_accounts = oauth_summary.get("oauth_accounts") if isinstance(oauth_summary, dict) else {}
67
+ connected_count = int(oauth_accounts.get("connected_count") or 0) if isinstance(oauth_accounts, dict) else 0
68
+ total_count = int(oauth_accounts.get("total_count") or 0) if isinstance(oauth_accounts, dict) else 0
69
+
70
+ # 1. Plugin Hub Hero
71
+ banners.append({
72
+ "id": "discovery-plugin-hub",
73
+ "type": "hero",
74
+ "title": "Discover the Plugin Hub",
75
+ "description": "Extend Agent Zero with integrations, tools, and automations from the community.",
76
+ "thumbnail": "/plugins/_discovery/webui/assets/hero-plugin-hub.png",
77
+ "icon": "extension",
78
+ "cta_text": "Explore Plugins",
79
+ "cta_action": "open-plugin-hub",
80
+ "dismissible": True,
81
+ "priority": 100,
82
+ "show_in_onboarding": True
83
+ })
84
+
85
+ # 2. Telegram
86
+ if not telegram_config.get("bot_token"):
87
+ banners.append({
88
+ "id": "discovery-telegram",
89
+ "type": "feature",
90
+ "title": "Telegram",
91
+ "description": "Chat on Telegram wherever you are.",
92
+ "thumbnail": "/plugins/_discovery/webui/assets/thumb-telegram.png",
93
+ "icon": "send",
94
+ "cta_text": "Connect",
95
+ "cta_action": "open-plugin-config:_telegram_integration",
96
+ "dismissible": True,
97
+ "priority": 50,
98
+ "show_in_onboarding": True
99
+ })
100
+
101
+ # 3. Email
102
+ email_handlers = email_config.get("handlers") or []
103
+ email_is_configured = any((handler or {}).get("username") for handler in email_handlers)
104
+ if not email_is_configured:
105
+ banners.append({
106
+ "id": "discovery-email",
107
+ "type": "feature",
108
+ "title": "Email",
109
+ "description": "Let Agent Zero read and send emails on your behalf.",
110
+ "thumbnail": "/plugins/_discovery/webui/assets/thumb-email.png",
111
+ "icon": "mail",
112
+ "cta_text": "Connect",
113
+ "cta_action": "open-plugin-config:_email_integration",
114
+ "dismissible": True,
115
+ "priority": 50,
116
+ "show_in_onboarding": True
117
+ })
118
+
119
+ # 4. WhatsApp
120
+ if not whatsapp_config.get("phone_number_id"):
121
+ banners.append({
122
+ "id": "discovery-whatsapp",
123
+ "type": "feature",
124
+ "title": "WhatsApp",
125
+ "description": "Send and receive WhatsApp messages through A0.",
126
+ "thumbnail": "/plugins/_discovery/webui/assets/thumb-whatsapp.png",
127
+ "icon": "chat",
128
+ "cta_text": "Connect",
129
+ "cta_action": "open-plugin-config:_whatsapp_integration",
130
+ "dismissible": True,
131
+ "priority": 50,
132
+ "show_in_onboarding": True
133
+ })
134
+
135
+ # 5. OAuth account providers
136
+ oauth_card = {
137
+ "id": "discovery-oauth-accounts",
138
+ "type": "hero",
139
+ "placement": "after-features",
140
+ "title": "Your AI accounts",
141
+ "description": "Use your subscription-backed logins for model access."
142
+ if not connected_count
143
+ else "",
144
+ "cta_text": "Connect accounts" if not connected_count else "Manage accounts",
145
+ "cta_action": "open-plugin-config:_oauth",
146
+ "dismissible": True,
147
+ "priority": 40,
148
+ "show_in_onboarding": True,
149
+ "account_chips": self._oauth_account_chips(oauth_summary),
150
+ }
151
+ usage_windows = self._oauth_usage_windows(oauth_summary)
152
+ if usage_windows:
153
+ oauth_card["usage_windows"] = usage_windows
154
+ banners.append(oauth_card)
plugins/_discovery/extensions/webui/initFw_end/auto-modal.js ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { callJsonApi } from "/js/api.js";
2
+ import { isModalOpen } from "/js/modals.js";
3
+
4
+ const DEFAULT_SURFACE = "welcome";
5
+ const STARTUP_DELAY_MS = 650;
6
+ const SUPPRESSION_PREFIX = "discovery_auto_modal_closed";
7
+
8
+ let initialized = false;
9
+ let lastOpened = null;
10
+ let checking = false;
11
+
12
+ function cleanPath(path = "") {
13
+ return String(path || "").replace(/^\/+/, "");
14
+ }
15
+
16
+ function currentContextId(fallback = "") {
17
+ try {
18
+ return fallback || globalThis.getContext?.() || sessionStorage.getItem("lastSelectedChat") || "welcome";
19
+ } catch {
20
+ return fallback || "welcome";
21
+ }
22
+ }
23
+
24
+ function bannerSupportsSurface(banner, surface) {
25
+ const surfaces = banner?.auto_modal_surfaces;
26
+ return !Array.isArray(surfaces) || surfaces.length === 0 || surfaces.includes(surface);
27
+ }
28
+
29
+ function suppressionKey({ surface, ctxid, reason, path }) {
30
+ return `${SUPPRESSION_PREFIX}:${surface}:${ctxid || "none"}:${reason || "unknown"}:${cleanPath(path)}`;
31
+ }
32
+
33
+ function isSuppressed(item) {
34
+ try {
35
+ return sessionStorage.getItem(suppressionKey(item)) === "1";
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ function suppress(item) {
42
+ try {
43
+ sessionStorage.setItem(suppressionKey(item), "1");
44
+ } catch {
45
+ // no-op
46
+ }
47
+ }
48
+
49
+ function modalAlreadyOpen(path) {
50
+ return isModalOpen(path);
51
+ }
52
+
53
+ async function fetchAutoModalBanner(surface, ctxid) {
54
+ const response = await callJsonApi("/banners", {
55
+ banners: [],
56
+ context: {
57
+ is_welcome: surface === "welcome",
58
+ is_onboarding: document.body.dataset.mode === "onboarding",
59
+ surface,
60
+ ctxid,
61
+ },
62
+ });
63
+
64
+ const banners = Array.isArray(response?.banners) ? response.banners : [];
65
+ return banners
66
+ .filter((banner) => banner?.auto_modal_path)
67
+ .filter((banner) => bannerSupportsSurface(banner, surface))
68
+ .sort((left, right) => (Number(right.auto_modal_priority || 0) - Number(left.auto_modal_priority || 0)))[0] || null;
69
+ }
70
+
71
+ async function maybeOpenAutoModal(surface = DEFAULT_SURFACE, detail = {}) {
72
+ if (checking) return;
73
+ checking = true;
74
+ const ctxid = currentContextId(detail.ctxid || "");
75
+
76
+ try {
77
+ const banner = await fetchAutoModalBanner(surface, ctxid);
78
+ if (!banner?.auto_modal_path) return;
79
+
80
+ const item = {
81
+ surface,
82
+ ctxid,
83
+ path: banner.auto_modal_path,
84
+ reason: banner.auto_modal_reason || banner.id || "auto-modal",
85
+ };
86
+
87
+ if (isSuppressed(item) || modalAlreadyOpen(item.path)) return;
88
+
89
+ const opener = globalThis.ensureModalOpen || globalThis.openModal;
90
+ if (!opener) return;
91
+
92
+ lastOpened = item;
93
+ await opener(item.path);
94
+ } catch (error) {
95
+ console.error("Discovery auto-modal check failed:", error);
96
+ } finally {
97
+ checking = false;
98
+ }
99
+ }
100
+
101
+ function handleModalClosed(event) {
102
+ const closedPath = event?.detail?.modalPath || "";
103
+ if (lastOpened && cleanPath(closedPath) === cleanPath(lastOpened.path)) {
104
+ suppress(lastOpened);
105
+ const surface = lastOpened.surface;
106
+ const ctxid = lastOpened.ctxid;
107
+ lastOpened = null;
108
+ window.setTimeout(() => {
109
+ void maybeOpenAutoModal(surface, { ctxid });
110
+ }, 250);
111
+ return;
112
+ }
113
+
114
+ window.setTimeout(() => {
115
+ void maybeOpenAutoModal(DEFAULT_SURFACE);
116
+ }, 250);
117
+ }
118
+
119
+ function handleChatCreated(event) {
120
+ const ctxid = event?.detail?.ctxid || "";
121
+ window.setTimeout(() => {
122
+ void maybeOpenAutoModal("chat-created", { ctxid });
123
+ }, 300);
124
+ }
125
+
126
+ export default function initDiscoveryAutoModal() {
127
+ if (initialized) return;
128
+ initialized = true;
129
+
130
+ document.addEventListener("modal-closed", handleModalClosed);
131
+ document.addEventListener("chat-created", handleChatCreated);
132
+
133
+ window.setTimeout(() => {
134
+ void maybeOpenAutoModal(DEFAULT_SURFACE);
135
+ }, STARTUP_DELAY_MS);
136
+ }
plugins/_discovery/extensions/webui/onboarding-success-end/discovery-cards.html ADDED
@@ -0,0 +1,597 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script type="module">
2
+ import { store } from "/plugins/_discovery/webui/discovery-store.js";
3
+ </script>
4
+
5
+ <div x-data>
6
+ <template x-if="$store.discoveryStore">
7
+ <div class="discovery-slot"
8
+ @modal-closed.window="$store.discoveryStore.refreshCards()"
9
+ x-create="$store.discoveryStore.refreshCards()"
10
+ x-show="$store.discoveryStore.cards.length > 0 || $store.discoveryStore.hasDismissedCards">
11
+
12
+ <div class="discovery-section">
13
+
14
+ <div class="discovery-features" x-show="$store.discoveryStore.featureCards.length > 0" style="text-align: left;">
15
+ <template x-for="card in $store.discoveryStore.featureCards" :key="card.id">
16
+ <article class="discovery-feature-card"
17
+ role="button"
18
+ tabindex="0"
19
+ @click="$store.discoveryStore.executeCta(card.cta_action)"
20
+ @keydown.enter.prevent="$store.discoveryStore.executeCta(card.cta_action)"
21
+ @keydown.space.prevent="$store.discoveryStore.executeCta(card.cta_action)">
22
+
23
+ <div class="discovery-feature-head">
24
+ <div class="discovery-feature-thumb">
25
+ <template x-if="card.thumbnail">
26
+ <img :src="card.thumbnail" :alt="card.title" loading="lazy">
27
+ </template>
28
+ <template x-if="!card.thumbnail && card.icon">
29
+ <x-icon class="discovery-feature-icon" :name="card.icon"></x-icon>
30
+ </template>
31
+ </div>
32
+ <h4 class="discovery-feature-title" x-text="card.title"></h4>
33
+ </div>
34
+
35
+ <button class="btn btn primary"
36
+ type="button"
37
+ @click.stop="$store.discoveryStore.executeCta(card.cta_action)">
38
+ <span x-text="card.cta_text"></span>
39
+ <x-icon name="arrow_forward"></x-icon>
40
+ </button>
41
+ </article>
42
+ </template>
43
+ </div>
44
+
45
+
46
+ <template x-for="card in $store.discoveryStore.heroCards.filter(card => !['discovery-codex-oauth', 'discovery-oauth-accounts'].includes(card.id))" :key="card.id">
47
+ <article class="discovery-hero"
48
+ role="button"
49
+ tabindex="0"
50
+ @click="$store.discoveryStore.executeCta(card.cta_action)"
51
+ @keydown.enter.prevent="$store.discoveryStore.executeCta(card.cta_action)"
52
+ @keydown.space.prevent="$store.discoveryStore.executeCta(card.cta_action)">
53
+ <button class="discovery-dismiss"
54
+ type="button"
55
+ x-show="card.dismissible"
56
+ @click.stop="$store.discoveryStore.dismissCard(card.id)"
57
+ :aria-label="`Dismiss ${card.title}`"
58
+ title="Dismiss">
59
+ <x-icon name="close"></x-icon>
60
+ </button>
61
+
62
+ <div class="discovery-hero-content">
63
+ <h3 class="discovery-hero-title" x-text="card.title"></h3>
64
+ <p class="discovery-hero-desc" x-text="card.description"></p>
65
+ <button class="btn btn-ok"
66
+ type="button"
67
+ @click.stop="$store.discoveryStore.executeCta(card.cta_action)">
68
+ <span x-text="card.cta_text"></span>
69
+ <x-icon name="arrow_forward"></x-icon>
70
+ </button>
71
+ </div>
72
+
73
+ <div class="discovery-hero-thumb">
74
+ <template x-if="card.thumbnail">
75
+ <img :src="card.thumbnail" :alt="card.title" loading="lazy">
76
+ </template>
77
+ <template x-if="!card.thumbnail && card.icon">
78
+ <x-icon class="discovery-hero-icon" :name="card.icon"></x-icon>
79
+ </template>
80
+ </div>
81
+ </article>
82
+ </template>
83
+
84
+ <article class="discovery-cli-card" aria-labelledby="discovery-cli-title">
85
+ <div class="discovery-cli-content">
86
+ <span class="discovery-cli-kicker">CLI Connector</span>
87
+ <h3 class="discovery-cli-title" id="discovery-cli-title">Connect Agent Zero to your desktop</h3>
88
+ <p class="discovery-cli-desc">
89
+ Install the CLI Connector when you want A0 to use your host terminal,
90
+ local files, and a browser from your computer.
91
+ </p>
92
+ <div class="discovery-cli-commands" aria-label="CLI Connector install commands">
93
+ <div class="discovery-cli-command">
94
+ <span class="discovery-cli-label">macOS / Linux</span>
95
+ <code>curl -LsSf https://cli.agent-zero.ai/install.sh | sh</code>
96
+ </div>
97
+ <div class="discovery-cli-command">
98
+ <span class="discovery-cli-label">Windows PowerShell</span>
99
+ <code>irm https://cli.agent-zero.ai/install.ps1 | iex</code>
100
+ </div>
101
+ </div>
102
+ </div>
103
+
104
+ <figure class="discovery-cli-preview" aria-label="Agent Zero CLI Connector preview">
105
+ <img src="/plugins/_discovery/webui/assets/cli-connector-preview.png"
106
+ alt="Agent Zero CLI Connector terminal preview"
107
+ loading="lazy">
108
+ </figure>
109
+ </article>
110
+
111
+ <div class="discovery-undismiss" x-show="$store.discoveryStore.hasDismissedCards">
112
+ <button type="button"
113
+ class="discovery-undismiss-btn"
114
+ @click="$store.discoveryStore.undismissCards()">
115
+ <x-icon name="undo"></x-icon>
116
+ <span>Show dismissed suggestions</span>
117
+ </button>
118
+ </div>
119
+ </div>
120
+ </div>
121
+ </template>
122
+
123
+ <style>
124
+ .discovery-slot {
125
+ grid-column: 1 / -1;
126
+ width: 100%;
127
+ }
128
+
129
+ .discovery-section {
130
+ display: flex;
131
+ flex-direction: column;
132
+ gap: 0.75rem;
133
+ width: 100%;
134
+ margin-top: 0.85rem;
135
+ }
136
+
137
+ .discovery-hero,
138
+ .discovery-feature-card,
139
+ .discovery-cli-card {
140
+ position: relative;
141
+ border: 1px solid var(--color-border);
142
+ border-radius: 10px;
143
+ background: var(--color-panel);
144
+ text-align: left;
145
+ transition:
146
+ border-color 0.2s ease,
147
+ background-color 0.2s ease,
148
+ box-shadow 0.2s ease,
149
+ transform 0.2s ease;
150
+ }
151
+
152
+ .discovery-hero:hover,
153
+ .discovery-feature-card:hover,
154
+ .discovery-cli-card:hover,
155
+ .discovery-hero:focus-visible,
156
+ .discovery-feature-card:focus-visible,
157
+ .discovery-cli-card:focus-visible {
158
+ border-color: color-mix(in srgb, var(--color-primary) 72%, white 6%);
159
+ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.16);
160
+ outline: none;
161
+ }
162
+
163
+ .discovery-hero {
164
+ display: flex;
165
+ align-items: center;
166
+ gap: 1rem;
167
+ min-height: 126px;
168
+ overflow: hidden;
169
+ padding: 1rem 1.15rem 1rem 1.25rem;
170
+ background:
171
+ linear-gradient(
172
+ 135deg,
173
+ color-mix(in srgb, var(--color-primary) 10%, transparent),
174
+ transparent 58%
175
+ ),
176
+ radial-gradient(
177
+ circle at 92% 24%,
178
+ color-mix(in srgb, var(--color-primary) 12%, transparent),
179
+ transparent 42%
180
+ ),
181
+ var(--color-panel);
182
+ }
183
+
184
+ .discovery-hero-content,
185
+ .discovery-hero-thumb {
186
+ position: relative;
187
+ z-index: 1;
188
+ }
189
+
190
+ .discovery-hero-content {
191
+ flex: 1 1 auto;
192
+ min-width: 0;
193
+ }
194
+
195
+ .discovery-hero-title {
196
+ margin: 0 0 0.35rem;
197
+ color: var(--color-text);
198
+ font-size: 1.02rem;
199
+ font-weight: 600;
200
+ letter-spacing: 0;
201
+ }
202
+
203
+ .discovery-hero-desc {
204
+ margin: 0 0 0.75rem;
205
+ color: var(--color-text);
206
+ font-size: 0.86rem;
207
+ line-height: 1.42;
208
+ max-width: 38ch;
209
+ }
210
+
211
+ .discovery-cta-link,
212
+ .discovery-undismiss-btn {
213
+ display: inline-flex;
214
+ align-items: center;
215
+ justify-content: center;
216
+ gap: 0.35rem;
217
+ cursor: pointer;
218
+ transition:
219
+ background-color 0.2s ease,
220
+ border-color 0.2s ease,
221
+ color 0.2s ease,
222
+ transform 0.2s ease;
223
+ }
224
+
225
+ .discovery-hero-thumb {
226
+ flex: 0 0 112px;
227
+ width: 112px;
228
+ height: 86px;
229
+ display: flex;
230
+ align-items: center;
231
+ justify-content: center;
232
+ }
233
+
234
+ .discovery-hero-thumb img {
235
+ width: 100%;
236
+ height: 100%;
237
+ object-fit: contain;
238
+ filter: drop-shadow(0 12px 18px rgba(0, 0, 0, 0.12));
239
+ }
240
+
241
+ .discovery-hero-icon {
242
+ font-size: 3rem;
243
+ color: var(--color-highlight-dark);
244
+ }
245
+
246
+ .discovery-features {
247
+ display: grid;
248
+ grid-template-columns: repeat(3, minmax(0, 1fr));
249
+ gap: 0.75rem;
250
+ }
251
+
252
+ .discovery-feature-card {
253
+ display: grid;
254
+ grid-template-columns: minmax(0, 1fr) auto;
255
+ gap: 0.75rem;
256
+ align-items: center;
257
+ padding: 0.72rem 0.9rem;
258
+ background:
259
+ linear-gradient(
260
+ 180deg,
261
+ color-mix(in srgb, var(--color-primary) 4%, transparent),
262
+ transparent 38%
263
+ ),
264
+ var(--color-panel);
265
+ }
266
+
267
+ .discovery-feature-head {
268
+ display: flex;
269
+ flex-direction: row;
270
+ align-items: center;
271
+ gap: 0.62rem;
272
+ width: 100%;
273
+ min-width: 0;
274
+ }
275
+
276
+ .discovery-feature-thumb {
277
+ flex: 0 0 auto;
278
+ width: 32px;
279
+ height: 32px;
280
+ display: flex;
281
+ align-items: center;
282
+ justify-content: center;
283
+ border-radius: 12px;
284
+ overflow: hidden;
285
+ background: color-mix(in srgb, var(--color-primary) 10%, transparent);
286
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 10%, transparent);
287
+ }
288
+
289
+ .discovery-feature-thumb img {
290
+ width: 100%;
291
+ height: 100%;
292
+ object-fit: cover;
293
+ }
294
+
295
+ .discovery-feature-icon {
296
+ font-size: 1.45rem;
297
+ color: var(--color-highlight-dark);
298
+ }
299
+
300
+ .discovery-feature-title {
301
+ margin: 0;
302
+ flex: 1 1 auto;
303
+ min-width: 0;
304
+ color: var(--color-text);
305
+ font-size: 0.88rem;
306
+ font-weight: 600;
307
+ line-height: 1.25;
308
+ }
309
+
310
+ .discovery-feature-card > .btn {
311
+ min-height: 36px;
312
+ padding: 0.44rem 0.78rem;
313
+ }
314
+
315
+ .discovery-feature-card > .btn .material-symbols-outlined {
316
+ font-size: 1.15rem;
317
+ }
318
+
319
+ .discovery-cli-card {
320
+ display: flex;
321
+ align-items: center;
322
+ gap: 1.1rem;
323
+ min-height: 134px;
324
+ overflow: hidden;
325
+ padding: 1rem 1.15rem 1rem 1.25rem;
326
+ background:
327
+ linear-gradient(
328
+ 135deg,
329
+ color-mix(in srgb, var(--color-primary) 12%, transparent),
330
+ transparent 52%
331
+ ),
332
+ radial-gradient(
333
+ circle at 86% 18%,
334
+ color-mix(in srgb, #00b7ff 14%, transparent),
335
+ transparent 34%
336
+ ),
337
+ var(--color-panel);
338
+ }
339
+
340
+ .discovery-cli-content {
341
+ flex: 1 1 auto;
342
+ min-width: 0;
343
+ }
344
+
345
+ .discovery-cli-kicker {
346
+ display: inline-flex;
347
+ width: max-content;
348
+ margin-bottom: 0.3rem;
349
+ border: 1px solid color-mix(in srgb, var(--color-primary) 34%, var(--color-border));
350
+ border-radius: 999px;
351
+ padding: 0.16rem 0.48rem;
352
+ color: color-mix(in srgb, var(--color-primary) 72%, white 20%);
353
+ font-size: 0.68rem;
354
+ font-weight: 700;
355
+ line-height: 1;
356
+ text-transform: uppercase;
357
+ }
358
+
359
+ .discovery-cli-title {
360
+ margin: 0 0 0.28rem;
361
+ color: var(--color-text);
362
+ font-size: 1.02rem;
363
+ font-weight: 700;
364
+ line-height: 1.2;
365
+ }
366
+
367
+ .discovery-cli-desc {
368
+ max-width: 62ch;
369
+ margin: 0 0 0.65rem;
370
+ color: var(--color-secondary);
371
+ font-size: 0.8rem;
372
+ line-height: 1.38;
373
+ }
374
+
375
+ .discovery-cli-commands {
376
+ display: grid;
377
+ grid-template-columns: repeat(2, minmax(0, 1fr));
378
+ gap: 0.55rem;
379
+ max-width: 790px;
380
+ }
381
+
382
+ .discovery-cli-command {
383
+ min-width: 0;
384
+ border: 1px solid color-mix(in srgb, var(--color-border) 82%, transparent);
385
+ border-radius: 8px;
386
+ background: color-mix(in srgb, #05080c 86%, var(--color-panel));
387
+ padding: 0.48rem 0.55rem;
388
+ }
389
+
390
+ .discovery-cli-label {
391
+ display: block;
392
+ margin-bottom: 0.22rem;
393
+ color: var(--color-secondary);
394
+ font-size: 0.68rem;
395
+ font-weight: 700;
396
+ line-height: 1.1;
397
+ }
398
+
399
+ .discovery-cli-command code {
400
+ display: block;
401
+ overflow-x: auto;
402
+ color: #72d8ff;
403
+ font-family: var(--font-family-monospace, ui-monospace, SFMono-Regular, Consolas, monospace);
404
+ font-size: 0.72rem;
405
+ line-height: 1.35;
406
+ white-space: nowrap;
407
+ }
408
+
409
+ .discovery-cli-preview {
410
+ flex: 0 0 218px;
411
+ width: 218px;
412
+ margin: 0;
413
+ border: 1px solid color-mix(in srgb, #00b7ff 24%, var(--color-border));
414
+ border-radius: 8px;
415
+ background: #070b10;
416
+ box-shadow:
417
+ 0 12px 28px rgba(0, 0, 0, 0.2),
418
+ 0 0 28px color-mix(in srgb, var(--color-primary) 12%, transparent);
419
+ overflow: hidden;
420
+ }
421
+
422
+ .discovery-cli-preview img {
423
+ display: block;
424
+ width: 100%;
425
+ height: auto;
426
+ }
427
+
428
+ .discovery-cta-link {
429
+ margin-top: 0.95rem;
430
+ border: 1px solid color-mix(in srgb, var(--color-primary) 34%, var(--color-border));
431
+ border-radius: 8px;
432
+ background: transparent;
433
+ color: var(--color-primary);
434
+ padding: 0.42rem 0.72rem;
435
+ font-size: 0.79rem;
436
+ font-weight: 600;
437
+ }
438
+
439
+ .discovery-cta-link:hover,
440
+ .discovery-cta-link:focus-visible {
441
+ background: var(--color-primary);
442
+ border-color: var(--color-primary);
443
+ color: #fff;
444
+ outline: none;
445
+ }
446
+
447
+ .discovery-dismiss {
448
+ position: absolute;
449
+ top: 0.65rem;
450
+ right: 0.65rem;
451
+ display: flex;
452
+ align-items: center;
453
+ justify-content: center;
454
+ width: 28px;
455
+ height: 28px;
456
+ border: none;
457
+ border-radius: 8px;
458
+ background: transparent;
459
+ color: var(--color-secondary);
460
+ cursor: pointer;
461
+ opacity: 0;
462
+ transition:
463
+ opacity 0.2s ease,
464
+ background-color 0.2s ease,
465
+ color 0.2s ease;
466
+ z-index: 2;
467
+ }
468
+
469
+ .discovery-dismiss-small {
470
+ top: 0.55rem;
471
+ right: 0.55rem;
472
+ width: 26px;
473
+ height: 26px;
474
+ }
475
+
476
+ .discovery-hero:hover .discovery-dismiss,
477
+ .discovery-hero:focus-visible .discovery-dismiss,
478
+ .discovery-feature-card:hover .discovery-dismiss,
479
+ .discovery-feature-card:focus-visible .discovery-dismiss {
480
+ opacity: 0.68;
481
+ }
482
+
483
+ .discovery-dismiss:hover,
484
+ .discovery-dismiss:focus-visible {
485
+ opacity: 1;
486
+ color: var(--color-text);
487
+ background: color-mix(in srgb, var(--color-border) 84%, transparent);
488
+ outline: none;
489
+ }
490
+
491
+ .discovery-dismiss .material-symbols-outlined {
492
+ font-size: 1rem;
493
+ }
494
+
495
+ .discovery-undismiss {
496
+ display: flex;
497
+ justify-content: flex-start;
498
+ }
499
+
500
+ .discovery-undismiss-btn {
501
+ border: none;
502
+ background: transparent;
503
+ padding: 0;
504
+ color: var(--color-secondary);
505
+ font-size: 0.8rem;
506
+ font-weight: 500;
507
+ }
508
+
509
+ .discovery-undismiss-btn:hover,
510
+ .discovery-undismiss-btn:focus-visible {
511
+ color: var(--color-primary);
512
+ outline: none;
513
+ }
514
+
515
+ .discovery-cta-link .material-symbols-outlined,
516
+ .discovery-undismiss-btn .material-symbols-outlined {
517
+ font-size: 1rem;
518
+ }
519
+
520
+ @media (hover: none) {
521
+ .discovery-dismiss {
522
+ opacity: 0.68;
523
+ }
524
+ }
525
+
526
+ @media (max-width: 768px) {
527
+ .discovery-section {
528
+ gap: 0.85rem;
529
+ }
530
+
531
+ .discovery-hero {
532
+ min-height: 0;
533
+ padding: 1rem;
534
+ }
535
+
536
+ .discovery-hero-thumb {
537
+ flex-basis: 92px;
538
+ width: 92px;
539
+ height: 76px;
540
+ }
541
+
542
+ .discovery-features {
543
+ grid-template-columns: 1fr;
544
+ gap: 0.75rem;
545
+ }
546
+
547
+ .discovery-cta-link {
548
+ margin-top: 0;
549
+ }
550
+
551
+ .discovery-cli-card {
552
+ flex-direction: column;
553
+ align-items: flex-start;
554
+ gap: 0.75rem;
555
+ min-height: 0;
556
+ padding: 0.95rem;
557
+ }
558
+
559
+ .discovery-cli-commands {
560
+ grid-template-columns: 1fr;
561
+ width: 100%;
562
+ max-width: none;
563
+ }
564
+
565
+ .discovery-cli-preview {
566
+ flex: 0 0 auto;
567
+ width: min(100%, 280px);
568
+ }
569
+ }
570
+
571
+ @media (max-width: 520px) {
572
+ .discovery-slot {
573
+ margin-top: 0.15rem;
574
+ }
575
+
576
+ .discovery-hero {
577
+ padding-right: 0.95rem;
578
+ }
579
+
580
+ .discovery-hero-thumb {
581
+ display: none;
582
+ }
583
+
584
+ .discovery-hero-title {
585
+ font-size: 1rem;
586
+ }
587
+
588
+ .discovery-cli-title {
589
+ font-size: 0.98rem;
590
+ }
591
+
592
+ .discovery-cli-preview {
593
+ width: 100%;
594
+ }
595
+ }
596
+ </style>
597
+ </div>
plugins/_discovery/extensions/webui/welcome-actions-end/discovery-cards.html ADDED
@@ -0,0 +1,557 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <script type="module">
2
+ import { store } from "/plugins/_discovery/webui/discovery-store.js";
3
+ </script>
4
+
5
+ <div x-data>
6
+ <template x-if="$store.discoveryStore">
7
+ <div class="discovery-slot"
8
+ @modal-closed.window="$store.discoveryStore.refreshCards()"
9
+ x-create="$store.discoveryStore.refreshCards()"
10
+ x-show="$store.discoveryStore.featureCards.length > 0 || $store.discoveryStore.oauthAccountCards.length > 0 || $store.discoveryStore.hasDismissedCards">
11
+
12
+ <section class="discovery-features-panel" x-show="$store.discoveryStore.featureCards.length > 0">
13
+ <header class="discovery-panel-header">
14
+ <div class="discovery-panel-copy">
15
+ <h2>Connect Channels</h2>
16
+ <p>Extend Agent Zero across your favorite platforms.</p>
17
+ </div>
18
+ <button class="discovery-dismiss discovery-panel-dismiss"
19
+ type="button"
20
+ x-show="$store.discoveryStore.featureCards.some(card => card.dismissible !== false)"
21
+ @click.stop="$store.discoveryStore.dismissFeatureCards()"
22
+ aria-label="Dismiss Connect Channels"
23
+ title="Dismiss">
24
+ <x-icon name="close"></x-icon>
25
+ </button>
26
+ </header>
27
+
28
+ <div class="discovery-features">
29
+ <template x-for="card in $store.discoveryStore.featureCards" :key="card.id">
30
+ <button class="discovery-feature-card"
31
+ type="button"
32
+ @click="$store.discoveryStore.executeCta(card.cta_action)">
33
+ <span class="discovery-feature-thumb">
34
+ <template x-if="card.thumbnail">
35
+ <img :src="card.thumbnail" :alt="card.title" loading="lazy">
36
+ </template>
37
+ <template x-if="!card.thumbnail && card.icon">
38
+ <x-icon class="discovery-feature-icon" :name="card.icon"></x-icon>
39
+ </template>
40
+ </span>
41
+ <span class="discovery-feature-title" x-text="`${card.cta_text || 'Connect'} ${card.title}`"></span>
42
+ </button>
43
+ </template>
44
+ </div>
45
+ </section>
46
+
47
+ <template x-for="card in $store.discoveryStore.oauthAccountCards" :key="card.id">
48
+ <article class="discovery-account-card">
49
+ <button class="discovery-dismiss"
50
+ type="button"
51
+ x-show="card.dismissible"
52
+ @click.stop="$store.discoveryStore.dismissCard(card.id)"
53
+ :aria-label="`Dismiss ${card.title}`"
54
+ title="Dismiss">
55
+ <x-icon name="close"></x-icon>
56
+ </button>
57
+
58
+ <header class="discovery-account-header">
59
+ <div class="discovery-account-copy">
60
+ <h2 x-text="card.title"></h2>
61
+ <p x-show="card.description" x-text="card.description"></p>
62
+ </div>
63
+ <button class="btn btn primary discovery-account-cta"
64
+ type="button"
65
+ @click="$store.discoveryStore.executeCta(card.cta_action)">
66
+ <span x-text="card.cta_text"></span>
67
+ <x-icon name="arrow_forward"></x-icon>
68
+ </button>
69
+ </header>
70
+
71
+ <div class="discovery-account-chips" x-show="(card.account_chips || []).length > 0">
72
+ <template x-for="chip in card.account_chips || []" :key="chip.provider_id || chip.label">
73
+ <span class="discovery-account-chip" :class="{ 'is-connected': chip.connected }">
74
+ <span class="discovery-account-chip-dot" aria-hidden="true"></span>
75
+ <span class="discovery-account-chip-label" x-text="chip.label"></span>
76
+ <span class="discovery-account-chip-detail" x-show="chip.detail" x-text="chip.detail"></span>
77
+ </span>
78
+ </template>
79
+ </div>
80
+
81
+ <div class="discovery-account-usage" x-show="(card.usage_windows || []).length > 0">
82
+ <template x-for="window in card.usage_windows || []" :key="window.key || window.title">
83
+ <div class="discovery-account-usage-row">
84
+ <div class="discovery-account-usage-label">
85
+ <span x-text="window.title"></span>
86
+ <small x-show="$store.discoveryStore.formatReset(window)"
87
+ x-text="`Resets in ${$store.discoveryStore.formatReset(window)}`"></small>
88
+ </div>
89
+ <div class="discovery-account-usage-meter">
90
+ <span class="discovery-account-usage-track">
91
+ <span class="discovery-account-usage-fill"
92
+ :style="`width: ${$store.discoveryStore.usageWidth(window)}`"></span>
93
+ </span>
94
+ <span class="discovery-account-usage-value"
95
+ x-text="$store.discoveryStore.formatRemainingPercent(window)"></span>
96
+ </div>
97
+ </div>
98
+ </template>
99
+ </div>
100
+
101
+ </article>
102
+ </template>
103
+
104
+ <div class="discovery-undismiss" x-show="$store.discoveryStore.hasDismissedCards">
105
+ <button type="button"
106
+ class="discovery-undismiss-btn"
107
+ @click="$store.discoveryStore.undismissCards()">
108
+ <x-icon name="undo"></x-icon>
109
+ <span>Show dismissed suggestions</span>
110
+ </button>
111
+ </div>
112
+ </div>
113
+ </template>
114
+
115
+ <style>
116
+ .discovery-slot {
117
+ display: grid;
118
+ gap: 1rem;
119
+ min-width: 0;
120
+ width: 100%;
121
+ }
122
+
123
+ .discovery-features-panel,
124
+ .discovery-account-card {
125
+ border: 1px solid color-mix(in srgb, var(--color-border) 66%, transparent);
126
+ border-radius: 8px;
127
+ background: color-mix(in srgb, var(--color-panel) 78%, var(--color-background) 22%);
128
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.026);
129
+ backdrop-filter: blur(18px);
130
+ text-align: left;
131
+ }
132
+
133
+ .discovery-features-panel {
134
+ min-height: 100%;
135
+ padding: 1.25rem 1.45rem;
136
+ }
137
+
138
+ .discovery-panel-header {
139
+ display: grid;
140
+ grid-template-columns: minmax(0, 1fr) auto;
141
+ gap: 0.7rem;
142
+ align-items: start;
143
+ margin-bottom: 1.28rem;
144
+ }
145
+
146
+ .discovery-panel-copy {
147
+ display: grid;
148
+ gap: 0.42rem;
149
+ min-width: 0;
150
+ }
151
+
152
+ .discovery-panel-header h2 {
153
+ margin: 0;
154
+ color: var(--color-text);
155
+ font-size: 1.05rem;
156
+ font-weight: 520;
157
+ line-height: 1.2;
158
+ }
159
+
160
+ .discovery-panel-header p {
161
+ margin: 0;
162
+ color: color-mix(in srgb, var(--color-text) 58%, transparent);
163
+ font-size: 0.92rem;
164
+ line-height: 1.35;
165
+ }
166
+
167
+ .discovery-features {
168
+ display: grid;
169
+ grid-template-columns: repeat(3, minmax(0, 1fr));
170
+ gap: 0.65rem;
171
+ }
172
+
173
+ .discovery-feature-card {
174
+ display: inline-flex;
175
+ align-items: center;
176
+ gap: 0.5rem;
177
+ min-width: 0;
178
+ min-height: 3.1rem;
179
+ border: 1px solid color-mix(in srgb, var(--color-border) 52%, transparent);
180
+ border-radius: 8px;
181
+ padding: 0.56rem 0.58rem;
182
+ background: color-mix(in srgb, var(--color-background) 50%, transparent);
183
+ color: inherit;
184
+ cursor: pointer;
185
+ font-family: var(--font-family-main, "Rubik", Arial, Helvetica, sans-serif);
186
+ text-align: left;
187
+ transition:
188
+ border-color 0.18s ease,
189
+ background-color 0.18s ease;
190
+ }
191
+
192
+ .discovery-feature-thumb {
193
+ flex: 0 0 auto;
194
+ width: 32px;
195
+ height: 32px;
196
+ display: flex;
197
+ align-items: center;
198
+ justify-content: center;
199
+ border-radius: 999px;
200
+ overflow: hidden;
201
+ background: color-mix(in srgb, var(--color-primary) 10%, transparent);
202
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-primary) 10%, transparent);
203
+ }
204
+
205
+ .discovery-feature-thumb img {
206
+ width: 100%;
207
+ height: 100%;
208
+ object-fit: cover;
209
+ }
210
+
211
+ .discovery-feature-icon {
212
+ font-size: 1.45rem;
213
+ color: var(--color-highlight-dark);
214
+ }
215
+
216
+ .discovery-feature-title {
217
+ margin: 0;
218
+ flex: 1 1 auto;
219
+ min-width: 0;
220
+ color: var(--color-text);
221
+ font-size: 0.84rem;
222
+ font-weight: 520;
223
+ line-height: 1.25;
224
+ }
225
+
226
+ .discovery-feature-card:hover,
227
+ .discovery-feature-card:focus-visible {
228
+ border-color: color-mix(in srgb, var(--color-border) 88%, var(--color-text) 12%);
229
+ background: color-mix(in srgb, var(--color-panel) 86%, var(--color-background) 14%);
230
+ outline: none;
231
+ }
232
+
233
+ .discovery-dismiss {
234
+ position: absolute;
235
+ top: 0.65rem;
236
+ right: 0.65rem;
237
+ display: flex;
238
+ align-items: center;
239
+ justify-content: center;
240
+ width: 28px;
241
+ height: 28px;
242
+ border: none;
243
+ border-radius: 8px;
244
+ background: transparent;
245
+ color: var(--color-secondary);
246
+ cursor: pointer;
247
+ opacity: 0;
248
+ transition:
249
+ opacity 0.2s ease,
250
+ background-color 0.2s ease,
251
+ color 0.2s ease;
252
+ z-index: 2;
253
+ }
254
+
255
+ .discovery-dismiss-small {
256
+ top: 0.55rem;
257
+ right: 0.55rem;
258
+ width: 26px;
259
+ height: 26px;
260
+ }
261
+
262
+ .discovery-panel-dismiss {
263
+ position: static;
264
+ opacity: 0.68;
265
+ }
266
+
267
+ .discovery-account-card > .discovery-dismiss {
268
+ opacity: 0.68;
269
+ }
270
+
271
+ .discovery-dismiss:hover,
272
+ .discovery-dismiss:focus-visible {
273
+ opacity: 1;
274
+ color: var(--color-text);
275
+ background: color-mix(in srgb, var(--color-border) 84%, transparent);
276
+ outline: none;
277
+ }
278
+
279
+ .discovery-dismiss .material-symbols-outlined {
280
+ font-size: 1rem;
281
+ }
282
+
283
+ .discovery-account-card {
284
+ position: relative;
285
+ display: grid;
286
+ gap: 1rem;
287
+ padding: 1.25rem 1.45rem 1.25rem;
288
+ }
289
+
290
+ .discovery-account-header {
291
+ display: grid;
292
+ grid-template-columns: minmax(0, 1fr) auto;
293
+ gap: 0.95rem;
294
+ align-items: center;
295
+ min-width: 0;
296
+ padding-right: 2.1rem;
297
+ }
298
+
299
+ .discovery-account-copy {
300
+ display: grid;
301
+ gap: 0.3rem;
302
+ min-width: 0;
303
+ }
304
+
305
+ .discovery-account-copy h2 {
306
+ margin: 0;
307
+ color: var(--color-text);
308
+ font-size: 1.05rem;
309
+ font-weight: 520;
310
+ line-height: 1.2;
311
+ }
312
+
313
+ .discovery-account-copy p {
314
+ margin: 0;
315
+ color: color-mix(in srgb, var(--color-text) 58%, transparent);
316
+ font-size: 0.9rem;
317
+ line-height: 1.42;
318
+ }
319
+
320
+ .discovery-account-chips {
321
+ display: flex;
322
+ flex-wrap: wrap;
323
+ gap: 0.5rem;
324
+ min-width: 0;
325
+ }
326
+
327
+ .discovery-account-chip {
328
+ display: inline-grid;
329
+ grid-template-columns: auto auto minmax(0, auto);
330
+ align-items: center;
331
+ gap: 0.4rem;
332
+ max-width: 100%;
333
+ min-height: 2rem;
334
+ padding: 0.32rem 0.58rem;
335
+ border: 1px solid color-mix(in srgb, var(--color-border) 52%, transparent);
336
+ border-radius: 8px;
337
+ background: color-mix(in srgb, var(--color-background) 50%, transparent);
338
+ color: color-mix(in srgb, var(--color-text) 74%, transparent);
339
+ font-size: 0.8rem;
340
+ line-height: 1.2;
341
+ }
342
+
343
+ .discovery-account-chip-dot {
344
+ width: 0.44rem;
345
+ height: 0.44rem;
346
+ border-radius: 999px;
347
+ background: color-mix(in srgb, var(--color-text) 34%, transparent);
348
+ }
349
+
350
+ .discovery-account-chip.is-connected .discovery-account-chip-dot {
351
+ background: #22c55e;
352
+ }
353
+
354
+ .discovery-account-chip-label,
355
+ .discovery-account-chip-detail {
356
+ min-width: 0;
357
+ overflow: hidden;
358
+ text-overflow: ellipsis;
359
+ white-space: nowrap;
360
+ }
361
+
362
+ .discovery-account-chip-label {
363
+ color: var(--color-text);
364
+ font-weight: 500;
365
+ }
366
+
367
+ .discovery-account-chip-detail {
368
+ color: color-mix(in srgb, var(--color-text) 52%, transparent);
369
+ }
370
+
371
+ .discovery-account-usage {
372
+ display: grid;
373
+ gap: 0.74rem;
374
+ padding-top: 0.12rem;
375
+ }
376
+
377
+ .discovery-account-usage-row {
378
+ display: grid;
379
+ grid-template-columns: minmax(0, 1fr) minmax(8rem, 0.82fr);
380
+ gap: 1rem;
381
+ align-items: center;
382
+ min-width: 0;
383
+ }
384
+
385
+ .discovery-account-usage-label {
386
+ display: grid;
387
+ gap: 0.22rem;
388
+ min-width: 0;
389
+ }
390
+
391
+ .discovery-account-usage-label span,
392
+ .discovery-account-usage-label small {
393
+ min-width: 0;
394
+ overflow: hidden;
395
+ text-overflow: ellipsis;
396
+ white-space: nowrap;
397
+ }
398
+
399
+ .discovery-account-usage-label span {
400
+ color: color-mix(in srgb, var(--color-text) 74%, transparent);
401
+ font-size: 0.84rem;
402
+ line-height: 1.25;
403
+ }
404
+
405
+ .discovery-account-usage-label small {
406
+ color: color-mix(in srgb, var(--color-text) 44%, transparent);
407
+ font-size: 0.72rem;
408
+ line-height: 1.2;
409
+ }
410
+
411
+ .discovery-account-usage-meter {
412
+ display: grid;
413
+ grid-template-columns: minmax(0, 1fr) auto;
414
+ gap: 0.58rem;
415
+ align-items: center;
416
+ min-width: 0;
417
+ }
418
+
419
+ .discovery-account-usage-track {
420
+ height: 0.52rem;
421
+ overflow: hidden;
422
+ border-radius: 999px;
423
+ background: color-mix(in srgb, var(--color-border) 70%, transparent);
424
+ }
425
+
426
+ .discovery-account-usage-fill {
427
+ display: block;
428
+ height: 100%;
429
+ border-radius: inherit;
430
+ background: #22c55e;
431
+ transition: width 0.22s ease;
432
+ }
433
+
434
+ .discovery-account-usage-value {
435
+ color: color-mix(in srgb, var(--color-text) 70%, transparent);
436
+ font-family: var(--font-family-code, "Roboto Mono", monospace);
437
+ font-size: 0.74rem;
438
+ line-height: 1;
439
+ white-space: nowrap;
440
+ }
441
+
442
+ .discovery-account-cta {
443
+ justify-self: end;
444
+ display: inline-flex;
445
+ align-items: center;
446
+ justify-content: center;
447
+ gap: 0.36rem;
448
+ min-height: 2.35rem;
449
+ min-width: 8.65rem;
450
+ border: 0;
451
+ border-radius: 8px;
452
+ padding: 0.55rem 0.9rem;
453
+ background-color: #4248f1;
454
+ color: #fff;
455
+ font-family: var(--font-family-main, "Rubik", Arial, Helvetica, sans-serif);
456
+ font-size: 0.88rem;
457
+ font-weight: 460;
458
+ white-space: nowrap;
459
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2);
460
+ }
461
+
462
+ .discovery-account-cta:hover,
463
+ .discovery-account-cta:focus-visible {
464
+ background-color: #353bc5;
465
+ filter: none;
466
+ outline: none;
467
+ }
468
+
469
+ .discovery-account-cta .material-symbols-outlined {
470
+ font-size: 1.05rem;
471
+ }
472
+
473
+ .discovery-undismiss {
474
+ display: flex;
475
+ justify-content: flex-start;
476
+ }
477
+
478
+ .discovery-undismiss-btn {
479
+ display: inline-flex;
480
+ align-items: center;
481
+ justify-content: center;
482
+ gap: 0.35rem;
483
+ border: none;
484
+ background: transparent;
485
+ padding: 0;
486
+ color: var(--color-secondary);
487
+ cursor: pointer;
488
+ font-size: 0.8rem;
489
+ font-weight: 500;
490
+ transition: color 0.2s ease;
491
+ }
492
+
493
+ .discovery-undismiss-btn:hover,
494
+ .discovery-undismiss-btn:focus-visible {
495
+ color: var(--color-primary);
496
+ outline: none;
497
+ }
498
+
499
+ .discovery-undismiss-btn .material-symbols-outlined {
500
+ font-size: 1rem;
501
+ }
502
+
503
+ @media (hover: none) {
504
+ .discovery-dismiss {
505
+ opacity: 0.68;
506
+ }
507
+ }
508
+
509
+ @media (max-width: 768px) {
510
+ .discovery-features {
511
+ grid-template-columns: 1fr;
512
+ gap: 0.75rem;
513
+ }
514
+
515
+ .discovery-feature-card {
516
+ display: inline-flex;
517
+ min-height: 0;
518
+ padding: 0.62rem 0.7rem;
519
+ }
520
+
521
+ .discovery-feature-thumb {
522
+ width: 32px;
523
+ height: 32px;
524
+ }
525
+
526
+ .discovery-account-usage-row {
527
+ grid-template-columns: 1fr;
528
+ gap: 0.45rem;
529
+ }
530
+ }
531
+
532
+ @media (max-width: 520px) {
533
+ .discovery-slot {
534
+ margin-top: 0.15rem;
535
+ }
536
+
537
+ .discovery-feature-card {
538
+ padding-right: 0.7rem;
539
+ }
540
+
541
+ .discovery-account-card {
542
+ padding: 1.05rem;
543
+ }
544
+
545
+ .discovery-account-header {
546
+ grid-template-columns: 1fr;
547
+ padding-right: 2.2rem;
548
+ }
549
+
550
+ .discovery-account-cta {
551
+ grid-column: 1 / -1;
552
+ justify-self: start;
553
+ }
554
+
555
+ }
556
+ </style>
557
+ </div>
plugins/_discovery/plugin.yaml ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ name: _discovery
2
+ title: Plugin Discovery
3
+ description: Contextual discovery cards on the welcome screen promoting plugins and integrations.
4
+ version: 1.0.0
5
+ settings_sections: []
6
+ always_enabled: true
7
+ per_project_config: false
8
+ per_agent_config: false
plugins/_discovery/webui/assets/cli-connector-preview.png ADDED
plugins/_discovery/webui/assets/hero-openai-oauth.png ADDED
plugins/_discovery/webui/assets/hero-plugin-hub.png ADDED
plugins/_discovery/webui/assets/thumb-email.png ADDED
plugins/_discovery/webui/assets/thumb-telegram.png ADDED
plugins/_discovery/webui/assets/thumb-whatsapp.png ADDED
plugins/_discovery/webui/discovery-store.js ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { createStore } from "/js/AlpineStore.js";
2
+ import { toastFrontendError } from "/components/notifications/notification-store.js";
3
+ import { store as pluginListStore } from "/components/plugins/list/pluginListStore.js";
4
+ import * as API from "/js/api.js";
5
+
6
+ const STORAGE_KEY = "dismissed_discovery_cards";
7
+
8
+ const model = {
9
+ // --- State ---
10
+ /** @type {any[]} */
11
+ cards: [],
12
+ hasDismissedCards: false,
13
+ _initialized: false,
14
+ _isLoading: false,
15
+
16
+ // --- Lifecycle ---
17
+ init() {
18
+ if (this._initialized) return;
19
+ this._initialized = true;
20
+ },
21
+
22
+ // --- Actions ---
23
+
24
+ async refreshCards() {
25
+ if (this._isLoading) return;
26
+ this._isLoading = true;
27
+
28
+ try {
29
+ const response = await API.callJsonApi("/banners", {
30
+ banners: [],
31
+ context: {
32
+ is_onboarding: document.body.dataset.mode === "onboarding"
33
+ },
34
+ });
35
+
36
+ const banners = response?.banners || [];
37
+ const dismissed = this._getDismissedIds();
38
+
39
+ // Filter out standard banners, keep only hero and feature
40
+ // Also respect the onboarding filtering
41
+ const is_onboarding = document.body.dataset.mode === "onboarding";
42
+
43
+ this.cards = banners
44
+ .filter((card) => card.type === "hero" || card.type === "feature")
45
+ .filter((card) => !is_onboarding || card.show_in_onboarding === true)
46
+ .filter((card) => !dismissed.has(card.id))
47
+ .sort((left, right) => (right.priority || 0) - (left.priority || 0));
48
+
49
+ this.hasDismissedCards = dismissed.size > 0;
50
+ } catch (error) {
51
+ console.error("Failed to fetch discovery cards:", error);
52
+ } finally {
53
+ this._isLoading = false;
54
+ }
55
+ },
56
+
57
+ dismissCard(cardId) {
58
+ const dismissed = this._getDismissedIds();
59
+ dismissed.add(cardId);
60
+ this._persistDismissedIds(dismissed);
61
+
62
+ // Optimistically update UI
63
+ this.cards = this.cards.filter(c => c.id !== cardId);
64
+ this.hasDismissedCards = true;
65
+ },
66
+
67
+ dismissFeatureCards() {
68
+ const featureIds = this.featureCards
69
+ .filter((card) => card.dismissible !== false)
70
+ .map((card) => card.id)
71
+ .filter(Boolean);
72
+
73
+ if (!featureIds.length) return;
74
+
75
+ const dismissed = this._getDismissedIds();
76
+ const dismissSet = new Set(featureIds);
77
+ for (const id of dismissSet) {
78
+ dismissed.add(id);
79
+ }
80
+ this._persistDismissedIds(dismissed);
81
+
82
+ this.cards = this.cards.filter((card) => !dismissSet.has(card.id));
83
+ this.hasDismissedCards = true;
84
+ },
85
+
86
+ undismissCards() {
87
+ localStorage.removeItem(STORAGE_KEY);
88
+ this.refreshCards();
89
+ },
90
+
91
+ async executeCta(action) {
92
+ if (!action) return;
93
+
94
+ try {
95
+ if (action === "open-plugin-hub") {
96
+ await pluginListStore.open("pluginHub");
97
+ return;
98
+ }
99
+
100
+ if (action.startsWith("open-plugin-config:")) {
101
+ const pluginName = action.split(":")[1];
102
+ await pluginListStore.openPluginConfig(pluginName);
103
+ return;
104
+ }
105
+
106
+ if (action.startsWith("open-url:")) {
107
+ const url = action.slice("open-url:".length);
108
+ if (url) {
109
+ window.open(url, "_blank", "noopener,noreferrer");
110
+ }
111
+ return;
112
+ }
113
+ } catch (error) {
114
+ console.error("Discovery action failed:", error);
115
+ const message = error instanceof Error ? error.message : String(error);
116
+ await toastFrontendError(message, "Discovery");
117
+ }
118
+ },
119
+
120
+ usageWidth(window) {
121
+ const value = Math.max(0, Math.min(100, this.remainingPercent(window)));
122
+ return `${value}%`;
123
+ },
124
+
125
+ remainingPercent(window) {
126
+ const remaining = Number(window?.remaining_percent);
127
+ if (Number.isFinite(remaining)) return remaining;
128
+ const used = Number(window?.used_percent);
129
+ if (Number.isFinite(used)) return 100 - used;
130
+ return Number.NaN;
131
+ },
132
+
133
+ formatRemainingPercent(window) {
134
+ const number = this.remainingPercent(window);
135
+ if (!Number.isFinite(number)) return "0%";
136
+ return `${Math.round(number * 10) / 10}% left`;
137
+ },
138
+
139
+ formatReset(window) {
140
+ const seconds = Number(window?.reset_at || 0);
141
+ if (!Number.isFinite(seconds) || seconds <= 0) return "";
142
+ const remainingMs = Math.max(0, seconds * 1000 - Date.now());
143
+ const minutes = Math.round(remainingMs / 60000);
144
+ if (minutes < 60) return `${minutes}m`;
145
+ const hours = Math.round(minutes / 60);
146
+ if (hours < 48) return `${hours}h`;
147
+ return `${Math.round(hours / 24)}d`;
148
+ },
149
+
150
+ // --- Helpers (Private-ish) ---
151
+
152
+ _getDismissedIds() {
153
+ try {
154
+ const raw = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
155
+ if (!Array.isArray(raw)) return new Set();
156
+ return new Set(raw);
157
+ } catch {
158
+ return new Set();
159
+ }
160
+ },
161
+
162
+ _persistDismissedIds(ids) {
163
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(Array.from(ids)));
164
+ },
165
+
166
+ // --- Computed ---
167
+
168
+ get topHeroCards() {
169
+ return this.cards.filter((card) => card.type === "hero" && card.placement !== "after-features");
170
+ },
171
+
172
+ get bottomHeroCards() {
173
+ return this.cards.filter((card) => card.type === "hero" && card.placement === "after-features");
174
+ },
175
+
176
+ get oauthAccountCards() {
177
+ return this.bottomHeroCards.filter((card) => card.id === "discovery-oauth-accounts");
178
+ },
179
+
180
+ get heroCards() {
181
+ return [...this.topHeroCards, ...this.bottomHeroCards];
182
+ },
183
+
184
+ get featureCards() {
185
+ return this.cards.filter((card) => card.type === "feature");
186
+ },
187
+ };
188
+
189
+ const store = createStore("discoveryStore", model);
190
+ export { store };
plugins/_discovery/webui/thumbnail.jpg ADDED
plugins/_document_query/AGENTS.md ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Document Query Plugin DOX
2
+
3
+ ## Purpose
4
+
5
+ - Own document loading, parsing, indexing, and Q&A over local and remote documents.
6
+
7
+ ## Ownership
8
+
9
+ - `tools/document_query.py` owns the agent-facing query tool.
10
+ - `helpers/` owns fetching, parser routing, indexing, and query orchestration.
11
+ - `prompts/` owns document-query tool and optimization prompts.
12
+ - `hooks.py`, `default_config.yaml`, `plugin.yaml`, `skills/`, `extensions/`, and `webui/` own dependency bootstrap, settings, metadata, skill guidance, hooks, and UI.
13
+
14
+ ## Local Contracts
15
+
16
+ - Respect configured size, timeout, retry, parser concurrency, and OCR limits.
17
+ - Fetch local and remote resources safely and avoid leaking document contents beyond intended prompt/tool use.
18
+ - Keep optional dependency installation targeted to the framework runtime.
19
+
20
+ ## Work Guidance
21
+
22
+ - Coordinate parser changes with timeout handling and fallback behavior.
23
+ - Keep prompt and skill guidance clear that image files, screenshots, scans, charts, photos, and diagrams should go to vision tools first when available; `document_query` image OCR is a text-focused fallback when vision is unavailable or cannot read the needed text.
24
+
25
+ ## Verification
26
+
27
+ - Smoke-test representative PDF, text/HTML, image/OCR, and remote-document queries after parser changes.
28
+
29
+ ## Child DOX Index
30
+
31
+ No child DOX files.
plugins/_document_query/README.md ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Document Query Plugin
2
+
3
+ Load, parse, index, and Q&A over local and remote documents with configurable
4
+ timeouts and thread-safe parsers.
5
+
6
+ ## Features
7
+
8
+ - **Strategy-pattern parsers** - MIME-type routing to dedicated parser classes
9
+ - **Centralized fetching** - local and HTTP(S) resources are fetched once, size-checked, then passed to parsers
10
+ - **LiteParse first path** - fast local parsing for PDFs and supported document/image formats, with legacy fallbacks
11
+ - **Adaptive OCR** - long PDFs skip OCR automatically to avoid pathological parse times
12
+ - **Adaptive indexing** - very large extracted documents increase chunk size to keep embedding work bounded
13
+ - **Bounded parser execution** - sync parsers are offloaded to asyncio.to_thread and globally capped across chats
14
+ - **Configurable timeouts** - per-document and gather-level timeouts
15
+ - **Expanded format support** - PDF, HTML, text, YAML, XML, TOML, JS, TS, images, and catch-all Unstructured
16
+
17
+ ## Configuration
18
+
19
+ See default_config.yaml for all options. Key settings:
20
+
21
+ | Setting | Default | Description |
22
+ |---------|---------|-------------|
23
+ | fetch_timeout | 30 | HTTP fetch timeout (seconds) |
24
+ | fetch_retries | 3 | HTTP retry attempts |
25
+ | max_remote_bytes | 52428800 | Max remote document size |
26
+ | per_document_timeout | 60 | Max time for a single document parse |
27
+ | gather_timeout | 120 | Max time for all documents combined |
28
+ | parser_concurrency | 1 | Max parser jobs running across all chats in one process |
29
+ | context_intro_chunks | 2 | Leading chunks included per document for title/abstract grounding |
30
+ | chunk_size | 1000 | Text splitter chunk size |
31
+ | chunk_overlap | 100 | Text splitter overlap |
32
+ | max_index_chunks | 1200 | Maximum indexed chunks before adaptive chunk sizing, or 0 for no cap |
33
+ | search_threshold | 0.5 | Similarity search threshold |
34
+ | liteparse_enabled | true | Prefer LiteParse before legacy parser fallbacks |
35
+ | liteparse_num_workers | 2 | Max LiteParse OCR workers per parser job |
36
+ | liteparse_ocr_auto_disable_pages | 30 | Disable OCR for PDFs at or above this effective page count |
37
+ | thread_offload | true | Offload sync parsers to thread pool |
38
+
39
+ LiteParse is installed into the Agent Zero framework runtime from hooks.py during
40
+ plugin install/startup. If installation fails, the plugin logs the error and
41
+ continues with the legacy parser fallbacks.
42
+
43
+ LiteParse always runs in a child process so native parser and OCR failures stay
44
+ isolated from the Web UI process.
45
+
46
+ ## Parsers
47
+
48
+ | Parser | MIME Types | Backend |
49
+ |--------|-----------|---------|
50
+ | LiteParseParser | PDF, Office/OpenDocument, images | LiteParse |
51
+ | PdfParser | application/pdf | PyMuPDF + Tesseract OCR fallback |
52
+ | HtmlParser | text/html | Markdownify transformer |
53
+ | TextParser | text/*, application/json, YAML, XML, TOML, JS, TS, shell | Direct read |
54
+ | ImageParser | image/* | UnstructuredLoader |
55
+ | UnstructuredParser | * (catch-all) | UnstructuredLoader hi-res |
56
+
57
+ ## Adding a new parser
58
+
59
+ 1. Create helpers/parsers/<format>.py extending BaseParser
60
+ 2. Set mimetypes class attribute
61
+ 3. Implement _parse_sync(document, config)
62
+ 4. Register in helpers/parsers/__init__.py