openvibers/openvibe.codes Commits
Run locally rebrand plus public-page SEO (#389)
Changes
@@@ -23,7 +23,7 @@ One place to send a coding task to the agent that fits it — by what the task n| The harness | `openvibe-codes` ([bin/openvibe-codes.js](bin/openvibe-codes.js), [harness/](harness/)) | Runs on a person's own machine with their CLIs and keys; nothing goes through OpenVibe. See **Use the harness** below. || Home | `/` | What the harness is, the agents in the catalog, how a task finds its agent, Improve OpenVibe, and an honest table of what works today and what comes next. || Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. |--| Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.link/machines), whose OpenVibe Link runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). The task page shows the **timeline of attempts** above the agents' **session** (each attempt with its why: "Codex hit its limit, so Command Code took over with what it had done") and the session with openvibe-shared/agent-session.js: steps grouped, each step's command, change and output (live while it runs), helpers nested, hand-offs and the plan; after the end, the session from `codes_session_lines` (migration 0007): the lines are kept as the stream arrives, back-filled from the machine's final log, and replayed at `GET /run/tasks/:id/session.json?after=` (the owner, or a service with `codes.task.read` and `X-OV-Subject`); tasks from before keep the old `log`. Run's `run.job.*` events finalize a task at `POST /internal/events` ([server/events-consumer.js](server/events-consumer.js), no polling but a 10-minute fallback), and every state change is published as `codes.task.queued|running|succeeded|failed|cancelled` from an outbox ([server/events/outbox.js](server/events/outbox.js), contracts 0.232.0). |++| Run locally | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.link/machines), whose OpenVibe Link runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). The task page shows the **timeline of attempts** above the agents' **session** (each attempt with its why: "Codex hit its limit, so Command Code took over with what it had done") and the session with openvibe-shared/agent-session.js: steps grouped, each step's command, change and output (live while it runs), helpers nested, hand-offs and the plan; after the end, the session from `codes_session_lines` (migration 0007): the lines are kept as the stream arrives, back-filled from the machine's final log, and replayed at `GET /run/tasks/:id/session.json?after=` (the owner, or a service with `codes.task.read` and `X-OV-Subject`); tasks from before keep the old `log`. Run's `run.job.*` events finalize a task at `POST /internal/events` ([server/events-consumer.js](server/events-consumer.js), no polling but a 10-minute fallback), and every state change is published as `codes.task.queued|running|succeeded|failed|cancelled` from an outbox ([server/events/outbox.js](server/events/outbox.js), contracts 0.232.0). || Knowledge | `/knowledge`, `GET/POST /api/v1/knowledge` ([server/http/knowledge.js](server/http/knowledge.js), [server/domain/knowledge.js](server/domain/knowledge.js)) | Workspace knowledge (Know K5): when a run ends, Codes captures what it taught — the agent's `Learned:` lines, a failure with its reason, the command that failed and the commands the repository uses — redacts secrets and stores it as a private row with its source run (`codes_knowledge`, migration 0025). The next run's job carries the relevant rows in its prompt before the brief, for every harness ([harness/job.js](harness/job.js) `agentPrompt`), so a pin bump, a test command or a trap is recalled without re-reading files. The page shows every row with its run, and edits, pins, shares (with a Network project's team) and deletes it; private by default. The same rows are exported and erased with the account ([server/account-data.js](server/account-data.js)); `ov-know`'s `knowledge_search`/`knowledge_add` ([know/knowledge.js](https://github.com/OpenVibers/openvibe-agents)) answer them for the person's own agents. || Skills hub | `/skills`, `/api/v1/skills`, `/api/v1/me/skills` ([server/http/skills-pages.js](server/http/skills-pages.js), [server/domain/skills.js](server/domain/skills.js)) | Skills, rules, MCP servers, commands and hooks kept per person and per project, versioned (`codes_skills`, `codes_skill_versions`, migration 0028): every save is an immutable version, a rollback copies an old version into a new one, and the body is rendered through the markup gate. Sharing is `openvibe-sdk/share`'s four audiences (`public`, `link`, `people`, `private`); only `public` is in the gallery, and the share sheet is the SDK's at `/api/v1/skill/:id/share`. A person adds a record to their sync, and `openvibe-link skills sync` writes it into Claude Code, Codex, OpenCode and Command Code inside one marked block, with a dry run, a diff and an undo. || The agents | `/harnesses` | Every harness in [`server/data/harness-offers.json`](server/data/harness-offers.json) (validated at boot as `platform.harness-offer@1`, each model as `platform.agent-offer@1`) with its provider, address kind, what it can do, price and limits, and its models with context limits and token prices. No local path is ever shown. |@@@ -33,7 +33,7 @@ One place to send a coding task to the agent that fits it — by what the task n| Community | `/policy`, `/policy/<document>` | The code of conduct ([CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)), the contribution guide ([CONTRIBUTING.md](CONTRIBUTING.md)), the [contributor ladder](docs/governance/contributor-ladder.md) and the [moderation policy](docs/governance/moderation.md), rendered from those files. All four are **drafts pending owner review**: marked at the top, shown with a notice, not indexed; deleting the draft line publishes one. They govern the repositories and the apps and releases on OpenVibe.Services alike. || Moved | `/projects`, `/docs`, `/oauth`, `/tools`, `/manifests`, `/releases`, `/apps`, `/staff`, `/policy/{rfc,compatibility,licensing,transparency}`, `/api/v1/*` but the agent and task APIs | Permanent redirects to the same path and query on `https://openvibe.services`: 301 for GET and HEAD, 308 for anything else (the method and body are kept). `/docs/harnesses` goes to `/harnesses` here. [`server/http/moved.js`](server/http/moved.js). || Machine | `/api/health`, `/api/ready`, `/release.json`, `/metrics` | Readiness is truthful (the database and the catalog required; JWKS, the OAuth client and Valkey reported as optional checks). `/metrics` answers loopback callers only. |--| Crawl artifacts | `/robots.txt`, `/sitemap.xml`, `/llms.txt`, `/llms-full.txt` | Built with openvibe-shared/seo. robots.txt disallows sign-in and the API and leaves the moved addresses crawlable, so engines follow their 301s to openvibe.services; the sitemap lists the public pages with STATUS.json's content date (never "today"). |++| Crawl artifacts | `/robots.txt`, `/sitemap.xml`, `/llms.txt`, `/llms-full.txt` | Built with openvibe-shared/seo. robots.txt disallows sign-in and the API and leaves the moved addresses crawlable, so engines follow their 301s to openvibe.services; the sitemap lists public pages, repository contribution pages and public skills with committed or content-derived dates (never "today"). `/llms-full.txt` and `Accept: text/markdown` use the anonymous page renderers. Deploy/start announces public URLs through openvibe-shared/indexnow when `INDEXNOW_KEY` is configured (100 URLs per batch). |Everything is server-rendered and usable without JavaScript.@@@ -77,7 +77,7 @@ More: [docs/cli.md](docs/cli.md).## What comes next--1. **A hosted runner** — the same harness on OpenVibe.Run workers for people who would rather not run agents themselves, with budgets per person and per project, metered through Billing. (Tasks from the web already run on the person's own machine: see "Run on your machine" above.)++1. **A hosted runner** — the same harness on OpenVibe.Run workers for people who would rather not run agents themselves, with budgets per person and per project, metered through Billing. (Tasks from the web already run on the person's own machine: see "Run locally" above.)2. **OpenVibe.Actor uses Codes** — Actor is OpenVibe's general agent (owner, 2026-10-08); its coding steps run through this harness.3. **Improve OpenVibe as a flow** — repository → change → tests → independent review → pull request, never straight to `main`.4. **The OpenVibe agent pool on this package** — `~/openvibe/agents` (the pool that builds OpenVibe) replaces its own adapters with these.@@@ -1,5 +1,5 @@/*-- * Run on your machine (server/http/run.js): the live lines of a running task and the colours of a finished patch.++ * Run locally (server/http/run.js): the live lines of a running task and the colours of a finished patch.* With openvibe-shared/agent-session.js on the page (the task page loads it), the lines become the agents' session:* steps grouped, each step's command, change and output (live), helpers, hand-offs and the plan; a finished task's* page shows the session its result kept. Without it, the plain list below.@@@ -11,10 +11,12 @@ function createAgentPages(ctx, publicViews, { home = null } = {}) {async function renderPublicPage(path) {// / is the C4 front door a visitor gets (server/http/c4.js visitorHome), not the old marketing home.if (path === '/') return twin((home && home()) || views.home(), path);++ if (path === '/run') return twin(require('./http/run').guestRunPage(), path);++ if (path === '/skills' || path.startsWith('/skills/')) return twin(await ctx.skillPages.publicView(path), path);if (path === '/start') return twin(views.start(), path);if (path === '/harnesses') return twin(views.harnesses(), path);if (path === '/improve') return twin(views.improve(), path);-- if (path === '/updates') return twin(views.updates(), path);++ if (path === '/updates') return twin(await views.updates(), path);if (path === '/policy') return twin(views.policy(), path);let m = /^\/policy\/([^/]+)$/.exec(path);if (m) return twin(views.policyDoc(decodeURIComponent(m[1])), path);@@@ -163,6 +163,8 @@ async function createApp(opts = {}) {tasks.attachBindings(ctx.prBindings);const app = express();++ ctx.indexnow = require('openvibe-shared/indexnow').createIndexNow({ host: config.baseUrl, key: (opts.env || process.env).INDEXNOW_KEY || '', log: (message) => log.warn(message) });++ if (ctx.indexnow.enabled) app.use(ctx.indexnow.keyFile);aiCatalogConfig.mount(app);// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts// (openvibe-contracts); the operations they mark internal-only are left out.@@@ -280,7 +282,7 @@ async function createApp(opts = {}) {}));// ── API ─────────────────────────────────────────────────-- // Run on your machine (plan T16): coding tasks on the person's own machine, through OpenVibe Run, from the /run++ // Run locally (plan T16): coding tasks on the person's own machine, through OpenVibe Run, from the /run// pages below or, for a first-party service acting for the person, from /api/v1/tasks (codes.task.*).// ── /api/v1/task/:id/share — the one Share sheet's API (openvibe-sdk/share) ──// GET/PUT .../share, .../share/requests, .../share/transfer[/accept|decline|cancel], GET /api/v1/share/people@@@ -328,22 +330,27 @@ async function createApp(opts = {}) {// ── Pages ───────────────────────────────────────────────app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.CODES_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false })); // per address; tests raise it-- app.use(createSkillPageRoutes({ config, skills: ctx.skills }));++ ctx.skillPages = createSkillPageRoutes({ config, skills: ctx.skills });++ ctx.publicFetch = fetchImpl || globalThis.fetch;const pageRoutes = createPageRoutes(ctx);++ ctx.publicPages = pageRoutes.publicPages;// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the public// pages. The pages themselves keep their own routes — a signed-in visitor's account bar and forms are theirs// — so only the manifest and twin requests reach this mount; the pages advertise the twin themselves.let c4Routes = null; // created below, after the chat app's routes (the twin of / reads its front door)const agentLayer = express();++ ctx.renderPublicPage = require('./agent-pages').createAgentPages(ctx, pageRoutes.publicViews, { home: () => c4Routes && c4Routes.visitorHome() }).renderPublicPage;require('openvibe-shared/agent-layer').mount(agentLayer, {descriptor: require('./agent-descriptor'),-- render: require('./agent-pages').createAgentPages(ctx, pageRoutes.publicViews, { home: () => c4Routes && c4Routes.visitorHome() }).renderPublicPage,++ render: ctx.renderPublicPage,});app.use((req, res, next) => {const twin = req.path.endsWith('.md') || req.query.format === 'markdown' || String(req.headers.accept || '').includes('text/markdown');if (req.path !== '/.well-known/agent.json' && !twin) return next();return agentLayer(req, res, next);});++ app.use(ctx.skillPages);++ app.get('/community', (_req, res) => res.redirect(301, '/policy'));// The chat app (L31 CH1) answers a signed-in person's / and /tasks/:id; everyone else falls through to C4.app.use(require('./http/chat').createChatRoutes(ctx));app.use(require('./http/git').createGitRoutes(ctx)); // after the chat app: it renders in ctx.chatShellnnew file mode 100644@@@ -0,0 +1,22 @@++'use strict';++// Public feed only: bounded wait, five-minute cache, and the last good response on failure.++function createPublicUpdates({ base, fetchImpl = globalThis.fetch }) {++ let cached = [], expires = 0, pending = null;++ return async function updates() {++ if (Date.now() < expires) return cached;++ if (pending) return pending;++ pending = (async () => {++ try {++ const response = await fetchImpl(`${base}/api/v1/changelog?service=codes&limit=50`, { signal: AbortSignal.timeout(2000), headers: { Accept: 'application/json' } });++ if (response.ok) {++ const data = await response.json();++ if (Array.isArray(data.entries)) cached = data.entries.filter(e => typeof e.subject === 'string' && /^https:\/\/github.com\/OpenVibers\//.test(e.url || '')).slice(0, 50);++ }++ } catch { /* The public page still renders the last successful feed. */ }++ expires = Date.now() + 300000;++ return cached;++ })().finally(() => { pending = null; });++ return pending;++ };++}++module.exports = { createPublicUpdates };@@@ -301,7 +301,11 @@ function createSkills({ store, sharing, log = console }) {.map((r) => ({ skill_id: r.skill_id, project: r.project, created_at: r.created_at instanceof Date ? r.created_at.toISOString() : String(r.created_at) }));}-- return { create, get, listVersions, gallery, mine, addVersion, rollback, setAudience, members, addMember, removeMember, assign, unassign, manifest, assignments };++ async function publicPages() {++ const rows = await db.many(`SELECT s.id, s.title, v.summary, s.updated_at ${FROM} WHERE sh.audience = 'public' ORDER BY s.id`);++ return rows.map(s => ({ path: `/skills/${s.id}`, title: s.title, text: s.summary, lastmod: new Date(s.updated_at).toISOString().slice(0, 10) }));++ }++ return { publicPages, create, get, listVersions, gallery, mine, addVersion, rollback, setAudience, members, addMember, removeMember, assign, unassign, manifest, assignments };}module.exports = { createSkills, SkillError, KINDS, AUDIENCES, TOOLS, MAX_BODY, MAX_TITLE, MAX_SUMMARY };@@@ -23,14 +23,14 @@ const SERVICES_ORIGIN = 'https://openvibe.services';const DESCRIPTION = 'The open coding-agent harness: run a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or a model you host, with routing by capability and cost, hand-offs between agents, and the way to improve OpenVibe itself.';const { version: VERSION } = require('../../package.json');const INSTALL_HINT = `npm install -g https://codeload.github.com/OpenVibers/OpenVibe.Codes/tar.gz/refs/tags/v${VERSION}`;--const HARNESS_PAGES = ['/', '/start', '/harnesses', '/skills', '/improve'];// Sign-in and the API are not for crawlers. The console's old addresses redirect to openvibe.services.--const DISALLOW = ['/auth/', '/api/'];++const DISALLOW = ['/auth/', '/api/', '/tasks', '/run/tasks', '/settings', '/internal/', '/conductor', '/approvals', '/machines', '/memory', '/knowledge', '/usage'];--// The one line /llms-full.txt says about each fixed public page: a title and what the page serves.++// Titles and one-line summaries for llms.txt. Full text uses the public HTML renderers.// These are the pages publicPages() lists (and so the sitemap), so the two cannot name different sets.const PAGE_TEXT = {'/': ['OpenVibe.Codes home', 'Code with any agent: the coding-agent harness, how a task is routed and handed off, what works today, and how to improve OpenVibe.'],++ '/run': ['Run locally', 'Start coding agents from any browser and run them on your own computer.'],'/start': ['Get started with the coding-agent harness', 'Install openvibe-codes; the agents it can use and what each needs; the commands, permission levels, hand-offs, the event format and using it from your own code.'],'/harnesses': ['The coding agents', 'Every harness the router can pick (Claude Code, Codex, OpenCode, Command Code, Aider, DeepSeek, your own model) with its models, capabilities, prices and limits.'],'/skills': ['The skills hub', 'Skills, rules and MCP servers kept per person and per project, versioned, shared through the four audiences, and synced into each coding tool on your own machine with a dry run and an undo.'],@@@ -52,13 +52,13 @@ function siteUpdated() {/*** The home page's JSON-LD: the site as a WebSite, the harness as the site's primary type (the shared kit's-- * WebApplication) and the page that carries them. The same nodes for every crawler; nothing about the reader.++ * SoftwareApplication) and the page that carries them. The same nodes for every crawler; nothing about the reader.*/function homeJsonLd(config) {const site = String(config.baseUrl).replace(/\/+$/, '');return [seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),-- seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'DeveloperApplication', keywords: 'ai coding agent, coding agent harness, coding agent router, agent hand-off, claude code, codex, opencode, command code, deepseek, aider, self-hosted coding agent, open source coding harness, contribute to open source' }),++ { ...seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'DeveloperApplication', keywords: 'ai coding agent, coding agent harness, coding agent router, agent hand-off, claude code, codex, opencode, command code, deepseek, aider, self-hosted coding agent, open source coding harness, contribute to open source' }), '@type': 'SoftwareApplication', operatingSystem: 'Linux, macOS, Windows', offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' } },seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),];}@@@ -67,6 +67,7 @@ function homeJsonLd(config) {function publicPages({ governance = [] }) {const pages = [{ path: '/', changefreq: 'weekly', priority: 1.0 },++ { path: '/run', changefreq: 'weekly', priority: 0.9 },{ path: '/start', changefreq: 'weekly', priority: 0.9 },{ path: '/harnesses', changefreq: 'weekly', priority: 0.9 },{ path: '/skills', changefreq: 'weekly', priority: 0.9 },@@@ -75,6 +76,8 @@ function publicPages({ governance = [] }) {{ path: '/policy', changefreq: 'monthly', priority: 0.4 },];for (const g of governance) if (!g.draft) pages.push({ path: `/policy/${g.slug}`, changefreq: 'monthly', priority: 0.3 });++ const repos = require('./pages').repositories();++ for (const repo of [...repos.running, ...repos.libraries]) pages.push({ path: `/improve/${repo.repository}`, changefreq: 'weekly', priority: 0.6 });return pages;}@@@ -84,6 +87,7 @@ function describePage(pathname, { governance = [] }) {const m = /^\/policy\/([^/]+)$/.exec(pathname);const g = m && governance.find((x) => x.slug === m[1]);if (g) return { title: g.title, text: g.blurb || '' };++ if (pathname.startsWith('/improve/')) { const name = pathname.split('/').pop(); return { title: `Improve ${name}`, text: `Contribute a change to ${name} with a coding agent on your computer.` }; }return { title: pathname, text: '' };}@@@ -108,7 +112,7 @@ function llmsTxt({ name, summary, details, sections = [], optional = [], base =if (details) out.push(String(details).trim(), '');for (const sec of [...sections, ...(optional.length ? [{ title: 'Optional', links: optional }] : [])]) {if (!sec) continue;-- const links = (sec.links || []).map((l) => '- [' + l.title + '](' + twinOf(l.url, base) + ')' + (l.note ? ': ' + String(l.note).replace(/\s+/g, ' ').trim() : ''));++ const links = (sec.links || []).map((l) => '- [' + l.title + '](' + (l.canonical ? l.url : twinOf(l.url, base)) + ')' + (l.note ? ': ' + String(l.note).replace(/\s+/g, ' ').trim() : ''));const notes = (sec.notes || []).map((n) => String(n).replace(/\s+/g, ' ').trim());if (!links.length && !notes.length) continue;out.push('## ' + sec.title, '');@@@ -124,6 +128,16 @@ function createDiscoveryRoutes(ctx) {const site = String(config.baseUrl).replace(/\/+$/, '');const abs = (p) => `${site}${p}`;const TEXT = cache.htmlHeaders({ maxAge: 3600 });++ const allPages = async () => {++ const skills = ctx.skills && ctx.skills.publicPages ? await ctx.skills.publicPages() : [];++ const updates = ctx.updateDate ? await ctx.updateDate() : null;++ const revision = siteUpdated();++ const latest = dates => dates.filter(Boolean).sort().at(-1);++ return [...publicPages({ governance }).map(p => ({ ...p,++ lastmod: p.path === '/skills' ? latest([revision, ...skills.map(s => s.lastmod)])++ : p.path === '/updates' ? latest([revision, updates]) : revision,++ })), ...skills];++ };r.get('/robots.txt', (_req, res) => {res.type('text/plain').set('Cache-Control', TEXT).send(@@@ -132,13 +146,14 @@ function createDiscoveryRoutes(ctx) {+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));});-- r.get('/llms.txt', (_req, res) => {++ r.get('/llms.txt', async (_req, res) => {res.type('text/plain').set('Cache-Control', TEXT).send(llmsTxt({name: SITE_NAME,base: site,summary: 'OpenVibe.Codes: the open coding-agent harness. openvibe-codes runs a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or your own OpenAI-compatible model, hands it to the next agent when one fails, and keeps every run; plus a public catalog and routing API, and the way to improve OpenVibe itself.',details: `Every page is server-rendered and readable without JavaScript. The harness is a command-line tool and a Node.js module that runs on your machine with your agents and keys (install: ${INSTALL_HINT}). Every agent's output becomes one event stream in Claude Code's stream-json shape. The catalog and the router are public: GET /api/v1/harnesses lists every harness with its models, and POST /api/v1/harnesses/route picks one for a task by capability first, then cost. A hosted runner comes next. The developer console that used to live here (projects, apps, keys, the API reference, OAuth and webhook tools, releases) is OpenVibe.Services at ${SERVICES_ORIGIN}.`,sections: [++ { title: 'Public pages', links: (await allPages()).map(p => ({ title: p.title || describePage(p.path, { governance }).title, url: abs(p.path), note: p.text || describePage(p.path, { governance }).text, canonical: true })) },{ title: 'Start here', links: [{ title: 'Code with any agent', url: abs('/'), note: 'what the harness is, how a task finds its agent, what works today' },{ title: 'Get started', url: abs('/start'), note: 'install, agents, commands, permission levels, hand-offs, events, from your own code' },@@@ -159,34 +174,31 @@ function createDiscoveryRoutes(ctx) {optional: [{ title: 'What shipped on OpenVibe.Codes', url: abs('/updates') },{ title: 'Sitemap', url: abs('/sitemap.xml'), note: 'the public pages, each with a real lastmod' },-- { title: 'Full text for language models', url: abs('/llms-full.txt'), note: 'every public page, one line each' },++ { title: 'Full text for language models', url: abs('/llms-full.txt'), note: 'the complete public pages as Markdown' },{ title: 'Release metadata (JSON)', url: abs('/release.json'), note: 'this service\'s current release, per ADR-016' },{ title: 'OpenVibe.Services', url: SERVICES_ORIGIN, note: 'the developer platform: projects, keys, grants and the API docs of every service' },],}));});-- // /llms-full.txt: the same public pages the sitemap lists, one title and one line of text each.-- r.get('/llms-full.txt', (_req, res) => {-- const pages = publicPages({ governance }).map((p) => ({ url: p.path, ...describePage(p.path, { governance }) }));-- res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({-- site: SITE_NAME,-- summary: 'Every public page of OpenVibe.Codes, one line each: the harness, getting started, the agent catalog, Improve OpenVibe, the update log and the community documents.',-- base: site,-- maxBytes: 128 * 1024,-- sections: [-- { title: 'Harness', pages: pages.filter((p) => HARNESS_PAGES.includes(p.url)) },-- { title: 'Site', pages: pages.filter((p) => !HARNESS_PAGES.includes(p.url)) },-- ],-- }));++ // Full text comes from the same anonymous renderers as HTML and negotiated Markdown.++ r.get('/llms-full.txt', async (_req, res) => {++ const chunks = [`# ${SITE_NAME}`, DESCRIPTION];++ for (const p of await allPages()) {++ const rendered = await ctx.renderPublicPage(p.path);++ if (!rendered) continue;++ chunks.push(`URL: ${abs(p.path)}\n` + require('openvibe-shared/agent-layer').markdownTwin({ ...rendered, canonical: abs(p.path) }));++ }++ res.type('text/plain').set('Cache-Control', TEXT).send(chunks.join('\n\n'));});-- r.get('/sitemap.xml', (_req, res) => {++ r.get('/sitemap.xml', async (_req, res) => {const lastmod = siteUpdated();-- const urls = publicPages({ governance }).map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));++ const urls = (await allPages()).map((e) => ({ loc: abs(e.path), ...(e.lastmod || lastmod ? { lastmod: e.lastmod || lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));});++ r.publicPages = allPages;return r;}@@@ -104,7 +104,7 @@ Added test/date.test.js: three cases, all pass.const statusTable = table(['Piece', 'State'], [[html`<a href="/start">The harness on your machine</a> (<code>openvibe-codes</code>)`, `Live, ${VERSION}: runs a task on any of the eight agents with your CLIs and keys, streams one event format, keeps every run to show or resume.`],['Hand-offs between agents', 'Live: a failed, rate-limited or silent agent hands the task to the next one with a note on what was done.'],-- [html`<a href="/run">Run on your machine</a>: start a task in any browser, your phone included; your own computer runs it`, 'Live: add your machine on openvibe.link/machines (one command), then watch the task live here and get the patch, a branch or a pull request. It runs only on your own machines, in Link\'s job sandbox.'],++ [html`<a href="/run">Run locally</a>: start a task in any browser, your phone included; your own computer runs it`, 'Live: add your machine on openvibe.link/machines (one command), then watch the task live here and get the patch, a branch or a pull request. It runs only on your own machines, in Link\'s job sandbox.'],[html`<a href="/harnesses">The agent catalog</a>`, 'Live: every harness and model with its capabilities, prices and limits.'],[html`<code>GET /api/v1/harnesses</code>, <code>POST /api/v1/harnesses/route</code>`, 'Live: public, no key needed; per-address and per-caller limits.'],[html`<a href="/improve">Improve OpenVibe</a>: a repository, a change on a branch with its test, a pull request that CI checks and a maintainer reviews`, 'Live: pick a repository, describe the change, and start a guided run on your machine.'],@@@ -119,7 +119,7 @@ Added test/date.test.js: three cases, all pass.title: 'Code with', accent: 'any agent',lede: 'One open harness for Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API and models you host yourself: send a coding task to the agent that fits it, hand it over when one gets stuck, and keep the whole run in one place. Then point it at OpenVibe itself.',// Two, so they sit side by side on a phone; the agents are the next section.-- actions: [{ label: 'Install the harness', href: '/start', primary: true }, { label: 'Run on your machine from the web', href: '/run' }],++ actions: [{ label: 'Install the harness', href: '/start', primary: true }, { label: 'Run locally from the web', href: '/run' }],note: `Open source (AGPL-3.0). Version ${VERSION} runs on your machine with the agents and keys you already have, from your terminal or started from this site in any browser; the list below says exactly what works.`,aside: { html: `<pre tabindex="0" class="codes-hero-code"><code>${showcase.esc(snippet)}</code></pre>` },}))}@@@ -227,7 +227,7 @@ ${table(['Level', 'What the agent may do'], [<pre tabindex="0"><code>${fromCode}</code></pre><p><code>harness.agents()</code>, <code>harness.route({ task })</code> and <code>harness.run({ prompt, cwd, agent, permission, handoff })</code> are what the command line uses. The router is the same one behind <a href="/api/v1/harnesses"><code>/api/v1/harnesses/route</code></a>.</p><h2 id="next">What comes next</h2>--<p>Tasks from the web already run on your own machine (<a href="/run">Run on your machine</a>). Next: a hosted runner on OpenVibe.Run workers for people who would rather not run agents themselves, with budgets; OpenVibe.Actor using Codes for its coding work; and <a href="/improve">Improve OpenVibe</a> as a guided run: pick a repository, describe the change, get a pull request.</p>`,++<p>Tasks from the web already run on your own machine (<a href="/run">Run locally</a>). Next: a hosted runner on OpenVibe.Run workers for people who would rather not run agents themselves, with budgets; OpenVibe.Actor using Codes for its coding work; and <a href="/improve">Improve OpenVibe</a> as a guided run: pick a repository, describe the change, get a pull request.</p>`,};}r.get('/start', (req, res) => page(req, res, startPage()));@@@ -445,8 +445,14 @@ ${raw(kit.fold({ title: 'Clone it yourself', meta: 'git clone', body: `${kit.sni// ── Community documents and the update log ───────────────// What shipped on OpenVibe.Codes: the shared update log every OpenVibe site has.-- const updatesPage = () => ({ agent: true, index: true, cache: PUBLIC_CACHE, title: 'What shipped on OpenVibe.Codes', body: raw(frame.updatesBody({ service: 'codes', siteName: 'OpenVibe.Codes' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`) });-- r.get('/updates', (req, res) => page(req, res, updatesPage()));++ const readUpdates = require('../data/public-updates').createPublicUpdates({ base: config.networkUrl, fetchImpl: ctx.publicFetch });++ const updatesPage = async () => {++ const entries = await readUpdates();++ return { agent: true, index: true, cache: PUBLIC_CACHE, title: 'What shipped on OpenVibe.Codes',++ body: html`${raw(frame.updatesBody({ service: 'codes', siteName: 'OpenVibe.Codes' }).replace(/<div data-ov-shipped=[\s\S]*?<\/div>/, ''))}++${entries.length ? html`<ol>${entries.map(e => html`<li><a href="${e.url}">${e.subject}</a> <time datetime="${e.deployed_at}">${String(e.deployed_at || '').slice(0, 10)}</time></li>`)}</ol>` : html`<p>The update feed is temporarily unavailable. <a href="https://github.com/OpenVibers/OpenVibe.Codes/commits/main">Read the Codes commit history</a>.</p>`}` };++ };++ r.get('/updates', async (req, res) => page(req, res, await updatesPage()));const policyIndexPage = () => ({agent: true, index: true, cache: PUBLIC_CACHE, title: 'Community', crumbs: [{ label: 'Community' }],body: html`<h1>Community</h1>@@@ -474,7 +480,11 @@ ${g.draft ? notice(html`<strong>Draft — pending owner review.</strong> This is};// ── Discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt (http/discovery.js, openvibe-shared/seo) ──-- r.use(createDiscoveryRoutes({ ...ctx, governance }));++ const discovery = createDiscoveryRoutes({ ...ctx, governance, renderPublicPage: (path) => ctx.renderPublicPage(path),++ updateDate: async () => (await readUpdates()).map(e => String(e.deployed_at || '').slice(0, 10)).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().at(-1),++ });++ r.publicPages = discovery.publicPages;++ r.use(discovery);return r;}@@@ -1,6 +1,6 @@'use strict';/**-- * Run on your machine (plan T16): a coding task started from any browser that runs on the person's own machine.++ * Run locally (plan T16): a coding task started from any browser that runs on the person's own machine.** GET /run the form, the person's projects and their recent tasks (a sign-in prompt for a guest)* POST /run start one: a run_tasks row and an OpenVibe Run job for the person (server/domain/run-client.js)@@@ -46,6 +46,16 @@ function repoName(url) {return html`<small class="run-host">${host}/</small>${path.map((part, i) => html`${i ? raw('/<wbr>') : ''}${part}`)}`;}++function guestRunPage() {++ return {++ title: 'Run locally', index: true, agent: true,++ body: html`<h1>Run coding agents locally, started from any browser</h1>++<p class="lead">Start a task from any browser, your phone included, and your own computer does the work: the agents and keys you already have, in a sandbox, with nothing to host. You watch it live here and get the changes back as a patch, a branch or a pull request.</p>++<ol class="run-steps"><li>Add your computer on <a href="${MACHINES_URL}">openvibe.link/machines</a>: one command, which installs OpenVibe Link with its job worker.</li><li>Sign in here and describe the task.</li><li>Your machine clones the repository, runs the agent and sends the result back.</li></ol>++<p><a class="run-btn run-btn-primary" href="/auth/login?next=%2Frun">Sign in to start</a></p>`,++ };++}++function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globalThis.fetch, log = console, tasks = null, sessions = null, keys = null, sharing = null }) {const r = asyncRouter();const db = store.db;@@@ -144,20 +154,14 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`async function runPage(req, res, { values, error, status = 200 } = {}) {if (!signedIn(req)) {-- return page(req, res, {-- title: 'Run on your machine', index: true,-- body: html`<h1>Run a coding task on your own machine</h1>--<p class="lead">Start a task from any browser, your phone included, and your own computer does the work: the agents and keys you already have, in a sandbox, with nothing to host. You watch it live here and get the changes back as a patch, a branch or a pull request.</p>--<ol class="run-steps"><li>Add your computer on <a href="${MACHINES_URL}">openvibe.link/machines</a>: one command, which installs OpenVibe Link with its job worker.</li><li>Sign in here and describe the task.</li><li>Your machine clones the repository, runs the agent and sends the result back.</li></ol>--<p><a class="run-btn run-btn-primary" href="/auth/login?next=%2Frun">Sign in to start</a></p>`,-- });++ return page(req, res, guestRunPage());}const [projects, recent] = await Promise.all([projectsOf(req, res),db.many('SELECT id, repo, prompt, state, created_at FROM run_tasks WHERE subject = $1 ORDER BY created_at DESC LIMIT 20', [req.viewer.subject]),]);const defaults = sharing ? await defaultsHtml(req.viewer.subject) : '';-- const body = html`<h1>Run on your machine</h1>++ const body = html`<h1>Run locally</h1><p class="lead">The task runs on a machine you added on <a href="${MACHINES_URL}">openvibe.link/machines</a>, in its job sandbox, with the agents and keys installed there. Nobody else's machine ever gets it.</p>${defaults}${projects.length ? formHtml({ projects, values, error }) : html`<form class="run-card" method="post" action="/run/project">@@@ -169,7 +173,7 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`<button class="run-btn run-btn-primary" type="submit">Create the project</button></form>`}<h2>Your tasks</h2>${tasksHtml(recent)}`;-- return page(req, res, { title: 'Run on your machine', body }, status);++ return page(req, res, { title: 'Run locally', body }, status);}r.get('/run', (req, res) => runPage(req, res, { values: { prompt: String(req.query.prompt || '').slice(0, 16000), repo: String(req.query.repo || '').slice(0, 2000), agent: String(req.query.agent || '').slice(0, 100) } }));@@@ -324,7 +328,7 @@ ${i.summary ? html`<p class="run-proof-summary" data-proof-summary>${i.summary}<if (!allowed) {const audience = sharing ? await sharing.audienceOf(base.id).catch(() => null) : null;if (audience === 'people') return gatePage(req, res, base.id);-- return page(req, res, { title: 'Not found', index: false, body: html`<h1>Not found</h1><p>No such task. <a href="/run">Run on your machine</a></p>` }, 404);++ return page(req, res, { title: 'Not found', index: false, body: html`<h1>Not found</h1><p>No such task. <a href="/run">Run locally</a></p>` }, 404);}}let row = base;@@@ -335,7 +339,7 @@ ${i.summary ? html`<p class="run-proof-summary" data-proof-summary>${i.summary}<const lines = live ? null : await sessionStore.linesForRow(row);const title = String(row.prompt || '').split('\n')[0].slice(0, 90);const session = lines && lines.length ? html`<section class="run-session" data-session data-title="${title}" data-state="${row.state}"><h2>The session</h2><div data-session-view></div><script type="application/json" data-session-log>${raw(JSON.stringify(lines).replace(/</g, '\\u003c'))}</script></section>` : '';-- const body = html`${shareHead ? raw(shareHead) : ''}<p class="crumbs"><a href="/run">Run on your machine</a></p>++ const body = html`${shareHead ? raw(shareHead) : ''}<p class="crumbs"><a href="/run">Run locally</a></p><h1 class="run-repo">${repoName(row.repo)}${row.ref ? html` <small>${row.ref}</small>` : ''}</h1><p><span class="run-pill run-pill-${row.state}" data-state>${STATE_LABEL[row.state] || row.state}</span>${row.agent ? html` <span class="run-pill">${row.agent}</span>` : ''}</p>${mine ? shareButton(row) : html`<p class="muted">This task was shared with you. Only its owner can change it.</p>`}@@@ -445,4 +449,4 @@ ${mine ? html`<form method="post" action="/run/tasks/${row.id}/cancel"><button creturn r;}--module.exports = { createRunRoutes, STATE_LABEL, TERMINAL, esc };++module.exports = { createRunRoutes, guestRunPage, STATE_LABEL, TERMINAL, esc };@@@ -41,7 +41,7 @@ function createSkillPageRoutes({ config, skills }) {try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }};const form = express.urlencoded({ extended: false, limit: '200kb' });-- const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });++ const page = (req, res, o, status = 200) => res ? send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o }) : o;const toolsText = (tools) => (tools && tools.length ? tools.map((t) => TOOL_LABELS[t] || t).join(', ') : 'every tool');const skillCard = (s, extra = '') => html`<li>@@@ -74,7 +74,7 @@ ${audience ? html`<div class="field"><label for="sk-audience">Who may read it</l<form class="stack" method="post" action="/skills/new">${formFields()}<button class="run-btn run-btn-primary" type="submit">Save version 1</button></form></section>`: html`<p><a class="run-btn" href="/auth/login?next=${encodeURIComponent('/skills')}">Sign in to publish and sync skills</a></p>`;return page(req, res, {-- title: 'Skills: the hub for your agents',++ title: 'Skills: the hub for your agents', agent: true, index: !kind && !q,description: 'Skills, rules and MCP servers kept per person and per project, versioned, and synced into Claude Code, Codex, OpenCode and Command Code on your machine.',crumbs: [{ label: 'Skills' }],body: html`<h1>Skills</h1>@@@ -111,7 +111,7 @@ ${skill.audience === 'people' ? html`<h3>Members</h3>${members.length ? html`<ul<form class="inline-form" method="post" action="/skills/${skill.id}/members"><label>Person <input name="subject" placeholder="usr_…" pattern="usr_[0-9A-HJKMNP-TV-Z]{26}" required></label><button class="run-btn" type="submit">Add</button></form>` : ''}</section>` : '';return page(req, res, {-- title: skill.title,++ title: skill.title, agent: skill.audience === 'public', index: skill.audience === 'public',description: skill.summary || `A ${KIND_LABELS[skill.kind]} on OpenVibe.Codes.`,crumbs: [{ label: 'Skills', href: '/skills' }, { label: skill.title }],body: html`${error ? notice(error, 'bad') : ''}@@@ -225,6 +225,15 @@ ${controls}`,}});++ r.publicView = async (path) => {++ const req = { viewer: { kind: 'anonymous' }, query: {}, params: {}, originalUrl: path };++ if (path === '/skills') return galleryPage(req, null);++ const match = /^\/skills\/([^/]+)$/.exec(path);++ if (!match) return null;++ req.params.id = match[1];++ const skill = await skills.get(req.params.id, null);++ return skill && skill.audience === 'public' ? detailPage(req, null) : null;++ };return r;}@@@ -39,6 +39,12 @@ async function start() {console.log(`[Codes] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);console.log(`[Codes] ${ctx.harnesses.list().length} harnesses in the catalog; the developer console's addresses redirect to openvibe.services`);});++ // Announce the public revision on deploy/start. Shared sends batches of 100, one second apart.++ if (ctx.indexnow.enabled) ctx.publicPages().then(pages => {++ ctx.indexnow.pingSoon(pages.map(p => p.path));++ console.log(`[Codes] IndexNow queued ${pages.length} public URLs (100 per batch)`);++ }).catch(() => console.warn('[Codes] IndexNow public-page enumeration failed'));++ else console.log('[Codes] IndexNow disabled: no configured key');server.keepAliveTimeout = 65_000;// Request telemetry (openvibe-sdk/service, plan T1): one aggregated sample per route template, method and status// class each minute, stored in this service's telemetry_samples table; rows older than 14 days go daily.nnew file mode 100644@@@ -0,0 +1,39 @@++'use strict';++// Public metadata is applied to HTML and Markdown through the same layout.++const META = {++ '/': ['OpenVibe.Codes: code with any agent', 'Run coding agents with your own plans and keys. Start tasks from any browser, follow their work, and review the changes with OpenVibe Codes.'],++ '/start': ['Get started with coding agents', 'Install the OpenVibe Codes harness, connect your coding agents, and run tasks locally with permissions, hand-offs and a saved event stream.'],++ '/run': ['Run locally', 'Run coding agents locally from any browser. Connect your computer, describe a task, and watch your agents return a patch, branch or pull request.'],++ '/harnesses': ['The coding agents', 'Compare the coding agents available in OpenVibe Codes, including their models, capabilities, prices and limits, and learn how tasks are routed.'],++ '/skills': ['Skills for coding agents', 'Browse public skills, rules and MCP servers for coding agents. Version and sync them into Claude Code, Codex, OpenCode and Command Code.'],++ '/improve': ['Improve OpenVibe', 'Choose an open-source OpenVibe repository, describe a change, and use a coding agent on your computer to prepare a reviewed pull request.'],++ '/updates': ['What shipped', 'Read the OpenVibe Codes update log for changes to coding agents, local runs, the harness, skills and the browser workspace.'],++ '/policy': ['Community', 'Read the OpenVibe contributor guides and community policies, including the code of conduct, contributor ladder and moderation process.'],++};++function metadata(path, o) {++ const fixed = META[path];++ const title = fixed ? fixed[0] : String(o.title || 'OpenVibe Codes').slice(0, 43);++ const description = fixed ? fixed[1] : String(o.description || `${o.title}: contributor guidance and community policy for OpenVibe Codes and its open-source repositories.`).slice(0, 155);++ return { title, description };++}++function structuredData(path, o, canonical) {++ const nodes = [...(o.jsonLd || [])];++ if (path !== '/') nodes.push({ '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: [++ { '@type': 'ListItem', position: 1, name: 'OpenVibe Codes', item: `${o.config.baseUrl}/` },++ ...(o.crumbs || []).filter(c => c.href).map((c, i) => ({ '@type': 'ListItem', position: i + 2, name: c.label, item: new URL(c.href, o.config.baseUrl).href })),++ { '@type': 'ListItem', name: o.title, item: canonical },++ ].map((item, i) => ({ ...item, position: i + 1 })) });++ const steps = path === '/run' ? [++ ['Add your computer', 'Add your computer on openvibe.link/machines: one command, which installs OpenVibe Link with its job worker.'],++ ['Describe the task', 'Sign in here and describe the task.'],++ ['Get the result', 'Your machine clones the repository, runs the agent and sends the result back.'],++ ] : path === '/start' ? [++ ['Install', 'Install Node.js 22 or newer, then install openvibe-codes with the command shown on this page.'],++ ['The agents it can use', 'Install any of the listed agents; the harness uses what is there and says why the rest are not.'],++ ['Use it', 'Run openvibe-codes run with your task. The exit code follows the result: 0 when the last agent finished, 1 when it did not.'],++ ] : null;++ if (steps) nodes.push({ '@context': 'https://schema.org', '@type': 'HowTo', name: o.title, url: canonical,++ step: steps.map(([name, text], i) => ({ '@type': 'HowToStep', position: i + 1, name, text })) });++ return nodes;++}++module.exports = { META, metadata, structuredData };@@@ -25,7 +25,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');// the navbar's More (Shared 4.22). Search is the navbar's magnifier and What shipped is the footer's.const NAV = [{ label: 'Get started', href: '/start', icon: 'fa-play' },-- { label: 'Run on your machine', href: '/run', icon: 'fa-microchip' },++ { label: 'Run locally', href: '/run', icon: 'fa-microchip' },{ label: 'Agents', href: '/harnesses', icon: 'fa-robot' },{ label: 'Skills', href: '/skills', icon: 'fa-puzzle-piece' },{ label: 'Improve OpenVibe', href: '/improve', icon: 'fa-code', more: true },@@@ -34,7 +34,7 @@ const NAV = [// The workspace's own sections (Codes C4c). The shared Frame owns the page's navigation (openvibe.design/patterns/// single-navbar), so on the workspace pages the Frame navbar's links ARE these sections and the page carries no--// sidebar of its own. The product's own places (Get started, Run on your machine) move into More for a signed-in++// sidebar of its own. The product's own places (Get started, Run locally) move into More for a signed-in// person, so a workspace page has one navigation and not two.const WORKSPACE_NAV = [{ label: 'Home', href: '/', icon: 'fa-house' },@@@ -75,7 +75,11 @@ function renderPage(o) {const pathOnly = pathNow.split('?')[0];const c4Workspace = pathOnly === '/' || /^\/(tasks|conductor|approvals|agents|machines|memory|knowledge|usage|settings)(\/|$)/.test(pathOnly);const loginNext = encodeURIComponent(pathNow);-- const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;++ const canonical = `${o.config.baseUrl}${pathOnly}`;++ if ((o.index || o.agent) && !signedIn) {++ o = { ...o, ...require('../public-seo').metadata(pathOnly, o) };++ o.jsonLd = require('../public-seo').structuredData(pathOnly, o, canonical);++ }// One navigation per page. On the workspace pages a signed-in person's navbar carries the workspace sections, with// the current one active, and the product's own places fold into More; a visitor who is not signed in keeps the// product's links, because the sections hold the visitor's own work and a visitor has none yet.@@@ -119,7 +123,7 @@ function renderPage(o) {const scripts = (o.sharedScripts || []).map((name) => html`<script src="${ovServe.url(name)}" defer></script>`);if (String(o.body || '').includes('data-ov-machines')) scripts.push(raw('<script src="/shared/machines.js" defer></script>'));scripts.push(...(o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`));-- return shell.page({++ const document = shell.page({name: SITE_NAME,lang: 'en',// The workspace pages carry their own phone tab bar, so the page's bottom inset is theirs (C4c).@@@ -129,6 +133,8 @@ function renderPage(o) {siteName: SITE_NAME,description: o.description || 'The open coding-agent harness: Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API and your own models behind one router, and the way to improve OpenVibe itself.',canonical,++ image: new URL(require('openvibe-shared/social').url('codes'), o.config.baseUrl).href,++ imageAlt: 'OpenVibe Codes — coding agents',robots: o.index ? 'index, follow' : 'noindex, nofollow',// A page with a Markdown twin (o.agent) advertises it in the head and the Link header; the twin itself// is server/agent-pages.js.@@@ -163,6 +169,8 @@ document.addEventListener('DOMContentLoaded', function () {});</script>`,});++ // Footer groups follow the page h1 independently of its final content heading.++ return document.replace(/<footer\b[\s\S]*?<\/footer>/g, footer => footer.replace(/<(\/?)h3\b/g, '<$1h2'));}function render(v) { return require('./html').render(v); }@@@ -172,7 +180,8 @@ function send(res, status, o) {// A page rendered for a signed-in person names them in the account bar: never shared caches.const personal = o.viewer && o.viewer.kind === 'user';if (!res.get('Cache-Control')) res.set('Cache-Control', !personal && o.cache ? o.cache : cache.htmlHeaders({ private: true }));-- res.set('Vary', 'Cookie');++ res.vary('Cookie');++ res.vary('Accept');if (o.agent) {const site = String(o.config.baseUrl).replace(/\/+$/, '');res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(`${site}${String(o.path || '/').split('?')[0]}`, `${site}/llms.txt`).header);@@@ -35,7 +35,7 @@ const split = (links) => ({assert.strictEqual(page.status, 200);const { top, more } = split(navbarOf(page.text).links);assert.deepStrictEqual(top, [], 'the navbar carries no sections');-- assert.ok(more.includes('Get started') && more.includes('Run on your machine'), 'only the product’s places, in More');++ assert.ok(more.includes('Get started') && more.includes('Run locally'), 'only the product’s places, in More');assert.ok(page.text.includes('<aside class="ovk-sidebar" id="ch-rail"'), 'the rail is on the page');assert.ok(page.text.includes('<a class="ovk-sidebar-link is-here" href="/agents" aria-current="page">'), 'and marks the page the person is on, a row of the network’s sidebar');assert.ok(!page.text.includes('c4-side') && !page.text.includes('c4-layout'), 'no other sidebar');@@@ -52,7 +52,7 @@ const split = (links) => ({const page = await t.get('/tasks');assert.strictEqual(page.status, 200);const { top, more } = split(navbarOf(page.text).links);-- assert.ok(top.includes('Get started') && top.includes('Run on your machine'), 'the product’s places are at the top');++ assert.ok(top.includes('Get started') && top.includes('Run locally'), 'the product’s places are at the top');assert.ok(!top.includes('Tasks'), 'and the workspace’s sections are not');assert.ok(more.includes('Improve OpenVibe'), 'the rest folds into More');});@@@ -61,7 +61,7 @@ const split = (links) => ({const page = await t.get('/harnesses', { as: ada });assert.strictEqual(page.status, 200);const { top } = split(navbarOf(page.text).links);-- assert.ok(top.includes('Get started') && top.includes('Run on your machine'), 'the product’s places are at the top');++ assert.ok(top.includes('Get started') && top.includes('Run locally'), 'the product’s places are at the top');assert.ok(!top.includes('Machines'), 'and the workspace’s sections are not');});@@@ -93,13 +93,72 @@ const PRIVATE = ['/projects', '/staff', '/auth/', '/oauth/test-callback'];assert.ok(!r.text.includes('https://openvibe.codes/api/'), 'api path in llms-full.txt');});++ await check('every public page: unique bounded metadata, social cards, structured data and readable HTML', async () => {++ const sitemap = await t.get('/sitemap.xml');++ const llms = await t.get('/llms.txt');++ const urls = [...sitemap.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map(m => m[1]);++ const titles = new Set(), descriptions = new Set();++ for (const url of urls) {++ const path = new URL(url).pathname;++ const r = await t.get(path);++ assert.equal(r.status, 200, path);++ const title = /<title>(.*?)<\/title>/.exec(r.text)[1];++ const description = /<meta name="description" content="([^"]*)"/.exec(r.text)[1];++ assert.ok(title.length <= 60 && !titles.has(title), `${path}: title ${title}`);++ assert.ok(description.length <= 155 && !descriptions.has(description), `${path}: description ${description}`);++ titles.add(title); descriptions.add(description);++ assert.ok(r.text.includes(`rel="canonical" href="${url}"`), path);++ assert.ok(!/name="robots" content="noindex/.test(r.text), path);++ for (const key of ['og:title', 'og:description', 'og:image', 'twitter:card', 'twitter:image']) assert.ok(r.text.includes(`="${key}"`), `${path}: ${key}`);++ assert.equal((r.text.match(/<h1\b/g) || []).length, 1, path);++ const headings = [...r.text.matchAll(/<h([1-6])\b/g)].map(m => Number(m[1]));++ headings.forEach((h, i) => { if (i) assert.ok(h <= headings[i - 1] + 1, `${path}: heading skips to h${h}`); });++ for (const image of r.text.matchAll(/<img\b[^>]*>/g)) assert.match(image[0], /\balt="[^"]*"/, path);++ const nodes = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map(m => JSON.parse(m[1]));++ const types = nodes.map(n => n['@type']);++ assert.ok(types.includes(path === '/' ? 'SoftwareApplication' : 'BreadcrumbList'), `${path}: ${types}`);++ if (['/start', '/run'].includes(path)) assert.ok(types.includes('HowTo'), path);++ assert.ok(!types.includes('FAQPage'), 'no FAQ is shown');++ assert.ok(llms.text.includes(`](${url})`), `llms missing ${path}`);++ }++ });++ await check('Markdown negotiation uses complete public text, including run and skills', async () => {++ for (const [path, expected] of [['/start', 'Permission levels'], ['/run', 'Run coding agents locally, started from any browser'], ['/skills', 'The gallery']]) {++ const r = await t.get(path, { headers: { Accept: 'text/markdown' } });++ assert.equal(r.status, 200);++ assert.match(r.headers.get('content-type'), /^text\/markdown/);++ assert.ok(r.text.includes(expected), path);++ assert.ok(!r.text.includes('<html'), path);++ }++ const full = await t.get('/llms-full.txt');++ assert.ok(full.text.includes('Permission levels') && full.text.includes('Where runs are kept'));++ });++ await check('crawler rules protect private paths for search and AI crawlers', async () => {++ const r = await t.get('/robots.txt');++ // Shared repeats public Allow and private exclusions for its search/AI group.++ assert.ok(r.text.includes('User-agent: *'));++ const groups = r.text.split(/\n\s*\n/).filter(g => g.includes('User-agent:'));++ for (const group of groups) {++ assert.ok(group.includes('Allow: /'));++ for (const path of ['/tasks', '/run/tasks', '/settings', '/auth/', '/api/']) assert.ok(group.includes(`Disallow: ${path}`));++ }++ for (const path of ['/tasks', '/run/tasks', '/settings', '/auth/', '/api/']) assert.ok(r.text.includes(`Disallow: ${path}`), path);++ for (const name of ['Googlebot', 'Bingbot', 'GPTBot', 'OAI-SearchBot', 'ClaudeBot', 'Claude-SearchBot', 'PerplexityBot', 'Google-Extended', 'Applebot']) {++ assert.ok(!r.text.includes(`User-agent: ${name}\nDisallow: /\n`), name);++ }++ });++await check('home page: WebSite + the site\'s primary type, the same for every crawler', async () => {const r = await t.get('/');assert.strictEqual(r.status, 200);const blocks = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));const types = blocks.map((b) => b['@type']);assert.ok(types.includes('WebSite'), `no WebSite node: ${types}`);-- assert.ok(types.includes('WebApplication'), `no primary-type node: ${types}`);++ assert.ok(types.includes('SoftwareApplication'), `no primary-type node: ${types}`);++ const app = blocks.find(b => b['@type'] === 'SoftwareApplication');++ assert.equal(app.applicationCategory, 'DeveloperApplication');++ assert.ok(app.operatingSystem);++ assert.equal(app.offers.price, '0');const site = blocks.find((b) => b['@type'] === 'WebSite');assert.strictEqual(site.url, 'https://openvibe.codes');assert.strictEqual(site.name, 'OpenVibe.Codes');@@@ -46,7 +46,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#maiassert.ok(head.includes('<link rel="canonical" href="https://openvibe.codes/">'), 'canonical');assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);-- assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage', 'Organization'], 'the home JSON-LD describes the workspace');++ assert.deepStrictEqual(ld, ['WebSite', 'SoftwareApplication', 'WebPage', 'Organization'], 'the home JSON-LD describes the workspace');for (const tag of ['<meta property="og:title"', 'data-ov-icon="codes"', '/css/codes.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {assert.ok(head.includes(tag), `head has ${tag}`);}nnew file mode 100644@@@ -0,0 +1,19 @@++'use strict';++const assert = require('assert');++const { createPublicUpdates } = require('../server/data/public-updates');++(async () => {++ let calls = 0;++ const entries = [{ subject: 'A deployed change', url: 'https://github.com/OpenVibers/OpenVibe.Codes/commit/abc', deployed_at: '2026-11-10T12:00:00Z' }];++ const read = createPublicUpdates({ base: 'https://example.test', fetchImpl: async (url, options) => {++ calls++;++ assert.ok(url.endsWith('/api/v1/changelog?service=codes&limit=50'));++ assert.ok(options.signal);++ return { ok: true, json: async () => ({ entries: [...entries, { subject: 'Bad link', url: 'javascript:alert(1)' }] }) };++ } });++ assert.deepEqual(await read(), entries);++ assert.deepEqual(await read(), entries);++ assert.equal(calls, 1, 'feed is cached');++ const failed = createPublicUpdates({ base: 'https://example.test', fetchImpl: async () => { throw new Error('offline'); } });++ assert.deepEqual(await failed(), []);++ console.log('public updates: cached server-rendered feed, safe links and failure fallback pass');++})().catch(error => { console.error(error); process.exitCode = 1; });@@@ -1,6 +1,6 @@'use strict';/**-- * Run on your machine (server/http/run.js, plan T16): a guest is asked to sign in; a person with no project they may++ * Run locally (server/http/run.js, plan T16): a guest is asked to sign in; a person with no project they may* run jobs in creates one; a task is checked like the job runner checks it, recorded, and started as an OpenVibe Run job* for that person in their project (the task id is the idempotency key); its page streams the job's lines through* Codes and then shows the summary, the changed files and the patch; another person never sees it; cancelling reaches@@@ -37,7 +37,7 @@ const { boot, check, done } = require('./helpers/boot');await check('a guest is told what it is and asked to sign in', async () => {const r = await t.get('/run');assert.strictEqual(r.status, 200);-- assert.ok(r.text.includes('Run a coding task on your own machine') && r.text.includes('/auth/login?next=%2Frun') && r.text.includes('openvibe.link/machines'));++ assert.ok(r.text.includes('Run coding agents locally, started from any browser') && r.text.includes('/auth/login?next=%2Frun') && r.text.includes('openvibe.link/machines'));});let project;@@@ -72,6 +72,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,'server/report.js': 'OpenVibe.Report (OV_REPORT_INTERNAL_URL, configured) for the report of a finished task, and the Network token endpoint (OV_NETWORK_INTERNAL_URL, configured) for the Codes service token; the paths are fixed, no URL from a person','server/owner-inputs-tests.js': 'the admin panel\'s Test button for the owner\'s keys, owner-only (codes.config.manage): fixed origins only (api.github.com/rate_limit, github.com/login/oauth/access_token, registry.npmjs.org/-/whoami) ; one read-only call each, never a URL from a person','server/events/outbox.js': 'OpenVibe.Events publish (EVENTS_URL, configured) of fixed codes.task.* payloads; no URL from a person',++ 'server/data/public-updates.js': 'the configured Network base (config.networkUrl) public changelog feed at the fixed path /api/v1/changelog?service=codes&limit=50 for the /updates page; no URL from a person',};const root = path.join(__dirname, '..');const found = [];@@@ -58,6 +58,20 @@ const { boot, check, done } = require('./helpers/boot');assert.strictEqual(anon.json().skill.audience, 'link');});++ await check('discovery includes only public skills and Markdown never exposes private or link records', async () => {++ for (const path of ['/sitemap.xml', '/llms.txt', '/llms-full.txt']) {++ const result = await t.get(path);++ assert.equal(result.status, 200, path);++ assert.ok(result.text.includes(`/skills/${pub.id}`), path);++ assert.ok(!result.text.includes(`/skills/${priv.id}`), path);++ assert.ok(!result.text.includes(`/skills/${link.id}`), path);++ }++ const markdown = await t.get(`/skills/${pub.id}`, { headers: { Accept: 'text/markdown' } });++ assert.match(markdown.headers.get('content-type'), /^text\/markdown/);++ assert.ok(markdown.text.includes('Public ship'));++ for (const skill of [priv, link]) assert.equal((await t.get(`/skills/${skill.id}.md`)).status, 404);++ });++await check('versions grow and a rollback copies the old body into a new version', async () => {const v2 = await api(`/api/v1/skills/${pub.id}/versions`, {as: ada, method: 'POST', json: { body: skillBody('public-ship', 'The second version.'), summary: 'Second' },
@@ -23,7 +23,7 @@ One place to send a coding task to the agent that fits it — by what the task n
@@ -23,7 +23,7 @@ One place to send a coding task to the agent that fits it — by what the task n
| The harness | `openvibe-codes` ([bin/openvibe-codes.js](bin/openvibe-codes.js), [harness/](harness/)) | Runs on a person's own machine with their CLIs and keys; nothing goes through OpenVibe. See **Use the harness** below. |
| The harness | `openvibe-codes` ([bin/openvibe-codes.js](bin/openvibe-codes.js), [harness/](harness/)) | Runs on a person's own machine with their CLIs and keys; nothing goes through OpenVibe. See **Use the harness** below. |
| Home | `/` | What the harness is, the agents in the catalog, how a task finds its agent, Improve OpenVibe, and an honest table of what works today and what comes next. |
| Home | `/` | What the harness is, the agents in the catalog, how a task finds its agent, Improve OpenVibe, and an honest table of what works today and what comes next. |
| Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. |
| Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. |
-| Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.link/machines), whose OpenVibe Link runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). The task page shows the **timeline of attempts** above the agents' **session** (each attempt with its why: "Codex hit its limit, so Command Code took over with what it had done") and the session with openvibe-shared/agent-session.js: steps grouped, each step's command, change and output (live while it runs), helpers nested, hand-offs and the plan; after the end, the session from `codes_session_lines` (migration 0007): the lines are kept as the stream arrives, back-filled from the machine's final log, and replayed at `GET /run/tasks/:id/session.json?after=` (the owner, or a service with `codes.task.read` and `X-OV-Subject`); tasks from before keep the old `log`. Run's `run.job.*` events finalize a task at `POST /internal/events` ([server/events-consumer.js](server/events-consumer.js), no polling but a 10-minute fallback), and every state change is published as `codes.task.queued|running|succeeded|failed|cancelled` from an outbox ([server/events/outbox.js](server/events/outbox.js), contracts 0.232.0). |
+| Run locally | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.link/machines), whose OpenVibe Link runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). The task page shows the **timeline of attempts** above the agents' **session** (each attempt with its why: "Codex hit its limit, so Command Code took over with what it had done") and the session with openvibe-shared/agent-session.js: steps grouped, each step's command, change and output (live while it runs), helpers nested, hand-offs and the plan; after the end, the session from `codes_session_lines` (migration 0007): the lines are kept as the stream arrives, back-filled from the machine's final log, and replayed at `GET /run/tasks/:id/session.json?after=` (the owner, or a service with `codes.task.read` and `X-OV-Subject`); tasks from before keep the old `log`. Run's `run.job.*` events finalize a task at `POST /internal/events` ([server/events-consumer.js](server/events-consumer.js), no polling but a 10-minute fallback), and every state change is published as `codes.task.queued|running|succeeded|failed|cancelled` from an outbox ([server/events/outbox.js](server/events/outbox.js), contracts 0.232.0). |
| Knowledge | `/knowledge`, `GET/POST /api/v1/knowledge` ([server/http/knowledge.js](server/http/knowledge.js), [server/domain/knowledge.js](server/domain/knowledge.js)) | Workspace knowledge (Know K5): when a run ends, Codes captures what it taught — the agent's `Learned:` lines, a failure with its reason, the command that failed and the commands the repository uses — redacts secrets and stores it as a private row with its source run (`codes_knowledge`, migration 0025). The next run's job carries the relevant rows in its prompt before the brief, for every harness ([harness/job.js](harness/job.js) `agentPrompt`), so a pin bump, a test command or a trap is recalled without re-reading files. The page shows every row with its run, and edits, pins, shares (with a Network project's team) and deletes it; private by default. The same rows are exported and erased with the account ([server/account-data.js](server/account-data.js)); `ov-know`'s `knowledge_search`/`knowledge_add` ([know/knowledge.js](https://github.com/OpenVibers/openvibe-agents)) answer them for the person's own agents. |
| Knowledge | `/knowledge`, `GET/POST /api/v1/knowledge` ([server/http/knowledge.js](server/http/knowledge.js), [server/domain/knowledge.js](server/domain/knowledge.js)) | Workspace knowledge (Know K5): when a run ends, Codes captures what it taught — the agent's `Learned:` lines, a failure with its reason, the command that failed and the commands the repository uses — redacts secrets and stores it as a private row with its source run (`codes_knowledge`, migration 0025). The next run's job carries the relevant rows in its prompt before the brief, for every harness ([harness/job.js](harness/job.js) `agentPrompt`), so a pin bump, a test command or a trap is recalled without re-reading files. The page shows every row with its run, and edits, pins, shares (with a Network project's team) and deletes it; private by default. The same rows are exported and erased with the account ([server/account-data.js](server/account-data.js)); `ov-know`'s `knowledge_search`/`knowledge_add` ([know/knowledge.js](https://github.com/OpenVibers/openvibe-agents)) answer them for the person's own agents. |
| Skills hub | `/skills`, `/api/v1/skills`, `/api/v1/me/skills` ([server/http/skills-pages.js](server/http/skills-pages.js), [server/domain/skills.js](server/domain/skills.js)) | Skills, rules, MCP servers, commands and hooks kept per person and per project, versioned (`codes_skills`, `codes_skill_versions`, migration 0028): every save is an immutable version, a rollback copies an old version into a new one, and the body is rendered through the markup gate. Sharing is `openvibe-sdk/share`'s four audiences (`public`, `link`, `people`, `private`); only `public` is in the gallery, and the share sheet is the SDK's at `/api/v1/skill/:id/share`. A person adds a record to their sync, and `openvibe-link skills sync` writes it into Claude Code, Codex, OpenCode and Command Code inside one marked block, with a dry run, a diff and an undo. |
| Skills hub | `/skills`, `/api/v1/skills`, `/api/v1/me/skills` ([server/http/skills-pages.js](server/http/skills-pages.js), [server/domain/skills.js](server/domain/skills.js)) | Skills, rules, MCP servers, commands and hooks kept per person and per project, versioned (`codes_skills`, `codes_skill_versions`, migration 0028): every save is an immutable version, a rollback copies an old version into a new one, and the body is rendered through the markup gate. Sharing is `openvibe-sdk/share`'s four audiences (`public`, `link`, `people`, `private`); only `public` is in the gallery, and the share sheet is the SDK's at `/api/v1/skill/:id/share`. A person adds a record to their sync, and `openvibe-link skills sync` writes it into Claude Code, Codex, OpenCode and Command Code inside one marked block, with a dry run, a diff and an undo. |
| The agents | `/harnesses` | Every harness in [`server/data/harness-offers.json`](server/data/harness-offers.json) (validated at boot as `platform.harness-offer@1`, each model as `platform.agent-offer@1`) with its provider, address kind, what it can do, price and limits, and its models with context limits and token prices. No local path is ever shown. |
| The agents | `/harnesses` | Every harness in [`server/data/harness-offers.json`](server/data/harness-offers.json) (validated at boot as `platform.harness-offer@1`, each model as `platform.agent-offer@1`) with its provider, address kind, what it can do, price and limits, and its models with context limits and token prices. No local path is ever shown. |
@@ -33,7 +33,7 @@ One place to send a coding task to the agent that fits it — by what the task n
@@ -33,7 +33,7 @@ One place to send a coding task to the agent that fits it — by what the task n
| Community | `/policy`, `/policy/<document>` | The code of conduct ([CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)), the contribution guide ([CONTRIBUTING.md](CONTRIBUTING.md)), the [contributor ladder](docs/governance/contributor-ladder.md) and the [moderation policy](docs/governance/moderation.md), rendered from those files. All four are **drafts pending owner review**: marked at the top, shown with a notice, not indexed; deleting the draft line publishes one. They govern the repositories and the apps and releases on OpenVibe.Services alike. |
| Community | `/policy`, `/policy/<document>` | The code of conduct ([CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)), the contribution guide ([CONTRIBUTING.md](CONTRIBUTING.md)), the [contributor ladder](docs/governance/contributor-ladder.md) and the [moderation policy](docs/governance/moderation.md), rendered from those files. All four are **drafts pending owner review**: marked at the top, shown with a notice, not indexed; deleting the draft line publishes one. They govern the repositories and the apps and releases on OpenVibe.Services alike. |
| Moved | `/projects`, `/docs`, `/oauth`, `/tools`, `/manifests`, `/releases`, `/apps`, `/staff`, `/policy/{rfc,compatibility,licensing,transparency}`, `/api/v1/*` but the agent and task APIs | Permanent redirects to the same path and query on `https://openvibe.services`: 301 for GET and HEAD, 308 for anything else (the method and body are kept). `/docs/harnesses` goes to `/harnesses` here. [`server/http/moved.js`](server/http/moved.js). |
| Moved | `/projects`, `/docs`, `/oauth`, `/tools`, `/manifests`, `/releases`, `/apps`, `/staff`, `/policy/{rfc,compatibility,licensing,transparency}`, `/api/v1/*` but the agent and task APIs | Permanent redirects to the same path and query on `https://openvibe.services`: 301 for GET and HEAD, 308 for anything else (the method and body are kept). `/docs/harnesses` goes to `/harnesses` here. [`server/http/moved.js`](server/http/moved.js). |
| Machine | `/api/health`, `/api/ready`, `/release.json`, `/metrics` | Readiness is truthful (the database and the catalog required; JWKS, the OAuth client and Valkey reported as optional checks). `/metrics` answers loopback callers only. |
| Machine | `/api/health`, `/api/ready`, `/release.json`, `/metrics` | Readiness is truthful (the database and the catalog required; JWKS, the OAuth client and Valkey reported as optional checks). `/metrics` answers loopback callers only. |
-| Crawl artifacts | `/robots.txt`, `/sitemap.xml`, `/llms.txt`, `/llms-full.txt` | Built with openvibe-shared/seo. robots.txt disallows sign-in and the API and leaves the moved addresses crawlable, so engines follow their 301s to openvibe.services; the sitemap lists the public pages with STATUS.json's content date (never "today"). |
+| Crawl artifacts | `/robots.txt`, `/sitemap.xml`, `/llms.txt`, `/llms-full.txt` | Built with openvibe-shared/seo. robots.txt disallows sign-in and the API and leaves the moved addresses crawlable, so engines follow their 301s to openvibe.services; the sitemap lists public pages, repository contribution pages and public skills with committed or content-derived dates (never "today"). `/llms-full.txt` and `Accept: text/markdown` use the anonymous page renderers. Deploy/start announces public URLs through openvibe-shared/indexnow when `INDEXNOW_KEY` is configured (100 URLs per batch). |
Everything is server-rendered and usable without JavaScript.
Everything is server-rendered and usable without JavaScript.
@@ -77,7 +77,7 @@ More: [docs/cli.md](docs/cli.md).
@@ -77,7 +77,7 @@ More: [docs/cli.md](docs/cli.md).
## What comes next
## What comes next
-1. **A hosted runner** — the same harness on OpenVibe.Run workers for people who would rather not run agents themselves, with budgets per person and per project, metered through Billing. (Tasks from the web already run on the person's own machine: see "Run on your machine" above.)
+1. **A hosted runner** — the same harness on OpenVibe.Run workers for people who would rather not run agents themselves, with budgets per person and per project, metered through Billing. (Tasks from the web already run on the person's own machine: see "Run locally" above.)
2. **OpenVibe.Actor uses Codes** — Actor is OpenVibe's general agent (owner, 2026-10-08); its coding steps run through this harness.
2. **OpenVibe.Actor uses Codes** — Actor is OpenVibe's general agent (owner, 2026-10-08); its coding steps run through this harness.
3. **Improve OpenVibe as a flow** — repository → change → tests → independent review → pull request, never straight to `main`.
3. **Improve OpenVibe as a flow** — repository → change → tests → independent review → pull request, never straight to `main`.
4. **The OpenVibe agent pool on this package** — `~/openvibe/agents` (the pool that builds OpenVibe) replaces its own adapters with these.
4. **The OpenVibe agent pool on this package** — `~/openvibe/agents` (the pool that builds OpenVibe) replaces its own adapters with these.
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
/*
/*
- * Run on your machine (server/http/run.js): the live lines of a running task and the colours of a finished patch.
+ * Run locally (server/http/run.js): the live lines of a running task and the colours of a finished patch.
* With openvibe-shared/agent-session.js on the page (the task page loads it), the lines become the agents' session:
* With openvibe-shared/agent-session.js on the page (the task page loads it), the lines become the agents' session:
* steps grouped, each step's command, change and output (live), helpers, hand-offs and the plan; a finished task's
* steps grouped, each step's command, change and output (live), helpers, hand-offs and the plan; a finished task's
* page shows the session its result kept. Without it, the plain list below.
* page shows the session its result kept. Without it, the plain list below.
@@ -11,10 +11,12 @@ function createAgentPages(ctx, publicViews, { home = null } = {}) {
@@ -11,10 +11,12 @@ function createAgentPages(ctx, publicViews, { home = null } = {}) {
async function renderPublicPage(path) {
async function renderPublicPage(path) {
// / is the C4 front door a visitor gets (server/http/c4.js visitorHome), not the old marketing home.
// / is the C4 front door a visitor gets (server/http/c4.js visitorHome), not the old marketing home.
if (path === '/') return twin((home && home()) || views.home(), path);
if (path === '/') return twin((home && home()) || views.home(), path);
+ if (path === '/run') return twin(require('./http/run').guestRunPage(), path);
+ if (path === '/skills' || path.startsWith('/skills/')) return twin(await ctx.skillPages.publicView(path), path);
if (path === '/start') return twin(views.start(), path);
if (path === '/start') return twin(views.start(), path);
if (path === '/harnesses') return twin(views.harnesses(), path);
if (path === '/harnesses') return twin(views.harnesses(), path);
if (path === '/improve') return twin(views.improve(), path);
if (path === '/improve') return twin(views.improve(), path);
- if (path === '/updates') return twin(views.updates(), path);
+ if (path === '/updates') return twin(await views.updates(), path);
if (path === '/policy') return twin(views.policy(), path);
if (path === '/policy') return twin(views.policy(), path);
let m = /^\/policy\/([^/]+)$/.exec(path);
let m = /^\/policy\/([^/]+)$/.exec(path);
if (m) return twin(views.policyDoc(decodeURIComponent(m[1])), path);
if (m) return twin(views.policyDoc(decodeURIComponent(m[1])), path);
@@ -163,6 +163,8 @@ async function createApp(opts = {}) {
@@ -163,6 +163,8 @@ async function createApp(opts = {}) {
tasks.attachBindings(ctx.prBindings);
tasks.attachBindings(ctx.prBindings);
const app = express();
const app = express();
+ ctx.indexnow = require('openvibe-shared/indexnow').createIndexNow({ host: config.baseUrl, key: (opts.env || process.env).INDEXNOW_KEY || '', log: (message) => log.warn(message) });
+ if (ctx.indexnow.enabled) app.use(ctx.indexnow.keyFile);
aiCatalogConfig.mount(app);
aiCatalogConfig.mount(app);
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// GET /openapi.json (SEO-3): this service's public API as OpenAPI 3.1, generated from the contracts
// (openvibe-contracts); the operations they mark internal-only are left out.
// (openvibe-contracts); the operations they mark internal-only are left out.
@@ -280,7 +282,7 @@ async function createApp(opts = {}) {
@@ -280,7 +282,7 @@ async function createApp(opts = {}) {
}));
}));
// ── API ─────────────────────────────────────────────────
// ── API ─────────────────────────────────────────────────
- // Run on your machine (plan T16): coding tasks on the person's own machine, through OpenVibe Run, from the /run
+ // Run locally (plan T16): coding tasks on the person's own machine, through OpenVibe Run, from the /run
// pages below or, for a first-party service acting for the person, from /api/v1/tasks (codes.task.*).
// pages below or, for a first-party service acting for the person, from /api/v1/tasks (codes.task.*).
// ── /api/v1/task/:id/share — the one Share sheet's API (openvibe-sdk/share) ──
// ── /api/v1/task/:id/share — the one Share sheet's API (openvibe-sdk/share) ──
// GET/PUT .../share, .../share/requests, .../share/transfer[/accept|decline|cancel], GET /api/v1/share/people
// GET/PUT .../share, .../share/requests, .../share/transfer[/accept|decline|cancel], GET /api/v1/share/people
@@ -328,22 +330,27 @@ async function createApp(opts = {}) {
@@ -328,22 +330,27 @@ async function createApp(opts = {}) {
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.CODES_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false })); // per address; tests raise it
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.CODES_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false })); // per address; tests raise it
- app.use(createSkillPageRoutes({ config, skills: ctx.skills }));
+ ctx.skillPages = createSkillPageRoutes({ config, skills: ctx.skills });
+ ctx.publicFetch = fetchImpl || globalThis.fetch;
const pageRoutes = createPageRoutes(ctx);
const pageRoutes = createPageRoutes(ctx);
+ ctx.publicPages = pageRoutes.publicPages;
// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the public
// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json and the Markdown twins of the public
// pages. The pages themselves keep their own routes — a signed-in visitor's account bar and forms are theirs
// pages. The pages themselves keep their own routes — a signed-in visitor's account bar and forms are theirs
// — so only the manifest and twin requests reach this mount; the pages advertise the twin themselves.
// — so only the manifest and twin requests reach this mount; the pages advertise the twin themselves.
let c4Routes = null; // created below, after the chat app's routes (the twin of / reads its front door)
let c4Routes = null; // created below, after the chat app's routes (the twin of / reads its front door)
const agentLayer = express();
const agentLayer = express();
+ ctx.renderPublicPage = require('./agent-pages').createAgentPages(ctx, pageRoutes.publicViews, { home: () => c4Routes && c4Routes.visitorHome() }).renderPublicPage;
require('openvibe-shared/agent-layer').mount(agentLayer, {
require('openvibe-shared/agent-layer').mount(agentLayer, {
descriptor: require('./agent-descriptor'),
descriptor: require('./agent-descriptor'),
- render: require('./agent-pages').createAgentPages(ctx, pageRoutes.publicViews, { home: () => c4Routes && c4Routes.visitorHome() }).renderPublicPage,
+ render: ctx.renderPublicPage,
});
});
app.use((req, res, next) => {
app.use((req, res, next) => {
const twin = req.path.endsWith('.md') || req.query.format === 'markdown' || String(req.headers.accept || '').includes('text/markdown');
const twin = req.path.endsWith('.md') || req.query.format === 'markdown' || String(req.headers.accept || '').includes('text/markdown');
if (req.path !== '/.well-known/agent.json' && !twin) return next();
if (req.path !== '/.well-known/agent.json' && !twin) return next();
return agentLayer(req, res, next);
return agentLayer(req, res, next);
});
});
+ app.use(ctx.skillPages);
+ app.get('/community', (_req, res) => res.redirect(301, '/policy'));
// The chat app (L31 CH1) answers a signed-in person's / and /tasks/:id; everyone else falls through to C4.
// The chat app (L31 CH1) answers a signed-in person's / and /tasks/:id; everyone else falls through to C4.
app.use(require('./http/chat').createChatRoutes(ctx));
app.use(require('./http/chat').createChatRoutes(ctx));
app.use(require('./http/git').createGitRoutes(ctx)); // after the chat app: it renders in ctx.chatShell
app.use(require('./http/git').createGitRoutes(ctx)); // after the chat app: it renders in ctx.chatShell
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+'use strict';
+// Public feed only: bounded wait, five-minute cache, and the last good response on failure.
+function createPublicUpdates({ base, fetchImpl = globalThis.fetch }) {
+ let cached = [], expires = 0, pending = null;
+ return async function updates() {
+ if (Date.now() < expires) return cached;
+ if (pending) return pending;
+ pending = (async () => {
+ try {
+ const response = await fetchImpl(`${base}/api/v1/changelog?service=codes&limit=50`, { signal: AbortSignal.timeout(2000), headers: { Accept: 'application/json' } });
+ if (response.ok) {
+ const data = await response.json();
+ if (Array.isArray(data.entries)) cached = data.entries.filter(e => typeof e.subject === 'string' && /^https:\/\/github.com\/OpenVibers\//.test(e.url || '')).slice(0, 50);
+ }
+ } catch { /* The public page still renders the last successful feed. */ }
+ expires = Date.now() + 300000;
+ return cached;
+ })().finally(() => { pending = null; });
+ return pending;
+ };
+}
+module.exports = { createPublicUpdates };
@@ -301,7 +301,11 @@ function createSkills({ store, sharing, log = console }) {
@@ -301,7 +301,11 @@ function createSkills({ store, sharing, log = console }) {
.map((r) => ({ skill_id: r.skill_id, project: r.project, created_at: r.created_at instanceof Date ? r.created_at.toISOString() : String(r.created_at) }));
.map((r) => ({ skill_id: r.skill_id, project: r.project, created_at: r.created_at instanceof Date ? r.created_at.toISOString() : String(r.created_at) }));
}
}
- return { create, get, listVersions, gallery, mine, addVersion, rollback, setAudience, members, addMember, removeMember, assign, unassign, manifest, assignments };
+ async function publicPages() {
+ const rows = await db.many(`SELECT s.id, s.title, v.summary, s.updated_at ${FROM} WHERE sh.audience = 'public' ORDER BY s.id`);
+ return rows.map(s => ({ path: `/skills/${s.id}`, title: s.title, text: s.summary, lastmod: new Date(s.updated_at).toISOString().slice(0, 10) }));
+ }
+ return { publicPages, create, get, listVersions, gallery, mine, addVersion, rollback, setAudience, members, addMember, removeMember, assign, unassign, manifest, assignments };
}
}
module.exports = { createSkills, SkillError, KINDS, AUDIENCES, TOOLS, MAX_BODY, MAX_TITLE, MAX_SUMMARY };
module.exports = { createSkills, SkillError, KINDS, AUDIENCES, TOOLS, MAX_BODY, MAX_TITLE, MAX_SUMMARY };
@@ -23,14 +23,14 @@ const SERVICES_ORIGIN = 'https://openvibe.services';
@@ -23,14 +23,14 @@ const SERVICES_ORIGIN = 'https://openvibe.services';
const DESCRIPTION = 'The open coding-agent harness: run a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or a model you host, with routing by capability and cost, hand-offs between agents, and the way to improve OpenVibe itself.';
const DESCRIPTION = 'The open coding-agent harness: run a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or a model you host, with routing by capability and cost, hand-offs between agents, and the way to improve OpenVibe itself.';
const { version: VERSION } = require('../../package.json');
const { version: VERSION } = require('../../package.json');
const INSTALL_HINT = `npm install -g https://codeload.github.com/OpenVibers/OpenVibe.Codes/tar.gz/refs/tags/v${VERSION}`;
const INSTALL_HINT = `npm install -g https://codeload.github.com/OpenVibers/OpenVibe.Codes/tar.gz/refs/tags/v${VERSION}`;
-const HARNESS_PAGES = ['/', '/start', '/harnesses', '/skills', '/improve'];
// Sign-in and the API are not for crawlers. The console's old addresses redirect to openvibe.services.
// Sign-in and the API are not for crawlers. The console's old addresses redirect to openvibe.services.
-const DISALLOW = ['/auth/', '/api/'];
+const DISALLOW = ['/auth/', '/api/', '/tasks', '/run/tasks', '/settings', '/internal/', '/conductor', '/approvals', '/machines', '/memory', '/knowledge', '/usage'];
-// The one line /llms-full.txt says about each fixed public page: a title and what the page serves.
+// Titles and one-line summaries for llms.txt. Full text uses the public HTML renderers.
// These are the pages publicPages() lists (and so the sitemap), so the two cannot name different sets.
// These are the pages publicPages() lists (and so the sitemap), so the two cannot name different sets.
const PAGE_TEXT = {
const PAGE_TEXT = {
'/': ['OpenVibe.Codes home', 'Code with any agent: the coding-agent harness, how a task is routed and handed off, what works today, and how to improve OpenVibe.'],
'/': ['OpenVibe.Codes home', 'Code with any agent: the coding-agent harness, how a task is routed and handed off, what works today, and how to improve OpenVibe.'],
+ '/run': ['Run locally', 'Start coding agents from any browser and run them on your own computer.'],
'/start': ['Get started with the coding-agent harness', 'Install openvibe-codes; the agents it can use and what each needs; the commands, permission levels, hand-offs, the event format and using it from your own code.'],
'/start': ['Get started with the coding-agent harness', 'Install openvibe-codes; the agents it can use and what each needs; the commands, permission levels, hand-offs, the event format and using it from your own code.'],
'/harnesses': ['The coding agents', 'Every harness the router can pick (Claude Code, Codex, OpenCode, Command Code, Aider, DeepSeek, your own model) with its models, capabilities, prices and limits.'],
'/harnesses': ['The coding agents', 'Every harness the router can pick (Claude Code, Codex, OpenCode, Command Code, Aider, DeepSeek, your own model) with its models, capabilities, prices and limits.'],
'/skills': ['The skills hub', 'Skills, rules and MCP servers kept per person and per project, versioned, shared through the four audiences, and synced into each coding tool on your own machine with a dry run and an undo.'],
'/skills': ['The skills hub', 'Skills, rules and MCP servers kept per person and per project, versioned, shared through the four audiences, and synced into each coding tool on your own machine with a dry run and an undo.'],
@@ -52,13 +52,13 @@ function siteUpdated() {
@@ -52,13 +52,13 @@ function siteUpdated() {
/**
/**
* The home page's JSON-LD: the site as a WebSite, the harness as the site's primary type (the shared kit's
* The home page's JSON-LD: the site as a WebSite, the harness as the site's primary type (the shared kit's
- * WebApplication) and the page that carries them. The same nodes for every crawler; nothing about the reader.
+ * SoftwareApplication) and the page that carries them. The same nodes for every crawler; nothing about the reader.
*/
*/
function homeJsonLd(config) {
function homeJsonLd(config) {
const site = String(config.baseUrl).replace(/\/+$/, '');
const site = String(config.baseUrl).replace(/\/+$/, '');
return [
return [
seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
- seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'DeveloperApplication', keywords: 'ai coding agent, coding agent harness, coding agent router, agent hand-off, claude code, codex, opencode, command code, deepseek, aider, self-hosted coding agent, open source coding harness, contribute to open source' }),
+ { ...seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'DeveloperApplication', keywords: 'ai coding agent, coding agent harness, coding agent router, agent hand-off, claude code, codex, opencode, command code, deepseek, aider, self-hosted coding agent, open source coding harness, contribute to open source' }), '@type': 'SoftwareApplication', operatingSystem: 'Linux, macOS, Windows', offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' } },
seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
];
];
}
}
@@ -67,6 +67,7 @@ function homeJsonLd(config) {
@@ -67,6 +67,7 @@ function homeJsonLd(config) {
function publicPages({ governance = [] }) {
function publicPages({ governance = [] }) {
const pages = [
const pages = [
{ path: '/', changefreq: 'weekly', priority: 1.0 },
{ path: '/', changefreq: 'weekly', priority: 1.0 },
+ { path: '/run', changefreq: 'weekly', priority: 0.9 },
{ path: '/start', changefreq: 'weekly', priority: 0.9 },
{ path: '/start', changefreq: 'weekly', priority: 0.9 },
{ path: '/harnesses', changefreq: 'weekly', priority: 0.9 },
{ path: '/harnesses', changefreq: 'weekly', priority: 0.9 },
{ path: '/skills', changefreq: 'weekly', priority: 0.9 },
{ path: '/skills', changefreq: 'weekly', priority: 0.9 },
@@ -75,6 +76,8 @@ function publicPages({ governance = [] }) {
@@ -75,6 +76,8 @@ function publicPages({ governance = [] }) {
{ path: '/policy', changefreq: 'monthly', priority: 0.4 },
{ path: '/policy', changefreq: 'monthly', priority: 0.4 },
];
];
for (const g of governance) if (!g.draft) pages.push({ path: `/policy/${g.slug}`, changefreq: 'monthly', priority: 0.3 });
for (const g of governance) if (!g.draft) pages.push({ path: `/policy/${g.slug}`, changefreq: 'monthly', priority: 0.3 });
+ const repos = require('./pages').repositories();
+ for (const repo of [...repos.running, ...repos.libraries]) pages.push({ path: `/improve/${repo.repository}`, changefreq: 'weekly', priority: 0.6 });
return pages;
return pages;
}
}
@@ -84,6 +87,7 @@ function describePage(pathname, { governance = [] }) {
@@ -84,6 +87,7 @@ function describePage(pathname, { governance = [] }) {
const m = /^\/policy\/([^/]+)$/.exec(pathname);
const m = /^\/policy\/([^/]+)$/.exec(pathname);
const g = m && governance.find((x) => x.slug === m[1]);
const g = m && governance.find((x) => x.slug === m[1]);
if (g) return { title: g.title, text: g.blurb || '' };
if (g) return { title: g.title, text: g.blurb || '' };
+ if (pathname.startsWith('/improve/')) { const name = pathname.split('/').pop(); return { title: `Improve ${name}`, text: `Contribute a change to ${name} with a coding agent on your computer.` }; }
return { title: pathname, text: '' };
return { title: pathname, text: '' };
}
}
@@ -108,7 +112,7 @@ function llmsTxt({ name, summary, details, sections = [], optional = [], base =
@@ -108,7 +112,7 @@ function llmsTxt({ name, summary, details, sections = [], optional = [], base =
if (details) out.push(String(details).trim(), '');
if (details) out.push(String(details).trim(), '');
for (const sec of [...sections, ...(optional.length ? [{ title: 'Optional', links: optional }] : [])]) {
for (const sec of [...sections, ...(optional.length ? [{ title: 'Optional', links: optional }] : [])]) {
if (!sec) continue;
if (!sec) continue;
- const links = (sec.links || []).map((l) => '- [' + l.title + '](' + twinOf(l.url, base) + ')' + (l.note ? ': ' + String(l.note).replace(/\s+/g, ' ').trim() : ''));
+ const links = (sec.links || []).map((l) => '- [' + l.title + '](' + (l.canonical ? l.url : twinOf(l.url, base)) + ')' + (l.note ? ': ' + String(l.note).replace(/\s+/g, ' ').trim() : ''));
const notes = (sec.notes || []).map((n) => String(n).replace(/\s+/g, ' ').trim());
const notes = (sec.notes || []).map((n) => String(n).replace(/\s+/g, ' ').trim());
if (!links.length && !notes.length) continue;
if (!links.length && !notes.length) continue;
out.push('## ' + sec.title, '');
out.push('## ' + sec.title, '');
@@ -124,6 +128,16 @@ function createDiscoveryRoutes(ctx) {
@@ -124,6 +128,16 @@ function createDiscoveryRoutes(ctx) {
const site = String(config.baseUrl).replace(/\/+$/, '');
const site = String(config.baseUrl).replace(/\/+$/, '');
const abs = (p) => `${site}${p}`;
const abs = (p) => `${site}${p}`;
const TEXT = cache.htmlHeaders({ maxAge: 3600 });
const TEXT = cache.htmlHeaders({ maxAge: 3600 });
+ const allPages = async () => {
+ const skills = ctx.skills && ctx.skills.publicPages ? await ctx.skills.publicPages() : [];
+ const updates = ctx.updateDate ? await ctx.updateDate() : null;
+ const revision = siteUpdated();
+ const latest = dates => dates.filter(Boolean).sort().at(-1);
+ return [...publicPages({ governance }).map(p => ({ ...p,
+ lastmod: p.path === '/skills' ? latest([revision, ...skills.map(s => s.lastmod)])
+ : p.path === '/updates' ? latest([revision, updates]) : revision,
+ })), ...skills];
+ };
r.get('/robots.txt', (_req, res) => {
r.get('/robots.txt', (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(
res.type('text/plain').set('Cache-Control', TEXT).send(
@@ -132,13 +146,14 @@ function createDiscoveryRoutes(ctx) {
@@ -132,13 +146,14 @@ function createDiscoveryRoutes(ctx) {
+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
+ seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
});
});
- r.get('/llms.txt', (_req, res) => {
+ r.get('/llms.txt', async (_req, res) => {
res.type('text/plain').set('Cache-Control', TEXT).send(llmsTxt({
res.type('text/plain').set('Cache-Control', TEXT).send(llmsTxt({
name: SITE_NAME,
name: SITE_NAME,
base: site,
base: site,
summary: 'OpenVibe.Codes: the open coding-agent harness. openvibe-codes runs a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or your own OpenAI-compatible model, hands it to the next agent when one fails, and keeps every run; plus a public catalog and routing API, and the way to improve OpenVibe itself.',
summary: 'OpenVibe.Codes: the open coding-agent harness. openvibe-codes runs a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or your own OpenAI-compatible model, hands it to the next agent when one fails, and keeps every run; plus a public catalog and routing API, and the way to improve OpenVibe itself.',
details: `Every page is server-rendered and readable without JavaScript. The harness is a command-line tool and a Node.js module that runs on your machine with your agents and keys (install: ${INSTALL_HINT}). Every agent's output becomes one event stream in Claude Code's stream-json shape. The catalog and the router are public: GET /api/v1/harnesses lists every harness with its models, and POST /api/v1/harnesses/route picks one for a task by capability first, then cost. A hosted runner comes next. The developer console that used to live here (projects, apps, keys, the API reference, OAuth and webhook tools, releases) is OpenVibe.Services at ${SERVICES_ORIGIN}.`,
details: `Every page is server-rendered and readable without JavaScript. The harness is a command-line tool and a Node.js module that runs on your machine with your agents and keys (install: ${INSTALL_HINT}). Every agent's output becomes one event stream in Claude Code's stream-json shape. The catalog and the router are public: GET /api/v1/harnesses lists every harness with its models, and POST /api/v1/harnesses/route picks one for a task by capability first, then cost. A hosted runner comes next. The developer console that used to live here (projects, apps, keys, the API reference, OAuth and webhook tools, releases) is OpenVibe.Services at ${SERVICES_ORIGIN}.`,
sections: [
sections: [
+ { title: 'Public pages', links: (await allPages()).map(p => ({ title: p.title || describePage(p.path, { governance }).title, url: abs(p.path), note: p.text || describePage(p.path, { governance }).text, canonical: true })) },
{ title: 'Start here', links: [
{ title: 'Start here', links: [
{ title: 'Code with any agent', url: abs('/'), note: 'what the harness is, how a task finds its agent, what works today' },
{ title: 'Code with any agent', url: abs('/'), note: 'what the harness is, how a task finds its agent, what works today' },
{ title: 'Get started', url: abs('/start'), note: 'install, agents, commands, permission levels, hand-offs, events, from your own code' },
{ title: 'Get started', url: abs('/start'), note: 'install, agents, commands, permission levels, hand-offs, events, from your own code' },
@@ -159,34 +174,31 @@ function createDiscoveryRoutes(ctx) {
@@ -159,34 +174,31 @@ function createDiscoveryRoutes(ctx) {
optional: [
optional: [
{ title: 'What shipped on OpenVibe.Codes', url: abs('/updates') },
{ title: 'What shipped on OpenVibe.Codes', url: abs('/updates') },
{ title: 'Sitemap', url: abs('/sitemap.xml'), note: 'the public pages, each with a real lastmod' },
{ title: 'Sitemap', url: abs('/sitemap.xml'), note: 'the public pages, each with a real lastmod' },
- { title: 'Full text for language models', url: abs('/llms-full.txt'), note: 'every public page, one line each' },
+ { title: 'Full text for language models', url: abs('/llms-full.txt'), note: 'the complete public pages as Markdown' },
{ title: 'Release metadata (JSON)', url: abs('/release.json'), note: 'this service\'s current release, per ADR-016' },
{ title: 'Release metadata (JSON)', url: abs('/release.json'), note: 'this service\'s current release, per ADR-016' },
{ title: 'OpenVibe.Services', url: SERVICES_ORIGIN, note: 'the developer platform: projects, keys, grants and the API docs of every service' },
{ title: 'OpenVibe.Services', url: SERVICES_ORIGIN, note: 'the developer platform: projects, keys, grants and the API docs of every service' },
],
],
}));
}));
});
});
- // /llms-full.txt: the same public pages the sitemap lists, one title and one line of text each.
- r.get('/llms-full.txt', (_req, res) => {
- const pages = publicPages({ governance }).map((p) => ({ url: p.path, ...describePage(p.path, { governance }) }));
- res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
- site: SITE_NAME,
- summary: 'Every public page of OpenVibe.Codes, one line each: the harness, getting started, the agent catalog, Improve OpenVibe, the update log and the community documents.',
- base: site,
- maxBytes: 128 * 1024,
- sections: [
- { title: 'Harness', pages: pages.filter((p) => HARNESS_PAGES.includes(p.url)) },
- { title: 'Site', pages: pages.filter((p) => !HARNESS_PAGES.includes(p.url)) },
- ],
- }));
+ // Full text comes from the same anonymous renderers as HTML and negotiated Markdown.
+ r.get('/llms-full.txt', async (_req, res) => {
+ const chunks = [`# ${SITE_NAME}`, DESCRIPTION];
+ for (const p of await allPages()) {
+ const rendered = await ctx.renderPublicPage(p.path);
+ if (!rendered) continue;
+ chunks.push(`URL: ${abs(p.path)}\n` + require('openvibe-shared/agent-layer').markdownTwin({ ...rendered, canonical: abs(p.path) }));
+ }
+ res.type('text/plain').set('Cache-Control', TEXT).send(chunks.join('\n\n'));
});
});
- r.get('/sitemap.xml', (_req, res) => {
+ r.get('/sitemap.xml', async (_req, res) => {
const lastmod = siteUpdated();
const lastmod = siteUpdated();
- const urls = publicPages({ governance }).map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));
+ const urls = (await allPages()).map((e) => ({ loc: abs(e.path), ...(e.lastmod || lastmod ? { lastmod: e.lastmod || lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));
res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
});
});
+ r.publicPages = allPages;
return r;
return r;
}
}
@@ -104,7 +104,7 @@ Added test/date.test.js: three cases, all pass.
@@ -104,7 +104,7 @@ Added test/date.test.js: three cases, all pass.
const statusTable = table(['Piece', 'State'], [
const statusTable = table(['Piece', 'State'], [
[html`<a href="/start">The harness on your machine</a> (<code>openvibe-codes</code>)`, `Live, ${VERSION}: runs a task on any of the eight agents with your CLIs and keys, streams one event format, keeps every run to show or resume.`],
[html`<a href="/start">The harness on your machine</a> (<code>openvibe-codes</code>)`, `Live, ${VERSION}: runs a task on any of the eight agents with your CLIs and keys, streams one event format, keeps every run to show or resume.`],
['Hand-offs between agents', 'Live: a failed, rate-limited or silent agent hands the task to the next one with a note on what was done.'],
['Hand-offs between agents', 'Live: a failed, rate-limited or silent agent hands the task to the next one with a note on what was done.'],
- [html`<a href="/run">Run on your machine</a>: start a task in any browser, your phone included; your own computer runs it`, 'Live: add your machine on openvibe.link/machines (one command), then watch the task live here and get the patch, a branch or a pull request. It runs only on your own machines, in Link\'s job sandbox.'],
+ [html`<a href="/run">Run locally</a>: start a task in any browser, your phone included; your own computer runs it`, 'Live: add your machine on openvibe.link/machines (one command), then watch the task live here and get the patch, a branch or a pull request. It runs only on your own machines, in Link\'s job sandbox.'],
[html`<a href="/harnesses">The agent catalog</a>`, 'Live: every harness and model with its capabilities, prices and limits.'],
[html`<a href="/harnesses">The agent catalog</a>`, 'Live: every harness and model with its capabilities, prices and limits.'],
[html`<code>GET /api/v1/harnesses</code>, <code>POST /api/v1/harnesses/route</code>`, 'Live: public, no key needed; per-address and per-caller limits.'],
[html`<code>GET /api/v1/harnesses</code>, <code>POST /api/v1/harnesses/route</code>`, 'Live: public, no key needed; per-address and per-caller limits.'],
[html`<a href="/improve">Improve OpenVibe</a>: a repository, a change on a branch with its test, a pull request that CI checks and a maintainer reviews`, 'Live: pick a repository, describe the change, and start a guided run on your machine.'],
[html`<a href="/improve">Improve OpenVibe</a>: a repository, a change on a branch with its test, a pull request that CI checks and a maintainer reviews`, 'Live: pick a repository, describe the change, and start a guided run on your machine.'],
@@ -119,7 +119,7 @@ Added test/date.test.js: three cases, all pass.
@@ -119,7 +119,7 @@ Added test/date.test.js: three cases, all pass.
title: 'Code with', accent: 'any agent',
title: 'Code with', accent: 'any agent',
lede: 'One open harness for Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API and models you host yourself: send a coding task to the agent that fits it, hand it over when one gets stuck, and keep the whole run in one place. Then point it at OpenVibe itself.',
lede: 'One open harness for Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API and models you host yourself: send a coding task to the agent that fits it, hand it over when one gets stuck, and keep the whole run in one place. Then point it at OpenVibe itself.',
// Two, so they sit side by side on a phone; the agents are the next section.
// Two, so they sit side by side on a phone; the agents are the next section.
- actions: [{ label: 'Install the harness', href: '/start', primary: true }, { label: 'Run on your machine from the web', href: '/run' }],
+ actions: [{ label: 'Install the harness', href: '/start', primary: true }, { label: 'Run locally from the web', href: '/run' }],
note: `Open source (AGPL-3.0). Version ${VERSION} runs on your machine with the agents and keys you already have, from your terminal or started from this site in any browser; the list below says exactly what works.`,
note: `Open source (AGPL-3.0). Version ${VERSION} runs on your machine with the agents and keys you already have, from your terminal or started from this site in any browser; the list below says exactly what works.`,
aside: { html: `<pre tabindex="0" class="codes-hero-code"><code>${showcase.esc(snippet)}</code></pre>` },
aside: { html: `<pre tabindex="0" class="codes-hero-code"><code>${showcase.esc(snippet)}</code></pre>` },
}))}
}))}
@@ -227,7 +227,7 @@ ${table(['Level', 'What the agent may do'], [
@@ -227,7 +227,7 @@ ${table(['Level', 'What the agent may do'], [
<pre tabindex="0"><code>${fromCode}</code></pre>
<pre tabindex="0"><code>${fromCode}</code></pre>
<p><code>harness.agents()</code>, <code>harness.route({ task })</code> and <code>harness.run({ prompt, cwd, agent, permission, handoff })</code> are what the command line uses. The router is the same one behind <a href="/api/v1/harnesses"><code>/api/v1/harnesses/route</code></a>.</p>
<p><code>harness.agents()</code>, <code>harness.route({ task })</code> and <code>harness.run({ prompt, cwd, agent, permission, handoff })</code> are what the command line uses. The router is the same one behind <a href="/api/v1/harnesses"><code>/api/v1/harnesses/route</code></a>.</p>
<h2 id="next">What comes next</h2>
<h2 id="next">What comes next</h2>
-<p>Tasks from the web already run on your own machine (<a href="/run">Run on your machine</a>). Next: a hosted runner on OpenVibe.Run workers for people who would rather not run agents themselves, with budgets; OpenVibe.Actor using Codes for its coding work; and <a href="/improve">Improve OpenVibe</a> as a guided run: pick a repository, describe the change, get a pull request.</p>`,
+<p>Tasks from the web already run on your own machine (<a href="/run">Run locally</a>). Next: a hosted runner on OpenVibe.Run workers for people who would rather not run agents themselves, with budgets; OpenVibe.Actor using Codes for its coding work; and <a href="/improve">Improve OpenVibe</a> as a guided run: pick a repository, describe the change, get a pull request.</p>`,
};
};
}
}
r.get('/start', (req, res) => page(req, res, startPage()));
r.get('/start', (req, res) => page(req, res, startPage()));
@@ -445,8 +445,14 @@ ${raw(kit.fold({ title: 'Clone it yourself', meta: 'git clone', body: `${kit.sni
@@ -445,8 +445,14 @@ ${raw(kit.fold({ title: 'Clone it yourself', meta: 'git clone', body: `${kit.sni
// ── Community documents and the update log ───────────────
// ── Community documents and the update log ───────────────
// What shipped on OpenVibe.Codes: the shared update log every OpenVibe site has.
// What shipped on OpenVibe.Codes: the shared update log every OpenVibe site has.
- const updatesPage = () => ({ agent: true, index: true, cache: PUBLIC_CACHE, title: 'What shipped on OpenVibe.Codes', body: raw(frame.updatesBody({ service: 'codes', siteName: 'OpenVibe.Codes' }) + `<script src="${ovServe.url('shipped.js')}" defer></script>`) });
- r.get('/updates', (req, res) => page(req, res, updatesPage()));
+ const readUpdates = require('../data/public-updates').createPublicUpdates({ base: config.networkUrl, fetchImpl: ctx.publicFetch });
+ const updatesPage = async () => {
+ const entries = await readUpdates();
+ return { agent: true, index: true, cache: PUBLIC_CACHE, title: 'What shipped on OpenVibe.Codes',
+ body: html`${raw(frame.updatesBody({ service: 'codes', siteName: 'OpenVibe.Codes' }).replace(/<div data-ov-shipped=[\s\S]*?<\/div>/, ''))}
+${entries.length ? html`<ol>${entries.map(e => html`<li><a href="${e.url}">${e.subject}</a> <time datetime="${e.deployed_at}">${String(e.deployed_at || '').slice(0, 10)}</time></li>`)}</ol>` : html`<p>The update feed is temporarily unavailable. <a href="https://github.com/OpenVibers/OpenVibe.Codes/commits/main">Read the Codes commit history</a>.</p>`}` };
+ };
+ r.get('/updates', async (req, res) => page(req, res, await updatesPage()));
const policyIndexPage = () => ({
const policyIndexPage = () => ({
agent: true, index: true, cache: PUBLIC_CACHE, title: 'Community', crumbs: [{ label: 'Community' }],
agent: true, index: true, cache: PUBLIC_CACHE, title: 'Community', crumbs: [{ label: 'Community' }],
body: html`<h1>Community</h1>
body: html`<h1>Community</h1>
@@ -474,7 +480,11 @@ ${g.draft ? notice(html`<strong>Draft — pending owner review.</strong> This is
@@ -474,7 +480,11 @@ ${g.draft ? notice(html`<strong>Draft — pending owner review.</strong> This is
};
};
// ── Discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt (http/discovery.js, openvibe-shared/seo) ──
// ── Discovery: robots.txt, sitemap.xml, llms.txt, llms-full.txt (http/discovery.js, openvibe-shared/seo) ──
- r.use(createDiscoveryRoutes({ ...ctx, governance }));
+ const discovery = createDiscoveryRoutes({ ...ctx, governance, renderPublicPage: (path) => ctx.renderPublicPage(path),
+ updateDate: async () => (await readUpdates()).map(e => String(e.deployed_at || '').slice(0, 10)).filter(d => /^\d{4}-\d{2}-\d{2}$/.test(d)).sort().at(-1),
+ });
+ r.publicPages = discovery.publicPages;
+ r.use(discovery);
return r;
return r;
}
}
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
/**
/**
- * Run on your machine (plan T16): a coding task started from any browser that runs on the person's own machine.
+ * Run locally (plan T16): a coding task started from any browser that runs on the person's own machine.
*
*
* GET /run the form, the person's projects and their recent tasks (a sign-in prompt for a guest)
* GET /run the form, the person's projects and their recent tasks (a sign-in prompt for a guest)
* POST /run start one: a run_tasks row and an OpenVibe Run job for the person (server/domain/run-client.js)
* POST /run start one: a run_tasks row and an OpenVibe Run job for the person (server/domain/run-client.js)
@@ -46,6 +46,16 @@ function repoName(url) {
@@ -46,6 +46,16 @@ function repoName(url) {
return html`<small class="run-host">${host}/</small>${path.map((part, i) => html`${i ? raw('/<wbr>') : ''}${part}`)}`;
return html`<small class="run-host">${host}/</small>${path.map((part, i) => html`${i ? raw('/<wbr>') : ''}${part}`)}`;
}
}
+function guestRunPage() {
+ return {
+ title: 'Run locally', index: true, agent: true,
+ body: html`<h1>Run coding agents locally, started from any browser</h1>
+<p class="lead">Start a task from any browser, your phone included, and your own computer does the work: the agents and keys you already have, in a sandbox, with nothing to host. You watch it live here and get the changes back as a patch, a branch or a pull request.</p>
+<ol class="run-steps"><li>Add your computer on <a href="${MACHINES_URL}">openvibe.link/machines</a>: one command, which installs OpenVibe Link with its job worker.</li><li>Sign in here and describe the task.</li><li>Your machine clones the repository, runs the agent and sends the result back.</li></ol>
+<p><a class="run-btn run-btn-primary" href="/auth/login?next=%2Frun">Sign in to start</a></p>`,
+ };
+}
+
function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globalThis.fetch, log = console, tasks = null, sessions = null, keys = null, sharing = null }) {
function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globalThis.fetch, log = console, tasks = null, sessions = null, keys = null, sharing = null }) {
const r = asyncRouter();
const r = asyncRouter();
const db = store.db;
const db = store.db;
@@ -144,20 +154,14 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`
@@ -144,20 +154,14 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`
async function runPage(req, res, { values, error, status = 200 } = {}) {
async function runPage(req, res, { values, error, status = 200 } = {}) {
if (!signedIn(req)) {
if (!signedIn(req)) {
- return page(req, res, {
- title: 'Run on your machine', index: true,
- body: html`<h1>Run a coding task on your own machine</h1>
-<p class="lead">Start a task from any browser, your phone included, and your own computer does the work: the agents and keys you already have, in a sandbox, with nothing to host. You watch it live here and get the changes back as a patch, a branch or a pull request.</p>
-<ol class="run-steps"><li>Add your computer on <a href="${MACHINES_URL}">openvibe.link/machines</a>: one command, which installs OpenVibe Link with its job worker.</li><li>Sign in here and describe the task.</li><li>Your machine clones the repository, runs the agent and sends the result back.</li></ol>
-<p><a class="run-btn run-btn-primary" href="/auth/login?next=%2Frun">Sign in to start</a></p>`,
- });
+ return page(req, res, guestRunPage());
}
}
const [projects, recent] = await Promise.all([
const [projects, recent] = await Promise.all([
projectsOf(req, res),
projectsOf(req, res),
db.many('SELECT id, repo, prompt, state, created_at FROM run_tasks WHERE subject = $1 ORDER BY created_at DESC LIMIT 20', [req.viewer.subject]),
db.many('SELECT id, repo, prompt, state, created_at FROM run_tasks WHERE subject = $1 ORDER BY created_at DESC LIMIT 20', [req.viewer.subject]),
]);
]);
const defaults = sharing ? await defaultsHtml(req.viewer.subject) : '';
const defaults = sharing ? await defaultsHtml(req.viewer.subject) : '';
- const body = html`<h1>Run on your machine</h1>
+ const body = html`<h1>Run locally</h1>
<p class="lead">The task runs on a machine you added on <a href="${MACHINES_URL}">openvibe.link/machines</a>, in its job sandbox, with the agents and keys installed there. Nobody else's machine ever gets it.</p>
<p class="lead">The task runs on a machine you added on <a href="${MACHINES_URL}">openvibe.link/machines</a>, in its job sandbox, with the agents and keys installed there. Nobody else's machine ever gets it.</p>
${defaults}
${defaults}
${projects.length ? formHtml({ projects, values, error }) : html`<form class="run-card" method="post" action="/run/project">
${projects.length ? formHtml({ projects, values, error }) : html`<form class="run-card" method="post" action="/run/project">
@@ -169,7 +173,7 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`
@@ -169,7 +173,7 @@ ${error ? html`<p class="run-notice run-notice-error" role="alert">${error}</p>`
<button class="run-btn run-btn-primary" type="submit">Create the project</button></form>`}
<button class="run-btn run-btn-primary" type="submit">Create the project</button></form>`}
<h2>Your tasks</h2>
<h2>Your tasks</h2>
${tasksHtml(recent)}`;
${tasksHtml(recent)}`;
- return page(req, res, { title: 'Run on your machine', body }, status);
+ return page(req, res, { title: 'Run locally', body }, status);
}
}
r.get('/run', (req, res) => runPage(req, res, { values: { prompt: String(req.query.prompt || '').slice(0, 16000), repo: String(req.query.repo || '').slice(0, 2000), agent: String(req.query.agent || '').slice(0, 100) } }));
r.get('/run', (req, res) => runPage(req, res, { values: { prompt: String(req.query.prompt || '').slice(0, 16000), repo: String(req.query.repo || '').slice(0, 2000), agent: String(req.query.agent || '').slice(0, 100) } }));
@@ -324,7 +328,7 @@ ${i.summary ? html`<p class="run-proof-summary" data-proof-summary>${i.summary}<
@@ -324,7 +328,7 @@ ${i.summary ? html`<p class="run-proof-summary" data-proof-summary>${i.summary}<
if (!allowed) {
if (!allowed) {
const audience = sharing ? await sharing.audienceOf(base.id).catch(() => null) : null;
const audience = sharing ? await sharing.audienceOf(base.id).catch(() => null) : null;
if (audience === 'people') return gatePage(req, res, base.id);
if (audience === 'people') return gatePage(req, res, base.id);
- return page(req, res, { title: 'Not found', index: false, body: html`<h1>Not found</h1><p>No such task. <a href="/run">Run on your machine</a></p>` }, 404);
+ return page(req, res, { title: 'Not found', index: false, body: html`<h1>Not found</h1><p>No such task. <a href="/run">Run locally</a></p>` }, 404);
}
}
}
}
let row = base;
let row = base;
@@ -335,7 +339,7 @@ ${i.summary ? html`<p class="run-proof-summary" data-proof-summary>${i.summary}<
@@ -335,7 +339,7 @@ ${i.summary ? html`<p class="run-proof-summary" data-proof-summary>${i.summary}<
const lines = live ? null : await sessionStore.linesForRow(row);
const lines = live ? null : await sessionStore.linesForRow(row);
const title = String(row.prompt || '').split('\n')[0].slice(0, 90);
const title = String(row.prompt || '').split('\n')[0].slice(0, 90);
const session = lines && lines.length ? html`<section class="run-session" data-session data-title="${title}" data-state="${row.state}"><h2>The session</h2><div data-session-view></div><script type="application/json" data-session-log>${raw(JSON.stringify(lines).replace(/</g, '\\u003c'))}</script></section>` : '';
const session = lines && lines.length ? html`<section class="run-session" data-session data-title="${title}" data-state="${row.state}"><h2>The session</h2><div data-session-view></div><script type="application/json" data-session-log>${raw(JSON.stringify(lines).replace(/</g, '\\u003c'))}</script></section>` : '';
- const body = html`${shareHead ? raw(shareHead) : ''}<p class="crumbs"><a href="/run">Run on your machine</a></p>
+ const body = html`${shareHead ? raw(shareHead) : ''}<p class="crumbs"><a href="/run">Run locally</a></p>
<h1 class="run-repo">${repoName(row.repo)}${row.ref ? html` <small>${row.ref}</small>` : ''}</h1>
<h1 class="run-repo">${repoName(row.repo)}${row.ref ? html` <small>${row.ref}</small>` : ''}</h1>
<p><span class="run-pill run-pill-${row.state}" data-state>${STATE_LABEL[row.state] || row.state}</span>${row.agent ? html` <span class="run-pill">${row.agent}</span>` : ''}</p>
<p><span class="run-pill run-pill-${row.state}" data-state>${STATE_LABEL[row.state] || row.state}</span>${row.agent ? html` <span class="run-pill">${row.agent}</span>` : ''}</p>
${mine ? shareButton(row) : html`<p class="muted">This task was shared with you. Only its owner can change it.</p>`}
${mine ? shareButton(row) : html`<p class="muted">This task was shared with you. Only its owner can change it.</p>`}
@@ -445,4 +449,4 @@ ${mine ? html`<form method="post" action="/run/tasks/${row.id}/cancel"><button c
@@ -445,4 +449,4 @@ ${mine ? html`<form method="post" action="/run/tasks/${row.id}/cancel"><button c
return r;
return r;
}
}
-module.exports = { createRunRoutes, STATE_LABEL, TERMINAL, esc };
+module.exports = { createRunRoutes, guestRunPage, STATE_LABEL, TERMINAL, esc };
@@ -41,7 +41,7 @@ function createSkillPageRoutes({ config, skills }) {
@@ -41,7 +41,7 @@ function createSkillPageRoutes({ config, skills }) {
try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
};
};
const form = express.urlencoded({ extended: false, limit: '200kb' });
const form = express.urlencoded({ extended: false, limit: '200kb' });
- const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
+ const page = (req, res, o, status = 200) => res ? send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o }) : o;
const toolsText = (tools) => (tools && tools.length ? tools.map((t) => TOOL_LABELS[t] || t).join(', ') : 'every tool');
const toolsText = (tools) => (tools && tools.length ? tools.map((t) => TOOL_LABELS[t] || t).join(', ') : 'every tool');
const skillCard = (s, extra = '') => html`<li>
const skillCard = (s, extra = '') => html`<li>
@@ -74,7 +74,7 @@ ${audience ? html`<div class="field"><label for="sk-audience">Who may read it</l
@@ -74,7 +74,7 @@ ${audience ? html`<div class="field"><label for="sk-audience">Who may read it</l
<form class="stack" method="post" action="/skills/new">${formFields()}<button class="run-btn run-btn-primary" type="submit">Save version 1</button></form></section>`
<form class="stack" method="post" action="/skills/new">${formFields()}<button class="run-btn run-btn-primary" type="submit">Save version 1</button></form></section>`
: html`<p><a class="run-btn" href="/auth/login?next=${encodeURIComponent('/skills')}">Sign in to publish and sync skills</a></p>`;
: html`<p><a class="run-btn" href="/auth/login?next=${encodeURIComponent('/skills')}">Sign in to publish and sync skills</a></p>`;
return page(req, res, {
return page(req, res, {
- title: 'Skills: the hub for your agents',
+ title: 'Skills: the hub for your agents', agent: true, index: !kind && !q,
description: 'Skills, rules and MCP servers kept per person and per project, versioned, and synced into Claude Code, Codex, OpenCode and Command Code on your machine.',
description: 'Skills, rules and MCP servers kept per person and per project, versioned, and synced into Claude Code, Codex, OpenCode and Command Code on your machine.',
crumbs: [{ label: 'Skills' }],
crumbs: [{ label: 'Skills' }],
body: html`<h1>Skills</h1>
body: html`<h1>Skills</h1>
@@ -111,7 +111,7 @@ ${skill.audience === 'people' ? html`<h3>Members</h3>${members.length ? html`<ul
@@ -111,7 +111,7 @@ ${skill.audience === 'people' ? html`<h3>Members</h3>${members.length ? html`<ul
<form class="inline-form" method="post" action="/skills/${skill.id}/members"><label>Person <input name="subject" placeholder="usr_…" pattern="usr_[0-9A-HJKMNP-TV-Z]{26}" required></label><button class="run-btn" type="submit">Add</button></form>` : ''}
<form class="inline-form" method="post" action="/skills/${skill.id}/members"><label>Person <input name="subject" placeholder="usr_…" pattern="usr_[0-9A-HJKMNP-TV-Z]{26}" required></label><button class="run-btn" type="submit">Add</button></form>` : ''}
</section>` : '';
</section>` : '';
return page(req, res, {
return page(req, res, {
- title: skill.title,
+ title: skill.title, agent: skill.audience === 'public', index: skill.audience === 'public',
description: skill.summary || `A ${KIND_LABELS[skill.kind]} on OpenVibe.Codes.`,
description: skill.summary || `A ${KIND_LABELS[skill.kind]} on OpenVibe.Codes.`,
crumbs: [{ label: 'Skills', href: '/skills' }, { label: skill.title }],
crumbs: [{ label: 'Skills', href: '/skills' }, { label: skill.title }],
body: html`${error ? notice(error, 'bad') : ''}
body: html`${error ? notice(error, 'bad') : ''}
@@ -225,6 +225,15 @@ ${controls}`,
@@ -225,6 +225,15 @@ ${controls}`,
}
}
});
});
+ r.publicView = async (path) => {
+ const req = { viewer: { kind: 'anonymous' }, query: {}, params: {}, originalUrl: path };
+ if (path === '/skills') return galleryPage(req, null);
+ const match = /^\/skills\/([^/]+)$/.exec(path);
+ if (!match) return null;
+ req.params.id = match[1];
+ const skill = await skills.get(req.params.id, null);
+ return skill && skill.audience === 'public' ? detailPage(req, null) : null;
+ };
return r;
return r;
}
}
@@ -39,6 +39,12 @@ async function start() {
@@ -39,6 +39,12 @@ async function start() {
console.log(`[Codes] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);
console.log(`[Codes] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.store.db.store})`);
console.log(`[Codes] ${ctx.harnesses.list().length} harnesses in the catalog; the developer console's addresses redirect to openvibe.services`);
console.log(`[Codes] ${ctx.harnesses.list().length} harnesses in the catalog; the developer console's addresses redirect to openvibe.services`);
});
});
+ // Announce the public revision on deploy/start. Shared sends batches of 100, one second apart.
+ if (ctx.indexnow.enabled) ctx.publicPages().then(pages => {
+ ctx.indexnow.pingSoon(pages.map(p => p.path));
+ console.log(`[Codes] IndexNow queued ${pages.length} public URLs (100 per batch)`);
+ }).catch(() => console.warn('[Codes] IndexNow public-page enumeration failed'));
+ else console.log('[Codes] IndexNow disabled: no configured key');
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
// Request telemetry (openvibe-sdk/service, plan T1): one aggregated sample per route template, method and status
// Request telemetry (openvibe-sdk/service, plan T1): one aggregated sample per route template, method and status
// class each minute, stored in this service's telemetry_samples table; rows older than 14 days go daily.
// class each minute, stored in this service's telemetry_samples table; rows older than 14 days go daily.
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+'use strict';
+// Public metadata is applied to HTML and Markdown through the same layout.
+const META = {
+ '/': ['OpenVibe.Codes: code with any agent', 'Run coding agents with your own plans and keys. Start tasks from any browser, follow their work, and review the changes with OpenVibe Codes.'],
+ '/start': ['Get started with coding agents', 'Install the OpenVibe Codes harness, connect your coding agents, and run tasks locally with permissions, hand-offs and a saved event stream.'],
+ '/run': ['Run locally', 'Run coding agents locally from any browser. Connect your computer, describe a task, and watch your agents return a patch, branch or pull request.'],
+ '/harnesses': ['The coding agents', 'Compare the coding agents available in OpenVibe Codes, including their models, capabilities, prices and limits, and learn how tasks are routed.'],
+ '/skills': ['Skills for coding agents', 'Browse public skills, rules and MCP servers for coding agents. Version and sync them into Claude Code, Codex, OpenCode and Command Code.'],
+ '/improve': ['Improve OpenVibe', 'Choose an open-source OpenVibe repository, describe a change, and use a coding agent on your computer to prepare a reviewed pull request.'],
+ '/updates': ['What shipped', 'Read the OpenVibe Codes update log for changes to coding agents, local runs, the harness, skills and the browser workspace.'],
+ '/policy': ['Community', 'Read the OpenVibe contributor guides and community policies, including the code of conduct, contributor ladder and moderation process.'],
+};
+function metadata(path, o) {
+ const fixed = META[path];
+ const title = fixed ? fixed[0] : String(o.title || 'OpenVibe Codes').slice(0, 43);
+ const description = fixed ? fixed[1] : String(o.description || `${o.title}: contributor guidance and community policy for OpenVibe Codes and its open-source repositories.`).slice(0, 155);
+ return { title, description };
+}
+function structuredData(path, o, canonical) {
+ const nodes = [...(o.jsonLd || [])];
+ if (path !== '/') nodes.push({ '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: [
+ { '@type': 'ListItem', position: 1, name: 'OpenVibe Codes', item: `${o.config.baseUrl}/` },
+ ...(o.crumbs || []).filter(c => c.href).map((c, i) => ({ '@type': 'ListItem', position: i + 2, name: c.label, item: new URL(c.href, o.config.baseUrl).href })),
+ { '@type': 'ListItem', name: o.title, item: canonical },
+ ].map((item, i) => ({ ...item, position: i + 1 })) });
+ const steps = path === '/run' ? [
+ ['Add your computer', 'Add your computer on openvibe.link/machines: one command, which installs OpenVibe Link with its job worker.'],
+ ['Describe the task', 'Sign in here and describe the task.'],
+ ['Get the result', 'Your machine clones the repository, runs the agent and sends the result back.'],
+ ] : path === '/start' ? [
+ ['Install', 'Install Node.js 22 or newer, then install openvibe-codes with the command shown on this page.'],
+ ['The agents it can use', 'Install any of the listed agents; the harness uses what is there and says why the rest are not.'],
+ ['Use it', 'Run openvibe-codes run with your task. The exit code follows the result: 0 when the last agent finished, 1 when it did not.'],
+ ] : null;
+ if (steps) nodes.push({ '@context': 'https://schema.org', '@type': 'HowTo', name: o.title, url: canonical,
+ step: steps.map(([name, text], i) => ({ '@type': 'HowToStep', position: i + 1, name, text })) });
+ return nodes;
+}
+module.exports = { META, metadata, structuredData };
@@ -25,7 +25,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
@@ -25,7 +25,7 @@ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
// the navbar's More (Shared 4.22). Search is the navbar's magnifier and What shipped is the footer's.
// the navbar's More (Shared 4.22). Search is the navbar's magnifier and What shipped is the footer's.
const NAV = [
const NAV = [
{ label: 'Get started', href: '/start', icon: 'fa-play' },
{ label: 'Get started', href: '/start', icon: 'fa-play' },
- { label: 'Run on your machine', href: '/run', icon: 'fa-microchip' },
+ { label: 'Run locally', href: '/run', icon: 'fa-microchip' },
{ label: 'Agents', href: '/harnesses', icon: 'fa-robot' },
{ label: 'Agents', href: '/harnesses', icon: 'fa-robot' },
{ label: 'Skills', href: '/skills', icon: 'fa-puzzle-piece' },
{ label: 'Skills', href: '/skills', icon: 'fa-puzzle-piece' },
{ label: 'Improve OpenVibe', href: '/improve', icon: 'fa-code', more: true },
{ label: 'Improve OpenVibe', href: '/improve', icon: 'fa-code', more: true },
@@ -34,7 +34,7 @@ const NAV = [
@@ -34,7 +34,7 @@ const NAV = [
// The workspace's own sections (Codes C4c). The shared Frame owns the page's navigation (openvibe.design/patterns/
// The workspace's own sections (Codes C4c). The shared Frame owns the page's navigation (openvibe.design/patterns/
// single-navbar), so on the workspace pages the Frame navbar's links ARE these sections and the page carries no
// single-navbar), so on the workspace pages the Frame navbar's links ARE these sections and the page carries no
-// sidebar of its own. The product's own places (Get started, Run on your machine) move into More for a signed-in
+// sidebar of its own. The product's own places (Get started, Run locally) move into More for a signed-in
// person, so a workspace page has one navigation and not two.
// person, so a workspace page has one navigation and not two.
const WORKSPACE_NAV = [
const WORKSPACE_NAV = [
{ label: 'Home', href: '/', icon: 'fa-house' },
{ label: 'Home', href: '/', icon: 'fa-house' },
@@ -75,7 +75,11 @@ function renderPage(o) {
@@ -75,7 +75,11 @@ function renderPage(o) {
const pathOnly = pathNow.split('?')[0];
const pathOnly = pathNow.split('?')[0];
const c4Workspace = pathOnly === '/' || /^\/(tasks|conductor|approvals|agents|machines|memory|knowledge|usage|settings)(\/|$)/.test(pathOnly);
const c4Workspace = pathOnly === '/' || /^\/(tasks|conductor|approvals|agents|machines|memory|knowledge|usage|settings)(\/|$)/.test(pathOnly);
const loginNext = encodeURIComponent(pathNow);
const loginNext = encodeURIComponent(pathNow);
- const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
+ const canonical = `${o.config.baseUrl}${pathOnly}`;
+ if ((o.index || o.agent) && !signedIn) {
+ o = { ...o, ...require('../public-seo').metadata(pathOnly, o) };
+ o.jsonLd = require('../public-seo').structuredData(pathOnly, o, canonical);
+ }
// One navigation per page. On the workspace pages a signed-in person's navbar carries the workspace sections, with
// One navigation per page. On the workspace pages a signed-in person's navbar carries the workspace sections, with
// the current one active, and the product's own places fold into More; a visitor who is not signed in keeps the
// the current one active, and the product's own places fold into More; a visitor who is not signed in keeps the
// product's links, because the sections hold the visitor's own work and a visitor has none yet.
// product's links, because the sections hold the visitor's own work and a visitor has none yet.
@@ -119,7 +123,7 @@ function renderPage(o) {
@@ -119,7 +123,7 @@ function renderPage(o) {
const scripts = (o.sharedScripts || []).map((name) => html`<script src="${ovServe.url(name)}" defer></script>`);
const scripts = (o.sharedScripts || []).map((name) => html`<script src="${ovServe.url(name)}" defer></script>`);
if (String(o.body || '').includes('data-ov-machines')) scripts.push(raw('<script src="/shared/machines.js" defer></script>'));
if (String(o.body || '').includes('data-ov-machines')) scripts.push(raw('<script src="/shared/machines.js" defer></script>'));
scripts.push(...(o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`));
scripts.push(...(o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`));
- return shell.page({
+ const document = shell.page({
name: SITE_NAME,
name: SITE_NAME,
lang: 'en',
lang: 'en',
// The workspace pages carry their own phone tab bar, so the page's bottom inset is theirs (C4c).
// The workspace pages carry their own phone tab bar, so the page's bottom inset is theirs (C4c).
@@ -129,6 +133,8 @@ function renderPage(o) {
@@ -129,6 +133,8 @@ function renderPage(o) {
siteName: SITE_NAME,
siteName: SITE_NAME,
description: o.description || 'The open coding-agent harness: Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API and your own models behind one router, and the way to improve OpenVibe itself.',
description: o.description || 'The open coding-agent harness: Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API and your own models behind one router, and the way to improve OpenVibe itself.',
canonical,
canonical,
+ image: new URL(require('openvibe-shared/social').url('codes'), o.config.baseUrl).href,
+ imageAlt: 'OpenVibe Codes — coding agents',
robots: o.index ? 'index, follow' : 'noindex, nofollow',
robots: o.index ? 'index, follow' : 'noindex, nofollow',
// A page with a Markdown twin (o.agent) advertises it in the head and the Link header; the twin itself
// A page with a Markdown twin (o.agent) advertises it in the head and the Link header; the twin itself
// is server/agent-pages.js.
// is server/agent-pages.js.
@@ -163,6 +169,8 @@ document.addEventListener('DOMContentLoaded', function () {
@@ -163,6 +169,8 @@ document.addEventListener('DOMContentLoaded', function () {
});
});
</script>`,
</script>`,
});
});
+ // Footer groups follow the page h1 independently of its final content heading.
+ return document.replace(/<footer\b[\s\S]*?<\/footer>/g, footer => footer.replace(/<(\/?)h3\b/g, '<$1h2'));
}
}
function render(v) { return require('./html').render(v); }
function render(v) { return require('./html').render(v); }
@@ -172,7 +180,8 @@ function send(res, status, o) {
@@ -172,7 +180,8 @@ function send(res, status, o) {
// A page rendered for a signed-in person names them in the account bar: never shared caches.
// A page rendered for a signed-in person names them in the account bar: never shared caches.
const personal = o.viewer && o.viewer.kind === 'user';
const personal = o.viewer && o.viewer.kind === 'user';
if (!res.get('Cache-Control')) res.set('Cache-Control', !personal && o.cache ? o.cache : cache.htmlHeaders({ private: true }));
if (!res.get('Cache-Control')) res.set('Cache-Control', !personal && o.cache ? o.cache : cache.htmlHeaders({ private: true }));
- res.set('Vary', 'Cookie');
+ res.vary('Cookie');
+ res.vary('Accept');
if (o.agent) {
if (o.agent) {
const site = String(o.config.baseUrl).replace(/\/+$/, '');
const site = String(o.config.baseUrl).replace(/\/+$/, '');
res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(`${site}${String(o.path || '/').split('?')[0]}`, `${site}/llms.txt`).header);
res.set('Link', require('openvibe-shared/agent-layer').linkHeaderTags(`${site}${String(o.path || '/').split('?')[0]}`, `${site}/llms.txt`).header);
@@ -35,7 +35,7 @@ const split = (links) => ({
@@ -35,7 +35,7 @@ const split = (links) => ({
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
const { top, more } = split(navbarOf(page.text).links);
const { top, more } = split(navbarOf(page.text).links);
assert.deepStrictEqual(top, [], 'the navbar carries no sections');
assert.deepStrictEqual(top, [], 'the navbar carries no sections');
- assert.ok(more.includes('Get started') && more.includes('Run on your machine'), 'only the product’s places, in More');
+ assert.ok(more.includes('Get started') && more.includes('Run locally'), 'only the product’s places, in More');
assert.ok(page.text.includes('<aside class="ovk-sidebar" id="ch-rail"'), 'the rail is on the page');
assert.ok(page.text.includes('<aside class="ovk-sidebar" id="ch-rail"'), 'the rail is on the page');
assert.ok(page.text.includes('<a class="ovk-sidebar-link is-here" href="/agents" aria-current="page">'), 'and marks the page the person is on, a row of the network’s sidebar');
assert.ok(page.text.includes('<a class="ovk-sidebar-link is-here" href="/agents" aria-current="page">'), 'and marks the page the person is on, a row of the network’s sidebar');
assert.ok(!page.text.includes('c4-side') && !page.text.includes('c4-layout'), 'no other sidebar');
assert.ok(!page.text.includes('c4-side') && !page.text.includes('c4-layout'), 'no other sidebar');
@@ -52,7 +52,7 @@ const split = (links) => ({
@@ -52,7 +52,7 @@ const split = (links) => ({
const page = await t.get('/tasks');
const page = await t.get('/tasks');
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
const { top, more } = split(navbarOf(page.text).links);
const { top, more } = split(navbarOf(page.text).links);
- assert.ok(top.includes('Get started') && top.includes('Run on your machine'), 'the product’s places are at the top');
+ assert.ok(top.includes('Get started') && top.includes('Run locally'), 'the product’s places are at the top');
assert.ok(!top.includes('Tasks'), 'and the workspace’s sections are not');
assert.ok(!top.includes('Tasks'), 'and the workspace’s sections are not');
assert.ok(more.includes('Improve OpenVibe'), 'the rest folds into More');
assert.ok(more.includes('Improve OpenVibe'), 'the rest folds into More');
});
});
@@ -61,7 +61,7 @@ const split = (links) => ({
@@ -61,7 +61,7 @@ const split = (links) => ({
const page = await t.get('/harnesses', { as: ada });
const page = await t.get('/harnesses', { as: ada });
assert.strictEqual(page.status, 200);
assert.strictEqual(page.status, 200);
const { top } = split(navbarOf(page.text).links);
const { top } = split(navbarOf(page.text).links);
- assert.ok(top.includes('Get started') && top.includes('Run on your machine'), 'the product’s places are at the top');
+ assert.ok(top.includes('Get started') && top.includes('Run locally'), 'the product’s places are at the top');
assert.ok(!top.includes('Machines'), 'and the workspace’s sections are not');
assert.ok(!top.includes('Machines'), 'and the workspace’s sections are not');
});
});
@@ -93,13 +93,72 @@ const PRIVATE = ['/projects', '/staff', '/auth/', '/oauth/test-callback'];
@@ -93,13 +93,72 @@ const PRIVATE = ['/projects', '/staff', '/auth/', '/oauth/test-callback'];
assert.ok(!r.text.includes('https://openvibe.codes/api/'), 'api path in llms-full.txt');
assert.ok(!r.text.includes('https://openvibe.codes/api/'), 'api path in llms-full.txt');
});
});
+ await check('every public page: unique bounded metadata, social cards, structured data and readable HTML', async () => {
+ const sitemap = await t.get('/sitemap.xml');
+ const llms = await t.get('/llms.txt');
+ const urls = [...sitemap.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map(m => m[1]);
+ const titles = new Set(), descriptions = new Set();
+ for (const url of urls) {
+ const path = new URL(url).pathname;
+ const r = await t.get(path);
+ assert.equal(r.status, 200, path);
+ const title = /<title>(.*?)<\/title>/.exec(r.text)[1];
+ const description = /<meta name="description" content="([^"]*)"/.exec(r.text)[1];
+ assert.ok(title.length <= 60 && !titles.has(title), `${path}: title ${title}`);
+ assert.ok(description.length <= 155 && !descriptions.has(description), `${path}: description ${description}`);
+ titles.add(title); descriptions.add(description);
+ assert.ok(r.text.includes(`rel="canonical" href="${url}"`), path);
+ assert.ok(!/name="robots" content="noindex/.test(r.text), path);
+ for (const key of ['og:title', 'og:description', 'og:image', 'twitter:card', 'twitter:image']) assert.ok(r.text.includes(`="${key}"`), `${path}: ${key}`);
+ assert.equal((r.text.match(/<h1\b/g) || []).length, 1, path);
+ const headings = [...r.text.matchAll(/<h([1-6])\b/g)].map(m => Number(m[1]));
+ headings.forEach((h, i) => { if (i) assert.ok(h <= headings[i - 1] + 1, `${path}: heading skips to h${h}`); });
+ for (const image of r.text.matchAll(/<img\b[^>]*>/g)) assert.match(image[0], /\balt="[^"]*"/, path);
+ const nodes = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map(m => JSON.parse(m[1]));
+ const types = nodes.map(n => n['@type']);
+ assert.ok(types.includes(path === '/' ? 'SoftwareApplication' : 'BreadcrumbList'), `${path}: ${types}`);
+ if (['/start', '/run'].includes(path)) assert.ok(types.includes('HowTo'), path);
+ assert.ok(!types.includes('FAQPage'), 'no FAQ is shown');
+ assert.ok(llms.text.includes(`](${url})`), `llms missing ${path}`);
+ }
+ });
+ await check('Markdown negotiation uses complete public text, including run and skills', async () => {
+ for (const [path, expected] of [['/start', 'Permission levels'], ['/run', 'Run coding agents locally, started from any browser'], ['/skills', 'The gallery']]) {
+ const r = await t.get(path, { headers: { Accept: 'text/markdown' } });
+ assert.equal(r.status, 200);
+ assert.match(r.headers.get('content-type'), /^text\/markdown/);
+ assert.ok(r.text.includes(expected), path);
+ assert.ok(!r.text.includes('<html'), path);
+ }
+ const full = await t.get('/llms-full.txt');
+ assert.ok(full.text.includes('Permission levels') && full.text.includes('Where runs are kept'));
+ });
+ await check('crawler rules protect private paths for search and AI crawlers', async () => {
+ const r = await t.get('/robots.txt');
+ // Shared repeats public Allow and private exclusions for its search/AI group.
+ assert.ok(r.text.includes('User-agent: *'));
+ const groups = r.text.split(/\n\s*\n/).filter(g => g.includes('User-agent:'));
+ for (const group of groups) {
+ assert.ok(group.includes('Allow: /'));
+ for (const path of ['/tasks', '/run/tasks', '/settings', '/auth/', '/api/']) assert.ok(group.includes(`Disallow: ${path}`));
+ }
+ for (const path of ['/tasks', '/run/tasks', '/settings', '/auth/', '/api/']) assert.ok(r.text.includes(`Disallow: ${path}`), path);
+ for (const name of ['Googlebot', 'Bingbot', 'GPTBot', 'OAI-SearchBot', 'ClaudeBot', 'Claude-SearchBot', 'PerplexityBot', 'Google-Extended', 'Applebot']) {
+ assert.ok(!r.text.includes(`User-agent: ${name}\nDisallow: /\n`), name);
+ }
+ });
+
await check('home page: WebSite + the site\'s primary type, the same for every crawler', async () => {
await check('home page: WebSite + the site\'s primary type, the same for every crawler', async () => {
const r = await t.get('/');
const r = await t.get('/');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
const blocks = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
const blocks = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
const types = blocks.map((b) => b['@type']);
const types = blocks.map((b) => b['@type']);
assert.ok(types.includes('WebSite'), `no WebSite node: ${types}`);
assert.ok(types.includes('WebSite'), `no WebSite node: ${types}`);
- assert.ok(types.includes('WebApplication'), `no primary-type node: ${types}`);
+ assert.ok(types.includes('SoftwareApplication'), `no primary-type node: ${types}`);
+ const app = blocks.find(b => b['@type'] === 'SoftwareApplication');
+ assert.equal(app.applicationCategory, 'DeveloperApplication');
+ assert.ok(app.operatingSystem);
+ assert.equal(app.offers.price, '0');
const site = blocks.find((b) => b['@type'] === 'WebSite');
const site = blocks.find((b) => b['@type'] === 'WebSite');
assert.strictEqual(site.url, 'https://openvibe.codes');
assert.strictEqual(site.url, 'https://openvibe.codes');
assert.strictEqual(site.name, 'OpenVibe.Codes');
assert.strictEqual(site.name, 'OpenVibe.Codes');
@@ -46,7 +46,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
@@ -46,7 +46,7 @@ const BOOST = /<script src="\/shared\/boost\.js\?v=[0-9a-f]{12}" data-main="#mai
assert.ok(head.includes('<link rel="canonical" href="https://openvibe.codes/">'), 'canonical');
assert.ok(head.includes('<link rel="canonical" href="https://openvibe.codes/">'), 'canonical');
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
assert.ok(head.includes('<meta name="robots" content="index, follow">'), 'robots');
const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
const ld = [...head.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1])['@type']);
- assert.deepStrictEqual(ld, ['WebSite', 'WebApplication', 'WebPage', 'Organization'], 'the home JSON-LD describes the workspace');
+ assert.deepStrictEqual(ld, ['WebSite', 'SoftwareApplication', 'WebPage', 'Organization'], 'the home JSON-LD describes the workspace');
for (const tag of ['<meta property="og:title"', 'data-ov-icon="codes"', '/css/codes.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
for (const tag of ['<meta property="og:title"', 'data-ov-icon="codes"', '/css/codes.css?v=', '/shared/theme-loader.js', '/shared/navbar.js', '/shared/footer.js', '<meta name="referrer"']) {
assert.ok(head.includes(tag), `head has ${tag}`);
assert.ok(head.includes(tag), `head has ${tag}`);
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+'use strict';
+const assert = require('assert');
+const { createPublicUpdates } = require('../server/data/public-updates');
+(async () => {
+ let calls = 0;
+ const entries = [{ subject: 'A deployed change', url: 'https://github.com/OpenVibers/OpenVibe.Codes/commit/abc', deployed_at: '2026-11-10T12:00:00Z' }];
+ const read = createPublicUpdates({ base: 'https://example.test', fetchImpl: async (url, options) => {
+ calls++;
+ assert.ok(url.endsWith('/api/v1/changelog?service=codes&limit=50'));
+ assert.ok(options.signal);
+ return { ok: true, json: async () => ({ entries: [...entries, { subject: 'Bad link', url: 'javascript:alert(1)' }] }) };
+ } });
+ assert.deepEqual(await read(), entries);
+ assert.deepEqual(await read(), entries);
+ assert.equal(calls, 1, 'feed is cached');
+ const failed = createPublicUpdates({ base: 'https://example.test', fetchImpl: async () => { throw new Error('offline'); } });
+ assert.deepEqual(await failed(), []);
+ console.log('public updates: cached server-rendered feed, safe links and failure fallback pass');
+})().catch(error => { console.error(error); process.exitCode = 1; });
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
/**
/**
- * Run on your machine (server/http/run.js, plan T16): a guest is asked to sign in; a person with no project they may
+ * Run locally (server/http/run.js, plan T16): a guest is asked to sign in; a person with no project they may
* run jobs in creates one; a task is checked like the job runner checks it, recorded, and started as an OpenVibe Run job
* run jobs in creates one; a task is checked like the job runner checks it, recorded, and started as an OpenVibe Run job
* for that person in their project (the task id is the idempotency key); its page streams the job's lines through
* for that person in their project (the task id is the idempotency key); its page streams the job's lines through
* Codes and then shows the summary, the changed files and the patch; another person never sees it; cancelling reaches
* Codes and then shows the summary, the changed files and the patch; another person never sees it; cancelling reaches
@@ -37,7 +37,7 @@ const { boot, check, done } = require('./helpers/boot');
@@ -37,7 +37,7 @@ const { boot, check, done } = require('./helpers/boot');
await check('a guest is told what it is and asked to sign in', async () => {
await check('a guest is told what it is and asked to sign in', async () => {
const r = await t.get('/run');
const r = await t.get('/run');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
- assert.ok(r.text.includes('Run a coding task on your own machine') && r.text.includes('/auth/login?next=%2Frun') && r.text.includes('openvibe.link/machines'));
+ assert.ok(r.text.includes('Run coding agents locally, started from any browser') && r.text.includes('/auth/login?next=%2Frun') && r.text.includes('openvibe.link/machines'));
});
});
let project;
let project;
@@ -72,6 +72,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
@@ -72,6 +72,7 @@ const INTERNAL = [`http://127.0.0.1:3000${PROBE}`, `http://2130706433${PROBE}`,
'server/report.js': 'OpenVibe.Report (OV_REPORT_INTERNAL_URL, configured) for the report of a finished task, and the Network token endpoint (OV_NETWORK_INTERNAL_URL, configured) for the Codes service token; the paths are fixed, no URL from a person',
'server/report.js': 'OpenVibe.Report (OV_REPORT_INTERNAL_URL, configured) for the report of a finished task, and the Network token endpoint (OV_NETWORK_INTERNAL_URL, configured) for the Codes service token; the paths are fixed, no URL from a person',
'server/owner-inputs-tests.js': 'the admin panel\'s Test button for the owner\'s keys, owner-only (codes.config.manage): fixed origins only (api.github.com/rate_limit, github.com/login/oauth/access_token, registry.npmjs.org/-/whoami) ; one read-only call each, never a URL from a person',
'server/owner-inputs-tests.js': 'the admin panel\'s Test button for the owner\'s keys, owner-only (codes.config.manage): fixed origins only (api.github.com/rate_limit, github.com/login/oauth/access_token, registry.npmjs.org/-/whoami) ; one read-only call each, never a URL from a person',
'server/events/outbox.js': 'OpenVibe.Events publish (EVENTS_URL, configured) of fixed codes.task.* payloads; no URL from a person',
'server/events/outbox.js': 'OpenVibe.Events publish (EVENTS_URL, configured) of fixed codes.task.* payloads; no URL from a person',
+ 'server/data/public-updates.js': 'the configured Network base (config.networkUrl) public changelog feed at the fixed path /api/v1/changelog?service=codes&limit=50 for the /updates page; no URL from a person',
};
};
const root = path.join(__dirname, '..');
const root = path.join(__dirname, '..');
const found = [];
const found = [];
@@ -58,6 +58,20 @@ const { boot, check, done } = require('./helpers/boot');
@@ -58,6 +58,20 @@ const { boot, check, done } = require('./helpers/boot');
assert.strictEqual(anon.json().skill.audience, 'link');
assert.strictEqual(anon.json().skill.audience, 'link');
});
});
+ await check('discovery includes only public skills and Markdown never exposes private or link records', async () => {
+ for (const path of ['/sitemap.xml', '/llms.txt', '/llms-full.txt']) {
+ const result = await t.get(path);
+ assert.equal(result.status, 200, path);
+ assert.ok(result.text.includes(`/skills/${pub.id}`), path);
+ assert.ok(!result.text.includes(`/skills/${priv.id}`), path);
+ assert.ok(!result.text.includes(`/skills/${link.id}`), path);
+ }
+ const markdown = await t.get(`/skills/${pub.id}`, { headers: { Accept: 'text/markdown' } });
+ assert.match(markdown.headers.get('content-type'), /^text\/markdown/);
+ assert.ok(markdown.text.includes('Public ship'));
+ for (const skill of [priv, link]) assert.equal((await t.get(`/skills/${skill.id}.md`)).status, 404);
+ });
+
await check('versions grow and a rollback copies the old body into a new version', async () => {
await check('versions grow and a rollback copies the old body into a new version', async () => {
const v2 = await api(`/api/v1/skills/${pub.id}/versions`, {
const v2 = await api(`/api/v1/skills/${pub.id}/versions`, {
as: ada, method: 'POST', json: { body: skillBody('public-ship', 'The second version.'), summary: 'Second' },
as: ada, method: 'POST', json: { body: skillBody('public-ship', 'The second version.'), summary: 'Second' },