@oddessentials/agent-guild 1.21.1 → 1.22.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/bin/agent-guild-report.mjs +5 -106
- package/bin/agent-guild.mjs +2 -0
- package/bin/native/darwin-arm64/agent-guild-report +0 -0
- package/bin/native/darwin-x64/agent-guild-report +0 -0
- package/bin/native/linux-arm64/agent-guild-report +0 -0
- package/bin/native/linux-x64/agent-guild-report +0 -0
- package/bin/native/win32-arm64/agent-guild-report.exe +0 -0
- package/bin/native/win32-x64/agent-guild-report.exe +0 -0
- package/bin/windows/arm64/agent-guild-start.exe +0 -0
- package/bin/windows/x64/agent-guild-start.exe +0 -0
- package/config/providers.default.json +4 -0
- package/docs/api.md +27 -5
- package/examples/terminal-ai-chat.json +27 -0
- package/node_modules/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-x64/conpty.node +0 -0
- package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
- package/package.json +2 -1
- package/src/manager/install-channels.mjs +3 -0
- package/src/manager/main.mjs +6 -2
- package/src/manager/mcp-connect.mjs +249 -0
- package/src/manager/providers.mjs +23 -4
- package/src/manager/report-shims.mjs +47 -3
- package/src/manager/server.mjs +31 -3
- package/src/manager/session-manager.mjs +36 -2
- package/src/manager/session.mjs +2 -1
- package/src/report/hook-event.mjs +40 -0
- package/src/report/transcripts.mjs +95 -0
- package/web/app.js +52 -3
- package/web/index.html +7 -1
- package/web/styles.css +2 -0
- package/web/yard/art.json +1 -1
- package/web/yard/renderer.js +110 -36
- package/web/yard/view.js +13 -3
|
@@ -22,6 +22,7 @@ import { MultiplexerRegistry } from './multiplexers.mjs';
|
|
|
22
22
|
import { reconcileHerdrPath } from './multiplexer-paths.mjs';
|
|
23
23
|
import { refreshLaunchers } from './launchers.mjs';
|
|
24
24
|
import { LaunchFlags, normalizeLaunchFlags } from './launch-flags.mjs';
|
|
25
|
+
import { MCP_MODES } from './mcp-connect.mjs';
|
|
25
26
|
|
|
26
27
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
27
28
|
export const DEFAULTS_FILE = path.resolve(here, '../../config/providers.default.json');
|
|
@@ -87,6 +88,8 @@ function normalizeChannels(raw) {
|
|
|
87
88
|
remove: stringList(raw.native.remove),
|
|
88
89
|
links: stringList(raw.native.links),
|
|
89
90
|
sharedWithNpm: raw.native.sharedWithNpm === true,
|
|
91
|
+
reinstall: raw.native.reinstall === true,
|
|
92
|
+
versionEnv: typeof raw.native.versionEnv === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(raw.native.versionEnv) ? raw.native.versionEnv : null,
|
|
90
93
|
};
|
|
91
94
|
}
|
|
92
95
|
if (raw.brew && typeof raw.brew === 'object') out.brew = { names: stringList(raw.brew.names), autoUpdates: raw.brew.autoUpdates === true };
|
|
@@ -171,6 +174,7 @@ function normalize(raw, platform, warnings) {
|
|
|
171
174
|
memory: ['claude', 'codex', 'grok'].includes(merged.memory) ? merged.memory : null,
|
|
172
175
|
instructions: ['claude', 'codex', 'grok', 'google'].includes(merged.instructions) ? merged.instructions : null,
|
|
173
176
|
reporting: REPORTING_MODES.has(merged.reporting) ? merged.reporting : null,
|
|
177
|
+
mcp: MCP_MODES.includes(merged.mcp) ? merged.mcp : null,
|
|
174
178
|
modelPattern: merged.modelPattern ? String(merged.modelPattern) : null,
|
|
175
179
|
args: Array.isArray(merged.args) ? merged.args.map(String) : [],
|
|
176
180
|
resumeArgs: Array.isArray(merged.resumeArgs) ? merged.resumeArgs.map(String) : [],
|
|
@@ -308,6 +312,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
308
312
|
this._npmRegistry = null;
|
|
309
313
|
this.reportingEnabled = null;
|
|
310
314
|
this.reportingNote = null;
|
|
315
|
+
this.mcpState = null;
|
|
311
316
|
this.multiplexers = new MultiplexerRegistry(this, multiplexerOptions);
|
|
312
317
|
this.multiplexerState = null;
|
|
313
318
|
this.reload();
|
|
@@ -585,6 +590,16 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
585
590
|
return args;
|
|
586
591
|
}
|
|
587
592
|
|
|
593
|
+
reinstalls(provider) {
|
|
594
|
+
return Boolean(!provider.plugin && !provider.package && provider.install && provider.channels.native?.reinstall);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
installEnv(provider) {
|
|
598
|
+
const name = this.reinstalls(provider) ? provider.channels.native.versionEnv : null;
|
|
599
|
+
const latest = name ? this.versions.get(provider.id)?.latest : null;
|
|
600
|
+
return latest ? { [name]: latest } : null;
|
|
601
|
+
}
|
|
602
|
+
|
|
588
603
|
/** Whether the tool is there to run: its command on PATH, and for a plugin a copy that `docker` runs. */
|
|
589
604
|
installed(provider) {
|
|
590
605
|
return Boolean(provider.plugin ? this._pluginPath(provider) : this.resolve(provider));
|
|
@@ -699,7 +714,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
699
714
|
guidance: `Homebrew moved ${provider.tool} from a formula to a cask, so brew upgrade no longer updates this copy. Run "brew uninstall ${channel.token}", then "brew install --cask ${channel.token}".`,
|
|
700
715
|
};
|
|
701
716
|
}
|
|
702
|
-
if (provider.plugin) return { file: null, args: [], command: channel.update.command, guidance: null };
|
|
717
|
+
if (provider.plugin || channel.update.reinstall) return { file: null, args: [], command: channel.update.command, guidance: null };
|
|
703
718
|
const args = this.npmArgs(channel.update, this.versions.get(provider.id)?.latest ?? 'latest');
|
|
704
719
|
const command = formatCommand(channel.update.file, args);
|
|
705
720
|
if (channel.probe) {
|
|
@@ -828,8 +843,10 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
828
843
|
resumable: provider.resumeArgs.length > 0,
|
|
829
844
|
launchFlags: provider.launchFlags.map(({ flag, risky }) => ({ flag, risky })),
|
|
830
845
|
launchFlagsEquals: provider.launchFlagsEquals,
|
|
831
|
-
installable: provider.plugin ? Boolean(commandPath && !resolvedPath && !pluginError && !pluginPending && provider.install)
|
|
832
|
-
|
|
846
|
+
installable: provider.plugin ? Boolean(commandPath && !resolvedPath && !pluginError && !pluginPending && provider.install)
|
|
847
|
+
: this.reinstalls(provider) || Boolean(provider.package && this.resolveNpm()),
|
|
848
|
+
installCommand: (provider.plugin || this.reinstalls(provider)) && provider.install ? provider.install : null,
|
|
849
|
+
plugin: provider.plugin,
|
|
833
850
|
pluginError,
|
|
834
851
|
pluginPending,
|
|
835
852
|
installedVersion: installed,
|
|
@@ -853,6 +870,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
853
870
|
reporting: provider.reporting,
|
|
854
871
|
reportingEnabled: this.reportingEnabled?.(provider) ?? null,
|
|
855
872
|
reportingNote: this.reportingNote?.(provider) ?? null,
|
|
873
|
+
mcp: this.mcpState?.(provider) ?? null,
|
|
856
874
|
accounts: provider.accounts.map((account) => ({ id: account.id, label: account.label })),
|
|
857
875
|
accountsEditable: Boolean(provider.homeVar),
|
|
858
876
|
shells: shells?.shells.map((shell) => ({ id: shell.id, label: shell.label, path: shell.path, multiplexer: Boolean(shell.multiplexer) })) ?? null,
|
|
@@ -935,7 +953,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
935
953
|
if (!update.command) {
|
|
936
954
|
throw refusal(400, 'not_updatable', update.guidance || `Still checking how ${provider.tool} was installed. Try again in a moment.`);
|
|
937
955
|
}
|
|
938
|
-
if (provider.plugin) return { spec: this._installCommandSpec(provider), channel: channel.channel };
|
|
956
|
+
if (provider.plugin || channel.update.reinstall) return { spec: this._installCommandSpec(provider), channel: channel.channel };
|
|
939
957
|
if (!channel.update.package) return { spec: buildSpawnSpec(update.file, update.args, this.env, this.platform), channel: channel.channel };
|
|
940
958
|
const args = this.npmArgs(channel.update, await this.resolveRelease(provider, update.file));
|
|
941
959
|
return { spec: npmSpawnSpec(update.file, args, this.env, this.platform), channel: 'npm' };
|
|
@@ -963,6 +981,7 @@ export class ProviderRegistry extends EventEmitter {
|
|
|
963
981
|
if (plugins.copies.some((c) => c.active)) throw refusal(409, 'already_installed', `${provider.tool} is already installed at ${this._pluginPath(provider)}.`);
|
|
964
982
|
return this._installCommandSpec(provider);
|
|
965
983
|
}
|
|
984
|
+
if (this.reinstalls(provider)) return this._installCommandSpec(provider);
|
|
966
985
|
if (!provider.package) {
|
|
967
986
|
const err = new Error(`${provider.tool} has no npm package configured; install it by hand: ${provider.install || provider.docs || 'see its documentation'}`);
|
|
968
987
|
err.status = 400;
|
|
@@ -25,6 +25,14 @@ import path from 'node:path';
|
|
|
25
25
|
|
|
26
26
|
export const SHIM_NAME = 'agent-guild-report';
|
|
27
27
|
export const LOADER_NAME = 'agent-guild-report-loader.mjs';
|
|
28
|
+
export const NATIVE_NAME = 'agent-guild-report-native';
|
|
29
|
+
|
|
30
|
+
const exeName = (name, platform) => (platform === 'win32' ? `${name}.exe` : name);
|
|
31
|
+
|
|
32
|
+
export function nativeReporter(root, { platform = process.platform, arch = process.arch } = {}) {
|
|
33
|
+
const file = path.join(root, 'bin', 'native', `${platform}-${arch}`, exeName(SHIM_NAME, platform));
|
|
34
|
+
return fs.existsSync(file) ? file : null;
|
|
35
|
+
}
|
|
28
36
|
|
|
29
37
|
/** Quote for double quotes in sh: backslash, double quote, dollar and backtick. */
|
|
30
38
|
function shQuote(value) {
|
|
@@ -43,12 +51,13 @@ export function fileUrl(file, platform = process.platform) {
|
|
|
43
51
|
* forward slashes in Windows paths, and a backslash would be an escape in
|
|
44
52
|
* the sh shim, so the sh shim gets forward slashes on Windows.
|
|
45
53
|
*/
|
|
46
|
-
export function shimContents({ execPath, script, platform = process.platform }) {
|
|
54
|
+
export function shimContents({ execPath, script, platform = process.platform, native = null }) {
|
|
47
55
|
const forSh = (p) => (platform === 'win32' ? p.replace(/\\/g, '/') : p);
|
|
48
56
|
const files = {
|
|
49
57
|
[SHIM_NAME]: [
|
|
50
58
|
'#!/bin/sh',
|
|
51
59
|
'[ "$1" = --hook ] && [ -z "$AGENT_GUILD_SESSION_ID" ] && exec cat >/dev/null',
|
|
60
|
+
...(native ? [`r=${shQuote(forSh(native))}`, '[ "$1" = --hook ] && [ -x "$r" ] && "$r" "$@" && exit 0'] : []),
|
|
52
61
|
`n=${shQuote(forSh(execPath))}`,
|
|
53
62
|
'[ -x "$n" ] || n=node',
|
|
54
63
|
`exec "$n" ${shQuote(forSh(script))} "$@"`,
|
|
@@ -56,30 +65,65 @@ export function shimContents({ execPath, script, platform = process.platform })
|
|
|
56
65
|
].join('\n'),
|
|
57
66
|
};
|
|
58
67
|
if (platform === 'win32') {
|
|
68
|
+
const exe = `%~dp0${exeName(NATIVE_NAME, platform)}`;
|
|
59
69
|
files[LOADER_NAME] = `import ${JSON.stringify(fileUrl(script, platform))};\n`;
|
|
60
70
|
files[`${SHIM_NAME}.cmd`] = [
|
|
61
71
|
'@ECHO OFF',
|
|
62
72
|
'IF "%~1"=="--hook" IF NOT DEFINED AGENT_GUILD_SESSION_ID EXIT /B 0',
|
|
73
|
+
...(native ? [`IF "%~1"=="--hook" IF EXIST "${exe}" GOTO native`, ':node'] : []),
|
|
63
74
|
'IF EXIST "%AGENT_GUILD_NODE%" GOTO manager',
|
|
64
75
|
`node "%~dp0${LOADER_NAME}" %*`,
|
|
65
76
|
'EXIT /B %ERRORLEVEL%',
|
|
66
77
|
':manager',
|
|
67
78
|
`"%AGENT_GUILD_NODE%" "%~dp0${LOADER_NAME}" %*`,
|
|
79
|
+
...(native ? ['EXIT /B %ERRORLEVEL%', ':native', `"${exe}" %*`, 'IF ERRORLEVEL 1 GOTO node'] : []),
|
|
68
80
|
'',
|
|
69
81
|
].join('\r\n');
|
|
70
82
|
}
|
|
71
83
|
return files;
|
|
72
84
|
}
|
|
73
85
|
|
|
86
|
+
function installNative(source, file, platform) {
|
|
87
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
88
|
+
fs.copyFileSync(source, tmp);
|
|
89
|
+
try { fs.chmodSync(tmp, 0o755); } catch {}
|
|
90
|
+
try {
|
|
91
|
+
fs.renameSync(tmp, file);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
if (platform !== 'win32' || !fs.existsSync(file)) {
|
|
94
|
+
fs.rmSync(tmp, { force: true });
|
|
95
|
+
throw err;
|
|
96
|
+
}
|
|
97
|
+
const aside = `${file}.${process.pid}.old`;
|
|
98
|
+
fs.renameSync(file, aside);
|
|
99
|
+
fs.renameSync(tmp, file);
|
|
100
|
+
try { fs.unlinkSync(aside); } catch {}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
74
104
|
/**
|
|
75
105
|
* Write the shims into `dir` (created if needed), replacing stale ones
|
|
76
106
|
* atomically so a hook that is starting meanwhile still runs a whole file.
|
|
77
107
|
* Returns `dir`. Throws when the folder is not writable; the caller decides
|
|
78
108
|
* whether that is fatal.
|
|
79
109
|
*/
|
|
80
|
-
export function writeReportShims({ dir, execPath = process.execPath, script, platform = process.platform }) {
|
|
110
|
+
export function writeReportShims({ dir, execPath = process.execPath, script, platform = process.platform, native = null, warn = console.warn }) {
|
|
81
111
|
fs.mkdirSync(dir, { recursive: true });
|
|
82
|
-
const
|
|
112
|
+
const copy = path.join(dir, exeName(NATIVE_NAME, platform));
|
|
113
|
+
const remove = (file) => { try { fs.rmSync(file, { force: true }); } catch {} };
|
|
114
|
+
for (const name of fs.readdirSync(dir)) {
|
|
115
|
+
if (name.startsWith(`${exeName(NATIVE_NAME, platform)}.`) && name.endsWith('.old')) remove(path.join(dir, name));
|
|
116
|
+
}
|
|
117
|
+
if (native) {
|
|
118
|
+
try {
|
|
119
|
+
installNative(native, copy, platform);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
warn(`[manager] could not copy the native agent-guild-report to ${copy}: ${err.message}; ${fs.existsSync(copy) ? 'hooks keep the earlier copy' : 'hooks run the Node.js reporter'}`);
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
remove(copy);
|
|
125
|
+
}
|
|
126
|
+
const files = shimContents({ execPath, script, platform, native: native ? copy : null });
|
|
83
127
|
for (const [name, contents] of Object.entries(files)) {
|
|
84
128
|
const file = path.join(dir, name);
|
|
85
129
|
const tmp = `${file}.${process.pid}.tmp`;
|
package/src/manager/server.mjs
CHANGED
|
@@ -25,6 +25,8 @@ const API = '/api/v1';
|
|
|
25
25
|
const MAX_BODY = 64 * 1024;
|
|
26
26
|
// A reply, and a Docker Agent stop that carries one, is a whole answer, which Claude Code does not clip.
|
|
27
27
|
const REPLY_BODY_LIMIT = 1024 * 1024;
|
|
28
|
+
const HOOK_BODY_LIMIT = 32 * 1024 * 1024;
|
|
29
|
+
const MAX_HOOK_PARAM = 128;
|
|
28
30
|
const MAX_WS_PAYLOAD = 1024 * 1024;
|
|
29
31
|
// The API explorer (web/api.html) reads the doc itself.
|
|
30
32
|
const API_DOC = path.join(path.dirname(require.resolve('../../package.json')), 'docs', 'api.md');
|
|
@@ -181,6 +183,8 @@ export function createManagerServer({
|
|
|
181
183
|
nextManagerProblem = ptyRestartProblem,
|
|
182
184
|
/** The Yard's art; without a data folder, only a checkout's. */
|
|
183
185
|
yardArt = new YardArt({ webDir }),
|
|
186
|
+
mcpConnect = null,
|
|
187
|
+
heartbeatMs = 30000,
|
|
184
188
|
}) {
|
|
185
189
|
let listedProviders = null;
|
|
186
190
|
const accounts = createAccountEditor({
|
|
@@ -306,14 +310,28 @@ export function createManagerServer({
|
|
|
306
310
|
// Agent, model and tool-session reports may authenticate with the
|
|
307
311
|
// per-session report token that the manager injects into each tool's
|
|
308
312
|
// environment.
|
|
309
|
-
const reportMatch = route.match(/^\/sessions\/([a-f0-9]+)\/(agents|model|tool-session|context|reporting|shells|docker|reply)$/);
|
|
313
|
+
const reportMatch = route.match(/^\/sessions\/([a-f0-9]+)\/(agents|model|tool-session|context|reporting|shells|docker|reply|hook)$/);
|
|
310
314
|
if (reportMatch && method === 'POST') {
|
|
311
315
|
const [, id, kind] = reportMatch;
|
|
312
|
-
const body = await readJsonBody(req, kind === 'reply' || kind === 'docker' ? REPLY_BODY_LIMIT : MAX_BODY);
|
|
313
316
|
const auth = {
|
|
314
317
|
trusted: timingSafeEqualString(requestToken(req, url), token),
|
|
315
318
|
reportToken: req.headers['x-agent-guild-report-token'],
|
|
316
319
|
};
|
|
320
|
+
if (kind === 'hook') {
|
|
321
|
+
// Before the body, which may be large, is read.
|
|
322
|
+
manager.checkReportAuth(id, auth);
|
|
323
|
+
const param = (name) => {
|
|
324
|
+
const value = url.searchParams.get(name);
|
|
325
|
+
if (value !== null && (value.length > MAX_HOOK_PARAM || /\p{Cc}/u.test(value))) {
|
|
326
|
+
throw new HttpError(400, `${name} must be printable and at most ${MAX_HOOK_PARAM} characters`, 'bad_request');
|
|
327
|
+
}
|
|
328
|
+
return value;
|
|
329
|
+
};
|
|
330
|
+
const options = { event: param('event') || null, reporting: param('reporting') };
|
|
331
|
+
const errors = await manager.reportHook(id, await readJsonBody(req, HOOK_BODY_LIMIT), options, auth);
|
|
332
|
+
return sendJson(res, 200, errors.length ? { ok: true, errors } : { ok: true });
|
|
333
|
+
}
|
|
334
|
+
const body = await readJsonBody(req, kind === 'reply' || kind === 'docker' ? REPLY_BODY_LIMIT : MAX_BODY);
|
|
317
335
|
if (kind === 'agents') return sendJson(res, 200, { agent: manager.reportAgent(id, body, auth) });
|
|
318
336
|
if (kind === 'docker') {
|
|
319
337
|
manager.reportDocker(id, body, auth);
|
|
@@ -501,6 +519,15 @@ export function createManagerServer({
|
|
|
501
519
|
registry.emit('updated');
|
|
502
520
|
return sendJson(res, 200, { provider: registry.describe(provider) });
|
|
503
521
|
}
|
|
522
|
+
const mcpMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/mcp$/);
|
|
523
|
+
if (mcpMatch && method === 'POST' && mcpConnect) {
|
|
524
|
+
const provider = registry.get(mcpMatch[1]);
|
|
525
|
+
if (!provider) throw new HttpError(404, `unknown provider "${mcpMatch[1]}"`, 'unknown_provider');
|
|
526
|
+
const body = await readJsonBody(req);
|
|
527
|
+
if (typeof body.enabled !== 'boolean') throw new HttpError(400, 'enabled must be true or false', 'bad_request');
|
|
528
|
+
const results = await mcpConnect.setEnabled(provider, body.enabled);
|
|
529
|
+
return sendJson(res, 200, { provider: registry.describe(provider), results });
|
|
530
|
+
}
|
|
504
531
|
const multiplexerMatch = route.match(/^\/providers\/([a-z0-9][a-z0-9_-]{0,31})\/multiplexers\/([a-z0-9_-]+)\/(install|update|uninstall)$/);
|
|
505
532
|
if (multiplexerMatch && method === 'POST') {
|
|
506
533
|
const body = await readJsonBody(req);
|
|
@@ -802,7 +829,8 @@ export function createManagerServer({
|
|
|
802
829
|
ws.isAlive = false;
|
|
803
830
|
try { ws.ping(); } catch { /* closing */ }
|
|
804
831
|
}
|
|
805
|
-
|
|
832
|
+
if (mcpConnect && wss.clients.size) for (const provider of registry.providers) mcpConnect.refresh(provider).catch(() => {});
|
|
833
|
+
}, heartbeatMs);
|
|
806
834
|
heartbeat.unref();
|
|
807
835
|
|
|
808
836
|
return {
|
|
@@ -17,8 +17,11 @@ 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 { handleHookEvent } from '../report/hook-event.mjs';
|
|
20
21
|
|
|
21
22
|
export const MAX_SESSIONS = 32;
|
|
23
|
+
const TRUSTED = Object.freeze({ trusted: true });
|
|
24
|
+
const unrefSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms).unref());
|
|
22
25
|
const TMUX_SESSION_GONE = [/^can't find session/m, /^no server running on /m, /^error connecting to .*\(No such file or directory\)$/m];
|
|
23
26
|
|
|
24
27
|
// The hooks.json earlier versions copied into Codex CLI accounts; an untouched copy would run every hook twice.
|
|
@@ -502,11 +505,11 @@ export class SessionManager extends EventEmitter {
|
|
|
502
505
|
const { spec, channel } = await this.registry.updateSpec(provider);
|
|
503
506
|
guard();
|
|
504
507
|
const name = `Update ${provider.tool} (${CHANNEL_LABELS[channel]})`;
|
|
505
|
-
return this._spawn({ provider, spawnSpec: spec, cwd: os.homedir(), name, task: 'install', installKind: 'update' });
|
|
508
|
+
return this._spawn({ provider, spawnSpec: spec, cwd: os.homedir(), name, task: 'install', installKind: 'update', extraEnv: this.registry.installEnv(provider) });
|
|
506
509
|
}
|
|
507
510
|
const spawnSpec = await this.registry.installSpec(provider);
|
|
508
511
|
guard();
|
|
509
|
-
return this._spawn({ provider, spawnSpec, cwd: os.homedir(), name: `Install ${provider.tool}`, task: 'install', installKind: 'install' });
|
|
512
|
+
return this._spawn({ provider, spawnSpec, cwd: os.homedir(), name: `Install ${provider.tool}`, task: 'install', installKind: 'install', extraEnv: this.registry.installEnv(provider) });
|
|
510
513
|
} finally {
|
|
511
514
|
this.installing.delete(provider.id);
|
|
512
515
|
}
|
|
@@ -865,6 +868,37 @@ export class SessionManager extends EventEmitter {
|
|
|
865
868
|
session.docker.report(event);
|
|
866
869
|
}
|
|
867
870
|
|
|
871
|
+
async reportHook(id, input, { event = null, reporting = null } = {}, auth) {
|
|
872
|
+
const session = this._reportingSession(id, auth);
|
|
873
|
+
const errors = [];
|
|
874
|
+
const apply = {
|
|
875
|
+
agents: (report) => this.reportAgent(id, report, TRUSTED),
|
|
876
|
+
model: (report) => this.reportModel(id, report, TRUSTED),
|
|
877
|
+
'tool-session': (report) => this.reportToolSession(id, report, TRUSTED),
|
|
878
|
+
context: (report) => this.reportContext(id, report, TRUSTED),
|
|
879
|
+
reply: (report) => this.reportReply(id, report, TRUSTED),
|
|
880
|
+
reporting: () => this.reportHello(id, TRUSTED),
|
|
881
|
+
shells: (report) => this.reportShell(id, report, TRUSTED),
|
|
882
|
+
docker: (report) => this.reportDocker(id, report, TRUSTED),
|
|
883
|
+
};
|
|
884
|
+
const send = async (report, kind) => {
|
|
885
|
+
try {
|
|
886
|
+
apply[kind](report);
|
|
887
|
+
} catch (err) {
|
|
888
|
+
errors.push({ status: err.status ?? 500, ...(err.code ? { code: err.code } : {}), message: err.message });
|
|
889
|
+
}
|
|
890
|
+
};
|
|
891
|
+
const { later } = await handleHookEvent(input, {
|
|
892
|
+
reporting: reporting ?? session.provider.reporting ?? '', event, since: Date.now() - 2000, send, sleep: unrefSleep,
|
|
893
|
+
});
|
|
894
|
+
later.catch((err) => console.warn(`[session ${id}] a hook report failed: ${err.message}`));
|
|
895
|
+
return errors;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
checkReportAuth(id, auth) {
|
|
899
|
+
this._reportingSession(id, auth);
|
|
900
|
+
}
|
|
901
|
+
|
|
868
902
|
_reportingSession(id, { reportToken, trusted = false } = {}) {
|
|
869
903
|
const session = this.sessions.get(id);
|
|
870
904
|
// Without the API token, an unknown session and a wrong token look the
|
package/src/manager/session.mjs
CHANGED
|
@@ -49,6 +49,7 @@ export function cleanName(name) {
|
|
|
49
49
|
return typeof name === 'string' ? name.trim().slice(0, 80) : '';
|
|
50
50
|
}
|
|
51
51
|
const MAX_AGENTS = 64;
|
|
52
|
+
export const SCROLLBACK_LINES = 1000;
|
|
52
53
|
|
|
53
54
|
export function clampDimension(value, fallback, min, max) {
|
|
54
55
|
const n = Math.floor(Number(value));
|
|
@@ -99,7 +100,7 @@ export class Session extends EventEmitter {
|
|
|
99
100
|
this.cols = opts.cols;
|
|
100
101
|
this.rows = opts.rows;
|
|
101
102
|
this.reportToken = opts.reportToken;
|
|
102
|
-
this.scrollback = opts.scrollback ??
|
|
103
|
+
this.scrollback = opts.scrollback ?? SCROLLBACK_LINES;
|
|
103
104
|
this.activityIdleMs = opts.activityIdleMs ?? 2500;
|
|
104
105
|
this.doneAgentLingerMs = opts.doneAgentLingerMs ?? 15000;
|
|
105
106
|
this.killGraceMs = opts.killGraceMs ?? 4000;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { hookRoute, hookToReports, dockerHookEvent, antigravityUserConversation } from './hooks.mjs';
|
|
2
|
+
import * as transcripts from './transcripts.mjs';
|
|
3
|
+
|
|
4
|
+
export function reportKind(report) {
|
|
5
|
+
if (report.compaction !== undefined) return 'context';
|
|
6
|
+
if (report.reply !== undefined) return 'reply';
|
|
7
|
+
if (report.shell !== undefined) return 'shells';
|
|
8
|
+
if (report.agentId !== undefined || report.finishForeground === true) return 'agents';
|
|
9
|
+
if (report.hello === true) return 'reporting';
|
|
10
|
+
if (report.toolSessionId !== undefined) return 'tool-session';
|
|
11
|
+
return 'model';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function handleHookEvent(input, { reporting = '', event = null, since = Date.now() - 2000, send, files = transcripts, sleep } = {}) {
|
|
15
|
+
const none = { later: Promise.resolve() };
|
|
16
|
+
if (!input || typeof input !== 'object') return none;
|
|
17
|
+
if (event && input.hook_event_name === undefined) input.hook_event_name = event;
|
|
18
|
+
const route = hookRoute(input, reporting);
|
|
19
|
+
if (!route) return none;
|
|
20
|
+
// Docker Agent's events go whole to the manager, which tells its sessions (tabs, sub-agents) apart.
|
|
21
|
+
if (route === 'docker') {
|
|
22
|
+
const docker = dockerHookEvent(input);
|
|
23
|
+
if (docker) await send(docker, 'docker');
|
|
24
|
+
return none;
|
|
25
|
+
}
|
|
26
|
+
// Antigravity CLI runs the hook in its sub-agents too, each a conversation of its own.
|
|
27
|
+
if (typeof input.conversationId === 'string' && !antigravityUserConversation(await files.firstRecord(input.transcriptPath))) return none;
|
|
28
|
+
const later = [];
|
|
29
|
+
for (const report of hookToReports(input)) {
|
|
30
|
+
if (report.transcript === undefined) await send(report, reportKind(report));
|
|
31
|
+
else {
|
|
32
|
+
later.push(files.claudeCompaction(report.transcript, since, sleep ? { sleep } : undefined)
|
|
33
|
+
.then((key) => (key ? send({ compaction: report.compaction, key }, 'context') : undefined)));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (typeof input.conversationId === 'string' && input.hook_event_name === 'Stop') {
|
|
37
|
+
await send({ reply: await files.antigravityTranscriptReply(input.transcriptPath) }, 'reply');
|
|
38
|
+
}
|
|
39
|
+
return { later: Promise.all(later).then(() => {}) };
|
|
40
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { antigravityReply, claudeCompactBoundary } from './hooks.mjs';
|
|
3
|
+
|
|
4
|
+
const MAX_FIRST_LINE = 1024 * 1024;
|
|
5
|
+
const COMPACTION_TAIL_BYTES = 1024 * 1024;
|
|
6
|
+
const REPLY_WINDOW_BYTES = 256 * 1024;
|
|
7
|
+
const MAX_REPLY_WINDOW_BYTES = 64 * 1024 * 1024;
|
|
8
|
+
// Claude Code writes the compaction record a moment after its PostCompact hook starts.
|
|
9
|
+
const COMPACTION_WAIT_MS = 3000;
|
|
10
|
+
const COMPACTION_STEP_MS = 250;
|
|
11
|
+
|
|
12
|
+
const parseJson = (raw) => { try { return JSON.parse(raw); } catch { return null; } };
|
|
13
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
|
|
15
|
+
async function openFile(file) {
|
|
16
|
+
if (typeof file !== 'string' || !file) return null;
|
|
17
|
+
try {
|
|
18
|
+
if (!(await fs.promises.stat(file)).isFile()) return null;
|
|
19
|
+
// Non-blocking, in case the path became a FIFO after the check: opening one would wait for a writer.
|
|
20
|
+
const handle = await fs.promises.open(file, fs.constants.O_RDONLY | (fs.constants.O_NONBLOCK ?? 0));
|
|
21
|
+
const stat = await handle.stat();
|
|
22
|
+
if (!stat.isFile()) {
|
|
23
|
+
await handle.close();
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return { handle, size: stat.size };
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function readRecords(handle, start, end) {
|
|
33
|
+
const buffer = Buffer.alloc(end - start);
|
|
34
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, start);
|
|
35
|
+
const lines = buffer.subarray(0, bytesRead).toString('utf8').split('\n');
|
|
36
|
+
// A window that starts mid-file starts mid-line.
|
|
37
|
+
if (start > 0) lines.shift();
|
|
38
|
+
return lines.map(parseJson);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The first JSON record of a JSONL file, or null. */
|
|
42
|
+
export async function firstRecord(file) {
|
|
43
|
+
const opened = await openFile(file);
|
|
44
|
+
if (!opened) return null;
|
|
45
|
+
try {
|
|
46
|
+
const length = Math.min(opened.size, MAX_FIRST_LINE);
|
|
47
|
+
const buffer = Buffer.alloc(length);
|
|
48
|
+
const { bytesRead } = await opened.handle.read(buffer, 0, length, 0);
|
|
49
|
+
const end = buffer.subarray(0, bytesRead).indexOf(10);
|
|
50
|
+
return parseJson(buffer.subarray(0, end === -1 ? bytesRead : end).toString('utf8'));
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
} finally {
|
|
54
|
+
await opened.handle.close().catch(() => {});
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function tailRecords(file, bytes) {
|
|
59
|
+
const opened = await openFile(file);
|
|
60
|
+
if (!opened) return [];
|
|
61
|
+
try {
|
|
62
|
+
return await readRecords(opened.handle, Math.max(0, opened.size - bytes), opened.size);
|
|
63
|
+
} catch {
|
|
64
|
+
return [];
|
|
65
|
+
} finally {
|
|
66
|
+
await opened.handle.close().catch(() => {});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The last response of an Antigravity CLI turn, read back from the end of its transcript. */
|
|
71
|
+
export async function antigravityTranscriptReply(file) {
|
|
72
|
+
const opened = await openFile(file);
|
|
73
|
+
if (!opened) return null;
|
|
74
|
+
try {
|
|
75
|
+
for (let bytes = REPLY_WINDOW_BYTES; ; bytes *= 4) {
|
|
76
|
+
const start = Math.max(0, opened.size - bytes);
|
|
77
|
+
const reply = antigravityReply(await readRecords(opened.handle, start, opened.size));
|
|
78
|
+
if (reply !== undefined || start === 0 || bytes >= MAX_REPLY_WINDOW_BYTES) return reply ?? null;
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
} finally {
|
|
83
|
+
await opened.handle.close().catch(() => {});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function claudeCompaction(file, since, { sleep = wait } = {}) {
|
|
88
|
+
if (typeof file !== 'string' || !file) return null;
|
|
89
|
+
for (let waited = 0; waited <= COMPACTION_WAIT_MS; waited += COMPACTION_STEP_MS) {
|
|
90
|
+
if (waited) await sleep(COMPACTION_STEP_MS);
|
|
91
|
+
const key = claudeCompactBoundary(await tailRecords(file, COMPACTION_TAIL_BYTES), since);
|
|
92
|
+
if (key) return key;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
package/web/app.js
CHANGED
|
@@ -2265,6 +2265,53 @@ function renderReportingSetup(card, provider) {
|
|
|
2265
2265
|
};
|
|
2266
2266
|
}
|
|
2267
2267
|
|
|
2268
|
+
const MCP_STATES = { on: 'on', off: 'off', disabled: 'turned off in the tool', other: 'another server is named agent-guild', unknown: 'settings could not be read' };
|
|
2269
|
+
|
|
2270
|
+
function mcpText(provider, mcp) {
|
|
2271
|
+
if (mcp.state === 'checking') return 'Agent Guild MCP: checking';
|
|
2272
|
+
if (mcp.state === 'on') return mcp.total > 1 ? `Agent Guild MCP on for all ${mcp.total} accounts` : 'Agent Guild MCP on';
|
|
2273
|
+
if (mcp.state === 'partial') return `Agent Guild MCP on for ${mcp.on} of ${mcp.total} accounts`;
|
|
2274
|
+
if (mcp.state === 'disabled') return `Agent Guild MCP turned off in ${provider.tool}`;
|
|
2275
|
+
if (mcp.state === 'other') return 'Agent Guild MCP: another server is named agent-guild';
|
|
2276
|
+
if (mcp.state === 'unknown') return 'Agent Guild MCP: settings could not be read';
|
|
2277
|
+
return 'Agent Guild MCP off';
|
|
2278
|
+
}
|
|
2279
|
+
|
|
2280
|
+
function renderMcpSetup(card, provider) {
|
|
2281
|
+
const row = card.querySelector('.mcp-row');
|
|
2282
|
+
const mcp = provider.mcp;
|
|
2283
|
+
row.hidden = !provider.available || !mcp;
|
|
2284
|
+
if (row.hidden) return;
|
|
2285
|
+
const text = row.querySelector('.mcp-text');
|
|
2286
|
+
text.textContent = mcpText(provider, mcp);
|
|
2287
|
+
text.title = mcp.accounts.map((a) => `${a.label}: ${MCP_STATES[a.state] ?? a.state}${a.detail ? `. ${a.detail}` : ''}`).join('\n');
|
|
2288
|
+
const states = new Set(mcp.accounts.map((a) => a.state));
|
|
2289
|
+
const turnOn = row.querySelector('.mcp-on');
|
|
2290
|
+
const turnOff = row.querySelector('.mcp-off');
|
|
2291
|
+
turnOn.hidden = !states.has('off') && !states.has('disabled');
|
|
2292
|
+
turnOff.hidden = !states.has('on') && !states.has('disabled');
|
|
2293
|
+
turnOn.title = `Lets ${provider.tool} see your other Agent Guild sessions, read their answers and screens, and start or stop sessions when you approve. Typing into sessions stays off. Adds agent-guild to ${provider.tool}'s MCP servers for every account.`;
|
|
2294
|
+
turnOff.title = `Removes Agent Guild's MCP server from ${provider.tool}'s settings. A server named agent-guild that runs something else is left as it is.`;
|
|
2295
|
+
const change = async (enabled) => {
|
|
2296
|
+
turnOn.disabled = turnOff.disabled = true;
|
|
2297
|
+
try {
|
|
2298
|
+
const { results } = await api('POST', `/providers/${provider.id}/mcp`, { enabled });
|
|
2299
|
+
const problems = results.filter((r) => r.outcome === 'failed' || r.outcome === 'skipped');
|
|
2300
|
+
const done = results.length - problems.length;
|
|
2301
|
+
const lead = !problems.length ? `Agent Guild MCP is ${enabled ? `on for ${provider.tool}. New sessions can use it` : `off for ${provider.tool}`}.`
|
|
2302
|
+
: `Agent Guild MCP is ${enabled ? 'on' : 'off'} for ${done} of ${results.length} ${provider.tool} accounts.`;
|
|
2303
|
+
toast([lead, ...problems.map((r) => `${r.label}: ${r.detail ?? MCP_STATES[r.state] ?? r.state}.`)].join(' '), problems.length ? 12000 : undefined);
|
|
2304
|
+
} catch (err) {
|
|
2305
|
+
if (err instanceof AuthError) return showAuth(err.message);
|
|
2306
|
+
toast(err.message, 10000);
|
|
2307
|
+
} finally {
|
|
2308
|
+
turnOn.disabled = turnOff.disabled = false;
|
|
2309
|
+
}
|
|
2310
|
+
};
|
|
2311
|
+
turnOn.onclick = () => change(true);
|
|
2312
|
+
turnOff.onclick = () => change(false);
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2268
2315
|
let dealt = false;
|
|
2269
2316
|
|
|
2270
2317
|
// Both layouts mount these same controls with these same command handlers.
|
|
@@ -2311,7 +2358,8 @@ function buildProvider(provider) {
|
|
|
2311
2358
|
if (provider.available && provider.instructionsSource && !instructionCounts.has(instructionKey(provider))) refreshInstructionCounts();
|
|
2312
2359
|
const install = node.querySelector('.install');
|
|
2313
2360
|
install.hidden = provider.available || !provider.installable;
|
|
2314
|
-
install.title = provider.
|
|
2361
|
+
install.title = provider.plugin ? `Downloads the latest ${provider.tool} release into Docker's plugin folder, in a session.`
|
|
2362
|
+
: provider.installCommand ? `Run "${provider.installCommand}" in a session` : `Install ${provider.tool} using npm.${provider.npmNote ? ` ${provider.npmNote}` : ''}`;
|
|
2315
2363
|
install.addEventListener('click', () => installProvider(provider, node));
|
|
2316
2364
|
const update = node.querySelector('.update');
|
|
2317
2365
|
update.hidden = !(provider.available && provider.updateCommand && (provider.updateAvailable || checkFailed));
|
|
@@ -2336,6 +2384,7 @@ function buildProvider(provider) {
|
|
|
2336
2384
|
renderMultiplexers(node, provider);
|
|
2337
2385
|
renderUsage(node, provider);
|
|
2338
2386
|
renderReportingSetup(node, provider);
|
|
2387
|
+
renderMcpSetup(node, provider);
|
|
2339
2388
|
renderModelStats(node, provider);
|
|
2340
2389
|
renderProviderPending(node, provider);
|
|
2341
2390
|
paintManagerActions(node);
|
|
@@ -3830,7 +3879,7 @@ function installNote(provider) {
|
|
|
3830
3879
|
}
|
|
3831
3880
|
if (last.kind === 'uninstall') return last.outcome === 'remaining' ? 'Uninstalled copy is still present' : '';
|
|
3832
3881
|
if (last.verification === 'failed') return `Installation completed, but ${provider.tool} verification failed`;
|
|
3833
|
-
if (last.outcome === 'missing') return provider.
|
|
3882
|
+
if (last.outcome === 'missing') return provider.plugin ? 'Installed, but docker does not list it' : 'Installed, but not found on PATH';
|
|
3834
3883
|
if (last.outcome === 'unchanged') return 'No version change after update';
|
|
3835
3884
|
return '';
|
|
3836
3885
|
}
|
|
@@ -6702,7 +6751,7 @@ class TerminalView {
|
|
|
6702
6751
|
cursorBlink: true,
|
|
6703
6752
|
fontFamily: 'ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace',
|
|
6704
6753
|
fontSize: terminalFontSize,
|
|
6705
|
-
scrollback:
|
|
6754
|
+
scrollback: 1000,
|
|
6706
6755
|
macOptionIsMeta: true,
|
|
6707
6756
|
// Option-drag selects text even while a program reads the mouse, as Shift-drag does elsewhere.
|
|
6708
6757
|
macOptionClickForcesSelection: true,
|
package/web/index.html
CHANGED
|
@@ -137,7 +137,7 @@
|
|
|
137
137
|
<div class="yard-overlay">
|
|
138
138
|
<div id="yard-loading" class="yard-loading" role="status" data-mode="scene"><span id="yard-loading-text">Preparing your yard…</span><span id="yard-loading-size" class="yard-loading-size" aria-hidden="true"></span><span class="yard-loading-bar" aria-hidden="true"></span></div>
|
|
139
139
|
<div id="yard-swap-failure" class="yard-swap-failure" hidden role="status"><span id="yard-swap-failure-text"></span><button id="yard-swap-retry" class="btn" type="button">Retry</button></div>
|
|
140
|
-
<div class="yard-camera" role="group" aria-label="
|
|
140
|
+
<div class="yard-camera" role="group" aria-label="Yard controls"><button id="yard-zoom-in" type="button" class="btn" aria-label="Zoom in">+</button><button id="yard-zoom-out" type="button" class="btn" aria-label="Zoom out">−</button><button id="yard-fit" type="button" class="btn">Overview</button><button id="yard-focus" type="button" class="btn" disabled>Focus selected</button><button id="yard-photo" type="button" class="btn" aria-haspopup="dialog" aria-label="Save a picture of the Yard" title="Save a picture of the Yard" disabled hidden><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M4 8.5A1.5 1.5 0 0 1 5.5 7h2.3l1.4-2h5.6l1.4 2h2.3A1.5 1.5 0 0 1 20 8.5v9a1.5 1.5 0 0 1-1.5 1.5h-13A1.5 1.5 0 0 1 4 17.5z"/><circle cx="12" cy="12.75" r="3.25"/></svg></button><button id="yard-fireworks" type="button" class="btn" aria-label="Fireworks in this Yard only" title="Fireworks in this Yard only" disabled><svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" aria-hidden="true" focusable="false"><path d="M12 3v3m0 9v6M3 10h3m12 0h3M5.6 3.6l2.2 2.2m8.4 8.4 2.2 2.2M18.4 3.6l-2.2 2.2m-8.4 8.4-2.2 2.2"/><circle cx="12" cy="10" r="1"/></svg></button></div>
|
|
141
141
|
<p id="yard-help"><span class="yard-help-pointer">Drag to explore · Scroll to zoom · Select to inspect · Double-click a session to open</span><span class="yard-help-touch">Drag to explore · Pinch to zoom · Tap to inspect · Open from the Inspector</span></p>
|
|
142
142
|
</div>
|
|
143
143
|
</div>
|
|
@@ -817,6 +817,12 @@
|
|
|
817
817
|
<span class="reporting-text"></span>
|
|
818
818
|
<button class="btn reporting-toggle" type="button"></button>
|
|
819
819
|
</div>
|
|
820
|
+
<div class="reporting-row mcp-row" hidden>
|
|
821
|
+
<span class="mcp-text"></span>
|
|
822
|
+
<a class="console-link mcp-docs" href="https://github.com/oddessentials/agent-guild/blob/main/docs/mcp.md" target="_blank" rel="noopener noreferrer" title="What Agent Guild's MCP server lets a coding agent do">About</a>
|
|
823
|
+
<button class="btn mcp-on" type="button">Turn on</button>
|
|
824
|
+
<button class="btn mcp-off" type="button">Turn off</button>
|
|
825
|
+
</div>
|
|
820
826
|
<div class="provider-links">
|
|
821
827
|
<a class="console-link usage-link" target="_blank" rel="noopener noreferrer">Usage</a>
|
|
822
828
|
<a class="console-link billing-link" target="_blank" rel="noopener noreferrer">Billing</a>
|
package/web/styles.css
CHANGED
|
@@ -308,6 +308,8 @@ main { max-width: calc(1200px + var(--dock-space, 0px)); margin: 0 auto; padding
|
|
|
308
308
|
}
|
|
309
309
|
.reporting-row { display: flex; align-items: center; gap: 10px; font-size: 0.78rem; color: var(--muted); }
|
|
310
310
|
.reporting-row .btn { padding: 2px 10px; font-size: 0.75rem; white-space: nowrap; }
|
|
311
|
+
.mcp-row { flex-wrap: wrap; row-gap: 4px; }
|
|
312
|
+
.mcp-row .mcp-text { flex-basis: 100%; }
|
|
311
313
|
.console-link { color: var(--accent); text-decoration: none; }
|
|
312
314
|
.console-link:hover { text-decoration: underline; }
|
|
313
315
|
.console-link:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; border-radius: 3px; }
|