Recently Written · git

ideamine

An idea inbox for Claude Code: /idea saves ideas at zero tokens; Claude triages them and routes each to the cheapest model that can build it.

git clone https://github.com/equwal/ideamine

Log | Files | Refs


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/);
 });