Why the code is the way it is, with the incidents and reviews behind each choice. Read the matching entry before changing the behavior it describes, and add an entry when you make a choice a future reader would question.
- Native & Bridge MCP - Codex has NO MCP server (it once did; codex-cli 0.154.0 ships none and
openai/codexhas nomcp-servercrate left -codex mcpmanages external servers andapp-serveris a different protocol), so GPT is reached by spawningcodex execfromcore/providers/codex.jsbehind the unified server'sask-gpt/ expert tools - advisory-only, single-shot. Gemini requires a bundled bridge (server/gemini/index.js) that wraps the Antigravity CLI (agy) in print mode. Grok has no MCP or CLI server mode, so a bundled bridge (server/grok/index.js) wraps the xAI Responses API (/v1/responses) directly - advisory-only (no file editing), but it can READ attached files (files:[{path|file_id|file_url|dir}], optionalroots[], per-entrymodefor upload-vs-inline delivery); uploaded files are SHA-256 dedup-cached locally, auto-expire (7-day default,GROK_FILE_TTL_SECONDS), and are managed with/grok-files(server/grok/files-admin.js:list/prune/gc). Details in TECHNICAL.md § Grok files and cleanup. OpenRouter uses a bundled bridge (server/openrouter/index.js) that calls any OpenAI-compatiblePOST {apiBase}/chat/completionsendpoint - advisory-only, text-inline file attachment only ({path}/{dir}; no upload path), config-driven via~/.config/deliberation/config.json(Windows:%APPDATA%\deliberation\config.json; override withDELIBERATION_CONFIG). Details in TECHNICAL.md § OpenRouter bridge. - Single-shot + multi-turn - Single-shot for advisory (full context per call), multi-turn via
threadIdfor chained implementation and retries - Dual mode - Any expert can advise or implement based on task. In
core, implementation is two-lock gated: a write happens only when the provider was constructed withallowImplement:true(construction lock, incore/providers/codex.js+antigravity.js) AND the request carriesmode:"implement"(request lock,DelegationRequest.mode, a closed"advisory"|"implement"enum defaulting to advisory). A stray or injectedmodealone never writes; the OS sandbox string (workspace-write) stays provider-internal so callers cannot smuggle argv.capabilities.canImplementreflects the construction lock, so discovery is honest per process. Gemini's credential env-scrub (*_KEY/*_TOKEN/GIT_ASKPASS/SSH_AUTH_SOCK) runs in both read-only and write mode - a write run edits the worktree but never receives the operator's keys. The construction lock is left OFF in the live composition root today, so the running server stays read-only; the unified-serverimplementtool + cache-bypass + forced audit record are part of the MCP consolidation. Grok/OpenRouter remain advisory-only (canImplement:false). See TECHNICAL.md § Implementation mode. - Synthesize, don't passthrough - Claude interprets expert output, applies judgment
- Proactive triggers - Claude checks for delegation triggers on every message
- Opt-in session store -
consensus/consensus-step/ask-allruns persist only whensessions.persistis on (default off); per-file JSON at<XDG cache>/deliberation/sessions/(overrideDELIBERATION_SESSIONS), secrets scrubbed, retention by count + age (-1= unlimited). SingleschemaVersion:1(no dual-version support; loop runs carry per-opinion verdict/criticalIssues + converged/confidence/rounds, synthesize runs carrysynthesis). Tools:session-get/session-revisit/session-annotate. Body capture is a separate opt-in: the per-opinion RESPONSE body (opinion.text) is stored ONLY whensessions.captureTextis also on (default off) - off = summaries only (question + verdict/criticalIssues); on = body, secret-scrubbed (mandatory) then best-effort PII (email) stripped. Gated uniformly at the single writer (persistRun);debug.jsonlNEVER receives body text regardless. Details in TECHNICAL.md § Session persistence. - Consensus engine SSOT - one pure state machine (
core/consensus-loop.js) behind two drivers (consensusserver-side,consensus-stephost-driven); the live/consensusis a thin driver overconsensus-step. See Consensus engine. - Orientation auto-attach (on by default) -
core/orientation.jsresolves a small bundle of high-signal repo files (fixed priority: AGENTS.md, else CLAUDE.md - never both - then package.json, pyproject.toml, Cargo.toml, go.mod, tsconfig.json, main.tf, then README.md; capped atorientation.maxFiles, default 6, AND a content-byte budgetorientation.maxBytes, default 16000, about 4K tokens; stat-only, never reads content, never throws). Size, not file count, is what costs tokens: a measured 37K-token Grok prompt did not move whenmaxFileswent from 6 to 3. Files that fit go whole, then the highest-priority file skipped for size is cut to the remaining budget (dropping lower-priority whole files when they are what leaves too little room), at most one cut file; each entry carriesheadBytes+mode: "inline", and both bridges read only that head throughcore/head-read.js(non-blocking open, fstat regular-file check, cut at a line break,[truncated: first N of M bytes]marker, content plus marker within the bridge per-file cap).headBytesis a new path-only field: a{dir}entry keepsmaxBytesas its walk cap.orchestrate.jswithOrientationgate injects the bundle into file-blind providers (Grok, OpenRouter - those wherewalksFilesystem === falseinProviderCapabilities) ONLY when the caller passed no files of its own. Injection happens BEFORE the dedup cache key is computed so a now-file-bearing request correctly skips the in-session result cache. The peer fan-out AND the arbiter blind pass are oriented; verdict/adjudication/revision passes are NOT. Bundle travels infiles[], never the shared prompt - zero cross-contamination. Provider bridge caps apply. Config:orientation: { enabled: true, maxFiles: 6, maxBytes: 16000 }(default ON since 3.29: with it off, a file-blind reviewer judged a plan against a repo it could not see, flagged missing pieces that existed, and the loop spent a round on a false disagreement; 16 KB per file-blind voice per round is small next to the persona plus plan). Details in TECHNICAL.md § Orientation auto-attach. - A provider stub is an error, not an answer - but a terse answer is an answer -
agycan exit 0 having printed only a preamble ("I will begin by finding the repository directory..."), andgrok-4.6(agentic-trained, reached withtools: []) can end its turn on "I'll verify the cited files..." (99-162 chars) waiting for a tool call that never comes. Any non-empty text used to flow downstream as a real opinion, and inside consensusparseReviewturned it intoverdict: null- silently blocking convergence with no diagnostic. The rule is ONE shared helper,core/answer-floor.js stubReason(text, minChars), called by both bridges: a reply is a stub when it is shorter thanGEMINI_MIN_ANSWER_CHARS/GROK_MIN_ANSWER_CHARS(default 1, i.e. empty;0disables both checks) OR is under 400 chars and opens with an intent phrase FOLLOWED BY an exploration verb ("I will begin by...", "Let me check..."; a bare "I will." or "I'll go with B" is an answer); it fails aserrorKind: "empty"with the reason in the message, andcallProviderretries once. The intent check is new for Gemini (Grok already had it). The first cut was an 80-char LENGTH floor; issue #180 showed why that is the wrong signal - the reporter's smoke prompt madeagyanswerok(2 chars, exit 0) and every call failed while the CLI worked by hand. A character count cannot tell terse from stub; the opening phrase can, and it catches both observed stubs. Gemini applies it on the clean-exit AND the drain path (validity must not be timing-dependent). The Grok bridge also always seeds the system turn with a runtime note - no tools, no filesystem, no further turns, answer now. Two more lessons from #180:toErrorResultnow forwards the bridge'smessage(bounded; never into the debug log or session store), because the coarseerrorKindalone hid a one-line diagnosis behind hours of sandbox instrumentation; andagy --helpprints to STDERR on 1.1.x, so the--modelcapability probe reads both streams - the pin had never actually been sent before, and agy rejects the built-inauto-gemini-3alias on 1.1.x (invalid model selection). That ONE pin self-heals because it ships in every install's config:runGeminiretries once without it (within the remaining budget), warns once, omits it for the rest of the process, and the result says so (pinDropped: true; unifiedmodel: "agy-settings-default"). Any other rejected id is the operator's own pin and fails loudly asmodel-not-allowedwith agy's catalog in the message - never a silent model swap. The pin is found by position (right before the-ptail), never by scanning argv values. Details in TECHNICAL.md § Answer floor. - Observability + per-provider progress (host-neutral; every result carries
ms+ effectivereasoningEffort, HTTP results add tokenusage):
panel+ask-onetools -panelechoes the exactselectForAskAllset (fanout cap applied) WITHOUT dispatching;ask-oneruns one named provider./ask-all(commands/ask-all.md) callspanelthen issues N parallelask-onecalls in ONE turn, so each provider renders independently as it lands (visible progress) while keeping parallel wall-time. The legacy single-callask-alltool is retained. When the dashboard journal is on,panelalso opens afanoutrun and returns itsrunId;/ask-allpasses it to everyask-oneso the dashboard draws one run (only ids that this process'spanelopened are joined). Empirically, Claude Code does NOT render mid-call MCPnotifications/message, but DOES surface each parallel tool result as it settles - so the per-provider path is the progress lever on this host.- Universal debug log (
core/debug-log.js, configdebug.enabled, OFF by default) - an injected logger emitted AT THE SOURCE incore(askAll/askOne/consensus/runToConvergence), so the Claude host-arbiter path and the in-core provider-arbiter loop log identically. Records latency, reasoning effort, HTTP token usage, and voting/approval outcomes; NEVER prompts/responses/issue text (ALLOWED_KEYSwhitelist enforced on write). Default path<XDG cache>/deliberation/debug.jsonl(overrideDELIBERATION_DEBUG_LOG). - In-session dedup cache (
core/result-cache.js) - identical advisory re-asks return instantly; LRU + 10-min TTL; errors never cached; file-bearing requests skip it;session-revisitbypasses it. Wired to the advisoryask-all/ask-onepaths only (NOT the consensus loop). analyzetool (core/analyze.js+commands/analyze.md) - reads the debug log back (tail-bounded, pre-aggregated server-side) for per-model latency/tokens/error/effort (Lens A) and the session store for verdict agreement-rate (Lens B), then returns advisory tuning suggestions (disable a slow/redundant model inask-all, lower an OpenRouter model's reasoning, adjustmaxFanout). The two lenses are NOT joined (no shared run id). This operationalizes the deferred "measure-then-recommend" lever. Read-only; writes nothing. Codex reasoning points atproviders.codex.reasoningEffort; Gemini reasoning is surfaced as external advice (it lives in agy, outside deliberation's config).- MCP
loggingcapability +notifications/messagesink - declared + emitted per provider settle; spec-compliant, so a host that renders server log notifications gets live progress for free.
-
Resolve the CLI, never interpose a shell -
core/resolve-bin.jsturns a bare command name into something Node can spawn, becausenpm install -gon Windows writes acodex.cmdshim thatspawnwithshell:falserefuses to execute (issue #170: every GPT call failed, plus threeagysites inserver/gemini/index.jsincluding the boot gate that callsprocess.exit(1)). Resolution on win32 is FIRST-MATCH-WINS in PATH then PATHEXT order (libuv/whereprecedence): a.exe/.comis spawned directly; a.cmd/.bat/.ps1shim is bypassed viaprocess.execPath+ the npm package's own entry (@openai/codex->bin/codex.js, probed in both the global and thenode_modules/.binlayout; no such package exists foragy, so it errors explicitly); scanning continues past the first match ONLY when it is a shim with no package behind it, and then anything usable wins (a.exeor another shim that does have its package) - deliberately out of shell order, because failing while a working install sits on PATH is worse; nothing found passes through and keeps today's ENOENT. An explicitly NAMED shim (CODEX_BIN=codex.cmd) is recognised as one, so the error is not "set CODEX_BIN" to someone who just did. Every non-win32 platform and every other path-bearing name pass through untouched - the AGY_BIN fixtures intest/_helpers.jsdepend on it.shell: truewas rejected on LIFECYCLE grounds, not injection (the prompt is stdin, argv is a constant): the shell would become the child, sochild.kill("SIGKILL")would kill the shell and leave the CLI running past the timeout the consensus circuit breaker depends on. Overrides:CODEX_BIN,AGY_BIN. Cross-platform part: a failed launch now reportsnot-found(codex) /missing-cli(gemini) instead ofunknown, keyed on a structuralspawnFailedflag rather than a stderr pattern - the same precedent astimedOut, because a coding agent legitimately prints ENOENT about the user's own files. The Gemini gate lives inrunGemini, NOT only behindrequire.main === module:server/mcp/index.jsrequire()s the bridge, so a boot-only gate never runs in the process that serves calls. CI is ubuntu-only, so win32 is asserted through injectedplatform/env/exists, thecore/paths.jspattern. Details in TECHNICAL.md § Windows CLI resolution. -
A transport ceiling is not a network fault, and a dead peer is not a voice - four defects that turned one bad provider into a 10-minute consensus round, all confirmed from
debug.jsonl:
- undici's ceilings are not raisable. Node's
fetchgives up after 300s waiting for response headers and 300s between body chunks, with no public API to change either. The Grok bridge postedstream: false, and xAI sends nothing until the answer is done, so everygrok-4.6call that thought for over five minutes died at exactly 300s -providers.grok.timeout: 480000was unreachable. Worse, the bareTypeError: fetch failedwas classifiednetwork, whichcallProviderRETRIES, so the round paid 600s to fail. Now:server/grok/index.jsstreams (SSE, branching on the responseContent-Typeso a non-streaming upstream and every error status still work;response.completedcarries the same object the non-streaming call returned, so the parser/usage/outputpath is unchanged;GROK_STREAM=0reverts), andcore/provider.js classifyFetchFailuremapsUND_ERR_HEADERS_TIMEOUT/UND_ERR_BODY_TIMEOUTtotimeout- never retried. OpenRouter only escaped this because its API emits processing keepalives. networkwas a black box. The message is dropped bydebug-log.jsALLOWED_KEYS, the session store only writes on a terminal transition, and the panel prints the kind - so a DNS failure, a socket reset, a 300s undici ceiling and a local throw were one indistinguishable label in the only place provider health is diagnosed.provider_resultnow carrieserrorCode(the rejection'scause.code; the key was already whitelisted forpersist_failed). A bare errno/undici symbol, never a message.- The breaker was on the wrong path and counted the wrong thing.
timeoutStreaklived inrunToConvergence(the provider-arbiter driver) and counted onlyerrorKind === "timeout". The live/consensusdrivesconsensus-step, which had NO breaker: it re-ranselectForConsensusevery round and re-dispatched peers that had failed every round since round 1. The streak now lives onLoopState.errorStreakincore/consensus-loop.js(updateErrorStreak/trippedProviders, folded in byaddOpinions), counts ANY error, and is read by both drivers - the same SSOT rule as #7.dispatch_peersreports removals asdroppedProviders[]and terminates withstopReason: "all-providers-circuit-broken"rather than looping on an empty fan-out. consensus.maxWallMsapplied to one driver.consensus-stepis a series of stateless calls, so it now carriesstartedAton its stored state and gates STARTING a round (never a fan-out in flight), returningstopReason: "budget-exhausted". Also: thenetworkretry no longer fires in the same millisecond as the failure (1s pause), andupstreamis now actually inRETRY_ONCE_KINDSrather than merely documented as retryable. Details in TECHNICAL.md § Circuit breaker and § Grok streaming.
Review follow-ups (same PR). Code review found the first cut of the above wrong in ways worth recording, because each is a general trap:
- An abort has two shapes.
classifyFetchFailurecaught theAbortErrorbut not the WRAPPED form undici produces when the controller fires mid-body:TypeError: terminatedwhosecauseis a DOMException namedAbortError. Neither its name nor its message mentions aborting, so the bridge's own ceiling fell back into the retryablenetworkbucket - reintroducing the exact double-billing through the door built to close it. - A fixture that mirrors the parser can never disagree with it. The SSE splitter matched only
"\n\n", so a CRLF stream never framed; the tests passed because the fixture also emitted"\n\n". Protocol tests need spec-derived inputs. Framing now lives incore/sse.js(LF/CRLF/CR) with the xAI semantics inserver/grok/stream.js, and the event name is read from the SSEevent:line when the JSON carries notype. - Prefer, do not trust.
final || deltasdiscarded a complete answer whenever a completion event arrived unparseable;response.incomplete(a truncated but usable answer on the non-streaming path) was thrown away as a failure; whitespace-only deltas escaped a truthiness check into a non-retryableparse. - Streaming by default needs a way to be wrong. A stream carrying no recognized event now retries once with
stream: false, so a drift in the event vocabulary degrades instead of taking Grok offline - which, with the new breaker, would have silently dropped it from the panel. - A terminal transition is not the same state as a round.
terminateLoopfires BEFORE a fan-out, so it must persist the last COMPLETED round's opinions fromhistory(resultswas already reset bysubmitRevision) and report the rounds that finished, not the one about to start. - Consolidating a rule means migrating every caller.
no-providerswas added toconsensus-stepand not torunToConvergenceon the same PR that argues for one rules layer;droppedProviderswas recomputed from the whole streak map, so it re-announced every round and could name peers absent from the panel.
-
Another tool's subcommand is not an API, and a CLI that forwards is worse than one that errors -
.claude-plugin/plugin.jsonregistered adeliberation-codexserver ascodex mcp-serverfor as long as that subcommand existed. codex-cli removed it (0.154.0 ships no MCP server mode;codex mcpmanages external servers,app-serveris a different protocol, and nomcp-servercrate remains inopenai/codex). The removal was invisible at the only moment it mattered: codex forwards an UNKNOWN subcommand to the interactive CLI, which exits onError: stdin is not a terminal- exit 0, no stderr about a missing command - so the host reportedCONNECTION_CLOSEDand two tools (codex/codex-reply) stayed advertised, forever unable to answer (issue #185). Same shape as #9 one layer down: a stub is not an answer, and a forwarded subcommand is not a server. The fix was deletion, not a probe -core/providers/codex.jsalready spawnscodex exec(a subcommand that exists), and the Codex/Kiro host manifests already shipped only the unified server, so dropping the entry left ONE GPT path instead of two advertised ones. What the delete costs, honestly: GPT multi-turn (codex-reply) and GPTworkspace-write- both already dead upstream, so the docs claiming them were the remaining bug. Implementation now means Gemini. The lesson generalises to every vendored CLI invocation: pin to the narrowest subcommand that the vendor documents as stable (exec), and treat "the process started but said nothing useful" as a failure signal, never as a connection. -
The host's cap wins, so fail under it and name it; a peer that cannot answer is not a voice - three independent defects made the plugin unusable in Claude Code on the web, each invisible from the error surface. (a) The web host exports
MCP_TOOL_TIMEOUT=60000and kills any tool call at 60s; the provider ceilings (180-600s) were unreachable, so the host reportedtool "ask-grok" timed out after 60sand the provider's own error path -errorKind, message, circuit-breaker input - never ran.core/host-budget.jsreads the cap and clamps every ceiling tocap - 5000ms at the point it is applied (Codexask,runGrok,runGeminiOnce,callOpenRouter- the standalone/ask-*bridges included); a timeout that fired because of the clamp says so inmessage(Host MCP_TOOL_TIMEOUT=60000 caps every MCP tool call ...). Review caught that a per-leg clamp alone re-arms the whole cap for every SEQUENTIAL leg (a retry, the arbiter passes of a round, Gemini's 120s drain after a 55s soft timeout = a 175s call under a 60s cap), so the server takes the clock at tool entry and every leg is stamped with what is LEFT (fitToHostBudget->req.hostBudgetRemainingMs, a separate field so a shorter configured ceiling still wins; the adapters' clamp reads it); a retry whose backoff would not fit is not even slept on; the Grok fallback and stale-file retry spend the same budget;runToConvergencestopsbudget-exhaustedonce the cap is spent; and the Gemini drain is off under any cap (graceWithinHostBudget). Deliberately NOT a config key: the cap is the host's, so the fix (raise it where the host is launched) is named, not emulated. Follow-up: the clamp alone still left every web call at 55s, and Claude Code resolves the cap per server asconfig.timeout ?? MCP_TOOL_TIMEOUT ?? default(progress notifications never extend it), so.claude-plugin/plugin.jsonnow declares"timeout": 1800000on every server AND mirrors it into the server env asMCP_TOOL_TIMEOUT- without the mirror the server inherits the host's 60000 and this very clamp cancels the override (test/plugin-manifest.test.jskeeps them equal). The "still times out at 60s" report that prompted it came from a container whose seed marketplace pinned v3.14.8 two minutes before v3.14.9 shipped;/deliberation:doctorand the hint now say "older install" instead of "raise the variable". (b) Making the clamp fire exposed that Grok's ceiling was unenforceable while an SSE body was flowing: on Node 22 aborting the fetch signal does not reliably error a streaming body (chunks kept arriving 20s+ past the abort) and thefor awaitloop holds the stream lock, so an outsidecancel()is refused.core/sse.js readSseStreamnow takes the signal, races eachreader.read()against it, and cancels the reader it owns - without awaiting the cancel, which is async and need not settle (review reproduced a pendingcancel()holding the timeout hostage). (c) codex-cli 0.154 readsCODEX_API_KEYorauth.json, neverOPENAI_API_KEY- the only key the web container exported - so every GPT call was401 Missing bearerafter 17s of reconnects. The first fix forwardedOPENAI_API_KEYasCODEX_API_KEY, and that broke every laptop that exportsOPENAI_API_KEYfor other tools: codex ranks the env key aboveauth.json, so the ChatGPT subscription was bypassed and calls died onYou have no credits remaining. The rule now (codexEnv()): a ChatGPT credential (acodex login, or a Business/EnterpriseCODEX_ACCESS_TOKEN, which codex reads natively and ranks aboveauth.json) always wins andCODEX_API_KEYis dropped from the child's env; without oneCODEX_API_KEYis used;OPENAI_API_KEYis never used and never reaches the child. A web host must get a login of its OWN: a ChatGPT refresh token works once, so anauth.jsoncopied from a laptop (or restored from a secret every session) dies on the first refresh either side makes, with...your refresh token was already used. That text names neither "auth" nor "login", so it classified asunknown;classifyCodexnow maps codex's exact phrase (access token could not be refreshed, stderr only -codex execechoes the prompt there, so a barerefresh tokenmatch would turn a rate limit on a token-code review into a non-retryableauth) toauth, and the message LEADS with codex's line and the fix, because codex prints its banner and the whole echoed prompt on stderr before the error (codex builds its own result; it never passes throughtoErrorResult, so its message is not capped - an earlier version of this note claimed it was). The fix itself is #15. Andcodex.health()was hardcoded{ok:true}whileantigravity.health()checked only that the bridge object existed, so the panel kept listing peers with no CLI or no credential and every/consensusround paid to discover it. Health is now stat-only and real (codexHealth,bridge.cliAvailable),registry.selectFor*takes theunhealthymap and reportsunavailable[]with reasons, andpanel/ask-all/consensus/consensus-stepall consult it before dispatch. The Gemini bridge's bootprocess.exit(1)whenagyis missing stays: an honestCONNECTION_CLOSEDbeats a server advertising tools it cannot serve (#13), and the unified server now simply omits gemini./deliberation:doctorreports all three. Rule of thumb: every ceiling in the process must be at or under the outermost one, or the outermost one is the only one that ever fires - and it is the one that says nothing. -
Log in where the answer is needed, and show the code where the user is looking - a Claude Code web container has no browser and must not borrow a laptop's
auth.json(#14c), so a ChatGPT Plus user had to typecodex login --device-authin every session. The unified server now builds codex withdeviceLogin: true: with no credential, or a spent one,ask()startscodex login --device-authitself (makeDeviceLogin, one login per process, replaced when its code expires), parses the link and code (parseDevicePrompt- codex's human text, so loose, with the raw output as the fallback), and asks the HOST to show them through MCP elicitation (confirmLogin: URL mode when offered, else a form). That meant three protocol changes inserver/mcp/index.js: negotiate the client's protocol version instead of always answering2024-11-05(elicitation is2025-06-18+), record the client's capabilities atinitialize, and route replies to server-sent requests (the stdin loop handles a batch's replies before its requests, because a tool call earlier in the batch may be the one waiting). Timing is the whole design. A SessionStart hook cannot do this: a code lives 15 minutes, so a session-start code is spent before the first GPT call and wasted on sessions that never make one. A tool call cannot show output mid-call, so without elicitation the code rides in the RESULT and GPT answers on the next call;dispatch_peerstherefore carries an errored voice's boundedmessage(plainMessage), and the command files say to print it as-is. The wait is bounded by the code's lifetime, 5 minutes, and half the remaining host budget; approving in the browser ends it even if the dialog is never answered; the codex run afterwards gets the budget minus the wait. Review found the traps: the link is the URL with/devicein it (an update notice can print another URL first), one code gets one dialog, elicitation is gated on the NEGOTIATED version (capabilities alone are not enough), a login that died is reported without its dead code, the link leads the message so any truncation keeps it, and the anti-phishing line codex prints is carried into both the dialog and the result. A second review round added: a DECLINE kills that login (only a dismissal or silence keeps the code alive, because a device login someone else completes binds this machine's codex to THEIR account, and every later prompt goes to their history), only an openai.com / chatgpt.com URL is ever offered as the link, and an unused code is reaped at expiry by us rather than trusting codex to exit. Round three: no dialog under 15 s of host budget (the 1000 ms budget floor would otherwise open a dialog nobody can answer in time), and a decline that lands after the login completed says so and points atcodex logout- deleting auth.json ourselves was rejected, because on the spent-login path that file is the user's own. A separate GPT review then reproduced six more, all fixed: the 20 s no-code timer was never cleared, so EVERY login died 20 s after showing its code (tests that finish in milliseconds could not see it - nowpromptWaitMsis injectable and CX-login-keepalive waits past it); killing the npm launcher left the native login polling (now a process group, killed as a tree); SIGTERM skipsexithooks and nothing handled stdin closing (now both kill live logins); a pipe chunk ending mid-code made the regex acceptABCD-1234(tokens now need trailing whitespace; CX-login-split tries every split point); a prompt quoting codex's exact error could start a login (lines that are part of the sent prompt are skipped); and an abandoned dialog stayed open (nownotifications/cancelledonce no caller waits, and on timeout). Its re-review then caught that the cancellation was dead in production - the composition root'sconfirmLoginlambda dropped the third argument, the AbortSignal, and each side's unit test passed alone (EL-e2e now drives the real server over stdio with a fakecodexviaCODEX_BIN) - plus a spent budget read back as a fresh one (spendHostBudgetfloors at 1 ms becauseclampToHostBudgettreats a non-positive budget as none), the echo filter hiding a genuine error line that the prompt also quoted (each prompt line is now consumed once), shutdown not refusing logins started afterwards, and a decline misreported when credentials landed just before the kill (theauth.jsonmtime is the truth, not the exit code). A third pass found that baseline recorded per caller instead of per shared login (a caller joining after the approval was saved compared against the new file), and that waiting for codex to print its code was bounded only by the 20 s prompt timer, not by the host budget; both fixed, both reproduced first. Health reports "no credential" asokwhiledeviceLoginis on, because that is the one gapask()closes itself; a panel that skipped codex could never deliver the code. Unverified at merge time: whether Claude Code on the web renders elicitation dialogs - the result path covers it either way. The first real web session then showed the design's blind spot: it assumed the agent always MAKES the GPT call. The agent readcodex.js, decided a logged-out call "would only return the device-auth link, not an answer", and answered from local config, so the login never started. Fix (chosen via/ask-all, 4/4 agreeing): acodex-logintool (provider.login(), the samesignInwithout a question, joining the same shared login) and a/deliberation:codex-logincommand the USER can run without the agent's judgement, plus a "never skip a GPT call because GPT looks logged out" line in the command files and in theask-gpt/ask-onetool descriptions (read when the call is decided). Rejected: starting a login frompanel/health (a probe must stay side-effect-free) and makingask-gptcallcodex-loginfirst (one more step to skip, and ask() already logs in). A prompt rule is not a mechanism; a user-runnable command is. Then the first sessions that DID use it measured the last flaw:ms: 300791, exactly the 5-minute dialog cap. The web host advertises elicitation and never answers, so every login waited the full cap before showing a code codex had printed in a second, and burned a third of that code's 15-minute life./ask-allwas 5/5: never await the dialog. It is sent and the call returns; the outcome belongs to the LOGIN (accept lets it land, decline ends it whenever it arrives, a settled login cancels the dialog), andDIALOG_WAIT_MAX_MS/DIALOG_MIN_BUDGET_MS/ the half-budget split are gone along with the same-call answer they bought. The same five rejected the tempting fix - prewarming a login at session start - for three reasons: a 15-minute code would be wasted on every session that never asks GPT, device codes are rate-limited, and a server-side prewarm cannot show the code anyway (Claude Code does not rendernotifications/message). Prewarm saves a second that no longer exists. The message now puts the link and the code each alone on a line: a code the user must retype is a UI, not a log line. Login before the fan-out (reverses one rejection above). Login on first use still lost GPT for the whole first run:/ask-allsynthesized without it, and in/consensusa codex voice that errors every round is circuit-broken off the panel, so the user had to remember/deliberation:codex-loginat the start of every web session.codex.health()now returns{ ok: true, needsLogin: true }(ok, so GPT stays on the panel),panelreports it asneedsLogin[](for: "consensus"for the consensus panel), and/ask-all,/consensusand/ask-gptgate on that FIELD:codex-login, print the link and code,AskUserQuestionto approve,codex-loginagain to confirmauthenticated, then dispatch. "Makingask-gptcallcodex-loginfirst" was rejected above as one more prose step to skip; the difference now is a structured flag the command reads, not a judgement about whether GPT looks logged out. What stays:panelnever starts a login (it reads a flag), no SessionStart login (the code starts inside a command the user ran, so it is spent on that run), and the in-call login remains the fallback for a spentauth.jsonthatstatcannot see. -
Ground the date in code, leave the lookup to the host - external PR #204 proposed a standalone "mandatory temporal grounding" skill (run
date -u, query AWS/Terraform MCP tools, search the web) pasted into AGENTS.md and the host rules. The problem is real - a delegate trained months ago calls a newer model or tool "hallucinated" and files a false critical issue that blocks a consensus round - but prose aimed at the delegates cannot fix it: Grok and OpenRouter run withtools: [], the vendor tool names exist only on some hosts, and persona text is paid on every call times panel size times rounds./ask-all(5/5) split it by who can act. The date is a FACT only code can supply, socore/grounding.js groundingNote()stamps it, with a one-sentence no-denial rule (unrecognized =[unverified], never "non-existent"), into every prompt builder (CodexrunOnce, GeminibuildAgyArgs, Grok and OpenRouterbuildInitialTurns) - the builders, notorchestrate.js, because they are the one place every path meets, standalone/ask-*bridges included. The rule ships WITH the date because a date alone still lets a model deny a name it does not know. Retrieval is the host's job:docs/tool-guide.md"Time-sensitive questions", every/ask-*command,/consensus, and the CONTEXT section ofrules/delegation-format.mdtell the orchestrator to verify and inline facts with an as-of date and source. Personas are unchanged, no skill, no config key. Details in TECHNICAL.md § Date grounding. -
The dashboard tails a journal the MCP processes write - the local dashboard (
deliberation-mcp dashboard,/deliberation:dashboard) draws each run as a state graph live and after the fact. Every host session runs its own stdio server, so the MCP processes append to a shared journal (core/journal.js, one<runId>.jsonlper run under<XDG cache>/deliberation/runs/,DELIBERATION_RUNS) and the dashboard is a separate process that tails it. Rejected: a localhost socket (events before the dashboard started are lost) and hosting it inside an MCP process (sessions contend for one port, and the view dies with its session). Events come from the one chokepoint every provider call passes (callProvider:call_start/call_end) plus the tool handlers (run_start/state/arbiter/run_end);consensus-stepuses itssessionIdas therunId, and/ask-alljoins its parallelask-onecalls through therunIdthatpanelreturns (only ids this process'spanelopened). Privacy is two switches:dashboard.capture(metadatadefault;contentadds prompts/responses,scrubSecretsthencapTextat write, per-kind key whitelist) anddashboard.showPII(PII redacted bycore/redact.jsat SERVE time, so flipping it needs no rewrite). Journaling never throws into a delegation, the debug log's content ban is unchanged, and the dashboard is read-only (GET/HEAD). Threat model: loopback bind, Host/Origin pinned to127.0.0.1:<port>/localhost:<port>against DNS rebinding, a per-start 32-byte token in the printed URL swapped for anHttpOnly; SameSite=Strictcookie (browsers send it to every 127.0.0.1 port: Host/Origin checks andnosniffstop a web page from reading data cross-port, but a local service that receives the cookie can replay the token server-side, e.g. curl withHost: 127.0.0.1:<port>plus the cookie or?t=, and read all dashboard data. Accepted because any local listener is already inside the same-machine trust boundary - the design scope is a single-user laptop;dlb_dashis only issued after the user opens the tokenized URL, and a restart rotates the token. On shared multi-user machines, do not enable the dashboard orcapture: "content"), the token kept off argv via a 0600 redirect file, and CSP +textContent-only rendering. Run status is derived at read time (deriveStatusinserver/dashboard/runs.js), never cached, because a crashed writer never touches its file again: arun_endwins; afanoutis done when every listed provider's latest call ended, or after 60 s of quiet with the uncalled providers asundispatched, andabandoned(never-dispatched) with no call after 20 minutes; aconsensus-stepidle past the loop store TTL isabandoned(expired); otherwise the run is live while its writer pid is (pid reuse checked by start time, Linux only). Packaging:prepackcopiesserver/dashboard/uitodist/dashboard-ui; the circular require (dashboard ->server/mcp/index.js) makes esbuild wrap the entry, so the main guard comparesrequire.main.filenameto__filenamerather thanrequire.main === module(test/mcp-bundle.test.js). Details in TECHNICAL.md - Dashboard. Analyzer + project + restart. Everyrun_startcarriesproject(core/project.js: grouped by the git remote'sorg/repowith a hashed host+path id, falling back to the main git dir when there is no remote, plus the checkout as a hashed workspacews; stat and read only, never a spawn. The remote is the key because the same repo is often checked out several times (T3 or other tool worktrees, review clones under/tmp), and debugging is per repo first; the raw URL is never stored since an https remote can carry a token. Runs from beforewsare regrouped at read time when their root still exists, never rewritten) and everycall_startthe request size as sent (promptChars/fileBytes, after orientation and consensus prompt building) plus the ceiling the attempt actually got (grantedMs) and where it came from, attributed by ORIGIN (own/shared/outer/host,sharedBy= the slowest SELECTED peer that set a fan-out deadline, dispatched or not): attributing by "which number is smallest" mislabels an ordinary call once setup time makes its own generated deadline the minimum. The Analyzer (server/dashboard/analyzer.js,/api/analyzer) reads only that metadata. Model overlap is consensus-only, becausecall_endcarries no verdict and ask-all answers are never parsed; a drop candidate also needs arbiter decisions (arbiter.decisions[], consensus-step only), so "agrees with everyone" alone never flags a model. Timeout advice treats timeouts as censored data (a success-only p95 understates large requests) and always suggests more than the calls already got.dashboard --stop(server/dashboard/stop.js) signals a pid only when argv position and start time prove it is the dashboard: the token probe proves a listener, not a pid.dashboard-restart.shandreload-mcp.shboth go through it. The Stats tab still reads the debug log, so it is the one view the project filter does not reach. -
Local providers (Ollama & LM Studio) & fail-closed dispatch - local inference via Ollama and LM Studio uses the OpenAI-compatible HTTP bridge with keyless defaults and dedicated provider blocks (
providers.ollama,providers.lmstudio). To prevent credential leakage and transport confusion: (a) Provider lookup must fail closed -pinDelegatesandresolveArbiternever fall back across transports. An unregistered provider delegate is excluded from dispatch and reported inunavailable[]. An explicit arbiter model targeting an unregistered provider warns and auto-selects from healthy registered providers, returning an error without dispatch only when the pool is empty. (b) Standalone OpenRouter bridge (server/openrouter/index.js) servesprovider: "openrouter"records only (model-not-allowedon others), and evaluates keys in strict precedence: per-recordapiKeyEnvwins; record with noapiBaseor matchingor.apiBase(stripping trailing slashes) uses provider key (OPENROUTER_API_KEY); any custom proxyapiBasewithoutapiKeyEnvdefaults to keyless (never leaking the provider key). Mid-session replies (openrouter-reply) reuse stored effective endpoint and env var name (secrets are never stored in memory), ensuring config edits mid-session do not reroute existing threads; switching alias or raw model re-resolves from live config. (c) Per-recordapiBase/apiKeyEnvremain standalone-bridge-only; the unified server emits a load warning when a record sets them.