@oddessentials/agent-guild 1.27.1 → 1.28.0

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.
package/docs/api.md CHANGED
@@ -666,6 +666,7 @@ All paths are under `/api/v1`.
666
666
  | GET | `/yard-art` | | `{ yardArt }`: the Yard's art. `state` is `ready`, `missing`, `downloading` (with `received` bytes), `installing`, `failed` or `unavailable` (each with `error`), or `cancelled`; `size` is the pack's size in bytes. A checkout serves `web/yard/assets` and is always `ready`. `/yard/assets/` answers 404 until the art is `ready`. |
667
667
  | POST | `/yard-art/download` | | `202 { yardArt }`: starts the one download of the art pack named by the package, unless it is running or installed. Progress and the outcome arrive as `yard-art.updated`. |
668
668
  | POST | `/yard-art/cancel` | | `{ yardArt }`: stops the download for every client; the state becomes `cancelled`. |
669
+ | POST | `/companion/install` | | `201 { session }`: starts a session that downloads the latest Grove Companion, checks its signature, installs it and starts it. 400 `companion_unsupported` unless the manager runs on Windows or macOS; 409 `install_in_progress` while one runs. |
669
670
  | POST | `/yard/fireworks` | | `{ ok, pages }`: fireworks over the Yard of every open page, sent as `yard.fireworks`. `pages` is how many pages were listening. |
670
671
  | GET | `/changelog` | | `{ refreshing, okAt, error, releases }`: Agent Guild's own releases, newest first, each `{ version, url, publishedAt, sections: [{ title, changes }] }`, where `title` may be null and a change is a list of text runs. A `changelog.updated` event follows a refresh. |
671
672
  | GET | `/providers/:id/history?account=&limit=&cwd=&q=` | | `{ history }`: a History object for one account (default `default`). |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oddessentials/agent-guild",
3
- "version": "1.27.1",
3
+ "version": "1.28.0",
4
4
  "description": "Launch and watch AI coding-assistant terminal sessions from one local web page. A bundled session manager owns the terminals so the page can close and reconnect.",
5
5
  "mcpName": "ai.oddessentials/agent-guild",
6
6
  "license": "AGPL-3.0-only",
@@ -0,0 +1,284 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { buildSpawnSpec } from './command-resolver.mjs';
8
+
9
+ export const COMPANION_RUNNER = fileURLToPath(import.meta.url);
10
+ export const WINDOWS_PUBLISHER = 'CN=Peter Palles, O=Peter Palles, L=Pottsville, S=pa, C=US';
11
+ export const MACOS_TEAM = 'H3FY62W58W';
12
+ const RELEASES = 'https://github.com/oddessentials/agent-guild-companions/releases';
13
+ const INSTALLERS = { win32: 'Grove-Companion-windows-setup.exe', darwin: 'Grove-Companion-macos.dmg' };
14
+ const APP = 'Grove Companion.app';
15
+ const BUNDLE_ID = 'com.oddessentials.agentguild.companion';
16
+ const TRANSACTION = '.grove-companion-install';
17
+ // Darwin's O_EXLOCK is not exposed by Node's fs.constants.
18
+ const MACOS_EXCLUSIVE_LOCK = 0x20;
19
+
20
+ export const companionSupported = (platform) => Object.hasOwn(INSTALLERS, platform);
21
+
22
+ export function companionSpawnSpec(env, platform) {
23
+ return buildSpawnSpec(process.execPath, [COMPANION_RUNNER], env, platform);
24
+ }
25
+
26
+ const refuse = (message) => Object.assign(new Error(message), { refused: true });
27
+ const output = (result) => `${result.stdout || ''}${result.stderr || ''}`;
28
+ const runCommand = (file, args, options = {}) => spawnSync(file, args, { encoding: 'utf8', ...options });
29
+ const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
30
+ const entry = (file) => fs.lstatSync(file, { throwIfNoEntry: false });
31
+
32
+ function cleanup(file, log) {
33
+ try { fs.rmSync(file, { recursive: true, force: true }); }
34
+ catch (err) { log(`Could not clean up ${file}: ${err.message}`); }
35
+ }
36
+
37
+ function isWritable(dir) {
38
+ try {
39
+ fs.accessSync(dir, fs.constants.W_OK);
40
+ return true;
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ async function latestRelease(fetchImpl) {
47
+ const response = await fetchImpl(`${RELEASES}/latest`, { method: 'HEAD', redirect: 'follow' });
48
+ const tag = /\/releases\/tag\/(v\d+\.\d+\.\d+)$/.exec(response.url || '')?.[1];
49
+ if (!response.ok || !tag) throw refuse('Could not find the latest Grove Companion release.');
50
+ return tag;
51
+ }
52
+
53
+ async function download(fetchImpl, tag, name) {
54
+ const response = await fetchImpl(`${RELEASES}/download/${tag}/${name}`, { redirect: 'follow' });
55
+ if (!response.ok) throw refuse(`Could not download ${name} from ${tag} (HTTP ${response.status}).`);
56
+ return Buffer.from(await response.arrayBuffer());
57
+ }
58
+
59
+ function listedHash(sums, name) {
60
+ for (const line of sums.split(/\r?\n/)) {
61
+ const match = /^([0-9a-f]{64})\s+\*?(.+)$/.exec(line.trim());
62
+ if (match?.[2] === name) return match[1];
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function installWindows(file, { env, run, log }) {
68
+ const powershell = path.win32.join(env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows', 'System32/WindowsPowerShell/v1.0/powershell.exe');
69
+ const script = `$s = Get-AuthenticodeSignature -LiteralPath '${file.replaceAll("'", "''")}'; @{ status = [string]$s.Status; subject = [string]$s.SignerCertificate.Subject; timestamped = [bool]$s.TimeStamperCertificate } | ConvertTo-Json -Compress`;
70
+ const result = run(powershell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(script, 'utf16le').toString('base64')]);
71
+ let signature = null;
72
+ try { signature = JSON.parse(result.stdout); } catch {}
73
+ if (signature?.status !== 'Valid' || !signature.timestamped || signature.subject !== WINDOWS_PUBLISHER) {
74
+ throw refuse(`The installer is not signed and timestamped by ${WINDOWS_PUBLISHER} (${signature ? `${signature.status}, ${signature.subject || 'no signer'}` : 'no signature read'}), so it was not run.`);
75
+ }
76
+ log('Installing, then starting the Grove Companion…');
77
+ const install = run(file, ['/S', '/R']);
78
+ if (install.status !== 0) throw refuse(`The installer exited with code ${install.status}.`);
79
+ }
80
+
81
+ function verifyMac(app, run, notarized = false) {
82
+ if (!entry(app)?.isDirectory()) throw refuse(`${app} is not a Grove Companion app bundle.`);
83
+ if (run('/usr/bin/codesign', ['--verify', '--deep', '--strict', app]).status !== 0) throw refuse('The app’s signature does not verify, so it was not installed.');
84
+ const details = run('/usr/bin/codesign', ['-dv', app]);
85
+ const team = /^TeamIdentifier=(\S+)$/m.exec(output(details))?.[1];
86
+ const id = /^Identifier=(\S+)$/m.exec(output(details))?.[1];
87
+ if (details.status !== 0 || team !== MACOS_TEAM) throw refuse(`The app is signed by team ${team || 'none'}, not ${MACOS_TEAM}, so it was not installed.`);
88
+ if (id !== BUNDLE_ID) throw refuse(`${app} is not the Grove Companion, so it was not replaced or installed.`);
89
+ if (notarized) {
90
+ const assess = run('/usr/sbin/spctl', ['--assess', '--type', 'exec', '-vv', app]);
91
+ if (assess.status !== 0 || !/source=Notarized Developer ID/.test(output(assess))) throw refuse('macOS does not accept the app as notarized, so it was not installed.');
92
+ }
93
+ }
94
+
95
+ function verifies(app, run) {
96
+ try {
97
+ verifyMac(app, run);
98
+ return true;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ function installedCompanion(app, run, repairing) {
105
+ const read = entry(app)?.isDirectory() && run('/usr/bin/plutil', ['-extract', 'CFBundleIdentifier', 'raw', '-o', '-', path.join(app, 'Contents', 'Info.plist')]);
106
+ const id = read && read.status === 0 ? read.stdout.trim() : null;
107
+ if (id === BUNDLE_ID || (repairing && id === null && entry(app)?.isDirectory())) return;
108
+ throw refuse(`${app} is not the Grove Companion, so it was left unchanged. Move it out of the way and try again.`);
109
+ }
110
+
111
+ function macFolder(home, applications, writable) {
112
+ const folders = [...new Set([applications, path.join(home, 'Applications')])];
113
+ const installed = folders.filter((folder) => entry(path.join(folder, APP)) || entry(path.join(folder, TRANSACTION, 'previous.app')));
114
+ if (installed.length > 1) throw refuse('Grove Companion installations exist in both Applications folders. Keep one installation before trying again.');
115
+ const folder = installed[0] || (writable(applications) ? applications : path.join(home, 'Applications'));
116
+ if (entry(folder) && (!entry(folder).isDirectory() || !writable(folder))) throw refuse(`Cannot replace the Grove Companion in ${folder}. Check its permissions before trying again.`);
117
+ fs.mkdirSync(folder, { recursive: true });
118
+ return folder;
119
+ }
120
+
121
+ function lockMac(folder) {
122
+ const file = path.join(folder, '.grove-companion-install.lock');
123
+ try {
124
+ return fs.openSync(file, fs.constants.O_CREAT | fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK | MACOS_EXCLUSIVE_LOCK, 0o644);
125
+ } catch (err) {
126
+ if (['EAGAIN', 'EWOULDBLOCK'].includes(err.code)) throw refuse('The Grove Companion is already being installed. Try again when it finishes.');
127
+ throw refuse(`Could not lock the Grove Companion installation in ${folder}: ${err.message}`);
128
+ }
129
+ }
130
+
131
+ function recoverMac(target, work, run, log) {
132
+ if (!entry(work)) return;
133
+ if (!entry(work).isDirectory() || fs.readdirSync(work).some((name) => !name.startsWith('.') && !['staged.app', 'previous.app', 'discard'].includes(name))) {
134
+ throw refuse(`Unrecognized installation files in ${work}. They were left unchanged.`);
135
+ }
136
+ for (const scratch of ['staged.app', 'discard']) fs.rmSync(path.join(work, scratch), { recursive: true, force: true });
137
+ const previous = path.join(work, 'previous.app');
138
+ if (entry(previous)) {
139
+ if (!entry(target)) {
140
+ installedCompanion(previous, run, true);
141
+ fs.renameSync(previous, target);
142
+ log('Restored the previous Grove Companion after an interrupted installation.');
143
+ } else if (verifies(target, run)) {
144
+ fs.rmSync(previous, { recursive: true });
145
+ } else {
146
+ return;
147
+ }
148
+ }
149
+ fs.rmSync(work, { recursive: true });
150
+ }
151
+
152
+ function quitMac(target, run, wait, now) {
153
+ const pattern = '^' + (target + '/Contents/MacOS/').replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
154
+ const check = (file, signal = []) => {
155
+ const result = run(file, [...signal, '-f', pattern]);
156
+ if (result.error || ![0, 1].includes(result.status)) throw refuse('Could not confirm that the Grove Companion has stopped. Quit it and try again.');
157
+ return result.status;
158
+ };
159
+ const stopped = () => {
160
+ const deadline = now() + 5000;
161
+ while (check('/usr/bin/pgrep') === 0) {
162
+ const remaining = deadline - now();
163
+ if (remaining <= 0) return false;
164
+ wait(Math.min(100, remaining));
165
+ }
166
+ return true;
167
+ };
168
+ check('/usr/bin/pkill');
169
+ if (stopped()) return;
170
+ check('/usr/bin/pkill', ['-KILL']);
171
+ if (!stopped()) throw refuse('The Grove Companion is still running. Quit it and try again; the existing app was not replaced.');
172
+ }
173
+
174
+ function replaceMac(app, folder, { run, log, wait, now }) {
175
+ const target = path.join(folder, APP);
176
+ const work = path.join(folder, TRANSACTION);
177
+ const staged = path.join(work, 'staged.app');
178
+ const previous = path.join(work, 'previous.app');
179
+ const discard = path.join(work, 'discard');
180
+ const repairing = Boolean(entry(previous));
181
+ if (entry(target)) installedCompanion(target, run, repairing);
182
+ fs.mkdirSync(work, { recursive: true });
183
+ let committed = false, quit = false;
184
+ try {
185
+ log(`Copying the Grove Companion to ${folder}…`);
186
+ if (run('/usr/bin/ditto', [app, staged]).status !== 0) throw refuse(`Could not copy the app to ${folder}.`);
187
+ verifyMac(staged, run, true);
188
+ quitMac(target, run, wait, now);
189
+ quit = true;
190
+ if (entry(target)) {
191
+ if (repairing) {
192
+ fs.mkdirSync(discard, { recursive: true });
193
+ fs.renameSync(target, path.join(discard, APP));
194
+ } else {
195
+ fs.renameSync(target, previous);
196
+ }
197
+ }
198
+ try { fs.renameSync(staged, target); }
199
+ catch (err) {
200
+ if (entry(previous)) {
201
+ try { fs.renameSync(previous, target); }
202
+ catch { throw refuse(`Could not replace or restore the Grove Companion. The previous app is preserved at ${previous}.`); }
203
+ }
204
+ throw err;
205
+ }
206
+ committed = true;
207
+ } finally {
208
+ if (!committed) {
209
+ cleanup(staged, log);
210
+ cleanup(discard, log);
211
+ if (quit && entry(target)) run('/usr/bin/open', [target]);
212
+ if (!entry(previous)) cleanup(work, log);
213
+ }
214
+ }
215
+ cleanup(work, log);
216
+ if (run('/usr/bin/open', [target]).status !== 0) throw refuse('The Grove Companion was installed but could not be started.');
217
+ }
218
+
219
+ function installMac(image, { workspace, folder, run, log, wait, now }) {
220
+ const mount = path.join(workspace.dir, 'image');
221
+ fs.mkdirSync(mount);
222
+ const attach = run('/usr/bin/hdiutil', ['attach', '-nobrowse', '-readonly', '-noautoopen', '-mountpoint', mount, image]);
223
+ if (attach.status !== 0) throw refuse(`Could not open the disk image (hdiutil exited with code ${attach.status}).`);
224
+ workspace.mounted = true;
225
+ try {
226
+ const app = path.join(mount, APP);
227
+ verifyMac(app, run, true);
228
+ replaceMac(app, folder, { run, log, wait, now });
229
+ } finally {
230
+ try {
231
+ workspace.mounted = run('/usr/bin/hdiutil', ['detach', mount]).status !== 0
232
+ && run('/usr/bin/hdiutil', ['detach', '-force', mount]).status !== 0;
233
+ } catch {}
234
+ if (workspace.mounted) log(`Could not detach the disk image. Temporary files were kept at ${workspace.dir}.`);
235
+ }
236
+ }
237
+
238
+ export async function installCompanion({
239
+ platform = process.platform, env = process.env, fetch: fetchImpl = globalThis.fetch, run = runCommand, log = console.log,
240
+ home = os.homedir(), tmp = os.tmpdir(), applications = '/Applications', writable = isWritable, wait = sleep, now = () => performance.now(),
241
+ acquireLock = lockMac,
242
+ } = {}) {
243
+ const name = INSTALLERS[platform];
244
+ if (!name) {
245
+ log('The Grove Companion is for Windows and macOS.');
246
+ return 1;
247
+ }
248
+ let workspace, mac;
249
+ try {
250
+ if (platform === 'darwin') {
251
+ const folder = macFolder(home, applications, writable);
252
+ const fd = acquireLock(folder);
253
+ const lockedRun = (file, args) => run(file, args, { stdio: ['ignore', 'pipe', 'pipe', fd] });
254
+ mac = { folder, fd, run: lockedRun };
255
+ recoverMac(path.join(folder, APP), path.join(folder, TRANSACTION), lockedRun, log);
256
+ }
257
+ workspace = { dir: fs.mkdtempSync(path.join(tmp, 'grove-companion-')), mounted: false };
258
+ const { dir } = workspace;
259
+ log('Finding the latest Grove Companion…');
260
+ const tag = await latestRelease(fetchImpl);
261
+ log(`Downloading ${name} from ${tag}…`);
262
+ const [installer, sums] = await Promise.all([download(fetchImpl, tag, name), download(fetchImpl, tag, 'SHA256SUMS')]);
263
+ if (crypto.createHash('sha256').update(installer).digest('hex') !== listedHash(sums.toString('utf8'), name)) {
264
+ throw refuse(`${name} does not match the release’s SHA256SUMS, so it was not run.`);
265
+ }
266
+ const file = path.join(dir, name);
267
+ fs.writeFileSync(file, installer);
268
+ log('Checking its signature…');
269
+ if (platform === 'win32') installWindows(file, { env, run, log });
270
+ else installMac(file, { workspace, ...mac, log, wait, now });
271
+ log(`Installed the Grove Companion ${tag}.`);
272
+ return 0;
273
+ } catch (err) {
274
+ log(err.refused ? err.message : `Could not install the Grove Companion: ${err.message}`);
275
+ return 1;
276
+ } finally {
277
+ if (workspace && !workspace.mounted) cleanup(workspace.dir, log);
278
+ if (mac) fs.closeSync(mac.fd);
279
+ }
280
+ }
281
+
282
+ if (process.argv[1] && path.resolve(process.argv[1]) === COMPANION_RUNNER) {
283
+ installCompanion().then((code) => { process.exitCode = code; });
284
+ }
@@ -551,6 +551,7 @@ export function createManagerServer({
551
551
  const session = await manager.manageMultiplexer(multiplexerMatch[1], multiplexerMatch[2], multiplexerMatch[3], { path: body.path, force: body.force === true });
552
552
  return sendJson(res, 201, { session: session.toJSON() });
553
553
  }
554
+ if (route === '/companion/install' && method === 'POST') return sendJson(res, 201, { session: manager.installCompanion().toJSON() });
554
555
  const installMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/install$/);
555
556
  if (installMatch && method === 'POST') {
556
557
  const body = await readJsonBody(req);
@@ -17,6 +17,7 @@ import { SELF_PROVIDER } from './self-update.mjs';
17
17
  import { GITHUB_PROVIDER, dropsFromCloneEnv, parseRepo } from './github.mjs';
18
18
  import { pathIdentity } from './multiplexer-paths.mjs';
19
19
  import { cleanResumeId } from './resume-id.mjs';
20
+ import { companionSpawnSpec, companionSupported } from './companion.mjs';
20
21
  import { handleHookEvent } from '../report/hook-event.mjs';
21
22
 
22
23
  export const MAX_SESSIONS = 32;
@@ -100,6 +101,7 @@ export class SessionManager extends EventEmitter {
100
101
  this.restoring = false;
101
102
  this.pendingCards = new Map();
102
103
  this.multiplexerOperations = new Set();
104
+ this.companionInstall = null;
103
105
  this.multiplexerStarts = new Map();
104
106
  this.pendingRestore = null;
105
107
  this.multiplexerRefresh = null;
@@ -545,7 +547,7 @@ export class SessionManager extends EventEmitter {
545
547
 
546
548
  installsRunningFor(providerId) {
547
549
  let n = 0;
548
- for (const s of [...this.sessions.values(), ...this.exiting]) if (s.status === 'running' && s.task === 'install' && !s.multiplexerInstall && s.provider.id === providerId) n++;
550
+ for (const s of [...this.sessions.values(), ...this.exiting]) if (s.status === 'running' && s.task === 'install' && !s.multiplexerInstall && !s.companionInstall && s.provider.id === providerId) n++;
549
551
  return n;
550
552
  }
551
553
 
@@ -632,6 +634,23 @@ export class SessionManager extends EventEmitter {
632
634
  }
633
635
  }
634
636
 
637
+ installCompanion() {
638
+ if (!companionSupported(this.registry.platform)) throw httpError(400, 'The Grove Companion is for Windows and macOS.', 'companion_unsupported');
639
+ const provider = this.registry.get('shell');
640
+ if (!provider) throw httpError(404, 'Unknown provider', 'unknown_provider');
641
+ if (this.companionInstall) throw httpError(409, 'The Grove Companion is already being installed.', 'install_in_progress');
642
+ const session = this._spawn({
643
+ provider, spawnSpec: this._companionSpec(), cwd: os.homedir(), name: 'Install Grove Companion', task: 'install', companionInstall: true,
644
+ });
645
+ this.companionInstall = session;
646
+ session.exited.then(() => { if (this.companionInstall === session) this.companionInstall = null; });
647
+ return session;
648
+ }
649
+
650
+ _companionSpec() {
651
+ return companionSpawnSpec(this.baseEnv, this.registry.platform);
652
+ }
653
+
635
654
  _restorePending() {
636
655
  if (this.pendingRestore) {
637
656
  this.pendingRestoreAgain = true;
@@ -756,7 +775,7 @@ export class SessionManager extends EventEmitter {
756
775
  _spawn({
757
776
  provider, description = this.registry.describe(provider), spawnSpec, cwd, cols, rows, name, defaultName, named = false, resume = null, task = null, installKind = null, installPath = null, account = null,
758
777
  extraEnv = null, dropEnv = null, clone = null, reporting = null, multiplexer = null,
759
- id = newId(), reportToken = crypto.randomBytes(16).toString('hex'), createdAt, context = null, multiplexerInstall = null,
778
+ id = newId(), reportToken = crypto.randomBytes(16).toString('hex'), createdAt, context = null, multiplexerInstall = null, companionInstall = false,
760
779
  }) {
761
780
  this._assertCanSpawn();
762
781
  let env = this._sessionEnv({ id, reportToken, provider, account, extraEnv, dropEnv });
@@ -797,7 +816,8 @@ export class SessionManager extends EventEmitter {
797
816
  });
798
817
  session.on('warning', (msg) => console.warn(`[session ${id}] ${msg}`));
799
818
  session.multiplexerInstall = multiplexerInstall;
800
- if (task === 'install' && !multiplexerInstall) {
819
+ session.companionInstall = companionInstall;
820
+ if (task === 'install' && !multiplexerInstall && !companionInstall) {
801
821
  session.on('exit', () => {
802
822
  this.registry.finishInstall(provider.id, { exitCode: session.exitCode, kind: installKind, path: installPath }).catch(() => {});
803
823
  });
package/web/app.js CHANGED
@@ -202,7 +202,7 @@ function holdToast(held) {
202
202
  const firstClick = (run) => (e) => { if (e.detail < 2) run(); };
203
203
 
204
204
  /** Buttons that act through the session manager. While it cannot be reached they are unavailable rather than left to fail. */
205
- const MANAGER_ACTIONS = '.provider :is(.new, .existing, .install, .update), .session-card :is(.resume, .rename, .stop, .remove), .history-resume, #panel-stop';
205
+ const MANAGER_ACTIONS = '.provider :is(.new, .existing, .install, .update), .session-card :is(.resume, .rename, .stop, .remove), .history-resume, #panel-stop, #yard-install';
206
206
 
207
207
  function paintManagerActions(root = document) {
208
208
  for (const button of root.querySelectorAll(MANAGER_ACTIONS)) button.disabled = !state.connected;
@@ -7943,6 +7943,7 @@ function connectEvents() {
7943
7943
  state.launcher = typeof msg.launcher === 'string' ? msg.launcher : null;
7944
7944
  state.folderOpener = msg.folderOpener || null;
7945
7945
  state.platform = msg.platform || null;
7946
+ $('yard-install').hidden = !['win32', 'darwin'].includes(state.platform);
7946
7947
  state.remoteAccessUI?.setAvailable(msg.remoteAccess);
7947
7948
  state.remoteAccessUI?.signInsUpdated();
7948
7949
  state.glassesLinkUI?.setAvailable(msg.remoteAccess);
@@ -8604,6 +8605,20 @@ onMenu($('yard-play-menu'), (menu) => {
8604
8605
  $('yard-play-menu').addEventListener('click', (e) => {
8605
8606
  if (e.target.closest('a')) closeMenu(e.currentTarget);
8606
8607
  });
8608
+ $('yard-install').addEventListener('click', async (e) => {
8609
+ const button = e.currentTarget;
8610
+ button.disabled = true;
8611
+ try {
8612
+ const { session } = await api('POST', '/companion/install');
8613
+ upsertSession(session);
8614
+ openPanel(session.id);
8615
+ } catch (err) {
8616
+ if (err instanceof AuthError) return showAuth(err.message);
8617
+ toast(err.message, 8000);
8618
+ } finally {
8619
+ button.disabled = !state.connected;
8620
+ }
8621
+ });
8607
8622
  new MutationObserver(() => closeMenu($('yard-play-menu'))).observe(document.documentElement, { attributes: true, attributeFilter: ['data-view', 'data-skin', 'data-layout'] });
8608
8623
  window.addEventListener('scroll', () => placeMenu($('yard-play-menu'), $('yard-play-more')), { passive: true });
8609
8624
  for (const menu of [$('manager-menu'), $('panel-sessions'), $('yard-play-menu')]) menu.addEventListener('keydown', moveInMenu);
package/web/index.html CHANGED
@@ -141,7 +141,7 @@
141
141
 
142
142
  <section id="yard" hidden aria-label="Yard">
143
143
  <div class="yard-main">
144
- <div class="yard-heading"><div><p class="yard-eyebrow">AGENT GUILD · YOUR WORK, ALIVE</p><h2 id="yard-title">The Guild Yard</h2><p id="yard-subtitle"></p></div><div class="yard-heading-side"><span id="yard-summary" class="yard-summary" role="status"></span><div class="yard-play"><svg class="yard-play-defs" aria-hidden="true" focusable="false"><defs><linearGradient id="yard-play-gold" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff3cf"/><stop offset=".3" stop-color="#f6dc9c"/><stop offset=".6" stop-color="#e8c47c"/><stop offset=".8" stop-color="#b8893d"/><stop offset="1" stop-color="#f1d08a"/></linearGradient><radialGradient id="yard-play-core" cx=".5" cy=".38" r=".7"><stop offset="0" stop-color="#2a2150"/><stop offset="1" stop-color="#090b16"/></radialGradient><filter id="yard-play-blur" x="-60%" y="-60%" width="220%" height="220%"><feGaussianBlur stdDeviation="4"/></filter><g id="yard-play-badge"><circle cx="24" cy="24" r="14.5" fill="url(#yard-play-gold)"/><circle cx="24" cy="24" r="12.8" fill="url(#yard-play-core)"/><circle cx="24" cy="24" r="10.5" fill="none" stroke="#e8c47c" stroke-opacity=".35"/></g><g id="yard-play-orb-art"><circle class="yard-play-pulse" cx="24" cy="24" r="17" fill="#8f74ff" opacity=".6" filter="url(#yard-play-blur)"/><g class="yard-play-ring"><ellipse cx="24" cy="24" rx="22" ry="7" fill="none" stroke="url(#yard-play-gold)" stroke-width="1" opacity=".8" transform="rotate(-24 24 24)"/><circle cx="44" cy="15" r="1.4" fill="#fff3cf"/></g><use href="#yard-play-badge"/><path d="M20.5 17.5 L31 24 L20.5 30.5 Z" fill="url(#yard-play-gold)" stroke="#fff3cf" stroke-opacity=".5" stroke-width=".6" stroke-linejoin="round"/></g></defs></svg><span class="yard-play-burst" aria-hidden="true"></span><div class="yard-play-chip"><a id="yard-play" class="yard-play-link" href="https://survive.oddessentials.ai/" target="_blank" rel="noopener noreferrer" aria-label="Play Agent Guild: AI Revolt, free in your browser" title="Play Agent Guild: AI Revolt, free in your browser"><svg class="yard-play-orb" viewBox="0 0 48 48" aria-hidden="true" focusable="false"><use href="#yard-play-orb-art"/></svg><span class="yard-play-text"><span class="yard-play-title">Play<span class="yard-play-now"> now</span></span><span class="yard-play-sub">AI Revolt<span class="yard-play-sub-more"> · free in your browser</span></span></span><svg class="yard-play-chev" viewBox="0 0 24 16" aria-hidden="true" focusable="false"><path d="M3 2l5 6-5 6"/><path d="M10 2l5 6-5 6"/><path d="M17 2l5 6-5 6"/></svg></a><button id="yard-play-more" class="yard-play-more" type="button" popovertarget="yard-play-menu" aria-haspopup="menu" aria-label="Choose a game" title="Choose a game"><svg class="yard-play-orb yard-play-more-orb" viewBox="0 0 48 48" aria-hidden="true" focusable="false"><use href="#yard-play-orb-art"/></svg><svg class="yard-play-caret" viewBox="0 0 12 8" aria-hidden="true" focusable="false"><path d="M1.5 1.5L6 6.5l4.5-5"/></svg></button></div><div id="yard-play-menu" class="menu yard-play-menu" popover role="menu" aria-label="Games"><div class="yard-play-menu-frame"><a class="menu-item yard-play-game" role="menuitem" href="https://survive.oddessentials.ai/" target="_blank" rel="noopener noreferrer"><svg class="yard-play-game-icon" viewBox="0 0 48 48" aria-hidden="true" focusable="false"><use href="#yard-play-badge"/><g fill="url(#yard-play-gold)"><circle cx="24" cy="24" r="3"/><circle cx="24" cy="16.5" r="1.9"/><circle cx="30.5" cy="20.2" r="1.9"/><circle cx="30.5" cy="27.8" r="1.9"/><circle cx="24" cy="31.5" r="1.9"/><circle cx="17.5" cy="27.8" r="1.9"/><circle cx="17.5" cy="20.2" r="1.9"/></g></svg><span class="yard-play-game-text"><span class="yard-play-title">Survive the swarm</span><span class="yard-play-game-sub">AI Revolt</span></span></a><a class="menu-item yard-play-game" role="menuitem" href="https://towers.oddessentials.ai/" target="_blank" rel="noopener noreferrer"><svg class="yard-play-game-icon" viewBox="0 0 48 48" aria-hidden="true" focusable="false"><use href="#yard-play-badge"/><path d="M17 33v-2.5h2.2V22L16.5 19.5V15h3.2v2.6h2.7V15h3.2v2.6h2.7V15h3.2v4.5L28.8 22v8.5H31V33z" fill="url(#yard-play-gold)"/></svg><span class="yard-play-game-text"><span class="yard-play-title">Defend the towers</span><span class="yard-play-game-sub">Towers</span></span></a></div></div></div><div class="yard-play yard-play-solo"><svg class="yard-play-defs" aria-hidden="true" focusable="false"><defs><linearGradient id="yard-solo-gold" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff3cf"/><stop offset=".28" stop-color="#f6dc9c"/><stop offset=".55" stop-color="#e8c47c"/><stop offset=".78" stop-color="#b8893d"/><stop offset="1" stop-color="#f1d08a"/></linearGradient><linearGradient id="yard-solo-core" x1="0" y1=".15" x2="1" y2=".85"><stop offset="0" stop-color="#a83a0c"/><stop offset=".47" stop-color="#14090c"/><stop offset=".53" stop-color="#070b1a"/><stop offset="1" stop-color="#1a5cb8"/></linearGradient><filter id="yard-solo-blur" x="-60%" y="-60%" width="220%" height="220%"><feGaussianBlur stdDeviation="4"/></filter></defs></svg><span class="yard-play-burst" aria-hidden="true"></span><div class="yard-play-chip"><a id="yard-play-solo" class="yard-play-link" href="https://agforever.oddessentials.ai/" target="_blank" rel="noopener noreferrer" aria-label="Play Wizard Duels, free in your browser" title="Play Wizard Duels, free in your browser"><svg class="yard-play-orb" viewBox="0 0 48 48" aria-hidden="true" focusable="false"><g class="yard-play-pulse"><circle cx="20" cy="24" r="15" fill="#ff6a1f" opacity=".8" filter="url(#yard-solo-blur)"/><circle cx="28" cy="24" r="15" fill="#3f9dff" opacity=".8" filter="url(#yard-solo-blur)"/></g><g class="yard-play-ring"><ellipse cx="24" cy="24" rx="22" ry="7" fill="none" stroke="url(#yard-solo-gold)" stroke-width="1" opacity=".8" transform="rotate(-24 24 24)"/><circle cx="44" cy="15" r="1.4" fill="#bfe6ff"/><circle cx="4" cy="33" r="1.4" fill="#ffb45e"/></g><circle cx="24" cy="24" r="14.5" fill="url(#yard-solo-gold)"/><circle cx="24" cy="24" r="12.8" fill="url(#yard-solo-core)"/><circle cx="24" cy="24" r="10.5" fill="none" stroke="#e8c47c" stroke-opacity=".35"/><path d="M20.5 17.5 L31 24 L20.5 30.5 Z" fill="url(#yard-solo-gold)" stroke="#fff3cf" stroke-opacity=".5" stroke-width=".6" stroke-linejoin="round"/></svg><span class="yard-play-text"><span class="yard-play-title">Play<span class="yard-play-now"> now</span></span><span class="yard-play-sub">Wizard Duels<span class="yard-play-sub-more"> · free in your browser</span></span></span><svg class="yard-play-chev" viewBox="0 0 24 16" aria-hidden="true" focusable="false"><path d="M3 2l5 6-5 6"/><path d="M10 2l5 6-5 6"/><path d="M17 2l5 6-5 6"/></svg></a></div></div></div></div>
144
+ <div class="yard-heading"><div><p class="yard-eyebrow">AGENT GUILD · YOUR WORK, ALIVE</p><h2 id="yard-title">The Guild Yard</h2><p id="yard-subtitle"></p></div><div class="yard-heading-side"><span id="yard-summary" class="yard-summary" role="status"></span><div class="yard-play"><svg class="yard-play-defs" aria-hidden="true" focusable="false"><defs><linearGradient id="yard-play-gold" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff3cf"/><stop offset=".3" stop-color="#f6dc9c"/><stop offset=".6" stop-color="#e8c47c"/><stop offset=".8" stop-color="#b8893d"/><stop offset="1" stop-color="#f1d08a"/></linearGradient><radialGradient id="yard-play-core" cx=".5" cy=".38" r=".7"><stop offset="0" stop-color="#2a2150"/><stop offset="1" stop-color="#090b16"/></radialGradient><filter id="yard-play-blur" x="-60%" y="-60%" width="220%" height="220%"><feGaussianBlur stdDeviation="4"/></filter><g id="yard-play-badge"><circle cx="24" cy="24" r="14.5" fill="url(#yard-play-gold)"/><circle cx="24" cy="24" r="12.8" fill="url(#yard-play-core)"/><circle cx="24" cy="24" r="10.5" fill="none" stroke="#e8c47c" stroke-opacity=".35"/></g><g id="yard-play-orb-art"><circle class="yard-play-pulse" cx="24" cy="24" r="17" fill="#8f74ff" opacity=".6" filter="url(#yard-play-blur)"/><g class="yard-play-ring"><ellipse cx="24" cy="24" rx="22" ry="7" fill="none" stroke="url(#yard-play-gold)" stroke-width="1" opacity=".8" transform="rotate(-24 24 24)"/><circle cx="44" cy="15" r="1.4" fill="#fff3cf"/></g><use href="#yard-play-badge"/><path d="M20.5 17.5 L31 24 L20.5 30.5 Z" fill="url(#yard-play-gold)" stroke="#fff3cf" stroke-opacity=".5" stroke-width=".6" stroke-linejoin="round"/></g></defs></svg><span class="yard-play-burst" aria-hidden="true"></span><div class="yard-play-chip"><a id="yard-play" class="yard-play-link" href="https://survive.oddessentials.ai/" target="_blank" rel="noopener noreferrer" aria-label="Play Agent Guild: AI Revolt, free in your browser" title="Play Agent Guild: AI Revolt, free in your browser"><svg class="yard-play-orb" viewBox="0 0 48 48" aria-hidden="true" focusable="false"><use href="#yard-play-orb-art"/></svg><span class="yard-play-text"><span class="yard-play-title">Play<span class="yard-play-now"> now</span></span><span class="yard-play-sub">AI Revolt<span class="yard-play-sub-more"> · free in your browser</span></span></span><svg class="yard-play-chev" viewBox="0 0 24 16" aria-hidden="true" focusable="false"><path d="M3 2l5 6-5 6"/><path d="M10 2l5 6-5 6"/><path d="M17 2l5 6-5 6"/></svg></a><button id="yard-play-more" class="yard-play-more" type="button" popovertarget="yard-play-menu" aria-haspopup="menu" aria-label="Choose a game" title="Choose a game"><svg class="yard-play-orb yard-play-more-orb" viewBox="0 0 48 48" aria-hidden="true" focusable="false"><use href="#yard-play-orb-art"/></svg><svg class="yard-play-caret" viewBox="0 0 12 8" aria-hidden="true" focusable="false"><path d="M1.5 1.5L6 6.5l4.5-5"/></svg></button></div><div id="yard-play-menu" class="menu yard-play-menu" popover role="menu" aria-label="Games"><div class="yard-play-menu-frame"><a class="menu-item yard-play-game" role="menuitem" href="https://survive.oddessentials.ai/" target="_blank" rel="noopener noreferrer"><svg class="yard-play-game-icon" viewBox="0 0 48 48" aria-hidden="true" focusable="false"><use href="#yard-play-badge"/><g fill="url(#yard-play-gold)"><circle cx="24" cy="24" r="3"/><circle cx="24" cy="16.5" r="1.9"/><circle cx="30.5" cy="20.2" r="1.9"/><circle cx="30.5" cy="27.8" r="1.9"/><circle cx="24" cy="31.5" r="1.9"/><circle cx="17.5" cy="27.8" r="1.9"/><circle cx="17.5" cy="20.2" r="1.9"/></g></svg><span class="yard-play-game-text"><span class="yard-play-title">Survive the swarm</span><span class="yard-play-game-sub">AI Revolt</span></span></a><a class="menu-item yard-play-game" role="menuitem" href="https://towers.oddessentials.ai/" target="_blank" rel="noopener noreferrer"><svg class="yard-play-game-icon" viewBox="0 0 48 48" aria-hidden="true" focusable="false"><use href="#yard-play-badge"/><path d="M17 33v-2.5h2.2V22L16.5 19.5V15h3.2v2.6h2.7V15h3.2v2.6h2.7V15h3.2v4.5L28.8 22v8.5H31V33z" fill="url(#yard-play-gold)"/></svg><span class="yard-play-game-text"><span class="yard-play-title">Defend the towers</span><span class="yard-play-game-sub">Towers</span></span></a></div></div></div><div class="yard-play yard-play-solo"><svg class="yard-play-defs" aria-hidden="true" focusable="false"><defs><linearGradient id="yard-solo-gold" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#fff3cf"/><stop offset=".28" stop-color="#f6dc9c"/><stop offset=".55" stop-color="#e8c47c"/><stop offset=".78" stop-color="#b8893d"/><stop offset="1" stop-color="#f1d08a"/></linearGradient><linearGradient id="yard-solo-core" x1="0" y1=".15" x2="1" y2=".85"><stop offset="0" stop-color="#a83a0c"/><stop offset=".47" stop-color="#14090c"/><stop offset=".53" stop-color="#070b1a"/><stop offset="1" stop-color="#1a5cb8"/></linearGradient><filter id="yard-solo-blur" x="-60%" y="-60%" width="220%" height="220%"><feGaussianBlur stdDeviation="4"/></filter></defs></svg><span class="yard-play-burst" aria-hidden="true"></span><div class="yard-play-chip"><a id="yard-play-solo" class="yard-play-link" href="https://agforever.oddessentials.ai/" target="_blank" rel="noopener noreferrer" aria-label="Play Wizard Duels, free in your browser" title="Play Wizard Duels, free in your browser"><svg class="yard-play-orb" viewBox="0 0 48 48" aria-hidden="true" focusable="false"><g class="yard-play-pulse"><circle cx="20" cy="24" r="15" fill="#ff6a1f" opacity=".8" filter="url(#yard-solo-blur)"/><circle cx="28" cy="24" r="15" fill="#3f9dff" opacity=".8" filter="url(#yard-solo-blur)"/></g><g class="yard-play-ring"><ellipse cx="24" cy="24" rx="22" ry="7" fill="none" stroke="url(#yard-solo-gold)" stroke-width="1" opacity=".8" transform="rotate(-24 24 24)"/><circle cx="44" cy="15" r="1.4" fill="#bfe6ff"/><circle cx="4" cy="33" r="1.4" fill="#ffb45e"/></g><circle cx="24" cy="24" r="14.5" fill="url(#yard-solo-gold)"/><circle cx="24" cy="24" r="12.8" fill="url(#yard-solo-core)"/><circle cx="24" cy="24" r="10.5" fill="none" stroke="#e8c47c" stroke-opacity=".35"/><path d="M20.5 17.5 L31 24 L20.5 30.5 Z" fill="url(#yard-solo-gold)" stroke="#fff3cf" stroke-opacity=".5" stroke-width=".6" stroke-linejoin="round"/></svg><span class="yard-play-text"><span class="yard-play-title">Play<span class="yard-play-now"> now</span></span><span class="yard-play-sub">Wizard Duels<span class="yard-play-sub-more"> · free in your browser</span></span></span><svg class="yard-play-chev" viewBox="0 0 24 16" aria-hidden="true" focusable="false"><path d="M3 2l5 6-5 6"/><path d="M10 2l5 6-5 6"/><path d="M17 2l5 6-5 6"/></svg></a></div></div><button id="yard-install" class="yard-install" type="button" hidden aria-label="Install Agent Guild: Grove Companion for Windows or macOS" title="Install Agent Guild: Grove Companion for Windows or macOS"><img class="yard-install-dark" src="/skins/grove/ui/install-now-dark.svg" alt="" loading="lazy"><img class="yard-install-light" src="/skins/grove/ui/install-now-light.svg" alt="" loading="lazy"></button></div></div>
145
145
  <div id="yard-stage" class="yard-stage" role="region" tabindex="0" aria-label="Interactive yard. Select a provider or session using the roster, or select it in the scene. Drag to pan; scroll to zoom." aria-describedby="yard-help">
146
146
  <div id="yard-labels" class="yard-labels"></div>
147
147
  <div id="yard-offline" class="yard-swap-failure" hidden role="status"><span id="yard-offline-text"></span><button id="yard-offline-open" class="btn" type="button">Open Agent Guild</button></div>