From 408052dbafc1f175d25809322346ee7ec733d747 Mon Sep 17 00:00:00 2001 From: Eugene Turetsky Date: Thu, 3 Sep 2026 04:47:15 -0400 Subject: [PATCH 01/67] Multi-AI Chat tab: initial native port of ProximaChatApp Classic (discuss/debate/personas/judge) and Orchestrated (crew/workflow/loop/agent) modes inside the Proxima Electron app, with ledger, message labels, attachments and context references. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ACdKJmXfzvwfCCLneCWd12 --- assets/multiai-icon.svg | 13 + electron/index-v2.html | 242 +++++- electron/ipc/multiai.cjs | 564 ++++++++++++ electron/main-v2.cjs | 2 + electron/multiai-renderer.js | 1570 ++++++++++++++++++++++++++++++++++ electron/preload.cjs | 22 +- 6 files changed, 2410 insertions(+), 3 deletions(-) create mode 100644 assets/multiai-icon.svg create mode 100644 electron/ipc/multiai.cjs create mode 100644 electron/multiai-renderer.js diff --git a/assets/multiai-icon.svg b/assets/multiai-icon.svg new file mode 100644 index 0000000..3f93458 --- /dev/null +++ b/assets/multiai-icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/electron/index-v2.html b/electron/index-v2.html index afc2123..891b358 100644 --- a/electron/index-v2.html +++ b/electron/index-v2.html @@ -84,6 +84,14 @@ background: rgba(255, 255, 255, 0.2); transform: translateY(-1px); } + #multiai-panel select { + background: #1a1a2e; + color: #fff; + } + #multiai-panel select option { + background: #1a1a2e; + color: #fff; + } .tab-nav { display: flex; @@ -1308,6 +1316,10 @@
+
+ Multi-AI Chat + Multi-AI Chat +
Perplexity Perplexity @@ -1328,7 +1340,7 @@ Gemini
-
+
-
+ + + +
+ +
+
+ 🧠 Multi-AI Chat + +
+
+ +
+
+
+
+
+
+
+
New chat
+ +
+
+ + + +
+
+ +
+ + +
+ + +
+ +
+ + + + +
+ + +
+ + +
+
+
+
+
+
+ πŸ“’ Ledger + +
+ +
+ + +
+
+ + + + + + + + + + +
CΒ·RAgentTimeMessage
+
+
+ + + + + +
+
@@ -2909,6 +3128,7 @@
- -
@@ -1503,6 +1509,7 @@

Get Started

+
@@ -1592,7 +1599,12 @@

Get Started

Discuss vs. Debate β€” Discuss has participants build on each other's points toward a shared answer. Debate assigns opposing sides and has them argue; with more than two participants each takes a distinct stance.

Rounds β€” how many times each participant speaks in turn. One round is usually enough for a quick comparison; use 2-4 for a discussion that needs to actually converge or a debate that needs rebuttals. More rounds costs more time and, if you're on metered API keys, more money.

Brevity β€” caps reply length. "max 4 sentences" keeps a multi-round discussion skimmable; switch to "as long as needed" for a single deep-dive round.

-

Judge β€” after a discussion, ask one provider to read the transcript and give a verdict. Judge scope picks whether it reads everything or just the final round (cheaper, and better once earlier rounds are just warm-up).

+

Context window β€” each participant is sent the last N messages verbatim, a condensed digest of older ones, and the pinned State summary if there is one. Long chats therefore stop re-sending their whole history every turn (the old behaviour hit provider limits after a few dozen long replies). Each chat also gets its own provider-side thread per AI, rotated every 12 turns, so chats never bleed into each other or into the MCP tools.

+

Stop token / PASS β€” participants are asked to begin a reply with the stop token (default DONE) when they think the group has converged, and to reply PASS when they have nothing new. With β€œStop early when all agree” on, a round where everyone says the token or passes ends the run β€” no more rounds of β€œAgreed, nothing further”. Passes are shown dimmed and are not fed to the other AIs; neither are errors.

+

Directed turns β€” mention participants in your message to set this Send's order: @claude kick off, others review makes Claude speak first; only @gemini and @chatgpt restricts the round to those two. Names: @chatgpt, @claude, @gemini, @perplexity.

+

Independent first round β€” round 1 of each Send runs everyone in parallel, blind to each other's replies (positions are committed before anyone reads the others); later rounds are round-robin as usual. In Debate with three or more participants, stances are assigned (for / against / critical evaluator / third option) rather than left to chance.

+

Judge β€” after a discussion, ask one provider to read the transcript and give a verdict. Judge scope: the whole chat, everything since your last message, or only the last round.

+

Summarize state β€” asks the Judge provider for a pinned summary (decisions, open questions, claims to verify, next step). It becomes the compaction point: later turns start from the summary instead of the older transcript. Use it whenever you'd otherwise ask β€œwhat is the state of the discussion”.

Personas β€” a shared library of a few built-ins (Skeptic, Optimist, Devil's Advocate, Pragmatist, Domain Expert, Concise Summarizer) plus any .md/.txt file you drop in the personas folder. Assign one per participant in the Personas block above (a separate setting from picking who's in the discussion); the same library also fills in a crew role's Instruction in Orchestrated mode, so you're not maintaining two systems.

Orchestrated mode

@@ -1605,7 +1617,9 @@

Get Started

General

Attachments (πŸ“Ž) fold text files into your prompt and note images/binaries by name only β€” none of these providers take image input through this tab.

Stop interrupts as soon as you click it β€” the AI in flight may keep replying in the background, but its answer is discarded rather than shown.

-

New chats are auto-titled from your first message once the first reply is underway; rename any chat any time via the ✏ next to its title, in the sidebar, or in the header.

+

New chats are titled from the first words of your first message (free, instant); ✨ next to the title asks the Judge provider for a better one. Rename any time via ✏.

+

Local agents β€” Codex, Claude Code or any script can join a chat through the gateway API on this app's REST port (Settings β†’ API, default http://127.0.0.1:3210): GET /v1/multiai/chats, GET /v1/multiai/chats/<id>?since=<messageId>, POST /v1/multiai/chats/<id>/messages {"who":"Codex","provider":"codex","text":"…"} to append a turn, POST /v1/multiai/chats/<id>/run {"text":"…","rounds":1} to post a message and run a round, POST …/stop. Appended turns show up here live and are part of the transcript the browser AIs see.

+

Runs belong to their chat, not to the screen: you can switch chats while one is running (● in the list), and its messages keep arriving and are saved. Each chat allows one run at a time. History is saved atomically with a rolling backup (multiai/chats.backup.json in the app's data folder); a damaged file is parked, never overwritten.

Message IDs & context β€” every message has a hidden unique id and a short label like 0103CG: cycle 01 (a cycle is one Send), round 03 (the turn within it), then a 2-letter agent code. Right-click any message, in the feed or the ledger, to add it as context for your next message (it's folded into the outgoing prompt and called out by label, even across chats) or to copy its reference label.

Click a ledger row to jump the transcript to that message. The ledger's two dropdowns filter by cycleΒ·round and by agent.

Outside of Agent mode, these providers only see text you send them β€” none can read or write files on this computer. The πŸ“Ž attach button is a one-way bridge: it reads a local file client-side and folds its text into your prompt (images/binaries are noted by name only).

diff --git a/electron/ipc/multiai-core.cjs b/electron/ipc/multiai-core.cjs new file mode 100644 index 0000000..544f73d --- /dev/null +++ b/electron/ipc/multiai-core.cjs @@ -0,0 +1,502 @@ +// Proxima β€” Multi-AI Chat core (no Electron dependency). +// Everything here is pure logic so it can be unit-tested with plain node: +// - createStore(): the chats.json store with atomic writes, a rolling +// backup, corruption recovery and upsert-merge semantics (the renderer +// saves whole snapshots; the main process appends single messages; both +// must never clobber each other). +// - buildClassicPrompt() / buildContext(): the bounded-context prompt each +// participant receives. Old transcripts are condensed, recent ones are +// verbatim, and a pinned "state summary" replaces everything before it, +// so a long chat no longer re-sends its entire history every turn. +// - Small helpers: stances for debates, @mention parsing for directed +// turns, stop-token / PASS detection, local titles, tool-call parsing. + +const DEFAULTS = Object.freeze({ + contextWindow: 10, // recent messages sent verbatim + digestCharsPerMessage: 400, // older assistant messages are condensed to this + digestUserChars: 1500, // the human's own messages are kept longer (they're instructions) + digestMaxChars: 30000, // total budget for the condensed block + stopToken: 'DONE', + providerResetEvery: 12, // start a fresh provider-side thread after this many turns +}); + +const KNOWN_PROVIDERS = ['chatgpt', 'claude', 'gemini', 'perplexity']; + +const PROVIDER_LABELS = { + chatgpt: 'ChatGPT', claude: 'Claude', gemini: 'Gemini', perplexity: 'Perplexity', + codex: 'Codex', 'claude-code': 'Claude Code', human: 'You', +}; + +const PROVIDER_CODES = { + chatgpt: 'CG', claude: 'CL', gemini: 'GM', perplexity: 'PX', + codex: 'CX', 'claude-code': 'CC', human: 'US', external: 'EX', +}; + +function providerLabel(p) { + if (!p) return ''; + const base = String(p).split(':')[0]; + return PROVIDER_LABELS[base] || base; +} + +function providerCode(p) { + if (!p) return '--'; + const base = String(p).split(':')[0]; + return PROVIDER_CODES[base] || base.slice(0, 2).toUpperCase(); +} + +function genMsgId() { + return 'm' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); +} + +function genChatId() { + return `chat_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +} + +// Human-readable reference label: cycle (one per Send) Β· round Β· agent code. +function refLabel(msg) { + const c = msg.cycle || 1; + const r = msg.round || 0; + let code; + if (msg.role === 'user') code = 'US'; + else if (msg.role === 'judge') code = 'JD'; + else if (msg.role === 'summary') code = 'SM'; + else if (msg.role === 'system') code = 'SY'; + else if (msg.role === 'error') code = 'ER'; + else if (msg.role === 'pass') code = providerCode(msg.provider); + else code = providerCode(msg.provider); + return `${String(c).padStart(2, '0')}${String(r).padStart(2, '0')}${code}`; +} + +// ---- Store ------------------------------------------------------------ + +function emptyStore() { + return { projects: [{ id: 'default', name: 'General', chats: [] }], uiPrefs: {} }; +} + +function normalizeStore(data) { + if (!data || typeof data !== 'object' || !Array.isArray(data.projects)) return emptyStore(); + if (!data.projects.length) data.projects.push({ id: 'default', name: 'General', chats: [] }); + for (const p of data.projects) { + if (!Array.isArray(p.chats)) p.chats = []; + for (const c of p.chats) { + if (!Array.isArray(c.messages)) c.messages = []; + for (const m of c.messages) if (!m.id) m.id = genMsgId(); + } + } + if (!data.uiPrefs || typeof data.uiPrefs !== 'object') data.uiPrefs = {}; + if (!Array.isArray(data.deletedChatIds)) data.deletedChatIds = []; + return data; +} + +function findChatIn(store, chatId) { + for (const p of store.projects) { + const c = p.chats.find(x => x.id === chatId); + if (c) return { project: p, chat: c }; + } + return null; +} + +// Upsert merge: `incoming` is the renderer's snapshot; `disk` may contain +// chats/messages appended by the main process (REST, run loops) that the +// renderer hasn't seen yet. Result keeps everything from both. Deletions are +// never inferred from a snapshot β€” they go through deleteChat() explicitly. +function mergeStores(disk, incoming) { + const out = normalizeStore(incoming ? JSON.parse(JSON.stringify(incoming)) : emptyStore()); + if (!disk) return out; + // Tombstones: a chat deleted on disk must not come back from a snapshot + // taken before the delete. + const dead = new Set((disk.deletedChatIds || []).concat(out.deletedChatIds || [])); + out.deletedChatIds = Array.from(dead).slice(-500); + for (const p of out.projects) p.chats = p.chats.filter(c => !dead.has(c.id)); + for (const dp of disk.projects || []) { + let op = out.projects.find(p => p.id === dp.id); + if (!op) { op = { id: dp.id, name: dp.name, chats: [] }; out.projects.push(op); } + for (const dc of dp.chats || []) { + if (dead.has(dc.id)) continue; + const loc = findChatIn(out, dc.id); + if (!loc) { op.chats.push(dc); continue; } + const oc = loc.chat; + const seen = new Set((oc.messages || []).map(m => m.id)); + const extras = (dc.messages || []).filter(m => m.id && !seen.has(m.id)); + if (extras.length) { + oc.messages = (oc.messages || []).concat(extras).sort((a, b) => (a.ts || 0) - (b.ts || 0) || 0); + } + if ((dc.cycle || 0) > (oc.cycle || 0)) oc.cycle = dc.cycle; + // Provider thread bookkeeping is written only by the main process; + // the disk copy is authoritative over any snapshot the renderer holds. + if (dc.providerSessions) oc.providerSessions = dc.providerSessions; + } + } + if (disk.uiPrefs && !Object.keys(out.uiPrefs || {}).length) out.uiPrefs = disk.uiPrefs; + return out; +} + +function createStore({ dir, fs, path, log }) { + const logger = log || (() => { }); + const file = () => path.join(dir, 'chats.json'); + const tmpFile = () => path.join(dir, 'chats.json.tmp'); + const backupFile = () => path.join(dir, 'chats.backup.json'); + + function readJson(p) { + return JSON.parse(fs.readFileSync(p, 'utf-8')); + } + + function load() { + fs.mkdirSync(dir, { recursive: true }); + const f = file(); + if (!fs.existsSync(f)) { + // A crash between the two renames in save() leaves only the backup. + if (fs.existsSync(backupFile())) { + try { return normalizeStore(readJson(backupFile())); } catch { /* fall through */ } + } + return emptyStore(); + } + try { + return normalizeStore(readJson(f)); + } catch (e) { + // Never silently replace a corrupt store with an empty one: park it + // under a dated name and fall back to the last good backup. + const parked = path.join(dir, `chats.corrupt-${Date.now()}.json`); + try { fs.renameSync(f, parked); } catch { /* ignore */ } + logger(`[MultiAI] chats.json unreadable (${e.message}); parked as ${path.basename(parked)}`); + if (fs.existsSync(backupFile())) { + try { + const data = normalizeStore(readJson(backupFile())); + logger('[MultiAI] Restored chats from chats.backup.json'); + return data; + } catch (e2) { + logger(`[MultiAI] Backup also unreadable: ${e2.message}`); + } + } + return emptyStore(); + } + } + + // Atomic: write tmp β†’ rotate current to backup β†’ rename tmp into place. + function save(data) { + fs.mkdirSync(dir, { recursive: true }); + const json = JSON.stringify(data, null, 2); + fs.writeFileSync(tmpFile(), json, 'utf-8'); + if (fs.existsSync(file())) { + try { fs.renameSync(file(), backupFile()); } catch { /* keep going; tmp is still valid */ } + } + fs.renameSync(tmpFile(), file()); + return { success: true }; + } + + // Renderer snapshot save: merge with disk so nothing appended by main + // since the renderer's last load is lost. + function saveSnapshot(incoming) { + const merged = mergeStores(load(), incoming); + save(merged); + return merged; + } + + function getChat(chatId) { + const loc = findChatIn(load(), chatId); + return loc ? loc.chat : null; + } + + function listChats() { + const data = load(); + return data.projects.flatMap(p => p.chats.map(c => ({ + id: c.id, title: c.title, mode: c.mode, strategy: c.strategy, + participants: c.participants || [], createdAt: c.createdAt || null, + updatedAt: (c.messages || []).reduce((mx, m) => Math.max(mx, m.ts || 0), 0) || c.createdAt || null, + messageCount: (c.messages || []).length, cycle: c.cycle || 0, + }))); + } + + function createChat(fields) { + const data = load(); + const chat = Object.assign({ + id: genChatId(), title: 'New chat', mode: 'classic', topic: '', discussMode: 'discuss', + rounds: 1, brevity: 'max 4 sentences', participants: [], personas: {}, judgeProvider: '', + judgeScope: 'all', strategy: 'crew', messages: [], createdAt: Date.now(), cycle: 0, + }, fields || {}); + chat.messages = chat.messages || []; + data.projects[0].chats.push(chat); + save(data); + return chat; + } + + // `patch` is an object to merge, or a function(chat) that mutates in + // place β€” use the function form for nested fields so concurrent callers + // touching different keys don't overwrite each other. + function updateChat(chatId, patch) { + const data = load(); + const loc = findChatIn(data, chatId); + if (!loc) return null; + if (typeof patch === 'function') patch(loc.chat); + else Object.assign(loc.chat, patch || {}); + save(data); + return loc.chat; + } + + function appendMessage(chatId, message) { + const data = load(); + const loc = findChatIn(data, chatId); + if (!loc) return null; + const chat = loc.chat; + const msg = Object.assign({}, message); + if (!msg.id) msg.id = genMsgId(); + if (!msg.ts) msg.ts = Date.now(); + if (!msg.cycle) msg.cycle = chat.cycle || 1; + if (msg.round == null) msg.round = 0; + if ((chat.messages || []).some(m => m.id === msg.id)) return msg; + chat.messages.push(msg); + save(data); + return msg; + } + + function deleteChat(chatId) { + const data = load(); + for (const p of data.projects) { + const i = p.chats.findIndex(c => c.id === chatId); + if (i !== -1) { + p.chats.splice(i, 1); + data.deletedChatIds = (data.deletedChatIds || []).filter(id => id !== chatId).concat([chatId]).slice(-500); + save(data); + return true; + } + } + return false; + } + + return { load, save, saveSnapshot, getChat, listChats, createChat, updateChat, appendMessage, deleteChat, file, backupFile }; +} + +// ---- Transcript / prompt building -------------------------------------- + +// Roles that other participants should actually read. Errors, stop notices, +// system notes and PASSes are UI bookkeeping, not conversation. +const READABLE_ROLES = new Set(['user', 'assistant', 'judge', 'summary']); + +function readable(messages) { + return (messages || []).filter(m => READABLE_ROLES.has(m.role || 'assistant') && (m.text || '').trim()); +} + +function whoOf(m) { + if (m.role === 'user') return m.who || 'You'; + if (m.role === 'judge') return m.who || 'Judge'; + if (m.role === 'summary') return 'State summary'; + return m.who || providerLabel(m.provider) || 'Participant'; +} + +function condense(text, max) { + const t = String(text || '').replace(/\s+/g, ' ').trim(); + return t.length > max ? t.slice(0, max) + '…' : t; +} + +// Splits a chat's readable messages into: the latest pinned summary (if any), +// a condensed digest of what came after it but before the window, and the +// recent window verbatim. Everything before the summary is dropped β€” the +// summary is the compaction point. +function buildContext(messages, opts) { + const o = Object.assign({}, DEFAULTS, opts || {}); + const all = readable(messages); + let summaryMsg = null; + for (let i = all.length - 1; i >= 0; i--) { + if (all[i].role === 'summary') { summaryMsg = all[i]; break; } + } + const afterSummary = summaryMsg ? all.slice(all.indexOf(summaryMsg) + 1) : all; + const window = Math.max(0, o.contextWindow | 0); + const recent = window ? afterSummary.slice(-window) : []; + const older = window ? afterSummary.slice(0, Math.max(0, afterSummary.length - window)) : afterSummary; + + // Digest oldest-first but trim from the oldest end when over budget, + // always keeping the first human message (usually the brief). + const lines = older.map(m => { + const cap = m.role === 'user' ? o.digestUserChars : o.digestCharsPerMessage; + return `[${refLabel(m)}] ${whoOf(m)}: ${condense(m.text, cap)}`; + }); + let total = lines.reduce((n, l) => n + l.length + 1, 0); + let dropped = 0; + // Pin the opening human message when it leads the digest; drop the + // oldest unpinned line until the block fits its budget. + const pinned = (older.length && older[0].role === 'user') ? 1 : 0; + while (total > o.digestMaxChars && lines.length > pinned) { + total -= lines[pinned].length + 1; + lines.splice(pinned, 1); + dropped++; + } + + const parts = []; + if (summaryMsg) parts.push(`State summary (as of ${refLabel(summaryMsg)}):\n${summaryMsg.text.trim()}`); + if (lines.length) { + parts.push(`Earlier discussion (condensed${dropped ? `, ${dropped} older message${dropped === 1 ? '' : 's'} omitted` : ''}):\n${lines.join('\n')}`); + } + if (recent.length) { + // Attachments / referenced context ride along with the human message + // that carried them, verbatim while in the window, dropped once condensed. + parts.push(`Recent messages (verbatim):\n${recent.map(m => `[${refLabel(m)}] ${whoOf(m)}: ${m.text.trim()}${m.attachmentsText ? '\n' + m.attachmentsText.trim() : ''}`).join('\n\n')}`); + } + return { + text: parts.join('\n\n') || '(no messages yet)', + summary: summaryMsg, digestCount: lines.length, recentCount: recent.length, dropped, + }; +} + +// Full transcript, used only for one-off readers (judge, summarizer, export). +function renderFullTranscript(messages) { + return readable(messages).map(m => `[${refLabel(m)}] ${whoOf(m)}: ${m.text.trim()}${m.attachmentsText ? '\n' + m.attachmentsText.trim() : ''}`).join('\n\n') || '(no messages yet)'; +} + +// The brief is the first human message; the latest instruction is the last. +function briefAndLatest(messages, fallbackTopic) { + const users = (messages || []).filter(m => m.role === 'user' && (m.text || '').trim()); + const brief = users.length ? users[0].text.trim() : (fallbackTopic || ''); + const latest = users.length ? users[users.length - 1].text.trim() : (fallbackTopic || ''); + return { brief, latest }; +} + +// Debate stances. Two participants: for/against. More: a distinct, assigned +// stance each, so they don't all drift to the same position. +const STANCES = [ + 'Argue FOR the primary proposal / first option.', + 'Argue AGAINST the primary proposal / first option; make the strongest case for the alternative.', + 'Act as the critical evaluator: do not take a side, stress-test both positions and name what evidence would settle it.', + 'Propose and defend a third option neither side has considered.', +]; + +function stanceFor(mode, index, count) { + if (mode !== 'debate') return ''; + if (count === 2) return index === 0 ? 'You argue strongly FOR the first option.' : 'You argue strongly AGAINST the first option (for the alternative).'; + return STANCES[index % STANCES.length]; +} + +function buildClassicPrompt({ chatTitle, topic, latestUserMessage, mode, brevity, persona, participants, provider, messages, contextOpts, stopToken, independent }) { + const base = String(provider).split(':')[0]; + const idx = Math.max(0, participants.indexOf(provider), participants.indexOf(base)); + const others = participants.filter(p => p !== provider && p !== base).map(providerLabel); + const side = stanceFor(mode, idx, participants.length); + const personaBlock = persona ? `[Your assigned persona / instructions β€” stay in character]\n${persona}\n\n` : ''; + const ctx = independent + ? buildContext(messages.filter(m => m.role !== 'assistant' || m.cycle !== independent.cycle), contextOpts) + : buildContext(messages, contextOpts); + const token = stopToken || DEFAULTS.stopToken; + const rules = [ + `Add your next contribution (${brevity || 'max 4 sentences'}). Be substantive and specific; build on or directly challenge points already made. Do not repeat yourself or restate what others said. Reply with only your message.`, + `If you have nothing new to add this turn, reply with exactly: PASS`, + `If you believe the group has fully converged and no further discussion is needed, begin your reply with the single word ${token} followed by your final position.`, + ]; + if (independent) rules.push('This is an independent first round: you have NOT been shown the other participants\' replies to the latest message. Commit to your own position; do not guess at theirs.'); + return `${personaBlock}You are ${providerLabel(provider)}, one participant in a multi-AI conversation` + + `${others.length ? ` with ${others.join(', ')}` : ''}${chatTitle ? ` titled "${chatTitle}"` : ''}.\n` + + `Topic / brief: ${topic || '(see conversation)'}\n` + + `${latestUserMessage && latestUserMessage !== topic ? `Latest instruction from the human: ${latestUserMessage}\n` : ''}` + + `${side ? side + '\n' : ''}\n` + + `${ctx.text}\n\n` + + rules.join('\n'); +} + +function buildJudgePrompt({ topic, messages }) { + return `You are an impartial judge. Topic: ${topic}\n\nTranscript:\n${renderFullTranscript(messages)}\n\n` + + `Deliver a concise verdict: who made the stronger case and why, the single best point from each participant, ` + + `and any unresolved question. Max 6 sentences.`; +} + +function buildSummaryPrompt({ topic, messages }) { + return `You are the note-taker for a multi-AI working session. Topic: ${topic}\n\nTranscript:\n${renderFullTranscript(messages)}\n\n` + + `Write a state summary that will replace this transcript as context for the participants. Include, as short labelled sections: ` + + `DECISIONS (what has been agreed, with who proposed it), OPEN QUESTIONS (unresolved disagreements, with each side's position), ` + + `CLAIMS TO VERIFY (anything asserted but not verified), and NEXT STEP (the single most useful next contribution). ` + + `Be faithful to the transcript; do not add opinions. Max 350 words.`; +} + +function buildTitlePrompt(firstPrompt) { + return `Reply with ONLY a short descriptive title (max 6 words, no quotes, no trailing punctuation) for a conversation that begins with this message:\n\n"${firstPrompt}"`; +} + +// Local, zero-cost title from the first message (PCA behaviour). +function localTitle(text, maxLen = 60) { + const words = String(text || '').replace(/(^|\s)@[\w-]+/g, ' ').replace(/^[\s,:;-]+/, '').replace(/[*#>`_\[\]]/g, '').split(/\s+/).filter(Boolean); + let name = words.slice(0, 8).join(' ') || 'New chat'; + if (name.length > maxLen) name = name.slice(0, maxLen - 1).trimEnd() + '…'; + return name; +} + +function cleanTitle(raw, maxLen = 60) { + let t = String(raw || '').trim().split('\n')[0].replace(/^["'β€œβ€]+|["'β€œβ€]+$/g, '').replace(/[.!?]+$/, '').trim(); + if (t.length > maxLen) t = t.slice(0, maxLen); + return t; +} + +// "@claude kick off, others review after" β†’ ['claude', ...rest]. +// Mentioned participants go first in mention order; the rest follow in their +// configured order. "@claude only" / "only @claude" restricts to the mentioned set. +function parseMentions(text, participants) { + const t = String(text || ''); + const found = []; + const re = /(^|[^\w@])@([a-z][\w-]*)/gi; + let m; + while ((m = re.exec(t)) !== null) { + const name = m[2].toLowerCase(); + const hit = participants.find(p => p.split(':')[0] === name || providerLabel(p).toLowerCase().replace(/\s+/g, '') === name); + if (hit && !found.includes(hit)) found.push(hit); + } + if (!found.length) return { order: participants.slice(), only: false, mentioned: [] }; + const only = /\bonly\b/i.test(t); + const order = only ? found : found.concat(participants.filter(p => !found.includes(p))); + return { order, only, mentioned: found }; +} + +function isPass(text) { + return /^\s*\**\s*PASS\s*\**\s*[.!]?\s*$/i.test(String(text || '')); +} + +function startsWithToken(text, token) { + const tok = (token || DEFAULTS.stopToken).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`^\\s*[*_#>\\s]*${tok}\\b`, 'i').test(String(text || '')); +} + +// Round outcome: converged when every participant that replied this round +// either said the stop token or passed, and at least one said the token. +function roundOutcome(roundMessages, token) { + const replies = roundMessages.filter(m => m.role === 'assistant' || m.role === 'pass'); + if (!replies.length) return { converged: false, allPassed: false }; + const tokenCount = replies.filter(m => m.role === 'assistant' && startsWithToken(m.text, token)).length; + const passCount = replies.filter(m => m.role === 'pass').length; + const allPassed = passCount === replies.length; + const converged = tokenCount > 0 && tokenCount + passCount === replies.length; + return { converged, allPassed, tokenCount, passCount }; +} + +// Session naming: one provider-side thread per Multi-AI chat, plus an +// auxiliary thread for one-off calls (judge / summary / title) that is reset +// before each use so it never accumulates. +function sessionFor(chatId) { return `multiai:${chatId}`; } +function auxSessionFor(chatId) { return `multiai:${chatId}:aux`; } + +function parseToolCall(text) { + const m = /```tool\s*([\s\S]*?)```/i.exec(text || ''); + if (!m) return null; + try { + const obj = JSON.parse(m[1].trim()); + if (!obj || typeof obj.op !== 'string') return null; + return obj; + } catch { + return null; + } +} + +// "provider: task" only when the prefix is an actual provider name; +// "Summarize: the key points" stays a task for the default provider. +function parseWorkflowLine(line, known) { + const m = /^([a-z][\w-]*(?::[\w.-]+)?)\s*:\s*(.+)$/i.exec(line.trim()); + if (m) { + const base = m[1].toLowerCase().split(':')[0]; + if ((known || KNOWN_PROVIDERS).includes(base)) return { provider: m[1].toLowerCase(), task: m[2].trim() }; + } + return { task: line.trim() }; +} + +module.exports = { + DEFAULTS, KNOWN_PROVIDERS, PROVIDER_LABELS, PROVIDER_CODES, + providerLabel, providerCode, genMsgId, genChatId, refLabel, + emptyStore, normalizeStore, findChatIn, mergeStores, createStore, + READABLE_ROLES, readable, buildContext, renderFullTranscript, briefAndLatest, stanceFor, + buildClassicPrompt, buildJudgePrompt, buildSummaryPrompt, buildTitlePrompt, + localTitle, cleanTitle, parseMentions, isPass, startsWithToken, roundOutcome, + sessionFor, auxSessionFor, parseToolCall, parseWorkflowLine, +}; diff --git a/electron/ipc/multiai.cjs b/electron/ipc/multiai.cjs index 2222aeb..58e9740 100644 --- a/electron/ipc/multiai.cjs +++ b/electron/ipc/multiai.cjs @@ -1,21 +1,32 @@ -// Proxima β€” Multi-AI Chat IPC Handlers. -// Adds a native "Multi-AI Chat" surface on top of the same browser-session +// Proxima β€” Multi-AI Chat IPC Handlers (main process). +// A native "Multi-AI Chat" surface on top of the same browser-session // providers Proxima already drives, with two logic modes: -// - Classic: discuss/debate/personas/judge, ported from the standalone -// ProximaChatApp (round-robin, one participant per turn). -// - Orchestrated: role-based crew / sequential workflow / iterate-loop, -// the same shapes as the MCP server's run_workflow/crew/run_loop tools, -// but calling sendMessageToProvider directly in-process instead of -// round-tripping through the MCP server's separate IPC bridge. -// History is stored in this app's own userData folder (multiai-chats.json), -// not an external file, so this is one integrated product rather than a -// second tool bolted on the side. - -const { ipcMain, app, dialog } = require('electron'); +// - Classic: discuss/debate with personas, stop-token convergence, an +// optional independent (parallel, blind) first round, directed turns via +// @mentions, a judge and a pinned state summary. +// - Orchestrated: role-based crew / sequential workflow / iterate-loop / +// file-tools agent, calling sendMessageToProvider in-process. +// +// Ownership rules (see multiai-core.cjs): +// - The main process owns persistence of every message it produces (runs, +// judge, summary, REST appends) via store.appendMessage(), and notifies +// the renderer with 'multiai-message'. The renderer keeps saving whole +// snapshots for settings/UI edits; store.saveSnapshot() merges, so the two +// never clobber each other. Deletions are explicit ('multiai-delete-chat'). +// - Each Multi-AI chat gets its own provider-side thread per provider +// (session id multiai::), rotated every +// DEFAULTS.providerResetEvery turns so provider threads stay bounded. +// Prompts are self-sufficient (bounded context built from the store), so +// a rotation never loses context. +// - Local agents (Codex, Claude Code, scripts) read and append through the +// REST API: GET/POST /v1/multiai/chats[...] on the existing gateway port. + +const { ipcMain, app, dialog, shell } = require('electron'); const fs = require('fs'); const path = require('path'); const sender = require('../providers/sender.cjs'); +const core = require('./multiai-core.cjs'); function dataDir() { const dir = path.join(app.getPath('userData'), 'multiai'); @@ -29,23 +40,6 @@ function personasDir() { return dir; } -function chatsFile() { - return path.join(dataDir(), 'chats.json'); -} - -function loadChats() { - try { - return JSON.parse(fs.readFileSync(chatsFile(), 'utf-8')); - } catch { - return { projects: [{ id: 'default', name: 'General', chats: [] }] }; - } -} - -function saveChats(data) { - fs.writeFileSync(chatsFile(), JSON.stringify(data, null, 2), 'utf-8'); - return { success: true }; -} - function listPersonas() { const dir = personasDir(); const out = {}; @@ -60,20 +54,48 @@ function listPersonas() { } function registerMultiAiHandlers(deps) { - const { mainWindow, loadSettings } = deps; - - // ---- cancellation ------------------------------------------------- - // A "signal" per running chatId lets Stop interrupt a call that's - // already in flight, instead of only taking effect between steps. - // sendMessageToProvider itself isn't cancellable (no AbortController - // support in the provider layer), so the underlying browser call keeps - // running in the background β€” but the run loop stops waiting on it the - // moment Stop is clicked, via Promise.race against the signal, and its - // eventual result is simply discarded. That's a real, immediate stop - // from the user's point of view, short of true request cancellation. + const { mainWindow, loadSettings, registerRouteExtension } = deps; + const store = core.createStore({ dir: dataDir(), fs, path, log: (m) => console.log(m) }); + + // ---- window / events --------------------------------------------- + function win() { + const w = typeof mainWindow === 'function' ? mainWindow() : mainWindow; + return (w && !w.isDestroyed()) ? w : null; + } + + function emit(channel, payload) { + const w = win(); + if (w) w.webContents.send(channel, payload); + } + + function enabledProviders() { + const settings = loadSettings(); + return Object.keys(settings.providers || {}).filter(p => settings.providers[p]?.enabled); + } + + // Persist + notify. Every message the main process produces goes through + // here so the store and the renderer stay in step. + function record(chatId, message) { + const saved = store.appendMessage(chatId, message); + if (saved) emit('multiai-message', { chatId, message: saved }); + return saved; + } + + function note(chatId, text, extra) { + return record(chatId, Object.assign({ who: 'System', role: 'system', text, ts: Date.now() }, extra || {})); + } + + // ---- cancellation / run guard ----------------------------------- + // One signal per running chat. A second run on a chat that's already + // running is refused (the UI can't express two concurrent runs in one + // transcript, and it used to double-post). Stop resolves the signal, so + // whatever provider call is in flight is abandoned immediately β€” the + // browser request itself isn't cancellable at the provider layer, so its + // eventual result is discarded rather than shown. const runSignals = new Map(); // chatId -> { cancelled, resolve, promise } function startSignal(chatId) { + if (runSignals.has(chatId)) throw new Error('A run is already in progress for this chat.'); let resolveFn; const promise = new Promise((res) => { resolveFn = res; }); const sig = { cancelled: false, resolve: resolveFn, promise }; @@ -85,9 +107,10 @@ function registerMultiAiHandlers(deps) { runSignals.delete(chatId); } - // Races a provider call against the cancellation signal for this chat. - // Returns { cancelled: true } immediately if Stop fires first, otherwise - // { ok: true, value } or { ok: false, error }. + function isRunning(chatId) { + return runSignals.has(chatId); + } + async function raceProvider(chatId, providerPromise) { const sig = runSignals.get(chatId); const settled = providerPromise.then( @@ -101,31 +124,67 @@ function registerMultiAiHandlers(deps) { ]); } - function win() { - const w = typeof mainWindow === 'function' ? mainWindow() : mainWindow; - return (w && !w.isDestroyed()) ? w : null; + // ---- provider sessions --------------------------------------------- + // Per chat, per provider: a generation counter and a turn counter. The + // session id embeds the generation; bumping it starts a fresh provider + // thread without touching the engine's live state (which a mid-flight + // reset for another chat could corrupt). + function providerString(chat, provider) { + const base = String(provider).split(':')[0]; + const engine = chat.engines && chat.engines[base]; + return engine || base; } - function emit(channel, payload) { - const w = win(); - if (w) w.webContents.send(channel, payload); + function sessionState(chat, base) { + const all = chat.providerSessions || {}; + return all[base] || { gen: 1, turns: 0 }; } - function enabledProviders() { - const settings = loadSettings(); - return Object.keys(settings.providers || {}).filter(p => settings.providers[p]?.enabled); + function sessionIdFor(chat, st) { + return `${core.sessionFor(chat.id)}:${st.gen}`; } - // ---- persistence ------------------------------------------------- + // Sends one turn on the chat's thread for that provider, then advances the + // turn counter (rotating the thread when it hits the bound). + async function sendOnChatThread(chat, provider, prompt) { + const base = String(provider).split(':')[0]; + let st = sessionState(chat, base); + if (st.turns >= core.DEFAULTS.providerResetEvery) st = { gen: st.gen + 1, turns: 0 }; + const sessionId = sessionIdFor(chat, st); + const result = await raceProvider(chat.id, sender.sendMessageToProvider(providerString(chat, provider), prompt, null, null, sessionId)); + if (result.ok || result.cancelled) { + const next = { gen: st.gen, turns: st.turns + 1 }; + chat.providerSessions = Object.assign({}, chat.providerSessions || {}, { [base]: next }); + store.updateChat(chat.id, (c) => { c.providerSessions = Object.assign({}, c.providerSessions || {}, { [base]: next }); }); + } + return result; + } - ipcMain.handle('multiai-load', () => loadChats()); - ipcMain.handle('multiai-save', (event, data) => saveChats(data)); + // One-off calls (judge, summary, title) always get a fresh thread so they + // never accumulate and never pollute the chat's own thread. + function sendAux(chat, provider, prompt) { + const sessionId = `${core.auxSessionFor(chat.id)}:${Date.now().toString(36)}`; + return sender.sendMessageToProvider(providerString(chat, provider), prompt, null, null, sessionId); + } + + function contextOpts(chat) { + const o = {}; + if (chat.contextWindow != null && chat.contextWindow !== '') o.contextWindow = Math.max(0, parseInt(chat.contextWindow, 10) || 0); + return o; + } + + // ---- persistence IPC ------------------------------------------------ + + ipcMain.handle('multiai-load', () => store.load()); + ipcMain.handle('multiai-save', (event, data) => { store.saveSnapshot(data); return { success: true }; }); + ipcMain.handle('multiai-delete-chat', (event, { chatId }) => ({ success: store.deleteChat(chatId) })); ipcMain.handle('multiai-list-personas', () => listPersonas()); ipcMain.handle('multiai-open-personas-folder', () => { - require('electron').shell.openPath(personasDir()); + shell.openPath(personasDir()); return { success: true }; }); ipcMain.handle('multiai-enabled-providers', () => enabledProviders()); + ipcMain.handle('multiai-running', () => Array.from(runSignals.keys())); ipcMain.handle('multiai-stop-run', (event, { chatId }) => { const sig = runSignals.get(chatId); @@ -153,260 +212,197 @@ function registerMultiAiHandlers(deps) { } }); - // Fire-and-forget helper used to auto-name a chat from its first message. - // Deliberately a one-off call, not part of the round-robin/orchestration - // machinery above β€” its result becomes the chat title only, and is never - // pushed into that chat's messages/ledger. - ipcMain.handle('multiai-generate-title', async (event, { provider, prompt }) => { + // On-demand AI title (the interim title is local and free β€” see renderer). + ipcMain.handle('multiai-generate-title', async (event, { chatId, provider, prompt }) => { try { - const result = await sender.sendMessageToProvider(provider, prompt); - return { success: true, title: result.response }; + const chat = store.getChat(chatId) || { id: chatId }; + const result = await sendAux(chat, provider, prompt); + const title = core.cleanTitle(result.response); + if (title && chat.id && store.getChat(chatId)) store.updateChat(chatId, { title }); + return { success: !!title, title }; } catch (e) { return { success: false, error: e.message }; } }); - // ---- File tools (Orchestrated "Agent" strategy) -------------------- - // None of the four providers have real API tool-calling wired up here β€” - // sender.js is a plain text-in/text-out pipe into the browser-automation - // layer, not a provider API with a `tools` schema. So this is a ReAct - // loop built entirely on our side: the model is instructed to emit a - // fenced ```tool {...}``` block when it wants a file operation, we parse - // and execute that against ONE user-chosen folder (never anything else β€” - // resolveInRoot refuses any path that resolves outside it), feed the - // result back as the next turn, and repeat until the model replies with - // plain text (no tool block), which is treated as its final answer. - // Per explicit user choice this executes reads AND writes/deletes with - // no confirmation step β€” the only safety boundary is the folder scope. + // ---- Classic mode: discuss / debate --------------------------------- - ipcMain.handle('multiai-tools-pick-folder', async () => { - const w = win(); - try { - const result = await dialog.showOpenDialog(w || undefined, { - title: 'Choose a working folder for Agent (file tools)', - properties: ['openDirectory', 'createDirectory'], - }); - if (result.canceled || !result.filePaths.length) return { success: false, canceled: true }; - return { success: true, path: result.filePaths[0] }; - } catch (e) { - return { success: false, error: e.message }; - } - }); + function messagesOfRound(chat, cycle, round) { + return (chat.messages || []).filter(m => (m.cycle || 1) === cycle && m.round === round); + } - function resolveInRoot(root, relPath) { - const normalizedRoot = path.resolve(root); - const target = path.resolve(normalizedRoot, relPath || '.'); - if (target !== normalizedRoot && !target.startsWith(normalizedRoot + path.sep)) { - throw new Error('That path is outside the working folder β€” refused.'); + async function takeTurn(chatId, provider, round, opts) { + const chat = store.getChat(chatId); + if (!chat) throw new Error('Chat not found'); + const base = String(provider).split(':')[0]; + const cycle = chat.cycle || 1; + emit('multiai-thinking', { chatId, provider: base }); + const { brief, latest } = core.briefAndLatest(chat.messages, chat.topic); + const prompt = core.buildClassicPrompt({ + chatTitle: chat.title, + topic: brief, + latestUserMessage: latest, + mode: chat.discussMode, + brevity: chat.brevity, + persona: (chat.personas && chat.personas[base]) || null, + participants: opts.participants, + provider: base, + messages: chat.messages, + contextOpts: contextOpts(chat), + stopToken: chat.stopToken || core.DEFAULTS.stopToken, + independent: opts.independent ? { cycle } : null, + }); + const raced = await sendOnChatThread(chat, provider, prompt); + const label = core.providerLabel(base); + if (raced.cancelled) { + record(chatId, { who: `${label} (stopped)`, text: 'Stopped before this reply arrived.', provider: base, role: 'error', cycle, round }); + return { cancelled: true }; } - return target; + if (!raced.ok) { + record(chatId, { who: `${label} error`, text: raced.error.message, provider: base, role: 'error', cycle, round }); + return { error: raced.error.message }; + } + const text = raced.value.response || ''; + if (core.isPass(text)) { + record(chatId, { who: label, text: 'PASS', provider: base, role: 'pass', cycle, round }); + return { pass: true }; + } + record(chatId, { who: label, text, provider: base, role: 'assistant', cycle, round }); + return { ok: true }; } - function execToolOp(root, op, relPath, content) { - const target = resolveInRoot(root, relPath); - switch (op) { - case 'list': { - const stat = fs.statSync(target); - if (!stat.isDirectory()) throw new Error('Not a directory.'); - const entries = fs.readdirSync(target, { withFileTypes: true }); - return entries.map(e => `${e.isDirectory() ? '[dir] ' : '[file]'} ${e.name}`).join('\n') || '(empty)'; - } - case 'read': { - let text = fs.readFileSync(target, 'utf-8'); - if (text.length > 50000) text = text.slice(0, 50000) + '\n...(truncated)'; - return text; - } - case 'write': { - fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.writeFileSync(target, content || '', 'utf-8'); - return `Wrote ${Buffer.byteLength(content || '', 'utf-8')} bytes.`; - } - case 'append': { - fs.mkdirSync(path.dirname(target), { recursive: true }); - fs.appendFileSync(target, content || '', 'utf-8'); - return `Appended ${Buffer.byteLength(content || '', 'utf-8')} bytes.`; - } - case 'mkdir': { - fs.mkdirSync(target, { recursive: true }); - return 'Directory created.'; + // Runs `rounds` rounds on a chat using its stored settings. `latestText` + // is the human message that triggered this cycle (already recorded by the + // caller) β€” used only for @mention parsing. + async function runClassic({ chatId, rounds, latestText }) { + const chat0 = store.getChat(chatId); + if (!chat0) throw new Error('Chat not found'); + const sig = startSignal(chatId); + const produced = []; + try { + const enabled = enabledProviders(); + let participants = (chat0.participants || []).filter(p => enabled.includes(String(p).split(':')[0])); + if (!participants.length) participants = (chat0.participants || []).slice(); + if (!participants.length) throw new Error('No participants selected.'); + const mentions = core.parseMentions(latestText || '', participants); + const order = mentions.order; + if (mentions.mentioned.length) { + note(chatId, `Directed turn: ${mentions.only ? 'only ' : ''}${mentions.mentioned.map(core.providerLabel).join(', ')}${mentions.only ? '' : ' first'}.`); } - case 'delete': { - const normalizedRoot = path.resolve(root); - if (target === normalizedRoot) throw new Error('Refusing to delete the working folder root itself.'); - fs.rmSync(target, { recursive: true, force: true }); - return 'Deleted.'; + const n = Math.max(1, Math.min(30, rounds || chat0.rounds || 1)); + const token = chat0.stopToken || core.DEFAULTS.stopToken; + const cycle = chat0.cycle || 1; + const stopEarly = chat0.stopWhenAllAgree !== false; + + outer: + for (let r = 1; r <= n; r++) { + if (sig.cancelled) break; + emit('multiai-round-start', { chatId, round: r, of: n }); + const independent = r === 1 && !!chat0.independentFirstRound && order.length > 1; + if (independent) { + const results = await Promise.all(order.map(p => takeTurn(chatId, p, r, { participants: order, independent: true }) + .catch(e => ({ error: e.message })))); + if (results.some(x => x && x.cancelled) || sig.cancelled) break outer; + } else { + for (const provider of order) { + if (sig.cancelled) break outer; + const res = await takeTurn(chatId, provider, r, { participants: order }).catch(e => ({ error: e.message })); + if (res && res.cancelled) break outer; + } + } + const after = store.getChat(chatId); + const outcome = core.roundOutcome(messagesOfRound(after, cycle, r), token); + if (stopEarly && outcome.converged) { + note(chatId, `Converged after round ${r}: every participant said ${token} or passed.`); + break; + } + if (stopEarly && outcome.allPassed) { + note(chatId, `Everyone passed in round ${r} β€” nothing new to add.`); + break; + } } - default: - throw new Error(`Unknown op: ${op}`); + if (sig.cancelled) note(chatId, 'Run stopped.'); + } finally { + endSignal(chatId); + emit('multiai-done', { chatId }); } + const final = store.getChat(chatId); + return (final && final.messages || []).filter(m => (m.cycle || 1) === (chat0.cycle || 1) && m.round > 0); } - function buildToolsPreamble() { - return 'You have access to file tools scoped to one working folder β€” you cannot reach anything outside it. ' + - 'To use a tool, reply with ONLY a single fenced block like this and nothing else:\n\n' + - '```tool\n{"op": "list", "path": "."}\n```\n\n' + - 'Available ops (path is always relative to the working folder root; use "." for the root itself):\n' + - '- list {"path"} β€” list a directory\'s contents\n' + - '- read {"path"} β€” read a text file\n' + - '- write {"path", "content"} β€” create or overwrite a text file\n' + - '- append {"path", "content"} β€” append to a text file\n' + - '- mkdir {"path"} β€” create a directory\n' + - '- delete {"path"} β€” delete a file or directory (irreversible)\n\n' + - 'After a tool result comes back, call another tool the same way, or reply with plain text and no tool ' + - 'block once you have your final answer β€” that plain-text reply ends the run.'; - } - - function parseToolCall(text) { - const m = /```tool\s*([\s\S]*?)```/i.exec(text || ''); - if (!m) return null; + ipcMain.handle('multiai-run-round', async (event, opts) => { try { - const obj = JSON.parse(m[1].trim()); - if (!obj || typeof obj.op !== 'string') return null; - return obj; - } catch { - return null; + const messages = await runClassic(opts); + return { success: true, messages }; + } catch (e) { + emit('multiai-done', { chatId: opts.chatId }); + return { success: false, error: e.message }; } - } - - async function runAgentTools({ chatId, task, provider, root, maxTurns }) { - if (!root) throw new Error('No working folder chosen.'); - const turns = Math.max(1, Math.min(15, maxTurns || 6)); - let transcript = `${buildToolsPreamble()}\n\nTASK: ${task}`; - const steps = []; - for (let t = 1; t <= turns; t++) { - const sig = runSignals.get(chatId); - if (sig && sig.cancelled) { - emit('multiai-orch-step', { chatId, status: 'cancelled', turn: t }); - break; - } - emit('multiai-orch-step', { chatId, status: 'running', turn: t, provider }); - const startMs = Date.now(); - let reply; - try { - reply = await chat(chatId, provider, transcript); - } catch (e) { - if (e.wasCancelled) emit('multiai-orch-step', { chatId, status: 'cancelled', turn: t }); - else emit('multiai-orch-step', { chatId, status: 'error', turn: t, error: e.message }); - break; - } - - const toolCall = parseToolCall(reply); - if (!toolCall) { - const step = { turn: t, output: reply, elapsedMs: Date.now() - startMs, ok: true }; - steps.push(step); - emit('multiai-orch-step', { chatId, status: 'done', ...step }); - return { finalOutput: reply, steps }; - } - - let resultText; - try { - resultText = execToolOp(root, toolCall.op, toolCall.path, toolCall.content); - } catch (e) { - resultText = `ERROR: ${e.message}`; - } - const step = { turn: t, output: `[tool: ${toolCall.op} ${toolCall.path || ''}]\n${resultText}`, elapsedMs: Date.now() - startMs, ok: true }; - steps.push(step); - emit('multiai-orch-step', { chatId, status: 'done', ...step }); + }); - transcript += `\n\nASSISTANT: ${reply}\n\nTOOL RESULT (${toolCall.op} ${toolCall.path || ''}):\n${resultText}\n\n` + - 'Continue with another tool call, or give your final answer as plain text with no tool block.'; + function scopedMessages(chat, scope) { + const msgs = chat.messages || []; + if (scope === 'cycle') { + const c = chat.cycle || Math.max(1, ...msgs.map(m => m.cycle || 1)); + return msgs.filter(m => (m.cycle || 1) === c); } - return { finalOutput: (steps[steps.length - 1] && steps[steps.length - 1].output) || '(turn limit reached with no final answer)', steps }; - } - - // ---- Classic mode: discuss / debate / judge ----------------------- - // One provider per turn, round-robin, building on the transcript so far. - // Mirrors ProximaChatApp's build_prompt()/run_round() logic exactly, - // but calls sendMessageToProvider in-process instead of hitting :3210. - - function renderTranscript(messages) { - return messages.map(m => `${m.who}: ${m.text}`).join('\n\n') || '(no messages yet)'; - } - - function buildClassicPrompt({ topic, mode, brevity, persona, participants, provider, transcript }) { - let side = ''; - if (mode === 'debate') { - const i = participants.indexOf(provider); - if (participants.length === 2) { - side = i === 0 - ? 'You argue strongly FOR the first option.' - : 'You argue strongly AGAINST the first option (for the alternative).'; - } else { - side = 'Take a distinct, well-defined stance and defend it.'; - } + if (scope === 'round' || scope === 'last') { + const c = chat.cycle || Math.max(1, ...msgs.map(m => m.cycle || 1)); + const inCycle = msgs.filter(m => (m.cycle || 1) === c); + const maxRound = inCycle.reduce((mx, m) => Math.max(mx, m.round || 0), 0); + return inCycle.filter(m => m.role === 'user' || m.round === maxRound); } - const personaBlock = persona ? `[Your assigned persona / instructions β€” stay in character]\n${persona}\n\n` : ''; - return `${personaBlock}You are ${provider}, one participant in a multi-AI conversation. Topic: ${topic}\n` + - `${side}\n\nConversation so far:\n${transcript}\n\n` + - `Add your next contribution (${brevity || 'max 4 sentences'}). Be substantive and specific; ` + - `build on or directly challenge points already made. Do not repeat yourself. Reply with only your message.`; + return msgs; } - ipcMain.handle('multiai-run-round', async (event, opts) => { - const { chatId, topic, mode, brevity, personas, participants, messages, rounds } = opts; - const n = Math.max(1, Math.min(30, rounds || 1)); - const out = []; - const sig = startSignal(chatId); - outer: - for (let r = 0; r < n; r++) { - if (sig.cancelled) break; - emit('multiai-round-start', { chatId, round: r + 1, of: n }); - for (const provider of participants) { - if (sig.cancelled) break outer; - emit('multiai-thinking', { chatId, provider }); - const persona = (personas && personas[provider]) || null; - const transcript = renderTranscript([...messages, ...out]); - const prompt = buildClassicPrompt({ topic, mode, brevity, persona, participants, provider, transcript }); - const raced = await raceProvider(chatId, sender.sendMessageToProvider(provider, prompt)); - if (raced.cancelled) { - const msg = { who: `${provider} (stopped)`, text: 'Stopped before this reply arrived.', provider, role: 'error', ts: Date.now() }; - out.push(msg); - emit('multiai-message', { chatId, message: msg }); - break outer; - } - if (raced.ok) { - const msg = { who: provider, text: raced.value.response, provider, role: 'assistant', ts: Date.now(), round: r + 1 }; - out.push(msg); - emit('multiai-message', { chatId, message: msg }); - } else { - const msg = { who: `${provider} error`, text: raced.error.message, provider, role: 'error', ts: Date.now() }; - out.push(msg); - emit('multiai-message', { chatId, message: msg }); - } - } + ipcMain.handle('multiai-judge', async (event, { chatId, judgeProvider, scope }) => { + const chat = store.getChat(chatId); + if (!chat) return { success: false, error: 'Chat not found' }; + const base = String(judgeProvider).split(':')[0]; + emit('multiai-thinking', { chatId, provider: base }); + const { brief } = core.briefAndLatest(chat.messages, chat.topic); + const prompt = core.buildJudgePrompt({ topic: brief, messages: scopedMessages(chat, scope || chat.judgeScope || 'all') }); + try { + const result = await sendAux(chat, judgeProvider, prompt); + const msg = record(chatId, { who: `Judge Β· ${core.providerLabel(base)}`, text: result.response, provider: base, role: 'judge', cycle: chat.cycle || 1, round: 0 }); + emit('multiai-done', { chatId }); + return { success: true, message: msg }; + } catch (e) { + record(chatId, { who: 'Judge error', text: e.message, provider: base, role: 'error', cycle: chat.cycle || 1, round: 0 }); + emit('multiai-done', { chatId }); + return { success: false, error: e.message }; } - endSignal(chatId); - emit('multiai-done', { chatId }); - return { success: true, messages: out }; }); - ipcMain.handle('multiai-judge', async (event, opts) => { - const { chatId, topic, messages, judgeProvider } = opts; - emit('multiai-thinking', { chatId, provider: judgeProvider }); - const transcript = renderTranscript(messages); - const prompt = `You are an impartial judge. Topic: ${topic}\n\nTranscript:\n${transcript}\n\n` + - `Deliver a concise verdict: who made the stronger case and why, the single best point from ` + - `each participant, and any unresolved question. Max 6 sentences.`; + // Pinned state summary: becomes the compaction point for every later prompt. + ipcMain.handle('multiai-summarize', async (event, { chatId, provider }) => { + const chat = store.getChat(chatId); + if (!chat) return { success: false, error: 'Chat not found' }; + const base = String(provider).split(':')[0]; + emit('multiai-thinking', { chatId, provider: base }); + const { brief } = core.briefAndLatest(chat.messages, chat.topic); + const prompt = core.buildSummaryPrompt({ topic: brief, messages: chat.messages }); try { - const result = await sender.sendMessageToProvider(judgeProvider, prompt); - const msg = { who: `Judge Β· ${judgeProvider}`, text: result.response, provider: judgeProvider, role: 'judge', ts: Date.now() }; - emit('multiai-message', { chatId, message: msg }); + const result = await sendAux(chat, provider, prompt); + const msg = record(chatId, { who: `State summary Β· ${core.providerLabel(base)}`, text: result.response, provider: base, role: 'summary', cycle: chat.cycle || 1, round: 0 }); emit('multiai-done', { chatId }); return { success: true, message: msg }; } catch (e) { + record(chatId, { who: 'Summary error', text: e.message, provider: base, role: 'error', cycle: chat.cycle || 1, round: 0 }); emit('multiai-done', { chatId }); return { success: false, error: e.message }; } }); - // ---- Orchestrated mode: crew / workflow / loop --------------------- - // Same shapes as the MCP server's run_workflow/crew/run_loop tools, - // reusing their algorithms but calling sendMessageToProvider directly - // (no need for the MCP server's separate IPC-bridge round-trip, since - // this code already runs inside the process that owns the providers). + // ---- Orchestrated mode: crew / workflow / loop / agent ----------------- + // Same shapes as the MCP server's run_workflow/crew/run_loop tools, but + // in-process. Every step is recorded as a message so the transcript and + // ledger carry the full trail, whichever chat is on screen. async function chat(chatId, provider, prompt) { - const raced = await raceProvider(chatId, sender.sendMessageToProvider(provider, prompt)); + const c = store.getChat(chatId); + if (!c) throw new Error('Chat not found'); + const raced = await sendOnChatThread(c, provider, prompt); if (raced.cancelled) { const err = new Error('Stopped before this reply arrived.'); err.wasCancelled = true; @@ -416,39 +412,54 @@ function registerMultiAiHandlers(deps) { return raced.value.response; } + // Prior conversation, bounded, for follow-up cycles in Orchestrated chats. + function priorContext(chatId) { + const c = store.getChat(chatId); + const prior = (c && c.messages || []).filter(m => m.role !== 'user' || (c.cycle || 1) !== (m.cycle || 1)); + if (!core.readable(prior).length) return ''; + return `\n\nPRIOR CONVERSATION IN THIS CHAT (for context):\n${core.buildContext(prior, contextOpts(c)).text}`; + } + + function stepMessage(chatId, { who, provider, text, role, round }) { + const c = store.getChat(chatId); + return record(chatId, { who, provider, text, role: role || 'assistant', cycle: (c && c.cycle) || 1, round: round || 0 }); + } + async function runCrew({ chatId, task, agents }) { const list = agents && agents.length ? agents : [ { role: 'Researcher', provider: 'perplexity', instruction: 'Research the topic thoroughly with facts and citations.' }, { role: 'Writer', provider: 'claude', instruction: 'Write a detailed, well-structured response based on research.' }, { role: 'Reviewer', provider: 'chatgpt', instruction: 'Review, improve and polish the output.' }, ]; + const context = priorContext(chatId); let previousOutput = task; const steps = []; + let idx = 0; for (const agent of list) { + idx++; const sig = runSignals.get(chatId); - if (sig && sig.cancelled) { - emit('multiai-orch-step', { chatId, status: 'cancelled', role: agent.role }); - break; - } - emit('multiai-orch-step', { chatId, status: 'running', role: agent.role, provider: agent.provider }); - const prompt = `You are a ${agent.role}. ${agent.instruction || ''}\n\nTASK: ${task}\n\n` + + if (sig && sig.cancelled) break; + const who = `${agent.role} Β· ${core.providerLabel(agent.provider)}`; + emit('multiai-orch-step', { chatId, status: 'running', role: agent.role, provider: agent.provider, label: who }); + const prompt = `You are a ${agent.role}. ${agent.instruction || ''}\n\nTASK: ${task}${idx === 1 ? context : ''}\n\n` + `${previousOutput !== task ? `PREVIOUS AGENT OUTPUT:\n${previousOutput}\n\n` : ''}` + `Provide your best work as a ${agent.role}.`; const startMs = Date.now(); try { const output = await chat(chatId, agent.provider, prompt); previousOutput = output; - const step = { role: agent.role, provider: agent.provider, output, elapsedMs: Date.now() - startMs, ok: true }; - steps.push(step); - emit('multiai-orch-step', { chatId, status: 'done', ...step }); + steps.push({ role: agent.role, provider: agent.provider, output, elapsedMs: Date.now() - startMs, ok: true }); + stepMessage(chatId, { who, provider: agent.provider, text: output, round: idx }); } catch (e) { if (e.wasCancelled) { - emit('multiai-orch-step', { chatId, status: 'cancelled', role: agent.role, provider: agent.provider }); + stepMessage(chatId, { who: `${who} (stopped)`, provider: agent.provider, text: 'Stopped before this step finished.', role: 'error', round: idx }); break; } - const step = { role: agent.role, provider: agent.provider, error: e.message, elapsedMs: Date.now() - startMs, ok: false }; - steps.push(step); - emit('multiai-orch-step', { chatId, status: 'error', ...step }); + steps.push({ role: agent.role, provider: agent.provider, error: e.message, elapsedMs: Date.now() - startMs, ok: false }); + stepMessage(chatId, { who: `${who} error`, provider: agent.provider, text: e.message, role: 'error', round: idx }); + // A failed role has nothing to hand on; stop rather than feed + // the next role stale input as if it were this role's work. + break; } } return { finalOutput: previousOutput, steps }; @@ -457,31 +468,28 @@ function registerMultiAiHandlers(deps) { async function runWorkflow({ chatId, input, steps: stepDefs }) { let previousOutput = input || ''; const fallback = enabledProviders()[0] || 'chatgpt'; + const context = priorContext(chatId); const steps = []; - for (const [idx, def] of stepDefs.entries()) { + for (const [i, def] of stepDefs.entries()) { const sig = runSignals.get(chatId); - if (sig && sig.cancelled) { - emit('multiai-orch-step', { chatId, status: 'cancelled', step: idx + 1 }); - break; - } + if (sig && sig.cancelled) break; const provider = def.provider || fallback; - emit('multiai-orch-step', { chatId, status: 'running', step: idx + 1, task: def.task, provider }); - const prompt = `${def.task}\n\n${previousOutput ? `Input from previous step:\n${previousOutput}` : ''}`; + const who = `Step ${i + 1} Β· ${core.providerLabel(provider)}`; + emit('multiai-orch-step', { chatId, status: 'running', step: i + 1, task: def.task, provider, label: who }); + const prompt = `${def.task}${i === 0 ? context : ''}\n\n${previousOutput ? `Input from previous step:\n${previousOutput}` : ''}`; const startMs = Date.now(); try { const output = await chat(chatId, provider, prompt); previousOutput = output; - const step = { step: idx + 1, task: def.task, provider, output, elapsedMs: Date.now() - startMs, ok: true }; - steps.push(step); - emit('multiai-orch-step', { chatId, status: 'done', ...step }); + steps.push({ step: i + 1, task: def.task, provider, output, elapsedMs: Date.now() - startMs, ok: true }); + stepMessage(chatId, { who, provider, text: output, round: i + 1 }); } catch (e) { if (e.wasCancelled) { - emit('multiai-orch-step', { chatId, status: 'cancelled', step: idx + 1, provider }); + stepMessage(chatId, { who: `${who} (stopped)`, provider, text: 'Stopped before this step finished.', role: 'error', round: i + 1 }); break; } - const step = { step: idx + 1, task: def.task, provider, error: e.message, elapsedMs: Date.now() - startMs, ok: false }; - steps.push(step); - emit('multiai-orch-step', { chatId, status: 'error', ...step }); + steps.push({ step: i + 1, task: def.task, provider, error: e.message, elapsedMs: Date.now() - startMs, ok: false }); + stepMessage(chatId, { who: `${who} error`, provider, text: e.message, role: 'error', round: i + 1 }); break; } } @@ -492,31 +500,28 @@ function registerMultiAiHandlers(deps) { const primary = provider || enabledProviders()[0] || 'chatgpt'; const reviewer = reviewProvider || (enabledProviders().find(p => p !== primary) || 'claude'); const turns = Math.max(1, Math.min(10, maxTurns || 3)); + const context = priorContext(chatId); let current = task; const iterations = []; for (let t = 1; t <= turns; t++) { const sig = runSignals.get(chatId); - if (sig && sig.cancelled) { - emit('multiai-orch-step', { chatId, status: 'cancelled', turn: t }); - break; - } - emit('multiai-orch-step', { chatId, status: 'running', turn: t, phase: 'generate', provider: primary }); + if (sig && sig.cancelled) break; + const genWho = `Turn ${t} Β· generate Β· ${core.providerLabel(primary)}`; + emit('multiai-orch-step', { chatId, status: 'running', turn: t, phase: 'generate', provider: primary, label: genWho }); const startMs = Date.now(); let generated; try { generated = await chat(chatId, primary, t === 1 - ? `Complete this task:\n\n${task}` + ? `Complete this task:\n\n${task}${context}` : `Revise this work based on the reviewer feedback below.\n\nORIGINAL TASK:\n${task}\n\nPREVIOUS ATTEMPT:\n${current}\n\nREVIEWER FEEDBACK:\n${iterations[iterations.length - 1].feedback}`); } catch (e) { - if (e.wasCancelled) { - emit('multiai-orch-step', { chatId, status: 'cancelled', turn: t }); - } else { - emit('multiai-orch-step', { chatId, status: 'error', turn: t, error: e.message }); - } + stepMessage(chatId, { who: e.wasCancelled ? `${genWho} (stopped)` : `${genWho} error`, provider: primary, text: e.wasCancelled ? 'Stopped before this step finished.' : e.message, role: 'error', round: t }); break; } current = generated; - emit('multiai-orch-step', { chatId, status: 'running', turn: t, phase: 'review', provider: reviewer }); + stepMessage(chatId, { who: genWho, provider: primary, text: generated, round: t }); + const revWho = `Turn ${t} Β· review Β· ${core.providerLabel(reviewer)}`; + emit('multiai-orch-step', { chatId, status: 'running', turn: t, phase: 'review', provider: reviewer, label: revWho }); let feedback = ''; let converged = false; try { @@ -524,25 +529,155 @@ function registerMultiAiHandlers(deps) { `Review this work against the original task. If it fully satisfies the task, reply with ` + `"CONVERGED" as the first word, otherwise give specific, actionable feedback.\n\n` + `ORIGINAL TASK:\n${task}\n\nWORK TO REVIEW:\n${current}`); - converged = /^\s*CONVERGED/i.test(feedback); + converged = /^\s*[*_#>\s]*CONVERGED/i.test(feedback); + stepMessage(chatId, { who: revWho, provider: reviewer, text: feedback, round: t }); } catch (e) { if (e.wasCancelled) { - emit('multiai-orch-step', { chatId, status: 'cancelled', turn: t }); + stepMessage(chatId, { who: `${revWho} (stopped)`, provider: reviewer, text: 'Stopped before this step finished.', role: 'error', round: t }); break; } feedback = `(review failed: ${e.message})`; + stepMessage(chatId, { who: `${revWho} error`, provider: reviewer, text: e.message, role: 'error', round: t }); } - const iter = { turn: t, output: current, feedback, converged, elapsedMs: Date.now() - startMs }; - iterations.push(iter); - emit('multiai-orch-step', { chatId, status: 'done', ...iter }); - if (converged) break; + iterations.push({ turn: t, output: current, feedback, converged, elapsedMs: Date.now() - startMs }); + if (converged) { note(chatId, `Loop converged at turn ${t}.`); break; } } return { finalOutput: current, converged: iterations[iterations.length - 1]?.converged || false, iterations }; } + // ---- File tools (Orchestrated "Agent" strategy) -------------------- + // A ReAct loop built on our side: the model emits a fenced ```tool {...}``` + // block, we execute it against ONE user-chosen folder (resolveInRoot + // refuses anything outside it), feed the result back, repeat until it + // replies with plain text. Reads AND writes/deletes, no confirmation β€” + // the folder scope is the only safety boundary, per explicit user choice. + + ipcMain.handle('multiai-tools-pick-folder', async () => { + const w = win(); + try { + const result = await dialog.showOpenDialog(w || undefined, { + title: 'Choose a working folder for Agent (file tools)', + properties: ['openDirectory', 'createDirectory'], + }); + if (result.canceled || !result.filePaths.length) return { success: false, canceled: true }; + return { success: true, path: result.filePaths[0] }; + } catch (e) { + return { success: false, error: e.message }; + } + }); + + function resolveInRoot(root, relPath) { + const normalizedRoot = path.resolve(root); + const target = path.resolve(normalizedRoot, relPath || '.'); + if (target !== normalizedRoot && !target.startsWith(normalizedRoot + path.sep)) { + throw new Error('That path is outside the working folder β€” refused.'); + } + return target; + } + + function execToolOp(root, op, relPath, content) { + const target = resolveInRoot(root, relPath); + switch (op) { + case 'list': { + const stat = fs.statSync(target); + if (!stat.isDirectory()) throw new Error('Not a directory.'); + const entries = fs.readdirSync(target, { withFileTypes: true }); + return entries.map(e => `${e.isDirectory() ? '[dir] ' : '[file]'} ${e.name}`).join('\n') || '(empty)'; + } + case 'read': { + let text = fs.readFileSync(target, 'utf-8'); + if (text.length > 50000) text = text.slice(0, 50000) + '\n...(truncated)'; + return text; + } + case 'write': { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, content || '', 'utf-8'); + return `Wrote ${Buffer.byteLength(content || '', 'utf-8')} bytes.`; + } + case 'append': { + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.appendFileSync(target, content || '', 'utf-8'); + return `Appended ${Buffer.byteLength(content || '', 'utf-8')} bytes.`; + } + case 'mkdir': { + fs.mkdirSync(target, { recursive: true }); + return 'Directory created.'; + } + case 'delete': { + const normalizedRoot = path.resolve(root); + if (target === normalizedRoot) throw new Error('Refusing to delete the working folder root itself.'); + fs.rmSync(target, { recursive: true, force: true }); + return 'Deleted.'; + } + default: + throw new Error(`Unknown op: ${op}`); + } + } + + function buildToolsPreamble() { + return 'You have access to file tools scoped to one working folder β€” you cannot reach anything outside it. ' + + 'To use a tool, reply with ONLY a single fenced block like this and nothing else:\n\n' + + '```tool\n{"op": "list", "path": "."}\n```\n\n' + + 'Available ops (path is always relative to the working folder root; use "." for the root itself):\n' + + '- list {"path"} β€” list a directory\'s contents\n' + + '- read {"path"} β€” read a text file\n' + + '- write {"path", "content"} β€” create or overwrite a text file\n' + + '- append {"path", "content"} β€” append to a text file\n' + + '- mkdir {"path"} β€” create a directory\n' + + '- delete {"path"} β€” delete a file or directory (irreversible)\n\n' + + 'After a tool result comes back, call another tool the same way, or reply with plain text and no tool ' + + 'block once you have your final answer β€” that plain-text reply ends the run.'; + } + + async function runAgentTools({ chatId, task, provider, root, maxTurns }) { + if (!root) throw new Error('No working folder chosen.'); + const turns = Math.max(1, Math.min(15, maxTurns || 6)); + let transcript = `${buildToolsPreamble()}\n\nTASK: ${task}`; + const steps = []; + const who = `Agent Β· ${core.providerLabel(provider)}`; + for (let t = 1; t <= turns; t++) { + const sig = runSignals.get(chatId); + if (sig && sig.cancelled) break; + emit('multiai-orch-step', { chatId, status: 'running', turn: t, provider, label: `${who} Β· turn ${t}` }); + const startMs = Date.now(); + let reply; + try { + reply = await chat(chatId, provider, transcript); + } catch (e) { + stepMessage(chatId, { who: e.wasCancelled ? `${who} (stopped)` : `${who} error`, provider, text: e.wasCancelled ? 'Stopped before this step finished.' : e.message, role: 'error', round: t }); + break; + } + + const toolCall = core.parseToolCall(reply); + if (!toolCall) { + steps.push({ turn: t, output: reply, elapsedMs: Date.now() - startMs, ok: true }); + stepMessage(chatId, { who, provider, text: reply, round: t }); + return { finalOutput: reply, steps }; + } + + let resultText; + try { + resultText = execToolOp(root, toolCall.op, toolCall.path, toolCall.content); + } catch (e) { + resultText = `ERROR: ${e.message}`; + } + const shown = `[tool: ${toolCall.op} ${toolCall.path || ''}]\n${resultText}`; + steps.push({ turn: t, output: shown, elapsedMs: Date.now() - startMs, ok: true }); + stepMessage(chatId, { who: `${who} Β· tool`, provider, text: shown, round: t }); + + transcript += `\n\nASSISTANT: ${reply}\n\nTOOL RESULT (${toolCall.op} ${toolCall.path || ''}):\n${resultText}\n\n` + + 'Continue with another tool call, or give your final answer as plain text with no tool block.'; + } + return { finalOutput: (steps[steps.length - 1] && steps[steps.length - 1].output) || '(turn limit reached with no final answer)', steps }; + } + ipcMain.handle('multiai-run-orchestrated', async (event, opts) => { const { chatId, strategy } = opts; - startSignal(chatId); + try { + startSignal(chatId); + } catch (e) { + return { success: false, error: e.message }; + } try { let result; if (strategy === 'crew') result = await runCrew(opts); @@ -550,6 +685,8 @@ function registerMultiAiHandlers(deps) { else if (strategy === 'loop') result = await runLoop(opts); else if (strategy === 'agent') result = await runAgentTools(opts); else throw new Error(`Unknown strategy: ${strategy}`); + const sig = runSignals.get(chatId); + if (sig && sig.cancelled) note(chatId, 'Run stopped.'); endSignal(chatId); emit('multiai-orch-done', { chatId }); return { success: true, ...result }; @@ -559,6 +696,113 @@ function registerMultiAiHandlers(deps) { return { success: false, error: e.message }; } }); + + // ---- REST: local agents read and append --------------------------- + // Registered on the existing gateway (port 3210+). Same loopback-only and + // API-key rules as every other /v1 route. All bodies/results are JSON. + // + // GET /v1/multiai/chats β†’ [{id,title,mode,participants,messageCount,...}] + // POST /v1/multiai/chats {title?, participants?, mode?, topic?} β†’ chat + // GET /v1/multiai/chats/:id[?since=] β†’ chat (messages after `since` if given) + // POST /v1/multiai/chats/:id/messages {who, text, role?, provider?} β†’ message + // (appends a turn from an external agent; nothing is sent to providers) + // POST /v1/multiai/chats/:id/run {text?, who?, rounds?, wait?} β†’ {started} | {messages} + // (optionally records `text` as a human/agent message, then runs rounds) + // POST /v1/multiai/chats/:id/stop + // GET /v1/multiai/running β†’ [chatId] + if (typeof registerRouteExtension === 'function') { + registerRouteExtension(async (method, url, body, res, { sendJSON, sendError }) => { + const p = url.pathname; + if (!p.startsWith('/v1/multiai')) return false; + const m = /^\/v1\/multiai\/chats\/([^/]+)(?:\/(messages|run|stop))?$/.exec(p); + try { + if (method === 'GET' && p === '/v1/multiai/running') { sendJSON(res, 200, { running: Array.from(runSignals.keys()) }); return true; } + if (method === 'GET' && p === '/v1/multiai/chats') { sendJSON(res, 200, { chats: store.listChats() }); return true; } + if (method === 'POST' && p === '/v1/multiai/chats') { + const fields = {}; + if (body.title) fields.title = String(body.title).slice(0, 120); + if (Array.isArray(body.participants)) fields.participants = body.participants.map(String); + if (body.mode === 'classic' || body.mode === 'orchestrated') fields.mode = body.mode; + if (body.discussMode === 'discuss' || body.discussMode === 'debate') fields.discussMode = body.discussMode; + if (body.brevity) fields.brevity = String(body.brevity); + if (body.topic) fields.topic = String(body.topic); + const created = store.createChat(fields); + if (body.topic) { + created.cycle = 1; + store.updateChat(created.id, { cycle: 1, title: fields.title || core.localTitle(body.topic) }); + record(created.id, { who: body.who || 'You', role: 'user', text: String(body.topic), cycle: 1, round: 0 }); + } + emit('multiai-chat-created', { chat: store.getChat(created.id) }); + sendJSON(res, 201, { chat: store.getChat(created.id) }); + return true; + } + if (!m) { sendError(res, 404, 'Unknown multiai route', 'not_found'); return true; } + const chatId = decodeURIComponent(m[1]); + const sub = m[2] || ''; + const chat = store.getChat(chatId); + if (!chat) { sendError(res, 404, 'Chat not found', 'not_found'); return true; } + + if (method === 'GET' && !sub) { + const since = url.searchParams.get('since'); + let messages = chat.messages || []; + if (since) { + const i = messages.findIndex(x => x.id === since); + messages = i === -1 ? messages : messages.slice(i + 1); + } + const out = Object.assign({}, chat, { messages: messages.map(x => Object.assign({ label: core.refLabel(x) }, x)) }); + out.running = isRunning(chatId); + sendJSON(res, 200, { chat: out }); + return true; + } + if (method === 'POST' && sub === 'messages') { + const text = String(body.text || '').trim(); + if (!text) { sendError(res, 400, 'text is required'); return true; } + const role = ['assistant', 'user', 'summary', 'judge', 'system'].includes(body.role) ? body.role : 'assistant'; + const provider = String(body.provider || (role === 'user' ? 'human' : 'external')).toLowerCase(); + const who = String(body.who || (role === 'user' ? 'You' : core.providerLabel(provider))).slice(0, 60); + if (role === 'user') { + store.updateChat(chatId, { cycle: (chat.cycle || 0) + 1 }); + } + const fresh = store.getChat(chatId); + const saved = record(chatId, { who, text, provider, role, cycle: fresh.cycle || 1, round: role === 'user' ? 0 : (body.round != null ? Number(body.round) : 0) }); + sendJSON(res, 201, { message: Object.assign({ label: core.refLabel(saved) }, saved) }); + return true; + } + if (method === 'POST' && sub === 'run') { + if (isRunning(chatId)) { sendError(res, 409, 'A run is already in progress for this chat', 'conflict'); return true; } + const text = String(body.text || '').trim(); + if (text) { + store.updateChat(chatId, { cycle: (chat.cycle || 0) + 1, topic: chat.topic || text }); + const fresh = store.getChat(chatId); + record(chatId, { who: String(body.who || 'You').slice(0, 60), text, provider: 'human', role: 'user', cycle: fresh.cycle, round: 0 }); + if (!chat.messages.length && (chat.title === 'New chat' || !chat.title)) store.updateChat(chatId, { title: core.localTitle(text) }); + } else if (!(chat.cycle || 0)) { + store.updateChat(chatId, { cycle: 1 }); + } + const rounds = body.rounds != null ? Number(body.rounds) : undefined; + const runP = runClassic({ chatId, rounds, latestText: text }).catch(e => { console.error('[MultiAI] REST run failed:', e.message); return []; }); + if (body.wait) { + const messages = await runP; + sendJSON(res, 200, { messages: messages.map(x => Object.assign({ label: core.refLabel(x) }, x)) }); + } else { + sendJSON(res, 202, { started: true, chatId }); + } + return true; + } + if (method === 'POST' && sub === 'stop') { + const sig = runSignals.get(chatId); + if (sig) { sig.cancelled = true; sig.resolve(); } + sendJSON(res, 200, { stopped: !!sig }); + return true; + } + sendError(res, 405, 'Method not allowed', 'method_not_allowed'); + return true; + } catch (e) { + sendError(res, 500, e.message); + return true; + } + }); + } } module.exports = { registerMultiAiHandlers }; diff --git a/electron/main-v2.cjs b/electron/main-v2.cjs index 5fb6227..f333297 100644 --- a/electron/main-v2.cjs +++ b/electron/main-v2.cjs @@ -5,7 +5,7 @@ const path = require('path'); const fs = require('fs'); const net = require('net'); const BrowserManager = require('./browser-manager.cjs'); -const { initRestAPI, startRestAPI, stopRestAPI, isRestAPIRunning, generateApiKey, revokeApiKey, loadApiKey } = require('./api/rest-api.cjs'); +const { initRestAPI, startRestAPI, stopRestAPI, isRestAPIRunning, generateApiKey, revokeApiKey, loadApiKey, registerRouteExtension } = require('./api/rest-api.cjs'); const providerAPI = require('./providers/api.cjs'); const byok = require('./api/byok/index.cjs'); @@ -28,6 +28,35 @@ const { registerMultiAiHandlers } = require('./ipc/multiai.cjs'); const pythonEnv = require('./python-env.cjs'); const envCheck = require('./env-check.cjs'); +// ── Instance hygiene ──────────────────────────────────────────────── +// A dev checkout (`npm start`) must never share userData with an installed +// Proxima: doing so produced Chromium cache/quota errors and, worse, let the +// dev instance overwrite ipc-port.json / ipc-token.json / settings.ipcPort +// that the installed app's MCP server and CLI rely on. Dev gets its own +// profile ("proxima-dev") unless PROXIMA_SHARED_USERDATA=1 opts back in. +if (!app.isPackaged && !process.env.PROXIMA_SHARED_USERDATA) { + app.setPath('userData', path.join(app.getPath('appData'), 'proxima-dev')); + console.log('[Instance] Dev mode β€” using userData', app.getPath('userData')); +} + +// One running instance per profile (the lock is per userData path, so an +// installed app and a dev checkout can still run side by side). A second +// launch of the same profile focuses the existing window and exits. +const _gotInstanceLock = app.requestSingleInstanceLock(); +if (!_gotInstanceLock) { + console.log('[Instance] Another Proxima instance owns this profile β€” exiting.'); + app.quit(); +} +app.on('second-instance', () => { + try { + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.show(); + mainWindow.focus(); + } + } catch { /* window may be gone */ } +}); + const CHROME_VERSION = (process.versions && process.versions.chrome) || '130.0.6723.191'; const CHROME_UA = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${CHROME_VERSION} Safari/537.36`; @@ -740,6 +769,7 @@ function initModules() { generateApiKey, revokeApiKey, loadApiKey, getFileReferenceEnabled: () => fileReferenceEnabled, setFileReferenceEnabled: (v) => { fileReferenceEnabled = !!v; }, + registerRouteExtension, }; registerCoreHandlers(handlerDeps); registerSettingsHandlers(handlerDeps); @@ -761,6 +791,19 @@ function setupAutoUpdater() { return; } + // A fork build must not be silently replaced by the upstream release + // channel it inherited in package.json `build.publish`. package.json's + // `proximaFork` marker turns the updater off unless settings.autoUpdate + // is explicitly true. + try { + const pkg = require('../package.json'); + const settings = loadSettings(); + if (pkg.proximaFork && settings.autoUpdate !== true) { + console.log(`[AutoUpdater] Fork build (of ${pkg.proximaFork.of || 'upstream'}) β€” auto-update disabled (set settings.autoUpdate=true to opt in).`); + return; + } + } catch { /* no package.json marker */ } + autoUpdater.on('checking-for-update', () => { console.log('[AutoUpdater] Checking for updates...'); if (mainWindow) mainWindow.webContents.send('updater-status', { status: 'checking' }); @@ -962,7 +1005,7 @@ function getMimeType(filePath) { return mimeTypes[ext] || 'application/octet-stream'; } -app.whenReady().then(createWindow); +if (_gotInstanceLock) app.whenReady().then(createWindow); app.on('window-all-closed', () => { if (ipcServer) { diff --git a/electron/multiai-renderer.js b/electron/multiai-renderer.js index 4b042d8..1f5f2ee 100644 --- a/electron/multiai-renderer.js +++ b/electron/multiai-renderer.js @@ -13,8 +13,12 @@ let multiaiState = { enabledProviders: [], personas: {}, pendingAttachments: [], // [{name, text, isImage, dataUrl}] queued for the next send + runningChats: new Set(), // chat ids with a run in flight (main process is the source of truth) }; +const MULTIAI_GEMINI_ENGINES = ['gemini', 'gemini:3.5-flash', 'gemini:3.1-pro', 'gemini:3.1-flash-lite', 'gemini:auto']; +const MULTIAI_KNOWN_PROVIDERS = ['chatgpt', 'claude', 'gemini', 'perplexity']; + // Attachments over this size are truncated before being folded into a prompt, // so one large file can't blow out the request to a provider. const MULTIAI_ATTACHMENT_MAX_CHARS = 40000; @@ -51,6 +55,11 @@ function multiaiDefaultChat() { personaKeys: {}, // provider -> persona name picked in the assignment UI (chat.personas holds the resolved text sent to the backend). pendingContextRefs: [], // message ids queued via "Add as context", folded into the next outgoing prompt then cleared. agentConfig: { provider: '', root: '', maxTurns: 6 }, // Orchestrated "Agent (file tools)" strategy settings. + engines: {}, // provider -> engine string (e.g. gemini -> 'gemini:3.1-pro'); only Gemini honours it today. + contextWindow: 10, // recent messages each participant sees verbatim; older ones are condensed (see multiai-core). + stopToken: 'DONE', // convergence token; a round where everyone says it (or passes) ends the run early. + stopWhenAllAgree: true, + independentFirstRound: false, // round 1 of each Send runs in parallel, blind to the others' replies. }; } @@ -63,7 +72,7 @@ function multiaiDefaultChat() { // the ledger's R# filter key; the raw id itself is never shown as its own // column, per design. -const MULTIAI_PROVIDER_CODE = { chatgpt: 'CG', claude: 'CL', gemini: 'GM', perplexity: 'PX' }; +const MULTIAI_PROVIDER_CODE = { chatgpt: 'CG', claude: 'CL', gemini: 'GM', perplexity: 'PX', codex: 'CX', 'claude-code': 'CC', human: 'US', external: 'EX' }; function multiaiGenMsgId() { return 'm' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); @@ -71,7 +80,8 @@ function multiaiGenMsgId() { function multiaiProviderCode(p) { if (!p) return '--'; - return MULTIAI_PROVIDER_CODE[p] || String(p).slice(0, 2).toUpperCase(); + const base = String(p).split(':')[0]; + return MULTIAI_PROVIDER_CODE[base] || base.slice(0, 2).toUpperCase(); } function multiaiRefLabel(msg) { @@ -80,6 +90,8 @@ function multiaiRefLabel(msg) { let code; if (msg.role === 'user') code = 'US'; else if (msg.role === 'judge') code = 'JD'; + else if (msg.role === 'summary') code = 'SM'; + else if (msg.role === 'system') code = 'SY'; else if (msg.role === 'error') code = 'ER'; else code = multiaiProviderCode(msg.provider); return `${String(c).padStart(2, '0')}${String(r).padStart(2, '0')}${code}`; @@ -142,20 +154,29 @@ async function multiaiInit() { multiaiState.personas = personas || {}; // Wire up live event streams from the main process. + // Every message the main process produces (turns, judge, summary, REST + // appends, system notes) arrives here, whichever chat is on screen. It is + // already persisted by main; we mirror it into memory and dedupe by id. agentHub.onMultiaiMessage(({ chatId, message }) => { const chat = multiaiFindChat(chatId); - if (chat) { - message.id = message.id || multiaiGenMsgId(); - message.cycle = message.cycle || chat.cycle || 1; - chat.messages.push(message); - multiaiPersist(); - if (chatId === multiaiState.currentChatId) { - multiaiAppendFeedItem(message); - multiaiRenderStatusLine(chat); - multiaiRenderLedger(chat); - } + if (!chat) return; + message.id = message.id || multiaiGenMsgId(); + message.cycle = message.cycle || chat.cycle || 1; + if ((chat.messages || []).some(m => m.id === message.id)) return; + chat.messages = chat.messages || []; + chat.messages.push(message); + if (message.role === 'user' && (message.cycle || 0) > (chat.cycle || 0)) chat.cycle = message.cycle; + if (chatId === multiaiState.currentChatId) { + multiaiAppendFeedItem(message); + multiaiRenderStatusLine(chat); + multiaiRenderLedger(chat); } }); + agentHub.onMultiaiChatCreated(({ chat }) => { + if (!chat || multiaiFindChat(chat.id)) return; + multiaiState.data.projects[0].chats.push(chat); + multiaiRenderChatList(); + }); agentHub.onMultiaiThinking(({ chatId, provider }) => { if (chatId === multiaiState.currentChatId) multiaiSetThinking(provider, true); }); @@ -168,15 +189,18 @@ async function multiaiInit() { if (chat) multiaiRenderStatusLine(chat); } }); - agentHub.onMultiaiDone(({ chatId }) => { - if (chatId === multiaiState.currentChatId) multiaiSetRunning(false); - }); + agentHub.onMultiaiDone(({ chatId }) => multiaiSetRunning(chatId, false)); agentHub.onMultiaiOrchStep((step) => { if (step.chatId === multiaiState.currentChatId) multiaiRenderOrchStep(step); }); - agentHub.onMultiaiOrchDone(({ chatId }) => { - if (chatId === multiaiState.currentChatId) multiaiSetRunning(false); - }); + agentHub.onMultiaiOrchDone(({ chatId }) => multiaiSetRunning(chatId, false)); + + // Runs started before this renderer loaded (or from the REST API) are + // still owned by the main process β€” pick up their state. + try { + const running = await agentHub.multiaiRunning(); + (running || []).forEach(id => multiaiState.runningChats.add(id)); + } catch { /* older main without the handler */ } if (!multiaiState.currentChatId) { const firstChat = multiaiState.data.projects[0]?.chats[0]; @@ -209,11 +233,31 @@ async function multiaiInit() { // whichever mode is active, and always reads the live Rounds field so a // submitted message runs the configured number of rounds, not just one. function multiaiSend() { - if (multiaiState.running) return; + const chat = multiaiCurrentChat(); + if (chat && multiaiIsRunning(chat.id)) return; if (multiaiState.mode === 'classic') multiaiRunRound(); else multiaiRunOrchestrated(); } +function multiaiIsRunning(chatId) { + return !!chatId && multiaiState.runningChats.has(chatId); +} + +// Debounced persist for typed fields (crew instructions, workflow steps), +// so each keystroke doesn't rewrite the store. +let _multiaiPersistTimer = null; +function multiaiPersistSoon() { + if (_multiaiPersistTimer) clearTimeout(_multiaiPersistTimer); + _multiaiPersistTimer = setTimeout(() => { _multiaiPersistTimer = null; multiaiPersist(); }, 400); +} + +function multiaiLocalTitle(text, maxLen = 60) { + const words = String(text || '').replace(/(^|\s)@[\w-]+/g, ' ').replace(/^[\s,:;-]+/, '').replace(/[*#>`_\[\]]/g, '').split(/\s+/).filter(Boolean); + let name = words.slice(0, 8).join(' ') || 'New chat'; + if (name.length > maxLen) name = name.slice(0, maxLen - 1).trimEnd() + '…'; + return name; +} + function multiaiFindChat(chatId) { if (!multiaiState.data) return null; for (const project of multiaiState.data.projects) { @@ -385,7 +429,7 @@ function multiaiRenderChatList() { style="display: flex; align-items: center; gap: 4px; padding: 9px 10px 9px 22px; font-size: 0.83rem; background: ${c.id === multiaiState.currentChatId ? 'rgba(255,255,255,0.06)' : 'transparent'};"> - ${c.mode === 'orchestrated' ? '🧩' : 'πŸ’¬'} ${multiaiEscape(c.title || 'Untitled')} + ${multiaiIsRunning(c.id) ? '● ' : ''}${c.mode === 'orchestrated' ? '🧩' : 'πŸ’¬'} ${multiaiEscape(c.title || 'Untitled')} @@ -420,10 +464,14 @@ function multiaiDeleteChat(chatId) { if (!chat) return; const ok = window.confirm(`Delete "${chat.title || 'this chat'}"? This can't be undone.`); if (!ok) return; + if (multiaiIsRunning(chatId)) agentHub.multiaiStopRun({ chatId }); for (const project of multiaiState.data.projects) { const idx = project.chats.findIndex(c => c.id === chatId); if (idx !== -1) { project.chats.splice(idx, 1); break; } } + // Snapshot saves are upsert-only (so a stale snapshot can never wipe a + // chat); deletion is an explicit call. + agentHub.multiaiDeleteChat({ chatId }); multiaiPersist(); if (chatId === multiaiState.currentChatId) { const remaining = multiaiState.data.projects.flatMap(p => p.chats).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)); @@ -481,14 +529,33 @@ function multiaiPickTitleProvider(chat) { return candidates[0]; } -async function multiaiMaybeAutoTitle(chat, firstPrompt) { +// Titles are local and free by default (first words of the first message β€” +// what the old ProximaChatApp did). An AI title is on demand via the ✨ +// button: it costs a provider round trip and used to fire on every first +// send, into the provider's shared thread, while round 1 was queued behind it. +function multiaiMaybeAutoTitle(chat, firstPrompt) { if (!chat || chat.titleGenerated) return; chat.titleGenerated = true; - const provider = multiaiPickTitleProvider(chat); + if (chat.title === 'New chat' || !chat.title) { + chat.title = multiaiLocalTitle(firstPrompt); + multiaiPersist(); + multiaiRenderChatList(); + if (chat.id === multiaiState.currentChatId) document.getElementById('multiai-chat-title').textContent = chat.title; + } +} + +async function multiaiAiTitle() { + const chat = multiaiCurrentChat(); + if (!chat) return; + const firstUser = (chat.messages || []).find(m => m.role === 'user'); + const firstPrompt = (firstUser && firstUser.text) || chat.topic; + if (!firstPrompt) { showToast('Send a message first.'); return; } + const provider = document.getElementById('multiai-judge-provider')?.value || multiaiPickTitleProvider(chat); if (!provider) return; - const prompt = `Reply with ONLY a short descriptive title (max 6 words, no quotes, no trailing punctuation) for a conversation that begins with this message:\n\n"${firstPrompt}"`; + showToast(`Asking ${multiaiProviderLabel(provider)} for a title…`); + const prompt = `Reply with ONLY a short descriptive title (max 6 words, no quotes, no trailing punctuation) for a conversation that begins with this message:\n\n"${firstPrompt.slice(0, 1500)}"`; try { - const result = await agentHub.multiaiGenerateTitle({ provider, prompt }); + const result = await agentHub.multiaiGenerateTitle({ chatId: chat.id, provider, prompt }); if (result && result.success && result.title) { let title = result.title.trim().replace(/^["'β€œβ€]+|["'β€œβ€]+$/g, '').replace(/[.!?]+$/, ''); if (title.length > 60) title = title.slice(0, 60); @@ -502,7 +569,7 @@ async function multiaiMaybeAutoTitle(chat, firstPrompt) { } } } catch (e) { - // Silent β€” the interim truncated-topic title set at send time stands. + showToast('Could not generate a title.'); } } @@ -638,7 +705,11 @@ function multiaiRenderOptionsSummary(chat) { if (multiaiState.mode === 'classic') { const n = (chat.participants || []).length; const rounds = chat.rounds || 1; - el.textContent = `βš™ ${n} participant${n === 1 ? '' : 's'} Β· ${chat.discussMode === 'debate' ? 'Debate' : 'Discuss'} Β· ${rounds} round${rounds === 1 ? '' : 's'} Β· ${chat.brevity || 'max 4 sentences'}`; + const extras = []; + if (chat.independentFirstRound) extras.push('blind R1'); + if (chat.stopWhenAllAgree !== false) extras.push(`stop on ${chat.stopToken || 'DONE'}`); + if (chat.engines && chat.engines.gemini && chat.engines.gemini !== 'gemini') extras.push(chat.engines.gemini); + el.textContent = `βš™ ${n} participant${n === 1 ? '' : 's'} Β· ${chat.discussMode === 'debate' ? 'Debate' : 'Discuss'} Β· ${rounds} round${rounds === 1 ? '' : 's'} Β· ${chat.brevity || 'max 4 sentences'}${extras.length ? ' Β· ' + extras.join(' Β· ') : ''}`; } else { const strategy = chat.strategy || 'crew'; let label = 'Crew'; @@ -676,7 +747,15 @@ function multiaiRenderChat() { document.getElementById('multiai-brevity').value = chat.brevity || 'max 4 sentences'; document.getElementById('multiai-strategy').value = chat.strategy || 'crew'; const judgeScopeEl = document.getElementById('multiai-judge-scope'); - if (judgeScopeEl) judgeScopeEl.value = chat.judgeScope || 'all'; + if (judgeScopeEl) judgeScopeEl.value = (chat.judgeScope === 'last') ? 'round' : (chat.judgeScope || 'all'); + const cw = document.getElementById('multiai-context-window'); + if (cw) cw.value = chat.contextWindow != null ? chat.contextWindow : 10; + const st = document.getElementById('multiai-stop-token'); + if (st) st.value = chat.stopToken || 'DONE'; + const sa = document.getElementById('multiai-stop-when-agree'); + if (sa) sa.checked = chat.stopWhenAllAgree !== false; + const ind = document.getElementById('multiai-independent-first'); + if (ind) ind.checked = !!chat.independentFirstRound; multiaiRenderParticipants(chat); multiaiRenderPersonaAssignment(chat); @@ -688,7 +767,7 @@ function multiaiRenderChat() { multiaiRenderOptionsSummary(chat); multiaiApplyOptionsState(); multiaiApplyModeLock(chat); - multiaiSetRunning(false); + multiaiApplyRunningUi(); multiaiState.pendingAttachments = []; multiaiRenderAttachmentsPreview(); multiaiRenderContextPreview(); @@ -787,20 +866,37 @@ function multiaiRenderParticipants(chat) { const providers = multiaiState.enabledProviders.length ? multiaiState.enabledProviders : ['chatgpt', 'claude', 'gemini', 'perplexity']; + const engines = chat.engines || {}; box.innerHTML = providers.map(p => { const checked = (chat.participants || []).includes(p); + // Only Gemini honours an engine choice in the provider layer today + // (ChatGPT/Claude/Perplexity ignore it), so only Gemini gets a picker. + const enginePicker = p === 'gemini' ? ` + ` : ''; return ` `; }).join(''); } +function multiaiSetEngine(provider, engine) { + const chat = multiaiCurrentChat(); + if (!chat) return; + chat.engines = chat.engines || {}; + if (!engine || engine === provider) delete chat.engines[provider]; else chat.engines[provider] = engine; + multiaiPersist(); + multiaiRenderOptionsSummary(chat); +} + function multiaiProviderLabel(p) { - const names = { chatgpt: 'ChatGPT', claude: 'Claude', gemini: 'Gemini', perplexity: 'Perplexity' }; - return names[p] || p; + const names = { chatgpt: 'ChatGPT', claude: 'Claude', gemini: 'Gemini', perplexity: 'Perplexity', codex: 'Codex', 'claude-code': 'Claude Code', human: 'You', external: 'External' }; + const base = String(p || '').split(':')[0]; + return names[base] || base; } function multiaiToggleParticipant(provider, on) { @@ -879,9 +975,7 @@ function multiaiRenderJudgeOptions(chat) { function multiaiOnClassicFieldChange() { const chat = multiaiCurrentChat(); if (!chat) return; - chat.discussMode = document.getElementById('multiai-discuss-mode').value; - chat.rounds = parseInt(document.getElementById('multiai-rounds').value, 10) || 1; - chat.brevity = document.getElementById('multiai-brevity').value; + multiaiReadClassicFields(chat); multiaiPersist(); multiaiRenderOptionsSummary(chat); } @@ -942,16 +1036,36 @@ function multiaiAppendFeedItem(message, skipScroll) { const isError = message.role === 'error'; const isJudge = message.role === 'judge'; const isUser = message.role === 'user'; + const isSummary = message.role === 'summary'; + const isPass = message.role === 'pass'; + if (message.role === 'system') { + // Persisted note (converged / stopped / directed turn): compact, centered. + const note = document.createElement('div'); + note.id = 'multiai-msg-' + message.id; + note.style.cssText = 'text-align: center; font-size: 0.72rem; color: #9ca3af; margin: 4px 0;'; + note.textContent = message.text || ''; + feed.appendChild(note); + if (!skipScroll) feed.scrollTop = feed.scrollHeight; + return; + } const item = document.createElement('div'); item.id = 'multiai-msg-' + message.id; item.oncontextmenu = (e) => multiaiShowMsgMenu(e, message.id); - item.style.cssText = `border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 12px 14px; ${isError ? 'border-color: rgba(239,68,68,0.4); background: rgba(239,68,68,0.06);' : isJudge ? 'border-color: rgba(168,85,247,0.4); background: rgba(168,85,247,0.06);' : isUser ? 'border-color: rgba(99,102,241,0.35); background: rgba(99,102,241,0.08);' : 'background: rgba(255,255,255,0.03);'}`; + const border = isError ? 'border-color: rgba(239,68,68,0.4); background: rgba(239,68,68,0.06);' + : isJudge ? 'border-color: rgba(168,85,247,0.4); background: rgba(168,85,247,0.06);' + : isSummary ? 'border-color: rgba(52,211,153,0.4); background: rgba(52,211,153,0.06);' + : isUser ? 'border-color: rgba(99,102,241,0.35); background: rgba(99,102,241,0.08);' + : isPass ? 'opacity: 0.55; background: rgba(255,255,255,0.02);' + : 'background: rgba(255,255,255,0.03);'; + const color = isError ? '#f87171' : isJudge ? '#c084fc' : isSummary ? '#34d399' : isUser ? '#a5b4fc' : '#93c5fd'; + item.style.cssText = `border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 12px 14px; ${border}`; + const attach = message.attachmentsText ? `
πŸ“Ž attached context (${message.attachmentsText.length.toLocaleString()} chars)
${multiaiEscape(message.attachmentsText)}
` : ''; item.innerHTML = `
-
${multiaiEscape(message.who || message.provider || '')}
+
${multiaiEscape(message.who || message.provider || '')}
${multiaiRefLabel(message)}
-
${multiaiEscape(message.text || '')}
+
${multiaiEscape(message.text || '')}
${attach} `; feed.appendChild(item); if (!skipScroll) feed.scrollTop = feed.scrollHeight; @@ -978,7 +1092,19 @@ function multiaiEscape(s) { return div.innerHTML; } -function multiaiSetRunning(on) { +// Running state is per chat (the main process owns it); this updates the +// set and, if that chat is on screen, the controls. Switching chats never +// clears it β€” it used to, which let a second run start in the same thread. +function multiaiSetRunning(chatId, on) { + if (!chatId) return; + if (on) multiaiState.runningChats.add(chatId); else multiaiState.runningChats.delete(chatId); + multiaiRenderChatList(); + if (chatId === multiaiState.currentChatId) multiaiApplyRunningUi(); +} + +function multiaiApplyRunningUi() { + const chat = multiaiCurrentChat(); + const on = !!(chat && multiaiIsRunning(chat.id)); multiaiState.running = on; const runBtn = document.getElementById('multiai-run-btn'); const stopBtn = document.getElementById('multiai-stop-btn'); @@ -1030,50 +1156,70 @@ async function multiaiRunRound() { return; } const isFirstMessage = !(chat.messages || []).length; - if (topic) chat.topic = topic; - chat.discussMode = document.getElementById('multiai-discuss-mode').value; - chat.rounds = parseInt(document.getElementById('multiai-rounds').value, 10) || 1; - chat.brevity = document.getElementById('multiai-brevity').value; - if (chat.title === 'New chat' && topic) chat.title = topic.slice(0, 60); + if (topic && !chat.topic) chat.topic = topic; // the brief = first message; later sends are instructions + multiaiReadClassicFields(chat); chat.cycle = (chat.cycle || 0) + 1; + // Attachments and referenced messages ride along with the human message + // (kept verbatim while it's in the context window, condensed after). + const attachmentsBlock = multiaiConsumeAttachments(chat); + const contextBlock = multiaiConsumeContextRefs(chat); + const attachmentsText = (attachmentsBlock + contextBlock).trim(); + // Show what was actually sent, right away β€” inline in the feed and // ledger, the same as every participant's turn. - if (topic) { + if (topic || attachmentsText) { chat.messages = chat.messages || []; - const userMsg = { id: multiaiGenMsgId(), who: 'You', text: topic, role: 'user', ts: Date.now(), cycle: chat.cycle, round: 0 }; + const userMsg = { id: multiaiGenMsgId(), who: 'You', text: topic || '(attachments only)', role: 'user', ts: Date.now(), cycle: chat.cycle, round: 0 }; + if (attachmentsText) userMsg.attachmentsText = attachmentsText; chat.messages.push(userMsg); multiaiAppendFeedItem(userMsg); multiaiRenderLedger(chat); } topicEl.value = ''; + if (isFirstMessage && topic) multiaiMaybeAutoTitle(chat, topic); + multiaiPersist(); multiaiRenderChatList(); document.getElementById('multiai-chat-title').textContent = chat.title; multiaiRenderOptionsSummary(chat); multiaiApplyModeLock(chat); - if (isFirstMessage && topic) multiaiMaybeAutoTitle(chat, topic); - - const attachmentsBlock = multiaiConsumeAttachments(chat); - const contextBlock = multiaiConsumeContextRefs(chat); - multiaiState.currentRound = null; multiaiState.totalRounds = chat.rounds; - multiaiSetRunning(true); - await agentHub.multiaiRunRound({ - chatId: chat.id, - topic: chat.topic + attachmentsBlock + contextBlock, - mode: chat.discussMode, - brevity: chat.brevity, - personas: chat.personas || {}, - participants: chat.participants, - messages: chat.messages || [], - rounds: chat.rounds, - }); - multiaiPersist(); + multiaiSetRunning(chat.id, true); + try { + // The main process reads participants/mode/brevity/personas/engines/ + // context settings from the store (persisted just above) and records + // every produced message itself; `latestText` is only for @mentions. + const result = await agentHub.multiaiRunRound({ chatId: chat.id, rounds: chat.rounds, latestText: topic }); + if (result && result.success === false && result.error) showToast(result.error); + } catch (e) { + showToast('Run failed: ' + (e.message || e)); + } finally { + multiaiSetRunning(chat.id, false); + } +} + +// Reads every Classic option control into the chat (persisted by callers). +function multiaiReadClassicFields(chat) { + chat.discussMode = document.getElementById('multiai-discuss-mode').value; + chat.rounds = parseInt(document.getElementById('multiai-rounds').value, 10) || 1; + chat.brevity = document.getElementById('multiai-brevity').value; + const cw = document.getElementById('multiai-context-window'); + if (cw) chat.contextWindow = Math.max(0, parseInt(cw.value, 10) || 0); + const st = document.getElementById('multiai-stop-token'); + if (st) chat.stopToken = (st.value || 'DONE').trim().split(/\s+/)[0] || 'DONE'; + const sa = document.getElementById('multiai-stop-when-agree'); + if (sa) chat.stopWhenAllAgree = !!sa.checked; + const ind = document.getElementById('multiai-independent-first'); + if (ind) chat.independentFirstRound = !!ind.checked; + const js = document.getElementById('multiai-judge-scope'); + if (js) chat.judgeScope = js.value; + const jp = document.getElementById('multiai-judge-provider'); + if (jp && jp.value) chat.judgeProvider = jp.value; } async function multiaiJudge() { @@ -1084,26 +1230,43 @@ async function multiaiJudge() { showToast('No provider available to judge.'); return; } + if (multiaiIsRunning(chat.id)) { showToast('Wait for the current run to finish.'); return; } const scopeEl = document.getElementById('multiai-judge-scope'); const scope = scopeEl ? scopeEl.value : 'all'; chat.judgeProvider = judgeProvider; chat.judgeScope = scope; multiaiPersist(); - let messages = chat.messages || []; - if (scope === 'last') { - const maxRound = messages.reduce((mx, m) => (m.round ? Math.max(mx, m.round) : mx), 0); - if (maxRound) messages = messages.filter(m => m.round === maxRound); + // Scope is resolved by the main process against (cycle, round) β€” "last + // round" used to match by round number alone, which picked messages from + // an earlier Send because rounds restart at 1 every cycle. + multiaiSetRunning(chat.id, true); + try { + const result = await agentHub.multiaiJudge({ chatId: chat.id, judgeProvider, scope }); + if (result && result.success === false && result.error) showToast('Judge failed: ' + result.error); + } finally { + multiaiSetRunning(chat.id, false); } +} - multiaiSetRunning(true); - await agentHub.multiaiJudge({ - chatId: chat.id, - topic: chat.topic, - messages, - judgeProvider, - }); - multiaiPersist(); +// Pinned state summary: asks the judge provider for DECISIONS / OPEN +// QUESTIONS / CLAIMS TO VERIFY / NEXT STEP, stores it as a 'summary' message, +// and from then on every participant's prompt starts from it instead of the +// older transcript β€” the answer to "what is the state of the discussion". +async function multiaiSummarize() { + const chat = multiaiCurrentChat(); + if (!chat) return; + if (!(chat.messages || []).some(m => m.role === 'assistant')) { showToast('Nothing to summarize yet.'); return; } + if (multiaiIsRunning(chat.id)) { showToast('Wait for the current run to finish.'); return; } + const provider = document.getElementById('multiai-judge-provider')?.value; + if (!provider) { showToast('No provider available to summarize.'); return; } + multiaiSetRunning(chat.id, true); + try { + const result = await agentHub.multiaiSummarize({ chatId: chat.id, provider }); + if (result && result.success === false && result.error) showToast('Summary failed: ' + result.error); + } finally { + multiaiSetRunning(chat.id, false); + } } function multiaiOpenPersonasFolder() { @@ -1211,11 +1374,14 @@ function multiaiConsumeAttachments(chat) { async function multiaiExportMd() { const chat = multiaiCurrentChat(); if (!chat) return; - const lines = [`# ${chat.title || 'Multi-AI Chat'}`, '', `Mode: ${chat.mode}`, `Created: ${new Date(chat.createdAt || Date.now()).toLocaleString()}`, '']; + const lines = [`# ${chat.title || 'Multi-AI Chat'}`, '', `Mode: ${chat.mode}`, `Created: ${new Date(chat.createdAt || Date.now()).toLocaleString()}`, + `Participants: ${(chat.participants || []).map(multiaiProviderLabel).join(', ') || 'β€”'}`, '']; (chat.messages || []).forEach(m => { const time = m.ts ? new Date(m.ts).toLocaleString() : ''; - const heading = `${m.who || m.provider || 'Message'}${m.round ? ` (Round ${m.round})` : ''}${time ? ' β€” ' + time : ''}`; + if (m.role === 'system') { lines.push(`> _${m.text || ''}_`, ''); return; } + const heading = `${m.who || m.provider || 'Message'} Β· ${multiaiRefLabel(m)}${time ? ' Β· ' + time : ''}`; lines.push(`## ${heading}`, '', m.text || '', ''); + if (m.attachmentsText) lines.push('
Attached context', '', m.attachmentsText, '', '
', ''); }); const markdown = lines.join('\n'); const result = await agentHub.multiaiExport({ title: chat.title, markdown }); @@ -1328,7 +1494,7 @@ function multiaiPersistWorkflowSteps(value) { const chat = multiaiCurrentChat(); if (!chat) return; chat.workflowStepsText = value; - multiaiPersist(); + multiaiPersistSoon(); } function multiaiPersistLoopConfig() { @@ -1398,7 +1564,7 @@ function multiaiCrewUpdateField(index, field, value) { const chat = multiaiCurrentChat(); if (!chat || !chat.crewAgents || !chat.crewAgents[index]) return; chat.crewAgents[index][field] = value; - multiaiPersist(); + multiaiPersistSoon(); } function multiaiCrewAddRole() { @@ -1428,31 +1594,32 @@ async function multiaiRunOrchestrated() { showToast('Describe the task first.'); return; } + if (multiaiIsRunning(chat.id)) { showToast('A run is already in progress for this chat.'); return; } const isFirstMessage = !(chat.messages || []).length; - if (chat.title === 'New chat') chat.title = task.slice(0, 60); - chat.topic = task; + if (!chat.topic) chat.topic = task; const strategy = document.getElementById('multiai-strategy').value; chat.strategy = strategy; chat.cycle = (chat.cycle || 0) + 1; - multiaiState.orchStepCounter = 0; + const attachmentsBlock = multiaiConsumeAttachments(chat); + const contextBlock = multiaiConsumeContextRefs(chat); + const attachmentsText = (attachmentsBlock + contextBlock).trim(); chat.messages = chat.messages || []; const userMsg = { id: multiaiGenMsgId(), who: 'You', text: task, role: 'user', ts: Date.now(), cycle: chat.cycle, round: 0 }; + if (attachmentsText) userMsg.attachmentsText = attachmentsText; chat.messages.push(userMsg); multiaiAppendFeedItem(userMsg); multiaiRenderLedger(chat); topicEl.value = ''; + if (isFirstMessage) multiaiMaybeAutoTitle(chat, task); + multiaiPersist(); multiaiRenderChatList(); document.getElementById('multiai-chat-title').textContent = chat.title; multiaiApplyModeLock(chat); - if (isFirstMessage) multiaiMaybeAutoTitle(chat, task); - - const attachmentsBlock = multiaiConsumeAttachments(chat); - const contextBlock = multiaiConsumeContextRefs(chat); const effectiveTask = task + attachmentsBlock + contextBlock; const opts = { chatId: chat.id, strategy }; @@ -1463,10 +1630,12 @@ async function multiaiRunOrchestrated() { const raw = (document.getElementById('multiai-workflow-steps')?.value || '').trim(); chat.workflowStepsText = document.getElementById('multiai-workflow-steps')?.value || ''; multiaiPersist(); + // "provider: task" only when the prefix is a real provider name β€” + // "Summarize: the key points" is a task for the default provider. const steps = raw.split('\n').map(l => l.trim()).filter(Boolean).map(line => { - const m = line.match(/^([a-z]*)\s*:\s*(.+)$/i); - if (m && m[1]) return { provider: m[1].toLowerCase(), task: m[2] }; - return { task: m ? m[2] : line }; + const m = line.match(/^([a-z][\w-]*(?::[\w.-]+)?)\s*:\s*(.+)$/i); + if (m && MULTIAI_KNOWN_PROVIDERS.includes(m[1].toLowerCase().split(':')[0])) return { provider: m[1].toLowerCase(), task: m[2].trim() }; + return { task: line }; }); if (!steps.length) { showToast('Add at least one workflow step.'); return; } opts.input = effectiveTask; @@ -1489,72 +1658,36 @@ async function multiaiRunOrchestrated() { } multiaiAppendSystemNote(`Running ${strategy}…`); - multiaiSetRunning(true); - const result = await agentHub.multiaiRunOrchestrated(opts); - multiaiSetRunning(false); + multiaiSetRunning(chat.id, true); + let result; + try { + result = await agentHub.multiaiRunOrchestrated(opts); + } catch (e) { + result = { success: false, error: e.message || String(e) }; + } finally { + multiaiSetRunning(chat.id, false); + } if (!result || result.success === false) { multiaiAppendFeedItem({ id: multiaiGenMsgId(), who: 'Orchestration error', text: (result && result.error) || 'Unknown error', role: 'error', cycle: chat.cycle, round: 0 }); } - // Each step's own 'done'/'error'/'cancelled' event already persisted and - // rendered its message via multiaiRenderOrchStep, so the final step IS - // the result β€” no separate "Result Β· strategy" message needed here. + // Every step's output/error is recorded by the main process and arrives + // through onMultiaiMessage (for whichever chat it belongs to), so a run + // in a background chat is no longer lost when you switch away. } -// Orchestrated steps now persist into chat.messages/the ledger too, not -// just the final output β€” so a crew/workflow/loop run leaves a full, -// filterable, referenceable trail like Classic mode does. Round numbering -// reuses the step's own index when the backend provides one (workflow's -// step, loop's turn); crew doesn't emit an index, so a per-run counter -// fills in, giving 1, 2, 3... in the order roles actually ran. +// Orchestrated progress: only the transient "running…" notes are rendered +// here; the step results themselves are persisted messages from main. function multiaiRenderOrchStep(step) { - const label = step.role + if (step.status !== 'running') return; + const label = step.label || (step.role ? `${step.role}${step.provider ? ' Β· ' + multiaiProviderLabel(step.provider) : ''}` : step.step ? `Step ${step.step}${step.provider ? ' Β· ' + multiaiProviderLabel(step.provider) : ''}` : step.turn ? `Turn ${step.turn}${step.phase ? ' Β· ' + step.phase : ''}` - : 'Step'; - - if (step.status === 'running') { - multiaiAppendSystemNote(`${label} β€” running…`); - return; - } - - let role = 'assistant'; - let text; - if (step.status === 'done') { - text = step.output || step.feedback || '(done)'; - } else if (step.status === 'cancelled') { - text = 'Stopped before this step finished.'; - role = 'error'; - } else if (step.status === 'error') { - text = step.error || 'Error'; - role = 'error'; - } else { - return; - } - - const chat = multiaiCurrentChat(); - multiaiState.orchStepCounter = (multiaiState.orchStepCounter || 0) + 1; - const roundNum = step.step || step.turn || multiaiState.orchStepCounter; - const msg = { - id: multiaiGenMsgId(), - who: label, - text, - role, - provider: step.provider, - ts: Date.now(), - cycle: chat ? (chat.cycle || 1) : 1, - round: roundNum, - }; - if (chat) { - chat.messages = chat.messages || []; - chat.messages.push(msg); - multiaiPersist(); - multiaiRenderLedger(chat); - } - multiaiAppendFeedItem(msg); + : 'Step'); + multiaiAppendSystemNote(`${label} β€” running…`); } // ---- Help modal ----------------------------------------------------------- diff --git a/electron/preload.cjs b/electron/preload.cjs index dc1e53c..a5ef76a 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -90,6 +90,9 @@ contextBridge.exposeInMainWorld('agentHub', { // Multi-AI Chat (native tab). multiaiLoad: () => ipcRenderer.invoke('multiai-load'), multiaiSave: (data) => ipcRenderer.invoke('multiai-save', data), + multiaiDeleteChat: (opts) => ipcRenderer.invoke('multiai-delete-chat', opts), + multiaiRunning: () => ipcRenderer.invoke('multiai-running'), + multiaiSummarize: (opts) => ipcRenderer.invoke('multiai-summarize', opts), multiaiListPersonas: () => ipcRenderer.invoke('multiai-list-personas'), multiaiOpenPersonasFolder: () => ipcRenderer.invoke('multiai-open-personas-folder'), multiaiEnabledProviders: () => ipcRenderer.invoke('multiai-enabled-providers'), @@ -105,5 +108,6 @@ contextBridge.exposeInMainWorld('agentHub', { onMultiaiRoundStart: (cb) => { ipcRenderer.removeAllListeners('multiai-round-start'); ipcRenderer.on('multiai-round-start', (e, d) => cb(d)); }, onMultiaiDone: (cb) => { ipcRenderer.removeAllListeners('multiai-done'); ipcRenderer.on('multiai-done', (e, d) => cb(d)); }, onMultiaiOrchStep: (cb) => { ipcRenderer.removeAllListeners('multiai-orch-step'); ipcRenderer.on('multiai-orch-step', (e, d) => cb(d)); }, - onMultiaiOrchDone: (cb) => { ipcRenderer.removeAllListeners('multiai-orch-done'); ipcRenderer.on('multiai-orch-done', (e, d) => cb(d)); } + onMultiaiOrchDone: (cb) => { ipcRenderer.removeAllListeners('multiai-orch-done'); ipcRenderer.on('multiai-orch-done', (e, d) => cb(d)); }, + onMultiaiChatCreated: (cb) => { ipcRenderer.removeAllListeners('multiai-chat-created'); ipcRenderer.on('multiai-chat-created', (e, d) => cb(d)); } }); diff --git a/package.json b/package.json index 73bbb01..3a2a08f 100644 --- a/package.json +++ b/package.json @@ -185,5 +185,9 @@ "electron": "^33.4.11", "electron-builder": "^25.1.8", "http-server": "^14.1.1" + }, + "proximaFork": { + "of": "Zen4-bit/Proxima", + "note": "Fork with the native Multi-AI Chat tab. Auto-update from upstream is disabled unless settings.autoUpdate is true." } -} \ No newline at end of file +} diff --git a/tests/electron/ipc/multiai-core.test.js b/tests/electron/ipc/multiai-core.test.js new file mode 100644 index 0000000..99a3bd6 --- /dev/null +++ b/tests/electron/ipc/multiai-core.test.js @@ -0,0 +1,203 @@ +// Proxima β€” Multi-AI core tests. +// Covers the store's atomic/backup/merge semantics and the bounded-context +// prompt builder, stances, mentions, stop-token detection and parsers. + +import test from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(import.meta.url); +const core = require('../../../electron/ipc/multiai-core.cjs'); + +function tmpDir() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'multiai-core-')); +} + +function msg(over) { + return Object.assign({ id: core.genMsgId(), role: 'assistant', provider: 'claude', who: 'claude', text: 'x', ts: Date.now(), cycle: 1, round: 1 }, over); +} + +// ---- store --------------------------------------------------------------- + +test('store: save is atomic and rotates a backup; load prefers the live file', () => { + const dir = tmpDir(); + const store = core.createStore({ dir, fs, path }); + const chat = store.createChat({ title: 'A' }); + assert.ok(fs.existsSync(store.file())); + store.appendMessage(chat.id, msg({ text: 'first' })); + assert.ok(fs.existsSync(store.backupFile()), 'second save must leave a backup of the previous version'); + assert.ok(!fs.existsSync(path.join(dir, 'chats.json.tmp')), 'tmp file must not linger'); + const loaded = store.load(); + assert.equal(loaded.projects[0].chats[0].messages.length, 1); +}); + +test('store: a corrupt chats.json is parked, not overwritten, and the backup is restored', () => { + const dir = tmpDir(); + const store = core.createStore({ dir, fs, path }); + const chat = store.createChat({ title: 'keep me' }); + store.appendMessage(chat.id, msg({ text: 'survives' })); // rotates a good backup + fs.writeFileSync(store.file(), '{ this is not json', 'utf-8'); + const loaded = store.load(); + assert.equal(loaded.projects[0].chats[0].title, 'keep me'); + const parked = fs.readdirSync(dir).filter(f => f.startsWith('chats.corrupt-')); + assert.equal(parked.length, 1, 'corrupt file is parked under a dated name'); +}); + +test('store: load recovers from backup when only the backup exists (crash between renames)', () => { + const dir = tmpDir(); + const store = core.createStore({ dir, fs, path }); + store.createChat({ title: 'B' }); + fs.renameSync(store.file(), store.backupFile()); + assert.equal(store.load().projects[0].chats[0].title, 'B'); +}); + +test('store: saveSnapshot merges messages appended by main since the renderer loaded', () => { + const dir = tmpDir(); + const store = core.createStore({ dir, fs, path }); + const chat = store.createChat({ title: 'race' }); + const rendererSnapshot = store.load(); // renderer loads + const appended = store.appendMessage(chat.id, msg({ text: 'from REST', ts: 5 })); // main appends + rendererSnapshot.projects[0].chats[0].messages.push(msg({ text: 'from UI', ts: 6 })); + const merged = store.saveSnapshot(rendererSnapshot); // renderer saves stale snapshot + const msgs = merged.projects[0].chats[0].messages; + assert.equal(msgs.length, 2); + assert.ok(msgs.some(m => m.id === appended.id), 'the REST message must survive the snapshot save'); +}); + +test('store: snapshot saves never delete chats; deleteChat does', () => { + const dir = tmpDir(); + const store = core.createStore({ dir, fs, path }); + const a = store.createChat({ title: 'a' }); + const b = store.createChat({ title: 'b' }); + const snap = store.load(); + snap.projects[0].chats = snap.projects[0].chats.filter(c => c.id !== b.id); + store.saveSnapshot(snap); + assert.equal(store.load().projects[0].chats.length, 2, 'missing chat in a snapshot is not a delete'); + assert.equal(store.deleteChat(b.id), true); + assert.equal(store.load().projects[0].chats.length, 1); + assert.equal(store.load().projects[0].chats[0].id, a.id); +}); + +test('store: appendMessage is idempotent on id and fills ts/cycle', () => { + const dir = tmpDir(); + const store = core.createStore({ dir, fs, path }); + const chat = store.createChat({ cycle: 3 }); + const m = store.appendMessage(chat.id, { id: 'fixed', role: 'assistant', text: 'hi' }); + store.appendMessage(chat.id, { id: 'fixed', role: 'assistant', text: 'hi again' }); + const got = store.getChat(chat.id); + assert.equal(got.messages.length, 1); + assert.equal(m.cycle, 3); + assert.ok(m.ts > 0); +}); + +// ---- context / prompts ---------------------------------------------------- + +test('buildContext: recent window verbatim, older condensed, errors and passes excluded', () => { + const messages = []; + messages.push(msg({ role: 'user', who: 'You', text: 'Brief: design a widget', round: 0 })); + for (let i = 0; i < 14; i++) messages.push(msg({ text: `reply ${i} ` + 'lorem '.repeat(200), round: i + 1 })); + messages.push(msg({ role: 'error', who: 'gemini error', text: 'API failed: 500' })); + messages.push(msg({ role: 'pass', who: 'claude', text: 'PASS' })); + const ctx = core.buildContext(messages, { contextWindow: 4, digestCharsPerMessage: 100 }); + assert.equal(ctx.recentCount, 4); + assert.equal(ctx.digestCount, 11); + assert.ok(!ctx.text.includes('API failed'), 'errors must not be fed back to participants'); + assert.ok(!/\bPASS\b/.test(ctx.text), 'passes must not be fed back'); + assert.ok(ctx.text.includes('Brief: design a widget')); + const verbatimCount = (ctx.text.match(/reply \d+ (lorem ){199}lorem/g) || []).length; + assert.equal(verbatimCount, 4, 'only the window is verbatim'); +}); + +test('buildContext: a pinned summary replaces everything before it', () => { + const messages = [ + msg({ role: 'user', who: 'You', text: 'old brief', round: 0 }), + msg({ text: 'ancient argument' }), + msg({ role: 'summary', text: 'DECISIONS: use X. OPEN: Y.' }), + msg({ text: 'post-summary point' }), + ]; + const ctx = core.buildContext(messages, { contextWindow: 10 }); + assert.ok(ctx.text.includes('State summary')); + assert.ok(ctx.text.includes('DECISIONS: use X')); + assert.ok(!ctx.text.includes('ancient argument')); + assert.ok(ctx.text.includes('post-summary point')); +}); + +test('buildContext: digest budget trims oldest lines but keeps the opening brief', () => { + const messages = [msg({ role: 'user', who: 'You', text: 'THE BRIEF', round: 0 })]; + for (let i = 0; i < 50; i++) messages.push(msg({ text: `point ${i} ` + 'x'.repeat(500) })); + const ctx = core.buildContext(messages, { contextWindow: 2, digestCharsPerMessage: 400, digestMaxChars: 3000 }); + assert.ok(ctx.text.includes('THE BRIEF')); + assert.ok(ctx.dropped > 0); + assert.ok(ctx.text.length < 3000 + 4000, 'prompt stays bounded'); +}); + +test('buildClassicPrompt: mentions others, assigns debate stances for 3+, includes PASS/DONE rules', () => { + const participants = ['chatgpt', 'claude', 'gemini']; + const p = core.buildClassicPrompt({ + topic: 'Should we ship?', mode: 'debate', brevity: 'max 4 sentences', persona: null, + participants, provider: 'gemini', messages: [], stopToken: 'DONE', + }); + assert.ok(p.includes('You are Gemini')); + assert.ok(p.includes('ChatGPT') && p.includes('Claude')); + assert.ok(p.includes('critical evaluator'), 'third debater gets the evaluator stance'); + assert.ok(p.includes('reply with exactly: PASS')); + assert.ok(p.includes('single word DONE')); +}); + +test('buildClassicPrompt: independent first round hides same-cycle replies from others', () => { + const messages = [ + msg({ role: 'user', who: 'You', text: 'Q', cycle: 2, round: 0 }), + msg({ provider: 'chatgpt', who: 'chatgpt', text: 'CHATGPT-SAME-CYCLE', cycle: 2, round: 1 }), + msg({ provider: 'chatgpt', who: 'chatgpt', text: 'CHATGPT-OLD-CYCLE', cycle: 1, round: 1 }), + ]; + const p = core.buildClassicPrompt({ topic: 'Q', mode: 'discuss', participants: ['chatgpt', 'claude'], provider: 'claude', messages, independent: { cycle: 2 } }); + assert.ok(!p.includes('CHATGPT-SAME-CYCLE')); + assert.ok(p.includes('CHATGPT-OLD-CYCLE')); + assert.ok(p.includes('independent first round')); +}); + +test('parseMentions: directed order, "only" restriction, label aliases', () => { + const parts = ['chatgpt', 'perplexity', 'gemini', 'claude']; + assert.deepEqual(core.parseMentions('@claude kick off, others review', parts).order, ['claude', 'chatgpt', 'perplexity', 'gemini']); + assert.deepEqual(core.parseMentions('only @gemini and @ChatGPT reply', parts).order, ['gemini', 'chatgpt']); + assert.equal(core.parseMentions('no mentions here', parts).mentioned.length, 0); + assert.equal(core.parseMentions('email me at a@b.com', parts).mentioned.length, 0, 'emails are not mentions'); +}); + +test('stop-token & PASS detection, round outcome', () => { + assert.ok(core.startsWithToken('DONE β€” final position: ship it', 'DONE')); + assert.ok(core.startsWithToken('**DONE** ship it', 'DONE')); + assert.ok(!core.startsWithToken('We are not DONE yet', 'DONE')); + assert.ok(core.isPass(' PASS ')); + assert.ok(core.isPass('**PASS**')); + assert.ok(!core.isPass('PASS the salt')); + const conv = core.roundOutcome([msg({ text: 'DONE agreed' }), msg({ role: 'pass', text: 'PASS' })], 'DONE'); + assert.equal(conv.converged, true); + const notConv = core.roundOutcome([msg({ text: 'DONE agreed' }), msg({ text: 'I disagree' })], 'DONE'); + assert.equal(notConv.converged, false); + const allPass = core.roundOutcome([msg({ role: 'pass' }), msg({ role: 'pass' })], 'DONE'); + assert.equal(allPass.allPassed, true); + assert.equal(allPass.converged, false); +}); + +test('parseWorkflowLine: only real provider prefixes are providers', () => { + assert.deepEqual(core.parseWorkflowLine('claude: outline it'), { provider: 'claude', task: 'outline it' }); + assert.deepEqual(core.parseWorkflowLine('gemini:3.1-pro: deep dive'), { provider: 'gemini:3.1-pro', task: 'deep dive' }); + assert.deepEqual(core.parseWorkflowLine('Summarize: the key points'), { task: 'Summarize: the key points' }); +}); + +test('titles: local title is short and clean; cleanTitle strips quotes/punctuation', () => { + assert.equal(core.localTitle('work together to create a novel sandwich. first propose one, then critique'), 'work together to create a novel sandwich. first'); + assert.equal(core.cleanTitle('"Koji Porchetta Design Session."\nextra'), 'Koji Porchetta Design Session'); + assert.equal(core.localTitle('@gemini kick off: design a novel sandwich'), 'kick off: design a novel sandwich'); +}); + +test('refLabel & provider codes cover external agents', () => { + assert.equal(core.refLabel({ cycle: 1, round: 3, role: 'assistant', provider: 'chatgpt' }), '0103CG'); + assert.equal(core.refLabel({ cycle: 2, round: 0, role: 'user' }), '0200US'); + assert.equal(core.refLabel({ cycle: 2, round: 1, role: 'assistant', provider: 'codex' }), '0201CX'); + assert.equal(core.refLabel({ cycle: 1, round: 0, role: 'summary' }), '0100SM'); +}); diff --git a/tests/electron/ipc/multiai-handlers.test.js b/tests/electron/ipc/multiai-handlers.test.js new file mode 100644 index 0000000..54ea927 --- /dev/null +++ b/tests/electron/ipc/multiai-handlers.test.js @@ -0,0 +1,299 @@ +// Proxima β€” Multi-AI IPC handler tests. +// Drives electron/ipc/multiai.cjs with a fake `electron` and a fake provider +// sender, so the run loop, session routing, convergence, cancellation and the +// REST extension are exercised end-to-end without a BrowserView. + +import test from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import Module from 'node:module'; + +const require = createRequire(import.meta.url); + +// ---- fakes ---------------------------------------------------------------- + +const userData = fs.mkdtempSync(path.join(os.tmpdir(), 'multiai-handlers-')); +const handlers = new Map(); +const sent = []; // every webContents.send(channel, payload) +const providerCalls = []; // every sendMessageToProvider(provider, prompt, ..., sessionId) +let replyFn = async (provider, prompt) => `reply from ${provider}`; + +const fakeElectron = { + ipcMain: { handle: (ch, fn) => handlers.set(ch, fn) }, + app: { getPath: (k) => (k === 'userData' ? userData : userData) }, + dialog: { showSaveDialog: async () => ({ canceled: true }), showOpenDialog: async () => ({ canceled: true }) }, + shell: { openPath: () => { } }, +}; +const fakeSender = { + sendMessageToProvider(provider, message, attachments, onChunk, conversationId) { + const call = { provider, message, conversationId, at: Date.now() }; + providerCalls.push(call); + return Promise.resolve().then(async () => { + const r = await replyFn(provider, message, conversationId); + call.done = Date.now(); + return { response: r }; + }); + }, +}; + +const origLoad = Module._load; +Module._load = function (request, parent, isMain) { + if (request === 'electron') return fakeElectron; + if (request.endsWith('providers/sender.cjs')) return fakeSender; + return origLoad.apply(this, arguments); +}; +const { registerMultiAiHandlers } = require('../../../electron/ipc/multiai.cjs'); +Module._load = origLoad; + +const routeExtensions = []; +registerMultiAiHandlers({ + mainWindow: () => ({ isDestroyed: () => false, webContents: { send: (ch, p) => sent.push({ ch, p }) } }), + loadSettings: () => ({ providers: { chatgpt: { enabled: true }, claude: { enabled: true }, gemini: { enabled: true }, perplexity: { enabled: false } } }), + registerRouteExtension: (fn) => routeExtensions.push(fn), +}); + +const invoke = (ch, payload) => handlers.get(ch)({}, payload); +const core = require('../../../electron/ipc/multiai-core.cjs'); +const store = core.createStore({ dir: path.join(userData, 'multiai'), fs, path }); + +function newChat(fields) { + return store.createChat(Object.assign({ participants: ['chatgpt', 'claude'], discussMode: 'discuss', brevity: 'max 4 sentences', rounds: 1, cycle: 1 }, fields || {})); +} + +function userMsg(chatId, text, cycle = 1) { + return store.appendMessage(chatId, { who: 'You', role: 'user', text, cycle, round: 0 }); +} + +function messagesOf(chatId) { return store.getChat(chatId).messages; } + +const delay = (ms) => new Promise(r => setTimeout(r, ms)); + +// ---- tests ----------------------------------------------------------------- + +test('run-round: records one turn per participant per round, on per-chat session ids, and emits them', async () => { + providerCalls.length = 0; sent.length = 0; + replyFn = async (p) => `${p} says hi`; + const chat = newChat({ rounds: 2 }); + userMsg(chat.id, 'Design a widget'); + const r = await invoke('multiai-run-round', { chatId: chat.id, rounds: 2, latestText: 'Design a widget' }); + assert.equal(r.success, true); + const turns = messagesOf(chat.id).filter(m => m.role === 'assistant'); + assert.equal(turns.length, 4); + assert.deepEqual(turns.map(t => t.round), [1, 1, 2, 2]); + assert.ok(providerCalls.every(c => c.conversationId === `multiai:${chat.id}:1`), 'every call rides the chat-specific thread'); + assert.ok(sent.filter(e => e.ch === 'multiai-message').length >= 4, 'renderer is notified of each turn'); + assert.ok(sent.some(e => e.ch === 'multiai-done' && e.p.chatId === chat.id)); + // second turn of a participant sees the first round in its prompt + const claudeR2 = providerCalls.filter(c => c.provider === 'claude')[1]; + assert.ok(claudeR2.message.includes('chatgpt says hi')); + assert.ok(!claudeR2.message.includes('API failed'), 'errors are never in prompts'); +}); + +test('run-round: stop token from everyone ends the run early; passes are excluded from context', async () => { + providerCalls.length = 0; sent.length = 0; + let n = 0; + replyFn = async (p) => { n++; return n <= 2 ? `${p}: point ${n}` : (p === 'chatgpt' ? 'DONE β€” ship it' : 'PASS'); }; + const chat = newChat({ rounds: 5 }); + userMsg(chat.id, 'Should we ship?'); + await invoke('multiai-run-round', { chatId: chat.id, rounds: 5 }); + const msgs = messagesOf(chat.id); + assert.equal(msgs.filter(m => m.role === 'assistant').length, 3, 'round 1 (2 turns) + chatgpt DONE in round 2'); + assert.equal(msgs.filter(m => m.role === 'pass').length, 1); + assert.ok(msgs.some(m => m.role === 'system' && /Converged after round 2/.test(m.text))); + assert.equal(providerCalls.length, 4, 'no round 3'); +}); + +test('run-round: independent first round runs participants in parallel and blind', async () => { + providerCalls.length = 0; + replyFn = async (p) => { await delay(60); return `${p} independent view`; }; + const chat = newChat({ participants: ['chatgpt', 'claude', 'gemini'], independentFirstRound: true, rounds: 2, stopWhenAllAgree: false }); + userMsg(chat.id, 'Positions please'); + const t0 = Date.now(); + await invoke('multiai-run-round', { chatId: chat.id, rounds: 2 }); + const r1 = providerCalls.slice(0, 3); + assert.ok(Math.max(...r1.map(c => c.at)) - Math.min(...r1.map(c => c.at)) < 40, 'round 1 calls start together'); + assert.ok(r1.every(c => c.message.includes('independent first round'))); + assert.ok(r1.every(c => !c.message.includes('independent view')), 'round 1 prompts contain no same-cycle replies'); + const r2 = providerCalls.slice(3); + assert.equal(r2.length, 3); + assert.ok(r2[0].message.includes('claude independent view'), 'round 2 sees round 1'); + assert.ok(Date.now() - t0 < 60 * 6 + 200, 'parallel round is faster than 6 serial calls'); +}); + +test('run-round: @mentions set order and "only" restricts the round', async () => { + providerCalls.length = 0; + replyFn = async (p) => `${p} ok`; + const chat = newChat({ participants: ['chatgpt', 'claude', 'gemini'], rounds: 1, stopWhenAllAgree: false }); + userMsg(chat.id, '@gemini kick off, others react'); + await invoke('multiai-run-round', { chatId: chat.id, rounds: 1, latestText: '@gemini kick off, others react' }); + assert.deepEqual(providerCalls.map(c => c.provider), ['gemini', 'chatgpt', 'claude']); + providerCalls.length = 0; + userMsg(chat.id, 'only @claude answer this', 2); + store.updateChat(chat.id, { cycle: 2 }); + await invoke('multiai-run-round', { chatId: chat.id, rounds: 1, latestText: 'only @claude answer this' }); + assert.deepEqual(providerCalls.map(c => c.provider), ['claude']); +}); + +test('run-round: engine choice is applied to the provider string (gemini:3.1-pro)', async () => { + providerCalls.length = 0; + replyFn = async (p) => `${p} ok`; + const chat = newChat({ participants: ['gemini'], engines: { gemini: 'gemini:3.1-pro' }, stopWhenAllAgree: false }); + userMsg(chat.id, 'hi'); + await invoke('multiai-run-round', { chatId: chat.id, rounds: 1 }); + assert.equal(providerCalls[0].provider, 'gemini:3.1-pro'); + assert.equal(messagesOf(chat.id).find(m => m.role === 'assistant').provider, 'gemini', 'stored provider is the base name'); +}); + +test('run-round: provider threads rotate after providerResetEvery turns', async () => { + providerCalls.length = 0; + replyFn = async (p) => `${p} ok`; + const chat = newChat({ participants: ['chatgpt'], stopWhenAllAgree: false }); + userMsg(chat.id, 'go'); + await invoke('multiai-run-round', { chatId: chat.id, rounds: core.DEFAULTS.providerResetEvery + 2 }); + const ids = providerCalls.map(c => c.conversationId); + assert.equal(new Set(ids).size, 2, 'exactly one rotation'); + assert.equal(ids[0], `multiai:${chat.id}:1`); + assert.equal(ids[ids.length - 1], `multiai:${chat.id}:2`); +}); + +test('run-round: a provider error is recorded as an error message and the round continues', async () => { + providerCalls.length = 0; + replyFn = async (p) => { if (p === 'chatgpt') throw new Error('API failed: HTTP 500'); return `${p} ok`; }; + const chat = newChat({ stopWhenAllAgree: false }); + userMsg(chat.id, 'go'); + await invoke('multiai-run-round', { chatId: chat.id, rounds: 1 }); + const msgs = messagesOf(chat.id); + assert.ok(msgs.some(m => m.role === 'error' && /HTTP 500/.test(m.text))); + assert.ok(msgs.some(m => m.role === 'assistant' && m.provider === 'claude')); + const claudePrompt = providerCalls.find(c => c.provider === 'claude').message; + assert.ok(!claudePrompt.includes('HTTP 500')); +}); + +test('run-round: refuses a second concurrent run on the same chat; stop cancels', async () => { + providerCalls.length = 0; + let release; + replyFn = () => new Promise(r => { release = r; }); + const chat = newChat({ stopWhenAllAgree: false }); + userMsg(chat.id, 'slow'); + const running = invoke('multiai-run-round', { chatId: chat.id, rounds: 3 }); + await delay(20); + const second = await invoke('multiai-run-round', { chatId: chat.id, rounds: 1 }); + assert.equal(second.success, false); + assert.match(second.error, /already in progress/); + assert.deepEqual(await invoke('multiai-running'), [chat.id]); + await invoke('multiai-stop-run', { chatId: chat.id }); + const r = await running; + assert.equal(r.success, true); + const msgs = messagesOf(chat.id); + assert.ok(msgs.some(m => m.role === 'error' && /stopped/i.test(m.who))); + assert.ok(msgs.some(m => m.role === 'system' && /Run stopped/.test(m.text))); + assert.deepEqual(await invoke('multiai-running'), []); + release('late reply'); // the abandoned provider call resolving later must not add a message + await delay(10); + assert.equal(messagesOf(chat.id).filter(m => m.role === 'assistant').length, 0); +}); + +test('judge: scope "round" uses the last round of the current cycle, not a round number across cycles', async () => { + providerCalls.length = 0; + replyFn = async () => 'verdict'; + const chat = newChat({ cycle: 2 }); + store.appendMessage(chat.id, { who: 'You', role: 'user', text: 'c1', cycle: 1, round: 0 }); + for (let r = 1; r <= 5; r++) store.appendMessage(chat.id, { who: 'chatgpt', provider: 'chatgpt', role: 'assistant', text: `cycle1 round${r}`, cycle: 1, round: r }); + store.appendMessage(chat.id, { who: 'You', role: 'user', text: 'c2', cycle: 2, round: 0 }); + store.appendMessage(chat.id, { who: 'claude', provider: 'claude', role: 'assistant', text: 'cycle2 round1 ONLY', cycle: 2, round: 1 }); + await invoke('multiai-judge', { chatId: chat.id, judgeProvider: 'claude', scope: 'round' }); + const prompt = providerCalls[0].message; + assert.ok(prompt.includes('cycle2 round1 ONLY')); + assert.ok(!prompt.includes('cycle1 round5')); + assert.ok(providerCalls[0].conversationId.startsWith(`multiai:${chat.id}:aux:`), 'judge uses a fresh aux thread'); + assert.ok(messagesOf(chat.id).some(m => m.role === 'judge' && m.text === 'verdict')); +}); + +test('summarize: stores a summary message that later prompts start from', async () => { + providerCalls.length = 0; + replyFn = async (p, prompt) => prompt.includes('note-taker') ? 'DECISIONS: use koji.' : `${p} after summary`; + const chat = newChat({ stopWhenAllAgree: false }); + userMsg(chat.id, 'the brief'); + store.appendMessage(chat.id, { who: 'chatgpt', provider: 'chatgpt', role: 'assistant', text: 'ANCIENT ARGUMENT', cycle: 1, round: 1 }); + const s = await invoke('multiai-summarize', { chatId: chat.id, provider: 'claude' }); + assert.equal(s.success, true); + assert.ok(messagesOf(chat.id).some(m => m.role === 'summary' && /koji/.test(m.text))); + providerCalls.length = 0; + await invoke('multiai-run-round', { chatId: chat.id, rounds: 1 }); + const prompt = providerCalls[0].message; + assert.ok(prompt.includes('DECISIONS: use koji')); + assert.ok(!prompt.includes('ANCIENT ARGUMENT'), 'pre-summary transcript is compacted away'); +}); + +test('orchestrated crew: steps are recorded as messages and a failed role stops the pipeline', async () => { + providerCalls.length = 0; + replyFn = async (p) => { if (p === 'claude') throw new Error('timed out'); return `${p} output`; }; + const chat = newChat({ mode: 'orchestrated', strategy: 'crew' }); + userMsg(chat.id, 'write a brief'); + const r = await invoke('multiai-run-orchestrated', { + chatId: chat.id, strategy: 'crew', task: 'write a brief', + agents: [{ role: 'Researcher', provider: 'chatgpt', instruction: 'research' }, { role: 'Writer', provider: 'claude', instruction: 'write' }, { role: 'Reviewer', provider: 'gemini', instruction: 'review' }], + }); + assert.equal(r.success, true); + const msgs = messagesOf(chat.id); + assert.ok(msgs.some(m => m.role === 'assistant' && /Researcher/.test(m.who) && m.round === 1)); + assert.ok(msgs.some(m => m.role === 'error' && /Writer/.test(m.who) && /timed out/.test(m.text))); + assert.ok(!msgs.some(m => /Reviewer/.test(m.who)), 'reviewer never ran on stale input'); + assert.equal(providerCalls.length, 2); +}); + +test('REST: list, create, append, read since, run (wait) and stop', async () => { + providerCalls.length = 0; + replyFn = async (p) => `${p} via rest`; + const ext = routeExtensions[0]; + assert.equal(typeof ext, 'function'); + const calls = []; + const res = {}; + const helpers = { sendJSON: (r, code, data) => calls.push({ code, data }), sendError: (r, code, msg) => calls.push({ code, error: msg }) }; + const url = (p, q) => Object.assign(new URL(`http://127.0.0.1:3210${p}`), q ? { search: q } : {}); + + assert.equal(await ext('GET', url('/v1/chat/completions'), {}, res, helpers), false, 'non-multiai routes fall through'); + + await ext('POST', url('/v1/multiai/chats'), { title: 'from codex', participants: ['chatgpt'], topic: 'Codex opens the floor', who: 'Codex' }, res, helpers); + const created = calls.pop(); + assert.equal(created.code, 201); + const id = created.data.chat.id; + assert.equal(created.data.chat.messages[0].role, 'user'); + + await ext('GET', url('/v1/multiai/chats'), {}, res, helpers); + assert.ok(calls.pop().data.chats.some(c => c.id === id)); + + await ext('POST', url(`/v1/multiai/chats/${id}/messages`), { who: 'Codex', provider: 'codex', text: 'I ran the backtest: VERIFIED path=results.csv' }, res, helpers); + const appended = calls.pop(); + assert.equal(appended.code, 201); + assert.equal(appended.data.message.label.slice(-2), 'CX'); + + await ext('POST', url(`/v1/multiai/chats/${id}/run`), { rounds: 1, wait: true }, res, helpers); + const ran = calls.pop(); + assert.equal(ran.code, 200); + assert.equal(ran.data.messages.filter(m => m.role === 'assistant').length, 1); + assert.ok(providerCalls[0].message.includes('VERIFIED path=results.csv'), 'the browser AI sees the local agent\'s turn'); + + await ext('GET', url(`/v1/multiai/chats/${id}`, `?since=${appended.data.message.id}`), {}, res, helpers); + const since = calls.pop(); + assert.equal(since.code, 200); + assert.ok(since.data.chat.messages.every(m => m.id !== appended.data.message.id)); + assert.equal(since.data.chat.running, false); + + await ext('POST', url(`/v1/multiai/chats/nope/stop`), {}, res, helpers); + assert.equal(calls.pop().code, 404); + await ext('POST', url(`/v1/multiai/chats/${id}/stop`), {}, res, helpers); + assert.equal(calls.pop().data.stopped, false); +}); + +test('delete-chat removes it; snapshot save afterwards does not resurrect it', async () => { + const chat = newChat(); + const snapshot = store.load(); + const r = await invoke('multiai-delete-chat', { chatId: chat.id }); + assert.equal(r.success, true); + await invoke('multiai-save', snapshot); + assert.equal(store.getChat(chat.id), null, 'tombstone keeps a stale snapshot from resurrecting the chat'); +}); From 1e06d5846ff6c1bf757f18532fcf0d8e20a4cba0 Mon Sep 17 00:00:00 2001 From: Eugene Turetsky Date: Thu, 3 Sep 2026 05:05:02 -0400 Subject: [PATCH 03/67] Multi-AI Chat: selectable context mode (bounded / delta + rolling summary / full transcript); version 5.1.0 Per-chat Context option: Bounded (default, summary + digest + recent window), Delta (only messages since this provider's last turn on its own thread, rolling auto-summary as the topic block, full catch-up on a fresh/rotated thread), Full (entire transcript every turn, the original PCA behaviour). Delta threads rotate every 40 turns instead of 12. Fork version bumped to 5.1.0 for the installer. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ACdKJmXfzvwfCCLneCWd12 --- docs/MULTIAI.md | 18 ++++- electron/index-v2.html | 15 +++- electron/ipc/multiai-core.cjs | 85 +++++++++++++++++++-- electron/ipc/multiai.cjs | 64 ++++++++++++++-- electron/multiai-renderer.js | 21 +++++ electron/package.json | 4 +- package.json | 4 +- tests/electron/ipc/multiai-core.test.js | 32 ++++++++ tests/electron/ipc/multiai-handlers.test.js | 53 +++++++++++++ 9 files changed, 271 insertions(+), 25 deletions(-) diff --git a/docs/MULTIAI.md b/docs/MULTIAI.md index 8150ad7..131512e 100644 --- a/docs/MULTIAI.md +++ b/docs/MULTIAI.md @@ -12,7 +12,19 @@ Code: `electron/ipc/multiai-core.cjs` (pure logic, unit-tested), ## How a turn is built -Every participant receives a self-sufficient prompt, not the whole history: +The **Context** option (per chat) picks one of three strategies: + +- **Bounded** (default) β€” a self-sufficient prompt, not the whole history (see below). +- **Delta** β€” only the messages since that provider's last turn; its own + provider-side thread remembers the rest, and a rolling State summary rides + along as the topic block (refreshed every *Auto-summary every* messages via + the Judge provider). A fresh or rotated thread gets one full catch-up prompt + (the bounded one), so nothing is lost. Cheapest per turn. +- **Full** β€” the entire transcript every turn (the original ProximaChatApp + behaviour). Simplest, unbounded, and the cause of the late-session HTTP 500s + and timeouts in long chats. + +In bounded mode every participant receives: 1. the brief (first human message) and the latest human instruction, 2. the pinned **State summary**, if one exists (everything before it is dropped), @@ -25,8 +37,8 @@ Every participant receives a self-sufficient prompt, not the whole history: Errors, stop notices, passes and system notes are never fed back to participants. Each chat has its own provider-side thread per provider -(`multiai::`), rotated every 12 turns so provider threads -stay bounded; judge/summary/title calls use a fresh one-off thread. Chats never +(`multiai::`), rotated every 12 turns (40 in Delta mode) so +provider threads stay bounded; judge/summary/title calls use a fresh one-off thread. Chats never bleed into each other or into the MCP tools' `mcp-session` thread. ## Classic mode diff --git a/electron/index-v2.html b/electron/index-v2.html index ad74671..26241f6 100644 --- a/electron/index-v2.html +++ b/electron/index-v2.html @@ -1491,7 +1491,17 @@

Get Started

-
@@ -1614,6 +1617,7 @@

Get Started

Stop token / PASS β€” participants are asked to begin a reply with the stop token (default DONE) when they think the group has converged, and to reply PASS when they have nothing new. With β€œStop early when all agree” on, a round where everyone says the token or passes ends the run β€” no more rounds of β€œAgreed, nothing further”. Passes are shown dimmed and are not fed to the other AIs; neither are errors.

Directed turns β€” mention participants in your message to set this Send's order: @claude kick off, others review makes Claude speak first; only @gemini and @chatgpt restricts the round to those two. Names: @chatgpt, @claude, @gemini, @perplexity.

Independent first round β€” round 1 of each Send runs everyone in parallel, blind to each other's replies (positions are committed before anyone reads the others); later rounds are round-robin as usual. In Debate with three or more participants, stances are assigned (for / against / critical evaluator / third option) rather than left to chance.

+

Charter mode β€” turns the coworking charter's discipline into engine rules: participants must tag claims VERIFIED [who/how/path], UNVERIFIED or ASSUMED [basis] (agreement never upgrades a claim); plain agreement counts as PASS; every message ends with HANDOFF β†’ <participant>: …, OPEN β†’ … or COMPLETE β†’ …; a HANDOFF pulls the named participant forward (next in this round, or first in the next); and one participant per round holds a rotating red-team seat (marked in the feed) whose job is the strongest objection to the emerging consensus.

Judge β€” after a discussion, ask one provider to read the transcript and give a verdict. Judge scope: the whole chat, everything since your last message, or only the last round.

Summarize state β€” asks the Judge provider for a pinned summary (decisions, open questions, claims to verify, next step). It becomes the compaction point: later turns start from the summary instead of the older transcript. Use it whenever you'd otherwise ask β€œwhat is the state of the discussion”.

Personas β€” a shared library of a few built-ins (Skeptic, Optimist, Devil's Advocate, Pragmatist, Domain Expert, Concise Summarizer) plus any .md/.txt file you drop in the personas folder. Assign one per participant in the Personas block above (a separate setting from picking who's in the discussion); the same library also fills in a crew role's Instruction in Orchestrated mode, so you're not maintaining two systems.

diff --git a/electron/ipc/multiai-core.cjs b/electron/ipc/multiai-core.cjs index f5b9f3f..5325cb9 100644 --- a/electron/ipc/multiai-core.cjs +++ b/electron/ipc/multiai-core.cjs @@ -422,12 +422,25 @@ function buildClassicPrompt(args) { // Same, plus meta: which context mode was used, whether a delta turn fell back // to a full catch-up, and the id of the last message included (the delta // anchor to store for this provider). -function buildClassicPromptWithMeta({ chatTitle, topic, latestUserMessage, mode, brevity, persona, participants, provider, messages, contextOpts, stopToken, independent, contextMode, delta }) { +// Charter mode (MULTI_AI_COWORKING_CHARTER Β§2, Β§4, Β§7): claim-status tags, +// an explicit handoff/open/complete trailer on every message, "agreement +// needs a reason", and a rotating red-team seat. Enforced by prompt plus the +// HANDOFF β†’ routing in the run loop. +const CHARTER_RULES = [ + 'Tag every factual or empirical claim you make: VERIFIED [who/how/path] only when you or a local agent actually ran or checked it; UNVERIFIED when it comes from memory or reasoning; ASSUMED [basis] when it is a working assumption. Agreement never upgrades a claim\'s status.', + 'Plain agreement is not a contribution: if you agree, add a distinct reason, a caveat, or a test that would settle it β€” otherwise reply PASS.', + 'End your message with exactly one trailer line: "HANDOFF β†’ : ", or "OPEN β†’ ", or "COMPLETE β†’ ".', +]; + +const RED_TEAM_BLOCK = 'This round you hold the RED-TEAM seat: your job is to find the strongest objection to the emerging consensus β€” a missing assumption, a test that would falsify it, a cheaper alternative β€” and state it plainly, even if you privately agree. Do not soften it.'; + +function buildClassicPromptWithMeta({ chatTitle, topic, latestUserMessage, mode, brevity, persona, participants, provider, messages, contextOpts, stopToken, independent, contextMode, delta, charter, redTeam }) { const base = String(provider).split(':')[0]; const idx = Math.max(0, participants.indexOf(provider), participants.indexOf(base)); const others = participants.filter(p => p !== provider && p !== base).map(providerLabel); const side = stanceFor(mode, idx, participants.length); - const personaBlock = persona ? `[Your assigned persona / instructions β€” stay in character]\n${persona}\n\n` : ''; + const personaText = [persona, redTeam ? RED_TEAM_BLOCK : ''].filter(Boolean).join('\n\n'); + const personaBlock = personaText ? `[Your assigned persona / instructions β€” stay in character]\n${personaText}\n\n` : ''; const visible = independent ? messages.filter(m => m.role !== 'assistant' || m.cycle !== independent.cycle) : messages; const cmode = CONTEXT_MODES.includes(contextMode) ? contextMode : DEFAULTS.contextMode; const ctx = buildContextByMode(visible, { mode: cmode, contextOpts, delta: Object.assign({ provider: base }, delta || {}) }); @@ -439,6 +452,7 @@ function buildClassicPromptWithMeta({ chatTitle, topic, latestUserMessage, mode, ]; if (independent) rules.push('This is an independent first round: you have NOT been shown the other participants\' replies to the latest message. Commit to your own position; do not guess at theirs.'); if (cmode === 'delta' && !ctx.catchUp) rules.push('You are continuing an ongoing thread: earlier messages are already in this conversation above β€” only the new ones are shown here.'); + if (charter) rules.push(...CHARTER_RULES); const prompt = `${personaBlock}You are ${providerLabel(provider)}, one participant in a multi-AI conversation` + `${others.length ? ` with ${others.join(', ')}` : ''}${chatTitle ? ` titled "${chatTitle}"` : ''}.\n` + `Topic / brief: ${topic || '(see conversation)'}\n` + @@ -446,7 +460,25 @@ function buildClassicPromptWithMeta({ chatTitle, topic, latestUserMessage, mode, `${side ? side + '\n' : ''}\n` + `${ctx.text}\n\n` + rules.join('\n'); - return { prompt, meta: { contextMode: cmode, catchUp: !!ctx.catchUp, lastId: ctx.lastId || null } }; + return { prompt, meta: { contextMode: cmode, catchUp: !!ctx.catchUp, lastId: ctx.lastId || null, redTeam: !!redTeam } }; +} + +// "HANDOFF β†’ Claude: run the test" / "HANDOFF -> @gemini" β†’ the named +// participant, or null. Only the trailer counts (last non-empty lines), so a +// quoted handoff earlier in the message doesn't reroute the round. +function parseHandoff(text, participants) { + const lines = String(text || '').trim().split('\n').map(l => l.trim()).filter(Boolean); + const tail = lines.slice(-3).join('\n'); + const m = /HANDOFF\s*(?:β†’|->|=>)\s*@?([A-Za-z][\w-]*)/i.exec(tail); + if (!m) return null; + const name = m[1].toLowerCase(); + return participants.find(p => p.split(':')[0] === name || providerLabel(p).toLowerCase().replace(/\s+/g, '') === name) || null; +} + +// Which participant holds the red-team seat in a given round (rotates). +function redTeamFor(order, round) { + if (!order || order.length < 2) return null; + return order[(round - 1) % order.length]; } // Messages (readable) since the last pinned summary β€” the trigger for the @@ -566,6 +598,6 @@ module.exports = { CONTEXT_MODES, READABLE_ROLES, readable, buildContext, buildFullContext, buildDeltaContext, buildContextByMode, renderFullTranscript, briefAndLatest, messagesSinceSummary, stanceFor, buildClassicPrompt, buildClassicPromptWithMeta, buildJudgePrompt, buildSummaryPrompt, buildTitlePrompt, - localTitle, cleanTitle, parseMentions, isPass, startsWithToken, roundOutcome, + localTitle, cleanTitle, parseMentions, parseHandoff, redTeamFor, CHARTER_RULES, RED_TEAM_BLOCK, isPass, startsWithToken, roundOutcome, sessionFor, auxSessionFor, parseToolCall, parseWorkflowLine, }; diff --git a/electron/ipc/multiai.cjs b/electron/ipc/multiai.cjs index 78afbc7..750515a 100644 --- a/electron/ipc/multiai.cjs +++ b/electron/ipc/multiai.cjs @@ -324,6 +324,8 @@ function registerMultiAiHandlers(deps) { contextMode: cmode, // A fresh/rotated thread has no memory: force the catch-up prompt. delta: { sinceId: (st.rotated || st.turns === 0) ? null : st.lastId, provider: base }, + charter: !!chat.charterMode, + redTeam: !!(chat.charterMode && opts.redTeam && String(opts.redTeam).split(':')[0] === base), }); const raced = await sendOnChatThread(chat, provider, built.prompt, st, cmode === 'delta' ? built.meta.lastId : null); const label = core.providerLabel(base); @@ -340,8 +342,8 @@ function registerMultiAiHandlers(deps) { record(chatId, { who: label, text: 'PASS', provider: base, role: 'pass', cycle, round }); return { pass: true }; } - record(chatId, { who: label, text, provider: base, role: 'assistant', cycle, round }); - return { ok: true }; + record(chatId, { who: label, text, provider: base, role: 'assistant', cycle, round, redTeam: built.meta.redTeam || undefined }); + return { ok: true, handoff: chat.charterMode ? core.parseHandoff(text, opts.participants) : null }; } // Rolling summary (delta mode's "Topic block"): once more than @@ -407,25 +409,57 @@ function registerMultiAiHandlers(deps) { } }; const active = () => order.filter(p => !benched.has(String(p).split(':')[0])); + // Charter mode: a HANDOFF β†’ trailer can pull the named participant + // forward β€” next within this round if they haven't spoken yet, + // otherwise first in the next round. + let nextFirst = null; + // A HANDOFF β†’ in the message that triggered this run (the human's, + // or a local agent's posted via REST/MCP) picks who speaks first. + if (chat0.charterMode) { + const readableMsgs = core.readable(chat0.messages); + const lastMsg = readableMsgs[readableMsgs.length - 1]; + const target = lastMsg ? core.parseHandoff(lastMsg.text, order) : null; + if (target) nextFirst = target; + } outer: for (let r = 1; r <= n; r++) { if (sig.cancelled) break; emit('multiai-round-start', { chatId, round: r, of: n }); - const turnOrder = active(); + let turnOrder = active(); if (!turnOrder.length) { note(chatId, 'No participants left to take a turn.'); break; } + if (nextFirst && turnOrder.includes(nextFirst)) turnOrder = [nextFirst].concat(turnOrder.filter(p => p !== nextFirst)); + nextFirst = null; + // Seat rotates over the stable participant order, not the + // handoff-reordered turn order, so everyone gets it in turn. + const redTeam = chat0.charterMode ? core.redTeamFor(active(), r) : null; + if (redTeam) note(chatId, `Round ${r}: ${core.providerLabel(redTeam)} holds the red-team seat.`); const independent = r === 1 && !!chat0.independentFirstRound && turnOrder.length > 1; if (independent) { - const results = await Promise.all(turnOrder.map(p => takeTurn(chatId, p, r, { participants: order, independent: true }) + const results = await Promise.all(turnOrder.map(p => takeTurn(chatId, p, r, { participants: order, independent: true, redTeam }) .catch(e => ({ error: e.message })))); results.forEach((res, i) => noteFailure(turnOrder[i], res)); if (results.some(x => x && x.cancelled) || sig.cancelled) break outer; + const lastHandoff = results.map(x => x && x.handoff).filter(Boolean).pop(); + if (lastHandoff) nextFirst = lastHandoff; } else { - for (const provider of turnOrder) { + const remaining = turnOrder.slice(); + while (remaining.length) { if (sig.cancelled) break outer; - const res = await takeTurn(chatId, provider, r, { participants: order }).catch(e => ({ error: e.message })); + const provider = remaining.shift(); + const res = await takeTurn(chatId, provider, r, { participants: order, redTeam }).catch(e => ({ error: e.message })); noteFailure(provider, res); if (res && res.cancelled) break outer; + if (res && res.handoff) { + const target = res.handoff; + if (remaining.includes(target)) { + remaining.splice(remaining.indexOf(target), 1); + remaining.unshift(target); + note(chatId, `${core.providerLabel(provider)} handed off to ${core.providerLabel(target)} β€” they go next.`); + } else if (target !== provider) { + nextFirst = target; + } + } } } const after = store.getChat(chatId); @@ -845,6 +879,14 @@ function registerMultiAiHandlers(deps) { if (body.discussMode === 'discuss' || body.discussMode === 'debate') fields.discussMode = body.discussMode; if (body.brevity) fields.brevity = String(body.brevity); if (core.CONTEXT_MODES.includes(body.contextMode)) fields.contextMode = body.contextMode; + if (body.charterMode != null) fields.charterMode = !!body.charterMode; + if (body.independentFirstRound != null) fields.independentFirstRound = !!body.independentFirstRound; + if (body.stopWhenAllAgree != null) fields.stopWhenAllAgree = !!body.stopWhenAllAgree; + if (body.stopToken) fields.stopToken = String(body.stopToken).trim().split(/\s+/)[0].slice(0, 20); + if (body.rounds != null) fields.rounds = Math.max(1, Math.min(30, parseInt(body.rounds, 10) || 1)); + if (body.contextWindow != null) fields.contextWindow = Math.max(0, parseInt(body.contextWindow, 10) || 0); + if (body.autoSummaryEvery != null) fields.autoSummaryEvery = Math.max(0, parseInt(body.autoSummaryEvery, 10) || 0); + if (body.engines && typeof body.engines === 'object') fields.engines = body.engines; if (body.topic) fields.topic = String(body.topic); const created = store.createChat(fields); if (body.topic) { diff --git a/electron/multiai-renderer.js b/electron/multiai-renderer.js index addafa6..33c8d2c 100644 --- a/electron/multiai-renderer.js +++ b/electron/multiai-renderer.js @@ -62,6 +62,7 @@ function multiaiDefaultChat() { stopToken: 'DONE', // convergence token; a round where everyone says it (or passes) ends the run early. stopWhenAllAgree: true, independentFirstRound: false, // round 1 of each Send runs in parallel, blind to the others' replies. + charterMode: false, // claim-status tags, HANDOFF β†’ routing, rotating red-team seat (MULTI_AI_COWORKING_CHARTER). }; } @@ -711,6 +712,7 @@ function multiaiRenderOptionsSummary(chat) { if (chat.contextMode === 'full') extras.push('full transcript'); else if (chat.contextMode === 'delta') extras.push(`delta${parseInt(chat.autoSummaryEvery, 10) > 0 ? ' + rolling summary' : ''}`); if (chat.independentFirstRound) extras.push('blind R1'); + if (chat.charterMode) extras.push('charter'); if (chat.stopWhenAllAgree !== false) extras.push(`stop on ${chat.stopToken || 'DONE'}`); if (chat.engines && chat.engines.gemini && chat.engines.gemini !== 'gemini') extras.push(chat.engines.gemini); el.textContent = `βš™ ${n} participant${n === 1 ? '' : 's'} Β· ${chat.discussMode === 'debate' ? 'Debate' : 'Discuss'} Β· ${rounds} round${rounds === 1 ? '' : 's'} Β· ${chat.brevity || 'max 4 sentences'}${extras.length ? ' Β· ' + extras.join(' Β· ') : ''}`; @@ -764,6 +766,8 @@ function multiaiRenderChat() { if (sa) sa.checked = chat.stopWhenAllAgree !== false; const ind = document.getElementById('multiai-independent-first'); if (ind) ind.checked = !!chat.independentFirstRound; + const ch = document.getElementById('multiai-charter-mode'); + if (ch) ch.checked = !!chat.charterMode; multiaiRenderParticipants(chat); multiaiRenderPersonaAssignment(chat); @@ -1070,7 +1074,7 @@ function multiaiAppendFeedItem(message, skipScroll) { const attach = message.attachmentsText ? `
πŸ“Ž attached context (${message.attachmentsText.length.toLocaleString()} chars)
${multiaiEscape(message.attachmentsText)}
` : ''; item.innerHTML = `
-
${multiaiEscape(message.who || message.provider || '')}
+
${multiaiEscape(message.who || message.provider || '')}${message.redTeam ? ' Β· red team' : ''}
${multiaiRefLabel(message)}
${multiaiEscape(message.text || '')}
${attach} @@ -1237,6 +1241,8 @@ function multiaiReadClassicFields(chat) { if (sa) chat.stopWhenAllAgree = !!sa.checked; const ind = document.getElementById('multiai-independent-first'); if (ind) chat.independentFirstRound = !!ind.checked; + const ch = document.getElementById('multiai-charter-mode'); + if (ch) chat.charterMode = !!ch.checked; const js = document.getElementById('multiai-judge-scope'); if (js) chat.judgeScope = js.value; const jp = document.getElementById('multiai-judge-provider'); diff --git a/electron/providers/engines/gemini-engine.js b/electron/providers/engines/gemini-engine.js index b1cee4c..6e92e55 100644 --- a/electron/providers/engines/gemini-engine.js +++ b/electron/providers/engines/gemini-engine.js @@ -317,10 +317,16 @@ } var replyText = ''; + // Diagnostics for the "stitched reply" problem seen in Multi-AI chats: + // record each frame's best candidate length so a restart (length drop) + // or a concatenated frame is visible in the log. Read via + // window.__proximaGeminiFrames after a send. + var _frameLens = []; for (var i = 0; i < allItems.length; i++) { var item = allItems[i]; var idx = dataIndices[i] || 2; + var _frameBest = 0; try { var inner = JSON.parse(item[idx]); var paths = [ @@ -336,11 +342,13 @@ for (var pi = 0; pi < paths.length; pi++) { try { var candidate = paths[pi](); - if (typeof candidate === 'string' && candidate.length > 0 && candidate.length > replyText.length && !/^[rc]_[a-f0-9]{16,}$/.test(candidate) && !/^(https?:)?\/\/[^\s]+$/.test(candidate.trim())) { - replyText = candidate; + if (typeof candidate === 'string' && candidate.length > 0 && !/^[rc]_[a-f0-9]{16,}$/.test(candidate) && !/^(https?:)?\/\/[^\s]+$/.test(candidate.trim())) { + if (candidate.length > _frameBest) _frameBest = candidate.length; + if (candidate.length > replyText.length) replyText = candidate; } } catch (e) { } } + _frameLens.push(_frameBest); if (!replyText) { function findLongest(obj, depth) { @@ -367,6 +375,15 @@ } throw new Error('Could not extract reply from Gemini'); } + if (commitIds) { + try { + var _drops = 0; + for (var fi = 1; fi < _frameLens.length; fi++) if (_frameLens[fi] && _frameLens[fi - 1] && _frameLens[fi] < _frameLens[fi - 1]) _drops++; + var _join = /[a-z,;][A-Z][a-z]+ (am|will|would|think|agree|disagree|propose)\b/.test(replyText) ? 'suspect-join' : 'ok'; + window.__proximaGeminiFrames = { lens: _frameLens, final: replyText.length, drops: _drops, join: _join }; + console.log('[Proxima API] frames=' + _frameLens.length + ' lens=' + _frameLens.join(',') + ' final=' + replyText.length + ' drops=' + _drops + ' ' + _join); + } catch (e) { } + } return replyText; } diff --git a/src/mcp/index.js b/src/mcp/index.js index 4afc2ff..659876f 100644 --- a/src/mcp/index.js +++ b/src/mcp/index.js @@ -205,9 +205,12 @@ async function chatWithProvider(providerName, message) { return await smartChat(message, p, {}); } +let PKG_VERSION = '5.0.0'; +try { PKG_VERSION = JSON.parse(fs.readFileSync(path.resolve(__dirname, '..', '..', 'package.json'), 'utf8')).version || PKG_VERSION; } catch { /* keep fallback */ } + const server = new McpServer({ name: 'agent-hub', - version: '5.0.0', + version: PKG_VERSION, description: 'Proxima MCP Server v5.0 β€” Modular Architecture', }); @@ -256,7 +259,7 @@ server.resource( mimeType: 'application/json', text: JSON.stringify({ server: 'Proxima MCP Server', - version: '5.0.0', + version: PKG_VERSION, architecture: 'modular', modules: ['ipc-bridge', 'helpers', 'pipeline', 'tools-chat', 'tools-code', 'tools-search', 'tools-content', 'tools-utility', 'tools-workflow'], enabledProviders: Array.from(enabled), diff --git a/src/mcp/tools-multiai.js b/src/mcp/tools-multiai.js index 5a32351..8f82668 100644 --- a/src/mcp/tools-multiai.js +++ b/src/mcp/tools-multiai.js @@ -86,6 +86,12 @@ export function register(server, deps) { discussMode: z.enum(['discuss', 'debate']).optional(), contextMode: z.enum(['bounded', 'delta', 'full']).optional(), brevity: z.string().optional().describe('e.g. "max 4 sentences" or "as long as needed"'), + rounds: z.number().int().min(1).max(30).optional().describe('Default rounds per run'), + charterMode: z.boolean().optional().describe('Coworking-charter rules: claim tags, HANDOFF β†’ routing, rotating red-team seat'), + independentFirstRound: z.boolean().optional().describe('Round 1 of each run is parallel and blind'), + stopWhenAllAgree: z.boolean().optional(), + stopToken: z.string().optional().describe('Convergence token (default DONE)'), + autoSummaryEvery: z.number().int().min(0).max(200).optional().describe('Delta mode: refresh the rolling summary every N messages'), }, annotations: Object.freeze({ readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }), }, async (args) => { diff --git a/tests/electron/ipc/multiai-core.test.js b/tests/electron/ipc/multiai-core.test.js index 721017c..5ae0bcc 100644 --- a/tests/electron/ipc/multiai-core.test.js +++ b/tests/electron/ipc/multiai-core.test.js @@ -233,3 +233,22 @@ test('context modes: full sends everything; delta sends only unseen messages fro assert.ok(withMeta.prompt.includes('continuing an ongoing thread')); assert.equal(core.messagesSinceSummary(messages).length, 2); }); + +test('charter mode: claim-tag rules and red-team block appear only when asked; handoff parsing reads the trailer', () => { + const parts = ['chatgpt', 'claude', 'gemini']; + const plain = core.buildClassicPrompt({ topic: 'T', mode: 'discuss', participants: parts, provider: 'claude', messages: [] }); + assert.ok(!plain.includes('VERIFIED [who/how/path]')); + const charter = core.buildClassicPromptWithMeta({ topic: 'T', mode: 'discuss', participants: parts, provider: 'claude', messages: [], charter: true, redTeam: true }); + assert.ok(charter.prompt.includes('VERIFIED [who/how/path]')); + assert.ok(charter.prompt.includes('HANDOFF β†’')); + assert.ok(charter.prompt.includes('RED-TEAM seat')); + assert.equal(charter.meta.redTeam, true); + assert.equal(core.parseHandoff('Good point.\n\nHANDOFF β†’ Gemini: run the numbers', parts), 'gemini'); + assert.equal(core.parseHandoff('HANDOFF -> @chatgpt', parts), 'chatgpt'); + assert.equal(core.parseHandoff('Earlier someone wrote HANDOFF β†’ claude but I disagree.\n\nline\nline\nline\nOPEN β†’ what now?', parts), null, 'only the trailer counts'); + assert.equal(core.parseHandoff('COMPLETE β†’ settled', parts), null); + assert.equal(core.redTeamFor(parts, 1), 'chatgpt'); + assert.equal(core.redTeamFor(parts, 4), 'chatgpt'); + assert.equal(core.redTeamFor(parts, 2), 'claude'); + assert.equal(core.redTeamFor(['solo'], 1), null); +}); diff --git a/tests/electron/ipc/multiai-handlers.test.js b/tests/electron/ipc/multiai-handlers.test.js index a94bf52..05de0be 100644 --- a/tests/electron/ipc/multiai-handlers.test.js +++ b/tests/electron/ipc/multiai-handlers.test.js @@ -404,3 +404,36 @@ test('stop: aborts the in-flight provider call and skips queued ones', async () release('late'); delete fakeSender.abortActive; delete fakeSender.aborted; }); + +test('charter mode: HANDOFF β†’ reorders the round, carries into the next round, and the red-team seat rotates', async () => { + providerCalls.length = 0; + replyFn = async (p, prompt) => { + if (p === 'chatgpt' && !prompt.includes('gemini said')) return 'Proposal X. UNVERIFIED.\n\nHANDOFF β†’ Gemini: check it'; + if (p === 'gemini') return 'gemini said: checked, VERIFIED [ran test].\n\nHANDOFF β†’ Claude: your view'; + if (p === 'claude') return 'claude view.\n\nHANDOFF β†’ Gemini: verify again'; + return `${p} view.\n\nOPEN β†’ next?`; + }; + const chat = newChat({ participants: ['chatgpt', 'claude', 'gemini'], charterMode: true, stopWhenAllAgree: false }); + userMsg(chat.id, 'charter test'); + await invoke('multiai-run-round', { chatId: chat.id, rounds: 2 }); + const order = providerCalls.map(c => c.provider); + assert.deepEqual(order.slice(0, 3), ['chatgpt', 'gemini', 'claude'], 'gemini pulled forward by the handoff'); + assert.deepEqual(order.slice(3), ['gemini', 'claude', 'chatgpt'], 'round 2 starts with the participant handed off to at the end of round 1 (who had already spoken)'); + const msgs = messagesOf(chat.id); + assert.ok(msgs.some(m => m.role === 'system' && /handed off to Gemini/.test(m.text))); + assert.ok(msgs.some(m => m.role === 'system' && /Round 1: ChatGPT holds the red-team seat/.test(m.text))); + assert.ok(msgs.some(m => m.role === 'system' && /Round 2: Claude holds the red-team seat/.test(m.text))); + const redTeamPrompts = providerCalls.filter(c => c.message.includes('RED-TEAM seat')); + assert.equal(redTeamPrompts.length, 2, 'exactly one red-team prompt per round'); + assert.ok(providerCalls.every(c => c.message.includes('VERIFIED [who/how/path]'))); +}); + +test('charter mode: a HANDOFF β†’ in a local agent\'s posted message picks who speaks first', async () => { + providerCalls.length = 0; + replyFn = async (p) => `${p} reply.\n\nOPEN β†’ next?`; + const chat = newChat({ participants: ['chatgpt', 'claude', 'gemini'], charterMode: true, stopWhenAllAgree: false }); + userMsg(chat.id, 'brief'); + store.appendMessage(chat.id, { who: 'Codex', provider: 'codex', role: 'assistant', text: 'VERIFIED [ran it]: numbers attached.\n\nHANDOFF β†’ Gemini: sanity-check them', cycle: 1, round: 0 }); + await invoke('multiai-run-round', { chatId: chat.id, rounds: 1 }); + assert.deepEqual(providerCalls.map(c => c.provider), ['gemini', 'chatgpt', 'claude']); +}); From a3adb1f7dc5d3030fc1b9d2d6343ef00163d22a5 Mon Sep 17 00:00:00 2001 From: Eugene Turetsky Date: Thu, 3 Sep 2026 18:51:33 -0400 Subject: [PATCH 07/67] Multi-AI: agents with routes (browser / API per Settings / Claude Code & Codex CLIs), models and efforts Participants become agents: name + provider + route + model + effort + persona. The same provider can sit at the table more than once ("Opus max" vs "Sonnet low", "GPT high" vs "GPT low"), which is what a single-provider panel needs. - Routes: auto follows Settings (API mode on + key -> provider API with the model chosen there or the agent's own; else browser tab); browser; api (falls back to the tab with a warning); cli for claude-cli / codex-cli, run locally on the user's subscription (electron/providers/cli.cjs: one-shot, tool-less, empty scratch dir, <=2 parallel per CLI, abortable). - BYOK: reasoningEffort -> OpenAI reasoning_effort / Anthropic extended thinking budget. Keys and API mode live only in Settings; the tab reads them. - Delta context falls back to bounded for one-shot routes; browser threads are keyed per agent (multiai:::). - Renderer: Agents block (cards with provider/route/model/effort/persona, quick-add, route status, Settings re-read); legacy participants migrate. - REST/MCP: create accepts agents, reads show resolved routes; labels use agent codes. - Tests: core agents helpers, handlers (api/cli routes, fallback, stop), MCP schema, BYOK effort payloads. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ACdKJmXfzvwfCCLneCWd12 --- docs/MULTIAI.md | 77 +- electron/api/byok/providers/anthropic.cjs | 11 +- electron/api/byok/providers/openai.cjs | 12 +- electron/index-v2.html | 8741 +++++++++---------- electron/ipc/multiai-core.cjs | 136 +- electron/ipc/multiai.cjs | 352 +- electron/main-v2.cjs | 1 + electron/multiai-renderer.js | 319 +- electron/preload.cjs | 1 + electron/providers/cli.cjs | 215 + src/mcp/tools-multiai.js | 23 +- tests/byok/providers.test.js | 24 + tests/electron/ipc/multiai-core.test.js | 56 + tests/electron/ipc/multiai-handlers.test.js | 135 +- tests/mcp/tools-multiai.test.js | 18 + 15 files changed, 5502 insertions(+), 4619 deletions(-) create mode 100644 electron/providers/cli.cjs diff --git a/docs/MULTIAI.md b/docs/MULTIAI.md index ba1153b..532a80b 100644 --- a/docs/MULTIAI.md +++ b/docs/MULTIAI.md @@ -1,14 +1,59 @@ # Multi-AI Chat tab -A native tab in this fork that runs discussions, debates and pipelines across the -browser-session providers Proxima already drives (ChatGPT, Claude, Gemini, -Perplexity), with a transcript that local agents (Codex, Claude Code, scripts) -can read and append to over the gateway's REST API. +A native tab in this fork that runs discussions, debates and pipelines across +**agents** β€” the browser-session providers Proxima already drives (ChatGPT, +Claude, Gemini, Perplexity), the same providers over their APIs when API mode +is on in Settings, and Claude Code / Codex run locally on your subscriptions β€” +with a transcript that local agents (Codex, Claude Code, scripts) can read and +append to over the gateway's REST API or MCP tools. Code: `electron/ipc/multiai-core.cjs` (pure logic, unit-tested), `electron/ipc/multiai.cjs` (main-process handlers + REST routes), +`electron/providers/cli.cjs` (Claude Code / Codex runners), `electron/multiai-renderer.js` + the `#multiai-panel` block in -`electron/index-v2.html` (UI). Tests: `tests/electron/ipc/`. +`electron/index-v2.html` (UI). Tests: `tests/electron/ipc/`, `tests/mcp/`. + +## Agents and routes + +A participant is an **agent**: `name + provider + route + model + effort + +persona`. The same provider can appear several times with different models or +efforts ("Opus Β· max" and "Sonnet Β· low", or "GPT high" and "GPT low"), which is +how you run a single-provider panel. Each agent's id (`opus-max`) is the slug of +its name, made unique within the chat (`bull`, `bull-2`); it is what `@mentions`, +HANDOFFs, the judge picker and the provider-side thread key on. + +| Route | What runs the turn | Model / effort | +|---|---|---| +| `auto` (default) | Follows **Settings β†’ API mode**: on, with a key for that provider β†’ the provider's API; otherwise the browser tab | API: the agent's model or, blank, the model selected in Settings; effort honoured. Browser: model ignored except Gemini's engine picker; effort ignored | +| `browser` | Always the logged-in browser tab | as above | +| `api` | The provider's API; falls back to the browser tab (with a ⚠ in the editor) if API mode is off or no key | model + effort | +| `cli` (implied for `claude-cli`, `codex-cli`) | `claude -p` / `codex exec` on this machine, one-shot, tool-less, in an empty scratch folder, ≀2 in parallel per CLI | Claude Code aliases `opus` `sonnet` `haiku` `fable` (+ `[1m]`), `--effort low…max`; Codex `gpt-5.5` etc., `model_reasoning_effort` (`max` β†’ `xhigh`) | + +Settings is the single source of truth for API mode, keys and default models β€” +the tab never stores keys and never overrides the mode; it only reads it +(`↻` in the Agents block re-reads Settings and re-probes the CLIs). Effort maps to +`reasoning_effort` for OpenAI and to an extended-thinking budget for Anthropic +(1k β†’ 32k tokens for `minimal` β†’ `max`). + +Route-specific behaviour: + +- **Context.** API and CLI turns are one-shot, so `Delta` context is replaced by + `Bounded` for them (the browser route keeps Delta). `Full` works everywhere. +- **Threads.** Browser agents keep one provider-side thread per agent + (`multiai:::`), rotated every 12 turns (40 in Delta). +- **Stop** aborts browser turns in the BrowserView and kills CLI process trees; + an API request in flight is left to finish and its result discarded. +- **Codex** has no "no tools" switch and its sandbox helper is missing on some + Windows installs, so it runs with approvals/sandbox bypassed inside an empty + scratch folder and a system prompt that forbids running anything. The Codex + account must support the model you pick (`gpt-5` is rejected on ChatGPT + subscriptions; `gpt-5.5` works). +- **Labels.** Known providers keep their codes; other agents get initials of + their name (`Opus Β· high` β†’ `OH`). + +Legacy chats (a `participants` list) are migrated to one browser-tab agent per +provider the first time they are opened; the `participants` field is kept in +sync for older readers. ## How a turn is built @@ -45,15 +90,17 @@ bleed into each other or into the MCP tools' `mcp-session` thread. - **Discuss / Debate.** In Debate with three or more participants the stances are assigned: for, against, critical evaluator, third option. -- **Rounds, Brevity, Personas** as before; the Gemini participant has a model - picker (`3.5-flash`, `3.1-pro`, `3.1-flash-lite`, `auto`). Other providers ignore - engine choice in the provider layer. +- **Rounds, Brevity, Personas** as before; personas are set per agent in the + Agents block. A Gemini browser agent's model field picks the engine + (`3.5-flash`, `3.1-pro`, `3.1-flash-lite`, `auto`); other browser agents ignore it. - **Stop early when all agree.** A round where everyone says the stop token or passes ends the run and posts a system note. - **Independent first round.** Round 1 of each Send runs everyone in parallel and blind (no same-cycle replies in their prompt); later rounds are round-robin. - **Directed turns.** `@claude kick off, others react` puts Claude first; - `only @gemini and @chatgpt …` restricts the round to those two. + `only @gemini and @chatgpt …` restricts the round to those two. Mention agents + by id (`@opus-max`), by name with the spaces dropped (`@gptlow` for "GPT low") + or, when only one agent uses it, by provider (`@claude`). - **Judge** with scope: whole chat / since my last message / last round only (resolved by cycle *and* round). - **Summarize state** pins a `DECISIONS / OPEN QUESTIONS / CLAIMS TO VERIFY / @@ -105,9 +152,9 @@ Same server as the rest of the gateway (Settings β†’ API; default | Method | Path | Body / query | Result | |---|---|---|---| -| GET | `/v1/multiai/chats` | | `{chats:[{id,title,mode,participants,messageCount,cycle,updatedAt}]}` | -| POST | `/v1/multiai/chats` | `{title?, participants?, mode?, discussMode?, brevity?, topic?, who?}` | `{chat}` (201); `topic` is recorded as the first message | -| GET | `/v1/multiai/chats/:id` | `?since=` | `{chat}` with `messages` (each has a `label` like `0103CG`) and `running` | +| GET | `/v1/multiai/chats` | | `{chats:[{id,title,mode,agents:[name],participants,messageCount,cycle,updatedAt}]}` | +| POST | `/v1/multiai/chats` | `{title?, agents?, participants?, mode?, discussMode?, contextMode?, brevity?, topic?, who?}` | `{chat}` (201); `topic` is recorded as the first message. `agents: [{name?, provider, route?, model?, effort?, persona?}]`; `participants` is the legacy one-agent-per-provider shorthand | +| GET | `/v1/multiai/chats/:id` | `?since=&last=` | `{chat}` with `agents` (resolved `route` per agent), `messages` (each has a `label` like `0103CG`, plus `agentId`, `route`, `model` on agent turns) and `running` | | POST | `/v1/multiai/chats/:id/messages` | `{who, text, provider?, role?}` | `{message}` (201). Appends a turn; nothing is sent to providers. `role: "user"` starts a new cycle. | | POST | `/v1/multiai/chats/:id/run` | `{text?, who?, rounds?, wait?}` | `{started:true}` (202) or, with `wait:true`, `{messages}` once the rounds finish | | POST | `/v1/multiai/chats/:id/wait` | `{since?, maxMs?}` | blocks until the run ends or `maxMs` (≀120 s); `{running, messages}` | @@ -135,9 +182,9 @@ Agent Hub IPC bridge, so they work even with the REST API disabled: | Tool | What it does | |---|---| -| `multiai_list_chats` | Chats with id, title, mode, participants, message count; ● when a run is in progress | -| `multiai_read_chat {chatId, since?, last?}` | Transcript with reference labels and message ids | -| `multiai_create_chat {title?, topic?, participants?, discussMode?, contextMode?}` | New chat, optionally with an opening message | +| `multiai_list_chats` | Chats with id, title, mode, agents, message count; ● when a run is in progress | +| `multiai_read_chat {chatId, since?, last?}` | Transcript with reference labels and message ids; header lists each agent's id, resolved route, model and effort | +| `multiai_create_chat {title?, topic?, agents?, participants?, discussMode?, contextMode?, …}` | New chat, optionally with an opening message; `agents` as in the REST body (e.g. `[{provider:"claude-cli", model:"opus", effort:"max"}, {provider:"chatgpt", route:"api", effort:"low"}]`) | | `multiai_post_message {chatId, who, text, role?}` | Append a turn under your own name (provider inferred: Codex β†’ `codex`, Claude Code β†’ `claude-code`); browser-AI names are never impersonated | | `multiai_run_round {chatId, text?, who?, rounds?, wait?}` | Optionally post, then run rounds; returns immediately unless `wait` | | `multiai_wait_for_run {chatId, since?, maxSeconds?}` | Block up to 120 s for the run, return new messages, say if still running | diff --git a/electron/api/byok/providers/anthropic.cjs b/electron/api/byok/providers/anthropic.cjs index fa3c6b9..4c8143c 100644 --- a/electron/api/byok/providers/anthropic.cjs +++ b/electron/api/byok/providers/anthropic.cjs @@ -34,10 +34,19 @@ async function call(apiKey, messageOrMessages, options = {}) { const payload = { model, - max_tokens: MAX_TOKENS.claude, + max_tokens: options.maxTokens || MAX_TOKENS.claude, messages, }; + // Effort β†’ extended thinking budget. max_tokens must exceed the budget. + if (options.reasoningEffort) { + const budget = { minimal: 1024, low: 2048, medium: 6000, high: 12000, xhigh: 24000, max: 32000 }[options.reasoningEffort]; + if (budget) { + payload.thinking = { type: 'enabled', budget_tokens: budget }; + payload.max_tokens = Math.max(payload.max_tokens, budget + 8192); + } + } + if (system) { payload.system = system; } diff --git a/electron/api/byok/providers/openai.cjs b/electron/api/byok/providers/openai.cjs index 72cf098..e4636f3 100644 --- a/electron/api/byok/providers/openai.cjs +++ b/electron/api/byok/providers/openai.cjs @@ -27,10 +27,20 @@ async function call(apiKey, messageOrMessages, options = {}) { const payload = { model, messages, - max_completion_tokens: MAX_TOKENS.chatgpt, + max_completion_tokens: options.maxTokens || MAX_TOKENS.chatgpt, store: false, }; + // Reasoning effort (o-series / GPT-5 family). Reasoning tokens count + // against the completion budget, so give it room when effort is set. + if (options.reasoningEffort) { + const eff = { minimal: 'minimal', low: 'low', medium: 'medium', high: 'high', xhigh: 'high', max: 'high' }[options.reasoningEffort]; + if (eff) { + payload.reasoning_effort = eff; + if (!options.maxTokens) payload.max_completion_tokens = Math.max(MAX_TOKENS.chatgpt, 16000); + } + } + if (tools && tools.length > 0) { payload.tools = tools; } diff --git a/electron/index-v2.html b/electron/index-v2.html index 5226a22..e55aa72 100644 --- a/electron/index-v2.html +++ b/electron/index-v2.html @@ -1,4372 +1,4369 @@ - - - - - - - - Proxima - - - - - -
-
-
-
-
-
-
-
-
-
-
- Proxima -
-
-
- -
-
- Multi-AI Chat - Multi-AI Chat -
-
- Perplexity - Perplexity -
-
-
- ChatGPT - ChatGPT -
-
-
- Claude - Claude -
-
-
- Gemini - Gemini -
-
-
- -
-
βš™οΈ
- Settings -
-
- -
- - - -
https://...
- - - -
- -
-
-
-

Proxima

-

- Free Multi-AI MCP Server
- No API keys required. Connect ChatGPT, Claude, Gemini & Perplexity - locally. -

-
-

Get Started

-
-
-
1
-
-
Enable Providers
-
Click Settings and toggle the AI providers you want to use -
-
-
-
-
2
-
-
Login to Each Provider
-
Click each tab above and login to your accounts
-
-
-
-
3
-
-
Copy MCP Config
-
Go to Settings and copy the config to your AI coding app -
-
-
-
-
-
- - -
- -
-
- 🧠 Multi-AI Chat - -
-
- -
-
-
-
-
-
-
-
New chat
- - -
-
- - - -
-
- -
- - -
- - -
- -
- - - - -
- - -
- - -
-
-
-
-
-
- πŸ“’ Ledger - -
- -
- - -
-
- - - - - - - - - - -
CΒ·RAgentTimeMessage
-
-
- - - - - -
-
-
- -
- Proxima - PROXIMA -
- - - - - -
- General -
-
- Providers -
-
- Authentication -
-
- Proxima Agent -
- - - - - -
- REST API & WS -
-
- MCP Config -
-
- CLI Tool -
- - - - - -
- Diagnostics - -
-
- About & Community -
- - -
- - -
-
- Providers - - - -- - -
-
- Mode: BYOK - - Python Ready -
-
-
- -
- -
-
-

- SYS - Current System State -

-

An architectural snapshot of - Proxima's active local integrations.

-
-
-
Auth Mode
-
Session
-
-
-
REST API
-
Disabled
-
-
-
CLI Tool
-
Checking...
-
-
-
Active Providers
-
0 / 4
-
-
-
- -
-

INFO - Architecture Overview

-

- Proxima binds locally to 127.0.0.1 to keep your credentials and code context - 100% private. -

-
-
[Electron Main Process] - (Core Router)
-   β”œβ”€β”€ TCP IPC Port 19222 (MCP Bridge) <──> Coding Assistants (Cursor, VS - Code, Windsurf)
-   β”œβ”€β”€ HTTP Port 3210 (OpenAI REST API) <──> Custom scripts / CLI / SDKs
-   β””── WebSockets (ws://localhost:3210/ws) <──> Real-time streaming clients -
-
- - -
-

- πŸ”’ Security & Key Storage Architecture -

- -
- -
-
- - Session Mode Security -
-

- Operates via local Chromium instances. Session cookies and login tokens are stored locally on your machine. -

-

- Credentials and tokens never leave your system. Direct encrypted connections are made to official AI provider domains. -

-
- - -
-
- - API Key Encryption -
-

- All Bring Your Own Key (BYOK) secrets are encrypted on-disk using hardware-bound encryption (DPAPI) via the safeStorage API. -

-

- Once loaded, requests bypass browser views and communicate directly with raw endpoints, guaranteeing zero telemetry and privacy. -

-
-
-
-
- - -
-
-

AI - AI Providers

-

- Enable or disable the background AI browser views. Toggled providers are dynamically mounted - or disposed. -

-
- -
-
-
- - Perplexity -
-
-
-
-
Status: Checking...
-
Latency: --ms -
-
Model: Auto
-
Account: Session
-
-
- - -
-
-
- - ChatGPT -
-
-
-
-
Status: Checking...
-
Latency: --ms
-
Model: Auto
-
Account: Session
-
-
- - -
-
-
- - Claude -
-
-
-
-
Status: Checking...
-
Latency: --ms
-
Model: Auto
-
Account: Session
-
-
- - -
-
-
- - Gemini -
-
-
-
-
Status: Checking...
-
Latency: --ms
-
Model: Auto
-
Account: Session
-
-
-
-
-
- - -
-
-

AUTH - Authentication Mode

-

- Choose whether Proxima routes calls via active browser sessions (free) or through your - direct API keys (BYOK). -

- -
-
-
-
- Session Mode - Connects via browser sessions (no API keys - required) -
-
-
-
-
- BYOK Mode - Bring Your Own Key (stable API billing) -
-
-
- - - - - - -
-
- -
- - β–Ό - -
-
- - - - - -
- Select a provider above to configure an API key. -
-
-
-
- - -
-
-

- AGENT - Proxima Agent -

- Checking... -
- -

- Proxima Agent is a standalone, local agent that uses Proxima as its backend. - It executes Python code, reads/writes files, runs shell commands, drives browsers, and debugs - codebases directly on your machine. -

- -
-
-
- Web-Based Control Panel
-
- Interact with the agent, view screenshots, and monitor terminal execution visually in - the browser. -
-
- -
- -
-
-
- Safety Guardrails
-
SSRF & - Command Gated
-
- All network scrapes, filesystem modifications, and subprocess commands run in a gated - environment. -
-
-
-
- Permission Modes
-
Smart / - Auto / Suggest
-
- Adjust delegation levels from full automation to requiring user permission on every - single terminal execution. -
-
-
- -
-

How to Run in - CLI

-

- To run the agent directly from a local terminal or integrate it inside your development - workflows: -

-
- cd proxima-agent && python -m venv .venv && pip install -e . && proxima-agent - -
-
- *Note: Make sure the Python environment is set up and ready under the Diagnostics tab. -
-
-
- - -
-
-

- API - REST API & WebSockets -

-
-
HTTP - REST API
-
WebSockets
-
- - -
-

- Run a local HTTP server that exposes an OpenAI-compatible REST API. External apps, - scripts, and SDKs can send requests. -

- -
-
-
Enable REST API
-
Start local server at http://localhost:3210
-
-
-
- - - -
-
- πŸ”‘ - API Key - Authentication - OPEN -
-

- Generate an API key to secure your REST API. Without a key, anyone on your network - can access it. -

- -
- - -
-

- Use in clients: Authorization: Bearer sk-xxx-proxima -

-
-
- - - -
-
- - -
-
-

MCP - MCP Configuration

-

- Add Proxima MCP server to your AI coding assistant. Choose your preferred method: -

- -
- - -
- -
- -
Loading...
-
- - -
-
- - -
-
-

CLI - CLI Configuration

-

- Terminal-native AI access. Execute queries, fix logs, and debate claims. -

- -
-
-
πŸ–₯️
-
-
CLI Executable
-
- proxima
-
-
- Not Installed
-
- -
-
πŸ§ͺ
-
-
Test Command
-
- proxima --version
-
- -
-
- -
- - - - Docs: http://localhost:3210/cli -
- - - - -
-

- πŸ’‘ Quick CLI Reference -

- -
- -
-
- proxima ask "..." - Chat with default model (or - specify: proxima ask claude "...") -
- -
- - -
-
- proxima compare "..." - Query all active providers and - display responses side-by-side -
- -
- - -
-
- proxima code ... - Perform code actions (e.g. - code review, code explain, - code debug) -
- -
- - -
-
- proxima --file ... - Include local file context or - upload images/PDFs natively -
- -
- - -
-
- ... | proxima fix - Pipe logs directly to debug - errors (e.g. npm run build 2>&1 | proxima fix) -
- -
-
-
-
-
- - -
-
-

DIAG - System Diagnostics

-

- Check the status of dependent systems, environments, and binary requirements. -

- -
-
- Python 3.10+ Environment - Checking... -
-
- Tesseract OCR (Optional) - Checking... -
-
- CLI Tool Installation - Checking... -
-
- - - -
-
- - -
- -
- -
- Proxima Logo -

- Proxima

- v5.0.0 -
- -
- ⭐ 1.2k+ - GitHub Stars - | - πŸ“₯ 5,000+ - Downloads - | - All Your AI Models Come Together -
-
- - -
- - -
- -
-

- - πŸ’‘ Why Proxima Exists - - β–Έ -

- -
- - -
-

- ❀️ Support Proxima -

-

- Proxima is an independent open-source project. If it helps streamline your - workflows, consider supporting its active development: -

-
-
- βœ“ Better local agents -
-
- βœ“ Faster releases -
-
- βœ“ Custom integrations -
-
- βœ“ Open-source MCPs -
-
- -
- -
- - -
-
-
- -
- - - -
- - - - -
-

- βš™οΈ Built With -

-
- Electron - Python - Node.js - Playwright - Chrome - CDP - SQLite -
-
-
- -
- - -
-
-
Made by:
- Zen4-bit -
-
-
Special Thanks:
- Open Source Community -
-
-
Platform Stats:
- 4 Providers β€’ MCP Enabled β€’ REST Server -
-
- - -
- Made with ❀️ by Zen4-bit. Thanks for supporting independent software.
- Β© 2026 Proxima -
-
-
-
-
- -
Message
- - - - - - - - - - \ No newline at end of file + + + + + + + + Proxima + + + + + +
+
+
+
+
+
+
+
+
+
+
+ Proxima +
+
+
+ +
+
+ Multi-AI Chat + Multi-AI Chat +
+
+ Perplexity + Perplexity +
+
+
+ ChatGPT + ChatGPT +
+
+
+ Claude + Claude +
+
+
+ Gemini + Gemini +
+
+
+ +
+
βš™οΈ
+ Settings +
+
+ +
+ + + +
https://...
+ + + +
+ +
+
+
+

Proxima

+

+ Free Multi-AI MCP Server
+ No API keys required. Connect ChatGPT, Claude, Gemini & Perplexity + locally. +

+
+

Get Started

+
+
+
1
+
+
Enable Providers
+
Click Settings and toggle the AI providers you want to use +
+
+
+
+
2
+
+
Login to Each Provider
+
Click each tab above and login to your accounts
+
+
+
+
3
+
+
Copy MCP Config
+
Go to Settings and copy the config to your AI coding app +
+
+
+
+
+
+ + +
+ +
+
+ 🧠 Multi-AI Chat + +
+
+ +
+
+
+
+
+
+
+
New chat
+ + +
+
+ + + +
+
+ +
+ + +
+ + +
+ +
+ + + + +
+ + +
+ + +
+
+
+
+
+
+ πŸ“’ Ledger + +
+ +
+ + +
+
+ + + + + + + + + + +
CΒ·RAgentTimeMessage
+
+
+ + + + + +
+
+
+ +
+ Proxima + PROXIMA +
+ + + + + +
+ General +
+
+ Providers +
+
+ Authentication +
+
+ Proxima Agent +
+ + + + + +
+ REST API & WS +
+
+ MCP Config +
+
+ CLI Tool +
+ + + + + +
+ Diagnostics + +
+
+ About & Community +
+ + +
+ + +
+
+ Providers + + + -- + +
+
+ Mode: BYOK + + Python Ready +
+
+
+ +
+ +
+
+

+ SYS + Current System State +

+

An architectural snapshot of + Proxima's active local integrations.

+
+
+
Auth Mode
+
Session
+
+
+
REST API
+
Disabled
+
+
+
CLI Tool
+
Checking...
+
+
+
Active Providers
+
0 / 4
+
+
+
+ +
+

INFO + Architecture Overview

+

+ Proxima binds locally to 127.0.0.1 to keep your credentials and code context + 100% private. +

+
+
[Electron Main Process] + (Core Router)
+   β”œβ”€β”€ TCP IPC Port 19222 (MCP Bridge) <──> Coding Assistants (Cursor, VS + Code, Windsurf)
+   β”œβ”€β”€ HTTP Port 3210 (OpenAI REST API) <──> Custom scripts / CLI / SDKs
+   β””── WebSockets (ws://localhost:3210/ws) <──> Real-time streaming clients +
+
+ + +
+

+ πŸ”’ Security & Key Storage Architecture +

+ +
+ +
+
+ + Session Mode Security +
+

+ Operates via local Chromium instances. Session cookies and login tokens are stored locally on your machine. +

+

+ Credentials and tokens never leave your system. Direct encrypted connections are made to official AI provider domains. +

+
+ + +
+
+ + API Key Encryption +
+

+ All Bring Your Own Key (BYOK) secrets are encrypted on-disk using hardware-bound encryption (DPAPI) via the safeStorage API. +

+

+ Once loaded, requests bypass browser views and communicate directly with raw endpoints, guaranteeing zero telemetry and privacy. +

+
+
+
+
+ + +
+
+

AI + AI Providers

+

+ Enable or disable the background AI browser views. Toggled providers are dynamically mounted + or disposed. +

+
+ +
+
+
+ + Perplexity +
+
+
+
+
Status: Checking...
+
Latency: --ms +
+
Model: Auto
+
Account: Session
+
+
+ + +
+
+
+ + ChatGPT +
+
+
+
+
Status: Checking...
+
Latency: --ms
+
Model: Auto
+
Account: Session
+
+
+ + +
+
+
+ + Claude +
+
+
+
+
Status: Checking...
+
Latency: --ms
+
Model: Auto
+
Account: Session
+
+
+ + +
+
+
+ + Gemini +
+
+
+
+
Status: Checking...
+
Latency: --ms
+
Model: Auto
+
Account: Session
+
+
+
+
+
+ + +
+
+

AUTH + Authentication Mode

+

+ Choose whether Proxima routes calls via active browser sessions (free) or through your + direct API keys (BYOK). +

+ +
+
+
+
+ Session Mode + Connects via browser sessions (no API keys + required) +
+
+
+
+
+ BYOK Mode + Bring Your Own Key (stable API billing) +
+
+
+ + + + + + +
+
+ +
+ + β–Ό + +
+
+ + + + + +
+ Select a provider above to configure an API key. +
+
+
+
+ + +
+
+

+ AGENT + Proxima Agent +

+ Checking... +
+ +

+ Proxima Agent is a standalone, local agent that uses Proxima as its backend. + It executes Python code, reads/writes files, runs shell commands, drives browsers, and debugs + codebases directly on your machine. +

+ +
+
+
+ Web-Based Control Panel
+
+ Interact with the agent, view screenshots, and monitor terminal execution visually in + the browser. +
+
+ +
+ +
+
+
+ Safety Guardrails
+
SSRF & + Command Gated
+
+ All network scrapes, filesystem modifications, and subprocess commands run in a gated + environment. +
+
+
+
+ Permission Modes
+
Smart / + Auto / Suggest
+
+ Adjust delegation levels from full automation to requiring user permission on every + single terminal execution. +
+
+
+ +
+

How to Run in + CLI

+

+ To run the agent directly from a local terminal or integrate it inside your development + workflows: +

+
+ cd proxima-agent && python -m venv .venv && pip install -e . && proxima-agent + +
+
+ *Note: Make sure the Python environment is set up and ready under the Diagnostics tab. +
+
+
+ + +
+
+

+ API + REST API & WebSockets +

+
+
HTTP + REST API
+
WebSockets
+
+ + +
+

+ Run a local HTTP server that exposes an OpenAI-compatible REST API. External apps, + scripts, and SDKs can send requests. +

+ +
+
+
Enable REST API
+
Start local server at http://localhost:3210
+
+
+
+ + + +
+
+ πŸ”‘ + API Key + Authentication + OPEN +
+

+ Generate an API key to secure your REST API. Without a key, anyone on your network + can access it. +

+ +
+ + +
+

+ Use in clients: Authorization: Bearer sk-xxx-proxima +

+
+
+ + + +
+
+ + +
+
+

MCP + MCP Configuration

+

+ Add Proxima MCP server to your AI coding assistant. Choose your preferred method: +

+ +
+ + +
+ +
+ +
Loading...
+
+ + +
+
+ + +
+
+

CLI + CLI Configuration

+

+ Terminal-native AI access. Execute queries, fix logs, and debate claims. +

+ +
+
+
πŸ–₯️
+
+
CLI Executable
+
+ proxima
+
+
+ Not Installed
+
+ +
+
πŸ§ͺ
+
+
Test Command
+
+ proxima --version
+
+ +
+
+ +
+ + + + Docs: http://localhost:3210/cli +
+ + + + +
+

+ πŸ’‘ Quick CLI Reference +

+ +
+ +
+
+ proxima ask "..." + Chat with default model (or + specify: proxima ask claude "...") +
+ +
+ + +
+
+ proxima compare "..." + Query all active providers and + display responses side-by-side +
+ +
+ + +
+
+ proxima code ... + Perform code actions (e.g. + code review, code explain, + code debug) +
+ +
+ + +
+
+ proxima --file ... + Include local file context or + upload images/PDFs natively +
+ +
+ + +
+
+ ... | proxima fix + Pipe logs directly to debug + errors (e.g. npm run build 2>&1 | proxima fix) +
+ +
+
+
+
+
+ + +
+
+

DIAG + System Diagnostics

+

+ Check the status of dependent systems, environments, and binary requirements. +

+ +
+
+ Python 3.10+ Environment + Checking... +
+
+ Tesseract OCR (Optional) + Checking... +
+
+ CLI Tool Installation + Checking... +
+
+ + + +
+
+ + +
+ +
+ +
+ Proxima Logo +

+ Proxima

+ v5.0.0 +
+ +
+ ⭐ 1.2k+ + GitHub Stars + | + πŸ“₯ 5,000+ + Downloads + | + All Your AI Models Come Together +
+
+ + +
+ + +
+ +
+

+ + πŸ’‘ Why Proxima Exists + + β–Έ +

+ +
+ + +
+

+ ❀️ Support Proxima +

+

+ Proxima is an independent open-source project. If it helps streamline your + workflows, consider supporting its active development: +

+
+
+ βœ“ Better local agents +
+
+ βœ“ Faster releases +
+
+ βœ“ Custom integrations +
+
+ βœ“ Open-source MCPs +
+
+ +
+ +
+ + +
+
+
+ +
+ + + +
+ + + + +
+

+ βš™οΈ Built With +

+
+ Electron + Python + Node.js + Playwright + Chrome + CDP + SQLite +
+
+
+ +
+ + +
+
+
Made by:
+ Zen4-bit +
+
+
Special Thanks:
+ Open Source Community +
+
+
Platform Stats:
+ 4 Providers β€’ MCP Enabled β€’ REST Server +
+
+ + +
+ Made with ❀️ by Zen4-bit. Thanks for supporting independent software.
+ Β© 2026 Proxima +
+
+
+
+
+ +
Message
+ + + + + + + + + + \ No newline at end of file diff --git a/electron/ipc/multiai-core.cjs b/electron/ipc/multiai-core.cjs index 5325cb9..69f360e 100644 --- a/electron/ipc/multiai-core.cjs +++ b/electron/ipc/multiai-core.cjs @@ -30,6 +30,98 @@ const DEFAULTS = Object.freeze({ // rest; a rolling summary rides in the Topic block, and the first turn on a fresh thread sends a full catch-up const CONTEXT_MODES = ['bounded', 'full', 'delta']; +// ---- Agents ------------------------------------------------------------ +// A participant is an *agent*: a name plus how to reach it. Several agents +// may share a provider (e.g. "Opus Β· high" and "Sonnet Β· low" both through +// Claude Code), so everything downstream is keyed by agent id, not provider. +// route: 'auto' β†’ API mode when the app's Settings have it on with a key +// for this provider, otherwise the browser tab +// 'browser' β†’ Proxima's provider tab (session login) +// 'api' β†’ the provider's API with the key from Settings +// 'cli' β†’ a local CLI on your subscription: claude-cli / codex-cli +// provider: chatgpt | claude | gemini | perplexity (browser/api) or +// claude-cli | codex-cli (cli) +// model: api/cli model id (or the Gemini engine string for the browser route) +// effort: '' | minimal | low | medium | high | xhigh | max (api/cli only) +const ROUTES = ['auto', 'browser', 'api', 'cli']; +const CLI_PROVIDERS = { 'claude-cli': { label: 'Claude Code', bin: 'claude' }, 'codex-cli': { label: 'Codex', bin: 'codex' } }; +const EFFORTS = ['', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']; + +function slugify(s) { + return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); +} + +// Two-letter code for labels: known providers keep their codes; anything else +// takes initials of the name ("Opus Β· high" β†’ OH, "Sonnet low" β†’ SL). +function agentCode(agent) { + const name = String(agent.name || ''); + const base = String(agent.provider || '').split(':')[0]; + if (name && PROVIDER_LABELS[base] && name === PROVIDER_LABELS[base]) return PROVIDER_CODES[base]; + const words = name.split(/[^A-Za-z0-9]+/).filter(Boolean); + if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase(); + if (words.length === 1 && words[0].length >= 2) return words[0].slice(0, 2).toUpperCase(); + return providerCode(base); +} + +function defaultAgentName(provider, model, effort) { + const base = String(provider || '').split(':')[0]; + const label = CLI_PROVIDERS[base] ? CLI_PROVIDERS[base].label : (PROVIDER_LABELS[base] || base); + const parts = []; + if (model && !(base === 'gemini' && model === 'gemini')) parts.push(String(model).replace(/^gemini:/, '')); + if (effort) parts.push(effort); + return parts.length ? `${label} Β· ${parts.join(' Β· ')}` : label; +} + +// Accepts the legacy participants list (provider strings) or an agents list; +// returns normalized agents. Ids are stable slugs so personas/sessions can +// key on them across saves. +function normalizeAgents(chat) { + const src = Array.isArray(chat.agents) && chat.agents.length ? chat.agents : (chat.participants || []); + const out = []; + const seen = new Set(); + for (const raw of src) { + let a; + if (typeof raw === 'string') { + const base = raw.split(':')[0]; + const engine = raw.includes(':') ? raw : ((chat.engines && chat.engines[base]) || ''); + a = { id: base, name: PROVIDER_LABELS[base] || base, route: 'auto', provider: base, model: engine && engine !== base ? engine : '', effort: '' }; + } else if (raw && typeof raw === 'object') { + const provider = String(raw.provider || 'chatgpt').split(':')[0]; + a = { + id: raw.id || '', name: raw.name || '', route: ROUTES.includes(raw.route) ? raw.route : 'auto', + provider, model: raw.model || '', effort: EFFORTS.includes(raw.effort) ? raw.effort : '', + persona: raw.persona || '', personaKey: raw.personaKey || '', + }; + if (CLI_PROVIDERS[provider]) a.route = 'cli'; + if (!a.name) a.name = defaultAgentName(provider, a.model, a.effort); + if (!a.id) a.id = slugify(a.name) || provider; + } else continue; + let id = a.id; let n = 2; + while (seen.has(id)) id = `${a.id}-${n++}`; + a.id = id; seen.add(id); + a.code = agentCode(a); + out.push(a); + } + return out; +} + +// Resolve a participant reference (agent id, name, provider) inside a list. +function findAgent(agents, ref) { + if (!ref) return null; + const r = String(ref).toLowerCase().replace(/\s+/g, ''); + return agents.find(a => a.id === ref) || + agents.find(a => a.id.toLowerCase() === r) || + agents.find(a => String(a.name).toLowerCase().replace(/\s+/g, '') === r) || + agents.find(a => String(a.name).toLowerCase().replace(/[^a-z0-9]+/g, '') === r.replace(/[^a-z0-9]+/g, '')) || + // a bare provider name matches only when exactly one agent uses that provider + (function () { const hits = agents.filter(a => a.provider === r || providerLabel(a.provider).toLowerCase().replace(/\s+/g, '') === r); return hits.length === 1 ? hits[0] : null; })() || + null; +} + +function asAgents(list) { + return (list || []).map(p => typeof p === 'string' ? { id: p.split(':')[0], name: providerLabel(p), provider: p.split(':')[0], code: providerCode(p) } : p); +} + const KNOWN_PROVIDERS = ['chatgpt', 'claude', 'gemini', 'perplexity']; const PROVIDER_LABELS = { @@ -211,7 +303,7 @@ function createStore({ dir, fs, path, log }) { const data = load(); return data.projects.flatMap(p => p.chats.map(c => ({ id: c.id, title: c.title, mode: c.mode, strategy: c.strategy, - participants: c.participants || [], createdAt: c.createdAt || null, + participants: c.participants || [], agents: normalizeAgents(c).map(a => a.name), createdAt: c.createdAt || null, updatedAt: (c.messages || []).reduce((mx, m) => Math.max(mx, m.ts || 0), 0) || c.createdAt || null, messageCount: (c.messages || []).length, cycle: c.cycle || 0, }))); @@ -362,7 +454,7 @@ function buildFullContext(messages) { // skipped (they're already in its thread as its answers). Returns catchUp=true // when there's no usable anchor, in which case the caller sends the bounded // context instead so a fresh provider thread starts with the whole picture. -function buildDeltaContext(messages, { sinceId, provider, contextOpts }) { +function buildDeltaContext(messages, { sinceId, provider, agentId, contextOpts }) { const all = readable(messages); const idx = sinceId ? all.findIndex(m => m.id === sinceId) : -1; if (idx === -1) { @@ -370,7 +462,8 @@ function buildDeltaContext(messages, { sinceId, provider, contextOpts }) { return Object.assign(ctx, { catchUp: true, lastId: all.length ? all[all.length - 1].id : null }); } const base = String(provider || '').split(':')[0]; - const fresh = all.slice(idx + 1).filter(m => !(m.role === 'assistant' && String(m.provider || '').split(':')[0] === base)); + const own = (m) => m.role === 'assistant' && (agentId ? m.agentId === agentId : String(m.provider || '').split(':')[0] === base); + const fresh = all.slice(idx + 1).filter(m => !own(m)); let summaryMsg = null; for (let i = all.length - 1; i >= 0; i--) if (all[i].role === 'summary') { summaryMsg = all[i]; break; } const parts = []; @@ -434,16 +527,18 @@ const CHARTER_RULES = [ const RED_TEAM_BLOCK = 'This round you hold the RED-TEAM seat: your job is to find the strongest objection to the emerging consensus β€” a missing assumption, a test that would falsify it, a cheaper alternative β€” and state it plainly, even if you privately agree. Do not soften it.'; -function buildClassicPromptWithMeta({ chatTitle, topic, latestUserMessage, mode, brevity, persona, participants, provider, messages, contextOpts, stopToken, independent, contextMode, delta, charter, redTeam }) { - const base = String(provider).split(':')[0]; - const idx = Math.max(0, participants.indexOf(provider), participants.indexOf(base)); - const others = participants.filter(p => p !== provider && p !== base).map(providerLabel); - const side = stanceFor(mode, idx, participants.length); +function buildClassicPromptWithMeta({ chatTitle, topic, latestUserMessage, mode, brevity, persona, participants, provider, agent, messages, contextOpts, stopToken, independent, contextMode, delta, charter, redTeam }) { + const agents = asAgents(participants); + const self = agent || findAgent(agents, provider) || { id: String(provider), name: providerLabel(provider), provider: String(provider).split(':')[0] }; + const base = String(self.provider || provider).split(':')[0]; + const idx = Math.max(0, agents.findIndex(a => a.id === self.id)); + const others = agents.filter(a => a.id !== self.id).map(a => a.name); + const side = stanceFor(mode, idx, agents.length); const personaText = [persona, redTeam ? RED_TEAM_BLOCK : ''].filter(Boolean).join('\n\n'); const personaBlock = personaText ? `[Your assigned persona / instructions β€” stay in character]\n${personaText}\n\n` : ''; const visible = independent ? messages.filter(m => m.role !== 'assistant' || m.cycle !== independent.cycle) : messages; const cmode = CONTEXT_MODES.includes(contextMode) ? contextMode : DEFAULTS.contextMode; - const ctx = buildContextByMode(visible, { mode: cmode, contextOpts, delta: Object.assign({ provider: base }, delta || {}) }); + const ctx = buildContextByMode(visible, { mode: cmode, contextOpts, delta: Object.assign({ provider: base, agentId: self.id }, delta || {}) }); const token = stopToken || DEFAULTS.stopToken; const rules = [ `Add your next contribution (${brevity || 'max 4 sentences'}). Be substantive and specific; build on or directly challenge points already made. Do not repeat yourself or restate what others said. Reply with only your message.`, @@ -453,7 +548,7 @@ function buildClassicPromptWithMeta({ chatTitle, topic, latestUserMessage, mode, if (independent) rules.push('This is an independent first round: you have NOT been shown the other participants\' replies to the latest message. Commit to your own position; do not guess at theirs.'); if (cmode === 'delta' && !ctx.catchUp) rules.push('You are continuing an ongoing thread: earlier messages are already in this conversation above β€” only the new ones are shown here.'); if (charter) rules.push(...CHARTER_RULES); - const prompt = `${personaBlock}You are ${providerLabel(provider)}, one participant in a multi-AI conversation` + + const prompt = `${personaBlock}You are ${self.name}, one participant in a multi-AI conversation` + `${others.length ? ` with ${others.join(', ')}` : ''}${chatTitle ? ` titled "${chatTitle}"` : ''}.\n` + `Topic / brief: ${topic || '(see conversation)'}\n` + `${latestUserMessage && latestUserMessage !== topic ? `Latest instruction from the human: ${latestUserMessage}\n` : ''}` + @@ -469,10 +564,11 @@ function buildClassicPromptWithMeta({ chatTitle, topic, latestUserMessage, mode, function parseHandoff(text, participants) { const lines = String(text || '').trim().split('\n').map(l => l.trim()).filter(Boolean); const tail = lines.slice(-3).join('\n'); - const m = /HANDOFF\s*(?:β†’|->|=>)\s*@?([A-Za-z][\w-]*)/i.exec(tail); + const m = /HANDOFF\s*(?:β†’|->|=>)\s*@?([A-Za-z][\w.Β·-]*(?:\s+[\w.Β·-]+){0,2}?)\s*(?::|$)/im.exec(tail) || /HANDOFF\s*(?:β†’|->|=>)\s*@?([A-Za-z][\w.-]*)/i.exec(tail); if (!m) return null; - const name = m[1].toLowerCase(); - return participants.find(p => p.split(':')[0] === name || providerLabel(p).toLowerCase().replace(/\s+/g, '') === name) || null; + const agents = asAgents(participants); + const hit = findAgent(agents, m[1].trim()) || findAgent(agents, m[1].trim().split(/\s+/)[0]); + return hit ? participants[agents.indexOf(hit)] : null; } // Which participant holds the red-team seat in a given round (rotates). @@ -525,20 +621,23 @@ function cleanTitle(raw, maxLen = 60) { // "@claude kick off, others review after" β†’ ['claude', ...rest]. // Mentioned participants go first in mention order; the rest follow in their // configured order. "@claude only" / "only @claude" restricts to the mentioned set. +// Works on provider strings or agent objects; returns the same kind it was given. function parseMentions(text, participants) { const t = String(text || ''); + const agents = asAgents(participants); const found = []; - const re = /(^|[^\w@])@([a-z][\w-]*)/gi; + const re = /(^|[^\w@])@([a-z][\w.-]*)/gi; let m; while ((m = re.exec(t)) !== null) { - const name = m[2].toLowerCase(); - const hit = participants.find(p => p.split(':')[0] === name || providerLabel(p).toLowerCase().replace(/\s+/g, '') === name); + const hit = findAgent(agents, m[2]); if (hit && !found.includes(hit)) found.push(hit); } + const back = (a) => participants[agents.indexOf(a)]; if (!found.length) return { order: participants.slice(), only: false, mentioned: [] }; const only = /\bonly\b/i.test(t); - const order = only ? found : found.concat(participants.filter(p => !found.includes(p))); - return { order, only, mentioned: found }; + const rest = agents.filter(a => !found.includes(a)); + const order = (only ? found : found.concat(rest)).map(back); + return { order, only, mentioned: found.map(back) }; } function isPass(text) { @@ -598,6 +697,7 @@ module.exports = { CONTEXT_MODES, READABLE_ROLES, readable, buildContext, buildFullContext, buildDeltaContext, buildContextByMode, renderFullTranscript, briefAndLatest, messagesSinceSummary, stanceFor, buildClassicPrompt, buildClassicPromptWithMeta, buildJudgePrompt, buildSummaryPrompt, buildTitlePrompt, + ROUTES, CLI_PROVIDERS, EFFORTS, slugify, agentCode, defaultAgentName, normalizeAgents, findAgent, asAgents, localTitle, cleanTitle, parseMentions, parseHandoff, redTeamFor, CHARTER_RULES, RED_TEAM_BLOCK, isPass, startsWithToken, roundOutcome, sessionFor, auxSessionFor, parseToolCall, parseWorkflowLine, }; diff --git a/electron/ipc/multiai.cjs b/electron/ipc/multiai.cjs index 750515a..3d0fabd 100644 --- a/electron/ipc/multiai.cjs +++ b/electron/ipc/multiai.cjs @@ -26,6 +26,7 @@ const fs = require('fs'); const path = require('path'); const sender = require('../providers/sender.cjs'); +const cli = require('../providers/cli.cjs'); const core = require('./multiai-core.cjs'); function dataDir() { @@ -105,11 +106,13 @@ function registerMultiAiHandlers(deps) { if (sig) { sig.cancelled = true; sig.resolve(); } const set = inFlight.get(chatId); if (set) { - for (const key of set) { - const [base, sessionId] = key.split('|'); - if (typeof sender.abortActive === 'function') { - sender.abortActive(base, sessionId).catch(() => { }); + for (const entry of set) { + if (entry.kind === 'browser' && typeof sender.abortActive === 'function') { + sender.abortActive(entry.base, entry.sessionId).catch(() => { }); + } else if (entry.kind === 'cli' && typeof entry.abort === 'function') { + try { entry.abort(); } catch { /* ignore */ } } + // API calls have no cancel hook yet; their result is discarded. } } return !!sig; @@ -145,63 +148,118 @@ function registerMultiAiHandlers(deps) { ]); } - // ---- provider sessions --------------------------------------------- - // Per chat, per provider: a generation counter and a turn counter. The - // session id embeds the generation; bumping it starts a fresh provider - // thread without touching the engine's live state (which a mid-flight - // reset for another chat could corrupt). - function providerString(chat, provider) { - const base = String(provider).split(':')[0]; - const engine = chat.engines && chat.engines[base]; - return engine || base; + // ---- agents & routes --------------------------------------------------- + // Participants are agents (multiai-core.normalizeAgents): name + route + + // provider + model + effort. Route resolution is governed by the app's + // general Settings: when API mode is on there and a key exists for the + // agent's provider, 'auto' agents go to the API with the model chosen there + // (or the agent's own); otherwise they go through the browser tab. CLI + // agents (Claude Code / Codex) always run locally on your subscription. + const byok = deps.byok || null; + + function agentsOf(chat) { + return core.normalizeAgents(chat || {}); } - function sessionState(chat, base) { + function apiAvailable(provider) { + try { + return !!(byok && byok.keys && byok.keys.isEnabled() && byok.keys.hasKey(provider)); + } catch { return false; } + } + + function resolveRoute(agent) { + if (core.CLI_PROVIDERS[agent.provider]) return 'cli'; + if (agent.route === 'browser') return 'browser'; + return apiAvailable(agent.provider) ? 'api' : 'browser'; + } + + // What the editor needs to offer sensible choices: API mode state and + // per-provider selected model / model list from Settings, CLI availability. + function routeInfo() { + const info = { apiMode: false, providers: {}, cli: cli.availability() }; + try { + info.apiMode = !!(byok && byok.keys && byok.keys.isEnabled()); + for (const p of core.KNOWN_PROVIDERS) { + let hasKey = false, model = '', models = []; + try { hasKey = !!(byok && byok.keys.hasKey(p)); } catch { /* ignore */ } + try { model = (byok && byok.keys.getSelectedModel(p)) || (byok && byok.models && byok.models.DEFAULT_MODELS && byok.models.DEFAULT_MODELS[p]) || ''; } catch { /* ignore */ } + try { models = ((byok && byok.keys.getModels(p)) || []).filter(m => m.enabled !== false).map(m => m.id); } catch { /* ignore */ } + info.providers[p] = { hasKey, model, models }; + } + } catch { /* settings unavailable */ } + return info; + } + + // Ad-hoc agent from a provider string / agent id / agent object (judge, + // summary, title, orchestrated steps). + function agentFrom(chat, ref) { + if (ref && typeof ref === 'object') return core.normalizeAgents({ agents: [ref] })[0]; + const agents = agentsOf(chat); + const hit = core.findAgent(agents, ref) || agents.find(a => a.provider === String(ref || '').split(':')[0]); + if (hit) return hit; + const base = String(ref || '').split(':')[0]; + const model = String(ref || '').includes(':') && !core.CLI_PROVIDERS[base] ? String(ref) : ''; + return core.normalizeAgents({ agents: [{ provider: base, model }] })[0]; + } + + function sessionState(chat, key) { const all = chat.providerSessions || {}; - return all[base] || { gen: 1, turns: 0, lastId: null }; + return all[key] || { gen: 1, turns: 0, lastId: null }; } function contextModeOf(chat) { return core.CONTEXT_MODES.includes(chat.contextMode) ? chat.contextMode : core.DEFAULTS.contextMode; } + // Delta needs a provider-side thread; API and CLI turns are one-shot, so + // they use the self-sufficient bounded prompt (or full, if chosen). + function contextModeFor(chat, route) { + const m = contextModeOf(chat); + if (route !== 'browser' && m === 'delta') return 'bounded'; + return m; + } + function resetEveryFor(chat) { return contextModeOf(chat) === 'delta' ? core.DEFAULTS.providerResetEveryDelta : core.DEFAULTS.providerResetEvery; } - function sessionIdFor(chat, st) { - return `${core.sessionFor(chat.id)}:${st.gen}`; + function sessionIdFor(chat, agent, st) { + return `${core.sessionFor(chat.id)}:${agent.id}:${st.gen}`; } // The thread state a turn will use: rotates to a fresh generation when the // turn bound is hit. Delta mode needs to know this BEFORE building the // prompt (a fresh thread gets a full catch-up instead of a delta). - function nextSessionState(chat, base) { - const st = sessionState(chat, base); + function nextSessionState(chat, agent) { + const st = sessionState(chat, agent.id); if (st.turns >= resetEveryFor(chat)) return { gen: st.gen + 1, turns: 0, lastId: null, rotated: true }; return Object.assign({}, st, { rotated: false }); } - // Sends one turn on the chat's thread for that provider, then advances the - // turn counter. `st` comes from nextSessionState(); `lastId` (delta mode) - // is the anchor to remember on success β€” the last message this provider - // has now seen. A cancelled call leaves the anchor alone so the next turn - // re-sends what the provider may or may not have read. - async function sendOnChatThread(chat, provider, prompt, st, lastId) { - const base = String(provider).split(':')[0]; - if (!st) st = nextSessionState(chat, base); - const sessionId = sessionIdFor(chat, st); - const key = `${base}|${sessionId}`; - if (!inFlight.has(chat.id)) inFlight.set(chat.id, new Set()); - inFlight.get(chat.id).add(key); + function saveSession(chat, key, next) { + chat.providerSessions = Object.assign({}, chat.providerSessions || {}, { [key]: next }); + if (store.getChat(chat.id)) store.updateChat(chat.id, (c) => { c.providerSessions = Object.assign({}, c.providerSessions || {}, { [key]: next }); }); + } + + function trackInFlight(chatId, entry) { + if (!inFlight.has(chatId)) inFlight.set(chatId, new Set()); + inFlight.get(chatId).add(entry); + return () => { const set = inFlight.get(chatId); if (set) { set.delete(entry); if (!set.size) inFlight.delete(chatId); } }; + } + + // Browser route: one provider-side thread per agent per chat. + async function sendBrowser(chat, agent, prompt, st, lastId) { + if (!st) st = nextSessionState(chat, agent); + const sessionId = sessionIdFor(chat, agent, st); + const providerString = (agent.provider === 'gemini' && agent.model) ? (agent.model.startsWith('gemini:') ? agent.model : `gemini:${agent.model}`) : agent.provider; + const untrack = trackInFlight(chat.id, { kind: 'browser', base: agent.provider, sessionId }); const sig = runSignals.get(chat.id); const options = { shouldRun: () => !(sig && sig.cancelled) }; let result; try { - result = await raceProvider(chat.id, sender.sendMessageToProvider(providerString(chat, provider), prompt, null, null, sessionId, options)); + result = await raceProvider(chat.id, sender.sendMessageToProvider(providerString, prompt, null, null, sessionId, options)); } finally { - const set = inFlight.get(chat.id); - if (set) { set.delete(key); if (!set.size) inFlight.delete(chat.id); } + untrack(); } let next = null; if (result.ok) { @@ -215,28 +273,77 @@ function registerMultiAiHandlers(deps) { // a full catch-up instead of a delta into an amnesiac thread. next = { gen: st.gen + 1, turns: 0, lastId: null }; } - if (next) { - chat.providerSessions = Object.assign({}, chat.providerSessions || {}, { [base]: next }); - store.updateChat(chat.id, (c) => { c.providerSessions = Object.assign({}, c.providerSessions || {}, { [base]: next }); }); - } + if (next) saveSession(chat, agent.id, next); + if (result.ok) result.value = { response: result.value.response, model: agent.model || null, route: 'browser' }; return result; } - // One-off calls (judge, summary, title) use a per-chat auxiliary thread, - // separate from the chat's own thread so they never pollute it. Their - // prompts are self-contained, so the thread is rotated every few uses to - // keep it bounded without opening a new provider conversation every time. + // API route: the provider's API with the key from Settings. Model = the + // agent's, else the model selected in Settings (resolveModel handles it). + async function sendApi(chat, agent, prompt) { + const key = byok.keys.getKey(agent.provider); + if (!key) return { ok: false, error: new Error(`No API key for ${core.providerLabel(agent.provider)} in Settings`) }; + const untrack = trackInFlight(chat.id, { kind: 'api', base: agent.provider }); + try { + const p = byok.callProvider(agent.provider, key, [{ role: 'user', content: prompt }], { + modelId: agent.model || undefined, + reasoningEffort: agent.effort || undefined, + }).then(r => ({ response: r.text, model: r.model || agent.model || null, route: 'api' })); + return await raceProvider(chat.id, p); + } finally { + untrack(); + } + } + + // CLI route: Claude Code / Codex on the local machine. + async function sendCli(chat, agent, prompt) { + const handle = cli.run(agent.provider, { model: agent.model || undefined, effort: agent.effort || undefined, prompt, cwd: dataDir() }); + const entry = { kind: 'cli', base: agent.provider, abort: handle.abort }; + const untrack = trackInFlight(chat.id, entry); + try { + const p = handle.promise.then(r => ({ response: r.text, model: r.model || agent.model || null, route: 'cli', costUsd: r.costUsd })); + const raced = await raceProvider(chat.id, p); + if (raced.cancelled) handle.abort(); + return raced; + } finally { + untrack(); + } + } + + async function sendAgent(chat, agent, prompt, extra) { + const route = resolveRoute(agent); + if (route === 'api') return sendApi(chat, agent, prompt); + if (route === 'cli') return sendCli(chat, agent, prompt); + return sendBrowser(chat, agent, prompt, extra && extra.st, extra && extra.lastId); + } + + // One-off calls (judge, summary, title) use a per-chat auxiliary thread + // on the browser route (rotated every few uses so it stays bounded); + // API/CLI calls are one-shot anyway. `ref` may be an agent, an agent id + // or a provider string. const AUX_RESET_EVERY = 5; - async function sendAux(chat, provider, prompt) { - const base = String(provider).split(':')[0]; - const key = `aux:${base}`; - let st = (chat.providerSessions && chat.providerSessions[key]) || { gen: 1, turns: 0 }; + async function sendAux(chat, ref, prompt) { + const agent = agentFrom(chat, ref); + const route = resolveRoute(agent); + if (route === 'api') { + const r = await sendApi(chat, agent, prompt); + if (r.ok) return r.value; + throw r.error; + } + if (route === 'cli') { + const r = await sendCli(chat, agent, prompt); + if (r.ok) return r.value; + if (r.cancelled) throw new Error('Cancelled by user'); + throw r.error; + } + const key = `aux:${agent.provider}`; + let st = sessionState(chat, key); if (st.turns >= AUX_RESET_EVERY) st = { gen: st.gen + 1, turns: 0 }; const sessionId = `${core.auxSessionFor(chat.id)}:${st.gen}`; - const result = await sender.sendMessageToProvider(providerString(chat, provider), prompt, null, null, sessionId); - const next = { gen: st.gen, turns: st.turns + 1 }; - if (store.getChat(chat.id)) store.updateChat(chat.id, (c) => { c.providerSessions = Object.assign({}, c.providerSessions || {}, { [key]: next }); }); - return result; + const providerString = (agent.provider === 'gemini' && agent.model) ? (agent.model.startsWith('gemini:') ? agent.model : `gemini:${agent.model}`) : agent.provider; + const result = await sender.sendMessageToProvider(providerString, prompt, null, null, sessionId); + saveSession(chat, key, { gen: st.gen, turns: st.turns + 1 }); + return { response: result.response, model: agent.model || null, route: 'browser' }; } function contextOpts(chat) { @@ -256,6 +363,7 @@ function registerMultiAiHandlers(deps) { return { success: true }; }); ipcMain.handle('multiai-enabled-providers', () => enabledProviders()); + ipcMain.handle('multiai-routes', () => { cli.resetAvailabilityCache(); return routeInfo(); }); ipcMain.handle('multiai-running', () => Array.from(runSignals.keys())); ipcMain.handle('multiai-stop-run', (event, { chatId }) => { @@ -299,71 +407,78 @@ function registerMultiAiHandlers(deps) { return (chat.messages || []).filter(m => (m.cycle || 1) === cycle && m.round === round); } - async function takeTurn(chatId, provider, round, opts) { + function personaFor(chat, agent) { + if (agent.persona) return agent.persona; + const p = chat.personas || {}; + return p[agent.id] || p[agent.provider] || null; + } + + async function takeTurn(chatId, agent, round, opts) { const chat = store.getChat(chatId); if (!chat) throw new Error('Chat not found'); - const base = String(provider).split(':')[0]; const cycle = chat.cycle || 1; - emit('multiai-thinking', { chatId, provider: base }); + emit('multiai-thinking', { chatId, provider: agent.provider, agentId: agent.id, who: agent.name }); const { brief, latest } = core.briefAndLatest(chat.messages, chat.topic); - const st = nextSessionState(chat, base); - const cmode = contextModeOf(chat); + const route = resolveRoute(agent); + const st = route === 'browser' ? nextSessionState(chat, agent) : null; + const cmode = contextModeFor(chat, route); const built = core.buildClassicPromptWithMeta({ chatTitle: chat.title, topic: brief, latestUserMessage: latest, mode: chat.discussMode, brevity: chat.brevity, - persona: (chat.personas && chat.personas[base]) || null, + persona: personaFor(chat, agent), participants: opts.participants, - provider: base, + agent, + provider: agent.provider, messages: chat.messages, contextOpts: contextOpts(chat), stopToken: chat.stopToken || core.DEFAULTS.stopToken, independent: opts.independent ? { cycle } : null, contextMode: cmode, // A fresh/rotated thread has no memory: force the catch-up prompt. - delta: { sinceId: (st.rotated || st.turns === 0) ? null : st.lastId, provider: base }, + delta: { sinceId: (!st || st.rotated || st.turns === 0) ? null : st.lastId, provider: agent.provider, agentId: agent.id }, charter: !!chat.charterMode, - redTeam: !!(chat.charterMode && opts.redTeam && String(opts.redTeam).split(':')[0] === base), + redTeam: !!(chat.charterMode && opts.redTeam && opts.redTeam.id === agent.id), }); - const raced = await sendOnChatThread(chat, provider, built.prompt, st, cmode === 'delta' ? built.meta.lastId : null); - const label = core.providerLabel(base); + const raced = await sendAgent(chat, agent, built.prompt, { st, lastId: cmode === 'delta' ? built.meta.lastId : null }); + const base = { provider: agent.provider, agentId: agent.id, code: agent.code, route, cycle, round }; if (raced.cancelled) { - record(chatId, { who: `${label} (stopped)`, text: 'Stopped before this reply arrived.', provider: base, role: 'error', cycle, round }); + record(chatId, Object.assign({ who: `${agent.name} (stopped)`, text: 'Stopped before this reply arrived.', role: 'error' }, base)); return { cancelled: true }; } if (!raced.ok) { - record(chatId, { who: `${label} error`, text: raced.error.message, provider: base, role: 'error', cycle, round }); + record(chatId, Object.assign({ who: `${agent.name} error`, text: raced.error.message, role: 'error' }, base)); return { error: raced.error.message }; } const text = raced.value.response || ''; + const model = raced.value.model || undefined; if (core.isPass(text)) { - record(chatId, { who: label, text: 'PASS', provider: base, role: 'pass', cycle, round }); + record(chatId, Object.assign({ who: agent.name, text: 'PASS', role: 'pass', model }, base)); return { pass: true }; } - record(chatId, { who: label, text, provider: base, role: 'assistant', cycle, round, redTeam: built.meta.redTeam || undefined }); + record(chatId, Object.assign({ who: agent.name, text, role: 'assistant', model, redTeam: built.meta.redTeam || undefined }, base)); return { ok: true, handoff: chat.charterMode ? core.parseHandoff(text, opts.participants) : null }; } // Rolling summary (delta mode's "Topic block"): once more than // autoSummaryEvery readable messages have accumulated since the last - // pinned summary, ask the judge provider (or the first participant) for a - // fresh one. Runs between rounds so it never interrupts a turn. - async function maybeAutoSummary(chatId, participants) { + // pinned summary, ask the judge (or the first participant) for a fresh + // one. Runs between rounds so it never interrupts a turn. + async function maybeAutoSummary(chatId, agents) { const chat = store.getChat(chatId); if (!chat) return; const every = parseInt(chat.autoSummaryEvery, 10) || 0; if (every <= 0) return; if (core.messagesSinceSummary(chat.messages).length < every) return; - const provider = chat.judgeProvider || participants[0]; - if (!provider) return; - const base = String(provider).split(':')[0]; - emit('multiai-thinking', { chatId, provider: base }); + const judge = chat.judgeProvider ? agentFrom(chat, chat.judgeProvider) : agents[0]; + if (!judge) return; + emit('multiai-thinking', { chatId, provider: judge.provider, who: judge.name }); const { brief } = core.briefAndLatest(chat.messages, chat.topic); try { - const result = await sendAux(chat, provider, core.buildSummaryPrompt({ topic: brief, messages: chat.messages })); - record(chatId, { who: `State summary Β· ${core.providerLabel(base)} (auto)`, text: result.response, provider: base, role: 'summary', cycle: chat.cycle || 1, round: 0 }); + const result = await sendAux(chat, judge, core.buildSummaryPrompt({ topic: brief, messages: chat.messages })); + record(chatId, { who: `State summary Β· ${judge.name} (auto)`, text: result.response, provider: judge.provider, agentId: judge.id, role: 'summary', cycle: chat.cycle || 1, round: 0 }); } catch (e) { note(chatId, `Auto-summary failed: ${e.message}`); } @@ -376,39 +491,41 @@ function registerMultiAiHandlers(deps) { const chat0 = store.getChat(chatId); if (!chat0) throw new Error('Chat not found'); const sig = startSignal(chatId); - const produced = []; try { const enabled = enabledProviders(); - let participants = (chat0.participants || []).filter(p => enabled.includes(String(p).split(':')[0])); - if (!participants.length) participants = (chat0.participants || []).slice(); - if (!participants.length) throw new Error('No participants selected.'); - const mentions = core.parseMentions(latestText || '', participants); + const all = agentsOf(chat0); + // Browser-route agents need their provider tab enabled in Settings; + // API and CLI agents don't. + let agents = all.filter(a => resolveRoute(a) !== 'browser' || enabled.includes(a.provider)); + if (!agents.length) agents = all.slice(); + if (!agents.length) throw new Error('No participants selected.'); + const mentions = core.parseMentions(latestText || '', agents); const order = mentions.order; if (mentions.mentioned.length) { - note(chatId, `Directed turn: ${mentions.only ? 'only ' : ''}${mentions.mentioned.map(core.providerLabel).join(', ')}${mentions.only ? '' : ' first'}.`); + note(chatId, `Directed turn: ${mentions.only ? 'only ' : ''}${mentions.mentioned.map(a => a.name).join(', ')}${mentions.only ? '' : ' first'}.`); } const n = Math.max(1, Math.min(30, rounds || chat0.rounds || 1)); const token = chat0.stopToken || core.DEFAULTS.stopToken; const cycle = chat0.cycle || 1; const stopEarly = chat0.stopWhenAllAgree !== false; - // A provider that fails twice in a row (Perplexity's HTTP 500s, - // a logged-out tab) is benched for the rest of this run rather - // than burning a slot β€” and an error message β€” every round. + // An agent that fails twice in a row (Perplexity's HTTP 500s, a + // logged-out tab, a CLI that can't start) is benched for the rest + // of this run rather than burning a slot β€” and an error message β€” + // every round. const failures = {}; const benched = new Set(); - const noteFailure = (provider, res) => { - const base = String(provider).split(':')[0]; + const noteFailure = (agent, res) => { if (res && res.error) { - failures[base] = (failures[base] || 0) + 1; - if (failures[base] >= 2 && !benched.has(base)) { - benched.add(base); - note(chatId, `${core.providerLabel(base)} failed twice in a row β€” skipping it for the rest of this run.`); + failures[agent.id] = (failures[agent.id] || 0) + 1; + if (failures[agent.id] >= 2 && !benched.has(agent.id)) { + benched.add(agent.id); + note(chatId, `${agent.name} failed twice in a row β€” skipping it for the rest of this run.`); } } else if (res && (res.ok || res.pass)) { - failures[base] = 0; + failures[agent.id] = 0; } }; - const active = () => order.filter(p => !benched.has(String(p).split(':')[0])); + const active = () => order.filter(a => !benched.has(a.id)); // Charter mode: a HANDOFF β†’ trailer can pull the named participant // forward β€” next within this round if they haven't spoken yet, // otherwise first in the next round. @@ -428,15 +545,15 @@ function registerMultiAiHandlers(deps) { emit('multiai-round-start', { chatId, round: r, of: n }); let turnOrder = active(); if (!turnOrder.length) { note(chatId, 'No participants left to take a turn.'); break; } - if (nextFirst && turnOrder.includes(nextFirst)) turnOrder = [nextFirst].concat(turnOrder.filter(p => p !== nextFirst)); + if (nextFirst && turnOrder.includes(nextFirst)) turnOrder = [nextFirst].concat(turnOrder.filter(a => a !== nextFirst)); nextFirst = null; // Seat rotates over the stable participant order, not the // handoff-reordered turn order, so everyone gets it in turn. const redTeam = chat0.charterMode ? core.redTeamFor(active(), r) : null; - if (redTeam) note(chatId, `Round ${r}: ${core.providerLabel(redTeam)} holds the red-team seat.`); + if (redTeam) note(chatId, `Round ${r}: ${redTeam.name} holds the red-team seat.`); const independent = r === 1 && !!chat0.independentFirstRound && turnOrder.length > 1; if (independent) { - const results = await Promise.all(turnOrder.map(p => takeTurn(chatId, p, r, { participants: order, independent: true, redTeam }) + const results = await Promise.all(turnOrder.map(a => takeTurn(chatId, a, r, { participants: order, independent: true, redTeam }) .catch(e => ({ error: e.message })))); results.forEach((res, i) => noteFailure(turnOrder[i], res)); if (results.some(x => x && x.cancelled) || sig.cancelled) break outer; @@ -446,17 +563,17 @@ function registerMultiAiHandlers(deps) { const remaining = turnOrder.slice(); while (remaining.length) { if (sig.cancelled) break outer; - const provider = remaining.shift(); - const res = await takeTurn(chatId, provider, r, { participants: order, redTeam }).catch(e => ({ error: e.message })); - noteFailure(provider, res); + const agent = remaining.shift(); + const res = await takeTurn(chatId, agent, r, { participants: order, redTeam }).catch(e => ({ error: e.message })); + noteFailure(agent, res); if (res && res.cancelled) break outer; if (res && res.handoff) { const target = res.handoff; if (remaining.includes(target)) { remaining.splice(remaining.indexOf(target), 1); remaining.unshift(target); - note(chatId, `${core.providerLabel(provider)} handed off to ${core.providerLabel(target)} β€” they go next.`); - } else if (target !== provider) { + note(chatId, `${agent.name} handed off to ${target.name} β€” they go next.`); + } else if (target !== agent) { nextFirst = target; } } @@ -511,17 +628,17 @@ function registerMultiAiHandlers(deps) { ipcMain.handle('multiai-judge', async (event, { chatId, judgeProvider, scope }) => { const chat = store.getChat(chatId); if (!chat) return { success: false, error: 'Chat not found' }; - const base = String(judgeProvider).split(':')[0]; - emit('multiai-thinking', { chatId, provider: base }); + const judge = agentFrom(chat, judgeProvider); + emit('multiai-thinking', { chatId, provider: judge.provider, who: judge.name }); const { brief } = core.briefAndLatest(chat.messages, chat.topic); const prompt = core.buildJudgePrompt({ topic: brief, messages: scopedMessages(chat, scope || chat.judgeScope || 'all') }); try { - const result = await sendAux(chat, judgeProvider, prompt); - const msg = record(chatId, { who: `Judge Β· ${core.providerLabel(base)}`, text: result.response, provider: base, role: 'judge', cycle: chat.cycle || 1, round: 0 }); + const result = await sendAux(chat, judge, prompt); + const msg = record(chatId, { who: `Judge Β· ${judge.name}`, text: result.response, provider: judge.provider, agentId: judge.id, model: result.model || undefined, role: 'judge', cycle: chat.cycle || 1, round: 0 }); emit('multiai-done', { chatId }); return { success: true, message: msg }; } catch (e) { - record(chatId, { who: 'Judge error', text: e.message, provider: base, role: 'error', cycle: chat.cycle || 1, round: 0 }); + record(chatId, { who: 'Judge error', text: e.message, provider: judge.provider, role: 'error', cycle: chat.cycle || 1, round: 0 }); emit('multiai-done', { chatId }); return { success: false, error: e.message }; } @@ -531,17 +648,17 @@ function registerMultiAiHandlers(deps) { ipcMain.handle('multiai-summarize', async (event, { chatId, provider }) => { const chat = store.getChat(chatId); if (!chat) return { success: false, error: 'Chat not found' }; - const base = String(provider).split(':')[0]; - emit('multiai-thinking', { chatId, provider: base }); + const judge = agentFrom(chat, provider); + emit('multiai-thinking', { chatId, provider: judge.provider, who: judge.name }); const { brief } = core.briefAndLatest(chat.messages, chat.topic); const prompt = core.buildSummaryPrompt({ topic: brief, messages: chat.messages }); try { - const result = await sendAux(chat, provider, prompt); - const msg = record(chatId, { who: `State summary Β· ${core.providerLabel(base)}`, text: result.response, provider: base, role: 'summary', cycle: chat.cycle || 1, round: 0 }); + const result = await sendAux(chat, judge, prompt); + const msg = record(chatId, { who: `State summary Β· ${judge.name}`, text: result.response, provider: judge.provider, agentId: judge.id, role: 'summary', cycle: chat.cycle || 1, round: 0 }); emit('multiai-done', { chatId }); return { success: true, message: msg }; } catch (e) { - record(chatId, { who: 'Summary error', text: e.message, provider: base, role: 'error', cycle: chat.cycle || 1, round: 0 }); + record(chatId, { who: 'Summary error', text: e.message, provider: judge.provider, role: 'error', cycle: chat.cycle || 1, round: 0 }); emit('multiai-done', { chatId }); return { success: false, error: e.message }; } @@ -555,7 +672,7 @@ function registerMultiAiHandlers(deps) { async function chat(chatId, provider, prompt) { const c = store.getChat(chatId); if (!c) throw new Error('Chat not found'); - const raced = await sendOnChatThread(c, provider, prompt); + const raced = await sendAgent(c, agentFrom(c, provider), prompt); if (raced.cancelled) { const err = new Error('Stopped before this reply arrived.'); err.wasCancelled = true; @@ -861,6 +978,11 @@ function registerMultiAiHandlers(deps) { return e; } + // Agents as shown to REST/MCP clients: resolved route, no persona text. + function publicAgents(chat) { + return agentsOf(chat).map(a => ({ id: a.id, name: a.name, code: a.code, route: resolveRoute(a), provider: a.provider, model: a.model, effort: a.effort })); + } + function withLabels(messages) { return (messages || []).map(x => Object.assign({ label: core.refLabel(x) }, x)); } @@ -887,6 +1009,7 @@ function registerMultiAiHandlers(deps) { if (body.contextWindow != null) fields.contextWindow = Math.max(0, parseInt(body.contextWindow, 10) || 0); if (body.autoSummaryEvery != null) fields.autoSummaryEvery = Math.max(0, parseInt(body.autoSummaryEvery, 10) || 0); if (body.engines && typeof body.engines === 'object') fields.engines = body.engines; + if (Array.isArray(body.agents) && body.agents.length) fields.agents = core.normalizeAgents({ agents: body.agents }).map(a => ({ id: a.id, name: a.name, route: a.route, provider: a.provider, model: a.model, effort: a.effort, persona: a.persona || '' })); if (body.topic) fields.topic = String(body.topic); const created = store.createChat(fields); if (body.topic) { @@ -895,7 +1018,7 @@ function registerMultiAiHandlers(deps) { } const chat = store.getChat(created.id); emit('multiai-chat-created', { chat }); - return chat; + return Object.assign({}, chat, { agents: publicAgents(chat) }); }, getChat(chatId, { since, last } = {}) { @@ -907,7 +1030,7 @@ function registerMultiAiHandlers(deps) { messages = i === -1 ? messages : messages.slice(i + 1); } if (last && Number(last) > 0) messages = messages.slice(-Number(last)); - return Object.assign({}, chat, { messages: withLabels(messages), running: isRunning(chatId), totalMessages: (chat.messages || []).length }); + return Object.assign({}, chat, { messages: withLabels(messages), agents: publicAgents(chat), running: isRunning(chatId), totalMessages: (chat.messages || []).length }); }, appendMessage(chatId, body) { @@ -972,8 +1095,9 @@ function registerMultiAiHandlers(deps) { // Registered on the existing gateway (port 3210+). Same loopback-only and // API-key rules as every other /v1 route. All bodies/results are JSON. // - // GET /v1/multiai/chats β†’ {chats:[{id,title,mode,participants,messageCount,...}]} - // POST /v1/multiai/chats {title?, participants?, mode?, contextMode?, topic?, who?} β†’ {chat} + // GET /v1/multiai/chats β†’ {chats:[{id,title,mode,agents:[name],participants,messageCount,...}]} + // POST /v1/multiai/chats {title?, agents?|participants?, mode?, contextMode?, topic?, who?} β†’ {chat} + // (agents: [{name?, provider, route?, model?, effort?, persona?}]; participants: legacy provider list) // GET /v1/multiai/chats/:id[?since=&last=] β†’ {chat} (messages after `since` / only the last n) // POST /v1/multiai/chats/:id/messages {who, text, role?, provider?} β†’ {message} // (appends a turn from an external agent; nothing is sent to providers) diff --git a/electron/main-v2.cjs b/electron/main-v2.cjs index 5e7357d..1b823ef 100644 --- a/electron/main-v2.cjs +++ b/electron/main-v2.cjs @@ -794,6 +794,7 @@ function initModules() { getFileReferenceEnabled: () => fileReferenceEnabled, setFileReferenceEnabled: (v) => { fileReferenceEnabled = !!v; }, registerRouteExtension, + byok, }; registerCoreHandlers(handlerDeps); registerSettingsHandlers(handlerDeps); diff --git a/electron/multiai-renderer.js b/electron/multiai-renderer.js index 33c8d2c..17d048f 100644 --- a/electron/multiai-renderer.js +++ b/electron/multiai-renderer.js @@ -42,6 +42,7 @@ function multiaiDefaultChat() { rounds: 1, brevity: 'max 4 sentences', participants: [], + agents: [], // [{id, name, route, provider, model, effort, persona, personaKey}] β€” see multiaiEnsureAgents personas: {}, judgeProvider: '', judgeScope: 'all', @@ -96,7 +97,7 @@ function multiaiRefLabel(msg) { else if (msg.role === 'summary') code = 'SM'; else if (msg.role === 'system') code = 'SY'; else if (msg.role === 'error') code = 'ER'; - else code = multiaiProviderCode(msg.provider); + else code = msg.code || multiaiProviderCode(msg.provider); return `${String(c).padStart(2, '0')}${String(r).padStart(2, '0')}${code}`; } @@ -146,11 +147,13 @@ async function multiaiInit() { } multiaiState.initDone = true; - const [data, providers, personas] = await Promise.all([ + const [data, providers, personas, routes] = await Promise.all([ agentHub.multiaiLoad(), agentHub.multiaiEnabledProviders(), agentHub.multiaiListPersonas(), + (agentHub.multiaiRoutes ? agentHub.multiaiRoutes() : Promise.resolve(null)).catch(() => null), ]); + multiaiState.routes = routes || { apiMode: false, providers: {}, cli: {} }; multiaiState.data = data && data.projects ? data : { projects: [{ id: 'default', name: 'General', chats: [] }] }; if (!multiaiState.data.uiPrefs) multiaiState.data.uiPrefs = {}; multiaiState.enabledProviders = providers || []; @@ -180,8 +183,8 @@ async function multiaiInit() { multiaiState.data.projects[0].chats.push(chat); multiaiRenderChatList(); }); - agentHub.onMultiaiThinking(({ chatId, provider }) => { - if (chatId === multiaiState.currentChatId) multiaiSetThinking(provider, true); + agentHub.onMultiaiThinking(({ chatId, provider, who }) => { + if (chatId === multiaiState.currentChatId) multiaiSetThinking(provider, true, who); }); agentHub.onMultiaiRoundStart(({ chatId, round, of }) => { if (chatId === multiaiState.currentChatId) { @@ -706,7 +709,7 @@ function multiaiRenderOptionsSummary(chat) { const el = document.getElementById('multiai-options-summary-text'); if (!el || !chat) return; if (multiaiState.mode === 'classic') { - const n = (chat.participants || []).length; + const n = multiaiEnsureAgents(chat).length; const rounds = chat.rounds || 1; const extras = []; if (chat.contextMode === 'full') extras.push('full transcript'); @@ -714,7 +717,9 @@ function multiaiRenderOptionsSummary(chat) { if (chat.independentFirstRound) extras.push('blind R1'); if (chat.charterMode) extras.push('charter'); if (chat.stopWhenAllAgree !== false) extras.push(`stop on ${chat.stopToken || 'DONE'}`); - if (chat.engines && chat.engines.gemini && chat.engines.gemini !== 'gemini') extras.push(chat.engines.gemini); + const routes = multiaiState.routes || {}; + if (routes.apiMode) extras.push('API mode'); + if ((chat.agents || []).some(a => MULTIAI_CLI_PROVIDERS[a.provider])) extras.push('CLI agents'); el.textContent = `βš™ ${n} participant${n === 1 ? '' : 's'} Β· ${chat.discussMode === 'debate' ? 'Debate' : 'Discuss'} Β· ${rounds} round${rounds === 1 ? '' : 's'} Β· ${chat.brevity || 'max 4 sentences'}${extras.length ? ' Β· ' + extras.join(' Β· ') : ''}`; } else { const strategy = chat.strategy || 'crew'; @@ -790,7 +795,7 @@ function multiaiRenderChat() { function multiaiRenderStatusLine(chat) { const el = document.getElementById('multiai-status-line'); if (!el) return; - const participants = (chat.participants || []).map(multiaiProviderLabel).join(', '); + const participants = multiaiEnsureAgents(chat).map(a => a.name).join(', '); const msgCount = (chat.messages || []).length; const roundBadge = multiaiState.currentRound ? ` Β· round ${multiaiState.currentRound}${multiaiState.totalRounds ? ' of ' + multiaiState.totalRounds : ''}` @@ -872,113 +877,257 @@ function multiaiOnLedgerFilterChange() { if (chat) multiaiRenderLedger(chat); } +// ---- Agents (participants) ------------------------------------------- +// A participant is an agent: name + route + provider + model + effort + +// persona. Several agents may share a provider ("Opus Β· high" and +// "Sonnet Β· low" through Claude Code; "GPT high"/"GPT low" through the +// OpenAI API). Route 'auto' follows the app's general Settings: API mode on +// with a key β†’ API with the model chosen there (or the agent's own); +// otherwise the browser tab. CLI agents run locally on your subscription. + +const MULTIAI_CLI_PROVIDERS = { 'claude-cli': 'Claude Code', 'codex-cli': 'Codex' }; +const MULTIAI_EFFORTS = ['', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']; +const MULTIAI_MODEL_HINTS = { + 'claude-cli': ['fable', 'opus', 'sonnet', 'haiku', 'opus[1m]', 'sonnet[1m]'], + 'codex-cli': ['gpt-5.5', 'gpt-5.5-codex', 'gpt-5.4'], + gemini: ['3.5-flash', '3.1-pro', '3.1-flash-lite', 'auto'], +}; + +function multiaiSlug(s) { + return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); +} + +function multiaiAgentCode(agent) { + const name = String(agent.name || ''); + const base = String(agent.provider || '').split(':')[0]; + if (name && multiaiProviderLabel(base) === name) return multiaiProviderCode(base); + const words = name.split(/[^A-Za-z0-9]+/).filter(Boolean); + if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase(); + if (words.length === 1 && words[0].length >= 2) return words[0].slice(0, 2).toUpperCase(); + return multiaiProviderCode(base); +} + +function multiaiDefaultAgentName(provider, model, effort) { + const label = MULTIAI_CLI_PROVIDERS[provider] || multiaiProviderLabel(provider); + const parts = []; + if (model && !(provider === 'gemini' && model === 'gemini')) parts.push(String(model).replace(/^gemini:/, '')); + if (effort) parts.push(effort); + return parts.length ? `${label} Β· ${parts.join(' Β· ')}` : label; +} + +// Migrates a chat's legacy participants list into agents (once) and keeps +// ids unique. Mirrors multiai-core.normalizeAgents. +function multiaiEnsureAgents(chat) { + if (!chat) return []; + if (!Array.isArray(chat.agents)) chat.agents = []; + if (!chat.agents.length && (chat.participants || []).length) { + chat.agents = chat.participants.map(p => { + const base = String(p).split(':')[0]; + const engine = String(p).includes(':') ? String(p) : ((chat.engines && chat.engines[base]) || ''); + const a = { id: base, name: multiaiProviderLabel(base), route: 'auto', provider: base, model: engine && engine !== base ? engine.replace(/^gemini:/, '') : '', effort: '' }; + const key = (chat.personaKeys || {})[base]; + if (key) { a.personaKey = key; a.persona = multiaiAllPersonas()[key] || ''; } + return a; + }); + } + const seen = new Set(); + chat.agents.forEach(a => { + if (!a.provider) a.provider = 'chatgpt'; + if (MULTIAI_CLI_PROVIDERS[a.provider]) a.route = 'cli'; + if (!a.route) a.route = 'auto'; + if (!a.name) a.name = multiaiDefaultAgentName(a.provider, a.model, a.effort); + if (!a.id) a.id = multiaiSlug(a.name) || a.provider; + let id = a.id, n = 2; + while (seen.has(id)) id = `${a.id}-${n++}`; + a.id = id; seen.add(id); + }); + // Keep the legacy field in sync for anything that still reads it. + chat.participants = chat.agents.map(a => a.provider); + return chat.agents; +} + +function multiaiRouteLabel(agent) { + const r = multiaiState.routes || { apiMode: false, providers: {}, cli: {} }; + if (MULTIAI_CLI_PROVIDERS[agent.provider]) { + const ok = agent.provider === 'claude-cli' ? r.cli.claude : r.cli.codex; + return ok ? `β†’ ${MULTIAI_CLI_PROVIDERS[agent.provider]} (local, subscription)` : `⚠ ${MULTIAI_CLI_PROVIDERS[agent.provider]} CLI not found`; + } + const p = r.providers[agent.provider] || {}; + const apiOk = r.apiMode && p.hasKey; + if (agent.route === 'browser') return 'β†’ browser tab'; + if (agent.route === 'api' && !apiOk) return `⚠ API mode is off or no ${multiaiProviderLabel(agent.provider)} key in Settings β€” will use the browser tab`; + if (apiOk) return `β†’ API Β· ${agent.model || (p.model ? p.model + ' (Settings)' : 'model from Settings')}`; + return 'β†’ browser tab' + (agent.effort ? ' (effort ignored β€” API mode is off in Settings)' : ''); +} + function multiaiRenderParticipants(chat) { const box = document.getElementById('multiai-participants'); if (!box) return; - const providers = multiaiState.enabledProviders.length - ? multiaiState.enabledProviders - : ['chatgpt', 'claude', 'gemini', 'perplexity']; - const engines = chat.engines || {}; - box.innerHTML = providers.map(p => { - const checked = (chat.participants || []).includes(p); - // Only Gemini honours an engine choice in the provider layer today - // (ChatGPT/Claude/Perplexity ignore it), so only Gemini gets a picker. - const enginePicker = p === 'gemini' ? ` - ` : ''; + const agents = multiaiEnsureAgents(chat); + const routes = multiaiState.routes || { apiMode: false, providers: {}, cli: {} }; + const personaNames = Object.keys(multiaiAllPersonas()); + const providerOptions = ['chatgpt', 'claude', 'gemini', 'perplexity', 'claude-cli', 'codex-cli']; + const inp = 'background: rgba(255,255,255,0.06); color: #fff; border: 1px solid rgba(255,255,255,0.15); border-radius: 6px; padding: 4px 6px; font-size: 0.76rem; box-sizing: border-box;'; + const cards = agents.map((a, i) => { + const isCli = !!MULTIAI_CLI_PROVIDERS[a.provider]; + const apiOk = routes.apiMode && (routes.providers[a.provider] || {}).hasKey; + const hints = isCli ? MULTIAI_MODEL_HINTS[a.provider] : (apiOk ? ((routes.providers[a.provider] || {}).models || []) : (a.provider === 'gemini' ? MULTIAI_MODEL_HINTS.gemini : [])); + const effortOn = isCli || apiOk || a.route === 'api'; return ` - - `; +
+
+ ${multiaiEscape(multiaiAgentCode(a))} + + + +
+
+ + +
+
+ + ${hints.map(h => ` + +
+ +
${multiaiEscape(multiaiRouteLabel(a))}
+
`; }).join(''); + const quick = ['chatgpt', 'claude', 'gemini', 'perplexity', 'claude-cli', 'codex-cli'].map(pv => { + const cliOk = pv === 'claude-cli' ? routes.cli.claude : (pv === 'codex-cli' ? routes.cli.codex : true); + return ``; + }).join(''); + const mode = routes.apiMode ? 'API mode ON in Settings β€” auto agents use API keys/models from there' : 'API mode OFF in Settings β€” auto agents use the browser tabs'; + box.style.display = 'block'; + box.innerHTML = ` +
${cards || '
No agents yet β€” add one below.
'}
+
${quick} + + ${multiaiEscape(mode)} +
`; } -function multiaiSetEngine(provider, engine) { +function multiaiAgentAdd(provider) { const chat = multiaiCurrentChat(); if (!chat) return; - chat.engines = chat.engines || {}; - if (!engine || engine === provider) delete chat.engines[provider]; else chat.engines[provider] = engine; + multiaiEnsureAgents(chat); + const isCli = !!MULTIAI_CLI_PROVIDERS[provider]; + const model = provider === 'codex-cli' ? 'gpt-5.5' : (provider === 'claude-cli' ? 'sonnet' : ''); + const a = { id: '', name: '', route: isCli ? 'cli' : 'auto', provider, model, effort: isCli ? 'high' : '' }; + a.name = multiaiDefaultAgentName(provider, model, a.effort); + chat.agents.push(a); + multiaiEnsureAgents(chat); multiaiPersist(); + multiaiRenderParticipants(chat); + multiaiRenderJudgeOptions(chat); multiaiRenderOptionsSummary(chat); } -function multiaiProviderLabel(p) { - const names = { chatgpt: 'ChatGPT', claude: 'Claude', gemini: 'Gemini', perplexity: 'Perplexity', codex: 'Codex', 'claude-code': 'Claude Code', human: 'You', external: 'External' }; - const base = String(p || '').split(':')[0]; - return names[base] || base; +function multiaiAgentDuplicate(i) { + const chat = multiaiCurrentChat(); + if (!chat || !chat.agents || !chat.agents[i]) return; + const src = chat.agents[i]; + const copy = Object.assign({}, src, { id: '', name: `${src.name} 2` }); + chat.agents.splice(i + 1, 0, copy); + multiaiEnsureAgents(chat); + multiaiPersist(); + multiaiRenderParticipants(chat); + multiaiRenderJudgeOptions(chat); + multiaiRenderOptionsSummary(chat); } -function multiaiToggleParticipant(provider, on) { +function multiaiAgentRemove(i) { const chat = multiaiCurrentChat(); - if (!chat) return; - chat.participants = chat.participants || []; - if (on && !chat.participants.includes(provider)) chat.participants.push(provider); - if (!on) chat.participants = chat.participants.filter(p => p !== provider); + if (!chat || !chat.agents) return; + chat.agents.splice(i, 1); + multiaiEnsureAgents(chat); multiaiPersist(); + multiaiRenderParticipants(chat); + multiaiRenderJudgeOptions(chat); multiaiRenderOptionsSummary(chat); - multiaiRenderPersonaAssignment(chat); } -// ---- Persona assignment (Classic mode) ------------------------------- -// A separate settings block, same idea as the crew role editor below: -// pick a persona per selected participant from the shared built-in + -// custom-folder library. chat.personaKeys remembers the picked name (for -// re-rendering the dropdown correctly); chat.personas holds the resolved -// text actually sent to the backend prompt builder. - -function multiaiRenderPersonaAssignment(chat) { - const box = document.getElementById('multiai-persona-assign'); - if (!box || !chat) return; - const participants = chat.participants || []; - if (!participants.length) { - box.innerHTML = '
Select participants above to assign personas.
'; - return; +function multiaiAgentField(i, field, value) { + const chat = multiaiCurrentChat(); + if (!chat || !chat.agents || !chat.agents[i]) return; + const a = chat.agents[i]; + const autoNamed = !a.name || a.name === multiaiDefaultAgentName(a.provider, a.model, a.effort); + a[field] = value; + if (field === 'provider') { + const isCli = !!MULTIAI_CLI_PROVIDERS[value]; + a.route = isCli ? 'cli' : (a.route === 'cli' ? 'auto' : a.route); + if (isCli) { a.model = value === 'codex-cli' ? 'gpt-5.5' : 'sonnet'; a.effort = a.effort || 'high'; } + else if (value !== 'gemini') a.model = ''; } - const personas = multiaiAllPersonas(); - const personaNames = Object.keys(personas); - chat.personas = chat.personas || {}; - chat.personaKeys = chat.personaKeys || {}; - box.innerHTML = participants.map(p => { - const current = chat.personaKeys[p] || ''; - const options = [''] - .concat(personaNames.map(name => ``)); - return ` - - `; - }).join(''); + if (field !== 'name' && autoNamed) { a.name = multiaiDefaultAgentName(a.provider, a.model, a.effort); a.id = ''; } + if (field === 'name') a.id = ''; // re-slug from the new name (so @mentions follow the label) + multiaiEnsureAgents(chat); + if (field === 'name') multiaiPersistSoon(); else multiaiPersist(); + if (field !== 'name' && field !== 'model') { multiaiRenderParticipants(chat); multiaiRenderJudgeOptions(chat); } + multiaiRenderOptionsSummary(chat); } -function multiaiSetParticipantPersona(provider, personaName) { +function multiaiAgentPersona(i, personaName) { const chat = multiaiCurrentChat(); - if (!chat) return; - chat.personaKeys = chat.personaKeys || {}; - chat.personas = chat.personas || {}; - if (!personaName) { - delete chat.personaKeys[provider]; - delete chat.personas[provider]; - } else { - chat.personaKeys[provider] = personaName; - chat.personas[provider] = multiaiAllPersonas()[personaName] || ''; - } + if (!chat || !chat.agents || !chat.agents[i]) return; + const a = chat.agents[i]; + if (!personaName) { delete a.personaKey; delete a.persona; } + else { a.personaKey = personaName; a.persona = multiaiAllPersonas()[personaName] || ''; } multiaiPersist(); } +async function multiaiRefreshRoutes() { + try { multiaiState.routes = await agentHub.multiaiRoutes(); } catch { /* older main */ } + const chat = multiaiCurrentChat(); + if (chat) { multiaiRenderParticipants(chat); multiaiRenderOptionsSummary(chat); } +} + +function multiaiProviderLabel(p) { + const names = { chatgpt: 'ChatGPT', claude: 'Claude', gemini: 'Gemini', perplexity: 'Perplexity', codex: 'Codex', 'claude-code': 'Claude Code', 'claude-cli': 'Claude Code', 'codex-cli': 'Codex', human: 'You', external: 'External' }; + const base = String(p || '').split(':')[0]; + return names[base] || base; +} + +// Legacy entry points kept for older call sites; agents carry personas now. +function multiaiToggleParticipant(provider, on) { + const chat = multiaiCurrentChat(); + if (!chat) return; + multiaiEnsureAgents(chat); + if (on) multiaiAgentAdd(provider); + else { chat.agents = chat.agents.filter(a => a.provider !== provider); multiaiEnsureAgents(chat); multiaiPersist(); multiaiRenderParticipants(chat); } +} + +function multiaiRenderPersonaAssignment() { /* personas live on the agent cards now */ } +function multiaiSetParticipantPersona() { /* see multiaiAgentPersona */ } +function multiaiSetEngine(provider, engine) { + const chat = multiaiCurrentChat(); + if (!chat) return; + multiaiEnsureAgents(chat); + const a = chat.agents.find(x => x.provider === provider); + if (a) { a.model = engine && engine !== provider ? String(engine).replace(/^gemini:/, '') : ''; multiaiPersist(); multiaiRenderParticipants(chat); } +} + function multiaiRenderJudgeOptions(chat) { const sel = document.getElementById('multiai-judge-provider'); if (!sel) return; + const agents = multiaiEnsureAgents(chat); const providers = multiaiState.enabledProviders.length ? multiaiState.enabledProviders : ['chatgpt', 'claude', 'gemini', 'perplexity']; - sel.innerHTML = providers.map(p => ``).join(''); - if (chat.judgeProvider) sel.value = chat.judgeProvider; + const opts = agents.map(a => ``) + .concat(providers.filter(p => !agents.some(a => a.provider === p)).map(p => ``)); + sel.innerHTML = opts.join(''); + if (chat.judgeProvider && [...sel.options].some(o => o.value === chat.judgeProvider)) sel.value = chat.judgeProvider; } // Live-persists the classic-mode fields as they change (not just at Run @@ -1093,9 +1242,9 @@ function multiaiAppendSystemNote(text) { feed.scrollTop = feed.scrollHeight; } -function multiaiSetThinking(provider, on) { +function multiaiSetThinking(provider, on, who) { if (!on) return; - multiaiAppendSystemNote(`${multiaiProviderLabel(provider)} is thinking…`); + multiaiAppendSystemNote(`${who || multiaiProviderLabel(provider)} is thinking…`); } function multiaiEscape(s) { @@ -1163,8 +1312,8 @@ async function multiaiRunRound() { showToast('Enter a topic first.'); return; } - if (!(chat.participants || []).length) { - showToast('Select at least one participant.'); + if (!multiaiEnsureAgents(chat).length) { + showToast('Add at least one agent.'); return; } const isFirstMessage = !(chat.messages || []).length; diff --git a/electron/preload.cjs b/electron/preload.cjs index a5ef76a..82202e5 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -92,6 +92,7 @@ contextBridge.exposeInMainWorld('agentHub', { multiaiSave: (data) => ipcRenderer.invoke('multiai-save', data), multiaiDeleteChat: (opts) => ipcRenderer.invoke('multiai-delete-chat', opts), multiaiRunning: () => ipcRenderer.invoke('multiai-running'), + multiaiRoutes: () => ipcRenderer.invoke('multiai-routes'), multiaiSummarize: (opts) => ipcRenderer.invoke('multiai-summarize', opts), multiaiListPersonas: () => ipcRenderer.invoke('multiai-list-personas'), multiaiOpenPersonasFolder: () => ipcRenderer.invoke('multiai-open-personas-folder'), diff --git a/electron/providers/cli.cjs b/electron/providers/cli.cjs new file mode 100644 index 0000000..5aaf57c --- /dev/null +++ b/electron/providers/cli.cjs @@ -0,0 +1,215 @@ +// Proxima β€” Local CLI providers for Multi-AI Chat. +// Runs a discussion turn through a locally installed, subscription-authenticated +// CLI instead of a browser tab or an API key: +// - claude-cli β†’ Claude Code: claude -p --model --effort … +// - codex-cli β†’ Codex: codex exec -m -c model_reasoning_effort= … +// Each call is one-shot, tool-less (as far as the CLI allows), runs in an +// empty scratch folder so no CLAUDE.md / AGENTS.md is picked up, and reads the +// prompt from stdin so long transcripts are fine on Windows. Concurrency per +// CLI is capped (protects the 5-hour usage windows). Calls are abortable: +// abort() kills the process tree. + +const { spawn, execFileSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const IS_WIN = process.platform === 'win32'; +const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; +const MAX_PARALLEL = { claude: 2, codex: 2 }; + +const EFFORT_MAP = { + claude: { minimal: 'low', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' }, + codex: { minimal: 'minimal', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'xhigh' }, +}; + +const DISCUSSION_SYSTEM_PROMPT = + 'You are one participant in a multi-agent discussion run through a local chat app. You have no tools and no ' + + 'filesystem; reply in plain text (light Markdown is fine). Follow the persona and instructions given in the user ' + + 'message. Do not narrate what you would do with tools; say what you think. Never run commands.'; + +// ---- availability ---------------------------------------------------------- + +const _avail = {}; +function which(bin) { + if (_avail[bin] !== undefined) return _avail[bin]; + try { + const out = execFileSync(IS_WIN ? 'where' : 'which', [bin], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }); + const first = out.split(/\r?\n/).map(l => l.trim()).filter(Boolean)[0] || null; + _avail[bin] = first; + } catch { + _avail[bin] = null; + } + return _avail[bin]; +} + +function availability() { + return { + claude: !!which('claude'), + codex: !!which('codex'), + }; +} + +function resetAvailabilityCache() { + for (const k of Object.keys(_avail)) delete _avail[k]; +} + +// ---- concurrency ---------------------------------------------------------- + +const _running = { claude: 0, codex: 0 }; +const _waiters = { claude: [], codex: [] }; + +function acquire(kind) { + if (_running[kind] < (MAX_PARALLEL[kind] || 2)) { _running[kind]++; return Promise.resolve(); } + return new Promise(res => _waiters[kind].push(res)).then(() => { _running[kind]++; }); +} + +function release(kind) { + _running[kind] = Math.max(0, _running[kind] - 1); + const next = _waiters[kind].shift(); + if (next) next(); +} + +// ---- process helpers ------------------------------------------------------ + +function scratchDir(base) { + const dir = path.join(base || os.tmpdir(), 'multiai-cli'); + fs.mkdirSync(dir, { recursive: true }); + const empty = path.join(dir, 'mcp_empty.json'); + if (!fs.existsSync(empty)) fs.writeFileSync(empty, '{"mcpServers":{}}', 'utf-8'); + return dir; +} + +function killTree(child) { + if (!child || child.killed) return; + try { + if (IS_WIN) spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true }); + else child.kill('SIGKILL'); + } catch { /* ignore */ } +} + +// Windows needs a shell to run the npm .cmd/.ps1 shims (claude); quote args. +function spawnCli(bin, args, cwd) { + if (IS_WIN) { + const q = (a) => (/[\s"]/.test(a) ? `"${String(a).replace(/"/g, '\\"')}"` : a); + return spawn(`${bin} ${args.map(q).join(' ')}`, { cwd, shell: true, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); + } + return spawn(bin, args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] }); +} + +// Runs a process with the prompt on stdin. Returns { promise, abort }. +function runProcess(kind, bin, args, { cwd, prompt, timeoutMs }) { + let child = null; + let aborted = false; + let timer = null; + const promise = (async () => { + await acquire(kind); + if (aborted) { release(kind); throw Object.assign(new Error('Cancelled by user'), { cancelled: true }); } + try { + return await new Promise((resolve, reject) => { + child = spawnCli(bin, args, cwd); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', d => { stdout += d.toString('utf8'); }); + child.stderr.on('data', d => { stderr += d.toString('utf8'); if (stderr.length > 200000) stderr = stderr.slice(-100000); }); + child.on('error', reject); + child.on('close', (code) => { + if (timer) clearTimeout(timer); + if (aborted) return reject(Object.assign(new Error('Cancelled by user'), { cancelled: true })); + resolve({ code, stdout, stderr }); + }); + timer = setTimeout(() => { aborted = true; killTree(child); reject(new Error(`${bin} timed out after ${Math.round((timeoutMs || DEFAULT_TIMEOUT_MS) / 1000)}s`)); }, timeoutMs || DEFAULT_TIMEOUT_MS); + try { + child.stdin.on('error', () => { /* EPIPE when the CLI exits early β€” the exit code will explain */ }); + child.stdin.write(prompt, 'utf8'); + child.stdin.end(); + } catch (e) { reject(e); } + }); + } finally { + release(kind); + } + })(); + return { + promise, + abort() { aborted = true; if (child) killTree(child); }, + }; +} + +function tailOf(s, n = 600) { + const t = String(s || '').trim(); + return t.length > n ? '…' + t.slice(-n) : t; +} + +// ---- Claude Code ------------------------------------------------------------ + +function runClaude({ model, effort, prompt, systemPrompt, cwd, timeoutMs }) { + const dir = scratchDir(cwd); + const sysFile = path.join(dir, `system-${process.pid}-${Date.now()}.txt`); + fs.writeFileSync(sysFile, systemPrompt || DISCUSSION_SYSTEM_PROMPT, 'utf-8'); + const args = ['-p', '--output-format', 'json', '--disallowedTools', '*', '--permission-mode', 'dontAsk', + '--strict-mcp-config', '--mcp-config', path.join(dir, 'mcp_empty.json'), '--max-turns', '3', '--system-prompt-file', sysFile]; + if (model) args.push('--model', String(model)); + const eff = effort && EFFORT_MAP.claude[effort]; + if (eff) args.push('--effort', eff); + const run = runProcess('claude', 'claude', args, { cwd: dir, prompt, timeoutMs }); + const promise = run.promise.then(({ code, stdout, stderr }) => { + try { fs.unlinkSync(sysFile); } catch { /* ignore */ } + let parsed = null; + const text = stdout.trim(); + try { parsed = JSON.parse(text); } catch { + // Sometimes the JSON is preceded by log lines: take the last {...} block. + const i = text.lastIndexOf('\n{'); + if (i !== -1) { try { parsed = JSON.parse(text.slice(i + 1)); } catch { /* ignore */ } } + } + if (parsed && typeof parsed === 'object') { + if (parsed.is_error || parsed.subtype === 'error' || (parsed.subtype && /error/.test(parsed.subtype) && !parsed.result)) { + throw new Error(`Claude Code: ${parsed.result || parsed.error || parsed.subtype || 'error'}`); + } + const result = typeof parsed.result === 'string' ? parsed.result : (parsed.result && parsed.result.text) || ''; + if (!result.trim()) throw new Error(`Claude Code returned no text (subtype ${parsed.subtype || '?'})`); + return { text: result, model: parsed.model || model || null, sessionId: parsed.session_id || null, costUsd: parsed.total_cost_usd || null }; + } + if (code !== 0) throw new Error(`Claude Code exited ${code}: ${tailOf(stderr || stdout)}`); + if (!text) throw new Error('Claude Code returned no output'); + return { text, model: model || null }; + }); + return { promise, abort: run.abort }; +} + +// ---- Codex -------------------------------------------------------------------- + +function runCodex({ model, effort, prompt, systemPrompt, cwd, timeoutMs }) { + const dir = scratchDir(cwd); + const outFile = path.join(dir, `codex-last-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.txt`); + // No "no tools" switch exists for codex exec; the sandbox helper is also + // missing on some Windows installs, so approvals/sandbox are bypassed and + // the scratch folder is empty. The system prompt tells the model not to + // run anything; it is prepended because exec has no system-prompt flag. + const args = ['exec', '--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check', '-C', dir, '--output-last-message', outFile]; + if (model) args.push('-m', String(model)); + const eff = effort && EFFORT_MAP.codex[effort]; + if (eff) args.push('-c', `model_reasoning_effort=${eff}`); + const full = `${systemPrompt || DISCUSSION_SYSTEM_PROMPT}\n\n${prompt}`; + const run = runProcess('codex', 'codex', args, { cwd: dir, prompt: full, timeoutMs }); + const promise = run.promise.then(({ code, stdout, stderr }) => { + let text = ''; + try { text = fs.readFileSync(outFile, 'utf8').trim(); fs.unlinkSync(outFile); } catch { /* no file */ } + if (!text) { + const all = `${stdout}\n${stderr}`; + const m = /"type":"error"[^\n]*"message":"((?:[^"\\]|\\.)*)"/.exec(all); + const why = m ? m[1].replace(/\\"/g, '"').replace(/\\\\/g, '\\') : tailOf(stderr || stdout); + throw new Error(`Codex${code ? ` exited ${code}` : ''}: ${why || 'no output'}`); + } + return { text, model: model || null }; + }); + return { promise, abort: run.abort }; +} + +function run(provider, opts) { + const base = String(provider || '').split(':')[0]; + if (base === 'claude-cli') return runClaude(opts); + if (base === 'codex-cli') return runCodex(opts); + return { promise: Promise.reject(new Error(`Unknown CLI provider: ${provider}`)), abort() { } }; +} + +module.exports = { run, runClaude, runCodex, availability, resetAvailabilityCache, which, EFFORT_MAP, DISCUSSION_SYSTEM_PROMPT, MAX_PARALLEL }; diff --git a/src/mcp/tools-multiai.js b/src/mcp/tools-multiai.js index 8f82668..85643a2 100644 --- a/src/mcp/tools-multiai.js +++ b/src/mcp/tools-multiai.js @@ -9,7 +9,7 @@ function summarizeChat(c) { const when = c.updatedAt ? new Date(c.updatedAt).toISOString() : ''; return `${c.id} "${c.title || 'Untitled'}" [${c.mode || 'classic'}${c.strategy && c.mode === 'orchestrated' ? '/' + c.strategy : ''}] ` + - `${(c.participants || []).join(', ') || 'no participants'} Β· ${c.messageCount || 0} msgs Β· cycle ${c.cycle || 0}${when ? ' Β· ' + when : ''}`; + `${(c.agents && c.agents.length ? c.agents : (c.participants || [])).join(', ') || 'no agents'} Β· ${c.messageCount || 0} msgs Β· cycle ${c.cycle || 0}${when ? ' Β· ' + when : ''}`; } function renderMessages(messages) { @@ -41,7 +41,7 @@ export function register(server, deps) { server.registerTool('multiai_list_chats', { title: 'Multi-AI Chat: list chats', - description: 'List the chats in Proxima\'s Multi-AI Chat tab (id, title, mode, participants, message count, whether a run is in progress). Use the id with the other multiai_* tools.', + description: 'List the chats in Proxima\'s Multi-AI Chat tab (id, title, mode, agents, message count, whether a run is in progress). Use the id with the other multiai_* tools.', inputSchema: {}, annotations: Object.freeze({ readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }), }, async () => { @@ -69,20 +69,29 @@ export function register(server, deps) { }, async ({ chatId, since, last }) => { try { const { chat } = await ipc('multiaiGetChat', { chatId, since, last }); + const agents = (chat.agents || []).map(a => `${a.name} [${a.id}: ${a.route}${a.model ? ' ' + a.model : ''}${a.effort ? ' ' + a.effort : ''}]`); const header = `"${chat.title}" [${chat.mode}${chat.mode === 'orchestrated' ? '/' + (chat.strategy || 'crew') : ''}, ${chat.discussMode || 'discuss'}, context=${chat.contextMode || 'bounded'}] ` + - `participants: ${(chat.participants || []).join(', ') || 'β€”'} Β· ${chat.totalMessages} messages total Β· cycle ${chat.cycle || 0} Β· ${chat.running ? 'RUN IN PROGRESS' : 'idle'}`; + `agents: ${agents.join(', ') || (chat.participants || []).join(', ') || 'β€”'} Β· ${chat.totalMessages} messages total Β· cycle ${chat.cycle || 0} Β· ${chat.running ? 'RUN IN PROGRESS' : 'idle'}`; return toolResponse(`${header}\n\n${renderMessages(chat.messages)}`); } catch (err) { return toolError(err); } }); server.registerTool('multiai_create_chat', { title: 'Multi-AI Chat: create a chat', - description: 'Create a new Multi-AI chat. Optionally give it an opening message (topic), participants (chatgpt, claude, gemini, perplexity), discuss/debate mode and a context mode (bounded | delta | full).', + description: 'Create a new Multi-AI chat. Optionally give it an opening message (topic), agents, discuss/debate mode and a context mode (bounded | delta | full). An agent is a name + provider + route + model + effort: several agents may share one provider ("GPT high" and "GPT low"). Routes: auto (follows the app\'s Settings β€” API mode on with a key β†’ API, else the browser tab), browser, api, cli (claude-cli = Claude Code, codex-cli = Codex, run locally on the user\'s subscription). `participants` is the legacy shorthand: one browser-tab agent per provider.', inputSchema: { title: z.string().optional().describe('Chat title (defaults to the first words of the topic)'), topic: z.string().optional().describe('Opening message / brief, recorded as the first human message'), who: z.string().optional().describe('Name to record the opening message under (default: You)'), - participants: z.array(z.string()).optional().describe('Browser AIs that take turns, e.g. ["chatgpt","claude"]'), + participants: z.array(z.string()).optional().describe('Legacy shorthand: one agent per provider, e.g. ["chatgpt","claude"] (ignored when `agents` is given)'), + agents: z.array(z.object({ + name: z.string().optional().describe('Display name, e.g. "Opus Β· max" (default: provider Β· model Β· effort)'), + provider: z.enum(['chatgpt', 'claude', 'gemini', 'perplexity', 'claude-cli', 'codex-cli']), + route: z.enum(['auto', 'browser', 'api', 'cli']).optional().describe('auto (default) follows Settings; cli is implied for claude-cli/codex-cli'), + model: z.string().optional().describe('API model id, CLI alias (opus/sonnet/gpt-5.5) or Gemini engine (3.1-pro); blank = Settings default'), + effort: z.enum(['minimal', 'low', 'medium', 'high', 'xhigh', 'max']).optional().describe('Reasoning effort β€” API and CLI routes only'), + persona: z.string().optional().describe('Persona / system instructions for this agent'), + })).optional().describe('The agents that take turns, in order'), discussMode: z.enum(['discuss', 'debate']).optional(), contextMode: z.enum(['bounded', 'delta', 'full']).optional(), brevity: z.string().optional().describe('e.g. "max 4 sentences" or "as long as needed"'), @@ -97,7 +106,7 @@ export function register(server, deps) { }, async (args) => { try { const { chat } = await ipc('multiaiCreateChat', args); - return toolResponse({ success: true, chatId: chat.id, title: chat.title, participants: chat.participants }); + return toolResponse({ success: true, chatId: chat.id, title: chat.title, agents: (chat.agents || []).map(a => ({ id: a.id, name: a.name, provider: a.provider, route: a.route, model: a.model || undefined, effort: a.effort || undefined })) }); } catch (err) { return toolError(err); } }); @@ -120,7 +129,7 @@ export function register(server, deps) { server.registerTool('multiai_run_round', { title: 'Multi-AI Chat: run round(s)', - description: 'Have the chat\'s browser-AI participants take their turns. Optionally post a message first (as the human, or under your name). Returns immediately by default β€” the run continues in the app; call multiai_wait_for_run to collect the replies. Mention participants in the text to direct the order ("@claude kick off, others react", "only @gemini …").', + description: 'Have the chat\'s agents take their turns (browser tabs, API keys or local CLIs, per each agent\'s route). Optionally post a message first (as the human, or under your name). Returns immediately by default β€” the run continues in the app; call multiai_wait_for_run to collect the replies. Mention agents by id or name in the text to direct the order ("@claude kick off, others react", "only @opus-max …").', inputSchema: { chatId: z.string().describe('Chat id'), text: z.string().optional().describe('Message to post before the round (recorded as a human/agent instruction β€” starts a new cycle)'), diff --git a/tests/byok/providers.test.js b/tests/byok/providers.test.js index cf3e849..dadbc58 100644 --- a/tests/byok/providers.test.js +++ b/tests/byok/providers.test.js @@ -134,3 +134,27 @@ test('openai-compatible aborts immediately on fatal 401 (no model fan-out)', asy await assert.rejects(() => compatible.call('key', 'hi', { provider: 'deepseek' }), /invalid key|401/i); assert.equal(stub.calls, 1, 'a 401 must abort after ONE attempt (no retry, no model fallback)'); }); + +test('openai maps reasoningEffort to reasoning_effort (xhigh/max clamp to high) and widens the completion budget', async () => { + reset(); + stub.resolve = { choices: [{ message: { content: 'ok' } }], model: 'gpt-5.5' }; + await openai.call('sk-x', 'hi', { reasoningEffort: 'low' }); + assert.equal(stub.last.body.reasoning_effort, 'low'); + assert.ok(stub.last.body.max_completion_tokens >= 16000); + await openai.call('sk-x', 'hi', { reasoningEffort: 'max', maxTokens: 500 }); + assert.equal(stub.last.body.reasoning_effort, 'high'); + assert.equal(stub.last.body.max_completion_tokens, 500, 'an explicit maxTokens is respected'); + await openai.call('sk-x', 'hi', {}); + assert.equal(stub.last.body.reasoning_effort, undefined); +}); + +test('anthropic maps reasoningEffort to an extended-thinking budget, keeps max_tokens above it, and ignores thinking blocks in the text', async () => { + reset(); + stub.resolve = { content: [{ type: 'thinking', thinking: 'hmm' }, { type: 'text', text: 'answer' }], model: 'claude-x' }; + const r = await anthropic.call('sk-x', 'hi', { reasoningEffort: 'high' }); + assert.deepEqual(stub.last.body.thinking, { type: 'enabled', budget_tokens: 12000 }); + assert.ok(stub.last.body.max_tokens > 12000); + assert.equal(r.text, 'answer'); + await anthropic.call('sk-x', 'hi', {}); + assert.equal(stub.last.body.thinking, undefined); +}); diff --git a/tests/electron/ipc/multiai-core.test.js b/tests/electron/ipc/multiai-core.test.js index 5ae0bcc..be7bdb0 100644 --- a/tests/electron/ipc/multiai-core.test.js +++ b/tests/electron/ipc/multiai-core.test.js @@ -252,3 +252,59 @@ test('charter mode: claim-tag rules and red-team block appear only when asked; h assert.equal(core.redTeamFor(parts, 2), 'claude'); assert.equal(core.redTeamFor(['solo'], 1), null); }); + +// ---- agents ---------------------------------------------------------------- + +test('normalizeAgents: legacy participants migrate, ids are unique slugs, CLI providers force the cli route', () => { + const legacy = core.normalizeAgents({ participants: ['chatgpt', 'gemini:3.1-pro'], engines: { chatgpt: 'chatgpt' } }); + assert.deepEqual(legacy.map(a => [a.id, a.name, a.provider, a.model, a.route, a.code]), + [['chatgpt', 'ChatGPT', 'chatgpt', '', 'auto', 'CG'], ['gemini', 'Gemini', 'gemini', 'gemini:3.1-pro', 'auto', 'GM']]); + const agents = core.normalizeAgents({ agents: [ + { provider: 'claude-cli', model: 'opus', effort: 'max' }, + { name: 'Bull', provider: 'chatgpt', route: 'api', effort: 'low' }, + { name: 'Bull', provider: 'chatgpt', route: 'nonsense', effort: 'silly' }, + 'perplexity', + ] }); + assert.deepEqual(agents.map(a => a.id), ['claude-code-opus-max', 'bull', 'bull-2', 'perplexity']); + assert.equal(agents[0].route, 'cli'); + assert.equal(agents[0].name, 'Claude Code Β· opus Β· max'); + assert.equal(agents[0].code, 'CC'); + assert.equal(agents[1].code, 'BU'); + assert.deepEqual([agents[2].route, agents[2].effort], ['auto', '']); +}); + +test('findAgent: id, name (spaces/punctuation dropped), unique provider; ambiguous provider is null', () => { + const agents = core.normalizeAgents({ agents: [ + { name: 'GPT high', provider: 'chatgpt', effort: 'high' }, { name: 'GPT low', provider: 'chatgpt', effort: 'low' }, { provider: 'claude' }, + ] }); + assert.equal(core.findAgent(agents, 'gpt-high').name, 'GPT high'); + assert.equal(core.findAgent(agents, 'gptlow').name, 'GPT low'); + assert.equal(core.findAgent(agents, 'GPT Low').name, 'GPT low'); + assert.equal(core.findAgent(agents, 'claude').id, 'claude'); + assert.equal(core.findAgent(agents, 'chatgpt'), null, 'two ChatGPT agents: a bare provider name is ambiguous'); +}); + +test('parseMentions / parseHandoff work on agents and return the same objects', () => { + const agents = core.normalizeAgents({ agents: [{ name: 'Opus max', provider: 'claude-cli', model: 'opus', effort: 'max' }, { name: 'Sonnet low', provider: 'claude-cli', model: 'sonnet', effort: 'low' }, { provider: 'gemini' }] }); + const m = core.parseMentions('only @sonnet-low and @gemini answer', agents); + assert.deepEqual(m.order.map(a => a.id), ['sonnet-low', 'gemini']); + assert.ok(m.order[0] === agents[1], 'returns the original agent objects'); + assert.equal(core.parseHandoff('fine.\n\nHANDOFF β†’ Opus max: verify it', agents), agents[0]); + assert.equal(core.parseHandoff('HANDOFF -> @gemini', agents).id, 'gemini'); +}); + +test('buildClassicPromptWithMeta with agents: names the seat, lists the others by name, stances by seat index; delta excludes own turns by agentId', () => { + const agents = core.normalizeAgents({ agents: [{ name: 'Bull', provider: 'chatgpt', effort: 'high' }, { name: 'Bear', provider: 'chatgpt', effort: 'low' }] }); + const r = core.buildClassicPromptWithMeta({ topic: 'T', mode: 'debate', participants: agents, agent: agents[1], messages: [] }); + assert.ok(r.prompt.includes('You are Bear, one participant in a multi-AI conversation with Bull')); + assert.ok(/against/i.test(r.prompt), 'second seat argues against'); + const messages = [ + msg({ id: 'u1', role: 'user', provider: 'human', who: 'You', text: 'go' }), + msg({ id: 'a1', who: 'Bull', provider: 'chatgpt', agentId: 'bull', text: 'buy' }), + msg({ id: 'a2', who: 'Bear', provider: 'chatgpt', agentId: 'bear', text: 'sell' }), + msg({ id: 'a3', who: 'Bull', provider: 'chatgpt', agentId: 'bull', text: 'buy more' }), + ]; + const d = core.buildClassicPromptWithMeta({ topic: 'T', mode: 'discuss', participants: agents, agent: agents[1], messages, contextMode: 'delta', delta: { sinceId: 'a2' } }); + assert.ok(d.prompt.includes('buy more'), 'Bull\'s newer turn is shown to Bear'); + assert.ok(!d.prompt.includes('] Bear: sell'), 'Bear\'s own turn is not echoed back'); +}); diff --git a/tests/electron/ipc/multiai-handlers.test.js b/tests/electron/ipc/multiai-handlers.test.js index 05de0be..f59849a 100644 --- a/tests/electron/ipc/multiai-handlers.test.js +++ b/tests/electron/ipc/multiai-handlers.test.js @@ -17,6 +17,7 @@ const require = createRequire(import.meta.url); const userData = fs.mkdtempSync(path.join(os.tmpdir(), 'multiai-handlers-')); const handlers = new Map(); +const delay = (ms) => new Promise(r => setTimeout(r, ms)); const sent = []; // every webContents.send(channel, payload) const providerCalls = []; // every sendMessageToProvider(provider, prompt, ..., sessionId) let replyFn = async (provider, prompt) => `reply from ${provider}`; @@ -39,10 +40,46 @@ const fakeSender = { }, }; +// Fake local CLIs (Claude Code / Codex) and a fake BYOK subsystem. +const cliCalls = []; +let cliReplyFn = async (provider, opts) => `${provider} says ${opts.model || 'default'}/${opts.effort || 'none'}`; +const fakeCli = { + availability: () => ({ claude: true, codex: true }), + resetAvailabilityCache() { }, + run(provider, opts) { + const call = { provider, opts, aborted: false }; + cliCalls.push(call); + let rejectFn; + const promise = new Promise((resolve, reject) => { + rejectFn = reject; + Promise.resolve().then(() => cliReplyFn(provider, opts)).then(text => resolve({ text, model: opts.model || null }), reject); + }); + return { promise, abort() { call.aborted = true; rejectFn(Object.assign(new Error('Cancelled by user'), { cancelled: true })); } }; + }, +}; +const byokState = { enabled: false, keys: { chatgpt: 'sk-openai', claude: 'sk-ant' }, selected: { chatgpt: 'gpt-5.5', claude: 'claude-sonnet-5' } }; +const byokCalls = []; +const fakeByok = { + keys: { + isEnabled: () => byokState.enabled, + hasKey: (p) => !!byokState.keys[p], + getKey: (p) => byokState.keys[p] || null, + getSelectedModel: (p) => byokState.selected[p] || null, + getModels: (p) => (byokState.selected[p] ? [{ id: byokState.selected[p], enabled: true }] : []), + }, + models: { DEFAULT_MODELS: { chatgpt: 'gpt-5.5', claude: 'claude-sonnet-5' } }, + async callProvider(provider, key, messages, options) { + byokCalls.push({ provider, key, messages, options }); + await delay(5); + return { text: `${provider} api says ${options.modelId || 'selected'}/${options.reasoningEffort || 'none'}`, model: options.modelId || byokState.selected[provider] }; + }, +}; + const origLoad = Module._load; Module._load = function (request, parent, isMain) { if (request === 'electron') return fakeElectron; if (request.endsWith('providers/sender.cjs')) return fakeSender; + if (request.endsWith('providers/cli.cjs')) return fakeCli; return origLoad.apply(this, arguments); }; const { registerMultiAiHandlers } = require('../../../electron/ipc/multiai.cjs'); @@ -53,6 +90,7 @@ registerMultiAiHandlers({ mainWindow: () => ({ isDestroyed: () => false, webContents: { send: (ch, p) => sent.push({ ch, p }) } }), loadSettings: () => ({ providers: { chatgpt: { enabled: true }, claude: { enabled: true }, gemini: { enabled: true }, perplexity: { enabled: false } } }), registerRouteExtension: (fn) => routeExtensions.push(fn), + byok: fakeByok, }); const invoke = (ch, payload) => handlers.get(ch)({}, payload); @@ -69,8 +107,6 @@ function userMsg(chatId, text, cycle = 1) { function messagesOf(chatId) { return store.getChat(chatId).messages; } -const delay = (ms) => new Promise(r => setTimeout(r, ms)); - // ---- tests ----------------------------------------------------------------- test('run-round: records one turn per participant per round, on per-chat session ids, and emits them', async () => { @@ -83,7 +119,7 @@ test('run-round: records one turn per participant per round, on per-chat session const turns = messagesOf(chat.id).filter(m => m.role === 'assistant'); assert.equal(turns.length, 4); assert.deepEqual(turns.map(t => t.round), [1, 1, 2, 2]); - assert.ok(providerCalls.every(c => c.conversationId === `multiai:${chat.id}:1`), 'every call rides the chat-specific thread'); + assert.ok(providerCalls.every(c => c.conversationId === `multiai:${chat.id}:${c.provider}:1`), 'every call rides the chat- and agent-specific thread'); assert.ok(sent.filter(e => e.ch === 'multiai-message').length >= 4, 'renderer is notified of each turn'); assert.ok(sent.some(e => e.ch === 'multiai-done' && e.p.chatId === chat.id)); // second turn of a participant sees the first round in its prompt @@ -155,8 +191,8 @@ test('run-round: provider threads rotate after providerResetEvery turns', async await invoke('multiai-run-round', { chatId: chat.id, rounds: core.DEFAULTS.providerResetEvery + 2 }); const ids = providerCalls.map(c => c.conversationId); assert.equal(new Set(ids).size, 2, 'exactly one rotation'); - assert.equal(ids[0], `multiai:${chat.id}:1`); - assert.equal(ids[ids.length - 1], `multiai:${chat.id}:2`); + assert.equal(ids[0], `multiai:${chat.id}:chatgpt:1`); + assert.equal(ids[ids.length - 1], `multiai:${chat.id}:chatgpt:2`); }); test('run-round: a provider error is recorded as an error message and the round continues', async () => { @@ -399,7 +435,7 @@ test('stop: aborts the in-flight provider call and skips queued ones', async () await delay(20); await invoke('multiai-stop-run', { chatId: chat.id }); await running; - assert.deepEqual(fakeSender.aborted, [`chatgpt|multiai:${chat.id}:1`], 'exactly the active call was aborted, by its session id'); + assert.deepEqual(fakeSender.aborted, [`chatgpt|multiai:${chat.id}:chatgpt:1`], 'exactly the active call was aborted, by its session id'); assert.equal(providerCalls.length, 1, 'no further participant was called after Stop'); release('late'); delete fakeSender.abortActive; delete fakeSender.aborted; @@ -437,3 +473,90 @@ test('charter mode: a HANDOFF β†’ in a local agent\'s posted message picks who s await invoke('multiai-run-round', { chatId: chat.id, rounds: 1 }); assert.deepEqual(providerCalls.map(c => c.provider), ['gemini', 'chatgpt', 'claude']); }); + + +test('agents: same provider twice with different models/efforts, API route when Settings has API mode on', async () => { + providerCalls.length = 0; byokCalls.length = 0; + byokState.enabled = true; + const chat = newChat({ + participants: [], + agents: [ + { name: 'GPT high', provider: 'chatgpt', model: 'gpt-5.5', effort: 'high' }, + { name: 'GPT low', provider: 'chatgpt', model: 'gpt-5.5', effort: 'low' }, + { name: 'Claude tab', provider: 'claude', route: 'browser' }, + ], + stopWhenAllAgree: false, + }); + userMsg(chat.id, '@gpt-low you start'); + await invoke('multiai-run-round', { chatId: chat.id, rounds: 1, latestText: '@gpt-low you start' }); + assert.equal(byokCalls.length, 2, 'both GPT agents went through the API'); + assert.deepEqual(byokCalls.map(c => c.options.reasoningEffort), ['low', 'high'], 'mention put GPT low first; efforts are per agent'); + assert.ok(byokCalls.every(c => c.provider === 'chatgpt' && c.key === 'sk-openai' && c.options.modelId === 'gpt-5.5')); + assert.equal(providerCalls.length, 1, 'the explicit browser-route agent used the tab'); + assert.equal(providerCalls[0].provider, 'claude'); + const msgs = messagesOf(chat.id).filter(m => m.role === 'assistant'); + assert.deepEqual(msgs.map(m => m.who), ['GPT low', 'GPT high', 'Claude tab']); + assert.deepEqual(msgs.map(m => m.code), ['GL', 'GH', 'CT']); + assert.ok(msgs[0].agentId && msgs[0].route === 'api' && msgs[2].route === 'browser'); + assert.ok(byokCalls[1].messages[0].content.includes('GPT low'), 'the second agent sees the first by name'); + byokState.enabled = false; +}); + +test('agents: with API mode off in Settings, auto agents fall back to the browser tab; model becomes the Gemini engine', async () => { + providerCalls.length = 0; byokCalls.length = 0; + byokState.enabled = false; + const chat = newChat({ participants: [], agents: [{ name: 'GPT', provider: 'chatgpt', effort: 'high' }, { name: 'Gem pro', provider: 'gemini', model: '3.1-pro' }], stopWhenAllAgree: false }); + userMsg(chat.id, 'go'); + await invoke('multiai-run-round', { chatId: chat.id, rounds: 1 }); + assert.equal(byokCalls.length, 0); + assert.deepEqual(providerCalls.map(c => c.provider), ['chatgpt', 'gemini:3.1-pro']); + assert.ok(providerCalls[0].conversationId.includes(':gpt:')); +}); + +test('agents: CLI agents run through the local CLI with model + effort; Stop kills the process', async () => { + cliCalls.length = 0; + cliReplyFn = async (p, o) => `${p} ${o.model}/${o.effort}`; + const chat = newChat({ participants: [], agents: [{ name: 'Opus Β· high', provider: 'claude-cli', model: 'opus', effort: 'high' }, { name: 'Codex 5.5', provider: 'codex-cli', model: 'gpt-5.5', effort: 'medium' }], stopWhenAllAgree: false }); + userMsg(chat.id, 'cli test'); + await invoke('multiai-run-round', { chatId: chat.id, rounds: 1 }); + assert.deepEqual(cliCalls.map(c => [c.provider, c.opts.model, c.opts.effort]), [['claude-cli', 'opus', 'high'], ['codex-cli', 'gpt-5.5', 'medium']]); + const msgs = messagesOf(chat.id).filter(m => m.role === 'assistant'); + assert.deepEqual(msgs.map(m => m.who), ['Opus Β· high', 'Codex 5.5']); + assert.deepEqual(msgs.map(m => m.code), ['OH', 'C5']); + assert.ok(cliCalls[1].opts.prompt.includes('Opus Β· high'), 'second CLI agent sees the first'); + + // Stop mid-call + cliCalls.length = 0; + let release; + cliReplyFn = () => new Promise(r => { release = r; }); + store.updateChat(chat.id, { cycle: 2 }); + userMsg(chat.id, 'again', 2); + const running = invoke('multiai-run-round', { chatId: chat.id, rounds: 1 }); + await delay(20); + await invoke('multiai-stop-run', { chatId: chat.id }); + await running; + assert.equal(cliCalls[0].aborted, true, 'the in-flight CLI process was killed'); + assert.equal(cliCalls.length, 1, 'the queued agent never started'); + release && release('late'); +}); + +test('routes: multiai-routes reports Settings API mode, selected models and CLI availability', async () => { + byokState.enabled = true; + const r = await invoke('multiai-routes'); + assert.equal(r.apiMode, true); + assert.equal(r.providers.chatgpt.hasKey, true); + assert.equal(r.providers.chatgpt.model, 'gpt-5.5'); + assert.equal(r.providers.gemini.hasKey, false); + assert.deepEqual(r.cli, { claude: true, codex: true }); + byokState.enabled = false; +}); + +test('legacy participants still work and judge can be an agent id', async () => { + providerCalls.length = 0; + replyFn = async () => 'verdict'; + const chat = newChat({ participants: ['chatgpt', 'claude'], judgeProvider: 'claude' }); + userMsg(chat.id, 'x'); + await invoke('multiai-judge', { chatId: chat.id, judgeProvider: 'claude', scope: 'all' }); + const j = messagesOf(chat.id).find(m => m.role === 'judge'); + assert.ok(j && j.who === 'Judge Β· Claude'); +}); diff --git a/tests/mcp/tools-multiai.test.js b/tests/mcp/tools-multiai.test.js index a514d24..5e69402 100644 --- a/tests/mcp/tools-multiai.test.js +++ b/tests/mcp/tools-multiai.test.js @@ -78,3 +78,21 @@ test('multiai tools: IPC errors surface as tool errors', async () => { assert.equal(res.isError, true); assert.match(textOf(res), /Chat not found/); }); + +test('multiai_create_chat: forwards agents (provider/route/model/effort) and reports them; read_chat shows resolved routes', async () => { + const agents = [ + { id: 'opus-max', name: 'Opus Β· max', code: 'OM', provider: 'claude-cli', route: 'cli', model: 'opus', effort: 'max' }, + { id: 'gpt-low', name: 'GPT low', code: 'GL', provider: 'chatgpt', route: 'api', model: 'gpt-5.4', effort: 'low' }, + ]; + const h = harness((action) => action === 'multiaiCreateChat' + ? { success: true, chat: { id: 'chat_9', title: 'Agents', agents } } + : { success: true, chat: Object.assign({}, chat, { agents, participants: undefined, messages: [] }) }); + const created = textOf(await h.tools.get('multiai_create_chat').handler({ + topic: 'x', agents: [{ provider: 'claude-cli', model: 'opus', effort: 'max', name: 'Opus Β· max' }, { provider: 'chatgpt', route: 'api', model: 'gpt-5.4', effort: 'low', name: 'GPT low' }], + })); + assert.deepEqual(h.ipcCalls[0][2].agents.map(a => a.provider), ['claude-cli', 'chatgpt']); + assert.match(created, /"id": "opus-max"/); + assert.match(created, /"effort": "low"/); + const read = textOf(await h.tools.get('multiai_read_chat').handler({ chatId: 'chat_9' })); + assert.match(read, /agents: Opus Β· max \[opus-max: cli opus max\], GPT low \[gpt-low: api gpt-5\.4 low\]/); +}); From 6f408a658a455fd3f5769c9628571b7699ced41d Mon Sep 17 00:00:00 2001 From: Eugene Turetsky Date: Thu, 3 Sep 2026 19:03:41 -0400 Subject: [PATCH 08/67] Multi-AI: reference labels use the agent's code; unique codes per chat; status line follows agent edits Found in the live CLI test: two Claude Code seats both labelled 0101CL. Messages now carry the agent code (SL/SH, CX for Codex, CG2 for a second ChatGPT seat) and refLabel prefers it; claude-cli/codex-cli get CC/CX. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ACdKJmXfzvwfCCLneCWd12 --- electron/ipc/multiai-core.cjs | 18 ++++++++++---- electron/multiai-renderer.js | 27 +++++++++++++++------ tests/electron/ipc/multiai-core.test.js | 7 ++++-- tests/electron/ipc/multiai-handlers.test.js | 4 +-- 4 files changed, 39 insertions(+), 17 deletions(-) diff --git a/electron/ipc/multiai-core.cjs b/electron/ipc/multiai-core.cjs index 69f360e..1eb8f09 100644 --- a/electron/ipc/multiai-core.cjs +++ b/electron/ipc/multiai-core.cjs @@ -56,7 +56,9 @@ function slugify(s) { function agentCode(agent) { const name = String(agent.name || ''); const base = String(agent.provider || '').split(':')[0]; - if (name && PROVIDER_LABELS[base] && name === PROVIDER_LABELS[base]) return PROVIDER_CODES[base]; + const label = PROVIDER_LABELS[base]; + // "ChatGPT", "ChatGPT Β· high", "Codex Β· gpt-5.5 Β· medium" β†’ the provider's code + if (name && label && (name === label || name.startsWith(label + ' ') || name.startsWith(label + 'Β·'))) return PROVIDER_CODES[base]; const words = name.split(/[^A-Za-z0-9]+/).filter(Boolean); if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase(); if (words.length === 1 && words[0].length >= 2) return words[0].slice(0, 2).toUpperCase(); @@ -102,6 +104,13 @@ function normalizeAgents(chat) { a.code = agentCode(a); out.push(a); } + // Label codes must tell agents apart: a second "CG" becomes "CG2". + const codes = new Set(); + for (const a of out) { + let code = a.code; let n = 2; + while (codes.has(code)) code = `${a.code}${n++}`; + a.code = code; codes.add(code); + } return out; } @@ -126,12 +135,12 @@ const KNOWN_PROVIDERS = ['chatgpt', 'claude', 'gemini', 'perplexity']; const PROVIDER_LABELS = { chatgpt: 'ChatGPT', claude: 'Claude', gemini: 'Gemini', perplexity: 'Perplexity', - codex: 'Codex', 'claude-code': 'Claude Code', human: 'You', + codex: 'Codex', 'claude-code': 'Claude Code', 'claude-cli': 'Claude Code', 'codex-cli': 'Codex', human: 'You', }; const PROVIDER_CODES = { chatgpt: 'CG', claude: 'CL', gemini: 'GM', perplexity: 'PX', - codex: 'CX', 'claude-code': 'CC', human: 'US', external: 'EX', + codex: 'CX', 'claude-code': 'CC', 'claude-cli': 'CC', 'codex-cli': 'CX', human: 'US', external: 'EX', }; function providerLabel(p) { @@ -164,8 +173,7 @@ function refLabel(msg) { else if (msg.role === 'summary') code = 'SM'; else if (msg.role === 'system') code = 'SY'; else if (msg.role === 'error') code = 'ER'; - else if (msg.role === 'pass') code = providerCode(msg.provider); - else code = providerCode(msg.provider); + else code = msg.code || providerCode(msg.provider); return `${String(c).padStart(2, '0')}${String(r).padStart(2, '0')}${code}`; } diff --git a/electron/multiai-renderer.js b/electron/multiai-renderer.js index 17d048f..40c1137 100644 --- a/electron/multiai-renderer.js +++ b/electron/multiai-renderer.js @@ -76,7 +76,7 @@ function multiaiDefaultChat() { // the ledger's R# filter key; the raw id itself is never shown as its own // column, per design. -const MULTIAI_PROVIDER_CODE = { chatgpt: 'CG', claude: 'CL', gemini: 'GM', perplexity: 'PX', codex: 'CX', 'claude-code': 'CC', human: 'US', external: 'EX' }; +const MULTIAI_PROVIDER_CODE = { chatgpt: 'CG', claude: 'CL', gemini: 'GM', perplexity: 'PX', codex: 'CX', 'claude-code': 'CC', 'claude-cli': 'CC', 'codex-cli': 'CX', human: 'US', external: 'EX' }; function multiaiGenMsgId() { return 'm' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); @@ -897,14 +897,20 @@ function multiaiSlug(s) { return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); } -function multiaiAgentCode(agent) { +function multiaiAgentCode(agent, taken) { const name = String(agent.name || ''); const base = String(agent.provider || '').split(':')[0]; - if (name && multiaiProviderLabel(base) === name) return multiaiProviderCode(base); - const words = name.split(/[^A-Za-z0-9]+/).filter(Boolean); - if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase(); - if (words.length === 1 && words[0].length >= 2) return words[0].slice(0, 2).toUpperCase(); - return multiaiProviderCode(base); + const label = multiaiProviderLabel(base); + let code; + if (name && (name === label || name.startsWith(label + ' ') || name.startsWith(label + 'Β·'))) code = multiaiProviderCode(base); + else { + const words = name.split(/[^A-Za-z0-9]+/).filter(Boolean); + if (words.length >= 2) code = (words[0][0] + words[1][0]).toUpperCase(); + else if (words.length === 1 && words[0].length >= 2) code = words[0].slice(0, 2).toUpperCase(); + else code = multiaiProviderCode(base); + } + if (taken) { let c = code, n = 2; while (taken.has(c)) c = `${code}${n++}`; taken.add(c); return c; } + return code; } function multiaiDefaultAgentName(provider, model, effort) { @@ -968,6 +974,7 @@ function multiaiRenderParticipants(chat) { const personaNames = Object.keys(multiaiAllPersonas()); const providerOptions = ['chatgpt', 'claude', 'gemini', 'perplexity', 'claude-cli', 'codex-cli']; const inp = 'background: rgba(255,255,255,0.06); color: #fff; border: 1px solid rgba(255,255,255,0.15); border-radius: 6px; padding: 4px 6px; font-size: 0.76rem; box-sizing: border-box;'; + const takenCodes = new Set(); const cards = agents.map((a, i) => { const isCli = !!MULTIAI_CLI_PROVIDERS[a.provider]; const apiOk = routes.apiMode && (routes.providers[a.provider] || {}).hasKey; @@ -976,7 +983,7 @@ function multiaiRenderParticipants(chat) { return `
- ${multiaiEscape(multiaiAgentCode(a))} + ${multiaiEscape(multiaiAgentCode(a, takenCodes))} @@ -1031,6 +1038,7 @@ function multiaiAgentAdd(provider) { multiaiRenderParticipants(chat); multiaiRenderJudgeOptions(chat); multiaiRenderOptionsSummary(chat); + multiaiRenderStatusLine(chat); } function multiaiAgentDuplicate(i) { @@ -1044,6 +1052,7 @@ function multiaiAgentDuplicate(i) { multiaiRenderParticipants(chat); multiaiRenderJudgeOptions(chat); multiaiRenderOptionsSummary(chat); + multiaiRenderStatusLine(chat); } function multiaiAgentRemove(i) { @@ -1055,6 +1064,7 @@ function multiaiAgentRemove(i) { multiaiRenderParticipants(chat); multiaiRenderJudgeOptions(chat); multiaiRenderOptionsSummary(chat); + multiaiRenderStatusLine(chat); } function multiaiAgentField(i, field, value) { @@ -1075,6 +1085,7 @@ function multiaiAgentField(i, field, value) { if (field === 'name') multiaiPersistSoon(); else multiaiPersist(); if (field !== 'name' && field !== 'model') { multiaiRenderParticipants(chat); multiaiRenderJudgeOptions(chat); } multiaiRenderOptionsSummary(chat); + multiaiRenderStatusLine(chat); } function multiaiAgentPersona(i, personaName) { diff --git a/tests/electron/ipc/multiai-core.test.js b/tests/electron/ipc/multiai-core.test.js index be7bdb0..098a81f 100644 --- a/tests/electron/ipc/multiai-core.test.js +++ b/tests/electron/ipc/multiai-core.test.js @@ -268,8 +268,11 @@ test('normalizeAgents: legacy participants migrate, ids are unique slugs, CLI pr assert.deepEqual(agents.map(a => a.id), ['claude-code-opus-max', 'bull', 'bull-2', 'perplexity']); assert.equal(agents[0].route, 'cli'); assert.equal(agents[0].name, 'Claude Code Β· opus Β· max'); - assert.equal(agents[0].code, 'CC'); - assert.equal(agents[1].code, 'BU'); + assert.deepEqual(agents.map(a => a.code), ['CC', 'BU', 'BU2', 'PX'], 'default names keep provider codes; duplicates get a suffix'); + const two = core.normalizeAgents({ agents: [{ provider: 'chatgpt', effort: 'high' }, { provider: 'chatgpt', effort: 'low' }, { provider: 'codex-cli', model: 'gpt-5.5' }] }); + assert.deepEqual(two.map(a => a.code), ['CG', 'CG2', 'CX']); + assert.equal(core.refLabel({ role: 'assistant', provider: 'chatgpt', code: 'CG2', cycle: 1, round: 2 }), '0102CG2', 'labels use the agent code'); + assert.equal(core.refLabel({ role: 'pass', provider: 'claude-cli', cycle: 1, round: 1 }), '0101CC'); assert.deepEqual([agents[2].route, agents[2].effort], ['auto', '']); }); diff --git a/tests/electron/ipc/multiai-handlers.test.js b/tests/electron/ipc/multiai-handlers.test.js index f59849a..7c847c0 100644 --- a/tests/electron/ipc/multiai-handlers.test.js +++ b/tests/electron/ipc/multiai-handlers.test.js @@ -496,7 +496,7 @@ test('agents: same provider twice with different models/efforts, API route when assert.equal(providerCalls[0].provider, 'claude'); const msgs = messagesOf(chat.id).filter(m => m.role === 'assistant'); assert.deepEqual(msgs.map(m => m.who), ['GPT low', 'GPT high', 'Claude tab']); - assert.deepEqual(msgs.map(m => m.code), ['GL', 'GH', 'CT']); + assert.deepEqual(msgs.map(m => m.code), ['GL', 'GH', 'CL']); assert.ok(msgs[0].agentId && msgs[0].route === 'api' && msgs[2].route === 'browser'); assert.ok(byokCalls[1].messages[0].content.includes('GPT low'), 'the second agent sees the first by name'); byokState.enabled = false; @@ -522,7 +522,7 @@ test('agents: CLI agents run through the local CLI with model + effort; Stop kil assert.deepEqual(cliCalls.map(c => [c.provider, c.opts.model, c.opts.effort]), [['claude-cli', 'opus', 'high'], ['codex-cli', 'gpt-5.5', 'medium']]); const msgs = messagesOf(chat.id).filter(m => m.role === 'assistant'); assert.deepEqual(msgs.map(m => m.who), ['Opus Β· high', 'Codex 5.5']); - assert.deepEqual(msgs.map(m => m.code), ['OH', 'C5']); + assert.deepEqual(msgs.map(m => m.code), ['OH', 'CX']); assert.ok(cliCalls[1].opts.prompt.includes('Opus Β· high'), 'second CLI agent sees the first'); // Stop mid-call From 7903f3af728dfa0a77efafb91244dc353c4be77f Mon Sep 17 00:00:00 2001 From: Eugene Turetsky Date: Thu, 3 Sep 2026 19:07:54 -0400 Subject: [PATCH 09/67] Multi-AI: auto-route agents never send a browser-side model name to the API When Settings flips API mode on, an auto agent whose model is a Gemini engine name ("3.1-pro") would have named a model the API rejects. On auto, the agent's model is used only if Settings lists it; otherwise the Settings selection applies. An explicit api route still trusts what was typed. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ACdKJmXfzvwfCCLneCWd12 --- docs/MULTIAI.md | 2 +- electron/ipc/multiai.cjs | 16 +++++++++++++++- tests/electron/ipc/multiai-handlers.test.js | 16 ++++++++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/docs/MULTIAI.md b/docs/MULTIAI.md index 532a80b..10750f2 100644 --- a/docs/MULTIAI.md +++ b/docs/MULTIAI.md @@ -24,7 +24,7 @@ HANDOFFs, the judge picker and the provider-side thread key on. | Route | What runs the turn | Model / effort | |---|---|---| -| `auto` (default) | Follows **Settings β†’ API mode**: on, with a key for that provider β†’ the provider's API; otherwise the browser tab | API: the agent's model or, blank, the model selected in Settings; effort honoured. Browser: model ignored except Gemini's engine picker; effort ignored | +| `auto` (default) | Follows **Settings β†’ API mode**: on, with a key for that provider β†’ the provider's API; otherwise the browser tab | API: the agent's model when Settings lists it (a browser-side name like the Gemini engine `3.1-pro` is ignored), else the model selected in Settings; effort honoured. Browser: model ignored except Gemini's engine picker; effort ignored | | `browser` | Always the logged-in browser tab | as above | | `api` | The provider's API; falls back to the browser tab (with a ⚠ in the editor) if API mode is off or no key | model + effort | | `cli` (implied for `claude-cli`, `codex-cli`) | `claude -p` / `codex exec` on this machine, one-shot, tool-less, in an empty scratch folder, ≀2 in parallel per CLI | Claude Code aliases `opus` `sonnet` `haiku` `fable` (+ `[1m]`), `--effort low…max`; Codex `gpt-5.5` etc., `model_reasoning_effort` (`max` β†’ `xhigh`) | diff --git a/electron/ipc/multiai.cjs b/electron/ipc/multiai.cjs index 3d0fabd..8d3ad01 100644 --- a/electron/ipc/multiai.cjs +++ b/electron/ipc/multiai.cjs @@ -278,6 +278,20 @@ function registerMultiAiHandlers(deps) { return result; } + // Which model an API call should name. An agent on the `auto` route may + // carry a browser-side model (a Gemini engine like "3.1-pro") that the API + // would reject, so on `auto` the agent's model is only used when Settings + // lists it; otherwise the model selected in Settings applies. An explicit + // `api` route trusts what was typed. + function apiModelFor(agent) { + if (!agent.model) return undefined; + if (agent.route === 'api') return agent.model; + try { + const known = (byok.keys.getModels(agent.provider) || []).map(m => m.id); + return known.length && !known.includes(agent.model) ? undefined : agent.model; + } catch { return agent.model; } + } + // API route: the provider's API with the key from Settings. Model = the // agent's, else the model selected in Settings (resolveModel handles it). async function sendApi(chat, agent, prompt) { @@ -286,7 +300,7 @@ function registerMultiAiHandlers(deps) { const untrack = trackInFlight(chat.id, { kind: 'api', base: agent.provider }); try { const p = byok.callProvider(agent.provider, key, [{ role: 'user', content: prompt }], { - modelId: agent.model || undefined, + modelId: apiModelFor(agent), reasoningEffort: agent.effort || undefined, }).then(r => ({ response: r.text, model: r.model || agent.model || null, route: 'api' })); return await raceProvider(chat.id, p); diff --git a/tests/electron/ipc/multiai-handlers.test.js b/tests/electron/ipc/multiai-handlers.test.js index 7c847c0..e86e257 100644 --- a/tests/electron/ipc/multiai-handlers.test.js +++ b/tests/electron/ipc/multiai-handlers.test.js @@ -502,6 +502,22 @@ test('agents: same provider twice with different models/efforts, API route when byokState.enabled = false; }); +test('agents: on the auto route a browser-only model (Gemini engine) is not sent to the API; an explicit api route trusts the typed model', async () => { + providerCalls.length = 0; byokCalls.length = 0; + byokState.enabled = true; + byokState.keys.gemini = 'AIza'; byokState.selected.gemini = 'gemini-3.1-pro'; + const chat = newChat({ participants: [], agents: [ + { name: 'Gem auto', provider: 'gemini', model: '3.1-pro' }, + { name: 'Gem api', provider: 'gemini', route: 'api', model: 'gemini-3.1-flash' }, + { name: 'GPT listed', provider: 'chatgpt', model: 'gpt-5.5' }, + ], stopWhenAllAgree: false }); + userMsg(chat.id, 'go'); + await invoke('multiai-run-round', { chatId: chat.id, rounds: 1 }); + assert.deepEqual(byokCalls.map(c => c.options.modelId), [undefined, 'gemini-3.1-flash', 'gpt-5.5']); + delete byokState.keys.gemini; delete byokState.selected.gemini; + byokState.enabled = false; +}); + test('agents: with API mode off in Settings, auto agents fall back to the browser tab; model becomes the Gemini engine', async () => { providerCalls.length = 0; byokCalls.length = 0; byokState.enabled = false; From 298731a747c2569b11691337d70efcf9b01f38af Mon Sep 17 00:00:00 2001 From: Eugene Turetsky Date: Thu, 3 Sep 2026 19:31:24 -0400 Subject: [PATCH 10/67] Multi-AI: agent roster with enable checkboxes + library; app opens on the Multi-AI tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Agents block is now a roster: one compact row per agent with a checkbox. Unticked agents are benched for that chat (kept configured; no turns, no mentions, not introduced to the others β€” normalizeAgents drops enabled:false). all / none shortcuts; run guard asks to tick a box. - Agents are defined once: "+ Add agent" (quick presets, library, Custom… editor) and live in uiPrefs.agentLibrary keyed by uid; every new chat starts with the whole library on; editing a library agent applies to every chat carrying it; row βœ• = this chat only, library βœ• = for good. Legacy/REST-created agents stay chat-local. - REST/MCP accept enabled on agents; readers see the active roster. - Startup: opens on Multi-AI (Settings β†’ General β†’ Open on start to change); main's set-active-provider no longer yanks the user to a provider tab; provider views start off-screen so they never cover the Multi-AI panel while initializing. - Help modal gains the Agents/Routes sections that were written but never saved in the previous commit. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ACdKJmXfzvwfCCLneCWd12 --- docs/MULTIAI.md | 21 +- electron/index-v2.html | 55 ++- electron/ipc/multiai-core.cjs | 11 +- electron/ipc/multiai.cjs | 2 +- electron/main-v2.cjs | 17 +- electron/multiai-renderer.js | 426 ++++++++++++++++---- src/mcp/tools-multiai.js | 1 + tests/electron/ipc/multiai-core.test.js | 8 + tests/electron/ipc/multiai-handlers.test.js | 19 + 9 files changed, 446 insertions(+), 114 deletions(-) diff --git a/docs/MULTIAI.md b/docs/MULTIAI.md index 10750f2..1090947 100644 --- a/docs/MULTIAI.md +++ b/docs/MULTIAI.md @@ -51,9 +51,26 @@ Route-specific behaviour: - **Labels.** Known providers keep their codes; other agents get initials of their name (`Opus Β· high` β†’ `OH`). +**Roster and library.** The Agents block is a roster: one row per agent with a +checkbox. Ticked agents take turns in this chat; unticked ones are benched β€” +still configured, but they take no turns, get no mentions and aren't introduced +to the others (`enabled: false` on the stored agent; `normalizeAgents` drops +them). Agents are defined once β€” *+ Add agent* offers quick presets, the +library, and *Custom…* for the full editor β€” and live in a library +(`uiPrefs.agentLibrary`, keyed by `uid`). Every new chat starts with the whole +library switched on; editing a library agent (✎) applies to every chat that +carries it; βœ• on a row removes the agent from that chat only, βœ• in the library +list removes it for good (chats keep a chat-local copy). Agents that arrive +without a `uid` β€” legacy `participants`, chats created over REST/MCP β€” are +chat-local. + Legacy chats (a `participants` list) are migrated to one browser-tab agent per provider the first time they are opened; the `participants` field is kept in -sync for older readers. +sync (enabled agents only) for older readers. + +The app opens on the Multi-AI tab by default (Settings β†’ General β†’ *Open on +start* picks another tab). Provider views start off-screen and only cover the +window when their tab is selected. ## How a turn is built @@ -153,7 +170,7 @@ Same server as the rest of the gateway (Settings β†’ API; default | Method | Path | Body / query | Result | |---|---|---|---| | GET | `/v1/multiai/chats` | | `{chats:[{id,title,mode,agents:[name],participants,messageCount,cycle,updatedAt}]}` | -| POST | `/v1/multiai/chats` | `{title?, agents?, participants?, mode?, discussMode?, contextMode?, brevity?, topic?, who?}` | `{chat}` (201); `topic` is recorded as the first message. `agents: [{name?, provider, route?, model?, effort?, persona?}]`; `participants` is the legacy one-agent-per-provider shorthand | +| POST | `/v1/multiai/chats` | `{title?, agents?, participants?, mode?, discussMode?, contextMode?, brevity?, topic?, who?}` | `{chat}` (201); `topic` is recorded as the first message. `agents: [{name?, provider, route?, model?, effort?, persona?, enabled?}]`; `participants` is the legacy one-agent-per-provider shorthand | | GET | `/v1/multiai/chats/:id` | `?since=&last=` | `{chat}` with `agents` (resolved `route` per agent), `messages` (each has a `label` like `0103CG`, plus `agentId`, `route`, `model` on agent turns) and `running` | | POST | `/v1/multiai/chats/:id/messages` | `{who, text, provider?, role?}` | `{message}` (201). Appends a turn; nothing is sent to providers. `role: "user"` starts a new cycle. | | POST | `/v1/multiai/chats/:id/run` | `{text?, who?, rounds?, wait?}` | `{started:true}` (202) or, with `wait:true`, `{messages}` once the rounds finish | diff --git a/electron/index-v2.html b/electron/index-v2.html index e55aa72..759b051 100644 --- a/electron/index-v2.html +++ b/electron/index-v2.html @@ -1459,7 +1459,7 @@

Get Started

-
Agents
+
Agents
@@ -1605,6 +1605,12 @@

Get Started

+

Agents

+

An agent is a seat β€” name + provider + route + model + effort + persona. Add the same provider more than once with different models or efforts (β€œOpus Β· max” and β€œSonnet Β· low”, or β€œGPT high” and β€œGPT low”) to run a single-provider panel. Names become ids for @mentions and HANDOFFs (@opus-max).

+

Roster and library β€” define your agents once (+ Add agent: quick presets, or Custom… for the full editor). They go into a library, and every new chat starts with all of them. In a chat, the checkbox next to each agent decides who takes turns this time β€” untick to bench an agent without losing its setup; benched agents get no turns, no mentions and aren't introduced to the others. ✎ edits an agent (for a library agent the edit applies everywhere it is used); βœ• removes it from this chat only β€” the library's own βœ• in the Add menu removes it for good.

+

Routes β€” auto follows the app's general Settings: API mode on with a key for that provider β†’ its API, using the model chosen in Settings unless the agent names one; otherwise the logged-in browser tab. browser always uses the tab (model ignored except Gemini's engine; effort ignored). api asks for the API and falls back to the tab, with a ⚠, if Settings doesn't allow it. Claude Code and Codex agents run the installed CLI locally on your subscription β€” one-shot, no tools, in an empty scratch folder β€” with the model alias and effort you pick. Keys and API mode are only ever set in Settings; ↻ re-reads them.

+

Route differences β€” API and CLI turns are one-shot, so Delta context becomes Bounded for them (browser agents keep Delta). Stop kills CLI processes and aborts browser turns; an API call already in flight finishes and is discarded. Codex must support the chosen model on your account (gpt-5.5 does; gpt-5 does not on ChatGPT plans).

+

Classic mode

Discuss vs. Debate β€” Discuss has participants build on each other's points toward a shared answer. Debate assigns opposing sides and has them argue; with more than two participants each takes a distinct stance.

Rounds β€” how many times each participant speaks in turn. One round is usually enough for a quick comparison; use 2-4 for a discussion that needs to actually converge or a debate that needs rebuttals. More rounds costs more time and, if you're on metered API keys, more money.

@@ -1612,7 +1618,7 @@

Get Started

Context β€” what each participant is sent every turn. Bounded (default): the pinned State summary, a condensed digest of older messages, and the last N messages verbatim (Context window) β€” self-contained and stays small. Delta: only the messages since that AI's last turn; its own provider-side thread remembers the rest, and a rolling summary (refreshed every β€œAuto-summary every” messages, via the Judge provider) rides along as the topic block β€” the cheapest per turn; a fresh or rotated thread gets one full catch-up so nothing is lost. Full: the entire transcript every turn, the way the original ProximaChatApp worked β€” simplest, but it grows without limit and long chats will hit provider limits (that is what caused the HTTP 500s and timeouts late in long sessions).

Each chat gets its own provider-side thread per AI (rotated every 12 turns, or 40 in Delta mode), so chats never bleed into each other or into the MCP tools.

Stop token / PASS β€” participants are asked to begin a reply with the stop token (default DONE) when they think the group has converged, and to reply PASS when they have nothing new. With β€œStop early when all agree” on, a round where everyone says the token or passes ends the run β€” no more rounds of β€œAgreed, nothing further”. Passes are shown dimmed and are not fed to the other AIs; neither are errors.

-

Directed turns β€” mention participants in your message to set this Send's order: @claude kick off, others review makes Claude speak first; only @gemini and @chatgpt restricts the round to those two. Names: @chatgpt, @claude, @gemini, @perplexity.

+

Directed turns β€” mention participants in your message to set this Send's order: @claude kick off, others review makes Claude speak first; only @gemini and @chatgpt restricts the round to those two. Use an agent's id (@opus-max), its name without spaces, or the provider when only one agent uses it.

Independent first round β€” round 1 of each Send runs everyone in parallel, blind to each other's replies (positions are committed before anyone reads the others); later rounds are round-robin as usual. In Debate with three or more participants, stances are assigned (for / against / critical evaluator / third option) rather than left to chance.

Charter mode β€” turns the coworking charter's discipline into engine rules: participants must tag claims VERIFIED [who/how/path], UNVERIFIED or ASSUMED [basis] (agreement never upgrades a claim); plain agreement counts as PASS; every message ends with HANDOFF β†’ <participant>: …, OPEN β†’ … or COMPLETE β†’ …; a HANDOFF pulls the named participant forward (next in this round, or first in the next); and one participant per round holds a rotating red-team seat (marked in the feed) whose job is the strongest objection to the emerging consensus.

Judge β€” after a discussion, ask one provider to read the transcript and give a verdict. Judge scope: the whole chat, everything since your last message, or only the last round.

@@ -1749,6 +1755,18 @@

Get Started

Active Providers
0 / 4
+
+
Open on start
+ +
@@ -2903,6 +2921,12 @@
-
Multi-AI Chat @@ -1351,6 +1391,7 @@ Settings
+
@@ -1445,117 +1486,144 @@

Get Started

- -
- -
+
Pipeline steps use the browser-session providers only. The agent roster β€” routes, models, efforts, personas and the workspace β€” is Classic-mode only, and file tools are off in this mode, so a workspace attached to this chat is not used here.
diff --git a/electron/ipc/multiai.cjs b/electron/ipc/multiai.cjs index 8d80ccc..3144868 100644 --- a/electron/ipc/multiai.cjs +++ b/electron/ipc/multiai.cjs @@ -1450,6 +1450,10 @@ function registerMultiAiHandlers(deps) { const r = await askVariant({ variant: String(body.model), message, system: body.system || system }); const completion = completionFor(r, String(body.model)); if (body.stream === true) { + // askVariant above can take many minutes, so the client + // is quite likely gone by now; the pre-existing chunked + // path guards every write for the same reason. + if (res.writableEnded || res.destroyed) return true; res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', ...(res._proximaCors || {}) }); const base = { id: completion.id, object: 'chat.completion.chunk', created: completion.created, model: completion.model }; res.write(`data: ${JSON.stringify(Object.assign({}, base, { choices: [{ index: 0, delta: { role: 'assistant', content: r.text }, finish_reason: null }] }))}\n\n`); diff --git a/electron/multiai-renderer.js b/electron/multiai-renderer.js index 253f60a..7ec2bd5 100644 --- a/electron/multiai-renderer.js +++ b/electron/multiai-renderer.js @@ -2298,6 +2298,12 @@ async function multiaiExportMd() { function multiaiOptionsWithSelected(options, value) { if (!value) return options; + // A saved provider that is no longer on offer (a CLI seat, or one disabled + // in Settings) used to fall silently to the first option, so the step ran + // as something the user never chose. Keep it, marked. + if (!options.includes(`value="${multiaiEscapeAttr(value)}"`) && !options.includes(`value="${value}"`)) { + return `` + options; + } return options.replace(`value="${value}"`, `value="${value}" selected`); } From 74136908b2e9ef1190e8743943e591dd23a97f17 Mon Sep 17 00:00:00 2001 From: Eugene Turetsky Date: Sat, 5 Sep 2026 22:31:38 -0400 Subject: [PATCH 34/67] CLI: stop blocking the main thread on binary lookup; surface warnings that were being cut off MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit which() used execFileSync, which blocks the Electron main process β€” and in Electron that freezes the window, every provider BrowserView, the REST server and the IPC bridge together, for up to 5s per lookup on a long PATH or a PATH entry on a disconnected share. Three of those fire from the refresh button, so it was reachable deliberately and repeatedly. The cache is now warmed asynchronously at the top of discover(), and the synchronous path only runs on a cold miss. Two UI fixes on the same theme of information that existed but could not be seen: the collapsed options summary is the only always-visible state readout, and warnings were appended last into a span that ellipsises β€” so "tools without a workspace" was the first thing to disappear. Warnings now sort first and the line may wrap to two. And a change card that followed a failed turn was styled identically to a successful one, so "the failed turn also changed my repo" read as a normal commit; it is now marked partial, in the error colour, explaining that the commit exists so nothing is lost. Co-Authored-By: Claude Opus 5 --- electron/index-v2.html | 2 +- electron/multiai-renderer.js | 6 +++++- electron/providers/cli.cjs | 28 ++++++++++++++++++++++++++-- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/electron/index-v2.html b/electron/index-v2.html index eaea116..d96d3dd 100644 --- a/electron/index-v2.html +++ b/electron/index-v2.html @@ -1499,7 +1499,7 @@

Get Started

diff --git a/electron/multiai-renderer.js b/electron/multiai-renderer.js index 7ec2bd5..f37a057 100644 --- a/electron/multiai-renderer.js +++ b/electron/multiai-renderer.js @@ -794,6 +794,10 @@ function multiaiRenderOptionsSummary(chat) { const wsSeats = multiaiWorkspaceSeats(chat); if (chat.workspace && chat.workspace.root) extras.push(wsSeats.length ? `workspace · ${wsSeats.length} with tools` : 'workspace (no seat has tools)'); else if (wsSeats.length) extras.push('⚠ tools without a workspace'); + // Warnings first: this one line is the only always-visible state + // readout, and it is exactly where the safety warnings used to be cut + // off, because they were appended last and the span ellipsises. + extras.sort((a, b) => (b.includes('⚠') ? 1 : 0) - (a.includes('⚠') ? 1 : 0)); el.textContent = `${n}${all > n ? '/' + all : ''} agent${Math.max(n, all) === 1 ? '' : 's'} · ${chat.discussMode === 'debate' ? 'Debate' : 'Discuss'} · ${rounds} round${rounds === 1 ? '' : 's'} · ${chat.brevity || 'max 4 sentences'}${extras.length ? ' · ' + extras.join(' · ') : ''}`; const hint = document.getElementById('multiai-composer-hint'); if (hint) { @@ -1876,7 +1880,7 @@ function multiaiAppendFeedItem(message, skipScroll) { item.style.cssText = 'border: 1px solid rgba(245,158,11,0.4); border-radius: 10px; padding: 12px 14px; background: rgba(245,158,11,0.06);'; item.innerHTML = `
-
πŸ›  ${multiaiEscape(message.who || 'changes')}${message.commit ? ` Β· ${multiaiEscape(String(message.commit).slice(0, 8))}` : ''}
+
πŸ›  ${multiaiEscape(message.who || 'changes')}${message.partial ? ' Β· partial, turn errored' : ''}${message.commit ? ` Β· ${multiaiEscape(String(message.commit).slice(0, 8))}` : ''}
${multiaiRefLabel(message)}
${multiaiEscape(head)}
diff --git a/electron/providers/cli.cjs b/electron/providers/cli.cjs index 93f6be7..e2f0688 100644 --- a/electron/providers/cli.cjs +++ b/electron/providers/cli.cjs @@ -20,12 +20,13 @@ // defaults / [profiles.*] from ~/.codex/config.toml; // - Gemini CLI: version and the auth method from ~/.gemini/settings.json. -const { spawn, execFileSync } = require('child_process'); +const { spawn, execFile, execFileSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const os = require('os'); const IS_WIN = process.platform === 'win32'; +const NEWLINE_RE = new RegExp('\r?\n'); const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000; const MAX_PARALLEL = { claude: 2, codex: 2, gemini: 2 }; @@ -210,6 +211,28 @@ function which(bin) { return found; } +// execFileSync blocks the Electron main process β€” and in Electron that means +// the window, every provider BrowserView, the REST server and the IPC bridge +// freeze together, for up to 5s per lookup on a long PATH or a PATH entry on a +// disconnected share. Three of those fire from the refresh button. So the cache +// is warmed asynchronously here, and the synchronous which() below only ever +// runs on a cold miss. +function whichAsync(bin) { + if (_avail[bin] !== undefined) return Promise.resolve(_avail[bin]); + return new Promise(resolve => { + execFile(IS_WIN ? 'where' : 'which', [bin], { encoding: 'utf8', timeout: 5000, env: childEnv(), windowsHide: true }, (err, stdout) => { + let found = err ? null : (String(stdout).split(NEWLINE_RE).map(l => l.trim()).filter(Boolean)[0] || null); + if (!found) found = knownLocations(bin).find(f => { try { return fs.existsSync(f); } catch { return false; } }) || null; + _avail[bin] = found; + resolve(found); + }); + }); +} + +async function warmAvailability() { + await Promise.all(Object.values(BINS).map(b => whichAsync(b).catch(() => null))); +} + function availability() { return { claude: !!which('claude'), @@ -346,6 +369,7 @@ async function discover({ force = false } = {}) { _discovering = (async () => { const home = os.homedir(); const out = { claude: null, codex: null, gemini: null, at: Date.now() }; + await warmAvailability(); const avail = availability(); if (avail.claude) { const [ver, help] = await Promise.all([runQuick('claude', ['--version']), runQuick('claude', ['--help'])]); @@ -776,6 +800,6 @@ module.exports = { run, runClaude, runCodex, runGemini, availability, resetAvailabilityCache, which, discover, discovered, childEnv, knownLocations, parseClaudeHelp, parseCodexModelsCache, parseCodexConfig, geminiAuthFrom, codexEffortFor, claudeArgs, codexArgs, geminiArgs, workspaceSystemPrompt, codexSandboxHelperDir, codexSandboxBundle, codexSandboxSupport, hasCodexHelpers, - codexCandidates, resolveCodex, codexExe, parseVersion, cmpVersion, spawnCli, winQuote, cmdCommandLine, killAll, + codexCandidates, resolveCodex, codexExe, parseVersion, cmpVersion, spawnCli, winQuote, cmdCommandLine, killAll, whichAsync, warmAvailability, EFFORT_MAP, DISCUSSION_SYSTEM_PROMPT, MAX_PARALLEL, KINDS, BINS, LABELS, TOOLS_LEVELS, CLAUDE_TOOLS, }; From f5579c0a9935c0bae91b317f2e36b8db1465159f Mon Sep 17 00:00:00 2001 From: Eugene Turetsky Date: Sat, 5 Sep 2026 22:35:29 -0400 Subject: [PATCH 35/67] Multi-AI: show who is working and who is only queued MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A run's only progress signal was an "X is thinking…" line appended to the feed at the top of every turn and never removed. It fired before route resolution, before the writer lock and before the per-CLI concurrency queue, so a seat waiting behind the two-at-a-time cap, a writer blocked on the workspace lock, and a seat genuinely mid-inference all rendered identically β€” and they piled up, about twenty stale lines in a four-seat five-round run. Switching to a running chat and back showed a static transcript with a Stop button and no sign anything was in flight at all. There is now a status strip above the composer, keyed by agent, updating in place and clearing when the run ends. Two states: working, and waiting with the reason as dim text ("Claude Code slot", "workspace turn"). The reason is not a third state on purpose β€” to the user it is one fact with two explanations, and the decision it informs, wait or Stop, is the same either way. The concurrency slot is taken inside cli.cjs, two layers below the IPC layer and in a module that must stay Electron-free for the standalone variants server β€” so runProcess gained an optional onState hook rather than the emit being moved somewhere it cannot see chatId. multiai-running now carries the per-agent state it already had room for, so a window entering a chat mid-run rebuilds the strip instead of guessing. Co-Authored-By: Claude Opus 5 --- electron/index-v2.html | 1 + electron/ipc/multiai.cjs | 23 +++++++- electron/multiai-renderer.js | 61 +++++++++++++++++++-- electron/preload.cjs | 3 +- electron/providers/cli.cjs | 22 +++++--- tests/electron/ipc/multiai-handlers.test.js | 11 +++- 6 files changed, 103 insertions(+), 18 deletions(-) diff --git a/electron/index-v2.html b/electron/index-v2.html index d96d3dd..eb6a690 100644 --- a/electron/index-v2.html +++ b/electron/index-v2.html @@ -1622,6 +1622,7 @@

Get Started

background: #1b1b26; color: #d1d5db; font-size: 0.72rem; cursor: pointer;">↓ new messages + diff --git a/electron/ipc/multiai.cjs b/electron/ipc/multiai.cjs index 3144868..815ad3f 100644 --- a/electron/ipc/multiai.cjs +++ b/electron/ipc/multiai.cjs @@ -139,14 +139,27 @@ function registerMultiAiHandlers(deps) { // Shape kept ahead of the per-agent status strip: `agents` is empty for now // and the renderer ignores it, so filling it in later needs no second // version of this payload. + const agentStates = new Map(); // chatId -> Map(agentId -> {state, reason, who}) + function setAgentState(chatId, agent, state, reason) { + if (!agentStates.has(chatId)) agentStates.set(chatId, new Map()); + const m = agentStates.get(chatId); + if (state === 'done') m.delete(agent.id); else m.set(agent.id, { state, reason: reason || '', who: agent.name }); + emit('multiai-agent-state', { chatId, agentId: agent.id, who: agent.name, state, reason: reason || '' }); + } + function clearAgentStates(chatId) { agentStates.delete(chatId); } + function runningState() { return Array.from(runSignals.entries()).map(([chatId, s]) => ({ - chatId, startedBy: s.startedBy || 'ui', agents: [], + chatId, + startedBy: s.startedBy || 'ui', + agents: Array.from((agentStates.get(chatId) || new Map()).entries()) + .map(([agentId, v]) => ({ agentId, who: v.who, state: v.state, reason: v.reason })), })); } function endSignal(chatId) { runSignals.delete(chatId); + clearAgentStates(chatId); } function isRunning(chatId) { @@ -397,7 +410,10 @@ function registerMultiAiHandlers(deps) { // CLI route: Claude Code / Codex / Gemini CLI on the local machine. async function sendCli(chat, agent, prompt) { const ws = workspaceFor(chat, agent); - const handle = cli.run(agent.provider, { model: agent.model || undefined, effort: agent.effort || undefined, prompt, cwd: dataDir(), workspace: ws || undefined }); + const handle = cli.run(agent.provider, { + model: agent.model || undefined, effort: agent.effort || undefined, prompt, cwd: dataDir(), workspace: ws || undefined, + onState: (st) => setAgentState(chat.id, agent, st === 'queued' ? 'waiting' : 'working', st === 'queued' ? `${core.providerLabel(agent.provider)} slot` : ''), + }); const entry = { kind: 'cli', base: agent.provider, abort: handle.abort }; const untrack = trackInFlight(chat.id, entry); try { @@ -541,6 +557,7 @@ function registerMultiAiHandlers(deps) { if (!chat) throw new Error('Chat not found'); const cycle = chat.cycle || 1; emit('multiai-thinking', { chatId, provider: agent.provider, agentId: agent.id, who: agent.name }); + setAgentState(chatId, agent, 'working', ''); const { brief, latest } = core.briefAndLatest(chat.messages, chat.topic); const route = resolveRoute(agent); const st = route === 'browser' ? nextSessionState(chat, agent) : null; @@ -571,6 +588,7 @@ function registerMultiAiHandlers(deps) { // on the same files and every commit belongs to one seat. const ws = workspaceFor(chat, agent); const writer = !!(ws && (ws.tools === 'write' || ws.tools === 'full')); + if (writer) setAgentState(chatId, agent, 'waiting', 'workspace turn'); const releaseWriter = writer ? await acquireWriter(chatId) : null; // Waiting for the lock can take a whole CLI turn, and Stop may have // landed meanwhile. Without this, a parallel round still spawns @@ -600,6 +618,7 @@ function registerMultiAiHandlers(deps) { if (writer) setWorkspacePending(chatId, null); if (releaseWriter) releaseWriter(); } + setAgentState(chatId, agent, 'done'); const base = { provider: agent.provider, agentId: agent.id, code: agent.code, route, cycle, round }; const recordChange = () => { if (!change) return; diff --git a/electron/multiai-renderer.js b/electron/multiai-renderer.js index f37a057..e9542d4 100644 --- a/electron/multiai-renderer.js +++ b/electron/multiai-renderer.js @@ -215,14 +215,15 @@ async function multiaiInit() { if (chat) multiaiRenderStatusLine(chat); } }); - agentHub.onMultiaiDone(({ chatId }) => multiaiSetRunning(chatId, false)); + agentHub.onMultiaiDone(({ chatId }) => { multiaiAgentStates(chatId).clear(); multiaiSetRunning(chatId, false); multiaiRenderAgentStatus(); }); agentHub.onMultiaiOrchStep((step) => { if (step.chatId === multiaiState.currentChatId) multiaiRenderOrchStep(step); }); - agentHub.onMultiaiOrchDone(({ chatId }) => multiaiSetRunning(chatId, false)); + agentHub.onMultiaiOrchDone(({ chatId }) => { multiaiAgentStates(chatId).clear(); multiaiSetRunning(chatId, false); multiaiRenderAgentStatus(); }); // Every run announces itself now, including one a local agent started over // REST or MCP. Idempotent on purpose: the UI's own runs already set this // locally before the IPC round-trip, so this arrives as a no-op for them. + if (agentHub.onMultiaiAgentState) agentHub.onMultiaiAgentState(multiaiSetAgentState); if (agentHub.onMultiaiRunStarted) { agentHub.onMultiaiRunStarted(({ chatId, startedBy }) => { if (startedBy && startedBy !== 'ui') multiaiState.externalRuns.add(chatId); @@ -240,6 +241,7 @@ async function multiaiInit() { if (!id) return; multiaiState.runningChats.add(id); if (r && r.startedBy && r.startedBy !== 'ui') multiaiState.externalRuns.add(id); + (r && r.agents ? r.agents : []).forEach(a => multiaiAgentStates(id).set(a.agentId, { who: a.who, state: a.state, reason: a.reason })); }); } catch { /* older main without the handler */ } @@ -830,6 +832,9 @@ function multiaiRenderChat() { multiaiNewChat(); return; } + // Switching into a running chat used to show a static transcript with a + // Stop button and no sign anything was in flight. + multiaiRenderAgentStatus(); multiaiEnsureMessageIds(chat); multiaiState.ledgerFilters = {}; @@ -1921,10 +1926,12 @@ function multiaiAppendSystemNote(text) { multiaiFeedFollow(feed, wasAtBottom); } -function multiaiSetThinking(provider, on, who) { - if (!on) return; - multiaiAppendSystemNote(`${who || multiaiProviderLabel(provider)} is thinking…`); -} +// Superseded by the status strip above the composer. This used to append a +// permanent note to the feed for every turn, which piled up (four seats over +// five rounds left about twenty of them interleaved with the transcript) and +// could not distinguish thinking from queued in any case. The strip is keyed by +// agent, updates in place, and clears when the run ends. +function multiaiSetThinking() { /* no-op: see multiaiRenderAgentStatus */ } function multiaiEscape(s) { const div = document.createElement('div'); @@ -1959,6 +1966,48 @@ function multiaiIsExternalRun(chatId) { return !!chatId && multiaiState.externalRuns.has(chatId); } +// Who is actually doing something, and who is only queued. The old signal was a +// "X is thinking…" line appended to the feed and never removed, so a seat +// waiting behind the two-per-CLI concurrency cap, a writer blocked on the +// workspace lock, and a seat genuinely mid-inference all looked identical and +// piled up β€” about twenty stale lines in a four-seat, five-round run. +// +// Two states only. The reason a seat is waiting rides along as dim text rather +// than becoming a third state: to the user it is one fact ("nothing is +// happening yet") with two explanations, and the decision it informs β€” wait, or +// press Stop β€” is the same either way. +function multiaiAgentStates(chatId) { + if (!multiaiState.agentStates) multiaiState.agentStates = new Map(); + if (!multiaiState.agentStates.has(chatId)) multiaiState.agentStates.set(chatId, new Map()); + return multiaiState.agentStates.get(chatId); +} + +function multiaiSetAgentState({ chatId, agentId, who, state, reason }) { + if (!chatId || !agentId) return; + const m = multiaiAgentStates(chatId); + if (state === 'done') m.delete(agentId); else m.set(agentId, { who, state, reason }); + if (chatId === multiaiState.currentChatId) multiaiRenderAgentStatus(); +} + +function multiaiRenderAgentStatus() { + const el = document.getElementById('multiai-agent-status'); + if (!el) return; + const chat = multiaiCurrentChat(); + const seats = chat ? Array.from(multiaiAgentStates(chat.id).values()) : []; + if (!seats.length) { el.style.display = 'none'; el.innerHTML = ''; return; } + el.style.display = 'flex'; + el.innerHTML = seats.map(s => { + const working = s.state === 'working'; + const colour = working ? '#34d399' : '#9ca3af'; + return ` + ${working ? '●' : 'β—‹'} + ${multiaiEscape(s.who || 'agent')} + ${working ? 'working' : 'waiting'}${!working && s.reason ? ' Β· ' + multiaiEscape(s.reason) : ''} + `; + }).join(''); +} + // A run failure used to exist only as a 2.5s toast, and two errors close // together meant the second was visible for about 100ms. Put it in the // transcript as well, where it survives. diff --git a/electron/preload.cjs b/electron/preload.cjs index 4bd2596..f2bdf25 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -112,5 +112,6 @@ contextBridge.exposeInMainWorld('agentHub', { onMultiaiOrchStep: (cb) => { ipcRenderer.removeAllListeners('multiai-orch-step'); ipcRenderer.on('multiai-orch-step', (e, d) => cb(d)); }, onMultiaiOrchDone: (cb) => { ipcRenderer.removeAllListeners('multiai-orch-done'); ipcRenderer.on('multiai-orch-done', (e, d) => cb(d)); }, onMultiaiChatCreated: (cb) => { ipcRenderer.removeAllListeners('multiai-chat-created'); ipcRenderer.on('multiai-chat-created', (e, d) => cb(d)); }, - onMultiaiRunStarted: (cb) => { ipcRenderer.removeAllListeners('multiai-run-started'); ipcRenderer.on('multiai-run-started', (e, d) => cb(d)); } + onMultiaiRunStarted: (cb) => { ipcRenderer.removeAllListeners('multiai-run-started'); ipcRenderer.on('multiai-run-started', (e, d) => cb(d)); }, + onMultiaiAgentState: (cb) => { ipcRenderer.removeAllListeners('multiai-agent-state'); ipcRenderer.on('multiai-agent-state', (e, d) => cb(d)); } }); diff --git a/electron/providers/cli.cjs b/electron/providers/cli.cjs index e2f0688..c833c8e 100644 --- a/electron/providers/cli.cjs +++ b/electron/providers/cli.cjs @@ -574,11 +574,18 @@ function spawnCli(bin, args, cwd, exeOverride) { } // Runs a process with the prompt on stdin. Returns { promise, abort }. -function runProcess(kind, bin, args, { cwd, prompt, timeoutMs, exe }) { +// `onState` is how the UI learns the difference between a seat that is waiting +// for a concurrency slot and one that is actually thinking β€” the two look +// identical otherwise, and a seat can sit queued for a full CLI timeout while +// the user decides whether to wait or press Stop. Optional: cli.cjs must stay +// Electron-free for the standalone variants server, which omits it. +function runProcess(kind, bin, args, { cwd, prompt, timeoutMs, exe, onState }) { + const state = (s) => { try { if (onState) onState(s); } catch { /* never let the UI break a run */ } }; let child = null; let aborted = false; let timer = null; const promise = (async () => { + state('queued'); await acquire(kind); if (aborted) { release(kind); throw Object.assign(new Error('Cancelled by user'), { cancelled: true }); } try { @@ -590,6 +597,7 @@ function runProcess(kind, bin, args, { cwd, prompt, timeoutMs, exe }) { if (aborted) throw Object.assign(new Error('Cancelled by user'), { cancelled: true }); return await new Promise((resolve, reject) => { child = spawnCli(bin, args, cwd, exeResolved); + state('running'); _live.add(child); child.once('close', () => _live.delete(child)); let stdout = ''; @@ -660,13 +668,13 @@ function claudeArgs({ model, effort, workspace }, dir, sysFile) { return args; } -function runClaude({ model, effort, prompt, systemPrompt, cwd, workspace, timeoutMs }) { +function runClaude({ model, effort, prompt, systemPrompt, cwd, workspace, timeoutMs, onState }) { const dir = scratchDir(cwd); const level = workspace && workspace.root && workspace.tools && workspace.tools !== 'none' ? workspace.tools : 'none'; const sysFile = path.join(dir, 'system.txt'); fs.writeFileSync(sysFile, systemPrompt || (level !== 'none' ? workspaceSystemPrompt(workspace) : DISCUSSION_SYSTEM_PROMPT), 'utf-8'); const args = claudeArgs({ model, effort, workspace: level !== 'none' ? workspace : null }, dir, sysFile); - const run = runProcess('claude', 'claude', args, { cwd: level !== 'none' ? workspace.root : dir, prompt, timeoutMs }); + const run = runProcess('claude', 'claude', args, { cwd: level !== 'none' ? workspace.root : dir, prompt, timeoutMs, onState }); // finally, not then: an aborted or timed-out turn used to leave its system // prompt behind forever. run.promise.finally(() => removeScratch(dir)).catch(() => {}); @@ -714,7 +722,7 @@ function codexArgs({ model, effort, workspace }, dir, outFile) { return args; } -function runCodex({ model, effort, prompt, systemPrompt, cwd, workspace, timeoutMs }) { +function runCodex({ model, effort, prompt, systemPrompt, cwd, workspace, timeoutMs, onState }) { const dir = scratchDir(cwd); const level = workspace && workspace.root && workspace.tools && workspace.tools !== 'none' ? workspace.tools : 'none'; const outFile = path.join(dir, 'codex-last.txt'); @@ -724,7 +732,7 @@ function runCodex({ model, effort, prompt, systemPrompt, cwd, workspace, timeout // spawnCli runs the resolved codex (newest of the desktop app's bundle and // the one on PATH β€” see resolveCodex); resolve first so the first turn // after startup doesn't guess. - const run = runProcess('codex', 'codex', args, { cwd: level !== 'none' ? workspace.root : dir, prompt: full, timeoutMs, exe: () => resolveCodex().then(p => (p ? p.exe : undefined)) }); + const run = runProcess('codex', 'codex', args, { cwd: level !== 'none' ? workspace.root : dir, prompt: full, timeoutMs, onState, exe: () => resolveCodex().then(p => (p ? p.exe : undefined)) }); run.promise.finally(() => removeScratch(dir)).catch(() => {}); const promise = run.promise.then(({ code, stdout, stderr }) => { let text = ''; @@ -756,12 +764,12 @@ function geminiArgs({ model, workspace }) { return args; } -function runGemini({ model, prompt, systemPrompt, cwd, workspace, timeoutMs }) { +function runGemini({ model, prompt, systemPrompt, cwd, workspace, timeoutMs, onState }) { const dir = scratchDir(cwd); const level = workspace && workspace.root && workspace.tools && workspace.tools !== 'none' ? workspace.tools : 'none'; const args = geminiArgs({ model, workspace: level !== 'none' ? workspace : null }); const full = `${systemPrompt || (level !== 'none' ? workspaceSystemPrompt(workspace) : DISCUSSION_SYSTEM_PROMPT)}\n\n${prompt}`; - const run = runProcess('gemini', 'gemini', args, { cwd: level !== 'none' ? workspace.root : dir, prompt: full, timeoutMs }); + const run = runProcess('gemini', 'gemini', args, { cwd: level !== 'none' ? workspace.root : dir, prompt: full, timeoutMs, onState }); run.promise.finally(() => removeScratch(dir)).catch(() => {}); const promise = run.promise.then(({ code, stdout, stderr }) => { const text = stdout.trim(); diff --git a/tests/electron/ipc/multiai-handlers.test.js b/tests/electron/ipc/multiai-handlers.test.js index 215699b..6c08072 100644 --- a/tests/electron/ipc/multiai-handlers.test.js +++ b/tests/electron/ipc/multiai-handlers.test.js @@ -228,7 +228,10 @@ test('run-round: refuses a second concurrent run on the same chat; stop cancels' // mistaken for one this window started. `agents` is reserved for the // per-agent status strip and is empty for now. The REST contract // (GET /v1/multiai/running β†’ {running:[chatId]}) is deliberately unchanged. - assert.deepEqual(await invoke('multiai-running'), [{ chatId: chat.id, startedBy: 'ui', agents: [] }]); + const live0 = await invoke('multiai-running'); + assert.equal(live0.length, 1); + assert.deepEqual([live0[0].chatId, live0[0].startedBy], [chat.id, 'ui']); + assert.ok(Array.isArray(live0[0].agents), 'per-agent state rides along for the status strip'); assert.deepEqual(multiaiApi.running(), [chat.id], 'REST keeps its documented shape'); await invoke('multiai-stop-run', { chatId: chat.id }); const r = await running; @@ -979,7 +982,11 @@ test('runs: a run started over REST announces itself, is attributed, and is stop assert.deepEqual(ev, [{ chatId: chat.id, startedBy: 'agent' }], 'the window is told, and told who started it'); const live = await invoke('multiai-running'); - assert.deepEqual(live, [{ chatId: chat.id, startedBy: 'agent', agents: [] }]); + assert.equal(live.length, 1); + assert.deepEqual([live[0].chatId, live[0].startedBy], [chat.id, 'agent']); + // Seats in flight ride along so a window entering the chat mid-run can + // rebuild the status strip rather than showing a static transcript. + assert.ok(live[0].agents.every(a => a.agentId && ['working', 'waiting'].includes(a.state))); assert.deepEqual(multiaiApi.running(), [chat.id], 'REST keeps its documented shape'); assert.equal(multiaiApi.stop(chat.id).stopped, true, 'and it can be stopped from either side'); From c6e08e1c5cde7c593fc6caec442a82e5f136a336 Mon Sep 17 00:00:00 2001 From: Eugene Turetsky Date: Sat, 5 Sep 2026 22:36:45 -0400 Subject: [PATCH 36/67] Multi-AI: keep focus in the roster, and give its controls real names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Editing any roster cell re-renders the whole table (one cell's value changes what others may contain) and the table is replaced via innerHTML, so focus landed back on . In Chromium a closed ${MULTIAI_TOOLS.map(t => opt(t, t === 'none' ? 'none' : (t === 'full' ? 'full ⚑' : t) + (toolsWarn ? ' ⚠' : ''), tools === t)).join('')}` + ? `` : ``; return `
${multiaiEscape(code)}
- - + + ${cliModels.length ? cliModels.map(m => ``).join('') : hints.map(h => ` - + ${toolsCell} @@ -1343,7 +1343,7 @@ function multiaiRenderParticipants(chat) { box.innerHTML = `
- + ${rows || ``}
AgentProviderRouteModelEffortToolsPersona
Taking partAgentProviderRouteModelEffortToolsPersonaActions
No agents yet β€” add one below. Agents go into the library, so every new chat starts with them; tick / untick to choose who takes part in this one.
@@ -1361,12 +1361,43 @@ function multiaiRenderParticipants(chat) { `; } +// Editing any cell re-renders the whole roster (a cell's value can change what +// other cells may contain), and the table is replaced via innerHTML β€” so focus +// used to land back on . In Chromium a closed - ${cliModels.length ? cliModels.map(m => ``).join('') : hints.map(h => ` +
${cliModels.length ? cliModels.map(m => ``).join('') : hints.map(h => `${isCli ? `` : ''}
${toolsCell} @@ -1317,6 +1483,18 @@ function multiaiRenderParticipants(chat) { const libFree = lib.filter(a => !inChat.has(a.uid)); const presets = ['chatgpt', 'claude', 'gemini', 'perplexity', 'claude-cli', 'codex-cli', 'gemini-cli'].concat(apiOnlyList.map(x => x.id)); const cliOkFor = (pv) => MULTIAI_CLI_PROVIDERS[pv] ? multiaiCliAvailable(pv) : true; + const setups = multiaiSetups(); + const saved = setups.filter(x => !x.builtIn); + const setupMenu = ` + + ${saved.length ? `` : ''}`; const addMenu = ` -
${multiaiEscape(code)}
+
${multiaiColorsEnabled() ? `` : ''}${multiaiEscape(code)}
${cliModels.length ? cliModels.map(m => ``).join('') : hints.map(h => `${isCli ? `` : ''}
@@ -1498,7 +1529,7 @@ function multiaiRenderParticipants(chat) { const addMenu = ``; const libManage = multiaiState.agentMenuOpen && lib.length ? `
@@ -1533,6 +1564,8 @@ function multiaiRenderParticipants(chat) { + ${multiaiMissingClis().map(pv => ``).join('')} + ${enabledN}/${agents.length} enabled Β· ${multiaiEscape(mode)} @@ -2053,6 +2086,79 @@ function multiaiSetNewBelow(on) { if (pill) pill.style.display = on ? 'block' : 'none'; } +// Colour per seat, so a five-round transcript can be read by eye instead of by +// name. Derived rather than assigned: the hue comes from the agent's id, so it +// is stable across sessions and machines and never needs storing, and the two +// seats most likely to be confused β€” the same provider at different efforts β€” +// get separated because their ids differ. Effort nudges lightness, so +// "Opus Β· max" and "Opus Β· low" read as the same family, not as strangers. +const MULTIAI_EFFORT_TINT = { minimal: 16, low: 12, medium: 0, high: -6, xhigh: -10, max: -14 }; + +function multiaiAgentHue(agent) { + const key = String((agent && (agent.id || agent.name || agent.provider)) || ''); + let h = 0; + for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0; + // Skip a band of muddy yellows that reads badly on the dark panel. + const raw = h % 320; + return raw >= 40 ? raw + 40 : raw; +} + +function multiaiAgentColor(agent) { + if (!agent) return null; + const prefs = multiaiUiPrefs(); + if (prefs.agentColors === 'off') return null; + if (agent.color) return agent.color; // explicit user override wins + const light = 62 + (MULTIAI_EFFORT_TINT[agent.effort] || 0); + return `hsl(${multiaiAgentHue(agent)} 70% ${Math.max(40, Math.min(80, light))}%)`; +} + +// Same hue, low alpha, for the card behind a turn. +function multiaiAgentTint(agent, alpha) { + const c = multiaiAgentColor(agent); + if (!c) return null; + const m = /^hsl\((\d+)/.exec(c); + if (m) return `hsl(${m[1]} 70% 60% / ${alpha})`; + return null; +} + +function multiaiColorsEnabled() { + return multiaiUiPrefs().agentColors !== 'off'; +} + +// only speaks hex, and the derived colours are HSL, so the +// swatch needs a conversion. An explicit override is already hex and passes +// straight through. +function multiaiAgentSwatch(agent) { + const c = multiaiAgentColor(agent) || '#93c5fd'; + if (c.startsWith('#')) return c; + const m = /^hsl\((\d+(?:\.\d+)?)\s+(\d+)%\s+(\d+(?:\.\d+)?)%/.exec(c); + if (!m) return '#93c5fd'; + const h = Number(m[1]) / 360, sat = Number(m[2]) / 100, l = Number(m[3]) / 100; + const k = (n) => (n + h * 12) % 12; + const a = sat * Math.min(l, 1 - l); + const f = (n) => Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))))); + return '#' + [f(0), f(8), f(4)].map(v => v.toString(16).padStart(2, '0')).join(''); +} + +function multiaiToggleAgentColors() { + const prefs = multiaiUiPrefs(); + prefs.agentColors = prefs.agentColors === 'off' ? 'auto' : 'off'; + multiaiPersist(); + const chat = multiaiCurrentChat(); + if (chat) { multiaiRerenderAgents(chat); multiaiRenderFeed(chat); } +} + +function multiaiSetAgentColor(i, value) { + const chat = multiaiCurrentChat(); + const a = chat && (chat.agents || [])[i]; + if (!a) return; + if (value) a.color = value; else delete a.color; // blank clears the override + multiaiLibrarySave(a); + multiaiPersist(); + multiaiRerenderAgents(chat); + multiaiRenderFeed(chat); +} + // The whole point of the feature is running one provider several times at // different models and efforts, but the feed showed only the agent's name β€” so // if a name was edited, or an `api` seat quietly fell back to the browser tab, @@ -2155,8 +2261,15 @@ function multiaiAppendFeedItem(message, skipScroll) { : isUser ? 'border-color: rgba(99,102,241,0.35); background: rgba(99,102,241,0.08);' : isPass ? 'opacity: 0.55; background: rgba(255,255,255,0.02);' : 'background: rgba(255,255,255,0.03);'; - const color = isError ? '#f87171' : isJudge ? '#c084fc' : isSummary ? '#34d399' : isUser ? '#a5b4fc' : '#93c5fd'; - item.style.cssText = `border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 12px 14px; ${border}`; + // A seat's own colour, when it has one and the role is not already saying + // something louder (an error is red before it is anybody's colour). + const seat = (!isError && !isJudge && !isSummary && !isUser && message.agentId) + ? ((multiaiCurrentChat() || {}).agents || []).find(x => x.id === message.agentId) : null; + const seatColor = seat ? multiaiAgentColor(seat) : null; + const seatTint = seat ? multiaiAgentTint(seat, 0.10) : null; + const color = isError ? '#f87171' : isJudge ? '#c084fc' : isSummary ? '#34d399' : isUser ? '#a5b4fc' : (seatColor || '#93c5fd'); + const seatSkin = seatColor ? `border-color: ${multiaiAgentTint(seat, 0.45)}; background: ${seatTint};` : ''; + item.style.cssText = `border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 12px 14px; ${border}${seatSkin}`; const attach = message.attachmentsText ? `
πŸ“Ž attached context (${message.attachmentsText.length.toLocaleString()} chars)
${multiaiEscape(message.attachmentsText)}
` : ''; item.innerHTML = `
@@ -2331,6 +2444,13 @@ async function multiaiRunRound() { showToast(multiaiEnsureAgents(chat).length ? 'Enable at least one agent (tick a box in Agents).' : 'Add at least one agent.'); return; } + // Empty the box now, not after the checks below. One of them asks main + // whether a run is already in flight, and main is often busy spawning CLI + // processes, so anything after that await leaves the text sitting there + // long enough to look like the send did not register. Put it back if we + // turn out not to be sending. + const restoreTopic = () => { if (topicEl.value === '') { topicEl.value = topic; topicEl.dispatchEvent(new Event('input')); } }; + topicEl.value = ''; // Ask main, not our cached set: a run started over REST or MCP may be in // flight that this window never heard about. Everything below mutates the // chat and persists it *before* the IPC round-trip, so without this the @@ -2343,6 +2463,7 @@ async function multiaiRunRound() { if (busy) { multiaiSetRunning(chat.id, true); showToast('A run is already in progress for this chat β€” press Stop first.'); + restoreTopic(); return; } } catch { /* older main without the handler: fall through */ } @@ -2369,7 +2490,6 @@ async function multiaiRunRound() { multiaiAppendFeedItem(userMsg); multiaiRenderLedger(chat); } - topicEl.value = ''; if (isFirstMessage && topic) multiaiMaybeAutoTitle(chat, topic); diff --git a/electron/providers/cli.cjs b/electron/providers/cli.cjs index edbf987..f56af57 100644 --- a/electron/providers/cli.cjs +++ b/electron/providers/cli.cjs @@ -731,7 +731,6 @@ function runClaude({ model, effort, prompt, systemPrompt, cwd, workspace, timeou const run = runProcess('claude', 'claude', args, { cwd: level !== 'none' ? workspace.root : dir, prompt, timeoutMs, onState, queueDeadlineMs }); // finally, not then: an aborted or timed-out turn used to leave its system // prompt behind forever. - run.promise.finally(() => removeScratch(dir)).catch(() => {}); const promise = run.promise.then(({ code, stdout, stderr }) => { let parsed = null; const text = stdout.trim(); @@ -752,6 +751,9 @@ function runClaude({ model, effort, prompt, systemPrompt, cwd, workspace, timeou if (!text) throw new Error('Claude Code returned no output'); return { text, model: model || null }; }); + // After the result is read, never before: Codex's answer arrives in a file + // inside this directory. + promise.finally(() => removeScratch(dir)).catch(() => {}); return { promise, abort: run.abort }; } @@ -787,7 +789,6 @@ function runCodex({ model, effort, prompt, systemPrompt, cwd, workspace, timeout // the one on PATH β€” see resolveCodex); resolve first so the first turn // after startup doesn't guess. const run = runProcess('codex', 'codex', args, { cwd: level !== 'none' ? workspace.root : dir, prompt: full, timeoutMs, onState, queueDeadlineMs, exe: () => resolveCodex().then(p => (p ? p.exe : undefined)) }); - run.promise.finally(() => removeScratch(dir)).catch(() => {}); const promise = run.promise.then(({ code, stdout, stderr }) => { let text = ''; try { text = fs.readFileSync(outFile, 'utf8').trim(); fs.unlinkSync(outFile); } catch { /* no file */ } @@ -799,6 +800,9 @@ function runCodex({ model, effort, prompt, systemPrompt, cwd, workspace, timeout } return { text, model: model || null }; }); + // After the result is read, never before: Codex's answer arrives in a file + // inside this directory. + promise.finally(() => removeScratch(dir)).catch(() => {}); return { promise, abort: run.abort }; } @@ -824,7 +828,6 @@ function runGemini({ model, prompt, systemPrompt, cwd, workspace, timeoutMs, onS const args = geminiArgs({ model, workspace: level !== 'none' ? workspace : null }); const full = `${systemPrompt || (level !== 'none' ? workspaceSystemPrompt(workspace) : DISCUSSION_SYSTEM_PROMPT)}\n\n${prompt}`; const run = runProcess('gemini', 'gemini', args, { cwd: level !== 'none' ? workspace.root : dir, prompt: full, timeoutMs, onState, queueDeadlineMs }); - run.promise.finally(() => removeScratch(dir)).catch(() => {}); const promise = run.promise.then(({ code, stdout, stderr }) => { const text = stdout.trim(); let parsed = null; @@ -847,6 +850,9 @@ function runGemini({ model, prompt, systemPrompt, cwd, workspace, timeoutMs, onS if (!text) throw new Error('Gemini CLI returned no output'); return { text, model: model || null }; }); + // After the result is read, never before: Codex's answer arrives in a file + // inside this directory. + promise.finally(() => removeScratch(dir)).catch(() => {}); return { promise, abort: run.abort }; } From 7304e9c868035105e2a6e470e524548c4fb498e0 Mon Sep 17 00:00:00 2001 From: Eugene Turetsky Date: Sat, 5 Sep 2026 23:32:57 -0400 Subject: [PATCH 43/67] Multi-AI: seat colours discriminate rows, and mean nothing else MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first version derived a hue from the agent's id and nudged lightness by effort, so two seats of one provider read as a family. That was solving a problem nobody has: the colour's only job is telling rows apart in a long transcript. Deriving it from provider, model or effort makes it look meaningful, which invites people to read significance into it that is not there β€” and it produced arbitrary hues that could land close together anyway. Now it is a fixed palette of well-separated colours handed out by position in the roster. Distinctness is guaranteed by construction rather than hoped for, the swatch needs no colour-space conversion because the palette is already hex, and the per-seat override and the off switch are unchanged. Co-Authored-By: Claude Opus 5 --- electron/multiai-renderer.js | 79 +++++++++++++++++------------------- 1 file changed, 37 insertions(+), 42 deletions(-) diff --git a/electron/multiai-renderer.js b/electron/multiai-renderer.js index 1b1bf51..2c5f888 100644 --- a/electron/multiai-renderer.js +++ b/electron/multiai-renderer.js @@ -1498,7 +1498,7 @@ function multiaiRenderParticipants(chat) { return ` -
${multiaiColorsEnabled() ? `` : ''}${multiaiEscape(code)}
+
${multiaiColorsEnabled() ? `` : ''}${multiaiEscape(code)}
${cliModels.length ? cliModels.map(m => ``).join('') : hints.map(h => `${isCli ? `` : ''}
@@ -2086,58 +2086,53 @@ function multiaiSetNewBelow(on) { if (pill) pill.style.display = on ? 'block' : 'none'; } -// Colour per seat, so a five-round transcript can be read by eye instead of by -// name. Derived rather than assigned: the hue comes from the agent's id, so it -// is stable across sessions and machines and never needs storing, and the two -// seats most likely to be confused β€” the same provider at different efforts β€” -// get separated because their ids differ. Effort nudges lightness, so -// "Opus Β· max" and "Opus Β· low" read as the same family, not as strangers. -const MULTIAI_EFFORT_TINT = { minimal: 16, low: 12, medium: 0, high: -6, xhigh: -10, max: -14 }; - -function multiaiAgentHue(agent) { - const key = String((agent && (agent.id || agent.name || agent.provider)) || ''); - let h = 0; - for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) >>> 0; - // Skip a band of muddy yellows that reads badly on the dark panel. - const raw = h % 320; - return raw >= 40 ? raw + 40 : raw; -} +// Colour per seat. Its only job is telling rows apart, so it carries no +// meaning: a fixed palette of well-separated hues, handed out by position in +// the roster. Nothing is derived from provider, model or effort, because a +// colour that looks meaningful invites people to read meaning into it. +const MULTIAI_AGENT_PALETTE = [ + '#60a5fa', '#fb923c', '#a78bfa', '#34d399', '#f472b6', + '#22d3ee', '#facc15', '#a3e635', '#e879f9', '#94a3b8', +]; -function multiaiAgentColor(agent) { +function multiaiAgentColor(agent, chat) { if (!agent) return null; - const prefs = multiaiUiPrefs(); - if (prefs.agentColors === 'off') return null; + if (multiaiUiPrefs().agentColors === 'off') return null; if (agent.color) return agent.color; // explicit user override wins - const light = 62 + (MULTIAI_EFFORT_TINT[agent.effort] || 0); - return `hsl(${multiaiAgentHue(agent)} 70% ${Math.max(40, Math.min(80, light))}%)`; + const roster = ((chat || multiaiCurrentChat() || {}).agents) || []; + const i = roster.findIndex(x => x && x.id === agent.id); + return MULTIAI_AGENT_PALETTE[(i === -1 ? 0 : i) % MULTIAI_AGENT_PALETTE.length]; } -// Same hue, low alpha, for the card behind a turn. -function multiaiAgentTint(agent, alpha) { - const c = multiaiAgentColor(agent); - if (!c) return null; - const m = /^hsl\((\d+)/.exec(c); - if (m) return `hsl(${m[1]} 70% 60% / ${alpha})`; - return null; +// The same colour at low alpha, for the card behind a turn. +function multiaiAgentTint(agent, alpha, chat) { + const c = multiaiAgentColor(agent, chat); + if (!c || !/^#[0-9a-f]{6}$/i.test(c)) return null; + const n = parseInt(c.slice(1), 16); + return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`; } function multiaiColorsEnabled() { return multiaiUiPrefs().agentColors !== 'off'; } -// only speaks hex, and the derived colours are HSL, so the -// swatch needs a conversion. An explicit override is already hex and passes -// straight through. -function multiaiAgentSwatch(agent) { - const c = multiaiAgentColor(agent) || '#93c5fd'; - if (c.startsWith('#')) return c; - const m = /^hsl\((\d+(?:\.\d+)?)\s+(\d+)%\s+(\d+(?:\.\d+)?)%/.exec(c); - if (!m) return '#93c5fd'; - const h = Number(m[1]) / 360, sat = Number(m[2]) / 100, l = Number(m[3]) / 100; - const k = (n) => (n + h * 12) % 12; - const a = sat * Math.min(l, 1 - l); - const f = (n) => Math.round(255 * (l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1))))); - return '#' + [f(0), f(8), f(4)].map(v => v.toString(16).padStart(2, '0')).join(''); +function multiaiToggleAgentColors() { + const prefs = multiaiUiPrefs(); + prefs.agentColors = prefs.agentColors === 'off' ? 'auto' : 'off'; + multiaiPersist(); + const chat = multiaiCurrentChat(); + if (chat) { multiaiRerenderAgents(chat); multiaiRenderFeed(chat); } +} + +function multiaiSetAgentColor(i, value) { + const chat = multiaiCurrentChat(); + const a = chat && (chat.agents || [])[i]; + if (!a) return; + if (value) a.color = value; else delete a.color; // blank clears the override + multiaiLibrarySave(a); + multiaiPersist(); + multiaiRerenderAgents(chat); + multiaiRenderFeed(chat); } function multiaiToggleAgentColors() { From b9e367baa8e067b66fa336d8925eda16635e2246 Mon Sep 17 00:00:00 2001 From: Eugene Turetsky Date: Sat, 5 Sep 2026 23:34:31 -0400 Subject: [PATCH 44/67] Multi-AI: a seat keeps its colour for the life of the chat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Colour was read from the seat's position in the roster, so removing or reordering one recoloured everything below it β€” including the turns those seats had already taken, which is exactly when a stable colour is worth having. A seat now claims a palette slot the first time it is seen and keeps it. Only a seat with no claim, or one whose claim an earlier seat already holds, is given the lowest free slot, so a removed seat's colour becomes available again for the next one added rather than shifting anybody else. The claim is chat-local and deliberately not saved to the library: the library is shared across chats, and a stored slot would mean every seat pulled from it arrived claiming the same colour. Co-Authored-By: Claude Opus 5 --- electron/multiai-renderer.js | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/electron/multiai-renderer.js b/electron/multiai-renderer.js index 2c5f888..a2b3a45 100644 --- a/electron/multiai-renderer.js +++ b/electron/multiai-renderer.js @@ -1193,6 +1193,17 @@ function multiaiEnsureAgents(chat) { while (seen.has(id)) id = `${a.id}-${n++}`; a.id = id; seen.add(id); }); + // Colour is claimed once and kept for the life of the chat, so removing or + // reordering a seat never recolours the others β€” or the turns they already + // took. Only a seat with no claim, or one whose claim an earlier seat + // already holds, gets the lowest free slot. + const claimed = new Set(); + chat.agents.forEach(a => { + if (Number.isInteger(a.colorIndex) && !claimed.has(a.colorIndex)) { claimed.add(a.colorIndex); return; } + let i = 0; + while (claimed.has(i)) i++; + a.colorIndex = i; claimed.add(i); + }); // Keep the legacy field in sync for anything that still reads it. chat.participants = chat.agents.filter(a => a.enabled !== false).map(a => a.provider); return chat.agents; @@ -1356,7 +1367,7 @@ function multiaiLibrarySave(agent) { const lib = multiaiAgentLibrary(); if (!agent.uid) agent.uid = multiaiAgentUid(); const copy = multiaiAgentClone(agent); - delete copy.enabled; delete copy.id; + delete copy.enabled; delete copy.id; delete copy.colorIndex; const i = lib.findIndex(x => x.uid === copy.uid); if (i === -1) lib.push(copy); else lib[i] = copy; } @@ -2099,9 +2110,8 @@ function multiaiAgentColor(agent, chat) { if (!agent) return null; if (multiaiUiPrefs().agentColors === 'off') return null; if (agent.color) return agent.color; // explicit user override wins - const roster = ((chat || multiaiCurrentChat() || {}).agents) || []; - const i = roster.findIndex(x => x && x.id === agent.id); - return MULTIAI_AGENT_PALETTE[(i === -1 ? 0 : i) % MULTIAI_AGENT_PALETTE.length]; + const i = Number.isInteger(agent.colorIndex) ? agent.colorIndex : 0; + return MULTIAI_AGENT_PALETTE[i % MULTIAI_AGENT_PALETTE.length]; } // The same colour at low alpha, for the card behind a turn. From f7c6b93fea04026c4e58b3e6543b813fd46dbd0b Mon Sep 17 00:00:00 2001 From: Eugene Turetsky Date: Sat, 5 Sep 2026 23:54:47 -0400 Subject: [PATCH 45/67] =?UTF-8?q?Multi-AI:=20three-tier=20model=20discover?= =?UTF-8?q?y=20=E2=80=94=20public=20catalogue,=20local=20CLI=20truth,=20ve?= =?UTF-8?q?rified=20by=20use?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLIs describe themselves badly. Claude Code's --help lists only its aliases though it accepts full claude-… ids, Gemini CLI enumerates nothing at all, and Codex's list comes from a cache written by whichever binary the user last ran. So the model picker was a free-text box behind a thin, often wrong list. Tier 1 is models.dev: git-backed, no API key, and the only free source found that enumerates effort *values* rather than merely saying an effort knob exists (OpenRouter's supported_parameters and LiteLLM's booleans do not). Fetched with a 24h disk cache, served stale-while-revalidating, and every failure path degrades to "no catalogue" β€” discovery never waits on the network and never fails because of it. Tier 2 is the CLI's own report, which wins outright on anything it lists, because only it knows what this machine is entitled to. That precedence is not theoretical: measured here, the catalogue offers gpt-5.6-sol an effort level Codex rejects (`none`) and omits one it accepts (`ultra`). Tier 3 is a model that answered a real turn, applied in the UI above both. Result on this machine: Claude Code 3 local aliases plus 14 catalogue ids, Codex 6 local plus 42, Gemini 0 plus 39 β€” and gpt-5.6-sol keeps its local effort list including ultra. The picker marks which tier each entry came from, so "ran here" is visibly worth more than "a catalogue mentions it". EFFORT_MAP no longer folds Codex's max down to xhigh. max and ultra are real levels now, and clamping against the model's own list is what narrows them β€” which is why the two effort tests changed rather than broke. Co-Authored-By: Claude Opus 5 --- electron/ipc/multiai.cjs | 3 + electron/multiai-renderer.js | 8 +- electron/providers/catalog.cjs | 114 +++++++++++++++++++++++++++ electron/providers/cli.cjs | 38 ++++++++- tests/electron/providers/cli.test.js | 17 ++-- 5 files changed, 168 insertions(+), 12 deletions(-) create mode 100644 electron/providers/catalog.cjs diff --git a/electron/ipc/multiai.cjs b/electron/ipc/multiai.cjs index e5ae347..ac62b4d 100644 --- a/electron/ipc/multiai.cjs +++ b/electron/ipc/multiai.cjs @@ -29,6 +29,7 @@ const sender = require('../providers/sender.cjs'); const cli = require('../providers/cli.cjs'); const { createVariants } = require('../providers/variants.cjs'); const workspace = require('../providers/workspace.cjs'); +const catalog = require('../providers/catalog.cjs'); const core = require('./multiai-core.cjs'); function dataDir() { @@ -59,6 +60,8 @@ function listPersonas() { function registerMultiAiHandlers(deps) { const { mainWindow, loadSettings, registerRouteExtension } = deps; const store = core.createStore({ dir: dataDir(), fs, path, log: (m) => console.log(m) }); + // Keep the keyless model catalogue beside the rest of the app's data. + catalog.setCacheDir(dataDir()); // ---- window / events --------------------------------------------- function win() { diff --git a/electron/multiai-renderer.js b/electron/multiai-renderer.js index a2b3a45..ab3fabb 100644 --- a/electron/multiai-renderer.js +++ b/electron/multiai-renderer.js @@ -1098,7 +1098,13 @@ function multiaiCliModels(provider) { .map(k => ({ id: k.id, label: `${k.id} βœ“`, efforts: null, verified: true })); return extra.concat(discovered.map(d => { const v = known.find(k => k.id === d.id); - return v ? Object.assign({}, d, { verified: true, label: `${d.label && d.label !== d.id ? d.label : d.id} βœ“` }) : d; + // Three tiers, marked so the list says how much each entry is worth: + // βœ“ ran here, plain = the CLI itself reported it, Β· from the public + // catalogue and only a candidate until it answers a real turn. + const base = d.label && d.label !== d.id ? d.label : d.id; + if (v) return Object.assign({}, d, { verified: true, label: `${base} βœ“` }); + if (d.source === 'catalog') return Object.assign({}, d, { label: `${base} Β· not seen on this machine yet` }); + return d; })); } diff --git a/electron/providers/catalog.cjs b/electron/providers/catalog.cjs new file mode 100644 index 0000000..433f001 --- /dev/null +++ b/electron/providers/catalog.cjs @@ -0,0 +1,114 @@ +// Proxima β€” keyless model catalogue (tier 1 of three). +// +// The CLIs are poor at describing themselves. Claude Code's --help lists only +// its aliases even though it accepts full claude-… ids, Gemini CLI enumerates +// nothing, and Codex's list comes from a cache written by whichever binary the +// user last ran. So a public catalogue fills the gaps: models.dev is git-backed +// (sst/models.dev), needs no API key, and is the only free source found that +// enumerates *effort values* rather than merely saying an effort knob exists. +// +// It is tier 1 because it is a candidate list, not an entitlement list, and it +// can be wrong in both directions β€” measured against this machine it offered +// `none` for gpt-5.6-sol (which the binary does not accept) and omitted +// `ultra` (which it does). Local CLI truth overrides it; a model verified by an +// actual run outranks both. Never let it fail a run: every path here degrades +// to "no catalogue" rather than throwing. + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const URL = 'https://models.dev/api.json'; +const TTL_MS = 24 * 60 * 60 * 1000; +const FETCH_TIMEOUT_MS = 20000; + +// models.dev groups by API provider; these are the ones our CLI kinds speak. +const PROVIDER_FOR_KIND = { claude: 'anthropic', codex: 'openai', gemini: 'google' }; + +let _cacheDir = null; +let _mem = null; +let _inflight = null; + +function setCacheDir(dir) { _cacheDir = dir || null; } +function cacheFile() { + return path.join(_cacheDir || os.tmpdir(), 'models-dev.json'); +} + +function readCache() { + try { + const raw = JSON.parse(fs.readFileSync(cacheFile(), 'utf8')); + if (raw && raw.at && raw.byKind) return raw; + } catch { /* no cache yet, or unreadable */ } + return null; +} + +function writeCache(entry) { + try { + fs.mkdirSync(path.dirname(cacheFile()), { recursive: true }); + fs.writeFileSync(cacheFile(), JSON.stringify(entry), 'utf8'); + } catch { /* a cache we cannot write is not an error, just a slower next start */ } +} + +// Pull out { id, name, efforts[] } per CLI kind. Everything else models.dev +// carries (pricing, modalities, limits) is deliberately dropped β€” the roster +// only needs something to offer in a dropdown. +function shape(api) { + const byKind = {}; + for (const [kind, provider] of Object.entries(PROVIDER_FOR_KIND)) { + const models = (api && api[provider] && api[provider].models) || {}; + byKind[kind] = Object.keys(models).map(id => { + const m = models[id] || {}; + const effort = (m.reasoning_options || []).find(o => o && o.type === 'effort'); + return { + id, + label: m.name || id, + efforts: Array.isArray(effort && effort.values) ? effort.values.slice() : [], + }; + }).filter(m => m.id); + } + return byKind; +} + +async function fetchCatalog() { + if (typeof fetch !== 'function') return null; // very old Node: skip quietly + const ctl = new AbortController(); + const t = setTimeout(() => ctl.abort(), FETCH_TIMEOUT_MS); + if (t.unref) t.unref(); + try { + const res = await fetch(URL, { signal: ctl.signal, headers: { accept: 'application/json' } }); + if (!res.ok) return null; + return shape(await res.json()); + } catch { + return null; // offline, blocked, slow β€” all the same here + } finally { + clearTimeout(t); + } +} + +// Returns { byKind, at, stale } or null. Never throws, never blocks a run: +// a cached copy is served immediately and refreshed in the background when it +// ages out, so only the very first call on a new machine waits on the network. +async function load({ force = false } = {}) { + if (_mem && !force && (Date.now() - _mem.at) < TTL_MS) return _mem; + if (!_mem) _mem = readCache(); + + const fresh = _mem && (Date.now() - _mem.at) < TTL_MS; + if (fresh && !force) return _mem; + + if (!_inflight) { + _inflight = fetchCatalog() + .then(byKind => { + if (byKind) { _mem = { byKind, at: Date.now() }; writeCache(_mem); } + return _mem; + }) + .finally(() => { _inflight = null; }); + } + // With a usable (if stale) copy in hand, don't make the caller wait. + if (_mem) { _inflight.catch(() => {}); return Object.assign({}, _mem, { stale: !fresh }); } + return _inflight; +} + +// Synchronous read of whatever is already loaded, for callers that cannot await. +function loaded() { return _mem; } + +module.exports = { load, loaded, setCacheDir, shape, PROVIDER_FOR_KIND, URL }; diff --git a/electron/providers/cli.cjs b/electron/providers/cli.cjs index f56af57..1e56c1d 100644 --- a/electron/providers/cli.cjs +++ b/electron/providers/cli.cjs @@ -24,6 +24,7 @@ const { spawn, execFile, execFileSync } = require('child_process'); const fs = require('fs'); const path = require('path'); const os = require('os'); +const catalog = require('./catalog.cjs'); const IS_WIN = process.platform === 'win32'; const NEWLINE_RE = new RegExp('\r?\n'); @@ -36,7 +37,7 @@ const LABELS = { claude: 'Claude Code', codex: 'Codex', gemini: 'Gemini CLI' }; const EFFORT_MAP = { claude: { minimal: 'low', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max' }, - codex: { minimal: 'minimal', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'xhigh' }, + codex: { minimal: 'minimal', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max', ultra: 'ultra' }, gemini: {}, // the CLI has no effort switch }; const CLAUDE_KNOWN_ALIASES = ['opus', 'sonnet', 'haiku']; @@ -383,6 +384,28 @@ let _discovered = null; let _discovering = null; // What each CLI offers on this machine. Cached; force to re-probe. + +// ---- three-tier model merge ------------------------------------------------ +// +// Tier 1 catalogue (models.dev, keyless) proposes; tier 2 the CLI's own report +// disposes; tier 3 (a model that answered a real run) is applied in the UI on +// top of both. Only tier 2 knows what this machine is entitled to, so where the +// two disagree the local answer wins outright β€” measured on this machine the +// catalogue offered Codex an effort level it rejects and omitted one it takes. +function mergeCatalog(kind, local) { + const cat = catalog.loaded(); + const extra = (cat && cat.byKind && cat.byKind[kind]) || []; + if (!extra.length) return local; + const out = local.slice(); + const seen = new Set(out.map(m => m.id)); + for (const m of extra) { + if (seen.has(m.id)) continue; // local wins on anything it lists + out.push({ id: m.id, label: m.label || m.id, efforts: m.efforts || [], source: 'catalog' }); + seen.add(m.id); + } + return out; +} + async function discover({ force = false } = {}) { if (_discovered && !force) return _discovered; // `force` has to beat the in-flight check too, or ↻ cannot recover from a @@ -392,6 +415,9 @@ async function discover({ force = false } = {}) { const home = os.homedir(); const out = { claude: null, codex: null, gemini: null, at: Date.now() }; await warmAvailability(); + // Fire-and-forget: a cached copy is used immediately and the network + // refresh lands on a later discover(). Discovery never waits on it. + catalog.load({ force }).catch(() => {}); const avail = availability(); if (avail.claude) { const [ver, help] = await Promise.all([runQuick('claude', ['--version']), runQuick('claude', ['--help'])]); @@ -401,7 +427,9 @@ async function discover({ force = false } = {}) { available: true, label: LABELS.claude, version: (ver.stdout.trim().split(/\s+/)[0] || '').trim(), efforts: parsed.efforts.length ? parsed.efforts : ['low', 'medium', 'high', 'xhigh', 'max'], - models: parsed.aliases.map(a => ({ id: a, label: a, efforts: null })), + // Aliases are all --help gives; the catalogue adds the full + // claude-… ids the CLI also accepts but never lists. + models: mergeCatalog('claude', parsed.aliases.map(a => ({ id: a, label: a, efforts: null }))), defaultModel: settings.model || '', defaultEffort: '', note: 'Aliases or full model names (claude-…); add [1m] for the 1M-context variant where offered.', @@ -427,7 +455,7 @@ async function discover({ force = false } = {}) { available: true, label: LABELS.codex, version: pick ? pick.version : '', efforts: efforts.length ? efforts : ['low', 'medium', 'high', 'xhigh'], - models: cache, + models: mergeCatalog('codex', cache), defaultModel: cfg.model, defaultEffort: cfg.effort, profiles: cfg.profiles, defaultModelListed: !cfg.model || cacheStale || cache.some(m => m.id === cfg.model), @@ -453,7 +481,9 @@ async function discover({ force = false } = {}) { available: true, label: LABELS.gemini, version: (ver.stdout.trim().split(/\s+/).pop() || '').trim(), efforts: [], - models: [], + // Gemini CLI enumerates nothing at all, so the catalogue is the + // only list there is for it. + models: mergeCatalog('gemini', []), defaultModel: (settings.model && (settings.model.name || settings.model)) || '', defaultEffort: '', auth, diff --git a/tests/electron/providers/cli.test.js b/tests/electron/providers/cli.test.js index 9b11e0e..70dbc48 100644 --- a/tests/electron/providers/cli.test.js +++ b/tests/electron/providers/cli.test.js @@ -72,9 +72,12 @@ model_reasoning_effort = "xhigh" assert.deepEqual(r.profiles, [{ name: 'fast', model: 'gpt-5.5', effort: 'low' }, { name: 'deep think', model: 'gpt-5.5', effort: 'xhigh' }]); }); -test('codexEffortFor: clamps to what the model supports once discovery has run', () => { - // no discovery yet β†’ plain mapping (max β†’ xhigh) - assert.equal(cli.codexEffortFor('gpt-5.5', 'max'), 'xhigh'); +test('codexEffortFor: with no discovery, the requested level passes through unclamped', () => { + // `max` and `ultra` are real Codex levels now, so the map no longer folds + // max down to xhigh. Clamping against the model's own list is what narrows + // it, and that needs discovery to have run. + assert.equal(cli.codexEffortFor('gpt-5.5', 'max'), 'max'); + assert.equal(cli.codexEffortFor('gpt-5.5', 'ultra'), 'ultra'); assert.equal(cli.codexEffortFor('gpt-5.5', ''), null); }); @@ -225,18 +228,18 @@ test('codexEffortFor: clamps down to the nearest supported level, not the highes { id: 'wide', efforts: ['low', 'medium', 'high', 'xhigh'] }, { id: 'none', efforts: [] }, ]; - // max maps to xhigh for Codex, then clamps to what the model supports. + // Codex maps max to max now; clamping is what narrows it per model. assert.equal(cli.codexEffortFor('mid', 'max', models), 'medium', 'nearest supported at or below, not the top'); assert.equal(cli.codexEffortFor('mid', 'high', models), 'medium'); assert.equal(cli.codexEffortFor('mid', 'low', models), 'low', 'a supported level is left alone'); - assert.equal(cli.codexEffortFor('wide', 'max', models), 'xhigh'); + assert.equal(cli.codexEffortFor('wide', 'max', models), 'xhigh', 'clamps to the model ceiling'); // Nothing at or below the request: fall up to the lowest on offer rather // than returning something the model would reject. assert.equal(cli.codexEffortFor('low-only', 'minimal', models), 'minimal'); assert.equal(cli.codexEffortFor('mid', 'minimal', models), 'low'); // Unknown model, empty effort list, or no effort asked for: pass through. - assert.equal(cli.codexEffortFor('not-listed', 'max', models), 'xhigh'); - assert.equal(cli.codexEffortFor('none', 'max', models), 'xhigh'); + assert.equal(cli.codexEffortFor('not-listed', 'max', models), 'max'); + assert.equal(cli.codexEffortFor('none', 'max', models), 'max'); assert.equal(cli.codexEffortFor('mid', '', models), null); }); From 25dbca6aeb7dab97dc51a12084342fe805d439f0 Mon Sep 17 00:00:00 2001 From: Eugene Turetsky Date: Sun, 6 Sep 2026 00:03:10 -0400 Subject: [PATCH 46/67] Catalogue: only fill a genuine void, and only with models a CLI could run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checking the picker after the merge showed the catalogue padding it with models the CLIs cannot drive: Codex was offered chatgpt-image-latest and gpt-4o, Gemini was offered lyria-3-clip-preview β€” a music model β€” plus Gemma, which is open-weights and served by a different surface entirely. models.dev's `openai` and `google` entries are the providers' whole API catalogues, not what these CLIs accept, so tier 1 was making the list worse rather than longer. Two rules now. Entries must plausibly drive a coding CLI: text output and tool calling, with Gemma and the image / computer-use / deep-research variants named explicitly because they pass that test and still are not general chat seats. And the catalogue only fills a genuine void β€” Codex publishes a complete, entitlement-aware list once refreshed, so it is left alone; Claude Code's list is knowingly partial (aliases only), so the catalogue supplies the full ids it also accepts; Gemini CLI reports nothing, so it is all there is. Result: Codex 6 authoritative and nothing added, Claude Code 3 aliases plus 14 real claude-… ids, Gemini 0 plus 16 (down from 39, no music models). The cache stores the shaped result, so a filter change had to invalidate it β€” it carries a shape version now. Without that, a machine with an existing cache would have kept serving the unfiltered list forever, which is exactly what happened while testing this. Co-Authored-By: Claude Opus 5 --- electron/providers/catalog.cjs | 25 ++++++++++++++++++++++--- electron/providers/cli.cjs | 6 ++++++ 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/electron/providers/catalog.cjs b/electron/providers/catalog.cjs index 433f001..a165c4a 100644 --- a/electron/providers/catalog.cjs +++ b/electron/providers/catalog.cjs @@ -20,11 +20,21 @@ const os = require('os'); const URL = 'https://models.dev/api.json'; const TTL_MS = 24 * 60 * 60 * 1000; +// The cache stores the *shaped* result, so a change to what shape() keeps or +// drops must invalidate it β€” otherwise an old machine keeps serving models a +// newer filter would have excluded. Bump on any change to shape(). +const SHAPE_VERSION = 2; const FETCH_TIMEOUT_MS = 20000; // models.dev groups by API provider; these are the ones our CLI kinds speak. const PROVIDER_FOR_KIND = { claude: 'anthropic', codex: 'openai', gemini: 'google' }; +// Same provider, different product. Gemma is open-weights and is not served +// by the Gemini CLI, and the image / computer-use / deep-research variants are +// task-specific surfaces rather than models a discussion seat can drive. They +// pass the text+tools test above, so they need naming explicitly. +const EXCLUDE = /^gemma|-image$|computer-use|deep-research/; + let _cacheDir = null; let _mem = null; let _inflight = null; @@ -37,7 +47,7 @@ function cacheFile() { function readCache() { try { const raw = JSON.parse(fs.readFileSync(cacheFile(), 'utf8')); - if (raw && raw.at && raw.byKind) return raw; + if (raw && raw.at && raw.byKind && raw.v === SHAPE_VERSION) return raw; } catch { /* no cache yet, or unreadable */ } return null; } @@ -59,12 +69,21 @@ function shape(api) { byKind[kind] = Object.keys(models).map(id => { const m = models[id] || {}; const effort = (m.reasoning_options || []).find(o => o && o.type === 'effort'); + const out = (m.modalities && m.modalities.output) || []; return { id, label: m.name || id, efforts: Array.isArray(effort && effort.values) ? effort.values.slice() : [], + // These decide whether the model could plausibly drive a coding + // CLI at all. models.dev lists a provider's whole API surface, + // which for OpenAI and Google includes image, audio and + // open-weights models β€” a music model in the roster's model + // picker is worse than a short list. + textOut: out.includes('text'), + toolCall: m.tool_call === true, }; - }).filter(m => m.id); + }).filter(m => m.id && m.textOut && m.toolCall && !EXCLUDE.test(m.id)) + .map(({ id, label, efforts }) => ({ id, label, efforts })); } return byKind; } @@ -98,7 +117,7 @@ async function load({ force = false } = {}) { if (!_inflight) { _inflight = fetchCatalog() .then(byKind => { - if (byKind) { _mem = { byKind, at: Date.now() }; writeCache(_mem); } + if (byKind) { _mem = { byKind, at: Date.now(), v: SHAPE_VERSION }; writeCache(_mem); } return _mem; }) .finally(() => { _inflight = null; }); diff --git a/electron/providers/cli.cjs b/electron/providers/cli.cjs index 1e56c1d..49e75c6 100644 --- a/electron/providers/cli.cjs +++ b/electron/providers/cli.cjs @@ -396,6 +396,12 @@ function mergeCatalog(kind, local) { const cat = catalog.loaded(); const extra = (cat && cat.byKind && cat.byKind[kind]) || []; if (!extra.length) return local; + // Only fill a genuine void. Codex publishes its own complete, entitlement- + // aware list once refreshed, so padding it from a public API catalogue adds + // ids the CLI would reject. Claude Code is the opposite case: its list is + // knowingly partial (aliases only), so the catalogue is what supplies the + // full ids it also accepts. Gemini CLI reports nothing, so it is all there is. + if (kind === 'codex' && local.length) return local; const out = local.slice(); const seen = new Set(out.map(m => m.id)); for (const m of extra) { From ce5034541a28ede33f95518e6abba1b8be3c86a5 Mon Sep 17 00:00:00 2001 From: Eugene Turetsky Date: Sun, 6 Sep 2026 00:06:23 -0400 Subject: [PATCH 47/67] Multi-AI: browser seats follow the tab; only local seats pick a model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two modes, and the Model cell now says which one a seat is in. A browser-route seat answers on whatever model that provider's tab is currently set to β€” the engines contain no model selection at all (claude-engine has zero mentions of model, chatgpt-engine one, and nothing anywhere clicks a web UI's model menu), so the cell reads "whatever the tab is set to" and explains where to change it. Offering a list there would be a control that does nothing and a transcript that misreports what replied. Local CLI seats are the opposite case and keep the full three-tier list. Gemini stays the one browser exception, because its engine really does map an engine name onto the request rather than driving a menu. Co-Authored-By: Claude Opus 5 --- electron/multiai-renderer.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/electron/multiai-renderer.js b/electron/multiai-renderer.js index ab3fabb..abc1550 100644 --- a/electron/multiai-renderer.js +++ b/electron/multiai-renderer.js @@ -1489,7 +1489,7 @@ function multiaiRenderParticipants(chat) { ? opt('api', p.hasKey ? 'api' : 'api ⚠ (Settings)', true) : opt('auto', `auto β†’ ${apiOk ? 'api' : 'tab'}`, a.route === 'auto') + opt('browser', 'browser tab', a.route === 'browser') + opt('api', apiOk ? 'api' : 'api ⚠ (Settings)', a.route === 'api')); const cliDefault = cliInfo && cliInfo.defaultModel ? `${cliInfo.defaultModel} (CLI default)` : 'CLI default'; - const modelPh = isCli ? cliDefault : (apiOnly ? (p.model ? `${p.model} (Settings)` : 'model id (required)') : (apiOk || a.route === 'api' ? (p.model ? `${p.model} (Settings)` : 'Settings default') : (a.provider === 'gemini' ? 'engine' : 'β€”'))); + const modelPh = isCli ? cliDefault : (apiOnly ? (p.model ? `${p.model} (Settings)` : 'model id (required)') : (apiOk || a.route === 'api' ? (p.model ? `${p.model} (Settings)` : 'Settings default') : (a.provider === 'gemini' ? 'engine' : 'whatever the tab is set to'))); const tools = isCli ? (MULTIAI_TOOLS.includes(a.tools) ? a.tools : 'none') : ''; const hasWs = !!(chat.workspace && chat.workspace.root); const codexNoSandbox = a.provider === 'codex-cli' && cliInfo && cliInfo.sandbox === 'missing' && (tools === 'read' || tools === 'write'); @@ -1518,7 +1518,7 @@ function multiaiRenderParticipants(chat) {
${multiaiColorsEnabled() ? `` : ''}${multiaiEscape(code)}
-
${cliModels.length ? cliModels.map(m => ``).join('') : hints.map(h => `${isCli ? `` : ''}
+
${cliModels.length ? cliModels.map(m => ``).join('') : hints.map(h => `${isCli ? `` : ''}
${toolsCell} From 00179388d69e608386f8ab3530ee8f068b51f3b4 Mon Sep 17 00:00:00 2001 From: Eugene Turetsky Date: Sun, 6 Sep 2026 00:10:10 -0400 Subject: [PATCH 48/67] Browser seats: record the model the tab is actually set to MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A browser turn recorded model: agent.model β€” the seat's configured value, which means nothing on that route since the engines cannot drive a web UI's model menu. In practice that was null, so the transcript said nothing at all about what half the seats were: a five-round debate with two tab seats kept no record of whether they answered on Opus or Haiku. sender.readTabModel scrapes the provider's model-menu button and the browser path records that instead. Verified against a real logged-in tab: a turn now carries model "Opus 5 High", matching what the Claude tab displays, effort included. Best-effort by construction. It reads a menu label, so it is subject to those sites restyling; every failure path returns null rather than a guess, the call is optional so an older sender or a test double cannot break a turn, and the value is only ever used as a label β€” never to route a request. Guarding that call was not defensive padding: the first version threw on a sender without the method and took down every browser turn in the suite. Co-Authored-By: Claude Opus 5 --- electron/ipc/multiai.cjs | 7 ++++-- electron/providers/sender.cjs | 43 ++++++++++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/electron/ipc/multiai.cjs b/electron/ipc/multiai.cjs index ac62b4d..68ad15a 100644 --- a/electron/ipc/multiai.cjs +++ b/electron/ipc/multiai.cjs @@ -367,7 +367,10 @@ function registerMultiAiHandlers(deps) { next = { gen: st.gen + 1, turns: 0, lastId: null }; } if (next) saveSession(chat, agent.id, next); - if (result.ok) result.value = { response: result.value.response, model: agent.model || null, route: 'browser' }; + // The seat's configured model means nothing on a browser route, so + // record what the tab is actually set to and fall back to null + // rather than labelling the turn with a model it did not use. + if (result.ok) result.value = { response: result.value.response, model: (sender.readTabModel ? await sender.readTabModel(agent.provider).catch(() => null) : null), route: 'browser' }; return result; } @@ -474,7 +477,7 @@ function registerMultiAiHandlers(deps) { const providerString = (agent.provider === 'gemini' && agent.model) ? (agent.model.startsWith('gemini:') ? agent.model : `gemini:${agent.model}`) : agent.provider; const result = await sender.sendMessageToProvider(providerString, prompt, null, null, sessionId); saveSession(chat, key, { gen: st.gen, turns: st.turns + 1 }); - return { response: result.response, model: agent.model || null, route: 'browser' }; + return { response: result.response, model: (sender.readTabModel ? await sender.readTabModel(agent.provider).catch(() => null) : null), route: 'browser' }; } function contextOpts(chat) { diff --git a/electron/providers/sender.cjs b/electron/providers/sender.cjs index 15b21a6..8d9429c 100644 --- a/electron/providers/sender.cjs +++ b/electron/providers/sender.cjs @@ -100,4 +100,45 @@ async function _sendMessageToProviderImplInner(provider, message, attachments = throw new Error(`API failed: ${lastError}`); } -module.exports = { init, sendMessageToProvider, abortActive }; + +// What model is that tab actually set to? The engines cannot drive a web UI's +// model menu, so a browser seat answers on whatever the user last picked there +// β€” and until now the transcript simply omitted it, which meant a five-round +// debate recorded nothing about what half the seats were. Reading it makes the +// record honest without pretending we control it. +// +// This scrapes a menu button's label, so it is best-effort by construction: +// these sites restyle often, and every failure returns null rather than a guess. +// The value is only ever used as a label, never to route a request. +const MODEL_LABEL_SELECTORS = { + chatgpt: ['[data-testid="model-switcher-dropdown-button"]', 'button[aria-label*="odel"]'], + claude: ['[data-testid="model-selector-dropdown"]', 'button[aria-haspopup="menu"][aria-label*="odel"]'], + gemini: ['button.gds-mode-switch-button', 'bard-mode-switcher button'], + perplexity: ['button[aria-label*="odel"]'], +}; + +async function readTabModel(provider) { + const base = String(provider || '').split(':')[0]; + const sel = MODEL_LABEL_SELECTORS[base]; + if (!sel || !browserManager || !browserManager.getWebContents(base)) return null; + const script = `(() => { + const sels = ${JSON.stringify(sel)}; + for (const s of sels) { + const el = document.querySelector(s); + const t = el && (el.innerText || el.textContent || '').trim(); + if (t) return t.split(String.fromCharCode(10))[0].slice(0, 60); + } + return null; + })()`; + try { + const raw = await browserManager.executeScript(base, script); + if (!raw || typeof raw !== 'string') return null; + // Menu buttons often carry chrome around the name ("Claude Opus 5 β–Ύ"). + const cleaned = raw.replace(/[β–Ύβ–ΌβŒ„Λ…]/g, '').replace(/\s+/g, ' ').trim(); + return cleaned && cleaned.length <= 60 ? cleaned : null; + } catch { + return null; // tab closed, navigating, or restyled + } +} + +module.exports = { init, sendMessageToProvider, abortActive, readTabModel }; From b3e7150f3981ea9624787ce14f5cb67be06f5eb8 Mon Sep 17 00:00:00 2001 From: Eugene Turetsky Date: Sun, 6 Sep 2026 01:25:58 -0400 Subject: [PATCH 49/67] Multi-AI roster: Provider is the vendor, Route is how it is reached MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The roster asked one question where there are two. "Claude" and "Claude Code" sat side by side in the Provider menu as if they were rival vendors, and the Route cell beside them was greyed out β€” because the provider id had already decided the route. The row stated the same fact twice, and the choice that actually mattered, subscription or API key, hid inside a menu entry called "auto" whose label read "auto β†’ tab" next to a separate "browser tab" entry that did exactly the same thing today and something different tomorrow. Provider is now the vendor and Route is the surface: subscription Β· browser tab, subscription Β· , or API key. MULTIAI_VENDORS is the only place the two views meet β€” `provider` is still 'claude' vs 'claude-cli' on disk, because that is what the main process, the REST port and every saved chat speak, so nothing downstream changed and no chat needed migrating. The Route cell is live on CLI seats now instead of greyed out. The API entry is offered only where Settings can honour it. It used to be permanent, and choosing it with API mode off changed nothing at all β€” resolveRoute() sends 'api' and 'auto' down the same branch β€” so the menu advertised a choice the app then ignored. With API mode on but no key for that vendor it is listed disabled rather than hidden, so it stays discoverable. 'auto' is no longer offered; rows that hold it keep it, labelled "follow Settings", until the route is changed. Rewriting it would re-route seats behind the user. Switching a seat's surface now re-derives what cannot survive the move. A browser Gemini seat carries an engine string ("3.1-pro") that the API rejects, and apiModelFor() trusts the box verbatim on an explicit api route, so it would have been sent as typed. A vendor with no CLI (Perplexity) lands on the tab rather than storing route 'cli' against a provider that has none. The model box was an , and Chromium filters a datalist by whatever is already in it: a seat set to "sonnet" was only ever offered the four sonnet ids, and the other fourteen looked as though they had failed to load. It is a grouped
${multiaiColorsEnabled() ? `` : ''}${multiaiEscape(code)}
- - -
${cliModels.length ? cliModels.map(m => ``).join('') : hints.map(h => `${isCli ? `` : ''}
+ + + ${multiaiModelCell(a, i, { isCli, apiOnly, apiOk, p, cliInfo, cliModels, modelOn, modelUnknown, modelPh, modelTitle })} ${toolsCell} @@ -1530,7 +1615,6 @@ function multiaiRenderParticipants(chat) { const inChat = new Set(agents.map(a => a.uid).filter(Boolean)); const libFree = lib.filter(a => !inChat.has(a.uid)); const presets = ['chatgpt', 'claude', 'gemini', 'perplexity', 'claude-cli', 'codex-cli', 'gemini-cli'].concat(apiOnlyList.map(x => x.id)); - const cliOkFor = (pv) => MULTIAI_CLI_PROVIDERS[pv] ? multiaiCliAvailable(pv) : true; const setups = multiaiSetups(); const saved = setups.filter(x => !x.builtIn); const setupMenu = ` ${libFree.length ? `${libFree.map(a => opt('lib:' + a.uid, `${a.name} β€” ${multiaiAgentMeta(a)}`)).join('')}` : ''} - ${presets.map(pv => opt('new:' + pv, (MULTIAI_CLI_PROVIDERS[pv] || (multiaiIsApiOnly(pv) ? multiaiProviderLabel(pv) + ' (API)' : multiaiProviderLabel(pv) + ' tab')) + (cliOkFor(pv) ? '' : ' β€” not installed'), false, !cliOkFor(pv))).join('')} + ${presets.map(pv => opt('new:' + pv, multiaiAddLabel(pv) + (cliOkFor(pv) ? '' : ' β€” not installed'), false, !cliOkFor(pv))).join('')} `; const libManage = multiaiState.agentMenuOpen && lib.length ? `
@@ -1554,7 +1638,7 @@ function multiaiRenderParticipants(chat) { ${lib.map(a => `${multiaiEscape(a.name)}${inChat.has(a.uid) ? ' Β· in chat' : ''}`).join('')} β€” new chats start with all of these, and each is also served as cli:<id> on the REST port and to MCP clients (its persona goes along as the system prompt)
` : ''; - const mode = routes.apiMode ? 'API mode ON in Settings' : 'API mode OFF in Settings β€” auto agents use the browser tabs'; + const mode = routes.apiMode ? 'API mode ON in Settings β€” the API route is offered where a key exists' : 'API mode OFF in Settings β€” no API routes; turn it on there to offer them'; const cliLine = ['claude-cli', 'codex-cli', 'gemini-cli'].map(pv => { const info = multiaiCliInfo(pv); if (!multiaiCliAvailable(pv)) return null; @@ -1569,7 +1653,7 @@ function multiaiRenderParticipants(chat) { box.innerHTML = `
- + ${rows || ``}
Taking partAgentProviderRouteModelEffortToolsPersonaActions
Taking partAgentProviderRouteModelEffortToolsPersonaActions
No agents yet β€” add one below. Agents go into the library, so every new chat starts with them; tick / untick to choose who takes part in this one.
@@ -1584,6 +1668,8 @@ function multiaiRenderParticipants(chat) { ${multiaiMissingClis().map(pv => ``).join('')} + + ${enabledN}/${agents.length} enabled Β· ${multiaiEscape(mode)} ${cliLine ? `${multiaiEscape(cliLine)}` : ''} @@ -1748,6 +1834,30 @@ function multiaiAgentsSetAll(on) { multiaiRerenderAgents(chat); } +// Empty the roster. Not the same as β€œnone”, which benches every seat but keeps +// it configured, so this asks first and says what survives. +function multiaiAgentsClearAll() { + const chat = multiaiCurrentChat(); + if (!chat) return; + const agents = multiaiEnsureAgents(chat); + if (!agents.length) return; + const lib = multiaiAgentLibrary(); + const kept = agents.filter(a => a.uid && lib.some(x => x.uid === a.uid)).length; + const gone = agents.length - kept; + if (!window.confirm(`Remove all ${agents.length} agent${agents.length === 1 ? '' : 's'} from this chat?\n\n` + + (kept ? `${kept} of them stay in the library β€” β€œ+ add agent…” puts ${kept === 1 ? 'it' : 'them'} back.\n` : '') + + (gone ? `${gone} ${gone === 1 ? 'is' : 'are'} not in the library, so this is the only copy.\n` : '') + + `\nThe transcript is untouched. To keep them but leave them out of the run, use β€œnone”.`)) return; + chat.agents = []; + // The legacy participants list is still the migration source: clearing only + // `agents` would have multiaiEnsureAgents rebuild the roster from it on the + // very next render. + chat.participants = []; + multiaiEnsureAgents(chat); + multiaiPersist(); + multiaiRerenderAgents(chat); +} + function multiaiAgentPreset(provider) { const isCli = !!MULTIAI_CLI_PROVIDERS[provider]; const apiOnly = multiaiIsApiOnly(provider); @@ -1877,13 +1987,45 @@ function multiaiAgentField(i, field, value) { } } } - if (field === 'model:commit') a.model = value; else a[field] = value; - if (field === 'provider') { - const isCli = !!MULTIAI_CLI_PROVIDERS[value]; - a.route = isCli ? 'cli' : (a.route === 'cli' ? 'auto' : a.route); - if (isCli) { a.model = multiaiCliPresetModel(value); a.effort = multiaiCliPresetEffort(value, a.model, a.effort); if (!MULTIAI_TOOLS.includes(a.tools)) a.tools = 'none'; } - else if (multiaiIsApiOnly(value)) { const pi = multiaiState.routes.providers[value] || {}; a.route = 'api'; a.model = pi.model || (pi.models && pi.models[0]) || ''; a.effort = ''; delete a.tools; } - else { if (value !== 'gemini') a.model = ''; delete a.tools; } + // 'vendor' and 'route' are the roster's two questions. The stored shape has + // one field that answers both β€” `provider` is 'claude' or 'claude-cli' β€” + // so they are translated here instead of migrating every saved chat, saved + // setup and library entry. + if (field === 'model:select') { + if (!multiaiState.customModelRows) multiaiState.customModelRows = {}; + const key = multiaiModelRowKey(a, i); + if (value === '__custom__') { multiaiState.customModelRows[key] = true; value = ''; } + else delete multiaiState.customModelRows[key]; + field = 'model:commit'; // same effort clamping + } + if (field === 'model:commit') a.model = value; + else if (field !== 'vendor' && field !== 'route') a[field] = value; + if (field === 'provider') multiaiApplyProviderDefaults(a, value); + if (field === 'vendor' || field === 'route') { + const want = field === 'route' ? value : multiaiRouteOf(a); + const d = MULTIAI_VENDORS[field === 'vendor' ? value : multiaiVendorOf(a.provider)]; + if (!d) { + // api-only seat (a custom endpoint): one route, so a vendor change + // is just a provider change. + if (field === 'vendor' && value !== a.provider) { a.provider = value; multiaiApplyProviderDefaults(a, value); } + } else { + const provider = (want === 'cli' && d.cli) ? d.cli : d.browser; + if (provider !== a.provider) { a.provider = provider; multiaiApplyProviderDefaults(a, provider); } + // The route follows the provider that was actually chosen, not the + // one asked for: moving a CLI seat to a vendor with no CLI + // (Perplexity) must land on the tab, not store 'cli' against a + // browser provider β€” resolveRoute() would then quietly ignore it. + a.route = MULTIAI_CLI_PROVIDERS[provider] ? 'cli' : (want === 'cli' ? 'browser' : want); + // Same provider, different surface: what is in the model box does + // not survive the move. A browser Gemini seat carries an engine + // string ("3.1-pro") that the API rejects, and the API route trusts + // the box verbatim (apiModelFor), so it would be sent as typed. + if (a.route === 'api') { + if (!multiaiApiAvailable(provider)) a.route = 'browser'; + else { const known = ((multiaiState.routes.providers || {})[provider] || {}).models || []; if (a.model && known.length && !known.includes(a.model)) a.model = ''; } + } + if (a.route === 'browser' && provider !== 'gemini') a.model = ''; + } } if (field === 'tools') { a.tools = MULTIAI_TOOLS.includes(value) ? value : 'none'; @@ -1905,6 +2047,99 @@ function multiaiAgentField(i, field, value) { else { multiaiRenderOptionsSummary(chat); multiaiRenderStatusLine(chat); } } +// Moving a seat to another surface invalidates what is configured on it: a CLI +// model id means nothing to the API, a browser seat has no workspace tools, and +// an api-only endpoint names its own models. Re-derive rather than carry across. +function multiaiApplyProviderDefaults(a, provider) { + const isCli = !!MULTIAI_CLI_PROVIDERS[provider]; + a.route = isCli ? 'cli' : (a.route === 'cli' ? 'auto' : a.route); + if (isCli) { a.model = multiaiCliPresetModel(provider); a.effort = multiaiCliPresetEffort(provider, a.model, a.effort); if (!MULTIAI_TOOLS.includes(a.tools)) a.tools = 'none'; } + else if (multiaiIsApiOnly(provider)) { const pi = (multiaiState.routes.providers || {})[provider] || {}; a.route = 'api'; a.model = pi.model || (pi.models && pi.models[0]) || ''; a.effort = ''; delete a.tools; } + else { if (provider !== 'gemini') a.model = ''; delete a.tools; } +} + +// ---- the model cell ------------------------------------------------------ +// Three sources, each authoritative for a different route: +// +// CLI Β· what the CLI reports about itself (Codex's account list; Claude +// Code's --help aliases), the public catalogue for the full ids those +// aliases stand for, and anything a real turn has proved here +// API Β· the provider's own /v1/models, fetched with your key in +// Settings β†’ API β€” the only entitlement-aware list there is for a key +// tab Β· nothing: the tab answers on whatever model it is set to +// +// A grouped this was. A datalist filters +// itself by whatever is already in the box, so a seat set to "sonnet" was only +// ever offered the four sonnet ids and the other fourteen looked as though they +// had failed to load. Free text stays reachable through "custom…", because +// Claude Code accepts full claude-… ids that no list here enumerates. +function multiaiModelGroups(a, ctx) { + const groups = []; + const push = (label, items) => { if (items.length) groups.push({ label, items }); }; + const ids = list => list.map(m => ({ id: m.id, label: m.id })); + if (ctx.isCli) { + const dated = m => /-\d{8}$/.test(m.id); + const cat = ctx.cliModels.filter(m => !m.verified && m.source === 'catalog'); + push('answered on this machine', ids(ctx.cliModels.filter(m => m.verified))); + push(a.provider === 'claude-cli' ? 'aliases the CLI lists' : 'your account', ids(ctx.cliModels.filter(m => !m.verified && m.source !== 'catalog'))); + push('full ids Β· not tried here', ids(cat.filter(m => !dated(m)))); + // Same models as the group above, pinned to a release date. Separated + // so the plain ids are not buried among their own duplicates. + push('dated pins Β· not tried here', ids(cat.filter(dated))); + } else if (ctx.apiOnly) { + push('served by this endpoint', (ctx.p.models || []).map(id => ({ id, label: id }))); + } else if (ctx.apiOk) { + push(ctx.p.modelsFrom === 'key' ? 'your key returns these (/v1/models)' : 'chosen in Settings', (ctx.p.models || []).map(id => ({ id, label: id }))); + } else if (a.provider === 'gemini') { + push('engines', MULTIAI_MODEL_HINTS.gemini.map(id => ({ id, label: id }))); + } + return groups; +} + +// Rows the user has put into free-text mode. Transient and per-row: an id that +// is simply not on the list puts its own row there without being remembered. +function multiaiModelRowKey(a, i) { return a.uid || a.id || String(i); } + +function multiaiModelCell(a, i, ctx) { + const esc = multiaiEscapeAttr; + const groups = multiaiModelGroups(a, ctx); + const listed = groups.some(g => g.items.some(m => m.id === a.model)); + const custom = !!(multiaiState.customModelRows && multiaiState.customModelRows[multiaiModelRowKey(a, i)]); + const test = ctx.isCli + ? `` + : ''; + const wrap = inner => `
${inner}${test}
`; + + // Free text: chosen, or forced because the stored id is on no list. + if (!ctx.modelOn || custom || (a.model && !listed && groups.length)) { + const back = groups.length && ctx.modelOn + ? `` + : ''; + return wrap(`${back}`); + } + + const opt = (v, label, sel) => ``; + const body = opt('', ctx.modelPh, !a.model) + + groups.map(g => `${g.items.map(m => opt(m.id, m.label, a.model === m.id)).join('')}`).join('') + + opt('__custom__', 'custom…', false); + return wrap(``); +} + +function multiaiModelPickFromList(i) { + const chat = multiaiCurrentChat(); + const a = chat && (chat.agents || [])[i]; + if (!a) return; + if (!multiaiState.customModelRows) multiaiState.customModelRows = {}; + delete multiaiState.customModelRows[multiaiModelRowKey(a, i)]; + // An id on no list would send the row straight back to free text, so + // returning to the list means giving that id up for the seat's default. + const isCli = !!MULTIAI_CLI_PROVIDERS[a.provider]; + const p = ((multiaiState.routes || {}).providers || {})[a.provider] || {}; + const groups = multiaiModelGroups(a, { isCli, apiOnly: !!p.apiOnly, apiOk: multiaiApiAvailable(a.provider), p, cliModels: isCli ? multiaiCliModels(a.provider) : [] }); + if (a.model && !groups.some(g => g.items.some(m => m.id === a.model))) { multiaiAgentField(i, 'model:commit', ''); return; } + multiaiRerenderAgents(chat); +} + // Preset model / effort for a new CLI agent: the CLI's configured default if // it is on its list, else the first listed model; effort = the model's // default level (Codex) or "high". diff --git a/electron/providers/cli.cjs b/electron/providers/cli.cjs index 49e75c6..9513b0a 100644 --- a/electron/providers/cli.cjs +++ b/electron/providers/cli.cjs @@ -423,7 +423,23 @@ async function discover({ force = false } = {}) { await warmAvailability(); // Fire-and-forget: a cached copy is used immediately and the network // refresh lands on a later discover(). Discovery never waits on it. - catalog.load({ force }).catch(() => {}); + // + // On a machine that has never had a catalogue there is no cached copy, + // so it lands *after* the merges below and a fresh install offered only + // the CLI's own aliases β€” three for Claude Code, none at all for + // Gemini CLI β€” until something forced a re-probe, which nothing does. + // Re-merge in place when it arrives instead: `out` is the object that + // becomes the cached discovery, so every later read of it (each return + // to the Multi-AI tab re-reads the cache) has the full list, and no + // second probe is needed. mergeCatalog is idempotent β€” it skips ids + // the local list already carries β€” so patching an already-merged kind + // is a no-op. + catalog.load({ force }).then(() => { + for (const kind of ['claude', 'codex', 'gemini']) { + const d = out[kind]; + if (d && d.available) d.models = mergeCatalog(kind, d.models || []); + } + }).catch(() => {}); const avail = availability(); if (avail.claude) { const [ver, help] = await Promise.all([runQuick('claude', ['--version']), runQuick('claude', ['--help'])]); diff --git a/tests/electron/multiai-roster.test.js b/tests/electron/multiai-roster.test.js new file mode 100644 index 0000000..ff79a0c --- /dev/null +++ b/tests/electron/multiai-roster.test.js @@ -0,0 +1,362 @@ +// The agent roster is the one screen where a wrong answer costs money or +// silently changes which machine answers, and it had no tests at all β€” every +// route rule lived in one 200-character template literal. These cover the +// vendor/route split: what the menus offer, and what a change writes back. + +import test from 'node:test'; +import assert from 'node:assert'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const SRC = path.join(HERE, '..', '..', 'electron', 'multiai-renderer.js'); + +// The renderer is a plain browser script: no exports, one DOM side effect and +// that one is guarded by `typeof document !== 'undefined'`. Evaluate it with a +// stub DOM and hand the top-level bindings back out through globalThis. +function load() { + const box = { innerHTML: '' }; + const sandbox = { + console, + setTimeout, + clearTimeout, + agentHub: { multiaiSave() {}, multiaiRoutes: null }, + showToast() {}, + document: { + getElementById: (id) => (id === 'multiai-participants' ? box : null), + querySelectorAll: () => [], + addEventListener() {}, + activeElement: null, + // multiaiEscape() escapes by round-tripping through a detached + // element; this is the same escape, done by hand. + createElement: () => ({ + innerHTML: '', + set textContent(v) { + this.innerHTML = String(v).replace(/&/g, '&').replace(//g, '>'); + }, + }), + }, + window: { addEventListener() {}, confirm: () => sandbox._confirm() }, + _confirm: () => true, + }; + sandbox.globalThis = sandbox; + vm.createContext(sandbox); + const src = fs.readFileSync(SRC, 'utf8') + ` +;globalThis.__t = { + state: multiaiState, + render: multiaiRenderParticipants, + field: multiaiAgentField, + vendorOf: multiaiVendorOf, + routeOf: multiaiRouteOf, + apiAvailable: multiaiApiAvailable, + pickFromList: multiaiModelPickFromList, + clearAll: multiaiAgentsClearAll, + VENDORS: MULTIAI_VENDORS, +};`; + vm.runInContext(src, sandbox, { filename: 'multiai-renderer.js' }); + const t = sandbox.__t; + t.box = box; + t.confirm = (fn) => { sandbox._confirm = fn; }; + t.state.data = { projects: [{ id: 'p1', name: 'p', chats: [] }], uiPrefs: {} }; + return t; +} + +// A chat holding exactly the given agents, made current. +function chatWith(t, agents, routes) { + t.state.routes = Object.assign({ apiMode: false, providers: {}, cli: {} }, routes || {}); + const chat = { id: 'c1', title: 'c', agents: agents.map(a => Object.assign({}, a)), workspace: null }; + t.state.data.projects[0].chats = [chat]; + t.state.currentChatId = 'c1'; + return chat; +} + +// The
+ +
@@ -1689,13 +1694,25 @@

Get Started