@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.
Files changed (34) hide show
  1. package/bin/agent-guild-report.mjs +5 -106
  2. package/bin/agent-guild.mjs +2 -0
  3. package/bin/native/darwin-arm64/agent-guild-report +0 -0
  4. package/bin/native/darwin-x64/agent-guild-report +0 -0
  5. package/bin/native/linux-arm64/agent-guild-report +0 -0
  6. package/bin/native/linux-x64/agent-guild-report +0 -0
  7. package/bin/native/win32-arm64/agent-guild-report.exe +0 -0
  8. package/bin/native/win32-x64/agent-guild-report.exe +0 -0
  9. package/bin/windows/arm64/agent-guild-start.exe +0 -0
  10. package/bin/windows/x64/agent-guild-start.exe +0 -0
  11. package/config/providers.default.json +4 -0
  12. package/docs/api.md +27 -5
  13. package/examples/terminal-ai-chat.json +27 -0
  14. package/node_modules/node-pty/prebuilds/win32-arm64/conpty.node +0 -0
  15. package/node_modules/node-pty/prebuilds/win32-arm64/conpty_console_list.node +0 -0
  16. package/node_modules/node-pty/prebuilds/win32-x64/conpty.node +0 -0
  17. package/node_modules/node-pty/prebuilds/win32-x64/conpty_console_list.node +0 -0
  18. package/package.json +2 -1
  19. package/src/manager/install-channels.mjs +3 -0
  20. package/src/manager/main.mjs +6 -2
  21. package/src/manager/mcp-connect.mjs +249 -0
  22. package/src/manager/providers.mjs +23 -4
  23. package/src/manager/report-shims.mjs +47 -3
  24. package/src/manager/server.mjs +31 -3
  25. package/src/manager/session-manager.mjs +36 -2
  26. package/src/manager/session.mjs +2 -1
  27. package/src/report/hook-event.mjs +40 -0
  28. package/src/report/transcripts.mjs +95 -0
  29. package/web/app.js +52 -3
  30. package/web/index.html +7 -1
  31. package/web/styles.css +2 -0
  32. package/web/yard/art.json +1 -1
  33. package/web/yard/renderer.js +110 -36
  34. 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) : Boolean(provider.package && this.resolveNpm()),
832
- installCommand: provider.plugin && provider.install ? provider.install : null,
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 files = shimContents({ execPath, script, platform });
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`;
@@ -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
- }, 30000);
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
@@ -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 ?? 5000;
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.installCommand ? `Downloads the latest ${provider.tool} release into Docker's plugin folder, in a session.` : `Install ${provider.tool} using npm.${provider.npmNote ? ` ${provider.npmNote}` : ''}`;
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.installCommand ? 'Installed, but docker does not list it' : 'Installed, but not found on PATH';
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: 5000,
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="Camera"><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></div>
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; }