commit 9a51378b6b6d0ad11335bac51108806fe2137a62 equwal <13551856+equwal@users.noreply.github.com> 2026-10-05 00:30:39 -0700 Coop agents build the ideas dropped in Doing: atomic claim, send to any, DONE closes the card
bin/ideamine.js | 2 + dashboard/index.html | 5 +- src/config.js | 7 ++ src/coop.js | 167 ++++++++++++++++++++++++++++++++++++++++++ src/serve.js | 10 +-- src/store.js | 11 +++ tests/coop.test.js | 199 +++++++++++++++++++++++++++++++++++++++++++++++++++ tests/serve.test.js | 3 +- 8 files changed, 397 insertions(+), 7 deletions(-)
diff --git a/bin/ideamine.js b/bin/ideamine.js index c3d4aa5..55ef7b3 100644 --- a/bin/ideamine.js +++ b/bin/ideamine.js @@ -238,6 +238,8 @@ async function main() { }); server.on('close', () => process.exit(0)); // /ideas-web off console.log(serve.logLine(`the dashboard with buttons runs at ${url} (Ctrl+C stops it)`)); + // Only the server role holds the archive, so only it gives ideas to coop agents. + if (!(await import('../src/sync.js')).enabled()) (await import('../src/coop.js')).start({ log: (text) => console.log(serve.logLine(text)) }); break; } case 'sync': { diff --git a/dashboard/index.html b/dashboard/index.html index ad3adc2..9915e77 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -645,8 +645,8 @@ function makeDraggable(card, idea) { /** * The command for a card that the user drops in `lane`, or null when the drop changes nothing. - * A drop in Doing starts the build where Claude Code is. A page without Claude Code only moves the - * idea, and the machine with the login builds it. + * A drop in Doing starts the build where Claude Code is. On a server with coop, a free coop agent + * builds it. Elsewhere the drop only moves the idea. */ function dropCommand(idea, lane) { if (!idea || idea.lane === lane) return null; @@ -654,6 +654,7 @@ function dropCommand(idea, lane) { if (lane === 'doing' && live.claude && live.window) { return { action: 'go', body: { id: idea.id }, busy: 'Opening Claude Code for ' + idea.key + '…' }; } + if (lane === 'doing' && live.coop) return { action: 'update', body: { id: idea.id, status: 'doing', agent: 'any' } }; if (['doing', 'done', 'dropped', 'inbox'].includes(lane)) return { action: 'update', body: { id: idea.id, status: lane } }; if (['do', 'maybe', 'skip'].includes(lane)) return { action: 'verdict', body: { id: idea.id, verdict: lane } }; return null; diff --git a/src/config.js b/src/config.js index ed235be..11956c4 100644 --- a/src/config.js +++ b/src/config.js @@ -52,6 +52,13 @@ export const SETTINGS = { value: '', about: 'memstated daemon that the Memory tab of `ideamine serve` reads (empty: no Memory tab)', }, + // The token of the hub is never a setting: it comes only from IDEAMINE_COOP_TOKEN. + coop_url: { + env: 'IDEAMINE_COOP_URL', + value: '', + about: 'coop hub whose agents build the ideas dropped in Doing (empty: off; needs IDEAMINE_COOP_TOKEN)', + }, + coop_session: { env: 'IDEAMINE_COOP_SESSION', value: 'workers', about: 'coop session of those agents' }, }; const configPath = () => path.join(home(), 'config.json'); diff --git a/src/coop.js b/src/coop.js new file mode 100644 index 0000000..a1334f5 --- /dev/null +++ b/src/coop.js @@ -0,0 +1,167 @@ +// The agents of a coop session build the ideas that wait in Doing. On the ideamine server, a loop +// takes one waiting idea at a time and sends it to `any` in the coop session: the coop hub gives +// it to a free agent. The idea shows that agent. The answer of the agent closes the idea (DONE) +// or puts it back in Do with the answer as a note. +// +// idea.agent: { name: 'any' } waits; { name: 'coop' } is being sent; { name, msg } is with the +// agent `name` (agent@machine), and msg is the id of the coop message with the task. + +import * as config from './config.js'; +import * as store from './store.js'; +import { clip } from './text.js'; + +const ROUND_MS = 15000; +const SEND_MS = 60000; // a card that stays "being sent" longer than this waits again (the server stopped mid-send) +// ponytail: one limit for every card. A limit per size of the idea if real builds need more. +const ANSWER_MS = 3 * 3600000; +const PAGE = 200; + +const now = () => new Date().toISOString(); + +/** The hub, the session, and the token, or null when coop is off. The token is only in the environment. */ +export function settings() { + const url = config.get('coop_url'); + const token = process.env.IDEAMINE_COOP_TOKEN; + return url && token ? { url: url.replace(/\/+$/, ''), session: config.get('coop_session'), token } : null; +} + +async function hub(s, method, route, body) { + const res = await fetch(`${s.url}/v1/admin/sessions/${encodeURIComponent(s.session)}/${route}`, { + method, + headers: { authorization: `Bearer ${s.token}`, 'content-type': 'application/json' }, + body: body && JSON.stringify(body), + signal: AbortSignal.timeout(10000), + }); + const json = await res.json().catch(() => ({})); + return { status: res.status, json }; +} + +const waiting = (i, t) => i.status === 'doing' && (i.agent?.name === 'any' || (i.agent?.name === 'coop' && t - Date.parse(i.agent.at) > SEND_MS)); + +/** + * Take the idea that waits longest for an agent. The archive lock makes the take atomic: two + * rounds, or two servers, never take the same idea. Null when no idea waits. + */ +export function claim() { + if (!store.load().ideas.some((i) => waiting(i, Date.now()))) return null; // no write when nothing waits + return store.mutate((db) => { + const t = Date.now(); + const idea = db.ideas.filter((i) => waiting(i, t)).sort((a, b) => a.agent.at.localeCompare(b.agent.at))[0]; + if (!idea) return null; + idea.agent = { name: 'coop', at: now() }; + return structuredClone(idea); + }); +} + +/** Change the idea only if it still has the agent state `was` (the user can move a card at any time). */ +function ifStill(id, was, fn) { + return store.mutate((db) => { + const idea = store.findIdea(db, id); + if (!idea || idea.status !== 'doing' || idea.agent?.name !== was.name || idea.agent?.msg !== was.msg) return false; + fn(idea); + idea.updated = now(); + return true; + }); +} + +/** The message that the agent gets. */ +export function task(idea) { + const t = idea.triage || {}; + const facts = [ + t.brief && `Plan from the triage: ${t.brief}`, + idea.project && `Project folder on jose's PC: ${idea.project}`, + t.model && `Suggested model: ${t.model}`, + ].filter(Boolean); + return [ + `Card IDEA-${idea.id} of jose's ideamine board: ${idea.title}`, + idea.text, + facts.join('\n'), + 'Answer with DONE: and one line that says what you did and where (commit, branch, file), or with FAILED: and the reason.\n' + + 'DONE moves the card to Done. Any other answer puts it back in Do with your answer as a note.', + ].filter(Boolean).join('\n\n'); +} + +/** Send one waiting idea to a free agent. */ +async function dispatch(s, log) { + const idea = claim(); + if (!idea) return; + const sending = { name: 'coop' }; + const back = (why) => { + ifStill(idea.id, sending, (i) => (i.agent = { name: 'any', at: now() })); + if (why) log(`coop: IDEA-${idea.id} waits again: ${why}`); + }; + let res; + try { + res = await hub(s, 'POST', 'messages', { to: 'any', text: task(idea) }); + } catch (e) { + return back(`cannot reach the hub (${e.message})`); + } + // 404: no agent is free now. A worker is offline for a moment between its rounds, so try again later. + if (res.status === 404) return back(null); + if (res.status !== 200 || !res.json.id || !res.json.to) return back(`the hub answered ${res.status} ${res.json.error || ''}`.trim()); + const { id: msg, to } = res.json; + ifStill(idea.id, sending, (i) => { + i.agent = { name: to, msg: String(msg), at: now() }; + (i.notes ||= []).push({ at: now(), text: `coop gave it to ${to}` }); + }); + log(`coop: IDEA-${idea.id} went to ${to} (message ${msg})`); +} + +/** Read the answers of the agents. DONE closes the idea; any other answer, or none in time, puts it back in Do. */ +async function collect(s, log) { + const out = store.load().ideas.filter((i) => i.status === 'doing' && i.agent?.msg); + if (!out.length) return; + const replies = new Map(); + for (let after = Math.min(...out.map((i) => Number(i.agent.msg))); ; ) { + const res = await hub(s, 'GET', `messages?after=${after}&limit=${PAGE}`); + if (res.status !== 200) throw new Error(`the hub answered ${res.status} to a read of the messages`); + const messages = res.json.messages || []; + for (const m of messages) if (m.reply_to) replies.set(String(m.reply_to), m); + if (messages.length < PAGE) break; + after = messages[messages.length - 1].id; + } + for (const idea of out) { + const reply = replies.get(idea.agent.msg); + const late = !reply && Date.now() - Date.parse(idea.agent.at) > ANSWER_MS; + if (!reply && !late) continue; + const done = !!reply && /^\s*DONE\b/i.test(reply.text); + const note = reply ? `${reply.from}: ${clip(reply.text, 500)}` : `no answer from ${idea.agent.name} in ${ANSWER_MS / 3600000} h`; + ifStill(idea.id, idea.agent, (i) => { + i.status = done ? 'done' : i.triage ? 'triaged' : 'inbox'; + if (done) i.closed = now(); + delete i.agent; + (i.notes ||= []).push({ at: now(), text: note }); + }); + log(`coop: IDEA-${idea.id} ${done ? 'done' : 'back in the queue'}: ${note}`); + } +} + +/** One round: read the answers, then send one waiting idea. */ +export async function round(s, { log = () => {} } = {}) { + try { + await collect(s, log); + } catch (e) { + log(`coop: ${e.message}`); + } + await dispatch(s, log); +} + +/** Run a round every 15 s on the ideamine server. Returns a function that stops it, or null when coop is off. */ +export function start({ log = () => {} } = {}) { + const s = settings(); + if (!s) return null; + let running = false; + const timer = setInterval(async () => { + if (running) return; + running = true; + try { + await round(s, { log }); + } catch (e) { + log(`coop: ${e.message}`); + } finally { + running = false; + } + }, ROUND_MS); + log(`coop: the agents of session ${s.session} at ${s.url} build the ideas dropped in Doing`); + return () => clearInterval(timer); +} diff --git a/src/serve.js b/src/serve.js index 8f991ec..1382869 100644 --- a/src/serve.js +++ b/src/serve.js @@ -19,6 +19,7 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import * as archive from './archive.js'; import * as config from './config.js'; +import * as coop from './coop.js'; import * as publish from './publish.js'; import { renderAdded, stamp } from './render.js'; import * as store from './store.js'; @@ -174,6 +175,7 @@ async function liveData() { prompts: synced || fs.existsSync(promptsPath()), memory: synced || !!config.get('memstate_url'), usage: synced || fs.existsSync(transcriptsDir()) || Object.keys(loadUsage().machines).length > 0, + coop: !synced && !!coop.settings(), // a drop in Doing asks a coop agent to build the idea }, }; } @@ -411,10 +413,10 @@ const ACTIONS = { return gone.queued ? archive.queuedText(gone) : gone.map((i) => `Removed #${i.id} · ${clip(i.title, 60)}`).join('\n'); }, - /** /ideas-done, /ideas-reopen, and the verbs start, drop, note, and model of the CLI. */ - async update({ id, status, note, model }) { - if (!status && !note && !model) throw new Error('nothing to change'); - const idea = await archive.update(id, { status, note, model }); + /** /ideas-done, /ideas-reopen, and the verbs start, drop, note, and model of the CLI. agent "any": a coop agent builds it. */ + async update({ id, status, note, model, agent }) { + if (!status && !note && !model && !agent) throw new Error('nothing to change'); + const idea = await archive.update(id, { status, note, model, agent }); if (idea.queued) return archive.queuedText(idea); const bits = [`#${idea.id} ${clip(idea.title, 60)} → ${store.lane(idea)}`]; if (model) bits.push(`model ${idea.triage.model}`); diff --git a/src/store.js b/src/store.js index e6c4b08..49989f5 100644 --- a/src/store.js +++ b/src/store.js @@ -374,6 +374,17 @@ export function updateIdea(id, patch = {}) { idea.status = status; if (status === 'done' || status === 'dropped') idea.closed = now(); else delete idea.closed; + if (status !== 'doing') delete idea.agent; // an agent works only on an idea in Doing + } + // agent "any": an agent of coop builds the idea (see coop.js). Only an idea in Doing can wait for one. + if (patch.agent != null) { + if (patch.agent !== 'any') throw new Error('agent must be "any"'); + if (idea.status !== 'doing') throw new Error('only an idea in Doing can wait for an agent'); + if (!idea.agent) { + idea.agent = { name: 'any', at: now() }; + idea.notes ||= []; + idea.notes.push({ at: now(), text: 'waits for a free coop agent' }); + } } if (patch.text) idea.text = String(patch.text).trim(); if (patch.title) idea.title = clip(patch.title, 90); diff --git a/tests/coop.test.js b/tests/coop.test.js new file mode 100644 index 0000000..7608ad7 --- /dev/null +++ b/tests/coop.test.js @@ -0,0 +1,199 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, test } from 'node:test'; +import fc from 'fast-check'; +import * as config from '../src/config.js'; +import * as coop from '../src/coop.js'; +import * as store from '../src/store.js'; + +const TOKEN = 'test-operator-token'; +let hub; // a fake coop hub: { url, sent, answers, messages, close } +let logs; + +/** A coop hub with the two operator routes that ideamine uses. `answers` are the replies to the next sends. */ +function startHub() { + const h = { sent: [], answers: [], messages: [], auth: [] }; + const server = http.createServer((req, res) => { + const chunks = []; + req.on('data', (c) => chunks.push(c)); + req.on('end', () => { + h.auth.push(req.headers.authorization); + const reply = (status, json) => res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(json)); + const u = new URL(req.url, 'http://hub'); + if (req.headers.authorization !== `Bearer ${TOKEN}`) return reply(401, { error: 'unauthorized' }); + if (req.method === 'POST' && u.pathname === '/v1/admin/sessions/workers/messages') { + h.sent.push(JSON.parse(Buffer.concat(chunks).toString())); + const [status, json] = h.answers.shift() || [404, { error: 'not_found', message: 'no peer in this session can take work now' }]; + return reply(status, json); + } + if (req.method === 'GET' && u.pathname === '/v1/admin/sessions/workers/messages') { + const after = Number(u.searchParams.get('after')); + return reply(200, { messages: h.messages.filter((m) => Number(m.id) > after).slice(0, Number(u.searchParams.get('limit'))) }); + } + reply(404, { error: 'not_found' }); + }); + }); + return new Promise((resolve) => + server.listen(0, '127.0.0.1', () => resolve({ ...h, h, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) })), + ); +} + +beforeEach(async () => { + process.env.IDEAMINE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-coop-')); + hub = await startHub(); + process.env.IDEAMINE_COOP_URL = hub.url; + process.env.IDEAMINE_COOP_TOKEN = TOKEN; + logs = []; +}); + +afterEach(async () => { + await hub.close(); + delete process.env.IDEAMINE_COOP_URL; + delete process.env.IDEAMINE_COOP_TOKEN; +}); + +const round = () => coop.round(coop.settings(), { log: (line) => logs.push(line) }); +const idea = (id) => store.findIdea(store.load(), id); +const lastNote = (id) => idea(id).notes.at(-1).text; + +/** An idea that the user dropped in Doing on a board with coop, through the page's own dropCommand. */ +function dropInDoing(text = 'build the thing') { + const [{ idea: added }] = store.addIdeas(text); + store.applyTriage([{ id: added.id, verdict: 'do', brief: 'the plan', model: 'sonnet' }]); + const page = fs.readFileSync(new URL('../dashboard/index.html', import.meta.url), 'utf8'); + const dropCommand = new Function('state', `${/function dropCommand[\s\S]*?\n}\n/.exec(page)[0]}; return dropCommand;`)({ data: { live: { coop: true } } }); + const command = dropCommand({ ...added, lane: 'do' }, 'doing'); + assert.deepEqual(command, { action: 'update', body: { id: added.id, status: 'doing', agent: 'any' } }); + const { id, ...patch } = command.body; + store.updateIdea(id, patch); + return id; +} + +test('coop is off without a hub url or without a token, the token is never a setting, and only Doing waits for an agent', () => { + assert.ok(coop.settings()); + assert.ok(!Object.keys(config.SETTINGS).some((key) => key.includes('token'))); + delete process.env.IDEAMINE_COOP_TOKEN; + assert.equal(coop.settings(), null); + process.env.IDEAMINE_COOP_TOKEN = TOKEN; + delete process.env.IDEAMINE_COOP_URL; + assert.equal(coop.settings(), null); + const [{ idea: i }] = store.addIdeas('x'); + assert.throws(() => store.updateIdea(i.id, { agent: 'any' }), /only an idea in Doing can wait for an agent/); + assert.throws(() => store.updateIdea(i.id, { status: 'doing', agent: 'claude@g' }), /agent must be "any"/); +}); + +test('a card dropped in Doing goes to a free agent, shows its name, and DONE closes it', async () => { + const id = dropInDoing(); + assert.equal(lastNote(id), 'waits for a free coop agent'); + hub.h.answers.push([200, { id: '101', to: 'claude@gentoo', sent_at: 'now' }]); + await round(); + assert.equal(hub.h.sent.length, 1); + assert.equal(hub.h.sent[0].to, 'any'); + assert.match(hub.h.sent[0].text, /^Card IDEA-1 of jose's ideamine board: build the thing\n\nbuild the thing\n\nPlan from the triage: the plan\n/); + assert.deepEqual([idea(id).status, idea(id).agent.name, idea(id).agent.msg], ['doing', 'claude@gentoo', '101']); + assert.equal(lastNote(id), 'coop gave it to claude@gentoo'); + + await round(); // no answer yet: nothing changes, and nothing else is sent + assert.equal(idea(id).agent.name, 'claude@gentoo'); + assert.equal(hub.h.sent.length, 1); + + hub.h.messages.push({ id: '102', from: 'claude@gentoo', to: 'operator', reply_to: '101', text: 'DONE: pushed abc123 to main' }); + await round(); + assert.equal(store.lane(idea(id)), 'done'); + assert.equal(idea(id).agent, undefined); + assert.equal(lastNote(id), 'claude@gentoo: DONE: pushed abc123 to main'); + assert.ok(hub.h.auth.every((a) => a === `Bearer ${TOKEN}`)); + assert.ok(logs.every((line) => !line.includes(TOKEN)), 'the token stays out of the log'); +}); + +test('an answer without DONE puts the card back in Do, with the answer as a note', async () => { + const id = dropInDoing(); + hub.h.answers.push([200, { id: '7', to: 'codex@gentoo' }]); + await round(); + hub.h.messages.push({ id: '8', from: 'codex@gentoo', reply_to: '7', text: 'FAILED: the project is not on this machine' }); + await round(); + assert.equal(store.lane(idea(id)), 'do'); + assert.equal(lastNote(id), 'codex@gentoo: FAILED: the project is not on this machine'); +}); + +test('when no agent is free, the card waits and goes in a later round', async () => { + const id = dropInDoing(); + await round(); // the hub answers 404 + assert.equal(idea(id).agent.name, 'any'); + assert.equal(logs.length, 0, 'a busy pool is normal, not an error'); + hub.h.answers.push([200, { id: '5', to: 'claude@subread' }]); + await round(); + assert.equal(idea(id).agent.name, 'claude@subread'); +}); + +test('a card that the user moves out of Doing is free of its agent, and a late answer changes nothing', async () => { + const id = dropInDoing(); + hub.h.answers.push([200, { id: '3', to: 'claude@honjimaku' }]); + await round(); + store.updateIdea(id, { status: 'reopen' }); + assert.equal(idea(id).agent, undefined); + hub.h.messages.push({ id: '4', from: 'claude@honjimaku', reply_to: '3', text: 'DONE: late' }); + await round(); + assert.equal(store.lane(idea(id)), 'do'); + assert.notEqual(lastNote(id), 'claude@honjimaku: DONE: late'); +}); + +test('a card without an answer for 3 hours goes back in Do', async () => { + const id = dropInDoing(); + hub.h.answers.push([200, { id: '9', to: 'claude@gentoo' }]); + await round(); + store.mutate((db) => (store.findIdea(db, id).agent.at = new Date(Date.now() - 4 * 3600000).toISOString())); + await round(); + assert.equal(store.lane(idea(id)), 'do'); + assert.equal(lastNote(id), 'no answer from claude@gentoo in 3 h'); +}); + +test('claim takes each waiting card once, oldest first, and never another card: a property', () => { + fc.assert( + fc.property(fc.array(fc.constantFrom('waits', 'doing', 'do', 'inbox', 'done'), { maxLength: 12 }), (kinds) => { + process.env.IDEAMINE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'ideamine-claim-')); + const waits = []; + kinds.forEach((kind, n) => { + const [{ idea: i }] = store.addIdeas(`idea ${n}`); + if (kind === 'do') store.applyTriage([{ id: i.id, verdict: 'do' }]); + if (['waits', 'doing', 'done'].includes(kind)) store.updateIdea(i.id, { status: kind === 'done' ? 'done' : 'doing' }); + if (kind === 'waits') { + store.updateIdea(i.id, { agent: 'any' }); + store.mutate((db) => (store.findIdea(db, i.id).agent.at = new Date(Date.UTC(2026, 0, 1, 0, 0, 59 - n)).toISOString())); + waits.push(i.id); + } + }); + const taken = []; + for (let c; (c = coop.claim()); ) taken.push(c.id); + assert.deepEqual(taken, waits.reverse()); // the later idea waits longer here + assert.ok(store.load().ideas.every((i) => (waits.includes(i.id) ? i.agent.name === 'coop' : !i.agent))); + }), + { numRuns: 60 }, + ); +}); + +test('claims from many processes at once never give one card twice', async () => { + const cards = 24; + for (let n = 0; n < cards; n++) { + const [{ idea: i }] = store.addIdeas(`card ${n}`); + store.updateIdea(i.id, { status: 'doing', agent: 'any' }); + } + const mod = new URL('../src/coop.js', import.meta.url).href; + const run = () => + new Promise((resolve, reject) => { + const script = `const c = await import(${JSON.stringify(mod)}); const got = []; for (let x; (x = c.claim()); ) got.push(x.id); console.log(JSON.stringify(got));`; + const child = spawn(process.execPath, ['--input-type=module', '-e', script], { env: process.env, stdio: ['ignore', 'pipe', 'pipe'] }); + let out = ''; + let err = ''; + child.stdout.on('data', (d) => (out += d)); + child.stderr.on('data', (d) => (err += d)); + child.on('close', (code) => (code === 0 ? resolve(JSON.parse(out)) : reject(new Error(err)))); + }); + const all = (await Promise.all(Array.from({ length: 6 }, run))).flat(); + assert.equal(all.length, cards); + assert.equal(new Set(all).size, cards); +}); diff --git a/tests/serve.test.js b/tests/serve.test.js index f0abf3d..c8496bb 100644 --- a/tests/serve.test.js +++ b/tests/serve.test.js @@ -28,7 +28,7 @@ beforeEach(async () => { // The triage and the questions must never reach the real claude or the projects of this machine. process.env.IDEAMINE_CLAUDE_BIN = fileURLToPath(new URL('./fixtures/fake-claude.js', import.meta.url)); process.env.CLAUDE_CONFIG_DIR = process.env.IDEAMINE_HOME; - for (const name of ['IDEAMINE_SYNC_URL', 'IDEAMINE_MEMSTATE_URL', 'IDEAMINE_SERVE_HOSTS', 'IDEAMINE_PROMPT_LOG']) delete process.env[name]; + for (const name of ['IDEAMINE_SYNC_URL', 'IDEAMINE_MEMSTATE_URL', 'IDEAMINE_SERVE_HOSTS', 'IDEAMINE_PROMPT_LOG', 'IDEAMINE_COOP_URL', 'IDEAMINE_COOP_TOKEN']) delete process.env[name]; embedServer = await startFakeServer(); process.env.IDEAMINE_EMBED_URL = `${embedServer.url}/v1`; opened = []; @@ -142,6 +142,7 @@ test('the page comes with live data, and no other page can frame it', async () = prompts: false, memory: false, usage: false, // no transcripts of Claude Code in the test home + coop: false, // no coop hub: a drop in Doing only moves the card }); assert.match(data.live.watch.status, /^ideamine watch: off/); });