diff --git a/README.md b/README.md index 5470170..1b67d82 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,72 @@ # Agent Guild for VS Code -Watch your AI coding agents come alive in a 3D world, in a VS Code tab. Claude Code, Codex CLI, Antigravity CLI, Grok Build, Docker Agent and your own shell each get a hall, and every session is a character at work. +Run Claude Code, Codex CLI, Antigravity CLI, Grok Build and Docker Agent side by side in real terminals, and watch every session come alive as a character in a 3D world, in a VS Code tab or right beside your code. Each tool runs on your own account, just as it does in any terminal. ![Six real agent sessions in the Guild Yard: the characters walk to their halls and start working, then Claude Code's terminal opens with its three helper agents](https://raw.githubusercontent.com/oddessentials/agent-guild-vscode/main/media/yard.gif) -## The Yard +## Get started + +1. Install this extension. +2. Click **Agent Guild** in the status bar, or open **Agent Guild** in the Explorer. +3. If Agent Guild isn't installed yet, choose **Install**. It runs `npm install -g @oddessentials/agent-guild` in a terminal and connects when it finishes. + +Press **New** on a tool's card to start a session, and sign in to the tool there the first time. + +## Your coding agents, side by side + +- Real terminals that keep running when you close the tab or VS Code. The tab comes back the next time VS Code starts. +- Rate-limit meters for Claude Code and Codex CLI, so you know how much headroom is left. +- Each session's helper agents and model, live on its card. +- Two sessions at once with **Split**, and earlier conversations back with **Resume**. + +| | Install from its card | Helper agents | Rate-limit meter | Resume | Several accounts | Model benchmarks | +| --- | :-: | :-: | :-: | :-: | :-: | :-: | +| Claude Code | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| Codex CLI | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| Antigravity CLI | Shows the command | | | ✓ | | ✓ | +| Grok Build | ✓ | With **Agent reporting** | | ✓ | ✓ | ✓ | +| Docker Agent | ✓ | ✓ | | ✓ | | | + +The **Shell** card runs your own shell, plus tmux (macOS and Linux) and herdr sessions that keep running when Agent Guild stops. Helper agents need a recent version of each tool: [setup per tool](https://github.com/oddessentials/agent-guild/blob/main/docs/agent-reporting.md). + +## Watch them work: the Yard - Every session is a character. It works at its tool's hall while its terminal is busy, and walks off to rest when it goes quiet. There is nothing to set up. -- Helper agents appear beside their character: Claude Code's, Codex CLI's and Docker Agent's on their own, Grok Build's once you turn on **Agent reporting** on its card. -- Halls glow while their sessions work, and the roster lists every tool and session. +- Helper agents gather beside their character, and halls glow while their sessions work. - Double-click a character to open its real terminal. - Six worlds: The Guild Yard, Operations Campus, Orbital Station, The Living Grove, Goblinville Works and Gnomeland Village. ![The same six sessions in each of the six worlds](https://raw.githubusercontent.com/oddessentials/agent-guild-vscode/main/media/worlds.jpg) -To open it, run **Agent Guild: Open the Yard** from the Command Palette. **Agent Guild: Open** then keeps opening the Yard, until you choose **Cards** under **Settings → View** in the tab. Pick a world under **Settings → Skin**. - -[Try the Yard in your browser](https://oddessentials.github.io/agent-guild/?view=yard) (simulated, nothing installed), or watch the trailer: +Run **Agent Guild: Open the Yard**, and pick a world under **Settings → Skin**. [Try the Yard in your browser](https://oddessentials.github.io/agent-guild/?view=yard) (simulated, nothing installed), or watch the trailer: [![Agent Guild: The Yard Update trailer on YouTube](https://i.ytimg.com/vi/Zttp-vs_6z0/maxresdefault.jpg)](https://www.youtube.com/watch?v=Zttp-vs_6z0) -## The tab +## The Yard beside your code + +Keep your agents in view while you work. Open **Agent Guild** in the Explorer, or drag it to the bottom panel. + +![VS Code with the Agent Guild view in the Explorer showing the Guild Yard beside a TypeScript file, with Claude Code, Codex CLI, Antigravity CLI, Grok Build and Shell sessions at their halls (demo sessions)](https://raw.githubusercontent.com/oddessentials/agent-guild-vscode/main/media/view.png) + +_Shown with demo sessions._ + +- It shows the Yard in the world and theme you chose in the tab. +- Double-click a character to open its terminal in the Agent Guild tab. +- It draws nothing while it's collapsed or hidden. + +## The Agent Guild tab -- **Agent Guild: Open** in the Command Palette, or **Agent Guild** in the status bar. -- If no session manager is running, one starts. If one is running, the tab connects to it. +- Opens from the status bar or **Agent Guild: Open**, and starts Agent Guild if it isn't running. - The same sessions stay open in your browser at the same time. -- Closing the tab or VS Code leaves your sessions running. The tab comes back the next time VS Code starts. - Links open in your default browser. ![Agent Guild opening from the status bar, then a Shell session starting from its card](https://raw.githubusercontent.com/oddessentials/agent-guild-vscode/main/media/demo.gif) -## Everything else Agent Guild does +## More in Agent Guild -Agent Guild runs your coding tools side by side, in real terminals that keep running when the page closes. - -- One card per tool: install and update it, start sessions, and see its helper agents and the model in use. -- Two terminals side by side with **Split**. -- Rate-limit meters for Claude Code and Codex CLI. -- Resume a tool's earlier sessions, and see what it remembers and which instruction files it reads. -- Several accounts per tool, and model grades and benchmarks. -- GitHub issues, pull requests and workflow runs for the working folder. -- tmux and herdr sessions that keep running when the manager stops. +- See what Claude Code, Codex CLI and Grok Build remember, and which instruction files your tools read in the working folder. +- Grades and benchmarks for your tools' models. +- GitHub issues, pull requests, workflow runs and branches for your working folder, once you sign in to GitHub. ![A Claude Code session's terminal, with its helper agents in the header](https://raw.githubusercontent.com/oddessentials/agent-guild-vscode/main/media/terminal.png) @@ -48,24 +74,55 @@ Agent Guild runs your coding tools side by side, in real terminals that keep run ![Claude Code's models with their coding, intelligence and agentic benchmarks](https://raw.githubusercontent.com/oddessentials/agent-guild-vscode/main/media/models.png) +## Commands + +| Command | What it does | +| --- | --- | +| **Agent Guild: Open**, or **Agent Guild** in the status bar | Opens the tab, in the view you last chose | +| **Agent Guild: Open the Yard** | Opens the tab straight into the Yard | +| **Agent Guild** in the Explorer | The Yard beside your code | + +There are no settings to configure. + ## Requirements -- Node.js 22 or newer. -- Agent Guild 1.2.0 or newer: `npm install -g @oddessentials/agent-guild`. If it is missing, the tab offers **Install**, which runs that command in a terminal. If it is older, the tab says so and opens it in your browser, where **Update** is. **Agent Guild: Open the Yard** needs 1.3.0 or newer; with 1.2, choose **Settings → View → Yard** in the tab. - VS Code 1.85 or newer, or an editor built on it. -- For the Yard: WebGL, and a one-time 255 MB download of its art the first time it opens. Everything else keeps working meanwhile, and after that it works offline. Without WebGL, the tab offers **Show Cards**. +- Node.js 22 or newer. +- Agent Guild, installed with `npm install -g @oddessentials/agent-guild` or **Install** in the tab. +- For the Yard: WebGL, and a one-time download of about 255 MB of art the first time it opens. After that it works offline. -The extension has no settings. +| Feature | Agent Guild | +| --- | --- | +| The tab | 1.2.0 or newer | +| **Agent Guild: Open the Yard** | 1.3.0 or newer | +| The Yard beside your code | 1.5.0 or newer | + +With an older Agent Guild, the tab or the view says which update it needs. From 1.0.0 the version follows semantic versioning: a new major version only when the extension stops supporting a VS Code, Agent Guild or Node.js version listed here. +## Troubleshooting + +| You see | What to do | +| --- | --- | +| Agent Guild is not installed | Choose **Install**. The tab connects when it finishes. | +| Agent Guild is not running | Choose **Open Agent Guild**, which starts it or offers **Install**. | +| Agent Guild needs Node.js | Install Node.js 22 or newer, then choose **Try again**. | +| Agent Guild did not start | Run `agent-guild doctor` in a terminal to see why. | +| Agent Guild needs an update | Choose **Open in browser**, then **Update** there. | +| Agent Guild stopped responding | Choose **Retry** once it's running again, or **Open Agent Guild** to start it. | + ## Known limits -- While the Agent Guild tab has keyboard focus, VS Code's own shortcuts, such as the Command Palette, go to the page. Click outside the tab to use them. -- Dictate is not offered in the tab. Use it from the browser. -- In a remote window (SSH, WSL, a container), the tab shows the Agent Guild on your own computer, not on the remote one. +- While the Agent Guild tab or view has keyboard focus, VS Code's own shortcuts, such as the Command Palette, go to the page. Click outside it to use them. +- **Dictate** works in the browser, not in VS Code. +- In a remote window (SSH, WSL, a container), the tab and the view show the Agent Guild on your own computer, not on the remote one. - vscode.dev and other editors in a browser are not supported. +## Privacy + +The extension talks only to the Agent Guild on your own computer, and collects no telemetry. Neither does Agent Guild: [its privacy section](https://github.com/oddessentials/agent-guild#privacy) lists what it fetches, such as version checks and usage meters, and none of it carries your code or prompts. + ## Agent Guild: World [Agent Guild: World](https://agentguild.oddessentials.ai/), coming soon to Steam, shows the same sessions as a crew at work in a living 3D world, on Windows, macOS and Linux. It runs on Agent Guild, which stays free. @@ -84,4 +141,6 @@ Pull requests are squash-merged, and their titles are Conventional Commits. Merg ## License -[PolyForm Shield 1.0.0](LICENSE), as Agent Guild. +Free to use, and source-available under [PolyForm Shield 1.0.0](LICENSE), like Agent Guild. + +Claude Code, Codex, Antigravity, Grok and Docker are trademarks of Anthropic, OpenAI, Google, xAI and Docker. Agent Guild is not affiliated with or endorsed by them. diff --git a/media/view.png b/media/view.png new file mode 100644 index 0000000..43789c3 Binary files /dev/null and b/media/view.png differ diff --git a/package.json b/package.json index 346a265..67a5184 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,9 @@ "agent visualization", "animated characters", "subagents", - "three.js" + "three.js", + "companion", + "sidebar" ], "extensionKind": [ "ui" @@ -65,9 +67,20 @@ "main": "src/extension.js", "activationEvents": [ "onStartupFinished", - "onWebviewPanel:agentGuild.page" + "onWebviewPanel:agentGuild.page", + "onView:agentGuild.yard" ], "contributes": { + "views": { + "explorer": [ + { + "type": "webview", + "id": "agentGuild.yard", + "name": "Agent Guild", + "visibility": "collapsed" + } + ] + }, "commands": [ { "command": "agentGuild.open", diff --git a/src/extension.js b/src/extension.js index 0a9ba75..ae73b85 100644 --- a/src/extension.js +++ b/src/extension.js @@ -1,12 +1,19 @@ -// Agent Guild in an editor tab. The session manager owns the sessions; closing the tab or VS Code leaves them running. +// Agent Guild in an editor tab, and its Yard in a small view. The session manager owns the sessions; closing either or VS Code leaves them running. const vscode = require('vscode'); const { Panel, VIEW_TYPE } = require('./panel'); +const { YardView, VIEW_ID } = require('./yard'); function activate(context) { - const ready = new vscode.EventEmitter(); - const open = () => Panel.open(ready); - const openYard = () => Panel.open(ready, 'yard'); + const events = { + ready: new vscode.EventEmitter(), + viewReady: new vscode.EventEmitter(), + look: new vscode.EventEmitter(), + connected: new vscode.EventEmitter(), + }; + const open = () => Panel.open(events); + const openYard = () => Panel.open(events, 'yard'); + const yard = new YardView(context.globalState, events); const button = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100); button.text = '$(organization) Agent Guild'; @@ -15,16 +22,17 @@ function activate(context) { button.show(); context.subscriptions.push( - ready, + ...Object.values(events), button, vscode.commands.registerCommand('agentGuild.open', open), vscode.commands.registerCommand('agentGuild.openYard', openYard), vscode.window.registerWebviewPanelSerializer(VIEW_TYPE, { - deserializeWebviewPanel: async (panel) => Panel.revive(panel, ready), + deserializeWebviewPanel: async (panel) => Panel.revive(panel, events), }), + vscode.window.registerWebviewViewProvider(VIEW_ID, yard), ); - // For the extension's own tests: open the tab and hear when the page reached its manager. - return { open, openYard, onReady: ready.event }; + // For the extension's own tests: open the tab and the view, and hear when their pages reached the manager. + return { open, openYard, yard, onReady: events.ready.event, onViewReady: events.viewReady.event }; } module.exports = { activate, deactivate() {} }; diff --git a/src/manager.js b/src/manager.js index 30398a4..5b7e376 100644 --- a/src/manager.js +++ b/src/manager.js @@ -9,6 +9,8 @@ const { spawn } = require('child_process'); const MIN_VERSION = '1.2.0'; /** The first manager whose page opens the view VS Code asks for. */ const YARD_VERSION = '1.3.0'; +/** The first manager whose page can fill a small view with the Yard. */ +const VIEW_VERSION = '1.5.0'; const PACKAGE = '@oddessentials/agent-guild'; /** The manager's per-user data folder, by the same rules as agent-guild's src/manager/config.mjs. */ @@ -109,6 +111,29 @@ function pageSrc({ url, token, version }, view) { return `${url}/#${hash}`; } +/** The page's address in the small view, in the tab's last look. */ +function compactSrc({ url, token }, look) { + const hash = new URLSearchParams({ token, layout: 'compact' }); + if (look) { + hash.set('skin', look.skin); + hash.set('theme', look.theme); + } + return `${url}/#${hash}`; +} + +/** The skin and theme a page reported, or null. */ +function readLook(value) { + const skin = value?.skin; + const theme = value?.theme; + if (typeof skin !== 'string' || !/^[a-z][a-z0-9-]{0,31}$/.test(skin) || (theme !== 'light' && theme !== 'dark')) return null; + return { skin, theme }; +} + +/** A session id from a page, or null. */ +function sessionId(value) { + return typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value) ? value : null; +} + /** The absolute http(s) URL `href` names, or null. */ function webUrl(href) { try { @@ -119,4 +144,6 @@ function webUrl(href) { } } -module.exports = { MIN_VERSION, YARD_VERSION, PACKAGE, dataDir, readManager, health, newEnough, locate, startFailure, pageSrc, webUrl }; +module.exports = { + MIN_VERSION, YARD_VERSION, VIEW_VERSION, PACKAGE, dataDir, readManager, health, newEnough, locate, startFailure, pageSrc, compactSrc, readLook, sessionId, webUrl, +}; diff --git a/src/panel.js b/src/panel.js index bd9c020..8456efb 100644 --- a/src/panel.js +++ b/src/panel.js @@ -2,7 +2,7 @@ const vscode = require('vscode'); const crypto = require('crypto'); -const { MIN_VERSION, YARD_VERSION, PACKAGE, health, locate, newEnough, pageSrc, webUrl } = require('./manager'); +const { MIN_VERSION, YARD_VERSION, PACKAGE, health, locate, newEnough, pageSrc, readLook, webUrl } = require('./manager'); const VIEW_TYPE = 'agentGuild.page'; const NODE_URL = 'https://nodejs.org/en/download'; @@ -11,34 +11,36 @@ class Panel { /** The one open tab, if any. */ static current = null; - /** Bring the tab forward, connecting first if the manager went away or moved, and show `view` if one is asked for. */ - static async open(ready, view) { + /** Bring the tab forward, connecting first if the manager went away or moved, and show `view` or open `session` if asked. */ + static async open(events, view, session) { if (Panel.current) { Panel.current.panel.reveal(); - return Panel.current.request(view); + return Panel.current.request(view, session); } const panel = vscode.window.createWebviewPanel(VIEW_TYPE, 'Agent Guild', vscode.ViewColumn.Active, { enableScripts: true, retainContextWhenHidden: true, }); - const tab = new Panel(panel, ready); + const tab = new Panel(panel, events); tab.view = view; + tab.session = session; return tab.connect(); } /** A tab VS Code brought back after a restart. */ - static revive(panel, ready) { - return new Panel(panel, ready).connect(); + static revive(panel, events) { + return new Panel(panel, events).connect(); } - constructor(panel, ready) { + constructor(panel, events) { Panel.current?.panel.dispose(); Panel.current = this; this.panel = panel; - this.ready = ready; + this.events = events; this.state = null; this.connecting = null; this.view = null; + this.session = null; panel.iconPath = vscode.Uri.joinPath(vscode.Uri.file(__dirname), '..', 'media', 'icon.png'); panel.webview.options = { enableScripts: true }; panel.onDidDispose(() => { if (Panel.current === this) Panel.current = null; }); @@ -66,12 +68,16 @@ class Panel { return this.connect(); } - /** A view asked for while the tab is open: the page is loaded again only if its manager moved. */ - async request(view) { + /** A view or session asked for while the tab is open: the page is loaded again only if its manager moved. */ + async request(view, session) { this.view = view; + if (session) this.session = session; await this.refresh(); - const asked = this.state?.state === 'ready' && this.take(); + if (this.state?.state !== 'ready') return; + const asked = this.take(); if (asked) this.panel.webview.postMessage({ kind: 'view', view: asked }); + const id = this.takeSession(); + if (id) this.panel.webview.postMessage({ kind: 'session', id }); } /** The asked-for view, once. A blocked tab keeps it until the page can show it. */ @@ -83,15 +89,36 @@ class Panel { return null; } + /** The asked-for session, once. A blocked tab keeps it until the page can open it. */ + takeSession() { + const id = this.session; + this.session = null; + return id; + } + show(state) { this.state = state; const nonce = crypto.randomBytes(16).toString('hex'); - this.panel.webview.html = state.state === 'ready' ? pageHtml(pageSrc(state, this.take()), state.url, nonce) : messageHtml(state, nonce); + if (state.state !== 'ready') { + this.panel.webview.html = messageHtml(state, nonce); + return; + } + this.panel.webview.html = pageHtml(pageSrc(state, this.take()), state.url, nonce, this.takeSession()); + this.events.connected.fire(state); } receive(message) { + // A frame that finished loading may show the browser's error page: keep it only if its manager still answers. + if (message?.kind === 'frame') { + this.refresh(); + return; + } if (message?.kind === 'page') { - if (message.type === 'ready') this.ready.fire(message.version); + if (message.type === 'ready') this.events.ready.fire(message.version); + if (message.type === 'look') { + const look = readLook(message); + if (look) this.events.look.fire(look); + } if (message.type === 'open') { const url = webUrl(message.url); if (url) vscode.env.openExternal(vscode.Uri.parse(url, true)); @@ -138,7 +165,7 @@ class Panel { const escape = (text) => String(text).replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`); -function pageHtml(src, url, nonce) { +function pageHtml(src, url, nonce, session) { return ` @@ -147,26 +174,28 @@ function pageHtml(src, url, nonce) { const vscode = acquireVsCodeApi(); const frame = document.getElementById('page'); const origin = ${JSON.stringify(url)}; -// The page listens for a view once it has loaded. +// The page listens for requests once it has loaded. let loaded = false; -let waiting = null; -const showView = (view) => frame.contentWindow.postMessage({ source: 'agent-guild-vscode', type: 'view', view }, origin); +const waiting = ${JSON.stringify(session ? [{ type: 'session', id: session }] : [])}; +const send = (message) => frame.contentWindow.postMessage({ source: 'agent-guild-vscode', ...message }, origin); +const deliver = (message) => (loaded ? send(message) : waiting.push(message)); frame.addEventListener('load', () => { loaded = true; - if (waiting) showView(waiting); - waiting = null; + waiting.splice(0).forEach(send); + vscode.postMessage({ kind: 'frame', type: 'load', page: '${nonce}' }); }); // Only the framed page's own messages are passed on. addEventListener('message', (event) => { if (event.source !== frame.contentWindow) { - const view = event.data?.kind === 'view' && event.data.view; - if (view === 'cards' || view === 'yard') loaded ? showView(view) : (waiting = view); + const data = event.data; + if (data?.kind === 'view' && (data.view === 'cards' || data.view === 'yard')) deliver({ type: 'view', view: data.view }); + if (data?.kind === 'session' && typeof data.id === 'string') deliver({ type: 'session', id: data.id }); return; } if (event.origin !== origin) return; const data = event.data; if (!data || data.source !== 'agent-guild') return; - vscode.postMessage({ kind: 'page', type: data.type, url: data.url, version: data.version }); + vscode.postMessage({ kind: 'page', page: '${nonce}', type: data.type, url: data.url, version: data.version, skin: data.skin, theme: data.theme, session: data.session }); }); `; } @@ -198,6 +227,11 @@ function messageHtml(state, nonce) { actions: [['browser', 'Open in browser'], ['retry', 'Try again']], } : MESSAGES[state.state]; + return noticeHtml(message, nonce, state.detail); +} + +/** A notice whose buttons post `{ kind: 'action', action }`. */ +function noticeHtml(message, nonce, detail) { const buttons = message.actions.map(([action, label], i) => ``).join(''); return ` @@ -214,7 +248,7 @@ button:focus-visible{outline:1px solid var(--vscode-focusBorder);outline-offset:

${escape(message.title)}

${message.text ? `

${escape(message.text)}

` : ''} -${state.detail ? `
${escape(state.detail)}
` : ''} +${detail ? `
${escape(detail)}
` : ''}
${buttons}
`; } -module.exports = { Panel, VIEW_TYPE }; +module.exports = { Panel, VIEW_TYPE, pageHtml, noticeHtml }; diff --git a/src/yard.js b/src/yard.js new file mode 100644 index 0000000..7711c2a --- /dev/null +++ b/src/yard.js @@ -0,0 +1,144 @@ +// The Yard in a small view beside the code. It draws only while the view shows; its sessions open in the tab. + +const vscode = require('vscode'); +const crypto = require('crypto'); +const { VIEW_VERSION, health, locate, newEnough, compactSrc, readLook, sessionId, webUrl } = require('./manager'); +const { Panel, pageHtml, noticeHtml } = require('./panel'); + +const VIEW_ID = 'agentGuild.yard'; +const LOOK_KEY = 'agentGuild.look'; + +const OPEN = [['tab', 'Open Agent Guild']]; + +class YardView { + constructor(memento, events) { + this.memento = memento; + this.events = events; + this.view = null; + this.shown = null; + this.url = null; + // The page now in the view, and whether its frame loaded and the page reached its manager. + this.page = null; + this.loaded = false; + this.ready = false; + this.attempt = 0; + this.status = null; + // The tab's look, kept for the next time the view opens. + events.look.event((look) => { + const last = this.look(); + if (last && last.skin === look.skin && last.theme === look.theme) return; + this.memento.update(LOOK_KEY, look); + this.render(); + }); + // The tab reached a manager, maybe a new one. + events.connected.event(() => this.render()); + } + + look() { + return readLook(this.memento.get(LOOK_KEY)); + } + + resolveWebviewView(view) { + this.view = view; + this.shown = null; + view.webview.options = { enableScripts: true }; + view.onDidChangeVisibility(() => { + // A hidden view loses its page; VS Code loads it again when the view shows. + if (!view.visible) { + this.loaded = false; + this.ready = false; + return; + } + this.render(); + }); + view.onDidDispose(() => { + if (this.view !== view) return; + this.view = null; + this.shown = null; + this.page = null; + }); + view.webview.onDidReceiveMessage((message) => this.receive(message)); + return this.render(); + } + + /** Show the Yard of the manager running now, unless the view already shows it or is still loading it. */ + async render() { + const view = this.view; + if (!view?.visible) return; + const attempt = ++this.attempt; + if (!this.shown) this.notice({ state: 'connecting' }); + let state; + try { + state = await locate(); + } catch (err) { + state = { state: 'failed', detail: err.message }; + } + if (attempt !== this.attempt || this.view !== view || !view.visible) return; + if (state.state !== 'ready' || !newEnough(state.version, VIEW_VERSION)) return this.notice(state); + const src = compactSrc(state, this.look()); + if (src === this.shown && !(this.loaded && !this.ready)) return; + this.shown = src; + this.url = state.url; + this.status = 'yard'; + this.page = crypto.randomBytes(16).toString('hex'); + this.loaded = false; + this.ready = false; + view.webview.html = pageHtml(src, state.url, this.page); + } + + /** The frame finished loading: unless its page already reached the manager, a manager that no longer answers left an error page. */ + async check() { + const view = this.view; + const page = this.page; + this.loaded = true; + if (this.ready || await health(this.url)) return; + if (this.view !== view || this.page !== page || this.ready || !view.visible) return; + this.notice({ state: 'unavailable' }); + } + + notice(state) { + this.shown = null; + this.page = null; + this.status = state.state; + let message; + if (state.state === 'connecting') message = { title: 'Connecting to Agent Guild…', actions: [] }; + else if (state.state === 'unavailable') { + message = { + title: 'Agent Guild stopped responding', + text: 'Retry once it is running again, or open Agent Guild to start it.', + actions: [['retry', 'Retry'], ...OPEN], + }; + } else if (state.state === 'ready' || state.state === 'old') { + message = { + title: `The Yard here needs Agent Guild ${VIEW_VERSION} or newer`, + text: `The running Agent Guild is ${state.version}. Open Agent Guild to update it.`, + actions: OPEN, + }; + } else message = { title: 'Agent Guild is not running', text: 'Open Agent Guild to install or start it.', actions: OPEN }; + this.view.webview.html = noticeHtml(message, crypto.randomBytes(16).toString('hex')); + } + + receive(message) { + if (message?.kind === 'action') { + if (message.action === 'tab') Panel.open(this.events); + if (message.action === 'retry') this.render(); + return; + } + if (message?.kind === 'frame') { + if (message.page === this.page) this.check(); + return; + } + if (message?.kind !== 'page') return; + if (message.type === 'ready' && message.page === this.page) { + this.ready = true; + this.events.viewReady.fire(message.version); + } + if (message.type === 'expand') Panel.open(this.events, undefined, sessionId(message.session)); + if (message.type === 'open') { + const url = webUrl(message.url); + if (url) vscode.env.openExternal(vscode.Uri.parse(url, true)); + } + } +} + +module.exports = { YardView, VIEW_ID }; diff --git a/test/e2e/suite.js b/test/e2e/suite.js index 79be6e1..84d402e 100644 --- a/test/e2e/suite.js +++ b/test/e2e/suite.js @@ -1,17 +1,218 @@ +const assert = require('assert'); +const fs = require('fs'); +const http = require('http'); +const path = require('path'); const vscode = require('vscode'); +const { VIEW_VERSION, newEnough } = require('../../src/manager'); const READY_WITHIN_MS = 60000; -exports.run = async () => { - const api = await vscode.extensions.getExtension('OddEssentials.agent-guild').activate(); - const version = await new Promise((resolve, reject) => { - const timer = setTimeout(() => reject(new Error(`The page did not report ready within ${READY_WITHIN_MS / 1000} s.`)), READY_WITHIN_MS); - const heard = api.onReady((v) => { +/** The next event, or a failure naming `what` after `ms`. */ +function next(event, what, ms = READY_WITHIN_MS) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + heard.dispose(); + reject(new Error(`${what} did not happen within ${ms / 1000} s.`)); + }, ms); + const heard = event((value) => { clearTimeout(timer); heard.dispose(); - resolve(v); + resolve(value); }); - api.open(); }); - console.log(`Agent Guild ${version} opened in an editor tab and reached its manager.`); +} + +async function until(check, what, ms = 20000) { + const end = Date.now() + ms; + while (Date.now() < end) { + const value = await check(); + if (value) return value; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`${what} did not happen within ${ms / 1000} s.`); +} + +/** How many times `event` fires in the next `ms`: a page that reloads by itself fires `ready` again. */ +async function count(event, ms = 3000) { + let n = 0; + const heard = event(() => n++); + await new Promise((resolve) => setTimeout(resolve, ms)); + heard.dispose(); + return n; +} + +/** + * A stand-in manager that answers its health checks as `answers` says, one each and then the last one again, + * and drops every other request, so its page never loads: a manager that answered and then went away. + */ +async function stub(answers) { + const stand = { checks: 0 }; + const server = http.createServer(async (req, res) => { + if (!req.url.startsWith('/api/v1/health')) return req.socket.destroy(); + const live = await answers[Math.min(stand.checks++, answers.length - 1)](); + res.writeHead(live ? 200 : 503, { 'content-type': 'application/json' }); + res.end(JSON.stringify(live ? { ok: true, name: 'agent-guild', version: '0.0.0-development' } : { ok: false })); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + stand.url = `http://127.0.0.1:${server.address().port}`; + stand.close = () => new Promise((resolve) => server.close(resolve)); + return stand; +} + +/** A promise and the function that settles it. */ +function held() { + let release; + const promise = new Promise((resolve) => { release = resolve; }); + return { promise, release }; +} + +/** The test manager's API, with the token from its data folder. */ +function managerApi() { + const home = process.env.AGENT_GUILD_HOME; + const { url } = JSON.parse(fs.readFileSync(path.join(home, 'manager.json'), 'utf8')); + const token = fs.readFileSync(path.join(home, 'auth-token'), 'utf8').trim(); + return async (method, route, body) => { + const res = await fetch(`${url}/api/v1${route}`, { + method, + headers: { authorization: `Bearer ${token}`, ...(body ? { 'content-type': 'application/json' } : {}) }, + body: body ? JSON.stringify(body) : undefined, + }); + if (!res.ok) throw new Error(`${method} ${route}: ${res.status} ${await res.text()}`); + return res.json(); + }; +} + +/** Ask for `session` from the view, as a double-click does, and wait for the tab to attach to it. */ +async function expandTo(api, call, what) { + const { session } = await call('POST', '/sessions', { providerId: 'shell', name: `e2e ${what}` }); + api.yard.receive({ kind: 'page', type: 'expand', session: session.id }); + await until(async () => (await call('GET', `/sessions/${session.id}`)).session.attachedClients > 0, `The tab opening the session (${what})`); + await call('DELETE', `/sessions/${session.id}`); +} + +exports.run = async () => { + const api = await vscode.extensions.getExtension('OddEssentials.agent-guild').activate(); + const tabReady = next(api.onReady, 'The tab reaching its manager'); + api.open(); + const version = await tabReady; + assert.equal(await count(api.onReady), 0, 'a healthy tab loads its page once'); + console.log(`Agent Guild ${version} opened in an editor tab, reached its manager and stayed loaded.`); + + if (!newEnough(version, VIEW_VERSION)) { + await vscode.commands.executeCommand('agentGuild.yard.focus'); + await until(() => api.yard.status === 'ready', 'The view naming the release it needs'); + console.log(`The view asks for Agent Guild ${VIEW_VERSION} or newer.`); + return; + } + + const viewReady = next(api.onViewReady, 'The view reaching its manager'); + await vscode.commands.executeCommand('agentGuild.yard.focus'); + await viewReady; + assert.equal(await count(api.onViewReady), 0, 'a healthy view loads its page once'); + assert.equal(api.yard.status, 'yard'); + assert.match(api.yard.shown, /layout=compact&skin=[a-z]+&theme=(light|dark)$/, 'the view takes the look the tab reported'); + console.log('The view shows the compact Yard in the look of the tab.'); + + const call = managerApi(); + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + await expandTo(api, call, 'tab closed'); + await vscode.commands.executeCommand('workbench.action.files.newUntitledFile'); + await expandTo(api, call, 'tab hidden'); + await expandTo(api, call, 'tab in front'); + console.log('A session asked for from the view opens in the tab, whether the tab was closed, hidden or in front.'); + + let readies = 0; + const counting = api.onViewReady(() => readies++); + await vscode.commands.executeCommand('workbench.view.search'); + await until(() => !api.yard.view?.visible, 'The view hiding'); + await vscode.commands.executeCommand('agentGuild.yard.focus'); + await until(() => readies > 0, 'The view coming back', READY_WITHIN_MS); + await new Promise((resolve) => setTimeout(resolve, 3000)); + counting.dispose(); + assert.equal(readies, 1, 'the view loads its page once each time it shows'); + console.log('A hidden view loads its page once when it shows again.'); + + const file = path.join(process.env.AGENT_GUILD_HOME, 'manager.json'); + const real = fs.readFileSync(file, 'utf8'); + const pointAt = (url) => fs.writeFileSync(file, JSON.stringify({ ...JSON.parse(real), url })); + const restore = () => fs.writeFileSync(file, real); + try { + // The manager answers the view's check, then is gone before the page loads. + let gone = await stub([() => true, () => false]); + pointAt(gone.url); + api.yard.receive({ kind: 'action', action: 'retry' }); + await until(() => api.yard.status === 'unavailable', 'The view saying Agent Guild stopped responding'); + assert.equal(gone.checks, 2, 'one check to choose the manager, one when its frame loaded'); + restore(); + let back = next(api.onViewReady, 'The view recovering on Retry'); + api.yard.receive({ kind: 'action', action: 'retry' }); + await back; + assert.equal(api.yard.status, 'yard'); + assert.equal(await count(api.onViewReady), 0, 'a recovered view loads its page once'); + await gone.close(); + console.log('A view whose manager vanished before its page loaded says so, and Retry brings the Yard back.'); + + // A check that answers after a newer page loaded changes nothing. + let late = held(); + gone = await stub([() => true, () => late.promise]); + pointAt(gone.url); + api.yard.receive({ kind: 'action', action: 'retry' }); + await until(() => gone.checks === 2, 'The frame check starting'); + restore(); + back = next(api.onViewReady, 'The newer page loading'); + api.yard.receive({ kind: 'action', action: 'retry' }); + await back; + late.release(false); + assert.equal(await count(api.onViewReady), 0); + assert.equal(api.yard.status, 'yard', 'a late failed check does not replace a newer page'); + await gone.close(); + + // A check that answers after its view was disposed changes nothing. + late = held(); + gone = await stub([() => true, () => late.promise]); + pointAt(gone.url); + api.yard.receive({ kind: 'action', action: 'retry' }); + await until(() => gone.checks === 2, 'The frame check starting'); + await vscode.commands.executeCommand('agentGuild.yard.removeView'); + await until(() => api.yard.view === null, 'The view being disposed'); + late.release(false); + await new Promise((resolve) => setTimeout(resolve, 1000)); + assert.equal(api.yard.view, null); + restore(); + back = next(api.onViewReady, 'The view coming back after it was disposed'); + await vscode.commands.executeCommand('agentGuild.yard.focus'); + await back; + assert.equal(api.yard.status, 'yard'); + await gone.close(); + console.log('Checks that answer late leave a newer page and a disposed view alone.'); + + // The tab: its manager is gone when its page loads and comes back at its old address. + await vscode.commands.executeCommand('workbench.view.search'); + await until(() => !api.yard.view?.visible, 'The view hiding'); + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + gone = await stub([() => true, () => { restore(); return false; }]); + pointAt(gone.url); + const tabBack = next(api.onReady, 'The tab recovering'); + api.open(); + await tabBack; + assert.equal(gone.checks, 2, 'the tab checked the vanished manager once after its frame loaded'); + assert.equal(await count(api.onReady), 0, 'a recovered tab loads its page once'); + await gone.close(); + console.log('A tab whose manager vanished before its page loaded reconnects by itself, once.'); + + // The real manager restarts on its port: both pages reconnect by themselves, once each. + back = next(api.onViewReady, 'The view showing again'); + await vscode.commands.executeCommand('agentGuild.yard.focus'); + await back; + const tabAgain = next(api.onReady, 'The tab reconnecting after a restart'); + const viewAgain = next(api.onViewReady, 'The view reconnecting after a restart'); + await call('POST', '/shutdown', { force: true, restart: true }); + await Promise.all([tabAgain, viewAgain]); + const [tabs, views] = await Promise.all([count(api.onReady), count(api.onViewReady)]); + assert.deepEqual([tabs, views], [0, 0], 'after a restart, neither page reloads again'); + assert.equal(api.yard.status, 'yard'); + console.log('After the manager restarts, the tab and the view each reconnect once.'); + } finally { + restore(); + } }; diff --git a/test/unit.test.js b/test/unit.test.js index 60df221..ee2b1d0 100644 --- a/test/unit.test.js +++ b/test/unit.test.js @@ -3,7 +3,7 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const os = require('os'); const path = require('path'); -const { dataDir, readManager, newEnough, startFailure, pageSrc, webUrl } = require('../src/manager'); +const { VIEW_VERSION, dataDir, readManager, newEnough, startFailure, pageSrc, compactSrc, readLook, sessionId, webUrl } = require('../src/manager'); test('the data folder follows agent-guild on every platform', () => { assert.equal(dataDir({ AGENT_GUILD_HOME: 'scratch' }, 'linux', '/home/a'), path.resolve('scratch')); @@ -46,6 +46,23 @@ test('the page opens the asked-for view only on a manager whose page can', () => assert.equal(pageSrc({ ...manager, version: '1.3.0' }, null), `http://127.0.0.1:47821/#token=${manager.token}`); }); +test('the small view asks for the compact Yard in the last look of the tab, and only from a manager that has it', () => { + const manager = { url: 'http://127.0.0.1:47821', token: 'ab'.repeat(24) }; + assert.equal(compactSrc(manager, { skin: 'grove', theme: 'dark' }), `http://127.0.0.1:47821/#token=${manager.token}&layout=compact&skin=grove&theme=dark`); + assert.equal(compactSrc(manager, null), `http://127.0.0.1:47821/#token=${manager.token}&layout=compact`); + assert.equal(newEnough('1.4.0', VIEW_VERSION), false); + assert.equal(newEnough(VIEW_VERSION, VIEW_VERSION), true); +}); + +test('looks and session ids from a page are taken only when well formed', () => { + assert.deepEqual(readLook({ skin: 'goblinville', theme: 'light', extra: 1 }), { skin: 'goblinville', theme: 'light' }); + for (const bad of [null, {}, { skin: 'grove' }, { skin: 'grove', theme: 'dim' }, { skin: 'Grove', theme: 'dark' }, { skin: '">', 'x'.repeat(129)]) assert.equal(sessionId(bad), null); +}); + test('links open only as http(s), and a failed start never shows the token', () => { assert.equal(webUrl('https://github.com/login/device'), 'https://github.com/login/device'); for (const bad of ['file:///etc/passwd', 'javascript:alert(1)', 'vscode://settings', 'not a url']) assert.equal(webUrl(bad), null);