@oddessentials/agent-guild 0.32.0 → 0.33.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/README.md +37 -2
- package/bin/agent-guild.mjs +8 -3
- package/package.json +1 -1
- package/src/manager/autostart.mjs +492 -0
- package/src/manager/main.mjs +15 -2
- package/src/manager/server.mjs +11 -0
- package/src/manager/session.mjs +27 -2
- package/src/manager/shell-env.mjs +53 -13
- package/src/manager/uninstall.mjs +7 -2
- package/src/manager/usage.mjs +116 -10
- package/web/app.js +87 -6
- package/web/index.html +6 -0
- package/web/layout.js +36 -0
- package/web/styles.css +7 -2
- package/web/terminal-controls.js +7 -25
package/src/manager/server.mjs
CHANGED
|
@@ -115,6 +115,8 @@ export function createManagerServer({
|
|
|
115
115
|
extraHosts = [],
|
|
116
116
|
extraOrigins = [],
|
|
117
117
|
remoteAccess = null,
|
|
118
|
+
/** Starting the manager at sign-in, or null where it is not offered. */
|
|
119
|
+
autostart = null,
|
|
118
120
|
/** In-memory when omitted, so a test server never reads the user's notes file. */
|
|
119
121
|
notes = createNotesStore(),
|
|
120
122
|
folderOpener = createFolderOpener({ resolveCwd: (cwd) => manager.resolveCwd(cwd) }),
|
|
@@ -276,6 +278,15 @@ export function createManagerServer({
|
|
|
276
278
|
return sendJson(res, 202, { remoteAccess: operation });
|
|
277
279
|
}
|
|
278
280
|
|
|
281
|
+
if (autostart && route === '/autostart' && method === 'GET') {
|
|
282
|
+
return sendJson(res, 200, { autostart: await autostart.describe() });
|
|
283
|
+
}
|
|
284
|
+
if (autostart && route === '/autostart' && method === 'PUT') {
|
|
285
|
+
const { enabled } = await readJsonBody(req);
|
|
286
|
+
if (typeof enabled !== 'boolean') throw new HttpError(400, 'enabled must be true or false', 'bad_request');
|
|
287
|
+
return sendJson(res, 200, { autostart: await autostart.set(enabled) });
|
|
288
|
+
}
|
|
289
|
+
|
|
279
290
|
if (route === '/info' && method === 'GET') {
|
|
280
291
|
selfUpdate?.refresh().catch(() => {});
|
|
281
292
|
return sendJson(res, 200, {
|
package/src/manager/session.mjs
CHANGED
|
@@ -23,6 +23,10 @@ const REPORTING_STATES = new Set(['pending', 'active', 'unavailable', 'setup_req
|
|
|
23
23
|
const SHELL_EVENTS = new Set(['start', 'waiting', 'asked', 'background', 'end', 'running', 'reset']);
|
|
24
24
|
const MAX_SHELLS = 256;
|
|
25
25
|
const MAX_ENDED_TASKS = 256;
|
|
26
|
+
// Keys and reports a terminal sends as escape sequences: X10 mouse reports (three raw bytes after ESC [ M), other CSI
|
|
27
|
+
// and SS3 sequences, OSC and DCS strings (to their end, or the next ESC, so a large paste is scanned once), and ESC
|
|
28
|
+
// with one character (Alt+key). Bracketed paste markers are CSI, so pasted text is kept.
|
|
29
|
+
const ESCAPE_SEQUENCE = /\x1b\[M[\s\S]{3}|\x1b\[[0-?]*[ -/]*[@-~]|\x1bO[\s\S]|\x1b[\]P][^\x07\x1b]*(?:\x07|\x1b\\)?|\x1b[\s\S]?/g;
|
|
26
30
|
|
|
27
31
|
/**
|
|
28
32
|
* Colours reported to programs that query them (OSC 10/11/12), matching the
|
|
@@ -91,6 +95,7 @@ export class Session extends EventEmitter {
|
|
|
91
95
|
this.reportingTimeoutMs = opts.reportingTimeoutMs ?? 30000;
|
|
92
96
|
this.reporting = REPORTING_STATES.has(opts.reporting?.state) ? { state: opts.reporting.state, reason: opts.reporting.reason ?? null } : null;
|
|
93
97
|
this._reportingTimer = null;
|
|
98
|
+
this._typedSinceEnter = false;
|
|
94
99
|
this.shellDisplayDelayMs = opts.shellDisplayDelayMs ?? 600;
|
|
95
100
|
this.shells = new Map();
|
|
96
101
|
this._shellSeq = 0;
|
|
@@ -309,12 +314,12 @@ export class Session extends EventEmitter {
|
|
|
309
314
|
|
|
310
315
|
input(data) {
|
|
311
316
|
this.write(data);
|
|
312
|
-
if (this.reporting?.state === 'pending' && !this._reportingTimer && typeof data === 'string' &&
|
|
317
|
+
if (this.reporting?.state === 'pending' && !this._reportingTimer && typeof data === 'string' && this._sendsTypedLine(data)) {
|
|
313
318
|
this._reportingTimer = setTimeout(() => {
|
|
314
319
|
if (this.reporting?.state !== 'pending' || this.status !== 'running') return;
|
|
315
320
|
this.reporting = {
|
|
316
321
|
state: 'unavailable',
|
|
317
|
-
reason:
|
|
322
|
+
reason: `No report from ${this.provider.tool}'s hooks yet. They report once a prompt is sent; that can wait while ${this.provider.tool} signs in or sets up. If a prompt was sent and this stays, its hooks may be turned off, restricted by an administrator, or not trusted for this folder.`,
|
|
318
323
|
};
|
|
319
324
|
this._changed();
|
|
320
325
|
}, this.reportingTimeoutMs);
|
|
@@ -322,6 +327,26 @@ export class Session extends EventEmitter {
|
|
|
322
327
|
}
|
|
323
328
|
}
|
|
324
329
|
|
|
330
|
+
/**
|
|
331
|
+
* Whether this input sends a line with something typed on it, the way a
|
|
332
|
+
* prompt is sent. An Enter on an empty line, or after only keys that send
|
|
333
|
+
* escape sequences (arrows in a menu, focus and mouse reports), is not a
|
|
334
|
+
* prompt; nor is one after Ctrl+C or Ctrl+U, which clear the line. Text
|
|
335
|
+
* may arrive one key at a time, so what was typed carries over.
|
|
336
|
+
*/
|
|
337
|
+
_sendsTypedLine(data) {
|
|
338
|
+
const keys = data.replace(ESCAPE_SEQUENCE, '');
|
|
339
|
+
let sent = false;
|
|
340
|
+
for (const ch of keys) {
|
|
341
|
+
if (ch === '\r' || ch === '\n') {
|
|
342
|
+
sent ||= this._typedSinceEnter;
|
|
343
|
+
this._typedSinceEnter = false;
|
|
344
|
+
} else if (ch === '\x03' || ch === '\x15') this._typedSinceEnter = false;
|
|
345
|
+
else if (ch >= ' ' && ch !== '\x7f') this._typedSinceEnter = true;
|
|
346
|
+
}
|
|
347
|
+
return sent;
|
|
348
|
+
}
|
|
349
|
+
|
|
325
350
|
_reply(data) {
|
|
326
351
|
this.write(data);
|
|
327
352
|
}
|
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
// Recover the user's real PATH on macOS and Linux.
|
|
1
|
+
// Recover the user's real PATH and locale on macOS and Linux.
|
|
2
2
|
//
|
|
3
3
|
// An app started from Finder, the Dock, or a login item does not inherit the
|
|
4
4
|
// PATH that the user's shell profile builds (Homebrew, nvm, ~/.local/bin, ...),
|
|
5
5
|
// which is exactly where CLI coding tools usually live. Like VS Code, we ask
|
|
6
6
|
// the user's login shell for its PATH and merge it in.
|
|
7
|
+
//
|
|
8
|
+
// launchd starts a sign-in manager with no locale at all, where Terminal.app
|
|
9
|
+
// would set LANG to the region's UTF-8 locale. Without one, tmux writes
|
|
10
|
+
// non-ASCII characters as underscores, so a macOS manager with no locale
|
|
11
|
+
// takes Terminal.app's, and there a locale the login shell exports wins.
|
|
7
12
|
|
|
13
|
+
import fs from 'node:fs';
|
|
8
14
|
import { execFile, spawn, spawnSync } from 'node:child_process';
|
|
9
15
|
import path from 'node:path';
|
|
10
16
|
|
|
@@ -24,17 +30,31 @@ export function mergePathLists(primary, secondary, delimiter = path.delimiter) {
|
|
|
24
30
|
return out.join(delimiter);
|
|
25
31
|
}
|
|
26
32
|
|
|
27
|
-
/**
|
|
28
|
-
export
|
|
33
|
+
/** The variables that choose a locale, in the order they take effect. */
|
|
34
|
+
export const LOCALE_VARS = ['LC_ALL', 'LC_CTYPE', 'LANG'];
|
|
35
|
+
|
|
36
|
+
/** The non-empty `names` in `env` output captured between the two markers, or null without the markers. */
|
|
37
|
+
export function parseEnvOutput(stdout, names) {
|
|
29
38
|
if (typeof stdout !== 'string') return null;
|
|
30
39
|
const start = stdout.indexOf(START);
|
|
31
40
|
const end = stdout.indexOf(END, start);
|
|
32
41
|
if (start === -1 || end === -1) return null;
|
|
33
|
-
const
|
|
34
|
-
|
|
42
|
+
const found = {};
|
|
43
|
+
for (const line of stdout.slice(start + START.length, end).split(/\r?\n/)) {
|
|
44
|
+
const eq = line.indexOf('=');
|
|
45
|
+
const name = line.slice(0, eq);
|
|
46
|
+
if (eq > 0 && names.includes(name) && line.length > eq + 1 && !(name in found)) found[name] = line.slice(eq + 1);
|
|
47
|
+
}
|
|
48
|
+
return found;
|
|
35
49
|
}
|
|
36
50
|
|
|
37
|
-
|
|
51
|
+
/** Pull PATH out of `env` output captured between the two markers. */
|
|
52
|
+
export function parsePathFromEnvOutput(stdout) {
|
|
53
|
+
return parseEnvOutput(stdout, ['PATH'])?.PATH ?? null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** PATH and the locale variables the user's login shell exports, or null. */
|
|
57
|
+
export function loginShellEnv({ shell, env = process.env, timeoutMs = 8000 } = {}) {
|
|
38
58
|
if (process.platform === 'win32' || !shell) return null;
|
|
39
59
|
// Read the exported PATH from `env` rather than expanding $PATH: fish, for
|
|
40
60
|
// one, expands "$PATH" to a space-separated list.
|
|
@@ -42,10 +62,26 @@ export function loginShellPath({ shell = process.env.SHELL, timeoutMs = 8000 } =
|
|
|
42
62
|
encoding: 'utf8',
|
|
43
63
|
timeout: timeoutMs,
|
|
44
64
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
45
|
-
env: { ...
|
|
65
|
+
env: { ...env, AGENT_GUILD_RESOLVING_ENV: '1' },
|
|
46
66
|
});
|
|
47
67
|
if (result.error) return null;
|
|
48
|
-
return
|
|
68
|
+
return parseEnvOutput(result.stdout, ['PATH', ...LOCALE_VARS]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function readAppleLocale() {
|
|
72
|
+
const result = spawnSync('/usr/bin/defaults', ['read', '-g', 'AppleLocale'], { encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
73
|
+
return result.error ? null : result.stdout;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The locale Terminal.app gives its shells: the system region's UTF-8 locale,
|
|
78
|
+
* or en_US.UTF-8 when macOS has none for it. `AppleLocale` may carry a script
|
|
79
|
+
* (`zh-Hans_CN`) and options (`en_US@rg=gbzzzz`), which locale names do not.
|
|
80
|
+
*/
|
|
81
|
+
export function macLocale({ read = readAppleLocale, exists = (name) => fs.existsSync(path.join('/usr/share/locale', name)) } = {}) {
|
|
82
|
+
const region = String(read() || '').trim().split('@')[0].replace(/-[A-Za-z]+(?=_)/, '');
|
|
83
|
+
const name = `${region}.UTF-8`;
|
|
84
|
+
return /^[A-Za-z]+_[A-Za-z]+$/.test(region) && exists(name) ? name : 'en_US.UTF-8';
|
|
49
85
|
}
|
|
50
86
|
|
|
51
87
|
/**
|
|
@@ -59,11 +95,15 @@ export function trimPathExt(env, platform = process.platform) {
|
|
|
59
95
|
return env;
|
|
60
96
|
}
|
|
61
97
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (
|
|
98
|
+
/** `platform`, `env`, `shellEnv` and `locale` are replaceable in tests. */
|
|
99
|
+
export function resolveBaseEnv({ platform = process.platform, env: source = process.env, shellEnv = loginShellEnv, locale = macLocale } = {}) {
|
|
100
|
+
const env = trimPathExt({ ...source }, platform);
|
|
101
|
+
// Set before asking the shell, so macOS's /etc/zprofile keeps it rather than putting its C.UTF-8 in its place.
|
|
102
|
+
if (platform === 'darwin' && !LOCALE_VARS.some((name) => env[name])) env.LANG = locale();
|
|
103
|
+
if (env.AGENT_GUILD_SKIP_SHELL_ENV === '1') return env;
|
|
104
|
+
const shell = shellEnv({ shell: env.SHELL, env }) || {};
|
|
105
|
+
if (shell.PATH) env.PATH = mergePathLists(shell.PATH, env.PATH);
|
|
106
|
+
if (platform === 'darwin') for (const name of LOCALE_VARS) if (shell[name]) env[name] = shell[name];
|
|
67
107
|
return env;
|
|
68
108
|
}
|
|
69
109
|
|
|
@@ -68,8 +68,11 @@ function launchChain(launcher) {
|
|
|
68
68
|
return chain;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
/** Deletes a file or folder, and a link as the link itself even when its target is already gone. */
|
|
72
|
+
export function removeFile(file) {
|
|
73
|
+
// Some Node releases (24.13.0 among them) silently keep a dangling link that rmSync is asked to force.
|
|
74
|
+
if (fs.lstatSync(file, { throwIfNoEntry: false })?.isSymbolicLink()) fs.unlinkSync(file);
|
|
75
|
+
else fs.rmSync(file, { recursive: true, force: true, maxRetries: 3 });
|
|
73
76
|
}
|
|
74
77
|
|
|
75
78
|
export function runPlan(
|
|
@@ -111,6 +114,8 @@ export function runPlan(
|
|
|
111
114
|
} catch (err) {
|
|
112
115
|
throw Object.assign(err, { file });
|
|
113
116
|
}
|
|
117
|
+
// Never report a removal that did not happen.
|
|
118
|
+
if (exists(file)) throw Object.assign(new Error('it is still there'), { file });
|
|
114
119
|
};
|
|
115
120
|
const prune = (dir) => {
|
|
116
121
|
for (const name of fs.readdirSync(dir)) {
|
package/src/manager/usage.mjs
CHANGED
|
@@ -139,7 +139,7 @@ export async function fetchClaudeUsage({ accessToken, plan = null, version = nul
|
|
|
139
139
|
},
|
|
140
140
|
signal: AbortSignal.timeout(10000),
|
|
141
141
|
});
|
|
142
|
-
if (!res.ok) throw httpUsageError(res
|
|
142
|
+
if (!res.ok) throw await httpUsageError(res, 'sign in again in Claude Code');
|
|
143
143
|
const body = await res.json();
|
|
144
144
|
const windows = [];
|
|
145
145
|
for (const [key, label] of [['five_hour', '5-hour'], ['seven_day', '7-day'], ['seven_day_opus', '7-day Opus'], ['seven_day_sonnet', '7-day Sonnet']]) {
|
|
@@ -210,7 +210,7 @@ export async function fetchCodexUsage({ accessToken, accountId = null, fetchImpl
|
|
|
210
210
|
const headers = { Authorization: `Bearer ${accessToken}`, Accept: 'application/json', 'User-Agent': 'agent-guild' };
|
|
211
211
|
if (accountId) headers['ChatGPT-Account-Id'] = accountId;
|
|
212
212
|
const res = await fetchImpl(CODEX_USAGE_URL, { headers, signal: AbortSignal.timeout(10000) });
|
|
213
|
-
if (!res.ok) throw httpUsageError(res
|
|
213
|
+
if (!res.ok) throw await httpUsageError(res, 'sign in again in Codex CLI');
|
|
214
214
|
const body = await res.json();
|
|
215
215
|
const windows = codexWindows(body?.rate_limit ?? body?.rateLimit);
|
|
216
216
|
// Limits metered apart from the plan's main windows, one entry per
|
|
@@ -279,10 +279,94 @@ export async function commandUsage({ command, args = [] }, env, platform = proce
|
|
|
279
279
|
return { plan: body.plan ? String(body.plan).slice(0, 40) : null, windows };
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
282
|
+
// JWTs, API keys, bearer credentials and other long opaque strings never reach a message or the log.
|
|
283
|
+
const SECRET_LIKE = /\beyJ[\w-]+\.[\w-]+(?:\.[\w-]*)?|\b(?:sk|rk|pk)-[\w-]{8,}|\bBearer\s+\S+|[\w+/=-]{40,}/gi;
|
|
284
|
+
|
|
285
|
+
/** One line of at most `max` characters from a response body, without markup or anything credential-like. */
|
|
286
|
+
export function excerpt(text, max = 200) {
|
|
287
|
+
if (typeof text !== 'string') return '';
|
|
288
|
+
return text
|
|
289
|
+
.replace(/<(script|style)\b[\s\S]*?<\/\1\s*>/gi, ' ')
|
|
290
|
+
.replace(/<[^>]*>/g, ' ')
|
|
291
|
+
.replace(SECRET_LIKE, '[redacted]')
|
|
292
|
+
.replace(/\s+/g, ' ')
|
|
293
|
+
.trim()
|
|
294
|
+
.slice(0, max);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** The error message a JSON body carries, in the shapes the vendors use, else null. */
|
|
298
|
+
function jsonMessage(text) {
|
|
299
|
+
let body;
|
|
300
|
+
try { body = JSON.parse(text); } catch { return null; }
|
|
301
|
+
const error = body?.error;
|
|
302
|
+
const message = firstText(error?.message, typeof error === 'string' ? error : null, body?.error_description, body?.detail, body?.message);
|
|
303
|
+
return message ? excerpt(message, 120) || null : null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** How much of a failed reply is read: enough for any error message or the start of a challenge page. */
|
|
307
|
+
export const ERROR_BODY_LIMIT = 16 * 1024;
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The start of a reply's body as text, at most about `limit` bytes, the rest
|
|
311
|
+
* left unread. A reply-like object without a body stream is read with text().
|
|
312
|
+
*/
|
|
313
|
+
export async function readStart(res, limit = ERROR_BODY_LIMIT) {
|
|
314
|
+
const reader = res.body?.getReader?.();
|
|
315
|
+
if (!reader) return typeof res.text === 'function' ? res.text() : '';
|
|
316
|
+
const decoder = new TextDecoder();
|
|
317
|
+
let text = '';
|
|
318
|
+
let size = 0;
|
|
319
|
+
try {
|
|
320
|
+
while (size < limit) {
|
|
321
|
+
const { done, value } = await reader.read();
|
|
322
|
+
if (done) break;
|
|
323
|
+
size += value.byteLength;
|
|
324
|
+
text += decoder.decode(value, { stream: true });
|
|
325
|
+
}
|
|
326
|
+
} finally {
|
|
327
|
+
// Ends the download of whatever is left; the result does not matter.
|
|
328
|
+
reader.cancel().catch(() => {});
|
|
329
|
+
}
|
|
330
|
+
return (text + decoder.decode()).slice(0, limit);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The error for a failed usage request. A 401 means the token was refused;
|
|
335
|
+
* a 403 that Cloudflare answered means the request was blocked before the
|
|
336
|
+
* token was looked at, usually because of the network it came from, so it
|
|
337
|
+
* gets no sign-in advice. `detail` is what the manager log records.
|
|
338
|
+
*/
|
|
339
|
+
async function httpUsageError(res, signInHint) {
|
|
340
|
+
const { status } = res;
|
|
341
|
+
const header = (name) => { try { return res.headers?.get?.(name) ?? null; } catch { return null; } };
|
|
342
|
+
let text = '';
|
|
343
|
+
try { text = await readStart(res); } catch { /* the body is optional */ }
|
|
344
|
+
const server = header('server');
|
|
345
|
+
const detail = { status, server: excerpt(server, 80) || null, ray: excerpt(header('cf-ray'), 80) || null, body: excerpt(text) };
|
|
346
|
+
const error = (message, extra = {}) => Object.assign(new UsageError(message), { detail }, extra);
|
|
347
|
+
if (status === 401) return error(`usage endpoint refused the sign-in (HTTP 401); ${signInHint}`);
|
|
348
|
+
if (status === 403) {
|
|
349
|
+
const blocked = header('cf-mitigated') !== null || (/cloudflare/i.test(server ?? '') && /html/i.test(header('content-type') ?? ''));
|
|
350
|
+
if (blocked) return error('usage endpoint blocked this network (HTTP 403), not a sign-in problem; usually a VPN, proxy or exit node, and it clears by itself');
|
|
351
|
+
const message = jsonMessage(text);
|
|
352
|
+
return error(`usage endpoint refused access (HTTP 403${message ? `: ${message}` : ''}); if this continues, ${signInHint}`);
|
|
353
|
+
}
|
|
354
|
+
if (status === 429) return error('usage endpoint is rate limiting requests; retrying later', { rateLimited: true });
|
|
355
|
+
return error(`usage endpoint answered HTTP ${status}`);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* What the manager log adds to a failed lookup's message, which already
|
|
360
|
+
* names the HTTP status: the reply's headers and body for a vendor failure,
|
|
361
|
+
* '' when there is nothing more to say, and null for a failure that needs
|
|
362
|
+
* no log entry, such as no sign-in.
|
|
363
|
+
*/
|
|
364
|
+
function failureDetail(err) {
|
|
365
|
+
if (err.detail) {
|
|
366
|
+
const { server, ray, body } = err.detail;
|
|
367
|
+
return [server && `server=${server}`, ray && `cf-ray=${ray}`, body && `body="${body}"`].filter(Boolean).join(' ');
|
|
368
|
+
}
|
|
369
|
+
return err instanceof UsageError || err.notSignedIn ? null : '';
|
|
286
370
|
}
|
|
287
371
|
|
|
288
372
|
// ---- monitor ----------------------------------------------------------------
|
|
@@ -295,15 +379,19 @@ export class UsageMonitor {
|
|
|
295
379
|
* @param {Function} [opts.fetchImpl]
|
|
296
380
|
* @param {number} [opts.ttlMs]
|
|
297
381
|
* @param {object} [opts.readers] credential readers, replaceable in tests
|
|
382
|
+
* @param {(line: string) => void} [opts.log] where vendor and network failures are recorded
|
|
298
383
|
*/
|
|
299
|
-
constructor({ registry, env = process.env, platform = process.platform, fetchImpl = fetch, ttlMs = USAGE_TTL_MS, readers = {} } = {}) {
|
|
384
|
+
constructor({ registry, env = process.env, platform = process.platform, fetchImpl = fetch, ttlMs = USAGE_TTL_MS, readers = {}, log = console.warn } = {}) {
|
|
300
385
|
this.registry = registry;
|
|
301
386
|
this.env = env;
|
|
302
387
|
this.platform = platform;
|
|
303
388
|
this.fetchImpl = fetchImpl;
|
|
304
389
|
this.ttlMs = ttlMs;
|
|
305
390
|
this.readers = { claude: readClaudeCredentials, codex: readCodexCredentials, ...readers };
|
|
391
|
+
this.log = log;
|
|
306
392
|
this.cache = new Map();
|
|
393
|
+
/** Account key → the failure last logged for it, so a failure that persists is logged once and its end once. */
|
|
394
|
+
this.logged = new Map();
|
|
307
395
|
}
|
|
308
396
|
|
|
309
397
|
/** Snapshots for every account of every provider that has a usage source. */
|
|
@@ -322,7 +410,8 @@ export class UsageMonitor {
|
|
|
322
410
|
const now = Date.now();
|
|
323
411
|
if (entry?.inflight) return entry.inflight;
|
|
324
412
|
if (entry && now - entry.at < entry.ttl) return Promise.resolve(entry.snapshot);
|
|
325
|
-
const inflight = this._fetch(provider, account).then((snapshot) => {
|
|
413
|
+
const inflight = this._fetch(provider, account).then(({ snapshot, failure }) => {
|
|
414
|
+
this._record(`${provider.id}/${account.id}`, snapshot.error, failure);
|
|
326
415
|
const ttl = snapshot.rateLimited ? RATE_LIMITED_TTL_MS : this.ttlMs;
|
|
327
416
|
this.cache.set(key, { snapshot, at: Date.now(), ttl });
|
|
328
417
|
return snapshot;
|
|
@@ -331,6 +420,20 @@ export class UsageMonitor {
|
|
|
331
420
|
return inflight;
|
|
332
421
|
}
|
|
333
422
|
|
|
423
|
+
/** Log a vendor or network failure when it starts or changes, and once when lookups succeed again. */
|
|
424
|
+
_record(key, error, failure) {
|
|
425
|
+
const previous = this.logged.get(key);
|
|
426
|
+
if (failure !== null) {
|
|
427
|
+
if (previous === error) return;
|
|
428
|
+
this.logged.set(key, error);
|
|
429
|
+
this.log(`[usage] ${key}: ${error}${failure ? ` (${failure})` : ''}`);
|
|
430
|
+
} else if (previous !== undefined) {
|
|
431
|
+
this.logged.delete(key);
|
|
432
|
+
if (!error) this.log(`[usage] ${key}: usage lookups work again`);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/** Resolves to `{ snapshot, failure }`: the snapshot for clients and, for a vendor or network failure, its log detail (else null). */
|
|
334
437
|
async _fetch(provider, account) {
|
|
335
438
|
const base = { providerId: provider.id, accountId: account.id, plan: null, windows: [], credits: null, signedIn: null, fetchedAt: new Date().toISOString(), error: null };
|
|
336
439
|
const env = { ...this.env, ...provider.env, ...account.env };
|
|
@@ -352,10 +455,13 @@ export class UsageMonitor {
|
|
|
352
455
|
} else {
|
|
353
456
|
result = await commandUsage(provider.usage, env, this.platform);
|
|
354
457
|
}
|
|
355
|
-
return { ...base, signedIn, ...result };
|
|
458
|
+
return { snapshot: { ...base, signedIn, ...result }, failure: null };
|
|
356
459
|
} catch (err) {
|
|
357
460
|
const message = err instanceof UsageError ? err.message : `usage check failed: ${err.name === 'TimeoutError' ? 'timed out' : err.message}`;
|
|
358
|
-
return {
|
|
461
|
+
return {
|
|
462
|
+
snapshot: { ...base, signedIn: err.notSignedIn ? false : signedIn, error: message, ...(err.rateLimited ? { rateLimited: true } : {}) },
|
|
463
|
+
failure: failureDetail(err),
|
|
464
|
+
};
|
|
359
465
|
}
|
|
360
466
|
}
|
|
361
467
|
}
|
package/web/app.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { SOUNDS, MAX_ALERT_AGE_MS, playOnce, rearmSound, managerLossWatcher, stopWatcher, updateWatcher } from './alerts.js';
|
|
5
5
|
import { TerminalCopy } from './terminal-copy.js';
|
|
6
6
|
import { TerminalControls, bindTerminalViewport } from './terminal-controls.js';
|
|
7
|
-
import { topbarInline, dockMode, clampDockWidth, stageBesideDock, splitMode, clampRatio, bindSplitter, DOCK_MIN, SPLIT_RATIO_MIN } from './layout.js';
|
|
7
|
+
import { topbarInline, dockMode, clampDockWidth, stageBesideDock, splitMode, clampRatio, bindSplitter, bindVisibleViewport, DOCK_MIN, SPLIT_RATIO_MIN } from './layout.js';
|
|
8
8
|
import { highlightParts, rankRepos, recentFirst, remember, repoForOrigin, repoKey } from './repo-search.js';
|
|
9
9
|
import { createActivityFavicon, isSessionWorking } from './activity-favicon.js';
|
|
10
10
|
import { createRemoteAccessUI } from './remote-access.js';
|
|
@@ -299,6 +299,69 @@ function alertSound(name, key, at = Date.now(), fresh = () => true, related = []
|
|
|
299
299
|
});
|
|
300
300
|
}
|
|
301
301
|
|
|
302
|
+
const AUTOSTART_NOTE = 'Starts the session manager in the background when you sign in to the computer running Agent Guild. The page does not open.';
|
|
303
|
+
let autostartRequest = 0;
|
|
304
|
+
let autostartChanging = false;
|
|
305
|
+
|
|
306
|
+
/** What the entry did at the last sign-in it ran at, or '' while it is off. */
|
|
307
|
+
function autostartRun(autostart) {
|
|
308
|
+
if (!autostart?.available || !autostart.enabled) return '';
|
|
309
|
+
const run = autostart.lastRun;
|
|
310
|
+
if (!run) return 'Has not run at a sign-in yet.';
|
|
311
|
+
const at = new Date(run.at).toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' });
|
|
312
|
+
if (run.outcome === 'started') return `Last ran at sign-in on ${at} and started the session manager.`;
|
|
313
|
+
if (run.outcome === 'running') return `Last ran at sign-in on ${at}; the session manager was already running.`;
|
|
314
|
+
if (run.outcome === 'starting') return `Starting the session manager for the sign-in on ${at}…`;
|
|
315
|
+
return `Last ran at sign-in on ${at}, but the session manager did not start.${autostart.log ? ` See ${autostart.log}.` : ''}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The manager's sign-in setting; hidden when the manager has none. */
|
|
319
|
+
function renderAutostart(autostart) {
|
|
320
|
+
$('autostart-choice').hidden = !autostart;
|
|
321
|
+
$('autostart').checked = Boolean(autostart?.enabled);
|
|
322
|
+
$('autostart').disabled = !autostart?.available;
|
|
323
|
+
$('autostart-note').textContent = autostart?.reason || autostart?.note || AUTOSTART_NOTE;
|
|
324
|
+
const run = autostartRun(autostart);
|
|
325
|
+
$('autostart-run').textContent = run;
|
|
326
|
+
$('autostart-run').hidden = !run;
|
|
327
|
+
$('autostart-run').dataset.outcome = autostart?.enabled ? autostart.lastRun?.outcome ?? 'none' : '';
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async function loadAutostart({ afterChange = false } = {}) {
|
|
331
|
+
if (!state.connected || (autostartChanging && !afterChange)) return;
|
|
332
|
+
const request = ++autostartRequest;
|
|
333
|
+
$('autostart').disabled = true;
|
|
334
|
+
try {
|
|
335
|
+
const { autostart } = await api('GET', '/autostart');
|
|
336
|
+
if (request === autostartRequest) renderAutostart(autostart);
|
|
337
|
+
} catch (err) {
|
|
338
|
+
if (request !== autostartRequest) return;
|
|
339
|
+
if (err instanceof AuthError) showAuth(err.message);
|
|
340
|
+
else if (err.code === 'not_found') renderAutostart(null);
|
|
341
|
+
else renderAutostart({ available: false, enabled: false, reason: `Could not read the startup setting: ${err.message}` });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function changeAutostart(input) {
|
|
346
|
+
if (autostartChanging) return;
|
|
347
|
+
autostartChanging = true;
|
|
348
|
+
++autostartRequest;
|
|
349
|
+
const enabled = input.checked;
|
|
350
|
+
input.disabled = true;
|
|
351
|
+
try {
|
|
352
|
+
renderAutostart((await api('PUT', '/autostart', { enabled })).autostart);
|
|
353
|
+
} catch (err) {
|
|
354
|
+
if (err instanceof AuthError) showAuth(err.message);
|
|
355
|
+
else {
|
|
356
|
+
toast(err.message);
|
|
357
|
+
// An OS operation can partly succeed. Read back what actually happened.
|
|
358
|
+
await loadAutostart({ afterChange: true });
|
|
359
|
+
}
|
|
360
|
+
} finally {
|
|
361
|
+
autostartChanging = false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
302
365
|
function changeSound(input) {
|
|
303
366
|
save(SOUND_KEY, input.checked ? 'on' : null);
|
|
304
367
|
prepareSounds();
|
|
@@ -3704,9 +3767,14 @@ async function loadBranches({ resume = false } = {}) {
|
|
|
3704
3767
|
}
|
|
3705
3768
|
for (const branch of result.branches) request.rows.set(branch.name, branch);
|
|
3706
3769
|
request.nextPage = result.nextPage;
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
|
|
3770
|
+
// First loads display each page. A refresh keeps the previous list until
|
|
3771
|
+
// every page arrives, so later-page rows and the reading position survive.
|
|
3772
|
+
// `received` means the displayed value belongs to this listing generation.
|
|
3773
|
+
if (!request.value || request.received || request.nextPage === null) {
|
|
3774
|
+
request.received = true;
|
|
3775
|
+
request.value = { ...result, branches: [...request.rows.values()],
|
|
3776
|
+
defaultBranch: request.defaultBranch, metadataError: request.metadataError };
|
|
3777
|
+
}
|
|
3710
3778
|
renderGitHubViews();
|
|
3711
3779
|
}
|
|
3712
3780
|
} catch (err) {
|
|
@@ -3774,7 +3842,7 @@ function renderBranches({ preserveAnchor = true } = {}) {
|
|
|
3774
3842
|
const branches = (value?.branches ?? []).filter((b) => b.name.toLocaleLowerCase().includes(query)).sort((a, b) =>
|
|
3775
3843
|
Number(b.name === value.defaultBranch) - Number(a.name === value.defaultBranch)
|
|
3776
3844
|
|| a.name.localeCompare(b.name) || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
3777
|
-
const complete = Boolean(
|
|
3845
|
+
const complete = Boolean(slot?.received && value?.nextPage === null);
|
|
3778
3846
|
const count = value?.branches.length ?? 0;
|
|
3779
3847
|
let status = !value ? (slot?.error ? 'Branches could not be loaded.' : 'Loading branches…')
|
|
3780
3848
|
: query ? `${branches.length} of ${count} loaded branches match.`
|
|
@@ -3879,7 +3947,17 @@ function setPickerOpen(open) {
|
|
|
3879
3947
|
input.setAttribute('aria-expanded', String(open));
|
|
3880
3948
|
$('github-repo-list').hidden = !open;
|
|
3881
3949
|
if (!open) input.removeAttribute('aria-activedescendant');
|
|
3882
|
-
else
|
|
3950
|
+
else {
|
|
3951
|
+
fitPickerList();
|
|
3952
|
+
renderPickerList();
|
|
3953
|
+
}
|
|
3954
|
+
}
|
|
3955
|
+
|
|
3956
|
+
// The list stays inside the visible part of the dock, so no option sits under an on-screen keyboard.
|
|
3957
|
+
function fitPickerList() {
|
|
3958
|
+
if (!githubPick.open) return;
|
|
3959
|
+
const room = $('dock').getBoundingClientRect().bottom - $('github-repo').getBoundingClientRect().bottom - 12;
|
|
3960
|
+
$('github-repo-list').style.setProperty('--picker-room', `${Math.max(120, Math.floor(room))}px`);
|
|
3883
3961
|
}
|
|
3884
3962
|
|
|
3885
3963
|
function highlighted(text, query) {
|
|
@@ -5041,6 +5119,7 @@ const terminalControls = new TerminalControls({
|
|
|
5041
5119
|
},
|
|
5042
5120
|
});
|
|
5043
5121
|
bindTerminalViewport($('terminal-panel'), $('terminal-controls'));
|
|
5122
|
+
bindVisibleViewport($('dock'), 'dock', { fitted: fitPickerList });
|
|
5044
5123
|
|
|
5045
5124
|
const terminalCopy = new TerminalCopy({
|
|
5046
5125
|
opener: $('panel-copy'), dialog: $('terminal-copy'),
|
|
@@ -5984,6 +6063,7 @@ $('settings-menu').addEventListener('change', (e) => {
|
|
|
5984
6063
|
else if (e.target.name === 'theme') changeTheme(e.target);
|
|
5985
6064
|
else if (e.target.name === 'sound') changeSound(e.target);
|
|
5986
6065
|
else if (e.target.name === 'voice') changeVoice(e.target);
|
|
6066
|
+
else if (e.target.name === 'autostart') changeAutostart(e.target);
|
|
5987
6067
|
});
|
|
5988
6068
|
/**
|
|
5989
6069
|
* Runs `opened` once a menu shows and `closed` once it hides. Chrome skips the toggle event of a menu
|
|
@@ -6003,6 +6083,7 @@ function returnFocus(menu, invoker) {
|
|
|
6003
6083
|
if ((!at || at === document.body || menu.contains(at)) && invoker?.checkVisibility?.()) invoker.focus();
|
|
6004
6084
|
}
|
|
6005
6085
|
onMenu($('settings-menu'), (menu) => {
|
|
6086
|
+
loadAutostart();
|
|
6006
6087
|
placeMenu(menu, $('settings'));
|
|
6007
6088
|
menu.querySelector('input:checked')?.focus();
|
|
6008
6089
|
}, (menu) => returnFocus(menu, $('settings')));
|
package/web/index.html
CHANGED
|
@@ -46,6 +46,12 @@
|
|
|
46
46
|
<legend>Sound</legend>
|
|
47
47
|
<label class="choice" title="Plays when the session manager stops, remains unavailable after a brief recovery check, or a new Agent Guild update is discovered. Click or type on this page to allow playback."><input id="sound" type="checkbox" name="sound"><span class="sound-alerts">Alert sounds</span></label>
|
|
48
48
|
</fieldset>
|
|
49
|
+
<fieldset id="autostart-choice" hidden>
|
|
50
|
+
<legend>Startup</legend>
|
|
51
|
+
<label class="choice"><input id="autostart" type="checkbox" name="autostart"><span class="autostart">Launch at sign-in</span></label>
|
|
52
|
+
<p id="autostart-note" class="voice-note"></p>
|
|
53
|
+
<p id="autostart-run" class="voice-note autostart-run" role="status" hidden></p>
|
|
54
|
+
</fieldset>
|
|
49
55
|
<fieldset id="voice-choice" hidden>
|
|
50
56
|
<legend>Voice</legend>
|
|
51
57
|
<label class="choice"><input id="voice" type="checkbox" name="voice"><span class="voice-input">Voice input</span></label>
|
package/web/layout.js
CHANGED
|
@@ -73,3 +73,39 @@ export function bindSplitter(handle, { axis = () => 'x', start, move, end, step
|
|
|
73
73
|
end();
|
|
74
74
|
});
|
|
75
75
|
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Publishes the part of the window an on-screen keyboard leaves visible as
|
|
79
|
+
* `--<name>-viewport-top`, `--<name>-viewport-bottom` and `--<name>-viewport-height`
|
|
80
|
+
* on `element` while `shown()` holds. iOS Safari keeps fixed elements at their full
|
|
81
|
+
* height under its keyboard and pans the page beneath them, so they must fit themselves.
|
|
82
|
+
* `fitted(height)` runs after each update, with null while the element is not shown.
|
|
83
|
+
*/
|
|
84
|
+
export function bindVisibleViewport(element, name, { shown = () => !element.hidden, watch = [element], fitted = () => {} } = {}) {
|
|
85
|
+
const viewport = window.visualViewport;
|
|
86
|
+
const props = ['top', 'bottom', 'height'].map((edge) => `--${name}-viewport-${edge}`);
|
|
87
|
+
let frame;
|
|
88
|
+
const update = () => {
|
|
89
|
+
if (!shown()) {
|
|
90
|
+
for (const prop of props) element.style.removeProperty(prop);
|
|
91
|
+
return fitted(null);
|
|
92
|
+
}
|
|
93
|
+
// Magnification belongs to the browser; it must not resize the layout.
|
|
94
|
+
if (viewport && Math.abs(viewport.scale - 1) > 0.01) return;
|
|
95
|
+
const top = viewport?.offsetTop || 0;
|
|
96
|
+
const height = viewport?.height ?? innerHeight;
|
|
97
|
+
element.style.setProperty(props[0], `${top}px`);
|
|
98
|
+
element.style.setProperty(props[1], `${Math.max(0, innerHeight - top - height)}px`);
|
|
99
|
+
element.style.setProperty(props[2], `${height}px`);
|
|
100
|
+
fitted(height);
|
|
101
|
+
};
|
|
102
|
+
const schedule = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(update); };
|
|
103
|
+
viewport?.addEventListener('resize', schedule);
|
|
104
|
+
viewport?.addEventListener('scroll', schedule);
|
|
105
|
+
addEventListener('resize', schedule);
|
|
106
|
+
addEventListener('pageshow', schedule);
|
|
107
|
+
const observer = new MutationObserver(schedule);
|
|
108
|
+
for (const watched of watch) observer.observe(watched, { attributes: true, attributeFilter: ['hidden'] });
|
|
109
|
+
schedule();
|
|
110
|
+
return schedule;
|
|
111
|
+
}
|
package/web/styles.css
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
--icon-sun: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'%3E%3Ccircle cx='12' cy='12' r='4.2'/%3E%3Cpath d='M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M5.3 18.7l1.6-1.6M17.1 6.9l1.6-1.6'/%3E%3C/svg%3E");
|
|
24
24
|
--icon-moon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'%3E%3Cpath d='M19.5 14.6A8 8 0 0 1 9.4 4.5a8 8 0 1 0 10.1 10.1z'/%3E%3C/svg%3E");
|
|
25
25
|
--icon-mic: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='9' y='3' width='6' height='11' rx='3'/%3E%3Cpath d='M5.5 11a6.5 6.5 0 0 0 13 0M12 17.5V21'/%3E%3C/svg%3E");
|
|
26
|
+
--icon-power: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round'%3E%3Cpath d='M12 3v8.5'/%3E%3Cpath d='M7 6.2a7.5 7.5 0 1 0 10 0'/%3E%3C/svg%3E");
|
|
26
27
|
--icon-sound: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 9.5h3.5L12 5.5v13l-4.5-4H4z'/%3E%3Cpath d='M15.5 9a4.2 4.2 0 0 1 0 6M18.3 6.5a8 8 0 0 1 0 11'/%3E%3C/svg%3E");
|
|
27
28
|
--icon-external:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 17L17 7M9 7h8v8'/%3E%3C/svg%3E");
|
|
28
29
|
--icon-cloud: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linejoin='round'%3E%3Cpath d='M7 18.5h10.5a4 4 0 0 0 .6-7.96A6 6 0 0 0 6.5 9.6 4.5 4.5 0 0 0 7 18.5z'/%3E%3C/svg%3E");
|
|
@@ -392,7 +393,10 @@ main { max-width: calc(1200px + var(--dock-space, 0px)); margin: 0 auto; padding
|
|
|
392
393
|
.settings-menu .choice > .mode-dark::before { mask-image: var(--icon-moon); }
|
|
393
394
|
.settings-menu .choice > .sound-alerts::before { mask-image: var(--icon-sound); }
|
|
394
395
|
.settings-menu .choice > .voice-input::before { mask-image: var(--icon-mic); }
|
|
396
|
+
.settings-menu .choice > .autostart::before { mask-image: var(--icon-power); }
|
|
395
397
|
.voice-note { margin: 0 8px 6px; max-width: 26ch; color: var(--muted); font-size: 0.75rem; line-height: 1.35; }
|
|
398
|
+
.autostart-run { overflow-wrap: anywhere; }
|
|
399
|
+
.autostart-run[data-outcome="failed"] { color: var(--danger); }
|
|
396
400
|
.notes-open::before { mask-image: var(--icon-notes); }
|
|
397
401
|
.notes-open[aria-pressed="true"], .github-toggle[aria-pressed="true"] { border-color: var(--accent); color: var(--accent); }
|
|
398
402
|
.menu-button::after { margin-left: 0.45em; transform: rotate(90deg); transition: transform 0.15s var(--ease); }
|
|
@@ -872,7 +876,7 @@ a.news-link:visited { color: var(--muted); }
|
|
|
872
876
|
.github-combo input:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
|
873
877
|
.github-combo input::placeholder { font-weight: 400; }
|
|
874
878
|
.github-repo-list {
|
|
875
|
-
position: absolute; z-index: 5; left: 0; right: 0; top: calc(100% + 4px); max-height: min(26rem, 60dvh); overflow: auto;
|
|
879
|
+
position: absolute; z-index: 5; left: 0; right: 0; top: calc(100% + 4px); max-height: min(26rem, 60dvh, var(--picker-room, 26rem)); overflow: auto;
|
|
876
880
|
margin: 0; padding: 4px; list-style: none; background: var(--surface); color: var(--text);
|
|
877
881
|
border: 1px solid var(--border); border-radius: var(--radius); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.28);
|
|
878
882
|
}
|
|
@@ -961,7 +965,8 @@ a.btn.github-out::after {
|
|
|
961
965
|
.toast .toast-action { margin-left: 12px; padding: 2px 10px; background: transparent; box-shadow: none; color: inherit; border-color: currentColor; }
|
|
962
966
|
|
|
963
967
|
.dock {
|
|
964
|
-
|
|
968
|
+
/* Above an on-screen keyboard, which the page publishes as --dock-viewport-*. */
|
|
969
|
+
position: fixed; top: max(var(--topbar-h, 56px), var(--dock-viewport-top, 0px)); right: 0; bottom: var(--dock-viewport-bottom, 0px); z-index: 12; width: var(--dock-w, 420px); max-width: 100vw;
|
|
965
970
|
display: flex; flex-direction: column; background: var(--surface); color: var(--text);
|
|
966
971
|
border-left: 1px solid var(--border); box-shadow: -16px 0 48px rgba(0, 0, 0, 0.25);
|
|
967
972
|
}
|