@vellumai/assistant 0.12.2-dev.202609191615.99620e2 → 0.12.2-dev.202609210132.d52247f

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,10 @@ flowchart LR
16
16
  Chrome --> Stream[Existing desktop stream]
17
17
  ```
18
18
 
19
+ The Terminal dock launcher opens [WezTerm](https://wezterm.org/), with a plain dark native title bar, minimize/maximize/close controls, a new-tab button and split panes. Use Ctrl+Shift+T for a tab, Ctrl+Shift+D to split side by side, and Ctrl+Shift+E to split top and bottom. The desktop seeds an editable `data/desktop-panel/wezterm/wezterm.lua` configuration with software rendering and a dark theme. The managed launcher keeps its stable `data/desktop-panel/applications/xterm.desktop` path so existing dock pins remain valid. Setup installs an exact-version, SHA-256-verified WezTerm package for Linux x64 or ARM64, plus its rendering libraries.
20
+
21
+ Chrome and WezTerm use explicit desktop window classes shared with their launcher entries. The desktop session bus and Plank share `XDG_DATA_HOME` so the D-Bus-activated window matcher can resolve the generated launchers and group running windows under their pinned icons.
22
+
19
23
  Chrome and its dock launcher share the managed profile and loopback debug port. For Chrome reopened from the dock, the profile singleton lock identifies a candidate PID, validated against the installed executable, profile and loopback debug arguments. Discovery checks `/proc` socket ownership against that browser PID, refuses redirects and validates the returned browser WebSocket endpoint. The connection stays within the container. No CDP endpoint or token is exposed to the renderer.
20
24
 
21
25
  The desktop client bypasses personal-browser discovery, extension reconnect waits and backend fallback. It reuses the existing AX snapshot, DOM element resolution, mouse, keyboard, extraction and credential-fill implementations. Operation-scoped clients borrow the lease's connection; disposing one does not release the lease. `detach`, `close`, turn completion, cancellation, errors and idle expiry release control. Turn completion clears the activity indicator immediately and queues browser cleanup before the next automation session; it leaves Chrome and the current page open for the user. Only `tabs close` closes a Chrome tab.
@@ -54,3 +58,17 @@ Browser commands, user handoff, errors, and interruption require a fresh
54
58
  observation before returning to native input. Browser commands and computer use
55
59
  share session ownership, cancellation, and the user-help reservation. Use
56
60
  browser commands for page-level operations and computer use for desktop UI.
61
+
62
+ ## File explorer
63
+
64
+ Open **Files**, between Chrome and Terminal in the virtual desktop dock, to browse the assistant workspace in
65
+ Thunar, with a Workspace sidebar shortcut and a dark theme. It supports tabs,
66
+ split views, search, hidden files, drag and drop,
67
+ copy/paste, bulk renaming, and file properties. GVfs provides Trash support and
68
+ Tumbler provides thumbnails. Use Ctrl+L to enter a path, Ctrl+T for a new tab,
69
+ Ctrl+H to toggle hidden files, and F3 for split view.
70
+
71
+ Files manages the assistant's files. Files on your personal computer must first
72
+ be uploaded or otherwise copied into the assistant workspace. Existing desktops
73
+ receive the Files dock shortcut once during upgrade; removing or rearranging it
74
+ is preserved across restarts.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-dev.202609191615.99620e2",
3
+ "version": "0.12.2-dev.202609210132.d52247f",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -91,15 +91,14 @@ try {
91
91
  `<bindings xmlns="http://openbox.org/3.4/rc">${mouse}${keyboard}</bindings>`,
92
92
  );
93
93
  const configDir = join(directory, "managed & theme");
94
- const config = writeDesktopWindowTheme(configDir, home, "#53b7aa");
94
+ const config = writeDesktopWindowTheme(configDir, home);
95
95
  const expanded = run("xmllint", "--xinclude", config);
96
96
  assert(
97
97
  expanded.includes("NextWindow") && expanded.includes("Move"),
98
98
  "Relative includes must retain the actual keyboard and mouse bindings",
99
99
  );
100
100
  assert.equal(await readFile(sourcePath, "utf8"), source);
101
- writeDesktopWindowTheme(configDir, home, "#bc7799");
102
- writeDesktopWindowTheme(configDir, home, "#53b7aa");
101
+ writeDesktopWindowTheme(configDir, home);
103
102
  assert.equal(await readFile(sourcePath, "utf8"), source);
104
103
  const blocked = join(directory, "blocked");
105
104
  await writeFile(blocked, "not a directory");
@@ -4,6 +4,8 @@ import { join } from "node:path";
4
4
 
5
5
  import { shouldRestoreDesktopChromeSession } from "./desktop-chrome-session.js";
6
6
 
7
+ export const DESKTOP_CHROME_WINDOW_CLASS = "vellum-desktop-chrome";
8
+
7
9
  export async function allocateDesktopDebugPort(): Promise<number> {
8
10
  const server = createServer();
9
11
  await new Promise<void>((resolve, reject) => {
@@ -129,6 +131,7 @@ export function desktopChromeArguments(
129
131
  return [
130
132
  "--no-sandbox",
131
133
  "--no-first-run",
134
+ `--class=${DESKTOP_CHROME_WINDOW_CLASS}`,
132
135
  "--force-renderer-accessibility",
133
136
  "--disable-dev-shm-usage",
134
137
  ...(shouldRestoreDesktopChromeSession(profileDir)
@@ -12,6 +12,17 @@ import { getLogger } from "../util/logger.js";
12
12
  import { getExternalDir } from "../util/platform.js";
13
13
 
14
14
  const log = getLogger("desktop-dependencies");
15
+ const WEZTERM_VERSION = "20240203-110809-5046fc22";
16
+ const WEZTERM_PACKAGES = {
17
+ x64: {
18
+ suffix: "Debian12.deb",
19
+ sha256: "d3a5c97093fbc0a87e8f9616e44efc4c9503cfc495fd1cfe4ffeff88578d15f8",
20
+ },
21
+ arm64: {
22
+ suffix: "Debian12.arm64.deb",
23
+ sha256: "351f8791c6e9561d687afa63834a2132de84800dad6e7c3d035cc6518d6c3744",
24
+ },
25
+ } as const;
15
26
  const CHROME_VERSION = "153.0.8010.36-1";
16
27
  const CHROME_PACKAGES = {
17
28
  x64: {
@@ -32,17 +43,22 @@ const DESKTOP_BINARIES = {
32
43
  panel: ["plank"],
33
44
  sessionBus: ["dbus-daemon"],
34
45
  clipboard: ["tigervncconfig", "vncconfig"],
35
- terminal: ["xterm"],
46
+ terminal: ["wezterm"],
47
+ fileManager: ["thunar"],
36
48
  wallpaper: ["feh"],
37
49
  input: ["xdotool"],
38
50
  capture: ["scrot"],
39
51
  } as const;
40
52
 
41
53
  const DESKTOP_PACKAGES = [
54
+ "adwaita-icon-theme",
42
55
  "at-spi2-core",
43
56
  "dbus-x11",
44
57
  "feh",
45
58
  "gnome-mines",
59
+ "gvfs",
60
+ "thunar",
61
+ "tumbler",
46
62
  "openbox",
47
63
  "python3",
48
64
  "python3-dbus",
@@ -54,11 +70,14 @@ const DESKTOP_PACKAGES = [
54
70
  "xauth",
55
71
  "xcompmgr",
56
72
  "xfonts-base",
73
+ // Preserved Openbox menus and shortcuts can invoke xterm directly.
57
74
  "xterm",
58
75
  "xdotool",
59
76
  "scrot",
60
77
  "fonts-liberation",
61
78
  "libgtk-3-0",
79
+ "libegl1",
80
+ "libgl1-mesa-dri",
62
81
  "libvulkan1",
63
82
  "libcurl4",
64
83
  ];
@@ -115,6 +134,12 @@ export class DesktopDependencyInstaller {
115
134
  existsSync(desktopChromePath() + ".ready") &&
116
135
  existsSync(desktopChromePath()) &&
117
136
  existsSync("/usr/games/gnome-mines") &&
137
+ existsSync(
138
+ "/usr/share/dbus-1/services/org.gtk.vfs.Daemon.service",
139
+ ) &&
140
+ existsSync(
141
+ "/usr/share/dbus-1/services/org.xfce.Tumbler.Thumbnailer1.service",
142
+ ) &&
118
143
  existsSync("/usr/share/fonts/X11/misc/fonts.dir") &&
119
144
  existsSync("/usr/share/dbus-1/services/org.ayatana.bamf.service") &&
120
145
  existsSync("/usr/share/dbus-1/services/org.a11y.Bus.service") &&
@@ -277,17 +302,36 @@ async function installDesktopComponents(
277
302
  await rm(desktopChromePath() + ".ready", { force: true });
278
303
  // Desktop binaries, X assets and the loader share the image root.
279
304
  await run([...apt, "update"]);
280
- await run([
281
- ...apt,
282
- "install",
283
- "-y",
284
- "--no-install-recommends",
285
- "--no-upgrade",
286
- "--no-remove",
287
- "-o",
288
- "DPkg::Lock::Timeout=120",
289
- ...DESKTOP_PACKAGES,
290
- ]);
305
+ const terminal =
306
+ WEZTERM_PACKAGES[process.arch as keyof typeof WEZTERM_PACKAGES];
307
+ const terminalDownloadDir = await mkdtemp(
308
+ join(tmpdir(), "desktop-terminal-"),
309
+ );
310
+ try {
311
+ const deb = join(terminalDownloadDir, "wezterm.deb");
312
+ await downloadDesktopPackage(
313
+ `https://github.com/wezterm/wezterm/releases/download/${WEZTERM_VERSION}/wezterm-${WEZTERM_VERSION}.${terminal.suffix}`,
314
+ deb,
315
+ terminal.sha256,
316
+ caBundle,
317
+ );
318
+ await chmod(terminalDownloadDir, 0o755);
319
+ await chmod(deb, 0o644);
320
+ await run([
321
+ ...apt,
322
+ "install",
323
+ "-y",
324
+ "--no-install-recommends",
325
+ "--no-upgrade",
326
+ "--no-remove",
327
+ "-o",
328
+ "DPkg::Lock::Timeout=120",
329
+ ...DESKTOP_PACKAGES,
330
+ deb,
331
+ ]);
332
+ } finally {
333
+ await rm(terminalDownloadDir, { recursive: true, force: true });
334
+ }
291
335
  onStage("chrome");
292
336
  if (!existsSync(desktopChromePath())) {
293
337
  const downloadDir = await mkdtemp(join(tmpdir(), "desktop-chrome-"));
@@ -296,30 +340,12 @@ async function installDesktopComponents(
296
340
  const staging = await mkdtemp(join(installRoot, ".install-"));
297
341
  try {
298
342
  const deb = join(downloadDir, "chrome.deb");
299
- await run([
300
- "curl",
301
- ...(caBundle ? ["--cacert", caBundle] : []),
302
- "--fail",
303
- "--location",
304
- "--proto",
305
- "=https",
306
- "--proto-redir",
307
- "=https",
308
- "--retry",
309
- "2",
310
- "--max-time",
311
- "300",
312
- "--output",
313
- deb,
343
+ await downloadDesktopPackage(
314
344
  `https://dl.google.com/linux/chrome/deb/pool/main/g/google-chrome-stable/google-chrome-stable_${CHROME_VERSION}_${chrome.arch}.deb`,
315
- ]);
316
- const hash = createHash("sha256");
317
- for await (const chunk of createReadStream(deb)) {
318
- hash.update(chunk);
319
- }
320
- if (hash.digest("hex") !== chrome.sha256) {
321
- throw new Error("Chrome download checksum mismatch");
322
- }
345
+ deb,
346
+ chrome.sha256,
347
+ caBundle,
348
+ );
323
349
  await run(["dpkg-deb", "--extract", deb, staging]);
324
350
  await rename(staging, join(installRoot, `chrome-${CHROME_VERSION}`));
325
351
  } finally {
@@ -330,8 +356,41 @@ async function installDesktopComponents(
330
356
  }
331
357
  }
332
358
  onStage("checking");
359
+ await run([resolveDesktopBinaries().terminal, "--version"]);
333
360
  await run([desktopChromePath(), "--version"]);
334
361
  await writeFile(desktopChromePath() + ".ready", "");
335
362
  }
336
363
 
364
+ async function downloadDesktopPackage(
365
+ url: string,
366
+ destination: string,
367
+ sha256: string,
368
+ caBundle?: string,
369
+ ): Promise<void> {
370
+ await run([
371
+ "curl",
372
+ ...(caBundle ? ["--cacert", caBundle] : []),
373
+ "--fail",
374
+ "--location",
375
+ "--proto",
376
+ "=https",
377
+ "--proto-redir",
378
+ "=https",
379
+ "--retry",
380
+ "2",
381
+ "--max-time",
382
+ "300",
383
+ "--output",
384
+ destination,
385
+ url,
386
+ ]);
387
+ const hash = createHash("sha256");
388
+ for await (const chunk of createReadStream(destination)) {
389
+ hash.update(chunk);
390
+ }
391
+ if (hash.digest("hex") !== sha256) {
392
+ throw new Error("Desktop package download checksum mismatch");
393
+ }
394
+ }
395
+
337
396
  export const desktopDependencyInstaller = new DesktopDependencyInstaller();
@@ -33,7 +33,9 @@ test("initializes the dock and preserves user changes across restarts", () => {
33
33
  configDir,
34
34
  chromiumPath: "/opt/chrome-v1/chrome",
35
35
  chromiumProfileDir: profileDir,
36
- terminalPath: "/usr/bin/xterm",
36
+ terminalPath: "/usr/bin/wezterm",
37
+ fileManagerPath: "/usr/bin/thunar",
38
+ workspaceDir: workspace,
37
39
  };
38
40
  writeDesktopPanelConfig(request);
39
41
  const settingsPath = join(configDir, "glib-2.0", "settings", "keyfile");
@@ -59,6 +61,9 @@ test("initializes the dock and preserves user changes across restarts", () => {
59
61
  rmSync(join(pinsDir, "mines.dockitem"));
60
62
  const customSettings = settings.replace("icon-size=48", "icon-size=64");
61
63
  writeFileSync(settingsPath, customSettings);
64
+ const terminalConfig = join(configDir, "wezterm", "wezterm.lua");
65
+ const customTerminalConfig = `${readFileSync(terminalConfig, "utf8")}\n-- Custom settings\n`;
66
+ writeFileSync(terminalConfig, customTerminalConfig);
62
67
  writeDesktopPanelConfig({
63
68
  ...request,
64
69
  chromiumPath: "/opt/chrome-v2/chrome",
@@ -66,6 +71,7 @@ test("initializes the dock and preserves user changes across restarts", () => {
66
71
  expect(() => readFileSync(join(pinsDir, "chrome.dockitem"))).toThrow();
67
72
  expect(() => readFileSync(join(pinsDir, "mines.dockitem"))).toThrow();
68
73
  expect(readFileSync(settingsPath, "utf8")).toBe(customSettings);
74
+ expect(readFileSync(terminalConfig, "utf8")).toBe(customTerminalConfig);
69
75
  expect(
70
76
  readFileSync(
71
77
  join(configDir, "applications", "google-chrome.desktop"),
@@ -89,7 +95,9 @@ test("initialization recovers partial pin creation", () => {
89
95
  configDir,
90
96
  chromiumPath: "/opt/chrome/chrome",
91
97
  chromiumProfileDir: join(workspace, "data", "desktop-profile"),
92
- terminalPath: "/usr/bin/xterm",
98
+ terminalPath: "/usr/bin/wezterm",
99
+ fileManagerPath: "/usr/bin/thunar",
100
+ workspaceDir: workspace,
93
101
  });
94
102
  expect(readFileSync(join(pinsDir, "chrome.dockitem"), "utf8")).toBe(
95
103
  "existing pin",
@@ -111,7 +119,9 @@ test("a failed settings write leaves initialization retryable", () => {
111
119
  configDir,
112
120
  chromiumPath: "/opt/chrome/chrome",
113
121
  chromiumProfileDir: join(workspace, "data", "desktop-profile"),
114
- terminalPath: "/usr/bin/xterm",
122
+ terminalPath: "/usr/bin/wezterm",
123
+ fileManagerPath: "/usr/bin/thunar",
124
+ workspaceDir: workspace,
115
125
  };
116
126
  const write = fs.writeFileSync;
117
127
  const interrupted = spyOn(fs, "writeFileSync").mockImplementation(
@@ -3,13 +3,21 @@ import {
3
3
  existsSync,
4
4
  linkSync,
5
5
  mkdirSync,
6
+ readFileSync,
7
+ renameSync,
6
8
  rmSync,
7
9
  writeFileSync,
8
10
  } from "node:fs";
9
11
  import { dirname, join } from "node:path";
10
12
  import { pathToFileURL } from "node:url";
11
13
 
12
- import { desktopChromeArguments } from "./desktop-browser-endpoint.js";
14
+ import {
15
+ DESKTOP_CHROME_WINDOW_CLASS,
16
+ desktopChromeArguments,
17
+ } from "./desktop-browser-endpoint.js";
18
+
19
+ const TERMINAL_WINDOW_CLASS = "vellum-desktop-terminal";
20
+ const TERMINAL_HEADER_CONFIG = " window_decorations = 'TITLE|RESIZE',\n";
13
21
 
14
22
  // Absolute icon paths work without an installed icon theme.
15
23
  const TERMINAL_ICON_BASE64 = [
@@ -34,6 +42,8 @@ export interface DesktopPanelConfigRequest {
34
42
  /** Profile the launcher shares with that browser, so it reuses the window. */
35
43
  readonly chromiumProfileDir: string;
36
44
  readonly terminalPath: string;
45
+ readonly fileManagerPath: string;
46
+ readonly workspaceDir: string;
37
47
  readonly debugPort?: number;
38
48
  }
39
49
 
@@ -50,41 +60,69 @@ export function writeDesktopPanelConfig(
50
60
  "product_logo_64.png",
51
61
  );
52
62
  writeFileSync(terminalIcon, Buffer.from(TERMINAL_ICON_BASE64, "base64"));
63
+ const terminalConfig = join(configDir, "wezterm", "wezterm.lua");
64
+ mkdirSync(dirname(terminalConfig), { recursive: true });
65
+ const terminalConfigContents = `local wezterm = require 'wezterm'
66
+ return {
67
+ front_end = 'Software',
68
+ enable_wayland = false,
69
+ check_for_updates = false,
70
+ hide_tab_bar_if_only_one_tab = false,
71
+ ${TERMINAL_HEADER_CONFIG} font_size = 11,
72
+ color_scheme = 'Catppuccin Mocha',
73
+ initial_cols = 110,
74
+ initial_rows = 28,
75
+ keys = {
76
+ { key = 'd', mods = 'CTRL|SHIFT', action = wezterm.action.SplitHorizontal { domain = 'CurrentPaneDomain' } },
77
+ { key = 'e', mods = 'CTRL|SHIFT', action = wezterm.action.SplitVertical { domain = 'CurrentPaneDomain' } },
78
+ },
79
+ }
80
+ `;
81
+ seedFile(terminalConfig, terminalConfigContents, [
82
+ terminalConfigContents.replace(TERMINAL_HEADER_CONFIG, ""),
83
+ ...["", " integrated_title_buttons = { 'Maximize', 'Close' },\n"].map(
84
+ (buttons) =>
85
+ terminalConfigContents.replace(
86
+ TERMINAL_HEADER_CONFIG,
87
+ ` window_decorations = 'INTEGRATED_BUTTONS|RESIZE',\n integrated_title_button_style = 'Gnome',\n${buttons}`,
88
+ ),
89
+ ),
90
+ ]);
53
91
 
54
92
  const applicationsDir = join(configDir, "applications");
55
93
  mkdirSync(applicationsDir, { recursive: true });
56
94
  const chromiumEntry = join(applicationsDir, "google-chrome.desktop");
95
+ // Saved dock pins reference this stable launcher path.
57
96
  const terminalEntry = join(applicationsDir, "xterm.desktop");
97
+ const filesEntry = join(applicationsDir, "thunar.desktop");
58
98
  const minesEntry = join(applicationsDir, "org.gnome.Mines.desktop");
59
99
  writeFileSync(
60
100
  chromiumEntry,
61
101
  desktopEntry({
62
102
  name: "Google Chrome",
63
- // Chrome includes its profile path in WM_CLASS.
64
- windowClass: `google-chrome (${request.chromiumProfileDir})`,
103
+ windowClass: DESKTOP_CHROME_WINDOW_CLASS,
65
104
  icon: browserIcon,
66
- exec: [
105
+ exec: desktopCommand([
67
106
  request.chromiumPath,
68
107
  ...desktopChromeArguments(
69
108
  request.chromiumProfileDir,
70
109
  request.debugPort,
71
110
  ),
72
- ]
73
- .map(
74
- (arg) => `"${arg.replace(/[\\"`$]/g, "\\$&").replace(/%/g, "%%")}"`,
75
- )
76
- .join(" "),
111
+ ]),
77
112
  }),
78
113
  );
79
114
  writeFileSync(
80
115
  terminalEntry,
81
116
  desktopEntry({
82
117
  name: "Terminal",
83
- windowClass: "XTerm",
118
+ windowClass: TERMINAL_WINDOW_CLASS,
84
119
  icon: terminalIcon,
85
- // `-fa` picks a scalable font through fontconfig; xterm otherwise falls
86
- // back to the `fixed` bitmap, which is unreadable at this geometry.
87
- exec: `"${request.terminalPath}" -fa Monospace -fs 11 -bg '#1c1c22' -fg '#e6e6ea' -title Terminal`,
120
+ exec: desktopCommand([
121
+ request.terminalPath,
122
+ "start",
123
+ "--new-tab",
124
+ `--class=${TERMINAL_WINDOW_CLASS}`,
125
+ ]),
88
126
  }),
89
127
  );
90
128
 
@@ -98,6 +136,37 @@ export function writeDesktopPanelConfig(
98
136
  }),
99
137
  );
100
138
 
139
+ writeFileSync(
140
+ filesEntry,
141
+ desktopEntry({
142
+ name: "Files",
143
+ windowClass: "Thunar",
144
+ icon: "/usr/share/icons/Adwaita/scalable/places/folder.svg",
145
+ exec: desktopCommand([request.fileManagerPath, request.workspaceDir]),
146
+ }),
147
+ );
148
+
149
+ const gtkDir = join(configDir, "gtk-3.0");
150
+ const xfconfDir = join(configDir, "xfce4", "xfconf", "xfce-perchannel-xml");
151
+ mkdirSync(gtkDir, { recursive: true });
152
+ mkdirSync(xfconfDir, { recursive: true });
153
+ seedFile(
154
+ join(gtkDir, "settings.ini"),
155
+ "[Settings]\ngtk-theme-name=Adwaita\ngtk-icon-theme-name=Adwaita\ngtk-application-prefer-dark-theme=true\ngtk-font-name=Sans 11\n",
156
+ );
157
+ seedFile(
158
+ join(gtkDir, "bookmarks"),
159
+ `${pathToFileURL(request.workspaceDir).href} Workspace\n`,
160
+ );
161
+ seedFile(
162
+ join(xfconfDir, "thunar.xml"),
163
+ `<channel name="thunar" version="1.0">
164
+ <property name="last-window-width" type="int" value="1000"/>
165
+ <property name="last-window-height" type="int" value="680"/>
166
+ <property name="last-view" type="string" value="ThunarDetailsView"/>
167
+ </channel>\n`,
168
+ );
169
+
101
170
  const launchersDir = join(configDir, "plank", "dock1", "launchers");
102
171
  const settingsDir = join(configDir, "glib-2.0", "settings");
103
172
  mkdirSync(launchersDir, { recursive: true });
@@ -107,13 +176,14 @@ export function writeDesktopPanelConfig(
107
176
  return;
108
177
  }
109
178
  seedFile(join(launchersDir, "chrome.dockitem"), dockItem(chromiumEntry));
179
+ seedFile(join(launchersDir, "files.dockitem"), dockItem(filesEntry));
110
180
  seedFile(join(launchersDir, "terminal.dockitem"), dockItem(terminalEntry));
111
181
  seedFile(join(launchersDir, "mines.dockitem"), dockItem(minesEntry));
112
182
  seedFile(
113
183
  join(settingsDir, "keyfile"),
114
184
  [
115
185
  "[net/launchpad/plank/docks/dock1]",
116
- "dock-items=['chrome.dockitem', 'terminal.dockitem', 'mines.dockitem']",
186
+ "dock-items=['chrome.dockitem', 'files.dockitem', 'terminal.dockitem', 'mines.dockitem']",
117
187
  "icon-size=48",
118
188
  "hide-mode='none'",
119
189
  "theme='Matte'",
@@ -127,7 +197,25 @@ function dockItem(launcher: string): string {
127
197
  return `[PlankDockItemPreferences]\nLauncher=${pathToFileURL(launcher).href}\n`;
128
198
  }
129
199
 
130
- function seedFile(path: string, contents: string): void {
200
+ function desktopCommand(args: string[]): string {
201
+ return args
202
+ .map((arg) => {
203
+ const quoted = `"${arg.replace(/[\\"`$]/g, "\\$&").replace(/%/g, "%%")}"`;
204
+ // Desktop values are unescaped before Exec arguments are parsed.
205
+ return quoted
206
+ .replace(/\\/g, "\\\\")
207
+ .replace(/\n/g, "\\n")
208
+ .replace(/\r/g, "\\r")
209
+ .replace(/\t/g, "\\t");
210
+ })
211
+ .join(" ");
212
+ }
213
+
214
+ function seedFile(
215
+ path: string,
216
+ contents: string,
217
+ previousContents: readonly string[] = [],
218
+ ): void {
131
219
  const temporaryPath = `${path}.${randomUUID()}.tmp`;
132
220
  try {
133
221
  writeFileSync(temporaryPath, contents, { flush: true });
@@ -137,6 +225,12 @@ function seedFile(path: string, contents: string): void {
137
225
  if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
138
226
  throw err;
139
227
  }
228
+ if (
229
+ previousContents.length > 0 &&
230
+ previousContents.includes(readFileSync(path, "utf8"))
231
+ ) {
232
+ renameSync(temporaryPath, path);
233
+ }
140
234
  } finally {
141
235
  rmSync(temporaryPath, { force: true });
142
236
  }
@@ -219,6 +219,7 @@ describe("DesktopSessionManager process tree", () => {
219
219
  expect(x[x.indexOf("-rfbport") + 1]).toBe(String(DESKTOP_VNC_PORT));
220
220
  expect(x[x.indexOf("-geometry") + 1]).toBe("1600x900");
221
221
  for (const role of [
222
+ "session-bus",
222
223
  "window-manager",
223
224
  "compositor",
224
225
  "clipboard",
@@ -229,6 +230,8 @@ describe("DesktopSessionManager process tree", () => {
229
230
  HOME: "/data",
230
231
  LANG: "C.UTF-8",
231
232
  DISPLAY: ":99",
233
+ XDG_CONFIG_HOME: panelConfigDir,
234
+ XDG_DATA_HOME: panelConfigDir,
232
235
  DBUS_SESSION_BUS_ADDRESS: h.manager.accessibilityBusAddress,
233
236
  NO_AT_BRIDGE: "0",
234
237
  GTK_A11Y: "atspi",
@@ -280,7 +283,7 @@ describe("DesktopSessionManager process tree", () => {
280
283
  "plank",
281
284
  "tigervncconfig",
282
285
  "vncconfig",
283
- "xterm",
286
+ "wezterm",
284
287
  ],
285
288
  });
286
289
  await expect(h.manager.ensureDesktopRunning()).rejects.toThrow(
@@ -306,7 +309,7 @@ describe("DesktopSessionManager process tree", () => {
306
309
  join(panelConfigDir, "applications", "xterm.desktop"),
307
310
  "utf8",
308
311
  );
309
- expect(terminal).toContain(`Exec="/usr/bin/xterm"`);
312
+ expect(terminal).toContain(`Exec="/usr/bin/wezterm"`);
310
313
  expect(terminal).toContain(`Icon=${join(panelConfigDir, "terminal.png")}`);
311
314
 
312
315
  const bytes = readFileSync(join(panelConfigDir, "terminal.png"));
@@ -11,13 +11,11 @@ import { mkdirSync, writeFileSync } from "node:fs";
11
11
  import { createConnection } from "node:net";
12
12
  import { join } from "node:path";
13
13
 
14
- import { readAvatarState } from "../avatar/avatar-manifest.js";
15
- import { resolveNotificationAccentHex } from "../avatar/notification-avatar.js";
16
14
  import { getIsContainerized } from "../config/env-registry.js";
17
15
  import { connectCdpWsTransport } from "../tools/browser/cdp-client/cdp-inspect/ws-transport.js";
18
16
  import { terminateProcessTree } from "../util/host-process.js";
19
17
  import { getLogger } from "../util/logger.js";
20
- import { getDataDir } from "../util/platform.js";
18
+ import { getDataDir, getWorkspaceDir } from "../util/platform.js";
21
19
  import { sleep } from "../util/retry.js";
22
20
  import { DesktopBrowserClient } from "./desktop-browser-client.js";
23
21
  import {
@@ -358,11 +356,7 @@ export class DesktopSessionManager {
358
356
  ((configDir) => {
359
357
  let sourcePath: string | undefined;
360
358
  try {
361
- sourcePath = writeDesktopWindowTheme(
362
- configDir,
363
- this.sourceEnv.HOME,
364
- resolveNotificationAccentHex(readAvatarState()),
365
- );
359
+ sourcePath = writeDesktopWindowTheme(configDir, this.sourceEnv.HOME);
366
360
  } catch (err) {
367
361
  log.warn({ err }, "Desktop window theme could not be applied");
368
362
  }
@@ -651,11 +645,11 @@ export class DesktopSessionManager {
651
645
  chromiumProfileDir: this.profileDir,
652
646
  debugPort: this.debugPort,
653
647
  terminalPath: binaries.terminal,
648
+ fileManagerPath: binaries.fileManager,
649
+ workspaceDir: getWorkspaceDir(),
654
650
  });
655
651
  this.launch("panel", [binaries.panel], {
656
652
  ...env,
657
- XDG_CONFIG_HOME: this.panelConfigDir,
658
- XDG_DATA_HOME: this.panelConfigDir,
659
653
  GSETTINGS_BACKEND: "keyfile",
660
654
  });
661
655
  } catch (err) {
@@ -882,6 +876,9 @@ export class DesktopSessionManager {
882
876
  }
883
877
  }
884
878
  env.DISPLAY = DESKTOP_DISPLAY;
879
+ // D-Bus services share app preferences and launchers.
880
+ env.XDG_CONFIG_HOME = this.panelConfigDir;
881
+ env.XDG_DATA_HOME = this.panelConfigDir;
885
882
  env.DBUS_SESSION_BUS_ADDRESS = this.accessibilityBusAddress;
886
883
  env.NO_AT_BRIDGE = "0";
887
884
  env.GTK_A11Y = "atspi";
@@ -13,24 +13,7 @@ const BUTTON_MASKS = {
13
13
  iconify: [0, 0, 0, 0, 0x3ff, 0x3ff, 0, 0, 0, 0],
14
14
  };
15
15
 
16
- function windowTheme(accentHex: string | null): string {
17
- const tint = (base: string, amount: number): string => {
18
- if (!accentHex || !/^#[\da-f]{6}$/i.test(accentHex)) {
19
- return base;
20
- }
21
- return (
22
- "#" +
23
- [1, 3, 5]
24
- .map((offset) => {
25
- const from = parseInt(base.slice(offset, offset + 2), 16);
26
- const to = parseInt(accentHex.slice(offset, offset + 2), 16);
27
- return Math.round(from + (to - from) * amount)
28
- .toString(16)
29
- .padStart(2, "0");
30
- })
31
- .join("")
32
- );
33
- };
16
+ function windowTheme(): string {
34
17
  return `
35
18
  border.width: 1
36
19
  padding.width: 8
@@ -47,17 +30,15 @@ window.*.grip.bg: Flat Solid
47
30
  window.*.button.*.bg: Parentrelative
48
31
  window.*.button.*.hover.bg: Flat Solid
49
32
  window.*.button.*.pressed.bg: Flat Solid
50
- window.active.title.bg: Flat Gradient Horizontal
51
- window.active.title.bg.color: ${tint("#292930", 0.28)}
52
- window.active.title.bg.colorTo: #292930
33
+ window.active.title.bg.color: #292930
53
34
  window.active.border.color: #4a4a54
54
35
  window.active.title.separator.color: #202026
55
36
  window.active.label.text.color: #f0f0f4
56
37
  window.active.handle.bg.color: #292930
57
38
  window.active.grip.bg.color: #292930
58
39
  window.active.button.*.image.color: #d4d4dc
59
- window.active.button.*.hover.bg.color: ${tint("#45454f", 0.22)}
60
- window.active.button.*.pressed.bg.color: ${tint("#555561", 0.18)}
40
+ window.active.button.*.hover.bg.color: #45454f
41
+ window.active.button.*.pressed.bg.color: #555561
61
42
  window.active.button.*.disabled.image.color: #62626e
62
43
  window.active.button.close.hover.bg.color: #b64150
63
44
  window.active.button.close.pressed.bg.color: #963442
@@ -97,7 +78,6 @@ osd.label.text.color: #f0f0f4
97
78
  export function writeDesktopWindowTheme(
98
79
  configDir: string,
99
80
  home?: string,
100
- accentHex: string | null = null,
101
81
  ): string {
102
82
  let source: string | undefined;
103
83
  let sourcePath = "";
@@ -134,7 +114,7 @@ export function writeDesktopWindowTheme(
134
114
  mkdirSync(join(themeDir, "openbox-3"), { recursive: true });
135
115
  writeFileSync(
136
116
  join(themeDir, "openbox-3", "themerc"),
137
- windowTheme(accentHex).trimStart(),
117
+ windowTheme().trimStart(),
138
118
  );
139
119
  for (const [name, rows] of Object.entries(BUTTON_MASKS)) {
140
120
  const bytes = rows.flatMap((row) => [row & 0xff, row >> 8]);
@@ -0,0 +1,94 @@
1
+ import {
2
+ linkSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { pathToFileURL } from "node:url";
11
+
12
+ import type { WorkspaceMigration } from "./types.js";
13
+
14
+ export const addDesktopFilesLauncherMigration: WorkspaceMigration = {
15
+ id: "157-add-desktop-files-launcher",
16
+ description: "Add Files to existing virtual desktop docks",
17
+ retryFailedCheckpoint: true,
18
+ run(workspaceDir): void {
19
+ const configDir = join(workspaceDir, "data", "desktop-panel");
20
+ const settingsPath = join(configDir, "glib-2.0", "settings", "keyfile");
21
+ let settings: string;
22
+ try {
23
+ settings = readFileSync(settingsPath, "utf8");
24
+ } catch (err) {
25
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
26
+ return;
27
+ }
28
+ throw err;
29
+ }
30
+
31
+ const section =
32
+ /(?:^|\n)\[net\/launchpad\/plank\/docks\/dock1\]\r?\n([^]*?)(?=\n\[|$)/;
33
+ const match = settings.match(section);
34
+ if (!match) {
35
+ return;
36
+ }
37
+ const pins = /^(dock-items\s*=\s*)(?:@as\s+)?\[([^\r\n]*)\](\r?)$/m;
38
+ const existing = match[1]!.match(pins);
39
+ if (existing && !existing[2]!.includes("'files.dockitem'")) {
40
+ const items = existing[2]!.trim();
41
+ let updatedItems: string;
42
+ if (items.includes("'chrome.dockitem'")) {
43
+ updatedItems = items.replace(
44
+ "'chrome.dockitem'",
45
+ "'chrome.dockitem', 'files.dockitem'",
46
+ );
47
+ } else if (items.includes("'terminal.dockitem'")) {
48
+ updatedItems = items.replace(
49
+ "'terminal.dockitem'",
50
+ "'files.dockitem', 'terminal.dockitem'",
51
+ );
52
+ } else {
53
+ updatedItems = `${items}${items ? ", " : ""}'files.dockitem'`;
54
+ }
55
+ const updated = match[0].replace(
56
+ pins,
57
+ () => `${existing[1]}[${updatedItems}]${existing[3]}`,
58
+ );
59
+ settings = settings.replace(section, () => updated);
60
+ } else if (!existing) {
61
+ settings = settings.replace(
62
+ section,
63
+ () =>
64
+ `${match[0]}${match[0].endsWith("\n") ? "" : "\n"}dock-items=['files.dockitem']\n`,
65
+ );
66
+ }
67
+
68
+ const launchersDir = join(configDir, "plank", "dock1", "launchers");
69
+ mkdirSync(launchersDir, { recursive: true });
70
+ const launcher = pathToFileURL(
71
+ join(configDir, "applications", "thunar.desktop"),
72
+ ).href;
73
+ const pinPath = join(launchersDir, "files.dockitem");
74
+ const pinTemporaryPath = `${pinPath}.migration.tmp`;
75
+ try {
76
+ writeFileSync(
77
+ pinTemporaryPath,
78
+ `[PlankDockItemPreferences]\nLauncher=${launcher}\n`,
79
+ { flush: true },
80
+ );
81
+ linkSync(pinTemporaryPath, pinPath);
82
+ } catch (err) {
83
+ if ((err as NodeJS.ErrnoException).code !== "EEXIST") {
84
+ throw err;
85
+ }
86
+ } finally {
87
+ rmSync(pinTemporaryPath, { force: true });
88
+ }
89
+ const temporaryPath = `${settingsPath}.files-migration.tmp`;
90
+ writeFileSync(temporaryPath, settings, { flush: true });
91
+ renameSync(temporaryPath, settingsPath);
92
+ },
93
+ down(): void {},
94
+ };
@@ -3,9 +3,12 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { describe, expect, test } from "bun:test";
5
5
 
6
- import { extractWorkspaceMcpJsonMigration } from "../156-extract-workspace-mcp-json.js";
7
6
  import { WORKSPACE_MIGRATIONS } from "../registry.js";
8
7
 
8
+ const extractWorkspaceMcpJsonMigration = WORKSPACE_MIGRATIONS.find(
9
+ (migration) => migration.id === "156-extract-workspace-mcp-json",
10
+ )!;
11
+
9
12
  const MIGRATION_156_SCHEMA_URL =
10
13
  "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json";
11
14
 
@@ -23,15 +26,6 @@ function readJson(dir: string, name: string): Record<string, unknown> {
23
26
  }
24
27
 
25
28
  describe("156-extract-workspace-mcp-json", () => {
26
- test("has the next migration id and is registered last", () => {
27
- expect(extractWorkspaceMcpJsonMigration.id).toBe(
28
- "156-extract-workspace-mcp-json",
29
- );
30
- expect(WORKSPACE_MIGRATIONS.at(-1)?.id).toBe(
31
- "156-extract-workspace-mcp-json",
32
- );
33
- });
34
-
35
29
  test("moves servers into a spec-pure mcp.json and drops mcp from config", () => {
36
30
  const dir = workspaceWith({
37
31
  llm: { activeProfile: "balanced" },
@@ -0,0 +1,113 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readFileSync,
6
+ rmSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { dirname, join } from "node:path";
11
+ import { afterEach, expect, test } from "bun:test";
12
+
13
+ import { writeDesktopPanelConfig } from "../../../desktop/desktop-panel-config.js";
14
+ import { WORKSPACE_MIGRATIONS } from "../registry.js";
15
+
16
+ const migration = WORKSPACE_MIGRATIONS.find(
17
+ (entry) => entry.id === "157-add-desktop-files-launcher",
18
+ )!;
19
+
20
+ const workspaces: string[] = [];
21
+ afterEach(() => {
22
+ for (const workspace of workspaces.splice(0)) {
23
+ rmSync(workspace, { recursive: true, force: true });
24
+ }
25
+ });
26
+
27
+ function setup() {
28
+ const workspace = mkdtempSync(join(tmpdir(), "desktop-files-migration-"));
29
+ workspaces.push(workspace);
30
+ const configDir = join(workspace, "data", "desktop-panel");
31
+ const settingsPath = join(configDir, "glib-2.0", "settings", "keyfile");
32
+ return { workspace, configDir, settingsPath };
33
+ }
34
+
35
+ test("upgrades a customized dock idempotently and respects later unpinning", () => {
36
+ const { workspace, configDir, settingsPath } = setup();
37
+ mkdirSync(dirname(settingsPath), { recursive: true });
38
+ const original =
39
+ "[unrelated]\nvalue=keep\n\n[net/launchpad/plank/docks/dock1]\ndock-items=['chrome.dockitem', 'terminal.dockitem', 'custom.dockitem']\nicon-size=64\n\n[another]\ndock-items=['other.dockitem']\n";
40
+ writeFileSync(settingsPath, original);
41
+ migration.run(workspace);
42
+ const upgraded = readFileSync(settingsPath, "utf8");
43
+ expect(upgraded).toBe(
44
+ original.replace(
45
+ "'chrome.dockitem', 'terminal.dockitem'",
46
+ "'chrome.dockitem', 'files.dockitem', 'terminal.dockitem'",
47
+ ),
48
+ );
49
+ migration.run(workspace);
50
+ expect(readFileSync(settingsPath, "utf8")).toBe(upgraded);
51
+ const pin = join(configDir, "plank", "dock1", "launchers", "files.dockitem");
52
+ expect(readFileSync(pin, "utf8")).toContain("/applications/thunar.desktop");
53
+ rmSync(pin);
54
+ writeFileSync(settingsPath, original);
55
+ writeDesktopPanelConfig({
56
+ configDir,
57
+ chromiumPath: "/opt/chrome/chrome",
58
+ chromiumProfileDir: join(workspace, "profile"),
59
+ terminalPath: "/usr/bin/wezterm",
60
+ fileManagerPath: "/usr/bin/thunar",
61
+ workspaceDir: workspace,
62
+ });
63
+ expect(existsSync(pin)).toBe(false);
64
+ expect(readFileSync(settingsPath, "utf8")).toBe(original);
65
+ expect(existsSync(join(configDir, "applications", "thunar.desktop"))).toBe(
66
+ true,
67
+ );
68
+ });
69
+
70
+ test.each(["[]", "@as []"])(
71
+ "upgrades an empty dock (%s) and recovers an existing pin",
72
+ (items) => {
73
+ const { workspace, configDir, settingsPath } = setup();
74
+ mkdirSync(dirname(settingsPath), { recursive: true });
75
+ writeFileSync(
76
+ settingsPath,
77
+ `[net/launchpad/plank/docks/dock1]\ndock-items=${items}\n`,
78
+ );
79
+ const pin = join(
80
+ configDir,
81
+ "plank",
82
+ "dock1",
83
+ "launchers",
84
+ "files.dockitem",
85
+ );
86
+ mkdirSync(dirname(pin), { recursive: true });
87
+ writeFileSync(pin, "user launcher");
88
+ migration.run(workspace);
89
+ expect(readFileSync(settingsPath, "utf8")).toContain(
90
+ "dock-items=['files.dockitem']",
91
+ );
92
+ expect(readFileSync(pin, "utf8")).toBe("user launcher");
93
+ },
94
+ );
95
+
96
+ test("does not initialize a desktop for workspaces without one", () => {
97
+ const { workspace, configDir } = setup();
98
+ migration.run(workspace);
99
+ expect(existsSync(configDir)).toBe(false);
100
+ });
101
+
102
+ test("adds an implicit dock pin list inside the correct settings section", () => {
103
+ const { workspace, settingsPath } = setup();
104
+ mkdirSync(dirname(settingsPath), { recursive: true });
105
+ writeFileSync(
106
+ settingsPath,
107
+ "[unrelated]\nvalue=keep\n[net/launchpad/plank/docks/dock1]\nicon-size=64\n[next]\nvalue=also-keep\n",
108
+ );
109
+ migration.run(workspace);
110
+ expect(readFileSync(settingsPath, "utf8")).toBe(
111
+ "[unrelated]\nvalue=keep\n[net/launchpad/plank/docks/dock1]\nicon-size=64\ndock-items=['files.dockitem']\n\n[next]\nvalue=also-keep\n",
112
+ );
113
+ });
@@ -154,6 +154,7 @@ import { stripMcpPolicyFieldsMigration } from "./153-strip-mcp-policy-fields.js"
154
154
  import { repairRetiredCodexGpt54ModelIdsMigration } from "./154-repair-retired-codex-gpt-5-4-model-ids.js";
155
155
  import { moveFrontModelConfigToVoiceMigration } from "./155-move-front-model-config-to-voice.js";
156
156
  import { extractWorkspaceMcpJsonMigration } from "./156-extract-workspace-mcp-json.js";
157
+ import { addDesktopFilesLauncherMigration } from "./157-add-desktop-files-launcher.js";
157
158
  import { migrateToWorkspaceVolumeMigration } from "./migrate-to-workspace-volume.js";
158
159
  import type { WorkspaceMigration } from "./types.js";
159
160
 
@@ -323,4 +324,5 @@ export const WORKSPACE_MIGRATIONS: WorkspaceMigration[] = [
323
324
  repairRetiredCodexGpt54ModelIdsMigration,
324
325
  moveFrontModelConfigToVoiceMigration,
325
326
  extractWorkspaceMcpJsonMigration,
327
+ addDesktopFilesLauncherMigration,
326
328
  ];