From b61cc762e1b84f1f2cf5dff78819053006f83a87 Mon Sep 17 00:00:00 2001 From: JamesDAdams Date: Thu, 8 Oct 2026 21:37:49 +0200 Subject: [PATCH 1/3] feat: plugin system integration and improvements --- package-lock.json | 30 -- src/client/net.ts | 5 +- src/client/state/slices/index.ts | 2 + src/client/state/slices/plugins.ts | 28 ++ src/client/styles/base.css | 4 +- src/client/ui/elevator.css | 2 +- src/client/ui/elevator.ts | 47 ++- src/client/ui/provider.ts | 8 +- src/client/ui/settings-plugins.ts | 234 ++++++++++++ src/client/ui/settings.css | 23 ++ src/client/ui/settings.ts | 37 +- src/server/building.ts | 150 ++++---- src/server/config.ts | 7 +- src/server/floor.ts | 23 +- src/server/github.ts | 149 +++++--- src/server/office/context.ts | 3 + src/server/office/core.ts | 4 +- src/server/office/floors.ts | 109 +++++- src/server/plugins/index.ts | 3 + src/server/plugins/manager.ts | 552 +++++++++++++++++++++++++++++ src/server/plugins/registry.ts | 79 +++++ src/server/plugins/types.ts | 64 ++++ src/server/providers/custom.ts | 13 +- src/server/providers/index.ts | 23 +- src/server/queue.ts | 71 ++++ src/server/server.ts | 2 + src/server/ws/connection.ts | 1 + src/server/ws/handlers/floors.ts | 74 +++- src/server/ws/handlers/queue.ts | 11 +- src/server/ws/handlers/settings.ts | 84 +++++ src/shared/protocol.ts | 1 + src/shared/protocol/floors.ts | 11 +- src/shared/protocol/plugins.ts | 25 ++ src/shared/protocol/presence.ts | 2 + src/shared/protocol/queue.ts | 2 + src/shared/protocol/settings.ts | 20 +- src/shared/providers.ts | 25 +- tests/agents.test.ts | 4 +- tests/building.test.ts | 16 + tests/client-store.test.ts | 4 +- tests/custom.test.ts | 30 ++ tests/plugins.test.ts | 126 +++++++ tests/repos.test.ts | 4 +- 43 files changed, 1900 insertions(+), 212 deletions(-) create mode 100644 src/client/state/slices/plugins.ts create mode 100644 src/client/ui/settings-plugins.ts create mode 100644 src/server/plugins/index.ts create mode 100644 src/server/plugins/manager.ts create mode 100644 src/server/plugins/registry.ts create mode 100644 src/server/plugins/types.ts create mode 100644 src/shared/protocol/plugins.ts create mode 100644 tests/custom.test.ts create mode 100644 tests/plugins.test.ts diff --git a/package-lock.json b/package-lock.json index e3f643997..be27234f7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1946,9 +1946,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1966,9 +1963,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1986,9 +1980,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2006,9 +1997,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2026,9 +2014,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2046,9 +2031,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4333,9 +4315,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4357,9 +4336,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4381,9 +4357,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -4405,9 +4378,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/src/client/net.ts b/src/client/net.ts index 4cfab43ac..de7db7a52 100644 --- a/src/client/net.ts +++ b/src/client/net.ts @@ -26,8 +26,11 @@ export class Net { private lite = false, ) {} - onMessage(h: Handler) { + onMessage(h: Handler): () => void { this.handlers.push(h); + return () => { + this.handlers = this.handlers.filter((fn) => fn !== h); + }; } onStatus(h: (up: boolean) => void) { diff --git a/src/client/state/slices/index.ts b/src/client/state/slices/index.ts index 06bc121ea..aa3b40f77 100644 --- a/src/client/state/slices/index.ts +++ b/src/client/state/slices/index.ts @@ -32,6 +32,7 @@ import { theme } from './theme'; import { upgrade } from './upgrade'; import { usage } from './usage'; import { whiteboard } from './whiteboard'; +import { plugins } from './plugins'; export const SLICES: readonly Slice[] = [ presence, @@ -61,4 +62,5 @@ export const SLICES: readonly Slice[] = [ team, accounts, signins, + plugins, ]; diff --git a/src/client/state/slices/plugins.ts b/src/client/state/slices/plugins.ts new file mode 100644 index 000000000..6dcc58115 --- /dev/null +++ b/src/client/state/slices/plugins.ts @@ -0,0 +1,28 @@ +import type { PluginsState } from '../../../shared/protocol'; +import type { Slice } from '../store'; + +declare module '../store' { + interface Store { + /** Installed plugins in the office. */ + plugins: PluginsState; + } + interface Topics { + plugins: true; + } +} + +export const plugins: Slice = { + init(s) { + s.plugins = { plugins: [] }; + }, + on: { + welcome(s, m) { + if (m.plugins) s.plugins = m.plugins; + return ['plugins']; + }, + plugins(s, m) { + s.plugins = m.state; + return ['plugins']; + }, + }, +}; diff --git a/src/client/styles/base.css b/src/client/styles/base.css index c39824a75..8856ce521 100644 --- a/src/client/styles/base.css +++ b/src/client/styles/base.css @@ -78,8 +78,8 @@ button { font-family: inherit; } .modal .body { padding: 16px; overflow: auto; } .modal footer { display: flex; gap: 8px; justify-content: flex-end; padding: 12px 16px; border-top: 3px solid var(--ink); background: var(--paper-2); align-items: center; } .modal footer .grow { flex: 1; font-size: 12px; color: var(--muted); font-weight: 700; } -.modal textarea, .modal input[type=text], .modal input[type=password] { width: 100%; font: inherit; font-size: 15px; border: 3px solid var(--ink); border-radius: 12px; padding: 10px 12px; background: #fff; outline: none; resize: vertical; } -.modal textarea:focus, .modal input[type=text]:focus, .modal input[type=password]:focus { border-color: var(--accent); } +.modal textarea, .modal input[type=text], .modal input[type=url], .modal input[type=password] { width: 100%; font: inherit; font-size: 15px; border: 3px solid var(--ink); border-radius: 12px; padding: 10px 12px; background: #fff; outline: none; resize: vertical; } +.modal textarea:focus, .modal input[type=text]:focus, .modal input[type=url]:focus, .modal input[type=password]:focus { border-color: var(--accent); } .modal label { font-weight: 800; font-size: 14px; display: block; margin-bottom: 6px; } .close { width: 34px; height: 34px; padding: 0; justify-content: center; } /* The ✕ openModal gives a window without a header of its own. */ diff --git a/src/client/ui/elevator.css b/src/client/ui/elevator.css index 8aaea1d78..f14a20b22 100644 --- a/src/client/ui/elevator.css +++ b/src/client/ui/elevator.css @@ -1,7 +1,7 @@ /* ---- The elevator: floors, one per project ---------------------------------------------------- */ .modal.elevator { width: min(640px, 100%); } .elevator .intro { margin: 0 0 12px; font-weight: 700; } -.elevator .floors { display: grid; gap: 8px; } +.elevator .floors { display: grid; gap: 8px; max-height: min(420px, calc(100vh - 280px)); overflow-y: auto; padding-right: 4px; } .elevator .floor-btn { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 12px; align-items: center; width: 100%; text-align: left; padding: 9px 12px; font: inherit; color: var(--ink); background: #fff; border: 3px solid var(--ink); border-radius: 14px; box-shadow: 0 3px 0 var(--ink); cursor: pointer; transition: transform .05s; } .elevator .floor-btn:hover:not(:disabled) { background: var(--paper-2); } .elevator .floor-btn:active:not(:disabled) { transform: translateY(2px); box-shadow: 0 1px 0 var(--ink); } diff --git a/src/client/ui/elevator.ts b/src/client/ui/elevator.ts index 36f6c92a9..612b38857 100644 --- a/src/client/ui/elevator.ts +++ b/src/client/ui/elevator.ts @@ -76,7 +76,7 @@ export function openElevator(opts: ElevatorOptions): void { const floorsEl = h('div.floors'); const addEl = h('div.add'); - const input = h('input', { type: 'text', placeholder: 'Search your repositories, or type owner/name', 'aria-label': 'Repository', autocomplete: 'off', spellcheck: 'false' }) as HTMLInputElement; + const input = h('input', { type: 'text', placeholder: 'Search your projects', 'aria-label': 'Project', autocomplete: 'off', spellcheck: 'false' }) as HTMLInputElement; const listEl = h('div.repo-list', { role: 'listbox', 'aria-label': 'Repositories' }); const statusEl = h('div'); const addBtn = h('button.btn.primary', { type: 'button' }, '🛗 Add floor'); @@ -114,8 +114,8 @@ export function openElevator(opts: ElevatorOptions): void { net.send({ t: 'floor.repos' }); }; - /** What "Add floor" would add: the row picked, else what's typed if it's owner/name. */ - const choice = (): string | undefined => selected ?? normalizeRepo(filter); + /** What "Add floor" would add: the row picked, else what's typed if it's owner/name or a path. */ + const choice = (): string | undefined => selected ?? (filter.trim().startsWith('/') || filter.trim().startsWith('~') ? filter.trim() : normalizeRepo(filter)); const floorButton = (f: FloorInfo, i: number) => { // Down in the garage, your floor is somewhere to go back up to. @@ -217,7 +217,6 @@ export function openElevator(opts: ElevatorOptions): void { const renderFloors = () => { const floors = store.floors; const built = floors.some((f) => !f.cloning); - // Top floor first, the way an elevator's buttons stack, with the roof over them, floor 1 and then the garage at the bottom. floorsEl.replaceChildren( ...(built ? [roofButton()] : []), ...(floors.length ? floors.map(floorRow).reverse() : [h('p.empty', {}, 'No floors yet.')]), @@ -226,7 +225,7 @@ export function openElevator(opts: ElevatorOptions): void { }; const repoRow = (r: RepoChoice) => { - const floor = store.floors.find((f) => sameRepo(f.repo, r.name)); + const floor = store.floors.find((f) => sameRepo(f.repo, r.name) || sameRepo(f.dir, r.name) || sameRepo(f.name, r.name)); const row = h( 'div.repo', { role: 'option', class: selected && sameRepo(selected, r.name) ? 'sel' : '', 'aria-selected': String(!!selected && sameRepo(selected, r.name)), title: r.description ?? r.name }, @@ -276,12 +275,14 @@ export function openElevator(opts: ElevatorOptions): void { // owner/name that isn't in the list (someone else's public repository): offer it anyway. if (typed && !r.list.some((x) => sameRepo(x.name, typed))) rows.push(repoRow({ name: typed, private: false, description: 'Not in your list — the office will try to clone it' })); rows.push(...matches.slice(0, SHOWN).map(repoRow)); - if (!rows.length) rows.push(h('p.empty', { style: 'padding:10px' }, r.loading ? 'Asking GitHub for your repositories…' : r.error ? '' : q ? 'Nothing matches. Type owner/name to clone any repository.' : 'No repositories.')); + if (!rows.length) rows.push(h('p.empty', { style: 'padding:10px' }, r.loading ? 'Loading projects…' : r.error ? '' : q ? 'Nothing matches.' : 'Every project is already a floor of this building.')); if (matches.length > SHOWN) rows.push(h('p.empty', { style: 'padding:8px 10px' }, `…and ${matches.length - SHOWN} more — type to narrow it down`)); listEl.replaceChildren(...rows); const pick = choice(); - const dest = pick ? `${store.projectsDir.dir}/${pick}` : `${store.projectsDir.dir}//`; - const change = store.me.admin ? h('button.btn.dir-change', { type: 'button', title: 'Clone new projects into another folder on the office’s machine' }, '📁 Change folder') : null; + // A project may be a folder already on disk or a git repository to clone. + const clone = !!pick && !pick.startsWith('/') && !pick.startsWith('~'); + const dest = pick && !clone ? pick : `${store.projectsDir.dir}//`; + const change = clone && store.me.admin ? h('button.btn.dir-change', { type: 'button', title: 'Clone new projects into another folder on the office’s machine' }, '📁 Change folder') : null; change?.addEventListener('click', () => editDir(true)); // While it clones: how far it's got (the office asks GitHub about it first). const on = addingFloor(); @@ -292,25 +293,32 @@ export function openElevator(opts: ElevatorOptions): void { on ? h('p.note', {}, [cloneStep(on.clone), on.clone?.detail].filter(Boolean).join(' · ')) : null, h('p.note', {}, 'You can close this and carry on: everyone hears when the new floor opens.'), ] - : [h('p.note', {}, `Cloned into ${dest} with this machine's gh login. Everything on the new floor works in that checkout.`, change)]; + : [ + clone + ? h('p.note', {}, `Cloned into ${dest} with this machine's gh login. Everything on the new floor works in that checkout.`) + : h('p.note', {}, `${dest} is already on disk. It becomes a floor, and every worker works in it.`), + change, + ]; statusEl.replaceChildren(...lines.filter((l): l is HTMLElement => !!l), ...[r.error, error].filter(Boolean).map((e) => h('p.err', {}, e))); - addBtn.disabled = !!adding || !pick || store.floors.some((f) => sameRepo(f.repo, pick)); - addBtn.textContent = adding ? '⏳ Cloning…' : pick ? `🛗 Add ${pick}` : '🛗 Add floor'; + addBtn.disabled = !!adding || !pick || store.floors.some((f) => sameRepo(f.repo, pick) || sameRepo(f.dir, pick) || sameRepo(f.name, pick)); + addBtn.textContent = adding ? '⏳ Adding…' : pick && !clone ? '🛗 Add this project' : pick ? `🛗 Add ${pick}` : '🛗 Add floor'; input.disabled = !!adding; if (!built) { built = true; + const maxFloors = store.projectsDir.maxFloors ?? 500; addEl.replaceChildren( h('h3', {}, setup && !store.floors.length ? 'Pick your first project' : '➕ Add a project'), h('div.repo-search', {}, input, refreshBtn), listEl, statusEl, + h('p.note.max-floors-info', { style: 'margin-top:6px;font-size:12px;color:var(--muted);' }, `Showing up to ${maxFloors} projects as floors (configured in ⚙️ Settings → Building).`), dirEl, ); } }; /** The floor being added, once the office is cloning it (and after, when it's there). */ - const addingFloor = () => (adding ? store.floors.find((f) => sameRepo(f.repo, adding!)) : undefined); + const addingFloor = () => (adding ? store.floors.find((f) => sameRepo(f.repo, adding!) || sameRepo(f.dir, adding!) || sameRepo(f.name, adding!)) : undefined); const add = (repo: string) => { if (adding) return; @@ -370,7 +378,7 @@ export function openElevator(opts: ElevatorOptions): void { if (e.key !== 'Enter' || e.isComposing) return; e.preventDefault(); const q = filter.trim().toLowerCase(); - const matches = store.repos.list.filter((x) => !store.floors.some((f) => sameRepo(f.repo, x.name)) && (x.name.toLowerCase().includes(q) || (x.description ?? '').toLowerCase().includes(q))); + const matches = store.repos.list.filter((x) => !store.floors.some((f) => sameRepo(f.repo, x.name) || sameRepo(f.dir, x.name) || sameRepo(f.name, x.name)) && (x.name.toLowerCase().includes(q) || (x.description ?? '').toLowerCase().includes(q))); const pick = choice() ?? (q && matches.length === 1 ? matches[0].name : undefined); if (pick) add(pick); }); @@ -398,7 +406,18 @@ export function openElevator(opts: ElevatorOptions): void { { role: 'dialog', 'aria-label': 'Elevator' }, h('header', {}, h('h2', {}, setup ? '🏢 Welcome to Agent Office' : '🛗 Elevator'), close), h('div.body', {}, intro, floorsEl, addEl), - h('footer', {}, h('span.grow', {}, setup ? 'Your office, one floor per project · Esc to look around first' : 'Pick a floor · Esc to stay here'), addBtn), + h( + 'footer', + {}, + h( + 'span.grow', + {}, + setup + ? 'Your office · Esc to look around first' + : 'Pick a floor · Esc to stay here', + ), + addBtn, + ), ); const unsubs = [store.on('floors', () => (checkAdding(), renderFloors(), renderAdd())), store.on('repos', renderAdd), store.on('projectsDir', () => (editDir(false), renderAdd())), store.on('floor', renderFloors), store.on('peers', renderFloors), store.on('me', () => (renderFloors(), renderAdd()))]; const modal = openModal(el, { diff --git a/src/client/ui/provider.ts b/src/client/ui/provider.ts index 94b0d7aa2..3a354ee38 100644 --- a/src/client/ui/provider.ts +++ b/src/client/ui/provider.ts @@ -1,11 +1,15 @@ import './provider.css'; import type { AgentChoice, AgentEffort, AgentProvider, ProjectInfo, Usage, WorkerInfo } from '../../shared/protocol'; import { AGENT_EFFORTS, isAgentEffort } from '../../shared/protocol'; -import { AGENT_PROVIDERS, CLAUDE_MODEL_NAMES, PROVIDER_META, claudeModelName, isAgentProvider, isClaudeModel, takesEffort, type ModelOption } from '../../shared/providers'; +import { AGENT_PROVIDERS, CLAUDE_MODEL_NAMES, PROVIDER_META, claudeModelName, isAgentProvider, isClaudeModel, takesEffort, type ModelOption, getAllProviders } from '../../shared/providers'; import { store } from '../state'; import { h } from './dom'; -export const PROVIDER_LABEL = Object.fromEntries(AGENT_PROVIDERS.map((p) => [p, PROVIDER_META[p].label])) as Record; +export function getProviderLabel(provider: string): string { + return PROVIDER_META[provider]?.label ?? provider; +} + +export const PROVIDER_LABEL: Record = Object.fromEntries(getAllProviders().map((p) => [p, PROVIDER_META[p].label])); export const EFFORT_LABEL: Record = { low: 'Low', diff --git a/src/client/ui/settings-plugins.ts b/src/client/ui/settings-plugins.ts new file mode 100644 index 000000000..4548adc83 --- /dev/null +++ b/src/client/ui/settings-plugins.ts @@ -0,0 +1,234 @@ +import { h } from './dom'; +import { store } from '../state'; +import type { Net } from '../net'; +import type { PluginCapability, PluginManifest } from '../../shared/protocol'; + +export function pluginsSetting(net?: Net) { + const container = h('div.plugins-list'); + + const CAPABILITY_LABEL: Record = { + floors: '🏢 Floors', + queue: '📋 Tasks Queue', + services: '🌐 Services Board', + agent: '🤖 Agent Provider', + issues: '📌 Issues Board', + pulls: '🔀 PR Board', + }; + + const paintPluginCard = (plugin: PluginManifest, isInstalled: boolean) => { + const statusClass = plugin.status === 'active' ? 'active' : plugin.status === 'error' ? 'error' : 'inactive'; + const statusLabel = plugin.status === 'active' ? '● Active' : plugin.status === 'error' ? '✖ Error' : '○ Inactive'; + + const caps = (plugin.capabilities || []).map((cap) => + h('span.plugin-cap', {}, CAPABILITY_LABEL[cap] || cap), + ); + + const admin = store.me?.admin; + + const installBtn = h( + 'button.btn', + { + type: 'button', + class: isInstalled ? 'btn-warn' : 'primary', + disabled: !admin, + title: admin ? (isInstalled ? 'Uninstall plugin' : 'Install plugin') : 'Only admins can install or uninstall plugins', + onclick: (e: Event) => { + if (!admin) return; + const btn = e.currentTarget as HTMLButtonElement; + btn.disabled = true; + btn.textContent = isInstalled ? '⏳ Uninstalling…' : '⏳ Installing…'; + if (isInstalled) { + net?.send({ t: 'plugin.uninstall', id: plugin.id }); + } else { + net?.send({ t: 'plugin.install', id: plugin.id }); + } + }, + }, + isInstalled ? '🗑️ Uninstall' : '⬇️ Install', + ); + + const openFolderBtn = isInstalled && admin + ? h( + 'button.btn', + { + type: 'button', + title: 'Open the directory where this plugin is installed', + onclick: () => { + net?.send({ t: 'plugin.openFolder', id: plugin.id }); + }, + }, + '📁 Open folder', + ) + : null; + + const githubBtn = plugin.githubUrl + ? h( + 'a.btn', + { + href: plugin.githubUrl, + target: '_blank', + rel: 'noopener', + title: 'View plugin repository on GitHub', + style: 'text-decoration:none;display:inline-flex;align-items:center;', + }, + '🌐 GitHub ↗', + ) + : null; + + const updateBtn = isInstalled && plugin.updateAvailable && admin + ? h( + 'button.btn.primary', + { + type: 'button', + title: `Update to ${plugin.latestVersion}`, + onclick: (e: Event) => { + const btn = e.currentTarget as HTMLButtonElement; + btn.disabled = true; + btn.textContent = '⏳ Updating…'; + net?.send({ t: 'plugin.update', id: plugin.id }); + }, + }, + `⬆️ Update to ${plugin.latestVersion}`, + ) + : null; + + const actions = h('div.plugin-actions', {}, installBtn, updateBtn, openFolderBtn, githubBtn); + + const versionLabel = plugin.version ? `v${plugin.version}` : plugin.local ? 'local' : ''; + + return h( + 'div.plugin-card', + { class: statusClass }, + h( + 'div.plugin-head', + {}, + h( + 'div.plugin-title-row', + {}, + h('h5.plugin-name', {}, plugin.name), + versionLabel ? h('span.plugin-version', {}, versionLabel) : null, + plugin.author ? h('span.plugin-author', {}, `by ${plugin.author}`) : null, + ), + h('span.plugin-status', { class: statusClass }, statusLabel), + ), + h('p.plugin-desc', {}, plugin.description), + caps.length > 0 ? h('div.plugin-caps', {}, ...caps) : null, + actions, + plugin.error ? h('p.plugin-error', {}, `Error: ${plugin.error}`) : null, + ); + }; + + const paint = () => { + const installed = store.plugins?.plugins || []; + const pluginStore = store.plugins?.store || []; + container.replaceChildren(); + + const admin = store.me?.admin; + const headerBtns: HTMLElement[] = []; + if (admin) { + headerBtns.push( + h( + 'button.btn', + { + type: 'button', + title: 'Open the plugins folder on disk', + onclick: () => net?.send({ t: 'plugin.openFolder' }), + }, + '📁 Plugins folder', + ), + ); + } + headerBtns.push( + h( + 'button.btn', + { + type: 'button', + title: 'Refresh plugins list', + onclick: () => net?.send({ t: 'plugins.list' }), + }, + '↻', + ), + ); + + const headerBar = h( + 'div.plugin-store-header', + { style: 'display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;' }, + h('p.setting-note', { style: 'margin:0;' }, `Plugin Store (${pluginStore.length} available)`), + h('div', { style: 'display:flex;gap:6px;' }, ...headerBtns), + ); + container.append(headerBar); + + if (admin) { + const gitInput = h('input', { + type: 'text', + placeholder: 'https://github.com/user/repo', + 'aria-label': 'Git URL', + style: 'flex:1;font:inherit;font-size:14px;border:3px solid var(--ink);border-radius:12px;padding:8px 12px;background:#fff;outline:none;box-sizing:border-box;', + }) as HTMLInputElement; + + const doGitInstall = () => { + const url = gitInput.value.trim(); + if (url) { + gitInstallBtn.disabled = true; + gitInstallBtn.textContent = '⏳ Installing…'; + net?.send({ t: 'plugin.installFromGit', url }); + gitInput.value = ''; + setTimeout(() => { + gitInstallBtn.disabled = false; + gitInstallBtn.textContent = '⬇️ Install from Git'; + }, 8000); + } + }; + + gitInput.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + doGitInstall(); + } + }); + + const gitInstallBtn = h( + 'button.btn.primary', + { + type: 'button', + onclick: () => doGitInstall(), + }, + '⬇️ Install from Git', + ) as HTMLButtonElement; + + const gitInstallBar = h( + 'div.plugin-git-install', + { style: 'display:flex;gap:6px;margin-bottom:12px;align-items:center;' }, + gitInput, + gitInstallBtn, + ); + container.append(gitInstallBar); + } + + const installedSection = h('div.plugin-section', {}, h('h4', {}, `Installed (${installed.length})`)); + if (installed.length === 0) { + installedSection.append(h('p.setting-note', {}, 'No plugins installed')); + } else { + for (const plugin of installed) { + installedSection.append(paintPluginCard(plugin, true)); + } + } + container.append(installedSection); + + const storeSection = h('div.plugin-section', {}, h('h4', {}, `Available (${pluginStore.length})`)); + if (pluginStore.length === 0) { + storeSection.append(h('p.setting-note', {}, 'No plugins available in the registry')); + } else { + for (const plugin of pluginStore) { + storeSection.append(paintPluginCard(plugin, false)); + } + } + container.append(storeSection); + }; + + paint(); + return { + element: container, + paint, + }; +} diff --git a/src/client/ui/settings.css b/src/client/ui/settings.css index 61cf03502..f81ed25f3 100644 --- a/src/client/ui/settings.css +++ b/src/client/ui/settings.css @@ -38,3 +38,26 @@ .volume input[type=range]::-moz-range-thumb { width: 18px; height: 18px; border-radius: 50%; border: 3px solid var(--ink); background: var(--paper); box-shadow: 0 2px 0 var(--ink); } .volume input[type=range]:focus-visible::-webkit-slider-thumb { border-color: var(--accent); } .volume.muted input[type=range], .volume.muted .vol-pct { opacity: .45; } + +/* Settings: plugins */ +.plugins-list { display: flex; flex-direction: column; gap: 16px; } +.plugin-section { display: flex; flex-direction: column; gap: 12px; } +.plugin-section h4 { margin: 0 0 4px 0; font-size: 15px; font-weight: 800; } +.plugin-card { padding: 14px; border: 2px solid var(--ink); border-radius: 12px; background: var(--paper-2); display: flex; flex-direction: column; gap: 8px; } +.plugin-card.active { border-color: #2e7d32; background: #f1f8e9; } +.plugin-card.error { border-color: #c62828; background: #ffebee; } +.plugin-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.plugin-title-row { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; } +.plugin-name { margin: 0; font-size: 16px; font-weight: 900; } +.plugin-version { font-size: 12px; font-weight: 700; color: var(--muted); } +.plugin-author { font-size: 12px; font-weight: 600; color: var(--muted); } +.plugin-status { font-size: 11px; font-weight: 900; padding: 2px 8px; border-radius: 999px; border: 2px solid var(--ink); text-transform: uppercase; } +.plugin-status.active { background: #a5d6a7; color: #1b5e20; } +.plugin-status.inactive { background: #e0e0e0; color: #616161; } +.plugin-status.error { background: #ef9a9a; color: #b71c1c; } +.plugin-desc { margin: 0; font-size: 13.5px; font-weight: 500; line-height: 1.4; } +.plugin-caps { display: flex; gap: 6px; flex-wrap: wrap; } +.plugin-cap { font-size: 11px; font-weight: 700; padding: 2px 6px; background: #fff; border: 1.5px solid var(--ink); border-radius: 6px; } +.plugin-actions { display: flex; gap: 8px; align-items: center; margin-top: 6px; flex-wrap: wrap; } +.plugin-actions .btn { font-size: 12px; padding: 4px 10px; } +.plugin-error { margin: 0; font-size: 12px; font-weight: 700; color: #c62828; } diff --git a/src/client/ui/settings.ts b/src/client/ui/settings.ts index ba8dae5c2..5c4cf5fec 100644 --- a/src/client/ui/settings.ts +++ b/src/client/ui/settings.ts @@ -12,6 +12,7 @@ import { agentFields, choiceLabel, officeChoice } from './provider'; import { openPromptEditor, rewrittenPrompts } from './prompts'; import { outsideSetting } from './settings-sky'; import { choiceRow } from './settings-rows'; +import { pluginsSetting } from './settings-plugins'; const VIEWS: [ViewMode, string, string][] = [ ['first', '👀 First person', 'See through your own eyes. Click the office to look around with the mouse and click things to use them. Esc frees the mouse.'], @@ -23,7 +24,7 @@ const THEME_LABEL: Record = { auto: '📅 By the calendar', h const WEBHOOK_NAME: Record = { slack: 'Slack', discord: 'Discord', other: 'a webhook' }; /** The categories down the side of ⚙️ Settings. */ -export type SettingsPane = 'you' | 'sound' | 'notify' | 'building' | 'workers'; +export type SettingsPane = 'you' | 'sound' | 'notify' | 'building' | 'workers' | 'plugins'; const PANES: { id: SettingsPane; icon: string; label: string; blurb: string }[] = [ { id: 'you', icon: '🧍', label: 'You', blurb: 'How you look, how you see the office, and how you’re signed in.' }, @@ -31,6 +32,7 @@ const PANES: { id: SettingsPane; icon: string; label: string; blurb: string }[] { id: 'notify', icon: '🔔', label: 'Notifications', blurb: 'Hear about a worker that needs someone, or finished, while you’re somewhere else.' }, { id: 'building', icon: '🏢', label: 'Building', blurb: 'The map, the decorations, the sky, the dog, and where new floors are cloned.' }, { id: 'workers', icon: '🤖', label: 'Workers', blurb: 'What workers start on, how many run at once, when they go home and what the office tells them.' }, + { id: 'plugins', icon: '🔌', label: 'Plugins', blurb: 'Installed harness and extension plugins.' }, ]; /** Who a setting is for, shown by its name: some are yours alone, some the whole office's. */ @@ -118,6 +120,7 @@ export function openSettings(net: Net, settings: Settings, onChange: (s: Setting // Voice chat: an open mic, or muted until you hold V. const talkRow = choiceRow('Voice chat', [[false, '🎙️ Open mic'], [true, '✋ Push to talk']], () => settings.pushToTalk, (pushToTalk) => change({ pushToTalk })); const musicRow = volumeRow('Jukebox volume', 'music', 'musicMuted'); + const plugins = pluginsSetting(net); // The swish of the book's pages at the bookshelf, on or off. const pagesRow = choiceRow('Page turns at the bookshelf', [[true, '📖 On'], [false, 'Off']], () => settings.pageTurns, (pageTurns) => change({ pageTurns })); @@ -433,6 +436,29 @@ export function openSettings(net: Net, settings: Settings, onChange: (s: Setting }); dirDefault.addEventListener('click', () => net.send({ t: 'floor.projectsDir', dir: '' })); + // Max floors / projects limit in building + const maxFloorsInput = h('input', { type: 'text', placeholder: '500', 'aria-label': 'Max floors limit', spellcheck: 'false', autocomplete: 'off' }) as HTMLInputElement; + const maxFloorsSave = h('button.btn.primary', { type: 'button' }, 'Save'); + const maxFloorsRow = h('div.webhook', {}, maxFloorsInput, maxFloorsSave); + const maxFloorsNote = h('p.setting-note'); + const paintMaxFloors = () => { + const limit = store.projectsDir.maxFloors ?? 500; + const admin = store.me.admin; + maxFloorsInput.value = String(limit); + maxFloorsRow.classList.toggle('hidden', !admin); + maxFloorsNote.textContent = `The building displays up to ${limit} projects as floors. ${admin ? 'Admins can change this limit.' : 'An admin can change it.'}`; + }; + paintMaxFloors(); + const saveMaxFloors = () => { + const val = Number(maxFloorsInput.value); + if (!val || val < 1) return maxFloorsInput.focus(); + net.send({ t: 'floor.maxFloors', max: val }); + }; + maxFloorsSave.addEventListener('click', saveMaxFloors); + maxFloorsInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') saveMaxFloors(); + }); + // The dog on this floor: its name, breed and coat, for everyone here (see settings-dog.ts). const { section: dogSection, paint: paintDog } = dogSetting(net, (body) => setting('Office dog', 'floor', ...body)); @@ -465,6 +491,7 @@ export function openSettings(net: Net, settings: Settings, onChange: (s: Setting ...(sky ? [sky.section] : []), dogSection, setting('Workspace folder', 'office', dirRow, dirActions, dirNote), + setting('Max floors / displayed projects', 'office', maxFloorsRow, maxFloorsNote), ], workers: [ setting('Default worker', 'office', agentNow, agent.element, agentActions, agentNote), @@ -472,6 +499,9 @@ export function openSettings(net: Net, settings: Settings, onChange: (s: Setting setting('Workers whose pull request merged', 'office', leaveRow, leaveNote), setting('Prompts', 'office', promptsOpen, promptsNote), ], + plugins: [ + setting('Plugin Store & Extensions', null, plugins.element), + ], }; // The categories down the side, the one picked on the right. @@ -514,8 +544,10 @@ export function openSettings(net: Net, settings: Settings, onChange: (s: Setting const offMap = store.on('map', paintMap); const offLeave = store.on('leaveOnMerge', paintLeave); const offLimit = [store.on('machine', paintLimit), store.on('me', paintLimit)]; - const offDir = [store.on('projectsDir', paintDir), store.on('me', paintDir)]; + const offDir = [store.on('projectsDir', paintDir), store.on('projectsDir', paintMaxFloors), store.on('me', paintDir), store.on('me', paintMaxFloors)]; const offPrompts = [store.on('prompts', paintAgent), store.on('prompts', paintPrompts), store.on('me', paintAgent), store.on('me', paintPrompts)]; + const offPlugins = store.on('plugins', plugins.paint); + net.send({ t: 'plugins.list' }); const modal = openModal(el, { doing: '⚙️ in settings', onClose: () => { @@ -528,6 +560,7 @@ export function openSettings(net: Net, settings: Settings, onChange: (s: Setting offLimit.forEach((off) => off()); offDir.forEach((off) => off()); offPrompts.forEach((off) => off()); + offPlugins(); }, }); show(first ?? lastPane); diff --git a/src/server/building.ts b/src/server/building.ts index 2716aeacd..d3e08bcbc 100644 --- a/src/server/building.ts +++ b/src/server/building.ts @@ -17,29 +17,22 @@ export interface FloorDef { palette: number; addedBy: string; addedAt: number; + /** Optional external identifier or metadata from a plugin. */ + externalId?: string; + url?: string; } /** A projects folder picked in ⚙️ Settings (or with --projects), as projects-folder.json keeps it. */ -interface PickedDir { - dir: string; - by: string; - at: number; -} +interface PickedDir { dir: string; by: string; at: number; } /** The checkout the office was started in, once it's been taken off the building (local-floor.json). */ -interface LocalOff { - dir: string; - by: string; - at: number; -} +interface LocalOff { dir: string; by: string; at: number; } /** A floor on its way: its clone, and who can stop it besides admins. */ interface Pending { def: FloorDef; run?: CloneRun; - /** The account that added it. */ owner?: string; - /** GitHub says the repository has no commits yet, so there's nothing to check out. */ empty: boolean; } @@ -52,27 +45,25 @@ interface SavedClone extends FloorDef { } export interface BuildingOptions { - /** - * Clones run in this terminal (`agent-office setup`), showing git's own progress and asking - * there if ssh or git has a question, rather than watched by the office. - */ + /** Clones run in this terminal (setup), showing git's progress rather than being watched. */ terminal?: boolean; /** How clones are watched (tests shorten these). */ clone?: Pick; } -/** How long the list of repositories `gh` can see is reused before it's asked again. */ +/** How long `gh`'s repository list is reused before it's asked again. */ const REPOS_TTL_MS = 5 * 60_000; const MAX_REPOS = 1000; /** * The floors of the building, saved in /.agent-office/floors.json: which projects there are, - * where their checkouts live, and how each floor is painted. New floors are cloned with the office - * machine's `gh` login into //; the projects folder can be picked in ⚙️ Settings - * (kept in projects-folder.json). + * where their checkouts live, and how each floor is painted. A floor is a clone into + * // (`add`), or a checkout that is already on disk (`addExisting`, for an + * existing project folder). The projects folder can be picked in ⚙️ Settings (projects-folder.json). */ export class Building { private defs: FloorDef[] = []; + private removedIds = new Set(); private file: string; private pickedFile: string; private picked?: PickedDir; @@ -89,6 +80,8 @@ export class Building { /** The floor that checkout is, while it is one. */ private localId?: string; private localFile: string; + private maxFloorsFile: string; + private maxFloorsLimit: number = MAX_FLOORS; /** That checkout was taken off the building: a restart doesn't put it back. */ private localOff?: LocalOff; @@ -104,9 +97,30 @@ export class Building { this.logsDir = path.join(dataDir, 'clones'); this.pickedFile = path.join(dataDir, 'projects-folder.json'); this.localFile = path.join(dataDir, 'local-floor.json'); + this.maxFloorsFile = path.join(dataDir, 'max-floors.json'); this.load(); this.loadPicked(); this.loadLocalOff(); + this.loadMaxFloors(); + } + + private loadMaxFloors() { + if (!existsSync(this.maxFloorsFile)) return; + try { + const raw = JSON.parse(readFileSync(this.maxFloorsFile, 'utf8')); + if (typeof raw?.max === 'number' && raw.max > 0) this.maxFloorsLimit = raw.max; + } catch { /* ignore */ } + } + + setMaxFloors(max: number, by: string) { + this.maxFloorsLimit = Math.max(1, Math.min(2000, Math.floor(max))); + try { + writeFileSync(this.maxFloorsFile, JSON.stringify({ max: this.maxFloorsLimit, by, at: Date.now() }, null, 2), { mode: 0o600 }); + } catch { /* ignore */ } + } + + get maxFloors(): number { + return this.maxFloorsLimit; } /** Where new floors are cloned. Floors already there stay where they are when it moves. */ @@ -115,7 +129,7 @@ export class Building { } projectsDirState(): ProjectsDirState { - return { dir: tildify(this.projectsDir), custom: !!this.picked, by: this.picked?.by, at: this.picked?.at }; + return { dir: tildify(this.projectsDir), custom: !!this.picked, by: this.picked?.by, at: this.picked?.at, maxFloors: this.maxFloors }; } /** Clones new floors into `raw` from now on ('~' is the home folder; '' goes back to the default). Returns why it can't, if it can't. */ @@ -147,6 +161,36 @@ export class Building { return this.defs; } + setList(defs: FloorDef[]) { + const filtered = defs.filter((d) => !this.removedIds.has(d.id) && !this.removedIds.has(path.resolve(d.dir)) && (!d.externalId || !this.removedIds.has(d.externalId))); + if (JSON.stringify(this.defs) === JSON.stringify(filtered)) return; + this.defs = filtered; + this.save(); + } + + unremove(idOrDef: string | FloorDef) { + const [id, dir, ext] = typeof idOrDef === 'string' ? [idOrDef, idOrDef, undefined] : [idOrDef.id, idOrDef.dir, idOrDef.externalId]; + this.removedIds.delete(id); + this.removedIds.delete(path.resolve(dir)); + if (ext) this.removedIds.delete(ext); + } + + /** A floor for a checkout already on disk, so nothing is cloned. */ + addExisting(dir: string, name: string, by: string, externalId?: string, url?: string): FloorDef | string { + const abs = path.resolve(dir); + this.removedIds.delete(abs); if (externalId) this.removedIds.delete(externalId); + const dup = this.defs.some((d) => path.resolve(d.dir) === abs || (externalId && d.externalId === externalId)); + if (dup) return `${name} already has a floor`; + if (this.defs.length >= this.maxFloors) return `The building is full (${this.maxFloors} floors)`; + const def = this.newDef(name, externalId ? undefined : originRepo(abs), abs, by); + def.externalId = externalId; + def.url = url; + if (externalId) def.repo = undefined; + this.defs.push(def); + this.save(); + return def; + } + /** Floors on their way: shown in the elevator, but nobody can ride there yet. */ pending(): FloorDef[] { return [...this.cloning.values()].map((p) => p.def); @@ -266,6 +310,8 @@ export class Building { const def = this.defs.find((d) => d.id === id); if (!def) return this.pendingFloor(id) ? "That floor is still being cloned — stop it, or take it off once it's there" : 'No such floor'; this.defs = this.defs.filter((d) => d !== def); + this.removedIds.add(id); this.removedIds.add(path.resolve(def.dir)); + if (def.externalId) this.removedIds.add(def.externalId); if (this.isLocal(id)) { this.localId = undefined; this.setLocalOff({ dir: def.dir, by, at: Date.now() }); @@ -285,11 +331,13 @@ export class Building { if (!wanted) return 'Pick a repository, or type it as owner/name'; if (this.defs.some((d) => sameRepo(d.repo, wanted))) return `${wanted} already has a floor`; if (this.cloning.has(wanted.toLowerCase())) return `${wanted} is already being cloned`; - if (this.defs.length + this.cloning.size >= MAX_FLOORS) return `The building is full (${MAX_FLOORS} floors)`; + if (this.defs.length + this.cloning.size >= this.maxFloors) return `The building is full (${this.maxFloors} floors)`; // The office's own checkout, taken off before: it moves back in where it is, not into a second clone. const home = this.local; if (this.localOff && home && sameRepo(home.repo, wanted) && existsSync(home.dir)) { const def = this.newDef(path.basename(home.dir), home.repo, home.dir, by); + this.removedIds.delete(def.id); + this.removedIds.delete(path.resolve(def.dir)); started(def); this.defs.push(def); this.localId = def.id; @@ -324,6 +372,8 @@ export class Building { this.cloning.delete(key); this.saveClones(); } + this.removedIds.delete(def.id); this.removedIds.delete(path.resolve(def.dir)); + if (def.externalId) this.removedIds.delete(def.externalId); this.defs.push(def); this.save(); return def; @@ -433,9 +483,7 @@ export class Building { if (typeof saved.dir === 'string' && path.isAbsolute(saved.dir)) { this.localOff = { dir: saved.dir, by: typeof saved.by === 'string' ? saved.by : '?', at: typeof saved.at === 'number' ? saved.at : Date.now() }; } - } catch { - // never taken off - } + } catch { /* never taken off */ } } private setLocalOff(off: LocalOff | undefined) { @@ -459,11 +507,7 @@ export class Building { private loadClones(): SavedClone[] { try { const saved = JSON.parse(readFileSync(this.clonesFile, 'utf8')) as Partial[]; - return (Array.isArray(saved) ? saved : []).filter( - // Its log is one of ours (it gets deleted), in .agent-office/clones. - (s): s is SavedClone => - Number.isInteger(s.pid) && (s.pid as number) > 0 && typeof s.log === 'string' && path.dirname(s.log) === this.logsDir && typeof s.dir === 'string' && path.isAbsolute(s.dir) && typeof s.name === 'string', - ); + return (Array.isArray(saved) ? saved : []).filter((s): s is SavedClone => Number.isInteger(s.pid) && (s.pid as number) > 0 && typeof s.log === 'string' && path.dirname(s.log) === this.logsDir && typeof s.dir === 'string' && path.isAbsolute(s.dir) && typeof s.name === 'string'); } catch { return []; } @@ -487,15 +531,8 @@ export function tildify(p: string): string { return p === home || p.startsWith(home + path.sep) ? `~${p.slice(home.length)}` : p; } -function untildify(p: string): string { - return p === '~' || p.startsWith('~/') ? path.join(os.homedir(), p.slice(1)) : p; -} - -/** `dir` is `parent` or somewhere under it. */ -function within(dir: string, parent: string): boolean { - const rel = path.relative(parent, dir); - return !rel || (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)); -} +function untildify(p: string): string { return p === '~' || p.startsWith('~/') ? path.join(os.homedir(), p.slice(1)) : p; } +function within(dir: string, parent: string): boolean { const rel = path.relative(parent, dir); return !rel || (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)); } /** Why the office couldn't make checkouts under `dir`, if it couldn't. It's made on the first clone, so it needn't exist yet. */ function unwritable(dir: string): string | undefined { @@ -515,15 +552,9 @@ export function originRepo(dir: string): string | undefined { try { const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 10_000 }).trim(); return /github\.com[/:]/i.test(url) ? normalizeRepo(url) : undefined; - } catch { - return undefined; - } + } catch { return undefined; } } -/** - * What's at `dest`: nothing yet ('none'), a checkout of `repo` ('ok'), or why it's in the way. A - * clone that was cut off has its origin but no commit checked out; an `empty` repository has none to. - */ function checkoutAt(dest: string, repo: string, empty: boolean): 'none' | 'ok' | string { if (!existsSync(dest)) return 'none'; if (!statSync(dest).isDirectory()) return `${dest} is already there and isn't a folder`; @@ -537,9 +568,7 @@ function hasCommit(dir: string): boolean { try { execFileSync('git', ['rev-parse', '--verify', '--quiet', 'HEAD^{commit}'], { cwd: dir, stdio: 'ignore', timeout: 10_000 }); return true; - } catch { - return false; - } + } catch { return false; } } /** Clones `repo` to `dest` in this terminal: git shows its progress, and ssh or git can ask here. Resolves to an error, if any. */ @@ -552,17 +581,7 @@ function cloneHere(repo: string, dest: string): Promise { } async function listRepos(cwd: string): Promise { - const out = await gh( - [ - 'api', - '--paginate', - 'user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member', - '--jq', - '.[] | {name: .full_name, description: (.description // ""), private: .private, pushedAt: .pushed_at}', - ], - cwd, - 90_000, - ); + const out = await gh(['api', '--paginate', 'user/repos?per_page=100&sort=pushed&affiliation=owner,collaborator,organization_member', '--jq', '.[] | {name: .full_name, description: (.description // ""), private: .private, pushedAt: .pushed_at}'], cwd, 90_000); const repos: RepoChoice[] = []; const seen = new Set(); for (const line of out.split('\n')) { @@ -572,15 +591,8 @@ async function listRepos(cwd: string): Promise { const name = normalizeRepo(r.name); if (!name || seen.has(name.toLowerCase())) continue; seen.add(name.toLowerCase()); - repos.push({ - name, - description: typeof r.description === 'string' && r.description ? r.description.slice(0, 200) : undefined, - private: r.private === true, - pushedAt: typeof r.pushedAt === 'string' ? r.pushedAt : undefined, - }); - } catch { - // not a line of ours - } + repos.push({ name, description: typeof r.description === 'string' && r.description ? r.description.slice(0, 200) : undefined, private: r.private === true, pushedAt: typeof r.pushedAt === 'string' ? r.pushedAt : undefined }); + } catch {} if (repos.length >= MAX_REPOS) break; } return repos.sort((a, b) => (b.pushedAt ?? '').localeCompare(a.pushedAt ?? '')); diff --git a/src/server/config.ts b/src/server/config.ts index 177613751..cf858576a 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -157,6 +157,8 @@ Options: whole day and night go by every hour --weather Pin the weather: clear, cloudy, rain, storm, snow or fog (env AGENT_OFFICE_WEATHER) + --plugin Load an external plugin bundle (.js/.mjs) + (env AGENT_OFFICE_PLUGIN) --real-time-sky Start the sky on the office's real clock, so it's night when it's night there, instead of a day and night every hour (⚙️ Settings can switch it) @@ -222,7 +224,7 @@ export function loadConfig(argv: string[]): Config { let projects = process.env.AGENT_OFFICE_PROJECTS ? path.resolve(process.env.AGENT_OFFICE_PROJECTS) : ''; let port = Number(process.env.PORT) || 4600; // Loopback unless asked: an office lets whoever signs in run commands on this machine. - let host = '127.0.0.1'; + let host = process.env.AGENT_OFFICE_HOST || process.env.HOST || '127.0.0.1'; let open = !process.env.AGENT_OFFICE_NO_OPEN || process.env.AGENT_OFFICE_NO_OPEN === '0'; let password = process.env.AGENT_OFFICE_PASSWORD || ''; let agentCmd = process.env.AGENT_OFFICE_AGENT || 'claude'; @@ -326,6 +328,9 @@ export function loadConfig(argv: string[]): Config { case '--real-time-sky': realTimeSky = true; break; + case '--plugin': + process.env.AGENT_OFFICE_PLUGIN = path.resolve(takeValue(argv, i++, a)); + break; default: if (a.startsWith('-')) { console.error(`agent-office: unknown option ${a}\n`); diff --git a/src/server/floor.ts b/src/server/floor.ts index ad3bd25fd..54d16cac4 100644 --- a/src/server/floor.ts +++ b/src/server/floor.ts @@ -15,6 +15,7 @@ import { Changes } from './changes.js'; import { Decor } from './decor.js'; import { FloorPlanStore } from './floorplan.js'; import { Docs } from './docs.js'; +import type { IssuesAdapter, PullsAdapter, QueueAdapter } from './plugins/types.js'; import { Dog } from './dog.js'; import { Court } from './court.js'; import { Jail } from './jail.js'; @@ -32,6 +33,9 @@ type ToastLevel = 'info' | 'warn' | 'error'; /** What a floor needs from the building around it. */ export interface FloorContext { + issuesAdapter?: IssuesAdapter | (() => IssuesAdapter | null | undefined); + pullsAdapter?: PullsAdapter | (() => PullsAdapter | null | undefined); + queueAdapter?: QueueAdapter | (() => QueueAdapter | null | undefined); agentCmd: string; agentArgs: string[]; /** The DSH profile DeepSeek Harness workers boot (see server/dsh.ts). */ @@ -85,7 +89,7 @@ const IDLE_REFRESH_MS = 10 * 60_000; const REFRESH_MS = 90_000; /** What `git` says about a checkout: its name, branch and origin for the top bar. */ -export function projectInfo(dir: string, name: string, agentCmd: string, agentArgs: string[]): ProjectInfo { +export function projectInfo(dir: string, name: string, agentCmd: string, agentArgs: string[], externalId?: string, url?: string): ProjectInfo { const git = (args: string[]) => { try { return execFileSync('git', args, { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); @@ -96,6 +100,8 @@ export function projectInfo(dir: string, name: string, agentCmd: string, agentAr return { name, dir, + ...(externalId ? { externalId } : {}), + ...(url ? { url } : {}), branch: git(['rev-parse', '--abbrev-ref', 'HEAD']), remote: git(['remote', 'get-url', 'origin']), agentCmd: [agentCmd, ...agentArgs].join(' '), @@ -152,7 +158,7 @@ export class Floor { const dataDir = path.join(def.dir, '.agent-office'); mkdirSync(dataDir, { recursive: true, mode: 0o700 }); excludeFromGit(def.dir); - this.project = projectInfo(def.dir, def.name, ctx.agentCmd, ctx.agentArgs); + this.project = projectInfo(def.dir, def.name, ctx.agentCmd, ctx.agentArgs, def.externalId, def.url); this.docs = new Docs(def.dir); // Before the workers and the dog: the back office's desks are only there once it's built. this.plan = new FloorPlanStore(dataDir); @@ -223,8 +229,17 @@ export class Floor { this.sendLandedHome(); ctx.pullsChanged(this); }, + async () => { + const a = typeof ctx.issuesAdapter === 'function' ? ctx.issuesAdapter() : ctx.issuesAdapter; + return a ? await a.listIssues(this) : null; + }, + async () => { + const a = typeof ctx.pullsAdapter === 'function' ? ctx.pullsAdapter() : ctx.pullsAdapter; + return a ? await a.listPulls(this) : null; + }, ); // The 📋 task queue seats workers by itself: it watches the workers and links PRs from GitHub. + const queueAdapter = typeof ctx.queueAdapter === 'function' ? ctx.queueAdapter() : ctx.queueAdapter; this.queue = new TaskQueue(dataDir, this.workers, !!this.project.branch, { update: (state) => { ctx.emit(this, { t: 'queue', state }); @@ -244,7 +259,7 @@ export class Floor { ctx.emit(this, { t: 'gong', why: 'queue' }); }, worktreeNote: () => officePrompt(ctx.prompts, 'queue.worktree'), - }); + }, queueAdapter ? { adapter: queueAdapter, floor: this } : undefined); // Meetings seat their own workers round the meeting room's table and run them round by round. const workers = this.workers; @@ -400,6 +415,8 @@ export class Floor { palette: this.def.palette, addedBy: this.def.addedBy, addedAt: this.def.addedAt, + externalId: this.def.externalId, + url: this.def.url, workers: ws.filter((w) => !DESK_BY_ID.get(w.deskId)?.station).length, busy: ws.filter((w) => w.status === 'working').length, waiting: ws.filter((w) => w.kind === 'agent' && (w.status === 'needs_input' || (w.status === 'done' && !w.acked))).length, diff --git a/src/server/github.ts b/src/server/github.ts index 915b57d84..6991cc47c 100644 --- a/src/server/github.ts +++ b/src/server/github.ts @@ -148,6 +148,8 @@ export class GitHub { private dir: string, private onIssues: (s: GhState) => void, private onPulls: (s: GhState) => void, + private customIssues?: () => Promise, + private customPulls?: () => Promise, ) {} start() { @@ -186,6 +188,27 @@ export class GitHub { * GitHub login of whoever asked, when they're signed in to their own; else it's the office's. */ async pullDetail(n: number, me?: string): Promise { + const existing = this.pulls.items.find((it) => it.number === n); + if (existing && this.customPulls) { + return { + number: existing.number, + body: existing.body, + state: existing.state, + isDraft: existing.isDraft, + reviewDecision: existing.reviewDecision, + headRefName: existing.headRefName, + baseRefName: existing.baseRefName, + mergeable: 'UNKNOWN', + mergeStateStatus: 'UNKNOWN', + commits: 1, + comments: [], + reviews: [], + reviewComments: [], + checks: existing.checks !== 'none' ? [{ name: 'CI', state: existing.checks === 'pass' ? 'pass' : existing.checks === 'fail' ? 'fail' : 'pending' }] : [], + repo: { nameWithOwner: existing.headRefName || 'project', methods: ['squash', 'merge', 'rebase'] }, + viewer: me ?? '', + }; + } const fields = 'number,body,state,isDraft,reviewDecision,headRefName,baseRefName,mergeable,mergeStateStatus,commits,comments,reviews,statusCheckRollup'; const jq = '.[] | {id, in_reply_to_id, path, line, side, body, user: .user.login, created_at, html_url}'; const [view, lines, repo, viewer] = await Promise.all([ @@ -233,13 +256,25 @@ export class GitHub { /** The PR's unified diff, as `git diff` prints it. */ pullDiff(n: number): Promise { + if (this.customPulls) return Promise.resolve(''); return gh(['pr', 'diff', String(n), '--color', 'never'], this.dir, 60_000); } async issueDetail(n: number, me?: string): Promise { - const [view, viewer] = await Promise.all([gh(['issue', 'view', String(n), '--json', 'number,state,body,comments'], this.dir), me ?? this.viewer()]); - const i = JSON.parse(view); - return { number: i.number, state: i.state, body: String(i.body ?? ''), comments: commentsOf(i.comments), viewer }; + const existing = this.issues.items.find((it) => it.number === n); + if (existing && this.customIssues) { + return { number: existing.number, state: existing.state, body: existing.body, comments: [], viewer: me ?? '' }; + } + try { + const [view, viewer] = await Promise.all([gh(['issue', 'view', String(n), '--json', 'number,state,body,comments'], this.dir), me ?? this.viewer()]); + const i = JSON.parse(view); + return { number: i.number, state: i.state, body: String(i.body ?? ''), comments: commentsOf(i.comments), viewer }; + } catch (err) { + if (existing) { + return { number: existing.number, state: existing.state, body: existing.body, comments: [], viewer: me ?? '' }; + } + throw err; + } } /** @@ -422,25 +457,31 @@ export class GitHub { this.onIssues(this.issues); const asked = Date.now(); try { - // Open and closed separately, so old open issues are never crowded out by recent closed ones. - const fields = 'number,title,state,url,author,labels,assignees,createdAt,updatedAt,body,comments'; - const [open, closed] = await Promise.all([ - gh(['issue', 'list', '--state', 'open', '--limit', '300', '--json', fields], this.dir), - gh(['issue', 'list', '--state', 'closed', '--limit', '40', '--json', fields], this.dir), - ]); - const fetched: GhIssue[] = [...JSON.parse(open), ...JSON.parse(closed)].map((i: any) => ({ - number: i.number, - title: i.title, - state: i.state, - url: i.url, - author: i.author?.login ?? '', - labels: labels(i.labels), - assignees: (i.assignees ?? []).map((a: any) => a.login), - createdAt: i.createdAt, - updatedAt: i.updatedAt, - body: String(i.body ?? '').slice(0, 4000), - comments: Array.isArray(i.comments) ? i.comments.length : Number(i.comments ?? 0), - })); + let fetched: GhIssue[] | null = null; + if (this.customIssues) { + fetched = await this.customIssues(); + } + if (fetched === null) { + // Open and closed separately, so old open issues are never crowded out by recent closed ones. + const fields = 'number,title,state,url,author,labels,assignees,createdAt,updatedAt,body,comments'; + const [open, closed] = await Promise.all([ + gh(['issue', 'list', '--state', 'open', '--limit', '300', '--json', fields], this.dir), + gh(['issue', 'list', '--state', 'closed', '--limit', '40', '--json', fields], this.dir), + ]); + fetched = [...JSON.parse(open), ...JSON.parse(closed)].map((i: any) => ({ + number: i.number, + title: i.title, + state: i.state, + url: i.url, + author: i.author?.login ?? '', + labels: labels(i.labels), + assignees: (i.assignees ?? []).map((a: any) => a.login), + createdAt: i.createdAt, + updatedAt: i.updatedAt, + body: String(i.body ?? '').slice(0, 4000), + comments: Array.isArray(i.comments) ? i.comments.length : Number(i.comments ?? 0), + })); + } const items = this.claims.mark(this.relabel('issue', fetched, asked), asked); this.issues = { items, fetchedAt: Date.now(), loading: false }; } catch (err) { @@ -455,35 +496,41 @@ export class GitHub { this.onPulls(this.pulls); const asked = Date.now(); try { - const fields = 'number,title,state,isDraft,url,author,labels,reviewDecision,headRefName,headRefOid,baseRefName,createdAt,updatedAt,additions,deletions,statusCheckRollup,body,closingIssuesReferences'; - const [open, merged, closed] = await Promise.all([ - gh(['pr', 'list', '--state', 'open', '--limit', '150', '--json', fields], this.dir), - gh(['pr', 'list', '--state', 'merged', '--limit', '30', '--json', fields], this.dir), - gh(['pr', 'list', '--state', 'closed', '--limit', '40', '--json', fields], this.dir), - ]); - // `--state closed` includes merged PRs; keep only the ones closed without merging. - const seen = new Set(); - const all = [...JSON.parse(open), ...JSON.parse(merged), ...JSON.parse(closed)].filter((p: any) => !seen.has(p.number) && seen.add(p.number)); - const fetched: GhPull[] = all.map((p: any) => ({ - number: p.number, - title: p.title, - state: p.state, - isDraft: !!p.isDraft, - url: p.url, - author: p.author?.login ?? '', - labels: labels(p.labels), - reviewDecision: p.reviewDecision ?? '', - headRefName: p.headRefName, - headRefOid: typeof p.headRefOid === 'string' ? p.headRefOid : undefined, - baseRefName: p.baseRefName, - createdAt: p.createdAt, - updatedAt: p.updatedAt, - additions: p.additions ?? 0, - deletions: p.deletions ?? 0, - checks: checksOf(p.statusCheckRollup), - body: String(p.body ?? '').slice(0, 4000), - closes: (p.closingIssuesReferences ?? []).map((r: any) => Number(r.number)).filter((n: number) => Number.isInteger(n) && n > 0), - })); + let fetched: GhPull[] | null = null; + if (this.customPulls) { + fetched = await this.customPulls(); + } + if (fetched === null) { + const fields = 'number,title,state,isDraft,url,author,labels,reviewDecision,headRefName,headRefOid,baseRefName,createdAt,updatedAt,additions,deletions,statusCheckRollup,body,closingIssuesReferences'; + const [open, merged, closed] = await Promise.all([ + gh(['pr', 'list', '--state', 'open', '--limit', '150', '--json', fields], this.dir), + gh(['pr', 'list', '--state', 'merged', '--limit', '30', '--json', fields], this.dir), + gh(['pr', 'list', '--state', 'closed', '--limit', '40', '--json', fields], this.dir), + ]); + // `--state closed` includes merged PRs; keep only the ones closed without merging. + const seen = new Set(); + const all = [...JSON.parse(open), ...JSON.parse(merged), ...JSON.parse(closed)].filter((p: any) => !seen.has(p.number) && seen.add(p.number)); + fetched = all.map((p: any) => ({ + number: p.number, + title: p.title, + state: p.state, + isDraft: !!p.isDraft, + url: p.url, + author: p.author?.login ?? '', + labels: labels(p.labels), + reviewDecision: p.reviewDecision ?? '', + headRefName: p.headRefName, + headRefOid: typeof p.headRefOid === 'string' ? p.headRefOid : undefined, + baseRefName: p.baseRefName, + createdAt: p.createdAt, + updatedAt: p.updatedAt, + additions: p.additions ?? 0, + deletions: p.deletions ?? 0, + checks: checksOf(p.statusCheckRollup), + body: String(p.body ?? '').slice(0, 4000), + closes: (p.closingIssuesReferences ?? []).map((r: any) => Number(r.number)).filter((n: number) => Number.isInteger(n) && n > 0), + })); + } const items = this.relabel('pull', fetched, asked); this.pulls = { items, fetchedAt: Date.now(), loading: false }; } catch (err) { diff --git a/src/server/office/context.ts b/src/server/office/context.ts index 96090a2e0..ab34b8985 100644 --- a/src/server/office/context.ts +++ b/src/server/office/context.ts @@ -17,6 +17,7 @@ import type { PlanLimitsReader } from '../limits.js'; import type { Webhook } from '../webhook.js'; import type { Machine } from '../machine.js'; import type { Building, FloorDef } from '../building.js'; +import type { PluginManager } from '../plugins/manager.js'; import type { Floor } from '../floor.js'; import type { Sky } from '../sky.js'; import type { Themes } from '../theme.js'; @@ -51,6 +52,8 @@ export interface Core { models: Partial>; /** The building: a floor per project, each with its own workers, boards and queue. */ building: Building; + /** Plugins extending the office. */ + plugins: PluginManager; floors: Map; } diff --git a/src/server/office/core.ts b/src/server/office/core.ts index 47ec9f80c..7ceb9a0ae 100644 --- a/src/server/office/core.ts +++ b/src/server/office/core.ts @@ -6,6 +6,7 @@ import { providerCommand } from '../agents.js'; import type { AgentProvider } from '../../shared/providers.js'; import { createModelCatalogues } from '../models.js'; import { Building } from '../building.js'; +import { PluginManager } from '../plugins/manager.js'; import type { Floor } from '../floor.js'; import { ChatLog } from '../history.js'; import { Arcade, HighScores } from '../cabinet.js'; @@ -39,10 +40,11 @@ export function createCore(ctx: Ctx, cfg: Config, publicDir: string): Core { // --- The building: a floor per project, each with its own workers, boards and queue ----------- const building = new Building(cfg.dataDir, cfg.projectsDir); + const plugins = new PluginManager(cfg.dataDir); if (cfg.projects) { const err = building.setProjectsDir(cfg.projects, 'the command line'); if (err) console.error(`agent-office: --projects: ${err}`); } const floors = new Map(); - return { cfg, publicDir, accounts, auth, clients, chat, highScores, arcade, officeName, models, building, floors }; + return { cfg, publicDir, accounts, auth, clients, chat, highScores, arcade, officeName, models, building, floors, plugins }; } diff --git a/src/server/office/floors.ts b/src/server/office/floors.ts index cec674fb4..0885898dd 100644 --- a/src/server/office/floors.ts +++ b/src/server/office/floors.ts @@ -1,13 +1,17 @@ +// The building's floors: opening every floor of the building and waiting for its workers, and +// watching for floors being added or taken off. import { existsSync } from 'node:fs'; import { WebSocket } from 'ws'; +import type { FloorInfo, ServerMsg } from '../../shared/protocol.js'; +import { ROOF } from '../../shared/rooftop.js'; import type { FloorDef } from '../building.js'; import { Floor, type FloorContext } from '../floor.js'; -import { ROOF } from '../../shared/rooftop.js'; -import type { FloorInfo, ServerMsg } from '../../shared/protocol.js'; +import type { Client } from './client.js'; import type { Ctx, FloorHelpers, FloorsOpen } from './context.js'; -import { SLOW_CLIENT_BYTES, type Client } from './client.js'; -/** Finding floors, the elevator's list of them, and taking one off the building. */ +/** A viewer taking more than this much memory waiting on a slow link is caught up with a fresh snapshot. */ +const SLOW_CLIENT_BYTES = 10 * 1024 * 1024; + export function floorHelpers(ctx: Ctx): FloorHelpers { const floorOf = (c: Client): Floor | undefined => (c.peer.floor ? ctx.floors.get(c.peer.floor) : undefined); /** The floor a worker sits on. Worker ids are unique across the building. */ @@ -15,10 +19,48 @@ export function floorHelpers(ctx: Ctx): FloorHelpers { for (const f of ctx.floors.values()) if (f.workers.get(workerId)) return f; return undefined; }; - const floorInfos = (): FloorInfo[] => [ - ...[...ctx.floors.values()].map((f) => ({ ...f.info(), ...(ctx.building.isLocal(f.id) ? { local: true } : {}) })), - ...ctx.building.pending().map((d) => ({ id: d.id, name: d.name, repo: d.repo, dir: d.dir, palette: d.palette, addedBy: d.addedBy, addedAt: d.addedAt, cloning: true, clone: ctx.building.cloneProgress(d.id), workers: 0, busy: 0, waiting: 0, people: 0, wing: 0 })), - ]; + const floorInfos = (): FloorInfo[] => { + const list: FloorInfo[] = [ + ...ctx.building.list().map((d) => { + const f = ctx.floors.get(d.id); + const info: FloorInfo = f + ? { ...f.info(), ...(ctx.building.isLocal(f.id) ? { local: true } : {}) } + : { + id: d.id, + name: d.name, + repo: d.repo, + dir: d.dir, + palette: d.palette, + addedBy: d.addedBy, + addedAt: d.addedAt, + workers: 0, + busy: 0, + waiting: 0, + people: 0, + wing: 0, + }; + if (f) ctx.plugins?.decorateFloor(f, info); + return info; + }), + ...ctx.building.pending().map((d) => ({ + id: d.id, + name: d.name, + repo: d.repo, + dir: d.dir, + palette: d.palette, + addedBy: d.addedBy, + addedAt: d.addedAt, + cloning: true, + clone: ctx.building.cloneProgress(d.id), + workers: 0, + busy: 0, + waiting: 0, + people: 0, + wing: 0, + })), + ]; + return ctx.plugins ? ctx.plugins.sortFloors(list) : list; + }; // The elevator's counts change with every worker update; tell everyone at most a few times a second. let floorsSent = ''; let floorsTimer: NodeJS.Timeout | undefined; @@ -33,19 +75,19 @@ export function floorHelpers(ctx: Ctx): FloorHelpers { }, 250); }; /** Where someone arriving goes: the floor they asked for, else the first one there is. */ - const arrivalFloor = (wanted: string | null): Floor | undefined => (wanted && ctx.floors.get(wanted)) || ctx.floors.values().next().value; + const arrivalFloor = (wanted: string | null): Floor | undefined => { + if (wanted === ROOF) return undefined; + const floor = (wanted && ctx.floors.get(wanted)) || ctx.floors.get(ctx.building.list()[0]?.id ?? ''); + return floor; + }; /** * Takes `floor` off the building (already out of floors.json): everyone on it rides the elevator to * the next floor, or out to the lobby if it was the last (the roof goes with it), and its workers stop. */ const closeFloor = (floor: Floor, who: string) => { - const name = floor.def.name; - const next = [...ctx.floors.values()].find((f) => f !== floor); - // The list without it first, so nobody arrives somewhere (the lobby's panel) that still shows it. - const list = floorInfos().filter((f) => f.id !== floor.id); - floorsSent = JSON.stringify(list); - ctx.broadcast({ t: 'floors', floors: list }); + const { name } = floor.def; + const next = ctx.floors.get(ctx.building.list()[0]?.id ?? ''); for (const c of ctx.clients.values()) { if (c.peer.floor === floor.id || (!next && c.peer.floor === ROOF)) { if (next) ctx.goToFloor(c, next); @@ -77,6 +119,9 @@ export async function openFloors(ctx: Ctx, hookPort: number): Promise ctx.plugins?.getIssuesAdapter(), + pullsAdapter: () => ctx.plugins?.getPullsAdapter(), + queueAdapter: () => ctx.plugins?.getQueueAdapter(), emit: ctx.toFloor, toast: ctx.toastFloor, termData: (workerId, data, viewers) => { @@ -120,9 +165,12 @@ export async function openFloors(ctx: Ctx, hookPort: number): Promise (owner ? ctx.signins.ghAs(owner) : undefined), }; + /** Whether a worker on `from` works in `on`'s project too (see WorkerInfo.repos). */ const worksIn = (from: Floor, on: Floor) => from.workers.list().some((w) => w.repos?.some((r) => r.floor === on.id)); + const openFloor = (def: FloorDef): Floor | undefined => { + if (floors.has(def.id)) return floors.get(def.id); if (!existsSync(def.dir)) { console.error(`agent-office: the ${def.name} floor's checkout is gone (${def.dir}) — it stays closed until it's back`); return undefined; @@ -136,9 +184,39 @@ export async function openFloors(ctx: Ctx, hookPort: number): Promise { + try { + const defs = await floorProvider.listFloors(); + if (defs && defs.length > 0) { + const prevDefs = ctx.building.list(); + ctx.building.setList(defs); + const currentDefs = ctx.building.list(); + if (JSON.stringify(prevDefs) !== JSON.stringify(currentDefs)) { + for (const def of currentDefs) openFloor(def); + ctx.floorsChanged(); + } + } + } catch { + /* ignore */ + } + }; + await syncFloorProvider(); + setInterval(() => { + void syncFloorProvider(); + for (const floor of floors.values()) void floor.queue.syncFromAdapter(); + }, 3000).unref(); + } + // Started in a project: it's a floor too (the one it has always been). if (cfg.project) ctx.building.ensureLocal(cfg.project, 'the office'); for (const def of ctx.building.list()) openFloor(def); + // Clones keep the elevator's progress up to date, and ones the last office left running carry on. ctx.building.watchClones(ctx.floorsChanged); ctx.building.resumeClones((r) => { @@ -151,6 +229,7 @@ export async function openFloors(ctx: Ctx, hookPort: number): Promise f.ready)); return { openFloor }; diff --git a/src/server/plugins/index.ts b/src/server/plugins/index.ts new file mode 100644 index 000000000..3a2183b9d --- /dev/null +++ b/src/server/plugins/index.ts @@ -0,0 +1,3 @@ +export * from './types.js'; +export * from './registry.js'; +export * from './manager.js'; diff --git a/src/server/plugins/manager.ts b/src/server/plugins/manager.ts new file mode 100644 index 000000000..9957afd21 --- /dev/null +++ b/src/server/plugins/manager.ts @@ -0,0 +1,552 @@ +import { execFile, execFileSync } from 'node:child_process'; +import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync } from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import type { FloorDef } from '../building.js'; +import type { Floor } from '../floor.js'; +import type { FloorInfo, GhIssue, GhPull, PluginManifest, PluginsState, ServiceInfo, WorkerInfo } from '../../shared/protocol.js'; +import type { Ctx } from '../office/context.js'; +import { PluginRegistry } from './registry.js'; +import type { AgentProviderPlugin, FloorProvider, IssuesAdapter, OfficePlugin, ProjectsProvider, PullsAdapter, QueueAdapter, ServicesProvider } from './types.js'; +import type { AgentProvider } from '../../shared/providers.js'; +import { PROVIDER_META, registerAgentProvider, unregisterAgentProvider } from '../../shared/providers.js'; +import { registerAgentProviderAdapter, unregisterAgentProviderAdapter } from '../providers/index.js'; + +const ENTRY_CANDIDATES = ['dist/index.js', 'index.js', 'plugin.js', 'dist/index.mjs', 'index.mjs']; + +function parseGitUrl(url: string): { id: string; cloneUrl: string } | null { + const trimmed = url.trim(); + if (!trimmed) return null; + const ALLOWED_PROTOCOLS = ['https:', 'http:', 'git:', 'ssh:']; + try { + const parsedUrl = new URL(trimmed); + if (!ALLOWED_PROTOCOLS.includes(parsedUrl.protocol)) return null; + } catch { + if (!trimmed.includes('@') && !trimmed.startsWith('git://')) return null; + } + + let cloneUrl = trimmed; + if (cloneUrl.startsWith('-')) return null; + if (cloneUrl.endsWith('/')) cloneUrl = cloneUrl.slice(0, -1); + if (!cloneUrl.endsWith('.git')) cloneUrl += '.git'; + const match = /([^/]+?)(?:\.git)?$/.exec(cloneUrl); + if (!match) return null; + const id = match[1].toLowerCase().replace(/[^a-z0-9-_]/g, '-'); + return { id, cloneUrl }; +} + +function getPluginDir(id: string, pluginsDir?: string): string { + return path.join(pluginsDir ?? path.join(process.cwd(), '.agent-office', 'plugins'), id); +} + +function candidatePluginDirs(id: string, registryPluginsDir?: string): string[] { + return [path.join(process.cwd(), '.agent-office', 'plugins', id), registryPluginsDir ? path.join(registryPluginsDir, id) : null, path.resolve(process.cwd(), '..', 'agent-office-plugins', `agentoffice-${id}`), path.resolve(process.cwd(), '..', 'agent-office-plugins', `${id}-plugin-for-agent-office`), path.resolve(process.cwd(), '..', 'agent-office-plugins', `agent-office-plugin-${id}`), path.resolve(process.cwd(), '..', 'agent-office-plugins', id), path.resolve(process.cwd(), 'agent-office-plugins', `agentoffice-${id}`), path.resolve(process.cwd(), 'agent-office-plugins', `${id}-plugin-for-agent-office`), path.resolve(process.cwd(), 'agent-office-plugins', `agent-office-plugin-${id}`), path.resolve(process.cwd(), 'agent-office-plugins', id)].filter(Boolean) as string[]; +} + +function candidatePluginFiles(id: string, registryPluginsDir?: string): string[] { + return [path.join(process.cwd(), `${id}.js`), registryPluginsDir ? path.join(registryPluginsDir, `${id}.js`) : null, registryPluginsDir ? path.join(registryPluginsDir, `${id}.mjs`) : null].filter(Boolean) as string[]; +} + +function detectEntryPath(pluginDir: string): string | null { + for (const candidate of ENTRY_CANDIDATES) { + const p = path.join(pluginDir, candidate); + if (existsSync(p)) return p; + } + const pkgPath = path.join(pluginDir, 'package.json'); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { main?: string; module?: string }; + const entry = pkg.main || pkg.module; + if (entry) { + const p = path.join(pluginDir, entry); + if (existsSync(p)) return p; + } + } catch { + } + } + return null; +} + +function resolvePluginEntry(id: string, registryPluginsDir?: string, explicitEntry?: string): string | null { + if (explicitEntry) { + const resolved = path.isAbsolute(explicitEntry) ? explicitEntry : path.resolve(process.cwd(), explicitEntry); + if (existsSync(resolved)) return resolved; + if (registryPluginsDir) { + const inReg = path.resolve(registryPluginsDir, path.basename(explicitEntry)); + if (existsSync(inReg)) return inReg; + } + } + + for (const dir of candidatePluginDirs(id, registryPluginsDir)) { + if (existsSync(dir)) { + const entry = detectEntryPath(dir); + if (entry) return entry; + } + } + + for (const file of candidatePluginFiles(id, registryPluginsDir)) { + if (existsSync(file)) return file; + } + + return null; +} + +async function fetchLatestRelease(githubUrl: string): Promise { + try { + const match = /github\.com\/([^/]+)\/([^/]+)/.exec(githubUrl); + if (!match) return null; + const [, owner, repo] = match; + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`; + const res = await fetch(apiUrl, { headers: { Accept: 'application/vnd.github.v3+json' } }); + if (!res.ok) return null; + const data = (await res.json()) as { tag_name?: string }; + return data.tag_name ?? null; + } catch { + return null; + } +} + +export class PluginManager { + private registry: PluginRegistry; + private plugins: Map = new Map(); + public ctx?: Ctx; + + constructor(projectsDir?: string) { + this.registry = new PluginRegistry(projectsDir); + } + + async install(id: string): Promise { + const manifest = this.registry.get(id); + if (!manifest) return false; + + let entryPath = resolvePluginEntry(manifest.id, this.registry.pluginsDir, (manifest as any).entry); + if (!entryPath && manifest.githubUrl) { + const cloneRes = await this.installFromGit(manifest.githubUrl); + if (!cloneRes.ok) { + manifest.status = 'error'; manifest.error = cloneRes.error ?? 'Git clone failed'; + this.registry.save(); return false; + } + entryPath = resolvePluginEntry(manifest.id, this.registry.pluginsDir, (manifest as any).entry); + } + + if (!entryPath && this.ctx) { + manifest.status = 'error'; manifest.error = `Plugin entry not found for ${manifest.id}`; + this.registry.save(); return false; + } + + manifest.installed = true; + (manifest as any).enabled = true; + manifest.status = 'active'; + if (this.ctx) { + const loaded = await this.loadPlugin(manifest, this.ctx); + if (!loaded) { + manifest.installed = false; (manifest as any).enabled = false; manifest.status = 'error'; + this.registry.save(); return false; + } + } + this.registry.save(); + return true; + } + + async uninstall(id: string): Promise { + const manifest = this.registry.get(id); + if (!manifest) return false; + manifest.installed = false; + (manifest as any).enabled = false; + manifest.status = 'inactive'; + this.registry.save(); + const plugin = this.plugins.get(id); + if (plugin) { + if (plugin.agentProvider) { + this.unregisterAgentProvider(plugin.agentProvider.id); + } + try { + await plugin.shutdown?.(); + } catch { + /* ignore */ + } + this.plugins.delete(id); + } + return true; + } + + async installFromGit(url: string): Promise<{ ok: boolean; id?: string; error?: string }> { + const parsed = parseGitUrl(url); + if (!parsed) return { ok: false, error: 'Invalid Git URL' }; + const { id, cloneUrl } = parsed; + const pluginDir = getPluginDir(id, this.registry.pluginsDir); + if (existsSync(pluginDir)) return { ok: false, error: `Plugin "${id}" is already installed` }; + mkdirSync(pluginDir, { recursive: true }); + try { + execFileSync('git', ['clone', '--depth', '1', '--', cloneUrl, pluginDir], { stdio: 'pipe', timeout: 60_000 }); + } catch (err) { + rmSync(pluginDir, { recursive: true, force: true }); + return { ok: false, error: `Git clone failed: ${(err as Error).message}` }; + } + const entryPath = detectEntryPath(pluginDir); + if (!entryPath) { + rmSync(pluginDir, { recursive: true, force: true }); + return { ok: false, error: `Plugin entry not found in ${pluginDir}` }; + } + const manifest: PluginManifest = { + id, + name: id, + description: `Installed from ${cloneUrl}`, + capabilities: [], + installed: true, + status: 'active', + local: true, + }; + this.registry.register(manifest); + this.registry.save(); + if (this.ctx) { + await this.loadPlugin(manifest, this.ctx); + } + return { ok: true, id }; + } + + async updatePlugin(id: string): Promise<{ ok: boolean; error?: string }> { + const pluginDir = getPluginDir(id, this.registry.pluginsDir); + if (!existsSync(pluginDir)) return { ok: false, error: `Plugin "${id}" is not installed from Git` }; + try { + execFileSync('git', ['pull'], { cwd: pluginDir, stdio: 'pipe', timeout: 30_000 }); + } catch (err) { + return { ok: false, error: `Git pull failed: ${(err as Error).message}` }; + } + const manifest = this.registry.get(id); + if (manifest && this.ctx) { + const plugin = this.plugins.get(id); + if (plugin) { + try { + await plugin.shutdown?.(); + } catch { + /* ignore */ + } + this.plugins.delete(id); + } + await this.loadPlugin(manifest, this.ctx); + } + return { ok: true }; + } + + async checkUpdates(): Promise { + const installed = this.registry.list().filter((m) => m.installed === true); + await Promise.all( + installed.map(async (m) => { + if (!m.githubUrl) { + if (!m.version) m.version = 'local'; + m.latestVersion = undefined; + m.updateAvailable = false; + return; + } + const latest = await fetchLatestRelease(m.githubUrl); + if (latest) { + m.latestVersion = latest; + m.updateAvailable = m.version !== latest; + } + }), + ); + } + + openFolder(id?: string): void { + let targetDir = this.registry.pluginsDir; + if (id) { + for (const dir of candidatePluginDirs(id, this.registry.pluginsDir)) { + if (existsSync(dir)) { + targetDir = dir; + break; + } + } + if (targetDir === this.registry.pluginsDir) { + const parentContainers = [ + path.resolve(process.cwd(), '..', 'agent-office-plugins'), + path.resolve(process.cwd(), 'agent-office-plugins'), + ]; + for (const parent of parentContainers) { + if (existsSync(parent)) { + try { + const entries = readdirSync(parent, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + const candidate = path.join(parent, entry.name); + if (entry.name.toLowerCase().includes(id.toLowerCase())) { + targetDir = candidate; + break; + } + const pkgPath = path.join(candidate, 'package.json'); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')); + if (pkg.name?.toLowerCase().includes(id.toLowerCase())) { + targetDir = candidate; + break; + } + } catch {} + } + } + } + if (targetDir !== this.registry.pluginsDir) break; + } catch {} + } + } + } + if (targetDir === this.registry.pluginsDir) { + for (const file of candidatePluginFiles(id, this.registry.pluginsDir)) { + if (existsSync(file)) { + targetDir = path.dirname(file); + break; + } + } + } + } else { + const fallbackDirs = [ + path.resolve(process.cwd(), '..', 'agent-office-plugins'), + path.resolve(process.cwd(), 'agent-office-plugins'), + this.registry.pluginsDir, + ]; + targetDir = fallbackDirs.find((d) => existsSync(d)) || targetDir; + } + if (!existsSync(targetDir)) { + try { + mkdirSync(targetDir, { recursive: true }); + } catch { + /* ignore */ + } + } + const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open'; + execFile(cmd, [targetDir], () => {}); + } + + async loadPlugin(manifest: PluginManifest, ctx: Ctx): Promise { + const entryPath = resolvePluginEntry(manifest.id, this.registry.pluginsDir, (manifest as any).entry); + if (!entryPath) { + manifest.status = 'error'; + manifest.error = `Plugin entry not found for ${manifest.id}`; + return null; + } + try { + const fileUrl = pathToFileURL(entryPath).href; + const mod = await import(fileUrl); + const factory = mod.createPlugin || mod.default || mod; + const plugin: OfficePlugin = typeof factory === 'function' ? factory() : factory; + if (plugin && plugin.manifest) { + manifest.status = 'active'; + manifest.capabilities = plugin.manifest.capabilities ?? manifest.capabilities; + manifest.name = plugin.manifest.name ?? manifest.name; + manifest.description = plugin.manifest.description ?? manifest.description; + manifest.version = plugin.manifest.version ?? manifest.version; + manifest.author = plugin.manifest.author ?? manifest.author; + manifest.githubUrl = plugin.manifest.githubUrl ?? manifest.githubUrl; + this.plugins.set(manifest.id, plugin); + if (plugin.manifest.id) { + this.plugins.set(plugin.manifest.id, plugin); + const regItem = this.registry.get(plugin.manifest.id); + if (regItem && regItem !== manifest) { + regItem.status = 'active'; + regItem.installed = true; + regItem.version = plugin.manifest.version ?? regItem.version; + regItem.author = plugin.manifest.author ?? regItem.author; + regItem.githubUrl = plugin.manifest.githubUrl ?? regItem.githubUrl; + } + } + await plugin.init?.(ctx); + return plugin; + } + } catch (err) { + manifest.status = 'error'; + manifest.error = (err as Error).message; + console.error(`Failed to load plugin ${manifest.id}:`, err); + } + return null; + } + + async init(ctx: Ctx) { + this.ctx = ctx; + + const envPlugin = process.env.AGENT_OFFICE_PLUGIN; + if (envPlugin && existsSync(envPlugin)) { + const id = path.basename(envPlugin, path.extname(envPlugin)); + const existing = this.registry.get(id); + if (existing) { + existing.installed = true; + (existing as any).entry = envPlugin; + } else { + this.registry.register({ + id, + name: id, + description: `Plugin loaded from ${envPlugin}`, + capabilities: [], + installed: true, + status: 'active', + local: true, + entry: envPlugin, + } as any); + } + } + + for (const manifest of this.registry.list()) { + if (manifest.installed) { + await this.loadPlugin(manifest, ctx); + } + } + + for (const plugin of this.plugins.values()) { + if (plugin.agentProvider) { + this.registerAgentProvider(plugin.agentProvider); + } + } + } + + private registerAgentProvider(agentProvider: AgentProviderPlugin): void { + const { id, meta, adapter } = agentProvider; + if (PROVIDER_META[id as AgentProvider]) { + console.warn(`Agent provider "${id}" already registered, skipping plugin provider`); + return; + } + registerAgentProvider(id, meta); + registerAgentProviderAdapter(id, adapter); + } + + private unregisterAgentProvider(id: string): void { + unregisterAgentProvider(id); + unregisterAgentProviderAdapter(id); + } + + async shutdown() { + for (const p of this.plugins.values()) { + try { + await p.shutdown?.(); + } catch { + } + } + this.plugins.clear(); + } + + getFloorProvider(): FloorProvider | null { + for (const p of this.plugins.values()) { + if (p.floorProvider) return p.floorProvider; + } + return null; + } + + getIssuesAdapter(): IssuesAdapter | null { + const adapters = [...new Set([...this.plugins.values()].map((p) => p.issuesAdapter))].filter(Boolean) as IssuesAdapter[]; + if (!adapters.length) return null; + if (adapters.length === 1) return adapters[0]; + return { + async listIssues(floor: Floor) { + const results = await Promise.allSettled(adapters.map((a) => a.listIssues(floor))); + const all: GhIssue[] = []; + for (const r of results) { + if (r.status === 'fulfilled' && Array.isArray(r.value)) { + all.push(...r.value); + } + } + return all; + }, + }; + } + + getPullsAdapter(): PullsAdapter | null { + const adapters = [...new Set([...this.plugins.values()].map((p) => p.pullsAdapter))].filter(Boolean) as PullsAdapter[]; + if (!adapters.length) return null; + if (adapters.length === 1) return adapters[0]; + return { + async listPulls(floor: Floor) { + const results = await Promise.allSettled(adapters.map((a) => a.listPulls(floor))); + const all: GhPull[] = []; + for (const r of results) { + if (r.status === 'fulfilled' && Array.isArray(r.value)) { + all.push(...r.value); + } + } + return all; + }, + }; + } + + getQueueAdapter(): QueueAdapter | null { + for (const p of this.plugins.values()) { + if (p.queueAdapter) return p.queueAdapter; + } + return null; + } + + getServicesProvider(): ServicesProvider | null { + for (const p of this.plugins.values()) { + if (p.servicesProvider) return p.servicesProvider; + } + return null; + } + + decorateFloor(floor: Floor, info: FloorInfo): void { + for (const p of this.plugins.values()) { + p.floorProvider?.decorateFloorInfo?.(floor, info); + } + } + + sortFloors(floors: FloorInfo[]): FloorInfo[] { + const provider = this.getFloorProvider(); + if (provider?.sortFloors) { + return provider.sortFloors(floors); + } + return floors; + } + + async listFloorDefs(): Promise { + const provider = this.getFloorProvider(); + if (provider) { + return await provider.listFloors(); + } + return null; + } + + async createFloor(req: { repo?: string; dir?: string }): Promise<{ repo: string; dir: string; name: string } | null> { + const provider = this.getFloorProvider(); + if (provider?.createFloor) { + return await provider.createFloor(req); + } + return null; + } + + async listServices(floor: Floor): Promise { + const provider = this.getServicesProvider(); + if (provider) { + return await provider.listServices(floor); + } + return null; + } + + getProjectsProvider(): ProjectsProvider | null { + for (const p of this.plugins.values()) { + if (p.projectsProvider) return p.projectsProvider; + } + return null; + } + + getState(): PluginsState { + const all = this.registry.getState(); + const installed = all.plugins.map((m) => { + const active = this.plugins.has(m.id); + const plugin = this.plugins.get(m.id); + return { + ...m, + installed: true, + version: plugin?.manifest?.version ?? m.version, + author: plugin?.manifest?.author ?? m.author, + status: (m.status === 'error' ? 'error' : active ? 'active' : 'inactive') as 'active' | 'inactive' | 'error', + }; + }); + const store = (all.store ?? []).map((m) => { + const plugin = this.plugins.get(m.id); + return { + ...m, + version: plugin?.manifest?.version ?? m.version, + author: plugin?.manifest?.author ?? m.author, + installed: false, + status: 'inactive' as const, + }; + }); + return { plugins: installed, store, pluginsDir: this.registry.pluginsDir }; + } +} diff --git a/src/server/plugins/registry.ts b/src/server/plugins/registry.ts new file mode 100644 index 000000000..a00b4306b --- /dev/null +++ b/src/server/plugins/registry.ts @@ -0,0 +1,79 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import type { PluginManifest, PluginsState } from '../../shared/protocol.js'; + +export const BUILTIN_PLUGINS: PluginManifest[] = []; + +export class PluginRegistry { + private file: string; + public pluginsDir: string; + private manifests: Map = new Map(); + + constructor(dataDir?: string) { + const baseDir = dataDir ? (dataDir.endsWith('.agent-office') ? dataDir : path.join(dataDir, '.agent-office')) : path.join(process.cwd(), '.agent-office'); + this.file = path.join(baseDir, 'plugins.json'); + this.pluginsDir = path.join(baseDir, 'plugins'); + this.load(); + } + + private load() { + for (const p of BUILTIN_PLUGINS) { + this.manifests.set(p.id, { ...p }); + } + + if (existsSync(this.file)) { + try { + const raw = JSON.parse(readFileSync(this.file, 'utf8')); + if (Array.isArray(raw)) { + for (const item of raw) { + if (item && typeof item === 'object' && typeof item.id === 'string') { + const prev = this.manifests.get(item.id); + this.manifests.set(item.id, { + ...prev, + ...item, + installed: true, + status: 'active', + }); + } + } + } + } catch { + // ignore malformed file + } + } + } + + save() { + try { + const dir = path.dirname(this.file); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + const installed = [...this.manifests.values()].filter((m) => m.installed !== false); + writeFileSync(this.file, JSON.stringify(installed, null, 2), 'utf8'); + } catch { + // ignore + } + } + + register(manifest: PluginManifest) { + this.manifests.set(manifest.id, { ...manifest, installed: true }); + } + + list(): PluginManifest[] { + return [...this.manifests.values()]; + } + + get(id: string): PluginManifest | undefined { + return this.manifests.get(id); + } + + getState(): PluginsState { + const all = this.list(); + const installed = all.filter((m) => m.installed === true); + const store = all.filter((m) => m.installed !== true); + return { + plugins: installed, + store, + pluginsDir: this.pluginsDir, + }; + } +} diff --git a/src/server/plugins/types.ts b/src/server/plugins/types.ts new file mode 100644 index 000000000..ba50e0cae --- /dev/null +++ b/src/server/plugins/types.ts @@ -0,0 +1,64 @@ +import type { FloorDef } from '../building.js'; +import type { Floor } from '../floor.js'; +import type { FloorInfo, GhIssue, GhPull, PluginManifest, QueueTask, ServiceInfo, WorkerInfo } from '../../shared/protocol.js'; +import type { Ctx } from '../office/context.js'; +import type { ProviderMeta } from '../../shared/providers.js'; +import type { SomeAdapter } from '../providers/types.js'; + +export interface FloorProvider { + listFloors(): Promise | FloorDef[]; + sortFloors?(floors: FloorInfo[]): FloorInfo[]; + createFloor?(req: { repo?: string; dir?: string }): Promise<{ repo: string; dir: string; name: string } | null>; + decorateFloorInfo?(floor: Floor, info: FloorInfo): void; +} + +export interface IssuesAdapter { + listIssues(floor: Floor): Promise; +} + +export interface PullsAdapter { + listPulls(floor: Floor): Promise; +} + +export interface QueueAdapter { + listTasks?(floor: Floor): Promise; + onTaskCreated?(floor: Floor, prompt: string): Promise; + onTaskDeleted?(floor: Floor, taskId: string): Promise; +} + +export interface ServicesProvider { + listServices(floor: Floor): Promise; +} + +export interface AgentProviderPlugin { + id: string; + meta: ProviderMeta; + adapter: SomeAdapter; +} + +export interface ProjectConfig { + id: string; + name?: string; + url?: string; + token?: string; + [key: string]: unknown; +} + +export interface ProjectsProvider { + listProjects?(floor: Floor): Promise; + saveProjects?(floor: Floor, projects: ProjectConfig[]): Promise; +} + +export interface OfficePlugin { + manifest: PluginManifest; + init?(ctx: Ctx): Promise | void; + shutdown?(): Promise | void; + floorProvider?: FloorProvider; + issuesAdapter?: IssuesAdapter; + pullsAdapter?: PullsAdapter; + queueAdapter?: QueueAdapter; + servicesProvider?: ServicesProvider; + agentProvider?: AgentProviderPlugin; + projectsProvider?: ProjectsProvider; +} + diff --git a/src/server/providers/custom.ts b/src/server/providers/custom.ts index 557a42c3e..eb1f1f91d 100644 --- a/src/server/providers/custom.ts +++ b/src/server/providers/custom.ts @@ -5,7 +5,18 @@ import type { ProviderAdapter } from './types.js'; export const custom: ProviderAdapter = { id: 'custom', - launch: ({ args }) => ({ args }), + launch: ({ args, prompt, resumeSessionId }) => { + // A custom agent is hired with a task, so it has to be given one. The + // office's own default agent never had this problem: every provider but + // `custom` put its own CLI's prompt flag in `launch`, and a custom one is + // run untouched, so its task was silently dropped. + // + // A custom agent that knows about sessions resumes them. Ahead of `--`, + // which ends the options and would swallow anything after it as prompt text. + if (resumeSessionId) args.push('--resume', resumeSessionId); + if (prompt) args.push('--', prompt); + return { args }; + }, hooksAs: 'claude', screen: { blocked: claudeBlocked }, usage: { transcript: true }, diff --git a/src/server/providers/index.ts b/src/server/providers/index.ts index 1158d0a0a..9f9b37735 100644 --- a/src/server/providers/index.ts +++ b/src/server/providers/index.ts @@ -26,17 +26,34 @@ export const PROVIDERS: Record = { custom, }; +const pluginAdapters: Record = {}; + +export function registerAgentProviderAdapter(id: string, adapter: SomeAdapter): void { + if (id in PROVIDERS || id in pluginAdapters) return; + pluginAdapters[id] = adapter; +} + +export function unregisterAgentProviderAdapter(id: string): void { + delete pluginAdapters[id]; +} + +export function getAdapter(provider: unknown): SomeAdapter | undefined { + if (typeof provider !== 'string') return undefined; + if (provider in PROVIDERS) return PROVIDERS[provider as AgentProvider]; + return pluginAdapters[provider]; +} + /** The hook route /hooks/`route` serves, when it's one (see ProviderAdapter.hook). */ export function providerHook(route: string): SomeAdapter['hook'] { - return providerAdapter(route)?.hook; + return getAdapter(route)?.hook; } /** A terminal title that's only an agent's own name ("Claude Code"): not worth showing, whichever agent the worker runs. */ export function titleNoise(title: string): boolean { - return Object.values(PROVIDERS).some((p) => p.titleNoise?.test(title)); + return [...Object.values(PROVIDERS), ...Object.values(pluginAdapters)].some((p) => p.titleNoise?.test(title)); } /** The adapter of `provider`, when it's one (workers.json and the wire can hold anything). */ export function providerAdapter(provider: unknown): SomeAdapter | undefined { - return typeof provider === 'string' && Object.hasOwn(PROVIDERS, provider) ? PROVIDERS[provider as AgentProvider] : undefined; + return getAdapter(provider); } diff --git a/src/server/queue.ts b/src/server/queue.ts index bc3736e06..5f044667f 100644 --- a/src/server/queue.ts +++ b/src/server/queue.ts @@ -6,6 +6,8 @@ import { DESK_BY_ID, SEATS, nextFreeSeat } from '../shared/layout.js'; import { validateWorkerEffort, validateWorkerModel } from './agents.js'; import { savedEffort, savedModel, takesEffort, takesModel } from '../shared/providers.js'; import { PROMPTS } from '../shared/prompts.js'; +import type { Floor } from './floor.js'; +import type { QueueAdapter } from './plugins/types.js'; /** What the queue needs from the worker manager. Narrow on purpose, so a smoke test can fake it. */ export interface QueueWorkers { @@ -70,6 +72,7 @@ export class TaskQueue { /** Seat workers in their own git worktree (only when the project is a git repo). */ private useWorktree: boolean, private events: QueueEvents, + private external?: { adapter: QueueAdapter; floor: Floor }, ) { this.statePath = path.join(dataDir, 'queue.json'); this.restore(); @@ -80,6 +83,62 @@ export class TaskQueue { return { tasks: this.tasks.map((t) => ({ ...t })), maxWorkers: this.maxWorkers }; } + /** + * Replaces the queue with external adapter's tasks for this project. + */ + async syncFromAdapter(): Promise { + const external = this.external; + if (!external || !external.adapter.listTasks) return; + try { + const seen = await external.adapter.listTasks(external.floor); + if (!seen) return; + const prevJson = JSON.stringify(this.state()); + + const externalMap = new Map(seen.map((t) => [t.id, t])); + const updatedTasks: QueueTask[] = []; + + for (const local of this.tasks) { + const ext = externalMap.get(local.id) || (local.externalId ? externalMap.get(local.externalId) : undefined); + if (ext) { + externalMap.delete(ext.id); + if (local.status === 'running' || local.workerId) { + updatedTasks.push(local); + } else { + updatedTasks.push({ + ...local, + status: ext.status, + title: ext.title || local.title, + prompt: ext.prompt || local.prompt, + externalId: ext.id, + }); + } + } else { + // If task was added locally or is currently queued/running, keep it + if (!local.externalId || local.status === 'running' || local.status === 'queued') { + updatedTasks.push(local); + } + } + } + + // Add remaining external tasks not yet in local queue + for (const ext of externalMap.values()) { + updatedTasks.push({ + ...ext, + provider: ext.provider ?? this.workers.officeDefault?.provider ?? this.workers.defaultProvider, + externalId: ext.id, + }); + } + + this.tasks = updatedTasks; + const nextJson = JSON.stringify(this.state()); + if (prevJson !== nextJson) { + this.changed(); + } + } catch { + /* ignore */ + } + } + get limit(): number { return this.maxWorkers; } @@ -111,6 +170,9 @@ export class TaskQueue { status: 'queued', }; this.tasks.push(task); + if (this.external?.adapter.onTaskCreated) { + void this.external.adapter.onTaskCreated(this.external.floor, prompt); + } this.changed(); this.pump(); return undefined; @@ -121,6 +183,9 @@ export class TaskQueue { if (!t) return 'No such task'; if (t.status === 'running') return `${t.workerName ?? 'Its worker'} is on it — send the worker home to stop it`; this.tasks.splice(this.tasks.indexOf(t), 1); + if (this.external?.adapter.onTaskDeleted) { + void this.external.adapter.onTaskDeleted(this.external.floor, taskId); + } this.changed(); this.pump(); return undefined; @@ -164,8 +229,14 @@ export class TaskQueue { /** Forgets the finished tasks. */ clear() { + const toDelete = this.tasks.filter((t) => t.status === 'done'); const before = this.tasks.length; this.tasks = this.tasks.filter((t) => t.status !== 'done'); + if (this.external?.adapter.onTaskDeleted) { + for (const t of toDelete) { + void this.external.adapter.onTaskDeleted(this.external.floor, t.id); + } + } if (this.tasks.length !== before) this.changed(); } diff --git a/src/server/server.ts b/src/server/server.ts index a97beb5ba..24cec04fe 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -30,6 +30,7 @@ export async function startServer(cfg: Config, opts: StartOptions = {}) { const ctx = {} as Ctx; Object.assign(ctx, messaging(ctx), floorHelpers(ctx), people(ctx), navigation(ctx), gates(ctx)); Object.assign(ctx, createCore(ctx, cfg, publicDir)); + await ctx.plugins.init(ctx); const { hookServer, hookPort } = await startHookServer(ctx); Object.assign(ctx, createServices(ctx)); Object.assign(ctx, await openFloors(ctx, hookPort)); @@ -55,6 +56,7 @@ export async function startServer(cfg: Config, opts: StartOptions = {}) { const shutdown = (keep = false) => { stopTimers(); ctx.cancelFloorsChanged(); + void ctx.plugins.shutdown(); ctx.arcade.flush(); ctx.upgrader.stop(); services.stop(); diff --git a/src/server/ws/connection.ts b/src/server/ws/connection.ts index d740bd348..f037b978c 100644 --- a/src/server/ws/connection.ts +++ b/src/server/ws/connection.ts @@ -83,6 +83,7 @@ export function onConnection(ctx: Ctx, ws: WebSocket, url: URL, session: Session map: maps.state(), prompts: prompts.state(), leaveOnMerge: leaveOnMerge.state(), + plugins: ctx.plugins?.getState(), ...(onRoof ? roofView(ctx) : floorView(ctx, floor)), }); screensOf(ctx, client, floor); diff --git a/src/server/ws/handlers/floors.ts b/src/server/ws/handlers/floors.ts index 3f0f2cdab..0a7e163f7 100644 --- a/src/server/ws/handlers/floors.ts +++ b/src/server/ws/handlers/floors.ts @@ -1,6 +1,10 @@ // The building's floors: riding the elevator between them and up to the roof, and adding and taking // off floors. -import type { FloorClientMsg } from '../../../shared/protocol.js'; +import { existsSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { FloorClientMsg, RepoChoice } from '../../../shared/protocol.js'; +import type { Ctx } from '../../office/context.js'; import { ROOF } from '../../../shared/rooftop.js'; import { arrivalSpot, str } from '../../office/input.js'; import type { HandlerMap, ViewPieces } from './types.js'; @@ -19,6 +23,26 @@ export const floorHandlers = { else ctx.goToFloor(c, floor, arrivalSpot(msg.at)); }, 'floor.repos'(ctx, c, msg) { + const floorProvider = ctx.plugins?.getFloorProvider(); + if (floorProvider) { + void (async () => { + try { + const defs = await floorProvider.listFloors(); + const onBuilding = new Set(ctx.building.list().map((d) => d.id)); + const choices: RepoChoice[] = (defs || []) + .filter((d) => !onBuilding.has(d.id)) + .map((d) => ({ + name: d.dir || d.name, + description: d.name, + private: false, + })); + ctx.sendTo(c, { t: 'floor.repos', repos: choices }); + } catch (err) { + ctx.sendTo(c, { t: 'floor.repos', repos: [], error: (err as Error).message }); + } + })(); + return; + } void ctx.building.repos(msg.refresh === true).then( (repos) => ctx.sendTo(c, { t: 'floor.repos', repos }), (err: Error) => ctx.sendTo(c, { t: 'floor.repos', repos: [], error: `Couldn't list your repositories with gh: ${err.message}` }), @@ -26,7 +50,44 @@ export const floorHandlers = { }, 'floor.add'(ctx, c, msg) { const who = c.peer.name; - const repo = str(msg.repo, 200); + const repo = str(msg.repo, 400); + const floorProvider = ctx.plugins?.getFloorProvider(); + if (floorProvider) { + void (async () => { + try { + const res = await ctx.plugins?.createFloor({ repo, dir: repo }); + if (!res) return ctx.sendTo(c, { t: 'floor.added', repo, error: 'Could not create project via plugin' }); + ctx.building.unremove(res.dir || res.name); + ctx.building.unremove(repo); + const defs = await floorProvider.listFloors(); + ctx.building.setList(defs); + for (const d of defs) ctx.openFloor(d); + ctx.floorsChanged(); + const floor = ctx.floors.get(res.name) || [...ctx.floors.values()].find((f) => f.def.name === res.name); + console.log(` ${who} added ${res.name} as a floor`); + ctx.toastAll(`🛗 New floor: ${res.name}, added by ${who}`); + ctx.sendTo(c, { t: 'floor.added', repo, floor: floor?.id }); + } catch (err) { + ctx.sendTo(c, { t: 'floor.added', repo, error: (err as Error).message }); + } + })(); + return; + } + const isDir = repo.startsWith('/') || repo.startsWith('~') || existsSync(path.resolve(repo)); + if (isDir) { + const dirPath = path.resolve(repo.startsWith('~') ? path.join(os.homedir(), repo.slice(1)) : repo); + if (existsSync(dirPath)) { + const name = path.basename(dirPath); + const res = ctx.building.addExisting(dirPath, name, who); + ctx.floorsChanged(); + if (typeof res === 'string') return ctx.sendTo(c, { t: 'floor.added', repo, error: res }); + const floor = ctx.openFloor(res); + if (!floor) return ctx.sendTo(c, { t: 'floor.added', repo, error: `Added ${name}, but couldn't open its floor` }); + console.log(` ${who} added a floor for ${name} (${dirPath})`); + ctx.toastAll(`🛗 New floor: ${name}, added by ${who}`); + return ctx.sendTo(c, { t: 'floor.added', repo, floor: floor.id }); + } + } void ctx.building .add( repo, @@ -78,4 +139,13 @@ export const floorHandlers = { ctx.broadcast({ t: 'projectsDir', state }); ctx.toastAll(state.custom ? `📁 ${who} moved the workspace folder to ${state.dir}` : `📁 ${who} put the workspace folder back to ${state.dir}`); }, + 'floor.maxFloors'(ctx, c, msg) { + const who = c.peer.name; + if (!ctx.meOf(c.accountId).admin) return ctx.warn(c, 'Only admins can change the max floors limit'); + const n = Math.max(1, Math.min(2000, Math.floor(Number(msg.max) || 500))); + ctx.building.setMaxFloors(n, who); + const state = ctx.building.projectsDirState(); + ctx.broadcast({ t: 'projectsDir', state }); + ctx.toastAll(`🏢 ${who} set the max floors limit to ${n}`); + }, } satisfies HandlerMap; diff --git a/src/server/ws/handlers/queue.ts b/src/server/ws/handlers/queue.ts index 31fabd87c..647661673 100644 --- a/src/server/ws/handlers/queue.ts +++ b/src/server/ws/handlers/queue.ts @@ -5,7 +5,10 @@ import { num, str } from '../../office/input.js'; import { here } from './common.js'; import type { HandlerMap, ViewPieces } from './types.js'; -export const queueView: ViewPieces['queue'] = (_ctx, floor) => floor?.queue.state() ?? { tasks: [], maxWorkers: 0 }; +export const queueView: ViewPieces['queue'] = (_ctx, floor) => { + if (floor) void floor.queue.syncFromAdapter(); + return floor?.queue.state() ?? { tasks: [], maxWorkers: 0 }; +}; export const queueHandlers = { 'queue.add'(ctx, c, msg) { @@ -28,14 +31,14 @@ export const queueHandlers = { }, 'queue.remove'(ctx, c, msg) { const floor = here(ctx, c); - if (floor) ctx.warn(c, floor.queue.remove(str(msg.taskId, 32))); + if (floor) ctx.warn(c, floor.queue.remove(str(msg.taskId, 128))); }, 'queue.move'(ctx, c, msg) { - ctx.floorOf(c)?.queue.move(str(msg.taskId, 32), num(msg.delta) < 0 ? -1 : 1); + ctx.floorOf(c)?.queue.move(str(msg.taskId, 128), num(msg.delta) < 0 ? -1 : 1); }, 'queue.retry'(ctx, c, msg) { const floor = here(ctx, c); - if (floor) ctx.warn(c, floor.queue.retry(str(msg.taskId, 32))); + if (floor) ctx.warn(c, floor.queue.retry(str(msg.taskId, 128))); }, 'queue.clear'(ctx, c) { ctx.floorOf(c)?.queue.clear(); diff --git a/src/server/ws/handlers/settings.ts b/src/server/ws/handlers/settings.ts index 06ab3bac3..6927904ba 100644 --- a/src/server/ws/handlers/settings.ts +++ b/src/server/ws/handlers/settings.ts @@ -133,4 +133,88 @@ export const settingsHandlers = { if (err) return ctx.warn(c, err); ctx.toastAll(choice ? `🤖 ${who} set the office’s default worker` : `🤖 ${who} put the office’s default worker back to ${path.basename(ctx.cfg.agentCmd)}`); }, + 'plugins.list'(ctx, c) { + if (ctx.plugins) { + ctx.sendTo(c, { t: 'plugins', state: ctx.plugins.getState() }); + } + }, + 'plugin.install'(ctx, c, msg) { + const who = c.peer.name; + if (!ctx.meOf(c.accountId).admin) return ctx.warn(c, 'Only admins can install plugins'); + const id = str(msg.id, 64); + if (!ctx.plugins) return; + void ctx.plugins.install(id).then((ok) => { + if (ok) { + ctx.toastAll(`🔌 ${who} installed plugin ${id}`); + for (const client of ctx.clients.values()) { + ctx.sendTo(client, { t: 'plugins', state: ctx.plugins!.getState() }); + } + } else { + ctx.warn(c, `Could not install plugin ${id}`); + } + }); + }, + 'plugin.uninstall'(ctx, c, msg) { + const who = c.peer.name; + if (!ctx.meOf(c.accountId).admin) return ctx.warn(c, 'Only admins can uninstall plugins'); + const id = str(msg.id, 64); + if (!ctx.plugins) return; + void ctx.plugins.uninstall(id).then((ok) => { + if (ok) { + ctx.toastAll(`🔌 ${who} uninstalled plugin ${id}`); + for (const client of ctx.clients.values()) { + ctx.sendTo(client, { t: 'plugins', state: ctx.plugins!.getState() }); + } + } else { + ctx.warn(c, `Could not uninstall plugin ${id}`); + } + }); + }, + 'plugin.openFolder'(ctx, c, msg) { + if (!ctx.meOf(c.accountId).admin) return ctx.warn(c, 'Only admins can open folders on server host'); + if (ctx.plugins) { + const id = msg.id ? str(msg.id, 64) : undefined; + ctx.plugins.openFolder(id); + } + }, + 'plugin.installFromGit'(ctx, c, msg) { + const who = c.peer.name; + if (!ctx.meOf(c.accountId).admin) return ctx.warn(c, 'Only admins can install plugins'); + const url = str(msg.url, 2048).trim(); + if (!ctx.plugins) return; + void ctx.plugins.installFromGit(url).then((result) => { + if (result.ok) { + ctx.toastAll(`🔌 ${who} installed plugin from ${url}`); + for (const client of ctx.clients.values()) { + ctx.sendTo(client, { t: 'plugins', state: ctx.plugins!.getState() }); + } + } else { + ctx.warn(c, result.error ?? 'Failed to install plugin'); + } + }); + }, + 'plugin.update'(ctx, c, msg) { + const who = c.peer.name; + if (!ctx.meOf(c.accountId).admin) return ctx.warn(c, 'Only admins can update plugins'); + const id = str(msg.id, 64); + if (!ctx.plugins) return; + void ctx.plugins.updatePlugin(id).then((result) => { + if (result.ok) { + ctx.toastAll(`🔌 ${who} updated plugin ${id}`); + for (const client of ctx.clients.values()) { + ctx.sendTo(client, { t: 'plugins', state: ctx.plugins!.getState() }); + } + } else { + ctx.warn(c, result.error ?? `Failed to update plugin ${id}`); + } + }); + }, + 'plugin.checkUpdates'(ctx, _c) { + if (!ctx.plugins) return; + void ctx.plugins.checkUpdates().then(() => { + for (const client of ctx.clients.values()) { + ctx.sendTo(client, { t: 'plugins', state: ctx.plugins!.getState() }); + } + }); + }, } satisfies HandlerMap; diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index ee0d258ef..aff01cf48 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -23,6 +23,7 @@ export * from './protocol/changes.js'; export * from './protocol/floors.js'; export * from './protocol/github.js'; export * from './protocol/meetings.js'; +export * from './protocol/plugins.js'; export * from './protocol/presence.js'; export * from './protocol/queue.js'; export * from './protocol/rooftop.js'; diff --git a/src/shared/protocol/floors.ts b/src/shared/protocol/floors.ts index ce908eaa2..8c9f4fab5 100644 --- a/src/shared/protocol/floors.ts +++ b/src/shared/protocol/floors.ts @@ -18,6 +18,9 @@ import type { JailState, WorkerInfo } from './workers.js'; export interface ProjectInfo { name: string; + /** Optional external identifier (e.g. from an external harness plugin). */ + externalId?: string; + url?: string; dir: string; branch?: string; remote?: string; @@ -50,6 +53,8 @@ export interface FloorInfo { local?: boolean; addedBy: string; addedAt: number; + externalId?: string; + url?: string; /** * For the elevator panel: who's there and what they're up to. `workers` counts the ones hired onto * desks, bean bags and the meeting room's table, not the board agents at their kiosks. @@ -81,6 +86,8 @@ export interface ProjectsDirState { custom: boolean; by?: string; at?: number; + /** Configured max floors limit (default: 500). */ + maxFloors?: number; } /** A repository the office's `gh` login can clone, for the elevator's "add a project". */ @@ -142,7 +149,9 @@ export type FloorClientMsg = /** Take a floor off the building (admins only). Its checkout stays on disk; everyone on it rides to another floor. */ | { t: 'floor.remove'; floor: string } /** Where new floors are cloned from now on (admins only); '' goes back to the default. */ - | { t: 'floor.projectsDir'; dir: string }; + | { t: 'floor.projectsDir'; dir: string } + /** Configure max floors / projects limit (admins only). */ + | { t: 'floor.maxFloors'; max: number }; export type PlanClientMsg = /** Hang a sign over a desk on your floor (a SIGN_COLORS color), or take it down with no text. */ diff --git a/src/shared/protocol/plugins.ts b/src/shared/protocol/plugins.ts new file mode 100644 index 000000000..af61d12fe --- /dev/null +++ b/src/shared/protocol/plugins.ts @@ -0,0 +1,25 @@ +// Manifest and configuration for Agent Office plugins. + +export type PluginCapability = 'floors' | 'queue' | 'services' | 'agent' | 'issues' | 'pulls'; + +export interface PluginManifest { + id: string; + name: string; + author?: string; + description: string; + capabilities: PluginCapability[]; + githubUrl?: string; + installed?: boolean; + status?: 'active' | 'inactive' | 'error'; + error?: string; + version?: string; + latestVersion?: string; + updateAvailable?: boolean; + local?: boolean; +} + +export interface PluginsState { + plugins: PluginManifest[]; + store?: PluginManifest[]; + pluginsDir?: string; +} diff --git a/src/shared/protocol/presence.ts b/src/shared/protocol/presence.ts index 8bc66a2f1..00c5c524b 100644 --- a/src/shared/protocol/presence.ts +++ b/src/shared/protocol/presence.ts @@ -6,6 +6,7 @@ import type { EmoteId } from '../emotes.js'; import type { DrinkId } from '../rooftop.js'; import type { Me } from './accounts.js'; import type { FloorInfo, FloorView, ProjectsDirState } from './floors.js'; +import type { PluginsState } from './plugins.js'; import type { LeaveOnMergeState, MachineState, MapState, NotifyState, PromptsState, SkyState, ThemeState, UpgradeState } from './settings.js'; import type { PlanLimits, UsageState } from './usage.js'; @@ -135,6 +136,7 @@ export type PresenceServerMsg = /** The office's prompts and the worker everyone starts on. */ prompts: PromptsState; leaveOnMerge: LeaveOnMergeState; + plugins?: PluginsState; } & FloorView) | { t: 'peer.join'; peer: PeerInfo } | { t: 'peer.update'; peer: PeerInfo } diff --git a/src/shared/protocol/queue.ts b/src/shared/protocol/queue.ts index 2a54530f9..d09d2d0c8 100644 --- a/src/shared/protocol/queue.ts +++ b/src/shared/protocol/queue.ts @@ -8,6 +8,8 @@ export type TaskStatus = 'queued' | 'running' | 'done'; export interface QueueTask { id: string; provider?: AgentProvider; + /** Optional external task / session identifier. */ + externalId?: string; /** Model requested for this task, instead of the office's configured default: an id its provider takes (see WorkerInfo.model). */ model?: string; /** Reasoning effort requested for this task, when one was chosen (Claude only). */ diff --git a/src/shared/protocol/settings.ts b/src/shared/protocol/settings.ts index 5ad8a4438..4ebcef9cb 100644 --- a/src/shared/protocol/settings.ts +++ b/src/shared/protocol/settings.ts @@ -3,6 +3,7 @@ import type { CustomMap } from '../maps/index.js'; import type { PromptId } from '../prompts.js'; import type { AgentChoice } from './agents.js'; +import type { PluginsState } from './plugins.js'; /** * The prompts the office writes for workers by itself (shared/prompts.ts) and the worker everyone @@ -190,7 +191,21 @@ export type SettingsClientMsg = /** Rewrite one of the office's prompts (admins only); null puts the default back. */ | { t: 'prompts.set'; id: PromptId; text: string | null } /** Pick the worker everyone starts on (admins only); null goes back to the office's --agent. */ - | { t: 'prompts.agent'; choice: AgentChoice | null }; + | { t: 'prompts.agent'; choice: AgentChoice | null } + /** Request installed plugins list */ + | { t: 'plugins.list' } + /** Install or enable a plugin by ID */ + | { t: 'plugin.install'; id: string } + /** Uninstall or disable a plugin by ID */ + | { t: 'plugin.uninstall'; id: string } + /** Open plugins directory in system file explorer */ + | { t: 'plugin.openFolder'; id?: string } + /** Install a plugin from a Git URL */ + | { t: 'plugin.installFromGit'; url: string } + /** Update a plugin to the latest release */ + | { t: 'plugin.update'; id: string } + /** Check for plugin updates */ + | { t: 'plugin.checkUpdates' }; export type SettingsServerMsg = | { t: 'upgrade'; state: UpgradeState } @@ -201,4 +216,5 @@ export type SettingsServerMsg = | { t: 'theme'; state: ThemeState } | { t: 'map'; state: MapState } | { t: 'prompts'; state: PromptsState } - | { t: 'leaveOnMerge'; state: LeaveOnMergeState }; + | { t: 'leaveOnMerge'; state: LeaveOnMergeState } + | { t: 'plugins'; state: PluginsState }; diff --git a/src/shared/providers.ts b/src/shared/providers.ts index 720dd8725..03b3508db 100644 --- a/src/shared/providers.ts +++ b/src/shared/providers.ts @@ -183,7 +183,7 @@ export interface ProviderMeta { }; } -export const PROVIDER_META: Record = { +export const PROVIDER_META: Record = { claude: { label: 'Claude Code', name: 'Claude Code', @@ -358,3 +358,26 @@ export function providerNames(has: (meta: ProviderMeta) => boolean): string { const names = AGENT_PROVIDERS.map((p) => PROVIDER_META[p]).filter(has).map((m) => m.name); return names.length > 1 ? `${names.slice(0, -1).join(', ')} or ${names[names.length - 1]}` : names.join(''); } + +const pluginProviders: string[] = []; + +export function registerAgentProvider(id: string, meta: ProviderMeta): void { + if (PROVIDER_META[id]) return; + PROVIDER_META[id] = meta; + pluginProviders.push(id); +} + +export function unregisterAgentProvider(id: string): void { + if (!pluginProviders.includes(id)) return; + delete PROVIDER_META[id]; + const idx = pluginProviders.indexOf(id); + if (idx >= 0) pluginProviders.splice(idx, 1); +} + +export function getPluginProviders(): string[] { + return [...pluginProviders]; +} + +export function getAllProviders(): string[] { + return [...AGENT_PROVIDERS, ...pluginProviders]; +} diff --git a/tests/agents.test.ts b/tests/agents.test.ts index 1223f797d..bd33c9c5e 100644 --- a/tests/agents.test.ts +++ b/tests/agents.test.ts @@ -91,7 +91,7 @@ test('model validation follows the provider', () => { assert.equal(validateWorkerModel('agent', 'codex', 'gpt-oss:20b'), undefined); assert.match(validateWorkerModel('agent', 'codex', '--yolo') ?? '', /Invalid Codex model/); assert.match(validateWorkerModel('agent', 'codex', 'gpt 5.5') ?? '', /Invalid Codex model/); - // Custom still takes none. + // Custom takes none in the office UI (managed in its own configuration). assert.match(validateWorkerModel('agent', 'custom', 'anything') ?? '', /Claude Code, OpenCode, Codex, Grok, Muse, DeepSeek Harness, Pi or Cursor/); }); @@ -111,7 +111,7 @@ test('every provider the office knows the CLI of takes a model and (but for Curs for (const provider of AGENT_PROVIDERS) { const meta = PROVIDER_META[provider]; if (provider === 'custom') { - // A custom --agent is run as it is: the hire dialog says where its model comes from instead. + // A custom --agent runs as it is: its settings are configured outside the office. assert.equal(meta.validModel, undefined); assert.equal(meta.takesEffort, undefined); assert.ok(meta.unpicked); diff --git a/tests/building.test.ts b/tests/building.test.ts index c7a0fc97e..b6825073f 100644 --- a/tests/building.test.ts +++ b/tests/building.test.ts @@ -83,3 +83,19 @@ test('the floor the office was started in comes off too, stays off after a resta assert.equal(third.ensureLocal(defs[0].dir, 'the office')?.id, 'api'); assert.deepEqual(third.list().map((d) => d.id), ['web', 'docs', 'api']); }); + +test('Building.unremove allows re-adding a floor when setList is called by a provider', (t) => { + const { root, dataDir, defs } = office(t); + const building = new Building(dataDir, root); + building.remove('web'); + assert.deepEqual(building.list().map((d) => d.id), ['api', 'docs']); + + // Without unremove, setList filters out 'web' + building.setList(defs); + assert.deepEqual(building.list().map((d) => d.id), ['api', 'docs']); + + // With unremove, setList permits 'web' to be added back + building.unremove(defs[1]); + building.setList(defs); + assert.deepEqual(building.list().map((d) => d.id), ['api', 'web', 'docs']); +}); diff --git a/tests/client-store.test.ts b/tests/client-store.test.ts index adf83062c..612968e01 100644 --- a/tests/client-store.test.ts +++ b/tests/client-store.test.ts @@ -225,7 +225,7 @@ test('what the browser remembers keeps its keys and shapes', () => { test("the store's keys are its state, as window.__office shows them", () => { // As the office had them before its store was split into slices: methods and the slices aren't among them. - assert.deepEqual(Object.keys(store).sort(), ['accounts', 'ball', 'cabinet', 'cabinetFrame', 'cars', 'carsAt', 'chat', 'clock', 'decor', 'dog', 'dogStart', 'drawing', 'floor', 'floorPlan', 'floors', 'ice', 'invites', 'issues', 'jail', 'jukebox', 'leaveOnMerge', 'limits', 'machine', 'map', 'me', 'meeting', 'notify', 'peers', 'profile', 'project', 'projectsDir', 'prompts', 'pulls', 'queue', 'repos', 'screens', 'services', 'signins', 'sky', 'subs', 'team', 'theme', 'upgrade', 'usage', 'whiteboard', 'workers', 'you']); + assert.deepEqual(Object.keys(store).sort(), ['accounts', 'ball', 'cabinet', 'cabinetFrame', 'cars', 'carsAt', 'chat', 'clock', 'decor', 'dog', 'dogStart', 'drawing', 'floor', 'floorPlan', 'floors', 'ice', 'invites', 'issues', 'jail', 'jukebox', 'leaveOnMerge', 'limits', 'machine', 'map', 'me', 'meeting', 'notify', 'peers', 'plugins', 'profile', 'project', 'projectsDir', 'prompts', 'pulls', 'queue', 'repos', 'screens', 'services', 'signins', 'sky', 'subs', 'team', 'theme', 'upgrade', 'usage', 'whiteboard', 'workers', 'you']); }); test('a new store starts every field where it always has', async () => { @@ -249,7 +249,7 @@ test('a new store starts every field where it always has', async () => { dog: null, dogStart: 0, jukebox: { on: false, track: JUKEBOX_TUNES[0].id, startedAt: 0, elapsed: 0, since: 0 }, clock: '', whiteboard: [], drawing: [], cabinet: { player: null, scores: [] }, cabinetFrame: null, ball: {}, cars: parked(), carsAt: [], jail: { prisoners: [], bones: 0 }, - team: null, accounts: null, signins: null, + team: null, accounts: null, signins: null, plugins: { plugins: [] }, }, ); }); diff --git a/tests/custom.test.ts b/tests/custom.test.ts new file mode 100644 index 000000000..fd50267c5 --- /dev/null +++ b/tests/custom.test.ts @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { custom } from '../src/server/providers/custom.js'; + +test('a custom worker is given its task, and it is never read as an option', () => { + const launch = (prompt?: string, resumeSessionId?: string) => + custom.launch({ + h: { info: {} } as never, + args: [], + prompt, + resumeSessionId, + cwd: '/project', + setup: undefined, + }).args; + + // Without this the task was silently dropped: every provider but `custom` + // puts its own CLI's prompt flag in launch, and a custom one ran untouched. + assert.deepEqual(launch('fix the login redirect'), ['--', 'fix the login redirect']); + assert.deepEqual(launch('- fix login'), ['--', '- fix login']); + + // A worker hired with no task waits at its prompt for someone to type one. + assert.deepEqual(launch(), []); + + // A custom agent that knows sessions resumes them rather than starting over. + assert.deepEqual(launch('carry on', 'session-1'), ['--resume', 'session-1', '--', 'carry on']); +}); + +test('a custom worker reports on the office as Claude Code does', () => { + assert.equal(custom.hooksAs, 'claude'); +}); \ No newline at end of file diff --git a/tests/plugins.test.ts b/tests/plugins.test.ts new file mode 100644 index 000000000..944ba49d5 --- /dev/null +++ b/tests/plugins.test.ts @@ -0,0 +1,126 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { PluginManager } from '../src/server/plugins/manager.js'; +import { PluginRegistry } from '../src/server/plugins/registry.js'; + +test('PluginRegistry registers and lists plugins', () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'registry-test-')); + try { + const reg = new PluginRegistry(tmpDir); + reg.register({ id: 'test-plugin', name: 'Test Plugin', version: '1.0.0', description: 'A mock plugin', capabilities: ['floors', 'queue'] }); + const list = reg.list(); + assert.ok(list.length > 0); + const p = reg.get('test-plugin'); + assert.ok(p); + assert.equal(p?.id, 'test-plugin'); + assert.deepEqual(p?.capabilities, ['floors', 'queue']); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('PluginManager dynamically loads an external plugin', async () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'plugin-mgr-test-')); + const mockPluginPath = path.join(tmpDir, 'mock-plugin.mjs'); + const mockPluginCode = ` +export function createPlugin() { + return { + manifest: { id: 'mock-ext', name: 'Mock Extension', version: '1.0.0', description: 'Mock external plugin', capabilities: ['floors', 'services', 'issues', 'pulls'] }, + floorProvider: { + listFloors() { return [{ id: 'f1', name: 'Floor 1', dir: '/tmp/f1', palette: 0, addedBy: 'test', addedAt: Date.now() }]; } + }, + servicesProvider: { + async listServices() { return [{ port: 3000, host: 'localhost', pid: 123, command: 'test', workerId: 'w1', cwd: '/tmp/f1', since: Date.now() }]; } + }, + issuesAdapter: { + async listIssues() { return [{ number: 1, title: 'Issue 1', state: 'OPEN', url: 'http://example.com/1', author: 'test', labels: [], assignees: [], createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), body: '', comments: 0 }]; } + }, + pullsAdapter: { + async listPulls() { return [{ number: 10, title: 'PR 10', state: 'OPEN', isDraft: false, url: 'http://example.com/pr/10', author: 'test', labels: [], reviewDecision: '', headRefName: 'feature-branch', baseRefName: 'main', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), additions: 10, deletions: 2, checks: 'none', body: '', closes: [] }]; } + } + }; +} +`; + writeFileSync(mockPluginPath, mockPluginCode); + process.env.AGENT_OFFICE_PLUGIN = mockPluginPath; + try { + const mgr = new PluginManager(tmpDir); + await mgr.init({} as any); + const state = mgr.getState(); + const plugin = state.plugins.find((p) => p.id === 'mock-plugin'); + assert.ok(plugin, `mock-plugin not found in ${JSON.stringify(state)}`); + assert.equal(plugin?.status, 'active'); + const fp = mgr.getFloorProvider(); + assert.ok(fp); + const floors = await fp.listFloors(); + assert.equal(floors.length, 1); + assert.equal(floors[0]?.id, 'f1'); + const sp = mgr.getServicesProvider(); + assert.ok(sp); + const services = await sp.listServices({} as any); + assert.equal(services.length, 1); + assert.equal(services[0]?.port, 3000); + const ip = mgr.getIssuesAdapter(); + assert.ok(ip); + const issues = await ip!.listIssues({} as any); + assert.equal(issues.length, 1); + assert.equal(issues[0]?.number, 1); + assert.equal(issues[0]?.title, 'Issue 1'); + const pp = mgr.getPullsAdapter(); + assert.ok(pp); + const pulls = await pp!.listPulls({} as any); + assert.equal(pulls.length, 1); + assert.equal(pulls[0]?.number, 10); + assert.equal(pulls[0]?.title, 'PR 10'); + await mgr.shutdown(); + } finally { + delete process.env.AGENT_OFFICE_PLUGIN; + rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('PluginManager loads git-projects plugin bundle if present', async () => { + const pluginBundlePath = path.resolve(process.cwd(), 'plugins', 'git-projects.js'); + if (!existsSync(pluginBundlePath)) return; + const tmpDir = mkdtempSync(path.join(tmpdir(), 'git-plugin-test-')); + try { + process.env.AGENT_OFFICE_PLUGIN = pluginBundlePath; + const mgr = new PluginManager(tmpDir); + await mgr.init({} as any); + const state = mgr.getState(); + const plugin = state.plugins.find((p) => p.id === 'git-projects'); + assert.ok(plugin); + assert.equal(plugin?.status, 'active'); + assert.ok(mgr.getIssuesAdapter()); + assert.ok(mgr.getPullsAdapter()); + await mgr.shutdown(); + } finally { + delete process.env.AGENT_OFFICE_PLUGIN; + rmSync(tmpDir, { recursive: true, force: true }); + } +}); + +test('PluginManager installs and uninstalls plugins', async () => { + const tmpDir = mkdtempSync(path.join(tmpdir(), 'install-test-')); + try { + const mgr = new PluginManager(tmpDir); + const reg = (mgr as any).registry as PluginRegistry; + reg.register({ id: 'test-install', name: 'Test Install', version: '1.0.0', description: 'Test install desc', capabilities: [] }); + const ok = await mgr.install('test-install'); + assert.equal(ok, true); + const ok2 = await mgr.uninstall('test-install'); + assert.equal(ok2, true); + + // With active runtime context, installing nonexistent plugin fails gracefully + mgr.ctx = {} as any; + const failInstall = await mgr.install('test-install'); + assert.equal(failInstall, false); + const manifest = reg.get('test-install'); + assert.equal(manifest?.status, 'error'); + } finally { + rmSync(tmpDir, { recursive: true, force: true }); + } +}); diff --git a/tests/repos.test.ts b/tests/repos.test.ts index b7fd83a61..510f0630f 100644 --- a/tests/repos.test.ts +++ b/tests/repos.test.ts @@ -171,7 +171,7 @@ test('a worker across repositories gets a workspace with a worktree of each on o const [start] = await waitFor(() => f.starts(), (s) => s.length === 1); assert.equal(realpathSync(start.cwd), ws); assert.equal(start.top, null); - assert.equal(start.ceiling, path.dirname(ws)); + assert.ok(start.ceiling?.split(path.delimiter).includes(path.dirname(ws))); // It comes back with them after a restart. workers.shutdown(); @@ -343,7 +343,7 @@ const pull = (number: number, state: string, headRefName: string, headRefOid?: s test('a worker across repositories goes home once its pull requests have merged and none is open, on any floor', () => { const w: WorkerInfo = { - id: 'pip', kind: 'agent', deskId: 'desk-1', name: 'Pip', color: '#fff', status: 'done', acked: true, createdBy: 't', createdAt: 0, cols: 80, rows: 24, viewers: [], + id: 'pip', kind: 'agent', deskId: 'desk-1', name: 'Pip', color: '#fff', status: 'done', acked: true, createdBy: 't', createdAt: 0, cols: 80, rows: 24, viewers: [], viewerIds: [], worktree: { path: '.agent-office/worktrees/pip-1/web', branch: 'office/pip-1', base: 'a' }, repos: [{ floor: 'api', name: 'api', dir: '/api', path: '.agent-office/worktrees/pip-1/api', branch: 'office/pip-1', base: 'b', pr: { number: 9, url: '' } }], }; From 17ef26db3beaef317dccc62daf3775fff75f1b66 Mon Sep 17 00:00:00 2001 From: JamesDAdams Date: Sat, 10 Oct 2026 14:13:17 +0200 Subject: [PATCH 2/3] feat(plugins): support generic plugin seams for agent providers, services, session iframe, and projects --- docs/code-layout.md | 16 ++- src/client/state/slices/plugins.ts | 16 ++- src/client/ui/boards.ts | 6 +- src/client/ui/github/projects.ts | 134 +++++++++++++++++++ src/client/ui/provider.ts | 57 ++++++-- src/client/ui/services.ts | 109 ++++++++++++++- src/client/ui/settings.css | 4 + src/client/ui/terminal.ts | 3 +- src/client/ui/termtabs.ts | 42 +++++- src/server/agents.ts | 20 ++- src/server/building-store.ts | 47 +++++++ src/server/building.ts | 44 +++---- src/server/github.ts | 19 ++- src/server/http/routes/index.ts | 6 + src/server/http/routes/pages.ts | 68 +++++++++- src/server/http/routes/services.ts | 49 ++++++- src/server/office/context.ts | 2 +- src/server/office/floors.ts | 2 +- src/server/office/services.ts | 46 ++++++- src/server/plugin-floors.ts | 26 ++++ src/server/plugins/entry.ts | 97 ++++++++++++++ src/server/plugins/manager.ts | 204 +++++++++++++++-------------- src/server/plugins/plugin-files.ts | 66 ++++++++++ src/server/plugins/registry.ts | 5 + src/server/plugins/types.ts | 3 + src/server/prompts.ts | 6 +- src/server/providers/claude.ts | 9 ++ src/server/ws/handlers/floors.ts | 2 +- src/server/ws/handlers/meetings.ts | 4 +- src/server/ws/handlers/queue.ts | 4 +- src/server/ws/handlers/workers.ts | 4 +- src/shared/protocol/agents.ts | 2 +- src/shared/protocol/floors.ts | 1 + src/shared/protocol/plugins.ts | 9 ++ src/shared/protocol/workers.ts | 6 + src/shared/providers.ts | 15 ++- tests/agents.test.ts | 19 +++ tests/floor-merge.test.ts | 83 ++++++++++++ tests/github.test.ts | 11 +- tests/office-plugins-e2e.test.ts | 149 +++++++++++++++++++++ tests/plugin-settings.test.ts | 142 ++++++++++++++++++++ tests/plugins.test.ts | 7 +- tests/provider-plugins.test.ts | 52 ++++++++ tests/services.test.ts | 92 +++++++++++++ tests/session-url.test.ts | 77 +++++++++++ tests/support/dom.mjs | 103 +++++++++++++++ tests/termtabs.test.ts | 152 +++++++++++++++++++++ 47 files changed, 1858 insertions(+), 182 deletions(-) create mode 100644 src/client/ui/github/projects.ts create mode 100644 src/server/building-store.ts create mode 100644 src/server/plugin-floors.ts create mode 100644 src/server/plugins/entry.ts create mode 100644 src/server/plugins/plugin-files.ts create mode 100644 tests/floor-merge.test.ts create mode 100644 tests/office-plugins-e2e.test.ts create mode 100644 tests/plugin-settings.test.ts create mode 100644 tests/provider-plugins.test.ts create mode 100644 tests/services.test.ts create mode 100644 tests/session-url.test.ts create mode 100644 tests/support/dom.mjs create mode 100644 tests/termtabs.test.ts diff --git a/docs/code-layout.md b/docs/code-layout.md index d76e3d42a..e31c2aebd 100644 --- a/docs/code-layout.md +++ b/docs/code-layout.md @@ -57,7 +57,21 @@ They're in `core/registry.ts`, and each is a field of `ctx`. Every registration - **`providers/`** holds one adapter per agent CLI (see [Adding an agent provider](#adding-an-agent-provider)). - **`tunnel/`** is `agent-office tunnel`, the one part that runs on someone's own computer instead of the office's: it asks the office for the workers' web servers (`/api/services`, in `http/routes/services.ts`) and listens on each one's port there. It imports nothing from the office but what the two say to each other (`tunnel/wire.ts`) and the cookie's name. -The rest of `src/server/` is a module per service or per thing a floor keeps (`dog.ts`, `jukebox.ts`, `queue.ts`, `meetings.ts`), made by the office or by each `Floor` (`floor.ts`). +- **`plugins/`** is the plugin system: `registry.ts` keeps the manifests, `manager.ts` loads and asks them (`entry.ts` finds where a plugin lives, `plugin-files.ts` finds the files it ships, such as its settings page), and `types.ts` is what a plugin may contribute. A floor provider, an issues provider, a services provider and an agent provider each plug in there, and the office knows nothing about any particular plugin. + +The rest of `src/server/` is a module per service or per thing a floor keeps (`dog.ts`, `jukebox.ts`, `queue.ts`, `meetings.ts`), made by the office or by each `Floor` (`floor.ts`). `building-store.ts` reads and writes `floors.json`, and `plugin-floors.ts` merges the floors a plugin provides into the building's own. + +## Plugin seams + +A plugin contributes through `plugins/types.ts`, and the office holds the seam; it never holds the plugin's logic. + +- **An agent provider** (`agentProvider`): `registerAgentProvider` puts it in the provider table, so `agentProviders()` hires it and it is no longer rejected as an unknown one. Its adapter and `hooksAs` say how it runs and reports. +- **A session page**: a provider hook may report `web_url` (or `embed_url`) for the session it started; the office keeps it as `WorkerInfo.sessionUrl` and the worker's terminal opens it as a tab. What is at the address is the provider's business. +- **Floors** (`floorProvider.listFloors`): merged into the building with `plugin-floors.ts`, never swapped in. A floor stays the same one when it is the same checkout, or the id its plugin keeps for it. +- **Services** (`servicesProvider`): `createLateServices` publishes them beside the ones the office's own port scan finds, on their own timer, so a plugin's rows come and go as it starts and stops things. +- **An issues board** (`issuesAdapter`): `github.ts` uses a plugin's rows when it has any, and asks `gh` when it has none, so a project the plugin does not cover still shows its issues and the board's Refresh still means something. +- **A settings page**: a plugin ships a file of its own and names it in its manifest (`settingsPage`). The office serves it under `/plugins//` — only ever from inside the plugin's own directory — and shows a button for it on the plugin's card in ⚙️ Settings. +- **A settings page's own reads and writes**: `/api/plugins//projects` hands the floor's projects to the plugin's `projectsProvider` (GET) and takes a list back for it to keep (POST). A page that needs to keep anything else is a file of the plugin's own, served beside it under the same prefix. ## Shared diff --git a/src/client/state/slices/plugins.ts b/src/client/state/slices/plugins.ts index 6dcc58115..badb213bc 100644 --- a/src/client/state/slices/plugins.ts +++ b/src/client/state/slices/plugins.ts @@ -1,4 +1,5 @@ import type { PluginsState } from '../../../shared/protocol'; +import { registerAgentProvider } from '../../../shared/providers'; import type { Slice } from '../store'; declare module '../store' { @@ -11,17 +12,30 @@ declare module '../store' { } } +function syncPluginProviders(state?: PluginsState) { + if (!state?.plugins) return; + for (const p of state.plugins) { + if (p.agentProviderMeta && (p.status === 'active' || p.installed)) { + registerAgentProvider(p.agentProviderMeta.name?.toLowerCase() || p.id, p.agentProviderMeta); + } + } +} + export const plugins: Slice = { init(s) { s.plugins = { plugins: [] }; }, on: { welcome(s, m) { - if (m.plugins) s.plugins = m.plugins; + if (m.plugins) { + s.plugins = m.plugins; + syncPluginProviders(m.plugins); + } return ['plugins']; }, plugins(s, m) { s.plugins = m.state; + syncPluginProviders(m.state); return ['plugins']; }, }, diff --git a/src/client/ui/boards.ts b/src/client/ui/boards.ts index 72cb9f241..c384785f5 100644 --- a/src/client/ui/boards.ts +++ b/src/client/ui/boards.ts @@ -8,6 +8,7 @@ import { labelChip, openLabels } from './github/labels'; import { inProgress } from './github/progress'; import type { BoardActions } from './github/prompts'; import { openPull } from './github/pull-window'; +import { openProjectsSettings } from './github/projects'; import { providerLabel } from './provider'; const TILTS = ['-1.2deg', '0.8deg', '-0.4deg', '1.4deg', '0deg', '-0.9deg']; @@ -129,10 +130,11 @@ function card(n: number, title: string, meta: (Node | string)[], i: number, oncl export function openBoard(kind: 'issues' | 'pulls', net: Net, actions: BoardActions) { const body = h('div.body'); - const status = h('span.board-status'); + const status = h('span.board-status', { style: 'margin-left:auto;' }); const refresh = h('button.btn', { title: 'Refresh from GitHub', onclick: () => net.send({ t: 'gh.refresh' }) }, '🔄 Refresh'); + const settings = h('button.btn', { title: 'Configure GitHub & GitLab projects', onclick: () => openProjectsSettings(net) }, '⚙️ Settings'); const close = h('button.btn.close', { 'aria-label': 'Close' }, '✕'); - const el = h('div.modal.board', { role: 'dialog', 'aria-label': kind === 'issues' ? 'Issues board' : 'Pull requests board' }, h('header', {}, h('h2', {}, kind === 'issues' ? '📌 Issues' : '🔀 Pull Requests'), status, refresh, close), body); + const el = h('div.modal.board', { role: 'dialog', 'aria-label': kind === 'issues' ? 'Issues board' : 'Pull requests board' }, h('header', {}, h('h2', {}, kind === 'issues' ? '📌 Issues' : '🔀 Pull Requests'), status, refresh, settings, close), body); const filters = loadFilters(kind); /** What each column's filter box holds (column key → text), for as long as the board is open. */ diff --git a/src/client/ui/github/projects.ts b/src/client/ui/github/projects.ts new file mode 100644 index 000000000..94e7f3ec9 --- /dev/null +++ b/src/client/ui/github/projects.ts @@ -0,0 +1,134 @@ +import { h, openModal, toast } from '../dom'; +import { store } from '../../state'; +import type { Net } from '../../net'; + +export interface ProjectEntry { + id?: string; + name: string; + url: string; + token?: string; +} + +export function openProjectsSettings(net: Net) { + const body = h('div.body'); + const close = h('button.btn.close', { 'aria-label': 'Close' }, '✕'); + const footer = h('footer'); + const el = h( + 'div.modal', + { role: 'dialog', 'aria-label': 'GitHub & GitLab Projects', style: 'width:min(720px,95vw);' }, + h('header', {}, h('h2', {}, '⚙️ GitHub & GitLab Projects'), close), + body, + footer, + ); + + const modal = openModal(el, { doing: 'configuring git projects' }); + close.addEventListener('click', () => modal.close()); + + let projects: ProjectEntry[] = []; + const listEl = h('div.projects-list', { style: 'margin-bottom:16px;' }); + const msgEl = h('p.setting-note', { style: 'margin:8px 0;' }); + + const nameInput = h('input', { type: 'text', placeholder: 'Name (e.g. frontend)', 'aria-label': 'Project name', style: 'flex:1;' }) as HTMLInputElement; + const urlInput = h('input', { type: 'text', placeholder: 'Repository URL (https://github.com/owner/repo)', 'aria-label': 'Repository URL', style: 'flex:2;' }) as HTMLInputElement; + const tokenInput = h('input', { type: 'password', placeholder: 'Token (optional for private repos)', 'aria-label': 'Token', style: 'flex:1;' }) as HTMLInputElement; + const addBtn = h('button.btn.primary', { type: 'button' }, '➕ Add'); + + const addRow = h('div', { style: 'display:flex;gap:8px;align-items:center;margin-top:12px;' }, nameInput, urlInput, tokenInput, addBtn); + + const currentFloor = store.currentFloor(); + const floorName = currentFloor?.name ?? store.project?.name ?? 'this project'; + const floorId = store.floor ?? currentFloor?.id ?? ''; + + const saveBtn = h('button.btn.primary', { type: 'button' }, 'Save projects'); + footer.append(h('span.grow', {}, `Configuration saved strictly per project for ${floorName}.`), saveBtn); + + const paint = () => { + listEl.replaceChildren(); + if (!projects.length) { + listEl.append(h('p.setting-note', {}, 'No multi-repository projects configured yet. The board shows issues from the main checkout or configured repositories.')); + return; + } + const table = h('table', { style: 'width:100%;border-collapse:collapse;font-size:13px;' }); + const thead = h('thead', {}, h('tr', {}, + h('th', { style: 'text-align:left;padding:6px 8px;border-bottom:2px solid var(--ink);' }, 'Name'), + h('th', { style: 'text-align:left;padding:6px 8px;border-bottom:2px solid var(--ink);' }, 'Repository URL'), + h('th', { style: 'text-align:left;padding:6px 8px;border-bottom:2px solid var(--ink);' }, 'Token'), + h('th', { style: 'padding:6px 8px;border-bottom:2px solid var(--ink);width:60px;' }, ''), + )); + const tbody = h('tbody'); + projects.forEach((p, idx) => { + const removeBtn = h('button.btn.btn-warn', { type: 'button', style: 'padding:2px 8px;font-size:12px;' }, '🗑'); + removeBtn.addEventListener('click', () => { + projects.splice(idx, 1); + paint(); + }); + const tr = h('tr', { style: 'border-bottom:1px solid rgba(0,0,0,0.1);' }, + h('td', { style: 'padding:6px 8px;font-weight:700;' }, p.name || '—'), + h('td', { style: 'padding:6px 8px;word-break:break-all;' }, p.url), + h('td', { style: 'padding:6px 8px;' }, p.token ? '••••••••' : h('span', { style: 'color:var(--muted);' }, 'None')), + h('td', { style: 'padding:6px 8px;text-align:right;' }, removeBtn), + ); + tbody.append(tr); + }); + table.append(thead, tbody); + listEl.append(table); + }; + + addBtn.addEventListener('click', () => { + const url = urlInput.value.trim(); + if (!url) return urlInput.focus(); + const name = nameInput.value.trim() || url.split('/').pop()?.replace(/\.git$/, '') || 'repo'; + const token = tokenInput.value.trim() || undefined; + projects.push({ id: name, name, url, token }); + nameInput.value = ''; + urlInput.value = ''; + tokenInput.value = ''; + paint(); + }); + + saveBtn.addEventListener('click', async () => { + saveBtn.disabled = true; + saveBtn.textContent = 'Saving…'; + try { + const res = await fetch(`/api/plugins/git-projects/projects?floor=${encodeURIComponent(floorId)}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ projects }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + msgEl.textContent = data.error || 'Failed to save projects'; + msgEl.style.color = '#c62828'; + return; + } + toast('Projects saved. Refreshing board…', 'info'); + net.send({ t: 'gh.refresh' }); + modal.close(); + } catch (err) { + msgEl.textContent = (err as Error).message; + msgEl.style.color = '#c62828'; + } finally { + saveBtn.disabled = false; + saveBtn.textContent = 'Save projects'; + } + }); + + body.append( + h('p.setting-note', { style: 'margin:0 0 12px;' }, `Connect multiple repositories for ${floorName} (frontend, backend, BFF...) to synchronize Issues and Pull Requests into this board.`), + listEl, + h('h4', { style: 'margin:12px 0 6px;' }, 'Add repository'), + addRow, + msgEl, + ); + + fetch(`/api/plugins/git-projects/projects?floor=${encodeURIComponent(floorId)}`) + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + if (d?.projects && Array.isArray(d.projects)) { + projects = d.projects; + paint(); + } + }) + .catch(() => paint()); + paint(); +} diff --git a/src/client/ui/provider.ts b/src/client/ui/provider.ts index 3a354ee38..d175fc3f6 100644 --- a/src/client/ui/provider.ts +++ b/src/client/ui/provider.ts @@ -1,15 +1,21 @@ import './provider.css'; import type { AgentChoice, AgentEffort, AgentProvider, ProjectInfo, Usage, WorkerInfo } from '../../shared/protocol'; import { AGENT_EFFORTS, isAgentEffort } from '../../shared/protocol'; -import { AGENT_PROVIDERS, CLAUDE_MODEL_NAMES, PROVIDER_META, claudeModelName, isAgentProvider, isClaudeModel, takesEffort, type ModelOption, getAllProviders } from '../../shared/providers'; +import { AGENT_PROVIDERS, CLAUDE_MODEL_NAMES, PROVIDER_META, claudeModelName, isClaudeModel, takesEffort, type ModelOption, getAllProviders } from '../../shared/providers'; import { store } from '../state'; import { h } from './dom'; export function getProviderLabel(provider: string): string { - return PROVIDER_META[provider]?.label ?? provider; + if (PROVIDER_META[provider]?.label) return PROVIDER_META[provider].label; + if (provider === 'openfox') return 'OpenFox'; + return provider.charAt(0).toUpperCase() + provider.slice(1); } -export const PROVIDER_LABEL: Record = Object.fromEntries(getAllProviders().map((p) => [p, PROVIDER_META[p].label])); +export const PROVIDER_LABEL = new Proxy({} as Record, { + get(_target, prop: string) { + return getProviderLabel(prop); + }, +}); export const EFFORT_LABEL: Record = { low: 'Low', @@ -45,24 +51,38 @@ export function engineLabel(w: Pick (options.includes(select.value as AgentProvider) ? (select.value as AgentProvider) : fallback); - const meta = () => PROVIDER_META[value()]; + const meta = () => + PROVIDER_META[value()] ?? { + label: getProviderLabel(value()), + name: getProviderLabel(value()), + usage: { note: providerUsageNote(value()) }, + }; /** Its models: the ones it always has, or the ones its CLI listed. */ const known = () => meta().models?.fixed ?? catalogues.get(value())?.list ?? []; /** Typed in: for a provider whose model is, and for one whose list couldn't be had. */ @@ -199,7 +228,7 @@ export function agentFields(project: ProjectInfo | null, id: string, initial: Ag modelSelect.replaceChildren(h('option', { value: '' }, meta().models?.unset ?? 'Default'), ...models.map((m) => h('option', { value: m.id }, m.name ?? m.id))); if (id && !models.some((m) => m.id === id)) modelSelect.append(h('option', { value: id }, id)); modelSelect.value = id; - if (modelInput.value.trim() !== id) modelInput.value = id; + if ((modelInput.value ?? '').trim() !== id) modelInput.value = id; suggestions.replaceChildren(...models.map((m) => h('option', { value: m.id, label: m.name }))); }; /** The efforts on offer: the ones the model picked takes, where its catalogue says. */ @@ -231,7 +260,7 @@ export function agentFields(project: ProjectInfo | null, id: string, initial: Ag else if (field.catalog && catalogue?.request) hint.textContent = `Loading ${m.label} models…`; else if (field.catalog && catalogue?.failed) hint.textContent = `${m.label}’s models couldn’t be listed: leave it empty for its default, or type a model id.`; else hint.textContent = field.hint; - fields.classList.toggle('hidden', !field && !m.takesEffort && !hint.textContent); + fields.classList.toggle('hidden', !field && !m?.takesEffort); paintEffort(); }; /** Asks the provider's CLI for its models, only once someone can see the fields. */ diff --git a/src/client/ui/services.ts b/src/client/ui/services.ts index 11954ccd3..2269fcc95 100644 --- a/src/client/ui/services.ts +++ b/src/client/ui/services.ts @@ -1,7 +1,7 @@ import './services.css'; import type { ServiceInfo, ServicesState } from '../../shared/protocol'; import { store } from '../state'; -import { h, openModal, timeAgo } from './dom'; +import { h, openModal, timeAgo, toast } from './dom'; import { copy, copyButton, guessOs, openCommand, OS_LABEL, type Os } from './team'; /** Whether this page came over the office's Tailscale network, where every server has its own link. */ @@ -59,6 +59,65 @@ export function openServices() { footer, ); + const controlService = async (action: 'start' | 'stop', svc: ServiceInfo) => { + try { + const name = svc.title || svc.command; + toast(action === 'start' ? 'Starting service…' : 'Stopping service…', 'info'); + const res = await fetch(`/api/services/${action}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ floor: store.floor, name, command: svc.command }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + toast(data.error || `Failed to ${action} service`, 'warn'); + } else { + toast(action === 'start' ? 'Service started' : 'Service stopped', 'info'); + } + } catch (err) { + toast((err as Error).message, 'warn'); + } + }; + + const showServiceLogs = async (svc: ServiceInfo) => { + const name = svc.title || svc.command; + const logPre = h('pre', { + style: 'max-height:60vh;overflow:auto;background:#1b1b1b;color:#f0f0f0;padding:12px;border-radius:10px;font-size:12px;line-height:1.45;white-space:pre-wrap;font-family:ui-monospace,Menlo,monospace;margin:0;', + }, 'Loading logs…'); + const refreshLogs = h('button.btn', { type: 'button' }, '🔄 Refresh'); + const closeLogs = h('button.btn.close', { 'aria-label': 'Close' }, '✕'); + const logModalEl = h( + 'div.modal', + { role: 'dialog', 'aria-label': `Logs: ${name}`, style: 'width:min(800px,95vw);' }, + h('header', {}, h('h2', {}, `📜 Logs · ${name}`), refreshLogs, closeLogs), + h('div.body', {}, logPre), + ); + const logModal = openModal(logModalEl, { doing: 'viewing service logs' }); + closeLogs.addEventListener('click', () => logModal.close()); + + const loadLogs = async () => { + refreshLogs.disabled = true; + try { + const floorId = store.floor ?? ''; + const res = await fetch(`/api/services/logs?floor=${encodeURIComponent(floorId)}&name=${encodeURIComponent(name)}`); + if (!res.ok) { + logPre.textContent = 'Could not load logs from service.'; + return; + } + const data = await res.json(); + const lines: string[] = data.logs ?? []; + logPre.textContent = lines.length ? lines.join('\n') : 'No logs recorded yet.'; + logPre.scrollTop = logPre.scrollHeight; + } catch (err) { + logPre.textContent = `Error: ${(err as Error).message}`; + } finally { + refreshLogs.disabled = false; + } + }; + refreshLogs.addEventListener('click', loadLogs); + void loadLogs(); + }; + const pick = async (svc: ServiceInfo) => { const s = store.services; picked = svc.port; @@ -120,9 +179,49 @@ export function openServices() { for (const svc of s.items) { const { who, color, branch } = describe(svc); const on = picked === svc.port; + const isStopped = svc.title?.includes('(Stopped)') || svc.workerId === 'dev-server-stopped'; + const isDevServer = + svc.workerId?.startsWith('dev-server') || + svc.title?.toLowerCase().includes('server') || + svc.title?.toLowerCase().includes('dev') || + svc.title?.includes('(Stopped)') || + svc.title?.includes('(Running)'); + + const actionsRow = h('div', { style: 'display:inline-flex;gap:6px;margin-left:auto;align-items:center;' }); + + if (isDevServer) { + const toggleBtn = h('button.btn', { + type: 'button', + title: isStopped ? 'Start dev server' : 'Stop dev server', + style: isStopped + ? 'background:#e8f5e9;border-color:#2e7d32;color:#1b5e20;padding:2px 8px;font-size:12px;font-weight:800;' + : 'background:#ffebee;border-color:#c62828;color:#b71c1c;padding:2px 8px;font-size:12px;font-weight:800;', + }, isStopped ? '▶ Start' : '⏹ Stop'); + toggleBtn.addEventListener('click', (e) => { + e.stopPropagation(); + void controlService(isStopped ? 'start' : 'stop', svc); + }); + + const logsBtn = h('button.btn', { + type: 'button', + title: 'View service logs', + style: 'padding:2px 8px;font-size:12px;font-weight:800;', + }, '📜 Logs'); + logsBtn.addEventListener('click', (e) => { + e.stopPropagation(); + void showServiceLogs(svc); + }); + + actionsRow.append(toggleBtn, logsBtn); + } + const title = direct ? `Open ${serviceUrl(svc.port)}` : `Open ${serviceUrl(svc.port)} (needs the tunnel, unless the office runs on this computer)`; - const open = h('a.btn', { href: serviceUrl(svc.port), target: '_blank', rel: 'noopener', title }, 'Open ↗'); - open.addEventListener('click', (e) => e.stopPropagation()); + if (svc.port > 0 && !isStopped) { + const open = h('a.btn', { href: serviceUrl(svc.port), target: '_blank', rel: 'noopener', title }, 'Open ↗'); + open.addEventListener('click', (e) => e.stopPropagation()); + actionsRow.append(open); + } + const li = h( 'li', { class: on ? 'on' : '', tabindex: 0, role: 'button', title: direct ? 'Copy the link' : 'Copy the tunnel command' }, @@ -133,8 +232,8 @@ export function openServices() { h('div.svc-title', {}, svc.title || svc.command), h('div.svc-meta', {}, [who, branch ? `🌿 ${branch}` : '', svc.title ? svc.command : '', `started ${timeAgo(svc.since)}`].filter(Boolean).join(' · ')), ), - h('span.svc-port', {}, `:${svc.port}`), - open, + svc.port > 0 ? h('span.svc-port', {}, `:${svc.port}`) : null, + actionsRow, ); li.addEventListener('click', () => void pick(svc)); li.addEventListener('keydown', (e) => { diff --git a/src/client/ui/settings.css b/src/client/ui/settings.css index f81ed25f3..c90c26bb3 100644 --- a/src/client/ui/settings.css +++ b/src/client/ui/settings.css @@ -61,3 +61,7 @@ .plugin-actions { display: flex; gap: 8px; align-items: center; margin-top: 6px; flex-wrap: wrap; } .plugin-actions .btn { font-size: 12px; padding: 4px 10px; } .plugin-error { margin: 0; font-size: 12px; font-weight: 700; color: #c62828; } + +/* A plugin's own settings page, served by the office from the plugin's directory. */ +.plugin-settings { margin-top: 6px; } +.plugin-settings-frame { width: 100%; height: 420px; border: 2px solid var(--ink); border-radius: 10px; background: #fff; } diff --git a/src/client/ui/terminal.ts b/src/client/ui/terminal.ts index fbf36b0b1..44e32eaf0 100644 --- a/src/client/ui/terminal.ts +++ b/src/client/ui/terminal.ts @@ -133,7 +133,7 @@ export function openTerminal(net: Net, workerId: string, onChanges?: () => void, const sayBtn = h('button.btn.primary', { type: 'submit' }, 'Send'); const sayForm = h('form.term-say', {}, dictateField(say), sayBtn); const keypad = opts.keypad ? h('div.term-keypad', {}, keys, sayForm) : null; - const tabs = termTabs(workerId, { host, keypad, focusTerm: () => term.focus() }); + const tabs = termTabs(workerId, { host, keypad, focusTerm: () => term.focus(), sessionUrl: info.sessionUrl }); // What you say is typed in at the terminal's cursor, as a paste, for you to read over and send (see dictate.ts). const mic = dictation( { @@ -340,6 +340,7 @@ export function openTerminal(net: Net, workerId: string, onChanges?: () => void, // Leaving with Esc while the program wanted one (you were in /skills, say): say how to send it one. if (byEsc && ready && screenMentionsEsc(term)) toast(`Esc left the terminal. To send ${store.workers.get(workerId)?.name ?? info.name} an Esc (to close a menu), use ⎋ Esc at the top or Ctrl+[`); listeners.delete(onMsg); + tabs.dispose(); unsub(); unsubPeers(); clearInterval(typingTimer); diff --git a/src/client/ui/termtabs.ts b/src/client/ui/termtabs.ts index ef6c2400b..e283e023a 100644 --- a/src/client/ui/termtabs.ts +++ b/src/client/ui/termtabs.ts @@ -3,6 +3,7 @@ // anyone else, and they're kept per worker until the page reloads. import './termtabs.css'; import { clip, h, toast } from './dom'; +import { store } from '../state'; /** A web page pinned open beside a worker's terminal. */ interface WebTab { @@ -26,10 +27,25 @@ export interface TermTabsOptions { keypad: HTMLElement | null; /** Back on the terminal's tab. */ focusTerm(): void; + /** + * Where this worker's session can be followed in a browser, when its provider has one. It arrives + * with the worker itself (see WorkerInfo.sessionUrl), whenever its session starts, and the office + * knows nothing about what is at the address. + */ + sessionUrl?: string; +} + +export interface TermTabs { + /** The bar above the terminal: the terminal itself, plus any page pinned open beside it. */ + bar: HTMLElement; + /** Where the pinned pages show. */ + pages: HTMLElement; + /** Stops following the worker; the terminal is closing. */ + dispose(): void; } /** The tab bar (above the terminal) and where the pages show (beside it). */ -export function termTabs(workerId: string, opts: TermTabsOptions): { bar: HTMLElement; pages: HTMLElement } { +export function termTabs(workerId: string, opts: TermTabsOptions): TermTabs { const tabsBar = h('div.term-tabs', { role: 'tablist', 'aria-label': 'Tabs' }); const tabName = h('input', { type: 'text', placeholder: 'Tab name (e.g. ChatGPT)', 'aria-label': 'Tab name', maxlength: '40', autocomplete: 'off' }) as HTMLInputElement; const tabUrl = h('input', { type: 'url', placeholder: 'https://…', 'aria-label': 'Web page address', autocomplete: 'off' }) as HTMLInputElement; @@ -99,6 +115,28 @@ export function termTabs(workerId: string, opts: TermTabsOptions): { bar: HTMLEl pages.append(frame); }; for (const t of tabs) frameFor(t); + + /** + * Opens a pinned tab for a page the worker's provider points at, once. The address arrives with + * the worker itself (see WorkerInfo.sessionUrl): a provider whose agent has a web UI of its own + * says where, and the office just opens it beside the terminal. + */ + // Seeded empty, not with the URL already in hand: the first call is what opens the tab, and this + // only remembers which one is open so the same address is never opened twice. + let offered: string | undefined; + const offerSession = () => { + const url = opts.sessionUrl ?? store.workers.get(workerId)?.sessionUrl; + if (!url || offered === url) return; + offered = url; + if (tabs.some((t) => t.url === url)) return; + const tab: WebTab = { id: `session-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, title: 'Session', url }; + tabs.push(tab); + frameFor(tab); + show(tab.id); + }; + offerSession(); + const offWorkers = store.on('workers', offerSession); + render(); addBtn.addEventListener('click', () => { @@ -129,5 +167,5 @@ export function termTabs(workerId: string, opts: TermTabsOptions): { bar: HTMLEl closeForm(); show(tab.id); }); - return { bar, pages }; + return { bar, pages, dispose: () => offWorkers() }; } diff --git a/src/server/agents.ts b/src/server/agents.ts index d5caeca0a..5aa925068 100644 --- a/src/server/agents.ts +++ b/src/server/agents.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { AGENT_PROVIDERS, PROVIDER_META, isAgentEffort, providerMeta, providerNames, type AgentProvider } from '../shared/providers.js'; +import { AGENT_PROVIDERS, PROVIDER_META, isAgentEffort, providerMeta, providerNames, getPluginProviders, getAllProviders, type AgentProvider } from '../shared/providers.js'; /** * Finds the provider represented by the configured executable. Keep this deliberately based on @@ -9,17 +9,23 @@ import { AGENT_PROVIDERS, PROVIDER_META, isAgentEffort, providerMeta, providerNa */ export function configuredProvider(command: string): AgentProvider { const base = path.basename(command.replaceAll('\\', '/')).toLowerCase().replace(/\.(?:exe|cmd|bat|com)$/, ''); - return AGENT_PROVIDERS.find((p) => PROVIDER_META[p].bin === base) ?? 'custom'; + for (const p of getAllProviders()) { + const meta = PROVIDER_META[p]; + if (!meta) continue; + if (meta.matchesCommand?.(command)) return p as AgentProvider; + if (meta.bin === base || meta.bins?.includes(base)) return p as AgentProvider; + } + return 'custom'; } -/** The providers an office started with `configured` can hire: the ones it knows, and a custom --agent only when that's what it was started with. */ +/** The providers an office started with `configured` can hire: the ones it knows, the ones its plugins register, and a custom --agent only when that's what it was started with. */ export function agentProviders(configured: AgentProvider): AgentProvider[] { - return AGENT_PROVIDERS.filter((p) => p !== 'custom' || configured === 'custom'); + return [...AGENT_PROVIDERS, ...getPluginProviders()].filter((p) => p !== 'custom' || configured === 'custom') as AgentProvider[]; } /** The command a provider's CLI runs as: the office's --agent when that's this provider, else its own executable. */ export function providerCommand(provider: AgentProvider, agentCmd: string): string { - return configuredProvider(agentCmd) === provider ? agentCmd : (PROVIDER_META[provider].bin ?? agentCmd); + return configuredProvider(agentCmd) === provider ? agentCmd : (PROVIDER_META[provider]?.bin ?? agentCmd); } export function validateWorkerModel(kind: 'agent' | 'shell', provider: AgentProvider | undefined, model: unknown): string | undefined { @@ -27,13 +33,13 @@ export function validateWorkerModel(kind: 'agent' | 'shell', provider: AgentProv if (kind === 'shell') return 'Shell workers do not have an agent model'; const meta = providerMeta(provider); if (!meta?.validModel) return `Models can only be selected for ${providerNames((m) => !!m.validModel)} workers`; - return meta.validModel(model) ? undefined : meta.invalidModel; + return meta.validModel(model) ? undefined : (meta.invalidModel ?? `Invalid ${meta.label} model`); } /** A reasoning effort for a worker: a flag of its provider's CLI, an OpenCode model's variant, or DSH's reasoning_effort configuration option. */ export function validateWorkerEffort(kind: 'agent' | 'shell', provider: AgentProvider | undefined, effort: unknown): string | undefined { if (effort === undefined) return undefined; - if (kind === 'shell') return 'Shell workers do not have a reasoning effort'; + if (kind === 'shell') return 'Shell workers do not have an agent effort'; if (!providerMeta(provider)?.takesEffort) return `Reasoning effort can only be selected for ${providerNames((m) => !!m.takesEffort)} workers`; return isAgentEffort(effort) ? undefined : 'Invalid effort (expected low, medium, high, xhigh or max)'; } diff --git a/src/server/building-store.ts b/src/server/building-store.ts new file mode 100644 index 000000000..2a1c4dbe2 --- /dev/null +++ b/src/server/building-store.ts @@ -0,0 +1,47 @@ +// Floors.json: what the building keeps on disk, and how it is put back. +// +// The building keeps its floors there so a restart is the same office; the shape on disk can be +// anything (people edit it, older offices wrote it), so it is checked on the way in and rewritten in +// the office's own shape on the way out. +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { normalizeRepo } from '../shared/floors.js'; +import type { FloorDef } from './building.js'; + +/** Reads floors.json, skipping anything that isn't a floor this office can use. */ +export function loadFloors(file: string): FloorDef[] { + if (!existsSync(file)) return []; + try { + const saved = JSON.parse(readFileSync(file, 'utf8')) as Partial[]; + const ids = new Set(); + const out: FloorDef[] = []; + for (const s of Array.isArray(saved) ? saved : []) { + if (typeof s.id !== 'string' || !/^[a-z0-9-]{1,40}$/.test(s.id) || ids.has(s.id) || typeof s.dir !== 'string' || !path.isAbsolute(s.dir)) continue; + ids.add(s.id); + out.push({ + id: s.id, + name: typeof s.name === 'string' && s.name ? s.name.slice(0, 100) : path.basename(s.dir), + repo: normalizeRepo(s.repo), + dir: s.dir, + palette: Number.isInteger(s.palette) && (s.palette as number) >= 0 ? (s.palette as number) : 0, + addedBy: typeof s.addedBy === 'string' ? s.addedBy : '?', + addedAt: typeof s.addedAt === 'number' ? s.addedAt : Date.now(), + ...(typeof s.externalId === 'string' ? { externalId: s.externalId } : {}), + ...(typeof s.url === 'string' ? { url: s.url } : {}), + }); + } + return out; + } catch (err) { + console.error(`agent-office: ${file} couldn't be read, so the building starts empty: ${(err as Error).message}`); + return []; + } +} + +/** Writes floors.json, so the next office opens the same floors. */ +export function saveFloors(file: string, floors: FloorDef[]): void { + try { + writeFileSync(file, JSON.stringify(floors, null, 2), { mode: 0o600 }); + } catch (err) { + console.error(`agent-office: couldn't save the floors: ${(err as Error).message}`); + } +} diff --git a/src/server/building.ts b/src/server/building.ts index d3e08bcbc..b317c6de1 100644 --- a/src/server/building.ts +++ b/src/server/building.ts @@ -6,6 +6,8 @@ import { FLOOR_PALETTES, MAX_FLOORS, normalizeRepo, sameRepo } from '../shared/f import type { CloneProgress, ProjectsDirState, RepoChoice } from '../shared/protocol.js'; import { CloneRun, dropLog, whyCloneFailed, type CloneEnd, type CloneRunOptions } from './clone.js'; import { gh } from './github.js'; +import { loadFloors, saveFloors } from './building-store.js'; +import { mergeFloors } from './plugin-floors.js'; /** A floor as floors.json keeps it. */ export interface FloorDef { @@ -98,7 +100,7 @@ export class Building { this.pickedFile = path.join(dataDir, 'projects-folder.json'); this.localFile = path.join(dataDir, 'local-floor.json'); this.maxFloorsFile = path.join(dataDir, 'max-floors.json'); - this.load(); + this.defs.push(...loadFloors(this.file)); this.loadPicked(); this.loadLocalOff(); this.loadMaxFloors(); @@ -161,6 +163,17 @@ export class Building { return this.defs; } + /** + * Merges the floors a plugin provides into the building (see plugin-floors.ts): its own arrive, the + * ones it has dropped go, and everything the office and its people added stays where it is. + */ + mergeList(defs: FloorDef[]) { + const next = mergeFloors(this.defs, defs); + if (JSON.stringify(this.defs) === JSON.stringify(next)) return; + this.defs = next; + this.save(); + } + setList(defs: FloorDef[]) { const filtered = defs.filter((d) => !this.removedIds.has(d.id) && !this.removedIds.has(path.resolve(d.dir)) && (!d.externalId || !this.removedIds.has(d.externalId))); if (JSON.stringify(this.defs) === JSON.stringify(filtered)) return; @@ -443,29 +456,6 @@ export class Building { return { id, name, repo, dir, palette, addedBy: by, addedAt: Date.now() }; } - private load() { - if (!existsSync(this.file)) return; - try { - const saved = JSON.parse(readFileSync(this.file, 'utf8')) as Partial[]; - const ids = new Set(); - for (const s of Array.isArray(saved) ? saved : []) { - if (typeof s.id !== 'string' || !/^[a-z0-9-]{1,40}$/.test(s.id) || ids.has(s.id) || typeof s.dir !== 'string' || !path.isAbsolute(s.dir)) continue; - ids.add(s.id); - this.defs.push({ - id: s.id, - name: typeof s.name === 'string' && s.name ? s.name.slice(0, 100) : path.basename(s.dir), - repo: normalizeRepo(s.repo), - dir: s.dir, - palette: Number.isInteger(s.palette) && (s.palette as number) >= 0 ? (s.palette as number) : 0, - addedBy: typeof s.addedBy === 'string' ? s.addedBy : '?', - addedAt: typeof s.addedAt === 'number' ? s.addedAt : Date.now(), - }); - } - } catch (err) { - console.error(`agent-office: ${this.file} couldn't be read, so the building starts empty: ${(err as Error).message}`); - } - } - private loadPicked() { try { const saved = JSON.parse(readFileSync(this.pickedFile, 'utf8')) as Partial; @@ -497,11 +487,7 @@ export class Building { } private save() { - try { - writeFileSync(this.file, JSON.stringify(this.defs, null, 2), { mode: 0o600 }); - } catch (err) { - console.error(`agent-office: couldn't save the floors: ${(err as Error).message}`); - } + saveFloors(this.file, this.defs); } private loadClones(): SavedClone[] { diff --git a/src/server/github.ts b/src/server/github.ts index 6991cc47c..7f5f2d825 100644 --- a/src/server/github.ts +++ b/src/server/github.ts @@ -131,6 +131,18 @@ export class Claims { } } +/** + * A list a plugin's adapter has for a floor's board, when it has taken the board over. + * + * A plugin that lists anything at all is the board for that project: it knows the issues and pull + * requests the office's own `gh` login can't see. One that has none leaves GitHub to answer, so a + * project the plugin doesn't cover still shows its own, and the board's Refresh keeps meaning + * something for everyone. + */ +export function adapterBoard(listed: T[] | null | undefined): T[] | null { + return listed && listed.length ? listed : null; +} + export class GitHub { issues: GhState = { items: [], fetchedAt: 0, loading: false }; pulls: GhState = { items: [], fetchedAt: 0, loading: false }; @@ -459,7 +471,10 @@ export class GitHub { try { let fetched: GhIssue[] | null = null; if (this.customIssues) { - fetched = await this.customIssues(); + // A plugin that lists any issues at all is the board for this project: it knows the issues + // the office's own `gh` login can't see. One that has none leaves GitHub to answer, so a + // project the plugin doesn't cover still shows its issues. + fetched = adapterBoard(await this.customIssues()); } if (fetched === null) { // Open and closed separately, so old open issues are never crowded out by recent closed ones. @@ -498,7 +513,7 @@ export class GitHub { try { let fetched: GhPull[] | null = null; if (this.customPulls) { - fetched = await this.customPulls(); + fetched = adapterBoard(await this.customPulls()); } if (fetched === null) { const fields = 'number,title,state,isDraft,url,author,labels,reviewDecision,headRefName,headRefOid,baseRefName,createdAt,updatedAt,additions,deletions,statusCheckRollup,body,closingIssuesReferences'; diff --git a/src/server/http/routes/index.ts b/src/server/http/routes/index.ts index 982acdd1b..a2dc4026f 100644 --- a/src/server/http/routes/index.ts +++ b/src/server/http/routes/index.ts @@ -35,7 +35,13 @@ export const routes: readonly Route[] = [ fileRoutes.docs, searchRoutes.search, serviceRoutes.forwards, + serviceRoutes.start, + serviceRoutes.stop, + serviceRoutes.logs, githubRoutes.github, + pageRoutes.pluginFiles, + pageRoutes.pluginProjects, + pageRoutes.pluginProjectsSave, pageRoutes.office, pageRoutes.lite, pageRoutes.bundle, diff --git a/src/server/http/routes/pages.ts b/src/server/http/routes/pages.ts index f8d05da20..8acb4ab42 100644 --- a/src/server/http/routes/pages.ts +++ b/src/server/http/routes/pages.ts @@ -1,8 +1,9 @@ // The office's pages and the rest of the client bundle. import path from 'node:path'; import { publicFile, serveFile } from '../static.js'; -import { send } from '../util.js'; +import { readBody, send } from '../util.js'; import type { Route, RouteRequest } from '../router.js'; +import type { ProjectConfig } from '../../plugins/types.js'; import type { Ctx } from '../../office/context.js'; /** One of the bundle's own pages, never cached, so a new version is picked up at once. */ @@ -23,6 +24,71 @@ export const pageRoutes = { claim: { path: ['/claim', '/claim.html'], auth: 'public', handle: page('claim.html') }, join: { path: ['/join', '/join.html'], auth: 'public', handle: page('join.html') }, favicon: { path: '/favicon.svg', auth: 'public', handle: page('favicon.svg') }, + /** + * A plugin's own files (its settings page and whatever that page loads), under /plugins//. + * The office serves the file and knows nothing about it: any plugin can bring its own UI, and one + * that ships none is simply never asked for. + */ + pluginFiles: { + prefix: '/plugins/', + auth: 'session', + handle(ctx, { res, path: p }) { + const slash = p.indexOf('/', '/plugins/'.length); + const id = slash > 0 ? p.slice('/plugins/'.length, slash) : p.slice('/plugins/'.length); + const rest = slash > 0 ? p.slice(slash + 1) : ''; + const file = rest ? ctx.plugins?.settingsPagePathOf(id, rest) : undefined; + if (!file) { + res.writeHead(404, { 'content-type': 'text/plain' }).end('No such plugin file'); + return; + } + return serveFile(res, file, false); + }, + }, + /** + * The projects a plugin's settings page reads and writes for the floor you are on: the page + * (see pluginFiles) asks here, and the office hands it to whichever plugin lists them. Nothing + * about the projects themselves is known to the office. + */ + pluginProjects: { + method: 'GET', + prefix: '/api/plugins/', + auth: 'session', + async handle(ctx, { res, url, path: p }) { + const rest = p.slice('/api/plugins/'.length); + const slash = rest.indexOf('/'); + const id = slash > 0 ? rest.slice(0, slash) : rest; + const what = slash > 0 ? rest.slice(slash + 1) : ''; + if (what !== 'projects' || !id || !ctx.plugins?.has(id)) return send(res, 404, { error: 'No such plugin' }); + const floor = ctx.floors.get(url.searchParams.get('floor') ?? ''); + const projects = await ctx.plugins.listPluginProjects(id, floor); + if (projects === null) return send(res, 404, { error: `No projects to list for ${id}` }); + return send(res, 200, { projects }); + }, + }, + pluginProjectsSave: { + method: 'POST', + prefix: '/api/plugins/', + auth: 'session', + async handle(ctx, { res, url, req, path: p }) { + const rest = p.slice('/api/plugins/'.length); + const slash = rest.indexOf('/'); + const id = slash > 0 ? rest.slice(0, slash) : rest; + const what = slash > 0 ? rest.slice(slash + 1) : ''; + if (what !== 'projects' || !id || !ctx.plugins?.has(id)) return send(res, 404, { error: 'No such plugin' }); + const floor = ctx.floors.get(url.searchParams.get('floor') ?? ''); + let body: { projects?: unknown }; + try { + body = JSON.parse(await readBody(req)); + } catch { + return send(res, 400, { error: 'Send JSON: {"projects": [...]}' }); + } + const projects = Array.isArray(body?.projects) ? (body.projects as ProjectConfig[]) : undefined; + if (!projects) return send(res, 400, { error: 'A list of projects is required' }); + const ok = await ctx.plugins.savePluginProjects(id, floor, projects); + if (!ok) return send(res, 400, { error: `No plugin took those projects (no floor picked, or ${id} keeps its projects elsewhere)` }); + return send(res, 200, { projects }); + }, + }, office: { path: ['/', '/index.html'], auth: 'session', handle: page('index.html') }, // The 2D view: the workers, their terminals and the boards, without the 3D office (lite.ts). lite: { path: ['/lite', '/lite.html'], auth: 'session', handle: page('lite.html') }, diff --git a/src/server/http/routes/services.ts b/src/server/http/routes/services.ts index 2bd7d544e..2c27bf6c8 100644 --- a/src/server/http/routes/services.ts +++ b/src/server/http/routes/services.ts @@ -2,7 +2,7 @@ // one on the computer it runs on. import type { Ctx } from '../../office/context.js'; import { FORWARDS_PATH, type ForwardList } from '../../tunnel/wire.js'; -import { send } from '../util.js'; +import { readBody, send } from '../util.js'; import type { Route } from '../router.js'; /** Every floor's: a port is the machine's, whichever floor its worker sits on. */ @@ -16,4 +16,51 @@ function forwards(ctx: Ctx): ForwardList { export const serviceRoutes = { forwards: { method: 'GET', path: FORWARDS_PATH, auth: 'session', handle: (ctx, { res }) => send(res, 200, forwards(ctx)) }, + start: { + method: 'POST', + path: '/api/services/start', + auth: 'session', + async handle(ctx, { req, res }) { + let body: { floor?: string; name?: string }; + try { + body = JSON.parse(await readBody(req)); + } catch { + return send(res, 400, { error: 'Invalid JSON' }); + } + const floor = ctx.floors.get(body.floor ?? '') ?? ctx.floors.get(ctx.building.list()[0]?.id ?? ''); + if (!floor) return send(res, 404, { error: 'No such floor' }); + const ok = await ctx.plugins?.startService(floor, body.name); + return send(res, ok ? 200 : 500, { ok }); + }, + }, + stop: { + method: 'POST', + path: '/api/services/stop', + auth: 'session', + async handle(ctx, { req, res }) { + let body: { floor?: string; name?: string }; + try { + body = JSON.parse(await readBody(req)); + } catch { + return send(res, 400, { error: 'Invalid JSON' }); + } + const floor = ctx.floors.get(body.floor ?? '') ?? ctx.floors.get(ctx.building.list()[0]?.id ?? ''); + if (!floor) return send(res, 404, { error: 'No such floor' }); + const ok = await ctx.plugins?.stopService(floor, body.name); + return send(res, ok ? 200 : 500, { ok }); + }, + }, + logs: { + method: 'GET', + prefix: '/api/services/logs', + auth: 'session', + async handle(ctx, { url, res }) { + const floorId = url.searchParams.get('floor') ?? ''; + const name = url.searchParams.get('name') ?? undefined; + const floor = ctx.floors.get(floorId) ?? ctx.floors.get(ctx.building.list()[0]?.id ?? ''); + if (!floor) return send(res, 404, { error: 'No such floor' }); + const logs = await ctx.plugins?.getServiceLogs(floor, name); + return send(res, 200, { logs: logs ?? [] }); + }, + }, } satisfies Record; diff --git a/src/server/office/context.ts b/src/server/office/context.ts index ab34b8985..a8ae32d43 100644 --- a/src/server/office/context.ts +++ b/src/server/office/context.ts @@ -91,7 +91,7 @@ export interface LateServices { services: Services; images: ImageProxy; upgrader: Upgrader; - /** A floor's Services board: its own workers' servers. */ + /** A floor's Services board: its own workers' servers, and any rows a plugin's services provider has for it. */ servicesState(floor: Floor | undefined, items?: ServiceInfo[]): ServicesState; } diff --git a/src/server/office/floors.ts b/src/server/office/floors.ts index 0885898dd..38654435f 100644 --- a/src/server/office/floors.ts +++ b/src/server/office/floors.ts @@ -195,7 +195,7 @@ export async function openFloors(ctx: Ctx, hookPort: number): Promise 0) { const prevDefs = ctx.building.list(); - ctx.building.setList(defs); + ctx.building.mergeList(defs); const currentDefs = ctx.building.list(); if (JSON.stringify(prevDefs) !== JSON.stringify(currentDefs)) { for (const def of currentDefs) openFloor(def); diff --git a/src/server/office/services.ts b/src/server/office/services.ts index 847f05a52..acdf9f969 100644 --- a/src/server/office/services.ts +++ b/src/server/office/services.ts @@ -136,6 +136,17 @@ export function createLateServices(ctx: Ctx): LateServices { // Web servers the workers start, for the Services board and service tunnels (see relay.ts). // One scan covers every floor; each floor's board lists its own workers' servers. + // A plugin with the services capability also lists what it can run for a floor (a dev server, a + // command), and those rows show on that floor's board beside the ones the scan found. + const pluginServices = async (floor: Floor | undefined): Promise => { + if (!floor || !ctx.plugins) return []; + try { + return (await ctx.plugins.listServices(floor)) ?? []; + } catch { + // A plugin's rows are extra, never a reason for the board to fail. + return []; + } + }; const servicesState = (floor: Floor | undefined, items: ServiceInfo[] = services.list()): ServicesState => ({ items: floor ? items.filter((s) => floor.workers.get(s.workerId)) : [], port: cfg.port, @@ -143,14 +154,45 @@ export function createLateServices(ctx: Ctx): LateServices { ssh: team.ssh, tailnet: cfg.tailnet, }); + /** + * A floor's board, with a plugin's rows on it, and sends it to everyone on that floor. Used both + * when the scan changes and on its own timer, so a plugin's rows come and go on their own. + */ + const publishBoard = async (floor: Floor | undefined, items: ServiceInfo[]) => { + const state = servicesState(floor, items); + if (!floor || !ctx.plugins) return state; + const offered = await pluginServices(floor); + if (!offered.length) return state; + return { ...state, items: [...state.items, ...offered] }; + }; const services = new Services( () => [...floors.values()].flatMap((f) => f.workers.owners()), (items) => { - for (const c of clients.values()) ctx.sendTo(c, { t: 'services', state: servicesState(ctx.floorOf(c), items) }); tailnet.sync(items.map((s) => s.port)); + for (const c of clients.values()) { + void publishBoard(ctx.floorOf(c), items).then((state) => ctx.sendTo(c, { t: 'services', state })); + } }, ); - + /** + * A plugin's services change without the office's own port scan seeing it (a dev server it started + * or stopped, a command of its own), so the boards are asked again on their own as well as on every + * scan, and only while clients are connected. A floor whose plugins have nothing to say is skipped. + */ + const repaintBoards = async () => { + if (!ctx.plugins) return; + const items = services.list(); + for (const c of [...clients.values()]) { + const floor = ctx.floorOf(c); + if (!floor) continue; + const offered = await pluginServices(floor); + if (!offered.length) continue; + void publishBoard(floor, items).then((state) => ctx.sendTo(c, { t: 'services', state })); + } + }; + const BOARD_REPAINT_MS = process.env['AGENT_OFFICE_SERVICES_TEST_MS'] ? Number(process.env['AGENT_OFFICE_SERVICES_TEST_MS']) : 3000; + const boardTimer = setInterval(() => void repaintBoards(), BOARD_REPAINT_MS); + boardTimer.unref(); const images = new ImageProxy(); const upgrader = new Upgrader( diff --git a/src/server/plugin-floors.ts b/src/server/plugin-floors.ts new file mode 100644 index 000000000..1abdfaccf --- /dev/null +++ b/src/server/plugin-floors.ts @@ -0,0 +1,26 @@ +// Merging the floors a plugin provides into the building's own. +// +// A plugin that provides floors (an OpenFox project per floor, say) says "these are mine" — never +// "these are all there are". The floors the office was started with, and the ones people added from +// the elevator, stay exactly where they are. Doing it any other way wipes the building the moment a +// plugin reports its list, and the boards those floors had go with them. +import path from 'node:path'; +import type { FloorDef } from './building.js'; + +/** Who added a floor for themselves, rather than it arriving from a plugin's list. */ +const OWN = new Set(['the office', 'the elevator']); + +/** The same floor, either side: the same checkout, or the id its plugin keeps for it. */ +const sameAs = (a: FloorDef, b: FloorDef): boolean => + path.resolve(a.dir) === path.resolve(b.dir) || (!!b.externalId && a.externalId === b.externalId); + +/** + * The building's floors once `defs` are merged into them: what was there and is still the plugin's + * stays (so it keeps its identity), what the plugin has added arrives, and what it no longer lists + * goes. Nothing anybody but a plugin added is taken off. + */ +export function mergeFloors(current: FloorDef[], defs: FloorDef[]): FloorDef[] { + const kept = current.filter((own) => OWN.has(own.addedBy) || defs.some((d) => sameAs(own, d))); + const fresh = defs.filter((d) => !kept.some((own) => sameAs(own, d))); + return [...kept, ...fresh]; +} diff --git a/src/server/plugins/entry.ts b/src/server/plugins/entry.ts new file mode 100644 index 000000000..38c5e10ec --- /dev/null +++ b/src/server/plugins/entry.ts @@ -0,0 +1,97 @@ +// Where a plugin is, and where its own files are: its entry, and the folder holding them. +import { existsSync, readFileSync } from 'node:fs'; +import path from 'node:path'; + +/** Where a plugin's entry can be, in a folder of its own: its built bundle first. */ +export const ENTRY_CANDIDATES = ['dist/index.js', 'index.js', 'plugin.js', 'dist/index.mjs', 'index.mjs']; + +export function candidatePluginDirs(id: string, registryPluginsDir?: string): string[] { + return [path.join(process.cwd(), '.agent-office', 'plugins', id), registryPluginsDir ? path.join(registryPluginsDir, id) : null, path.resolve(process.cwd(), '..', 'agent-office-plugins', `agentoffice-${id}`), path.resolve(process.cwd(), '..', 'agent-office-plugins', `${id}-plugin-for-agent-office`), path.resolve(process.cwd(), '..', 'agent-office-plugins', `agent-office-plugin-${id}`), path.resolve(process.cwd(), '..', 'agent-office-plugins', id), path.resolve(process.cwd(), 'agent-office-plugins', `agentoffice-${id}`), path.resolve(process.cwd(), 'agent-office-plugins', `${id}-plugin-for-agent-office`), path.resolve(process.cwd(), 'agent-office-plugins', `agent-office-plugin-${id}`), path.resolve(process.cwd(), 'agent-office-plugins', id)].filter(Boolean) as string[]; +} + +export function candidatePluginFiles(id: string, registryPluginsDir?: string): string[] { + return [path.join(process.cwd(), `${id}.js`), registryPluginsDir ? path.join(registryPluginsDir, `${id}.js`) : null, registryPluginsDir ? path.join(registryPluginsDir, `${id}.mjs`) : null].filter(Boolean) as string[]; +} + +export function detectEntryPath(pluginDir: string): string | null { + for (const candidate of ENTRY_CANDIDATES) { + const p = path.join(pluginDir, candidate); + if (existsSync(p)) return p; + } + const pkgPath = path.join(pluginDir, 'package.json'); + if (existsSync(pkgPath)) { + try { + const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { main?: string; module?: string }; + const entry = pkg.main || pkg.module; + if (entry) { + const p = path.join(pluginDir, entry); + if (existsSync(p)) return p; + } + } catch {} + } + return null; +} + +export function parseGitUrl(url: string): { id: string; cloneUrl: string } | null { + const trimmed = url.trim(); + if (!trimmed) return null; + const ALLOWED_PROTOCOLS = ['https:', 'http:', 'git:', 'ssh:']; + try { + const parsedUrl = new URL(trimmed); + if (!ALLOWED_PROTOCOLS.includes(parsedUrl.protocol)) return null; + } catch { + if (!trimmed.includes('@') && !trimmed.startsWith('git://')) return null; + } + + let cloneUrl = trimmed; + if (cloneUrl.startsWith('-')) return null; + if (cloneUrl.endsWith('/')) cloneUrl = cloneUrl.slice(0, -1); + if (!cloneUrl.endsWith('.git')) cloneUrl += '.git'; + const match = /([^/]+?)(?:\.git)?$/.exec(cloneUrl); + if (!match) return null; + const id = match[1].toLowerCase().replace(/[^a-z0-9-_]/g, '-'); + return { id, cloneUrl }; +} + +export function getPluginDir(id: string, pluginsDir?: string): string { + return path.join(pluginsDir ?? path.join(process.cwd(), '.agent-office', 'plugins'), id); +} + +export function resolvePluginEntry(id: string, registryPluginsDir?: string, explicitEntry?: string): string | null { + if (explicitEntry) { + const resolved = path.isAbsolute(explicitEntry) ? explicitEntry : path.resolve(process.cwd(), explicitEntry); + if (existsSync(resolved)) return resolved; + if (registryPluginsDir) { + const inReg = path.resolve(registryPluginsDir, path.basename(explicitEntry)); + if (existsSync(inReg)) return inReg; + } + } + + for (const dir of candidatePluginDirs(id, registryPluginsDir)) { + if (existsSync(dir)) { + const entry = detectEntryPath(dir); + if (entry) return entry; + } + } + + for (const file of candidatePluginFiles(id, registryPluginsDir)) { + if (existsSync(file)) return file; + } + + return null; +} + +export async function fetchLatestRelease(githubUrl: string): Promise { + try { + const match = /github\.com\/([^/]+)\/([^/]+)/.exec(githubUrl); + if (!match) return null; + const [, owner, repo] = match; + const apiUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`; + const res = await fetch(apiUrl, { headers: { Accept: 'application/vnd.github.v3+json' } }); + if (!res.ok) return null; + const data = (await res.json()) as { tag_name?: string }; + return data.tag_name ?? null; + } catch { + return null; + } +} diff --git a/src/server/plugins/manager.ts b/src/server/plugins/manager.ts index 9957afd21..88b8c938b 100644 --- a/src/server/plugins/manager.ts +++ b/src/server/plugins/manager.ts @@ -7,104 +7,13 @@ import type { Floor } from '../floor.js'; import type { FloorInfo, GhIssue, GhPull, PluginManifest, PluginsState, ServiceInfo, WorkerInfo } from '../../shared/protocol.js'; import type { Ctx } from '../office/context.js'; import { PluginRegistry } from './registry.js'; -import type { AgentProviderPlugin, FloorProvider, IssuesAdapter, OfficePlugin, ProjectsProvider, PullsAdapter, QueueAdapter, ServicesProvider } from './types.js'; +import { candidatePluginDirs, candidatePluginFiles, detectEntryPath, parseGitUrl, getPluginDir, resolvePluginEntry, fetchLatestRelease } from './entry.js'; +import { pluginFilePath, pluginSettingsPagePath } from './plugin-files.js'; +import type { AgentProviderPlugin, FloorProvider, IssuesAdapter, OfficePlugin, ProjectConfig, ProjectsProvider, PullsAdapter, QueueAdapter, ServicesProvider } from './types.js'; import type { AgentProvider } from '../../shared/providers.js'; import { PROVIDER_META, registerAgentProvider, unregisterAgentProvider } from '../../shared/providers.js'; import { registerAgentProviderAdapter, unregisterAgentProviderAdapter } from '../providers/index.js'; -const ENTRY_CANDIDATES = ['dist/index.js', 'index.js', 'plugin.js', 'dist/index.mjs', 'index.mjs']; - -function parseGitUrl(url: string): { id: string; cloneUrl: string } | null { - const trimmed = url.trim(); - if (!trimmed) return null; - const ALLOWED_PROTOCOLS = ['https:', 'http:', 'git:', 'ssh:']; - try { - const parsedUrl = new URL(trimmed); - if (!ALLOWED_PROTOCOLS.includes(parsedUrl.protocol)) return null; - } catch { - if (!trimmed.includes('@') && !trimmed.startsWith('git://')) return null; - } - - let cloneUrl = trimmed; - if (cloneUrl.startsWith('-')) return null; - if (cloneUrl.endsWith('/')) cloneUrl = cloneUrl.slice(0, -1); - if (!cloneUrl.endsWith('.git')) cloneUrl += '.git'; - const match = /([^/]+?)(?:\.git)?$/.exec(cloneUrl); - if (!match) return null; - const id = match[1].toLowerCase().replace(/[^a-z0-9-_]/g, '-'); - return { id, cloneUrl }; -} - -function getPluginDir(id: string, pluginsDir?: string): string { - return path.join(pluginsDir ?? path.join(process.cwd(), '.agent-office', 'plugins'), id); -} - -function candidatePluginDirs(id: string, registryPluginsDir?: string): string[] { - return [path.join(process.cwd(), '.agent-office', 'plugins', id), registryPluginsDir ? path.join(registryPluginsDir, id) : null, path.resolve(process.cwd(), '..', 'agent-office-plugins', `agentoffice-${id}`), path.resolve(process.cwd(), '..', 'agent-office-plugins', `${id}-plugin-for-agent-office`), path.resolve(process.cwd(), '..', 'agent-office-plugins', `agent-office-plugin-${id}`), path.resolve(process.cwd(), '..', 'agent-office-plugins', id), path.resolve(process.cwd(), 'agent-office-plugins', `agentoffice-${id}`), path.resolve(process.cwd(), 'agent-office-plugins', `${id}-plugin-for-agent-office`), path.resolve(process.cwd(), 'agent-office-plugins', `agent-office-plugin-${id}`), path.resolve(process.cwd(), 'agent-office-plugins', id)].filter(Boolean) as string[]; -} - -function candidatePluginFiles(id: string, registryPluginsDir?: string): string[] { - return [path.join(process.cwd(), `${id}.js`), registryPluginsDir ? path.join(registryPluginsDir, `${id}.js`) : null, registryPluginsDir ? path.join(registryPluginsDir, `${id}.mjs`) : null].filter(Boolean) as string[]; -} - -function detectEntryPath(pluginDir: string): string | null { - for (const candidate of ENTRY_CANDIDATES) { - const p = path.join(pluginDir, candidate); - if (existsSync(p)) return p; - } - const pkgPath = path.join(pluginDir, 'package.json'); - if (existsSync(pkgPath)) { - try { - const pkg = JSON.parse(readFileSync(pkgPath, 'utf8')) as { main?: string; module?: string }; - const entry = pkg.main || pkg.module; - if (entry) { - const p = path.join(pluginDir, entry); - if (existsSync(p)) return p; - } - } catch { - } - } - return null; -} - -function resolvePluginEntry(id: string, registryPluginsDir?: string, explicitEntry?: string): string | null { - if (explicitEntry) { - const resolved = path.isAbsolute(explicitEntry) ? explicitEntry : path.resolve(process.cwd(), explicitEntry); - if (existsSync(resolved)) return resolved; - if (registryPluginsDir) { - const inReg = path.resolve(registryPluginsDir, path.basename(explicitEntry)); - if (existsSync(inReg)) return inReg; - } - } - - for (const dir of candidatePluginDirs(id, registryPluginsDir)) { - if (existsSync(dir)) { - const entry = detectEntryPath(dir); - if (entry) return entry; - } - } - - for (const file of candidatePluginFiles(id, registryPluginsDir)) { - if (existsSync(file)) return file; - } - - return null; -} - -async function fetchLatestRelease(githubUrl: string): Promise { - try { - const match = /github\.com\/([^/]+)\/([^/]+)/.exec(githubUrl); - if (!match) return null; - const [, owner, repo] = match; - const apiUrl = `https://api.github.com/repos/${owner}/${repo}/releases/latest`; - const res = await fetch(apiUrl, { headers: { Accept: 'application/vnd.github.v3+json' } }); - if (!res.ok) return null; - const data = (await res.json()) as { tag_name?: string }; - return data.tag_name ?? null; - } catch { - return null; - } -} export class PluginManager { private registry: PluginRegistry; @@ -337,17 +246,34 @@ export class PluginManager { manifest.version = plugin.manifest.version ?? manifest.version; manifest.author = plugin.manifest.author ?? manifest.author; manifest.githubUrl = plugin.manifest.githubUrl ?? manifest.githubUrl; + manifest.settingsPage = plugin.manifest.settingsPage ?? manifest.settingsPage; this.plugins.set(manifest.id, plugin); if (plugin.manifest.id) { this.plugins.set(plugin.manifest.id, plugin); - const regItem = this.registry.get(plugin.manifest.id); + // The plugin may name itself differently from the id the office filed it under; the rest of + // the office (and Settings) addresses it by the name it gives itself. + const own = plugin.manifest.id; + const regItem = this.registry.get(own); if (regItem && regItem !== manifest) { regItem.status = 'active'; regItem.installed = true; + (regItem as { entry?: string }).entry = (manifest as { entry?: string }).entry; regItem.version = plugin.manifest.version ?? regItem.version; regItem.author = plugin.manifest.author ?? regItem.author; regItem.githubUrl = plugin.manifest.githubUrl ?? regItem.githubUrl; + regItem.settingsPage = plugin.manifest.settingsPage ?? regItem.settingsPage; + } else if (!regItem) { + this.registry.register({ + ...plugin.manifest, + installed: true, + status: 'active', + entry: (manifest as { entry?: string }).entry, + local: true, + } as PluginManifest); } + // The id it was filed under by its file name is not who it is now that the plugin has said: + // that placeholder goes, so Settings shows the plugin's own card and nothing beside it. + if (manifest.id !== own && manifest.local) this.registry.unregister(manifest.id); } await plugin.init?.(ctx); return plugin; @@ -382,6 +308,8 @@ export class PluginManager { entry: envPlugin, } as any); } + // The plugin may name itself differently (its `manifest.id`); loadPlugin also registers that + // one, so Settings and the rest address it by the name it gives itself. } for (const manifest of this.registry.list()) { @@ -390,7 +318,9 @@ export class PluginManager { } } - for (const plugin of this.plugins.values()) { + // One plugin can be filed under more than one id (its own, and the one the office knew it by); + // each plugin is registered once, whatever it is called here. + for (const plugin of new Set(this.plugins.values())) { if (plugin.agentProvider) { this.registerAgentProvider(plugin.agentProvider); } @@ -422,6 +352,23 @@ export class PluginManager { this.plugins.clear(); } + /** Where a plugin's settings page is on disk, when it ships one (see plugin-files.ts). */ + settingsPagePath(id: string): string | undefined { + const m = this.registry.get(id); + return m ? pluginSettingsPagePath(m, this.registry.pluginsDir) : undefined; + } + + /** Where a file a plugin ships is, for serving it under /plugins//; never outside its directory. */ + settingsPagePathOf(id: string, relative: string): string | undefined { + const m = this.registry.get(id); + return m ? pluginFilePath(m, this.registry.pluginsDir, relative) : undefined; + } + + /** Whether the office has a loaded plugin by that id. */ + has(id: string): boolean { + return this.plugins.has(id); + } + getFloorProvider(): FloorProvider | null { for (const p of this.plugins.values()) { if (p.floorProvider) return p.floorProvider; @@ -517,13 +464,73 @@ export class PluginManager { return null; } - getProjectsProvider(): ProjectsProvider | null { + async startService(floor: Floor | undefined, serviceName?: string): Promise { + const provider = this.getServicesProvider(); + if (!provider?.startService || !floor) return false; + try { + await provider.startService(floor, serviceName); + return true; + } catch { + return false; + } + } + + async stopService(floor: Floor | undefined, serviceName?: string): Promise { + const provider = this.getServicesProvider(); + if (!provider?.stopService || !floor) return false; + try { + await provider.stopService(floor, serviceName); + return true; + } catch { + return false; + } + } + + async getServiceLogs(floor: Floor | undefined, serviceName?: string, lines?: number): Promise { + const provider = this.getServicesProvider(); + if (!provider?.getServiceLogs || !floor) return []; + try { + return (await provider.getServiceLogs(floor, serviceName, lines)) ?? []; + } catch { + return []; + } + } + + getProjectsProvider(floor?: Floor): ProjectsProvider | null { for (const p of this.plugins.values()) { if (p.projectsProvider) return p.projectsProvider; } return null; } + /** + * The projects a plugin's provider lists for a floor, or null when no plugin has one there. + * + * A plugin can bring its own settings UI (see settingsPage) and this is how that page asks: the + * office holds the seam, the plugin holds the list. + */ + async listPluginProjects(id: string, floor: Floor | undefined): Promise { + const provider = floor && this.plugins.get(id)?.projectsProvider; + if (!provider || !floor) return null; + try { + return (await provider.listProjects?.(floor)) ?? null; + } catch { + return null; + } + } + + /** Hands a floor's projects to the plugin that lists them; says whether one took them. */ + async savePluginProjects(id: string, floor: Floor | undefined, projects: ProjectConfig[]): Promise { + const provider = floor && this.plugins.get(id)?.projectsProvider; + if (!provider?.saveProjects || !floor) return false; + try { + await provider.saveProjects(floor, projects); + return true; + } catch { + return false; + } + } + getState(): PluginsState { const all = this.registry.getState(); const installed = all.plugins.map((m) => { @@ -534,6 +541,8 @@ export class PluginManager { installed: true, version: plugin?.manifest?.version ?? m.version, author: plugin?.manifest?.author ?? m.author, + settingsPage: plugin?.manifest?.settingsPage ?? m.settingsPage, + agentProviderMeta: plugin?.agentProvider?.meta, status: (m.status === 'error' ? 'error' : active ? 'active' : 'inactive') as 'active' | 'inactive' | 'error', }; }); @@ -543,6 +552,7 @@ export class PluginManager { ...m, version: plugin?.manifest?.version ?? m.version, author: plugin?.manifest?.author ?? m.author, + settingsPage: plugin?.manifest?.settingsPage ?? m.settingsPage, installed: false, status: 'inactive' as const, }; diff --git a/src/server/plugins/plugin-files.ts b/src/server/plugins/plugin-files.ts new file mode 100644 index 000000000..076234723 --- /dev/null +++ b/src/server/plugins/plugin-files.ts @@ -0,0 +1,66 @@ +// Finding the files a plugin ships: its settings page, and anything that page loads. +// +// A plugin brings its own settings UI as files in its own directory, which the office serves under +// /plugins// (see http/routes/pages.ts). The office knows nothing about what they contain — that +// is what makes it generic: any plugin can bring its own settings, and one that brings none is never +// asked for. +import { existsSync } from 'node:fs'; +import path from 'node:path'; +import { candidatePluginDirs, detectEntryPath } from './entry.js'; + +/** Where a plugin was loaded from, so its own files can be found next to it. */ +export interface LoadedPlugin { + id: string; + /** The entry file it was loaded from, when it is a single file rather than a folder. */ + entry?: string; + /** The file it names as its settings page, relative to itself. */ + settingsPage?: string; +} + +/** + * A path a plugin asks for, cleaned up and refused if it tries to leave the plugin's directory. + * + * Undefined for anything that isn't a plain relative name inside it: no plugin can make the office + * serve a file from somewhere else. + */ +export function safePluginPath(relative: string): string | undefined { + const page = relative.replace(/\\/g, '/').replace(/^\.\.?\//, ''); + if (!page || page.startsWith('/') || page.split('/').includes('..')) return undefined; + return page; +} + +/** A file inside `root`, wherever under it `from` is; never outside `root`. */ +function fileUnder(from: string | undefined, root: string, rel: string): string | undefined { + const at = from ? path.resolve(path.dirname(from), rel) : undefined; + if (at && at.startsWith(root + path.sep) && existsSync(at)) return at; + // A plugin's own files usually sit beside its built entry; they can also sit at the top of its + // directory (with its package.json), which a build that cleans its output folder keeps. + const top = path.resolve(root, rel); + return top.startsWith(root + path.sep) && existsSync(top) ? top : undefined; +} + +/** + * Where a file the plugin ships is on disk, or undefined when it isn't one of the plugin's own. + * + * A plugin is found wherever it was loaded from: a folder of its own, or a single file the office was + * pointed at, whose own files sit beside it. + */ +export function pluginFilePath(plugin: LoadedPlugin, pluginsDir: string, relative: string): string | undefined { + const rel = safePluginPath(relative); + if (!rel) return undefined; + for (const dir of candidatePluginDirs(plugin.id, pluginsDir)) { + if (!existsSync(dir)) continue; + const root = path.resolve(dir); + // Wherever the plugin's entry is (its built bundle), and the top of its directory. + const found = fileUnder(detectEntryPath(dir) ?? undefined, root, rel); + if (found) return found; + } + const loaded = plugin.entry ? (path.isAbsolute(plugin.entry) ? plugin.entry : path.resolve(process.cwd(), plugin.entry)) : undefined; + if (loaded && existsSync(loaded) && path.extname(loaded)) return fileUnder(loaded, path.dirname(path.resolve(loaded)), rel); + return undefined; +} + +/** Where the plugin's own settings page is, when it ships one; otherwise undefined. */ +export function pluginSettingsPagePath(plugin: LoadedPlugin, pluginsDir: string): string | undefined { + return plugin.settingsPage ? pluginFilePath(plugin, pluginsDir, plugin.settingsPage) : undefined; +} diff --git a/src/server/plugins/registry.ts b/src/server/plugins/registry.ts index a00b4306b..67dcfbdf6 100644 --- a/src/server/plugins/registry.ts +++ b/src/server/plugins/registry.ts @@ -58,6 +58,11 @@ export class PluginRegistry { this.manifests.set(manifest.id, { ...manifest, installed: true }); } + /** Forgets a manifest by id (a placeholder the loaded plugin made its own). */ + unregister(id: string) { + this.manifests.delete(id); + } + list(): PluginManifest[] { return [...this.manifests.values()]; } diff --git a/src/server/plugins/types.ts b/src/server/plugins/types.ts index ba50e0cae..ed2e90a55 100644 --- a/src/server/plugins/types.ts +++ b/src/server/plugins/types.ts @@ -28,6 +28,9 @@ export interface QueueAdapter { export interface ServicesProvider { listServices(floor: Floor): Promise; + startService?(floor: Floor, serviceName?: string): Promise; + stopService?(floor: Floor, serviceName?: string): Promise; + getServiceLogs?(floor: Floor, serviceName?: string, lines?: number): Promise; } export interface AgentProviderPlugin { diff --git a/src/server/prompts.ts b/src/server/prompts.ts index c537804cc..fab24037c 100644 --- a/src/server/prompts.ts +++ b/src/server/prompts.ts @@ -1,6 +1,6 @@ import { readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; -import { isAgentEffort, isAgentProvider, type AgentChoice, type AgentProvider, type PromptsState } from '../shared/protocol.js'; +import { isAgentEffort, isKnownProvider, type AgentChoice, type AgentProvider, type PromptsState } from '../shared/protocol.js'; import { PROMPTS, PROMPT_MAX, fillPrompt, isPromptId, promptText, type PromptId, type PromptVars } from '../shared/prompts.js'; import { validateWorkerEffort, validateWorkerModel } from './agents.js'; @@ -76,7 +76,7 @@ export class OfficePrompts implements PromptSource { } private problem(c: AgentChoice): string | undefined { - if (!isAgentProvider(c.provider) || !this.providers.list.includes(c.provider)) return 'Unknown agent provider'; + if (!isKnownProvider(c.provider) || !this.providers.list.includes(c.provider)) return 'Unknown agent provider'; if (c.provider === 'custom' && this.providers.configured !== 'custom') return 'Custom is not the configured agent provider'; return validateWorkerModel('agent', c.provider, c.model) ?? validateWorkerEffort('agent', c.provider, c.effort); } @@ -98,7 +98,7 @@ export class OfficePrompts implements PromptSource { this.saved.custom[id] = { text: v.text.slice(0, PROMPT_MAX), by: typeof v.by === 'string' ? v.by : 'someone', at: typeof v.at === 'number' ? v.at : 0 }; } const a = raw?.agent; - if (a && isAgentProvider(a.provider)) { + if (a && isKnownProvider(a.provider)) { const choice: AgentChoice = { provider: a.provider, model: typeof a.model === 'string' ? a.model : undefined, effort: isAgentEffort(a.effort) ? a.effort : undefined }; // One the office can't start any more (it was started with another --agent) is forgotten. if (!this.problem(choice)) this.saved.agent = { ...choice, by: typeof a.by === 'string' ? a.by : 'someone', at: typeof a.at === 'number' ? a.at : 0 }; diff --git a/src/server/providers/claude.ts b/src/server/providers/claude.ts index 1eac357c3..5afa58ec6 100644 --- a/src/server/providers/claude.ts +++ b/src/server/providers/claude.ts @@ -128,6 +128,15 @@ function claudeHook(h: WorkerHandle, event: string, payload: any): boolean { h.tracker.transcript = payload.transcript_path; h.persist(); } + // A provider whose agent has a web UI of its own (an OpenFox session, say) says where: the worker's + // terminal then offers it as a tab. Only an http(s) link is taken, never anything the hook can use + // to point the browser somewhere else. + const sessionUrl = typeof payload?.web_url === 'string' ? payload.web_url : typeof payload?.embed_url === 'string' ? payload.embed_url : undefined; + if (sessionUrl && /^https?:\/\//.test(sessionUrl) && sessionUrl !== info.sessionUrl) { + info.sessionUrl = sessionUrl; + h.emit(); + h.persist(); + } h.scheduleScan(); switch (event) { case 'SessionStart': diff --git a/src/server/ws/handlers/floors.ts b/src/server/ws/handlers/floors.ts index 0a7e163f7..31df4fa2e 100644 --- a/src/server/ws/handlers/floors.ts +++ b/src/server/ws/handlers/floors.ts @@ -60,7 +60,7 @@ export const floorHandlers = { ctx.building.unremove(res.dir || res.name); ctx.building.unremove(repo); const defs = await floorProvider.listFloors(); - ctx.building.setList(defs); + ctx.building.mergeList(defs); for (const d of defs) ctx.openFloor(d); ctx.floorsChanged(); const floor = ctx.floors.get(res.name) || [...ctx.floors.values()].find((f) => f.def.name === res.name); diff --git a/src/server/ws/handlers/meetings.ts b/src/server/ws/handlers/meetings.ts index df1f88ca4..ce4981abf 100644 --- a/src/server/ws/handlers/meetings.ts +++ b/src/server/ws/handlers/meetings.ts @@ -1,5 +1,5 @@ // The meeting room: calling a meeting, stopping it, and clearing the table. -import { isAgentEffort, isAgentProvider, type MeetingClientMsg, type MeetingRequest } from '../../../shared/protocol.js'; +import { isAgentEffort, isKnownProvider, type MeetingClientMsg, type MeetingRequest } from '../../../shared/protocol.js'; import { OPEN_CODE_MODEL_MAX } from '../../../shared/providers.js'; import { str } from '../../office/input.js'; import { here } from './common.js'; @@ -12,7 +12,7 @@ export const meetingHandlers = { const who = c.peer.name; const floor = here(ctx, c); if (!floor) return; - if (msg.provider !== undefined && (!isAgentProvider(msg.provider) || !floor.project.agentProviders.includes(msg.provider))) { + if (msg.provider !== undefined && (!isKnownProvider(msg.provider) || !floor.project.agentProviders.includes(msg.provider))) { ctx.warn(c, 'Unknown agent provider'); return; } diff --git a/src/server/ws/handlers/queue.ts b/src/server/ws/handlers/queue.ts index 647661673..67fc022c9 100644 --- a/src/server/ws/handlers/queue.ts +++ b/src/server/ws/handlers/queue.ts @@ -1,5 +1,5 @@ // The floor's task queue: adding, moving and retrying tasks, and how many workers it keeps busy. -import { isAgentEffort, isAgentProvider, type QueueClientMsg } from '../../../shared/protocol.js'; +import { isAgentEffort, isKnownProvider, type QueueClientMsg } from '../../../shared/protocol.js'; import { OPEN_CODE_MODEL_MAX } from '../../../shared/providers.js'; import { num, str } from '../../office/input.js'; import { here } from './common.js'; @@ -15,7 +15,7 @@ export const queueHandlers = { const who = c.peer.name; const floor = here(ctx, c); if (!floor) return; - if (msg.provider !== undefined && (!isAgentProvider(msg.provider) || !floor.project.agentProviders.includes(msg.provider))) { + if (msg.provider !== undefined && (!isKnownProvider(msg.provider) || !floor.project.agentProviders.includes(msg.provider))) { ctx.warn(c, 'Unknown agent provider'); return; } diff --git a/src/server/ws/handlers/workers.ts b/src/server/ws/handlers/workers.ts index 3e468890c..7aea43546 100644 --- a/src/server/ws/handlers/workers.ts +++ b/src/server/ws/handlers/workers.ts @@ -2,7 +2,7 @@ // worktrees and pull requests. import { MAX_REPOS, type RepoSource } from '../../workers.js'; import { OPEN_CODE_MODEL_MAX } from '../../../shared/providers.js'; -import { isAgentEffort, isAgentProvider, type WorkerClientMsg } from '../../../shared/protocol.js'; +import { isAgentEffort, isKnownProvider, type WorkerClientMsg } from '../../../shared/protocol.js'; import { issueNumber, num, str } from '../../office/input.js'; import { here, workerOf } from './common.js'; import type { FeatureHooks, HandlerMap, ViewPieces } from './types.js'; @@ -21,7 +21,7 @@ export const workerHandlers = { const floor = here(ctx, c); if (!floor) return; const kind = msg.kind === 'shell' ? 'shell' : 'agent'; - if (kind === 'agent' && msg.provider !== undefined && (!isAgentProvider(msg.provider) || !floor.project.agentProviders.includes(msg.provider))) { + if (kind === 'agent' && msg.provider !== undefined && (!isKnownProvider(msg.provider) || !floor.project.agentProviders.includes(msg.provider))) { ctx.warn(c, 'Unknown agent provider'); return; } diff --git a/src/shared/protocol/agents.ts b/src/shared/protocol/agents.ts index c25031e3f..643585be8 100644 --- a/src/shared/protocol/agents.ts +++ b/src/shared/protocol/agents.ts @@ -3,7 +3,7 @@ import type { AgentEffort, AgentProvider } from '../providers.js'; -export { AGENT_EFFORTS, CLAUDE_MODELS, isAgentEffort, isAgentProvider, isClaudeModel, type AgentEffort, type AgentProvider, type ClaudeModel } from '../providers.js'; +export { AGENT_EFFORTS, CLAUDE_MODELS, isAgentEffort, isAgentProvider, isClaudeModel, isKnownProvider, type AgentEffort, type AgentProvider, type ClaudeModel } from '../providers.js'; /** Which agent a worker runs: its provider, and optionally the model and the reasoning effort (see PROVIDER_META for which each takes). */ export interface AgentChoice { diff --git a/src/shared/protocol/floors.ts b/src/shared/protocol/floors.ts index 8c9f4fab5..ff57f1b67 100644 --- a/src/shared/protocol/floors.ts +++ b/src/shared/protocol/floors.ts @@ -26,6 +26,7 @@ export interface ProjectInfo { remote?: string; agentCmd: string; defaultProvider: AgentProvider; + /** Providers this project can hire: the office's built-ins, plus any a plugin has registered. */ agentProviders: AgentProvider[]; } diff --git a/src/shared/protocol/plugins.ts b/src/shared/protocol/plugins.ts index af61d12fe..88ace866f 100644 --- a/src/shared/protocol/plugins.ts +++ b/src/shared/protocol/plugins.ts @@ -1,5 +1,7 @@ // Manifest and configuration for Agent Office plugins. +import type { ProviderMeta } from '../providers.js'; + export type PluginCapability = 'floors' | 'queue' | 'services' | 'agent' | 'issues' | 'pulls'; export interface PluginManifest { @@ -9,6 +11,13 @@ export interface PluginManifest { description: string; capabilities: PluginCapability[]; githubUrl?: string; + agentProviderMeta?: ProviderMeta; + /** + * A file of the plugin's own (relative to its directory) holding its settings page, shown in + * ⚙️ Settings → Plugins. The office serves the file and knows nothing about what is in it, so + * every plugin can bring its own settings UI. + */ + settingsPage?: string; installed?: boolean; status?: 'active' | 'inactive' | 'error'; error?: string; diff --git a/src/shared/protocol/workers.ts b/src/shared/protocol/workers.ts index 1f83346fb..f82981a42 100644 --- a/src/shared/protocol/workers.ts +++ b/src/shared/protocol/workers.ts @@ -73,6 +73,12 @@ export interface WorkerInfo { prOpening?: boolean; title?: string; sessionId?: string; + /** + * Where its session can be followed in a browser, when the provider running it has one: its own + * web UI, say. A worker's terminal then offers it as a pinned tab, and the office knows nothing + * about what is at the address. + */ + sessionUrl?: string; exitCode?: number; cols: number; rows: number; diff --git a/src/shared/providers.ts b/src/shared/providers.ts index 03b3508db..5b7797809 100644 --- a/src/shared/providers.ts +++ b/src/shared/providers.ts @@ -12,6 +12,15 @@ export function isAgentProvider(value: unknown): value is AgentProvider { return (AGENT_PROVIDERS as readonly unknown[]).includes(value); } +/** + * Whether `value` names a provider the office can run: one of the built-in ones, or one a plugin has + * registered (see registerAgentProvider). The wire and saved state can hold anything, so anything + * that isn't one of these is a provider the office no longer knows. + */ +export function isKnownProvider(value: unknown): value is AgentProvider { + return typeof value === 'string' && (isAgentProvider(value) || pluginProviders.includes(value)); +} + /** A Claude model alias the hire dialog and queue can request explicitly (see server/agents.ts). */ export type ClaudeModel = 'fable' | 'opus' | 'sonnet' | 'haiku'; export const CLAUDE_MODELS: readonly ClaudeModel[] = ['fable', 'opus', 'sonnet', 'haiku']; @@ -156,6 +165,10 @@ export interface ProviderMeta { name: string; /** The executable it runs as: an office started with `--agent ` runs it (see server/agents.ts). None for a custom --agent. */ bin?: string; + /** Alternate executable names or script basenames that also identify this provider. */ + bins?: readonly string[]; + /** Custom matcher for an arbitrary `--agent` command path or invocation. */ + matchesCommand?: (command: string) => boolean; /** Takes a model picked for each worker: which ids (they end up on its command line, so they're checked). */ validModel?: (value: unknown) => value is string; /** What asking for a model `validModel` turns down says. */ @@ -330,7 +343,7 @@ export const PROVIDER_META: Record = { /** What the table says about `provider`, when it's one (saved state and the wire can hold anything). */ export function providerMeta(provider: unknown): ProviderMeta | undefined { - return isAgentProvider(provider) ? PROVIDER_META[provider] : undefined; + return typeof provider === 'string' ? PROVIDER_META[provider] : undefined; } /** Whether a model can be picked for a worker on `provider`. */ diff --git a/tests/agents.test.ts b/tests/agents.test.ts index bd33c9c5e..1ab925efd 100644 --- a/tests/agents.test.ts +++ b/tests/agents.test.ts @@ -156,3 +156,22 @@ test('Claude models go by the name of the model, not only its family', () => { assert.equal(claudeModelName('gpt-5.5'), undefined); assert.equal(claudeModelName(''), undefined); }); + +test('a plugin-registered agent provider becomes hirable', async () => { + const { registerAgentProvider, unregisterAgentProvider, getPluginProviders, getAllProviders, providerMeta } = await import('../src/shared/providers.js'); + // Register a provider the way a plugin does. + registerAgentProvider('openfox', { label: 'OpenFox', name: 'OpenFox', bin: 'openfox', usage: { reports: true, note: 'OpenFox usage is tracked by the OpenFox server.' } }); + try { + assert.ok(getPluginProviders().includes('openfox')); + assert.ok(getAllProviders().includes('openfox')); + assert.equal(providerMeta('openfox')?.label, 'OpenFox'); + // The office's hirable provider list now includes it, whatever the configured agent is. + assert.ok(agentProviders('claude').includes('openfox')); + assert.ok(agentProviders('custom').includes('openfox')); + // And the built-ins are all still there. + for (const p of AGENT_PROVIDERS.filter((x) => x !== 'custom')) assert.ok(agentProviders('claude').includes(p)); + } finally { + unregisterAgentProvider('openfox'); + } + assert.ok(!getPluginProviders().includes('openfox'), 'unregistered again'); +}); diff --git a/tests/floor-merge.test.ts b/tests/floor-merge.test.ts new file mode 100644 index 000000000..1f3f6352e --- /dev/null +++ b/tests/floor-merge.test.ts @@ -0,0 +1,83 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { Building } from '../src/server/building.js'; +import type { FloorDef } from '../src/server/building.js'; + +/** + * A plugin that provides its own floors (an OpenFox project per floor, say) adds to the building: + * the floors the office already has, and the ones people added themselves, stay where they are. + * Merging is what the plugin's list means: "these are mine", not "these are all there are". + */ + +function def(id: string, dir: string, extra: Partial = {}): FloorDef { + return { id, name: id, repo: id, dir, palette: 0, addedBy: 'plugin', addedAt: 1, ...extra }; +} + +test("a plugin's floors are merged into the building, not swapped for it", () => { + const dataDir = mkdtempSync(path.join(tmpdir(), 'floors-merge-')); + try { + const building = new Building(dataDir, '/'); + // The office's own floors: one a project folder on disk, one a clone of a repository. + building.addExisting('/disk/web', 'web', 'the office'); + building.addExisting('/disk/api', 'api', 'the office'); + const before = building.list().map((d) => d.id); + assert.equal(before.length, 2); + + // The plugin's list, as it comes back from listFloors(): its own projects only. + building.mergeList([def('openfox-a', '/disk/web'), def('openfox-b', '/home/me/b')]); + const after = building.list().map((d) => d.id); + // Both of the office's own floors are still there... + assert.ok(after.includes(building.list().find((d) => d.dir === '/disk/web')!.id), 'the project folder floor stays'); + assert.ok(after.includes(building.list().find((d) => d.dir === '/disk/api')!.id), 'the repository floor stays'); + // ...and the plugin's new one arrived. + assert.ok(after.includes('openfox-b'), "the plugin's own floor arrives"); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } +}); + +test("a floor the office already has, under another name, is not duplicated", () => { + const dataDir = mkdtempSync(path.join(tmpdir(), 'floors-dup-')); + try { + const building = new Building(dataDir, '/'); + const added = building.addExisting('/disk/web', 'web', 'the office'); + assert.equal(typeof added, 'object'); + const own = (added as FloorDef).id; + building.mergeList([def('openfox-web', '/disk/web')]); + // Same checkout as a floor the building already has: it is not added a second time. + assert.equal(building.list().filter((d) => path.resolve(d.dir) === path.resolve('/disk/web')).length, 1); + assert.equal(building.list()[0]!.id, own, 'the floor it already had keeps its identity'); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } +}); + +test("a plugin's floor that was there and is gone from its list is taken off", () => { + const dataDir = mkdtempSync(path.join(tmpdir(), 'floors-gone-')); + try { + const building = new Building(dataDir, '/'); + building.mergeList([def('openfox-a', '/p/a'), def('openfox-b', '/p/b')]); + assert.equal(building.list().length, 2); + building.mergeList([def('openfox-a', '/p/a')]); + assert.deepEqual(building.list().map((d) => d.id), ['openfox-a'], 'the one it no longer lists goes'); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } +}); + +test("a floor the office added itself is never taken off by a plugin's list", () => { + const dataDir = mkdtempSync(path.join(tmpdir(), 'floors-theirs-')); + try { + const building = new Building(dataDir, '/'); + building.addExisting('/disk/api', 'api', 'the office'); + // The plugin lists several of its own; the office's floor is in none of them. + building.mergeList([def('openfox-a', '/p/a')]); + const stillThere = building.list().some((d) => path.resolve(d.dir) === path.resolve('/disk/api')); + assert.ok(stillThere, 'a floor nobody but the office added stays'); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } +}); diff --git a/tests/github.test.ts b/tests/github.test.ts index 0ca2a0eb2..7bf08beb5 100644 --- a/tests/github.test.ts +++ b/tests/github.test.ts @@ -1,6 +1,6 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { Claims, MergeWatch } from '../src/server/github.js'; +import { Claims, MergeWatch, adapterBoard } from '../src/server/github.js'; import type { GhIssue, GhPull } from '../src/shared/protocol.js'; const pull = (number: number, state: string): GhPull => ({ @@ -72,3 +72,12 @@ test('handed over twice, the first answer failing leaves the second one standing second(true, 3000); assert.deepEqual(taken(c.mark([issue(7, ['octocat'])], 3500)), []); }); + +test("a plugin's board rows are used only when it has any", () => { + // A plugin that lists something knows the issues the office's `gh` login cannot see: its list is the board. + assert.deepEqual(adapterBoard([issue(1), issue(2)]), [issue(1), issue(2)]); + // One that has nothing leaves GitHub to answer, so a project it doesn't cover still shows its issues. + assert.equal(adapterBoard([]), null); + assert.equal(adapterBoard(null), null); + assert.equal(adapterBoard(undefined), null); +}); diff --git a/tests/office-plugins-e2e.test.ts b/tests/office-plugins-e2e.test.ts new file mode 100644 index 000000000..0e779f579 --- /dev/null +++ b/tests/office-plugins-e2e.test.ts @@ -0,0 +1,149 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { existsSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import net from 'node:net'; +import net from 'node:net'; +import { loadConfig } from '../src/server/config.js'; +import { startServer } from '../src/server/server.js'; +import { PROVIDER_META } from '../src/shared/providers.js'; + +const PLUGIN = path.resolve('../agent-office-plugins/agentoffice-openfox/dist/index.js'); +const GIT_PROJECTS = path.resolve('../agent-office-plugins/agentoffice-git-projects/dist/index.js'); + +/** Signs a browser in over HTTP, and gives back the cookie it keeps. */ +async function signIn(port: number, password = 'e2e-pw'): Promise { + const res = await fetch(`http://127.0.0.1:${port}/api/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ password }), + }); + const cookie = (res.headers.get('set-cookie') ?? '').split(';')[0]; + assert.ok(cookie.includes('='), `no cookie: ${res.status}`); + return cookie; +} + +/** A port nothing is on yet. */ +const freePort = () => + new Promise((res) => { + const s = net.createServer(); + s.listen(0, '127.0.0.1', () => { + const picked = (s.address() as net.AddressInfo).port; + s.close(() => res(picked)); + }); + }); + +/** + * The office boots the way cli.ts does, with the OpenFox plugin loaded, and the wiring the plugin + * declares reaches the office: its provider is one the office can hire, its services are on the + * floor's board, and its floors sit in the building next to the office's own. + * + * Uses the loaded plugin where the office has one on disk, and skips what needs a live OpenFox, so + * the office's own floors and adapters are what this checks. + */ +test('the openfox plugin reaches the office: provider, services, floors', async (t) => { + if (!existsSync(PLUGIN)) return t.skip('the openfox plugin is not built'); + const tmp = mkdtempSync(path.join(tmpdir(), 'office-plugin-e2e-')); + try { + const project = path.join(tmp, 'project'); + mkdirSync(project, { recursive: true }); + writeFileSync(path.join(project, 'README.md'), 'demo\n', 'utf8'); + execFileSync('git', ['init', '-q', project], { stdio: 'ignore' }); + execFileSync('git', ['-C', project, 'add', '.'], { stdio: 'ignore' }); + execFileSync('git', ['-C', project, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init'], { stdio: 'ignore' }); + + // The plugin reaches OpenFox for the floors and services it provides; point it at the one here. + const openfox = process.env['OPENFOX_PORT'] ?? '10369'; + const prev = process.env['OPENFOX_PORT']; + process.env['OPENFOX_PORT'] = openfox; + const port = await freePort(); + const cfg = loadConfig([ + project, + '--no-open', + '--password', 'e2e-pw', + '--port', String(port), + '--projects', tmp, + '--plugin', PLUGIN, + ]); + const office = await startServer(cfg); + try { + // Open the plugin's own floors (an OpenFox project per floor) and the office's own, then ask + // for one of them: what a browser is sent on arriving there is what this checks. + // The plugin's floors are merged in on the openFloors pass (see ceiling), the first of which + // has run by the time the office answers. + const floor = office.floors().find((f) => f.def.addedBy === 'openfox') ?? office.floors()[0]!; + // 10: the provider the plugin registers becomes one the office can hire. + assert.equal(PROVIDER_META['openfox']?.label, 'OpenFox', `the plugin's provider is registered: ${Object.keys(PROVIDER_META).join(',')}`); + + // The project a browser sees on the plugin's floor: the providers it can hire there. + const providers = floor.project.agentProviders; + assert.ok(providers.includes('openfox'), `openfox is hireable on the plugin's floor: ${JSON.stringify(providers)}`); + // 5: the plugin's floors are in the building, next to the office's own, not instead of them. + const dirs = office.floors().map((f) => f.def.dir); + assert.ok(dirs.includes(path.resolve(project)), `the office's own floor is still there: ${JSON.stringify(dirs)}`); + const fromPlugin = office.floors().filter((f) => f.def.addedBy === 'openfox'); + assert.ok(fromPlugin.length > 0, 'the plugin brought its own floors'); + console.log('E2E plugin floors:', fromPlugin.length, '| office floors:', office.floors().length, '| providers:', JSON.stringify(providers)); + } finally { + office.shutdown(); + if (prev === undefined) delete process.env['OPENFOX_PORT']; + else process.env['OPENFOX_PORT'] = prev; + } + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +/** + * A plugin with its own settings page and a projects provider (the GitHub & GitLab projects plugin): + * the office serves the page from the plugin's own directory, and the page's reads and writes reach + * the plugin wherever it keeps them. The office knows nothing about what any of it is. + */ +test('a plugin reaches its own settings page and projects through the office', async (t) => { + if (!existsSync(GIT_PROJECTS)) return t.skip('the git-projects plugin is not built'); + const tmp = mkdtempSync(path.join(tmpdir(), 'office-gitprojects-e2e-')); + try { + const project = path.join(tmp, 'project'); + mkdirSync(project, { recursive: true }); + writeFileSync(path.join(project, 'README.md'), 'demo\n', 'utf8'); + execFileSync('git', ['init', '-q', project], { stdio: 'ignore' }); + execFileSync('git', ['-C', project, 'add', '.'], { stdio: 'ignore' }); + execFileSync('git', ['-C', project, '-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'init'], { stdio: 'ignore' }); + // Its own projects, where the plugin keeps them: in the checkout's .agent-office folder. + mkdirSync(path.join(project, '.agent-office'), { recursive: true }); + writeFileSync(path.join(project, '.agent-office', 'git-projects.json'), JSON.stringify([{ id: 'front', name: 'front', url: 'https://github.com/owner/front' }]), 'utf8'); + + const port = await freePort(); + const cfg = loadConfig([project, '--no-open', '--password', 'e2e-pw', '--port', String(port), '--projects', tmp, '--plugin', GIT_PROJECTS]); + const office = await startServer(cfg); + try { + const cookie = await signIn(port); + const floor = office.floors()[0]!; + + // The floor's projects, as the plugin lists them, and a save the plugin takes. + const listed = await fetch(`http://127.0.0.1:${port}/api/plugins/git-projects/projects?floor=${encodeURIComponent(floor.id)}`, { headers: { cookie } }); + assert.equal(listed.status, 200, 'the projects are listed'); + const { projects } = (await listed.json()) as { projects: { url: string }[] }; + assert.deepEqual(projects.map((p) => p.url), ['https://github.com/owner/front'], 'what the plugin keeps is what the office hands back'); + + const saved = await fetch(`http://127.0.0.1:${port}/api/plugins/git-projects/projects?floor=${encodeURIComponent(floor.id)}`, { + method: 'POST', + headers: { cookie, 'content-type': 'application/json' }, + body: JSON.stringify({ projects: [{ id: 'front', name: 'front', url: 'https://github.com/owner/front' }, { id: 'bff', name: 'bff', url: 'https://github.com/owner/bff' }] }), + }); + assert.equal(saved.status, 200, 'the plugin took the projects'); + + // A floor nobody picked has no projects to list. + const none = await fetch(`http://127.0.0.1:${port}/api/plugins/git-projects/projects`, { headers: { cookie } }); + assert.equal(none.status, 404, 'no floor, no projects'); + console.log('E2E git-projects: settings page served, projects listed and saved'); + } finally { + office.shutdown(); + } + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/tests/plugin-settings.test.ts b/tests/plugin-settings.test.ts new file mode 100644 index 000000000..7a6eb48aa --- /dev/null +++ b/tests/plugin-settings.test.ts @@ -0,0 +1,142 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { PluginManager } from '../src/server/plugins/manager.js'; + +/** + * A plugin can bring its own settings page: any file it ships under its own directory, served by + * the office at /plugins//. The office knows nothing about what it contains, so any + * plugin can have its own settings UI, and one without is simply absent from ⚙️ Settings → Plugins. + */ +test('a plugin ships a settings page the office serves from its own directory', async () => { + const tmp = mkdtempSync(path.join(tmpdir(), 'plugin-settings-')); + const dir = path.join(tmp, 'plugins', 'demo'); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, 'settings.html'), '

demo settings

', 'utf8'); + writeFileSync( + path.join(dir, 'index.js'), + 'export function createPlugin(){return {manifest:{id:"demo",name:"Demo",description:"d",capabilities:[],enabled:true,settingsPage:"settings.html"}}};', + 'utf8', + ); + process.env['AGENT_OFFICE_PLUGIN'] = path.join(dir, 'index.js'); + try { + const mgr = new PluginManager(tmp); + await mgr.init({} as never); + // The manifest field travels through to the state the browsers get, under the plugin's own id. + const found = mgr.getState().plugins.find((p) => p.id === 'demo'); + assert.ok(found, `demo not in ${JSON.stringify(mgr.getState().plugins.map((p) => p.id))}`); + assert.equal(found.status, 'active'); + assert.equal(found.settingsPage, 'settings.html'); + // And the office can say where that file is, for serving it to the browser. + assert.equal(mgr.settingsPagePath('demo'), path.join(dir, 'settings.html')); + await mgr.shutdown(); + } finally { + delete process.env['AGENT_OFFICE_PLUGIN']; + } +}); + +test('a plugin with no settings page has no path, and the core says nothing about it', async () => { + const tmp = mkdtempSync(path.join(tmpdir(), 'plugin-nosettings-')); + process.env['AGENT_OFFICE_PLUGIN'] = path.join(tmp, 'nothing.js'); + try { + const mgr = new PluginManager(tmp); + await mgr.init({} as never); + assert.equal(mgr.settingsPagePath('demo'), undefined); + await mgr.shutdown(); + } finally { + delete process.env['AGENT_OFFICE_PLUGIN']; + } +}); + +test("a plugin's settings page sits at the top of its directory, beside its package.json", async () => { + // A plugin's files usually sit beside its built entry, but a build that cleans its output folder + // (tsup with clean: true) keeps only the bundle, so the page is often at the top of the directory. + const tmp = mkdtempSync(path.join(tmpdir(), 'plugin-settings-root-')); + const dir = path.join(tmp, '.agent-office', 'plugins', 'demo'); + mkdirSync(dir, { recursive: true }); + mkdirSync(path.join(dir, 'dist'), { recursive: true }); + writeFileSync(path.join(dir, 'settings.html'), '

at the top

', 'utf8'); + writeFileSync(path.join(dir, 'dist', 'index.js'), 'export function createPlugin(){return {manifest:{id:"demo",name:"Demo",description:"d",capabilities:[],enabled:true,settingsPage:"settings.html"}}};', 'utf8'); + writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'demo', main: 'dist/index.js' }), 'utf8'); + process.env['AGENT_OFFICE_PLUGIN'] = path.join(dir, 'dist', 'index.js'); + try { + const mgr = new PluginManager(tmp); + await mgr.init({} as never); + assert.equal(mgr.settingsPagePath('demo'), path.join(dir, 'settings.html'), 'found at the top of the directory'); + await mgr.shutdown(); + } finally { + delete process.env['AGENT_OFFICE_PLUGIN']; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test('a settings page asking for a file outside its plugin is refused', () => { + const tmp = mkdtempSync(path.join(tmpdir(), 'plugin-settings-esc-')); + const dir = path.join(tmp, 'plugins', 'demo'); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, 'index.js'), 'export function createPlugin(){return {manifest:{id:"demo",name:"Demo",description:"d",capabilities:[],enabled:true}}}'); + // A file outside the plugin's directory, one level up. + writeFileSync(path.join(tmp, 'secret.txt'), 'not yours', 'utf8'); + try { + const mgr = new PluginManager(tmp); + assert.equal(mgr.settingsPagePathOf('demo', '../secret.txt'), undefined); + assert.equal(mgr.settingsPagePathOf('demo', '/etc/passwd'), undefined); + assert.equal(mgr.settingsPagePathOf('demo', ''), undefined); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test('a plugin loaded from a file appears under its own id, once', async () => { + // The office files a plugin under the name of the file it was pointed at, but a plugin names itself + // in its manifest. Settings shows the name it gives itself, not the file's. + const tmp = mkdtempSync(path.join(tmpdir(), 'plugin-own-id-')); + const dir = path.join(tmp, 'plugins', 'git-projects'); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, 'settings.html'), '

mine

', 'utf8'); + writeFileSync(path.join(dir, 'index.js'), 'export function createPlugin(){return {manifest:{id:"git-projects",name:"GitHub & GitLab Projects",description:"d",capabilities:["issues","pulls"],enabled:true,settingsPage:"settings.html"}}};', 'utf8'); + process.env['AGENT_OFFICE_PLUGIN'] = path.join(dir, 'index.js'); + try { + const mgr = new PluginManager(tmp); + await mgr.init({} as never); + const state = mgr.getState(); + const ids = state.plugins.map((p) => p.id); + assert.ok(ids.includes('git-projects'), `its own id is there: ${JSON.stringify(ids)}`); + assert.equal(ids.filter((id) => id === 'git-projects').length, 1, 'exactly once'); + // The file name is not a card of its own: nothing is served for it either. + assert.equal(ids.includes('index'), false, `the file name is not a plugin of its own: ${JSON.stringify(ids)}`); + assert.equal(mgr.settingsPagePathOf('index', 'settings.html'), undefined); + // The card that is there has the plugin's own settings page, wherever that one is on disk. + const found = mgr.settingsPagePath('git-projects'); + assert.ok(found, 'the page is found'); + assert.match(found, /settings\.html$/); + // And its card carries the page, the way the settings pane reads it. + const card = state.plugins.find((p) => p.id === 'git-projects'); + assert.equal(card?.settingsPage, 'settings.html'); + await mgr.shutdown(); + } finally { + delete process.env['AGENT_OFFICE_PLUGIN']; + rmSync(tmp, { recursive: true, force: true }); + } +}); + +test('a plugin whose file is named for itself keeps that card', async () => { + // Nothing to give way: the file name and the manifest id are the same one. + const tmp = mkdtempSync(path.join(tmpdir(), 'plugin-same-id-')); + const dir = path.join(tmp, 'plugins', 'demo'); + mkdirSync(dir, { recursive: true }); + writeFileSync(path.join(dir, 'demo.js'), 'export function createPlugin(){return {manifest:{id:"demo",name:"Demo",description:"d",capabilities:[],enabled:true}}};', 'utf8'); + process.env['AGENT_OFFICE_PLUGIN'] = path.join(dir, 'demo.js'); + try { + const mgr = new PluginManager(tmp); + await mgr.init({} as never); + const ids = mgr.getState().plugins.map((p) => p.id); + assert.deepEqual(ids, ['demo']); + await mgr.shutdown(); + } finally { + delete process.env['AGENT_OFFICE_PLUGIN']; + rmSync(tmp, { recursive: true, force: true }); + } +}); diff --git a/tests/plugins.test.ts b/tests/plugins.test.ts index 944ba49d5..e61ce26ee 100644 --- a/tests/plugins.test.ts +++ b/tests/plugins.test.ts @@ -50,9 +50,12 @@ export function createPlugin() { const mgr = new PluginManager(tmpDir); await mgr.init({} as any); const state = mgr.getState(); - const plugin = state.plugins.find((p) => p.id === 'mock-plugin'); - assert.ok(plugin, `mock-plugin not found in ${JSON.stringify(state)}`); + // The plugin names itself in its manifest, so that is the id the office knows it by — the name of + // the file it was loaded from is not a second card in Settings. + const plugin = state.plugins.find((p) => p.id === 'mock-ext'); + assert.ok(plugin, `mock-ext not found in ${JSON.stringify(state)}`); assert.equal(plugin?.status, 'active'); + assert.equal(state.plugins.some((p) => p.id === 'mock-plugin'), false, 'the file name is not a plugin of its own'); const fp = mgr.getFloorProvider(); assert.ok(fp); const floors = await fp.listFloors(); diff --git a/tests/provider-plugins.test.ts b/tests/provider-plugins.test.ts new file mode 100644 index 000000000..14a1a0d8d --- /dev/null +++ b/tests/provider-plugins.test.ts @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { standDom } from './support/dom.mjs'; + +standDom(); + +const { registerAgentProvider, unregisterAgentProvider, PROVIDER_META } = await import('../src/shared/providers.js'); +const { supportedProviders, providerLabel, PROVIDER_LABEL, agentFields, choiceLabel } = await import('../src/client/ui/provider.js'); + +/** A project as the office says what it can hire. */ +const project = (providers: string[], defaultProvider = 'claude') => + ({ name: 'p', dir: '/p', agentCmd: defaultProvider, defaultProvider, agentProviders: providers }) as never; + +test("a provider a plugin registers shows up in the picker, not only the built-ins", () => { + // The labels the picker offers are frozen when the browser module loads (PROVIDER_LABEL); the + // provider table is not. A plugin registering one at runtime must still reach the picker. + registerAgentProvider('openfox', { label: 'OpenFox', name: 'OpenFox', bin: 'openfox', usage: { reports: true, note: 'tracked by OpenFox' } }); + try { + const supported = supportedProviders(project(['claude', 'openfox'])); + assert.deepEqual(supported, ['claude', 'openfox'], 'the plugin provider is offered'); + assert.equal(providerLabel('openfox', project(['claude', 'openfox'])), 'OpenFox'); + assert.ok(PROVIDER_META['openfox'], 'the table holds it'); + } finally { + unregisterAgentProvider('openfox'); + } + assert.deepEqual(supportedProviders(project(['claude', 'openfox'])), ['claude'], 'and goes again when it does'); +}); + +test("a provider nobody registered is still left out", () => { + assert.deepEqual(supportedProviders(project(['claude', 'nope'])), ['claude']); +}); + +test("with no provider list of its own, a project's default is what it hires on", () => { + assert.deepEqual(supportedProviders({ ...(project(['claude']) as object), agentProviders: undefined } as never), ['claude']); +}); + +test("a registered provider produces a visible label in PROVIDER_LABEL and options", () => { + registerAgentProvider('openfox', { label: 'OpenFox', name: 'OpenFox', bin: 'openfox', usage: { reports: true, note: 'tracked by OpenFox' } }); + try { + assert.equal(PROVIDER_LABEL['openfox'], 'OpenFox', 'PROVIDER_LABEL map returns OpenFox'); + assert.equal(choiceLabel({ provider: 'openfox' as never }), 'OpenFox'); + // Standing up a fake select to check option text + const fields = agentFields(project(['openfox']), 'test-agent', { provider: 'openfox' as never }); + const select = (fields.element as any).find('select')[0]; + assert.ok(select, 'select element exists'); + const opt = select.children.find((c: any) => c.attrs?.get('value') === 'openfox'); + assert.ok(opt, 'openfox option exists'); + assert.equal(opt.children[0], 'OpenFox', 'option has visible text OpenFox, not empty'); + } finally { + unregisterAgentProvider('openfox'); + } +}); diff --git a/tests/services.test.ts b/tests/services.test.ts new file mode 100644 index 000000000..d24a2fcf5 --- /dev/null +++ b/tests/services.test.ts @@ -0,0 +1,92 @@ +import test from 'node:test'; +// The board repaint runs on its own timer (3s in the office); tests shorten it. +process.env['AGENT_OFFICE_SERVICES_TEST_MS'] = '30'; +import assert from 'node:assert/strict'; +import { createLateServices } from '../src/server/office/services.js'; +import type { Ctx } from '../src/server/office/context.js'; +import type { ServiceInfo, ServicesState } from '../src/shared/protocol.js'; + +/** + * The Services board shows what a plugin's services provider lists, next to what the office's own + * port scan finds: any plugin declaring the 'services' capability feeds the board. + */ + +/** A floor good enough for the services board: its workers own the servers the scan credits to them. */ +const FLoor = { + workers: { + get: (id: string) => (id ? { id } : undefined), + }, +}; + +/** What a browser on a floor is sent when the Services board is published. */ +interface Sent { + msg: { t: string; state: ServicesState }; +} + +/** A Ctx with the services pieces createLateServices needs, plus a plugin answer and an outbox. */ +function ctxWith(pluginServices: ServiceInfo[] | null, opts: { throwIt?: boolean } = {}): { ctx: Ctx; sent: Sent[] } { + const sent: Sent[] = []; + const ownWorkers = [{ workerId: 'w-own', pid: 4242, agent: false, cwd: '/p', root: '/p' }]; + const client = { accountId: 'acct-1', peer: { floor: 'floor-1' } }; + const ctx = { + cfg: { port: 4600, deployScript: undefined, tailnet: false, publicHost: 'localhost' }, + clients: { values: () => [client] }, + floors: { + values: () => [{ id: 'floor-1', workers: { owners: () => ownWorkers } }], + }, + floorOf: () => FLoor, + sendTo: (_c: unknown, msg: Sent['msg']) => sent.push({ msg }), + plugins: { + listServices: async () => { + if (opts.throwIt) throw new Error('plugin exploded'); + return pluginServices; + }, + }, + } as unknown as Ctx; + return { ctx, sent }; +} + +const openFoxService: ServiceInfo = { + port: 4100, + host: 'localhost', + pid: 9, + command: 'openfox dev', + workerId: 'w-openfox', + cwd: '/p', + title: 'OpenFox (Stopped)', + since: 0, +}; + +test("a plugin's services show up on the floor's Services board", async () => { + const { ctx, sent } = ctxWith([openFoxService]); + const { services } = createLateServices(ctx); + // The scan publishes, then the plugin's rows are merged in and pushed to the floor. + services.start(); + await new Promise((r) => setTimeout(r, 200)); + services.stop(); + const board = sent.map((s) => s.msg).filter((m) => m.t === 'services').at(-1); + assert.ok(board, `no services message in ${JSON.stringify(sent.map((s) => s.msg.t))}`); + assert.equal(board.state.items.length, 1); + assert.equal(board.state.items[0]!.port, 4100); + assert.equal(board.state.items[0]!.workerId, 'w-openfox'); + assert.equal(board.state.items[0]!.title, 'OpenFox (Stopped)'); +}); + +test("with no plugin, the board is the office's own scan and nothing more", async () => { + const { ctx, sent } = ctxWith(null); + const { services } = createLateServices(ctx); + services.start(); + await new Promise((r) => setTimeout(r, 200)); + services.stop(); + const boards = sent.map((s) => s.msg).filter((m) => m.t === 'services'); + for (const b of boards) assert.deepEqual(b.state.items, []); +}); + +test('a plugin that throws does not take the board down', async () => { + const { ctx, sent } = ctxWith([], { throwIt: true }); + const { services } = createLateServices(ctx); + services.start(); + await new Promise((r) => setTimeout(r, 200)); + services.stop(); + for (const b of sent.map((s) => s.msg).filter((m) => m.t === 'services')) assert.ok(Array.isArray(b.state.items)); +}); diff --git a/tests/session-url.test.ts b/tests/session-url.test.ts new file mode 100644 index 000000000..dde1d691a --- /dev/null +++ b/tests/session-url.test.ts @@ -0,0 +1,77 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { claude } from '../src/server/providers/claude.js'; +import type { WorkerHandle } from '../src/server/workers/types.js'; + +/** + * A worker's terminal offers a page its provider points at (see WorkerInfo.sessionUrl), opened as a + * pinned tab beside the terminal. How it gets there: Claude Code's hook route, which any provider + * can speak (see hooksAs), carries the address with the worker's session. + */ + +/** A handle enough for the hook: what the worker is, and what may be done to it. */ +function handleWith(info: ReturnType extends never ? never : WorkerHandle['info']): WorkerHandle & { emitted: number } { + const h = { + info, + state: undefined, + running: true, + bootBlocked: false, + leftNeedsInputAt: 0, + failStreak: 0, + tracker: { transcript: undefined } as unknown as WorkerHandle['tracker'], + pendingPrompt: undefined, + emitted: 0, + setStatus: (status: WorkerHandle['info']['status']) => { + info.status = status; + }, + emit: () => { + h.emitted += 1; + }, + persist: () => {}, + notePrompt: () => {}, + noteTool: () => {}, + notePr: () => {}, + clearTask: () => {}, + scheduleScan: () => {}, + prompt: () => undefined, + }; + return h as unknown as WorkerHandle & { emitted: number }; +} + +const start = (url: unknown) => ({ session_id: 's-1', source: 'startup', web_url: url }); + +test("a session's own web page becomes the worker's session url", () => { + const h = handleWith({ id: 'w1', kind: 'agent', status: 'starting' } as WorkerHandle['info']); + assert.equal(claude.hook?.handle(h, 'SessionStart', start('https://openfox.local/p/p1/s/s1')), true); + assert.equal(h.info.sessionUrl, 'https://openfox.local/p/p1/s/s1'); + assert.ok(h.emitted >= 1, 'everyone on the floor is told'); +}); + +test('an embed url is taken too, when that is what the provider sends', () => { + const h = handleWith({ id: 'w1', kind: 'agent', status: 'starting' } as WorkerHandle['info']); + claude.hook?.handle(h, 'SessionStart', { session_id: 's-1', embed_url: 'http://127.0.0.1:10469/p/p1/s/s1' }); + assert.equal(h.info.sessionUrl, 'http://127.0.0.1:10469/p/p1/s/s1'); +}); + +test('a session url that is not a web link is never taken', () => { + for (const bad of ['javascript:alert(1)', 'data:text/html,