Console permission surface: /permissions, /plan, --permission-mode + interactive approval (v1.23.0)#104
Conversation
… UI, recover empty reasoning-only replies Three linked fixes: 1. Provider editor "获取模型" now shows a per-model "+" and a "全部添加" (add-all) control that registers chosen models as aliases bound to the provider (POST /admin/models/aliases). For a brand-new draft the provider is persisted once first so each alias references a real provider. 2. A configured generic openai_compatible relay no longer falls through to api.openai.com for raw OpenAI-family ids. OpenAICompatibleProvider.supports() returns False, so the registry scan skipped it and a gpt-*/o*- id resolved to a fresh OpenAIProvider() with no base_url. ProviderRegistry.resolve now prefers a configured openai_compatible relay in the legacy fallback before the public-OpenAI default; vendor market kinds keep their own supports(). 3. A reasoning-only turn (model streams only chain-of-thought, or a relay mislabels the answer as reasoning_content) no longer surfaces as an empty reply. The reasoning loop nudges once for the visible final answer instead of fabricating placeholder text. Tests: +2 registry regression, +3 reasoning-loop, +3 provider UI cases. ruff + mypy + UI typecheck/lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…ase_url The admin "fetch models" probe already normalises a relay base_url (appends /v1/models), but the chat client used the raw base_url — so a relay entered as "https://relay" fetched models fine yet chat hit ".../chat/completions" (missing /v1) and failed. Add complete_openai_base_url() and apply it in OpenAICompatibleProvider .__init__ so the chat endpoint is completed the same way as the probe: - bare host / "/api" → append /v1 - already-versioned "/v1", "/api/v4" → unchanged (idempotent; covers the market kinds' /v1 defaults) - a pasted full endpoint (/chat/completions, /responses, /completions, /models) → trimmed back to the root - trailing "#" → verbatim escape for non-standard gateways Only the openai_compatible kind (+ market subclasses) is affected; Azure, Codex, and first-party providers construct their clients separately. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…p-style) Surface INLINE subagents (spawned + awaited during interactive chat turns) live in the browser, not just background dispatches. Backend: - LiveSubagentRegistry, fed from the single funnel every subagent event already passes through (JournalBackedEmitter.emit → SubagentSpawned/ SubagentEvent/SubagentCompleted). No change to the agent hot path. - /admin/subagents list + /admin/subagents/events/live now MERGE inline rows (registry) with background rows (store). SubagentStatusResponse gains depth/activity/source. Overview polls every 2s (was 10s) for a live feel. Frontend: - LiveAgentsPanel — Codex-style live cards (status badge, current-activity line, elapsed ticker, tool count, inline tag, supervisor→worker nesting). /admin/subagents swaps its table for it (and fixes a latent .subagents→.rows warm-up bug). - ChatLiveAgents — collapsible right rail in /chat showing the current session's subagents live (same SSE, session-filtered). Kill gated to background rows; click drills into the session timeline. - EventSource test shim in vitest.setup.ts (jsdom ships none). Tests: LiveSubagentRegistry unit + route-merge + SSE-snapshot (py); LiveAgentsPanel (ui). ruff + mypy + ui typecheck/lint clean. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…show in grpc_agent mode
In grpc_agent mode the agent runs in a SEPARATE process; inline subagents
emit SubagentSpawned/Event/Completed through the AGENT process's emitter, so
the gateway's emitter-observer (LiveSubagentRegistry) never fired and the
/subagents panel + chat rail stayed empty during real runs.
The gateway already bridges agent-process events into the live session SSE by
polling the shared journal (sessions_events._sse_stream). Feed the registry
from that same journal-read path: LiveSubagentRegistry.observe_journal_event()
consumes the journal row dict ({event_type, payload, timestamp_ms}) and is
called (best-effort, no emit → no double-journal/deliver) at the catch-up and
poll yield sites. Registry row updates refactored into source-agnostic
_apply_* helpers shared by the dataclass (observe) and dict
(observe_journal_event) paths; spawn/complete are idempotent so poll
re-delivery can't reset an advancing row.
Tests: +journal-dict lifecycle / idempotent-respawn / ignore-non-subagent.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…fter fix) console.debug at every resumeInFlight decision point (sessionKey, latest-turn status, each bail, backlog len + lastSeq, live-tail open, first live events) so we can pin WHY returning to an in-progress conversation doesn't stream in grpc_agent mode (frozen + static-only per repro). No behaviour change — debug logs only. Reverted once the precise fix lands. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…t cards - deep-research / web_search no longer hard-require providers.brave.api_key. Both use web.search, which defaults to the keyless DuckDuckGo backend, so they work out of the box (Brave/SerpApi optional). The unmet requirement was returning requirements_unmet and making research subagents flail → timeouts. NOTE: seeded copies in the data dir must be patched on deploy (seeding never overwrites). - LiveAgentsPanel cards are now expandable (click → inline detail: prompt, activity, tools, elapsed, state, finish_reason, summary). The chat rail uses it (expandable) instead of navigating away — so an inline agent's current status is visible on click. Global /subagents keeps its drawer. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…y not 404
Two prod console errors:
- /admin/sessions//cost (empty path segment → 404): CostFooter fetched before
a session key resolved. Guard load() to skip when the key is blank.
- /admin/sessions/{key}/replay 404 on in-progress conversations: a journaled
session with turns but no replayable messages yet (assistant/tool rows
aren't journaled until the turn completes) wrongly early-returned None from
_replay_from_journal → fell through to the write-dead legacy store → 404.
Per the function's own docstring, return the empty dump instead so /chat
renders a clean empty thread + reattaches the live stream. A genuinely
unknown session (no turn rows at all) still 404s.
(The /admin/tenants 403 is by-design — tenancy disabled — and already handled
non-fatally by fetchTenants; it's cosmetic browser logging, not a bug.)
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
… when unwired; resume-debug visible - Inline research subagents were dying with finish_reason=timeout mid-research: DEFAULT_MAX_WALL_SECONDS=60 is too tight for a general-purpose child making 6+ web search/fetch calls. Raise to 180s (300s ceiling still bounds runaways; the spawn tool schema advertises the new default to the model). - GET /admin/approvals returns 200 [] (not 503) when the approval gate isn't wired — an empty queue is the correct operator view, and it stops the admin dashboard's periodic poll from spamming a 503 in the browser console. The mutating decide routes still 503. - resume-debug diagnostics switched console.debug -> console.log so they show without enabling DevTools "Verbose". Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
…ubble Returning to a still-generating conversation had two defects: 1. No live continuation. The resume path tailed the cross-process SSE journal poll, which (empirically, in the two-process grpc_agent prod deploy) does not reliably stream an already-in-progress turn after a navigate-away/back — the bubble froze at the backlog snapshot and new tool calls only appeared on a manual re-entry. Add a client-side poll backstop in resumeInFlight: every 1.5s it re-fetches journal events past a shared cursor (resumeSeqRef) and reduces the fresh ones. It reuses the exact fetchTurnEvents replay that a manual re-entry uses (proven to return fresh events) and reduceEvent dedups by (turn, sequence), so it streams correctly whether or not the SSE tail also fires. Folds the old 5s status poll in as a completion safety net. 2. Duplicate frozen bubble. A multi-step agentic turn journals its intermediate assistant/tool message rows as it runs, so the settled transcript was rendering the in-progress turn (a frozen '已隐藏 N 个工具调用' bubble) ABOVE the live resume bubble. Exclude in-progress turns from _replay_from_journal — the resume path owns that turn live; finalizeJournalTurn invalidates the transcript query when it ends so the completed turn lands on the refetch. Also removes the TEMP [resume-debug] console diagnostics. Tests: new test_sessions_replay_in_progress (in-progress excluded; only-in-progress -> empty 200 transcript); UI resume suite green.
…stop being blocked Production repro: asking the chat agent to do multi-agent web research + PDF failed — every web_search/web_fetch/write_file call was denied with 'tool_not_allowed: not in the active skill's allowed-tools list', and subagents spawned with tool_allowlist=['web.search','web.fetch'] were rejected as privilege escalation. The agent then (correctly) refused to fabricate news and produced nothing. Root cause: skills declare allowed-tools in a dotted logical namespace (web.search, file.read, shell.run) that never matched the underscore wire tool names the runtime dispatches (web_search, read_file, run_shell). The allowed-tools gate and the subagent escalation check compared the strings literally, so the instant a scoped skill (deep-research / web_search / note-taking) was pulled, the union narrowed the catalog to names that match no real tool — blocking everything the model actually calls. The always-on skills (document-generator/visual-output-quality) declare no allowed-tools, which is why plain chat was unaffected and only research workflows broke. Fix: corlinman_agent.tool_aliases.canonicalize_tool_name maps dotted -> wire (explicit table for the REVERSED file.*/shell.* family, generic '.'->'_' for the rest), applied to BOTH sides of every comparison so a genuinely-dotted tool (blackboard.read) still matches. Wired into the servicer gate (_skill_allowed_tools_block) and the subagent filter (_filter_tools_for_child) — the latter keeps the parent's real wire names in the child's effective set so dispatch + the execution-boundary check stay correct. Genuine escalation (a tool the parent lacks) still rejects. Tests: test_tool_aliases (canon table + idempotence + escalation gating), servicer dotted-allowed-tools gate test; full subagent suite green (77).
`corlinman console` (in-process, no --attach) died with "no provider registered for 'cornna'" whenever the default model resolved to a custom provider (e.g. an openai_compatible relay) configured in the TOML. The embedded brain built CorlinmanAgentServicer() WITHOUT a provider_resolver, so it fell back to the offline-mock / legacy env-prefix table and never loaded the py-config provider/alias catalog — even though the gateway + agent server (which pass _ReloadingProviderResolver) resolve it fine. Fix: mirror the agent server's construction (main._main else-branch) — build a _ReloadingProviderResolver over CORLINMAN_PY_CONFIG and pass provider_resolver + aliases + subagent_config to the servicer. Degrades to the prior mock/legacy resolver on any failure. Lets the standalone CLI run the same provider/model catalog the admin UI manages, so a self-contained 'corlinman console' works token-free. Tests: console suite green (123).
…mplete Two interactive-mode polish items toward the claude-code REPL feel: * Boxed welcome card (model / session / data dir / brain + key commands) replacing the one-line banner. Degrades to the plain banner if rich's Panel is unavailable. * prompt_toolkit completer for /slash commands (name + aliases, with the command description as completion meta), complete-while-typing — the command palette. Built lazily so prompt_toolkit stays a soft dep. The renderer already streams tokens, renders tool calls (◐/✓/✗), the todo_write checklist, and a cost/status footer, so this closes the remaining welcome + input-affordance gap. Console suite green (123).
…tool blocks Interactive console (TTY only) now renders like claude-code: * assistant text → live rich Markdown (headings/bold/lists/highlighted code) via one rich.live.Live, throttled re-parse; * an animated working spinner (思考中… / tool name + elapsed + Ctrl-C hint) while the model thinks and while a tool runs; * tool calls as ⏺ tool(args) on start, ⎿ ✓/✗ tool (1.2s) on finish; the todo_write checklist carries over. Robustness: exactly one Live at a time; refs cleared before stop() (a raising stop() can't strand a refresh thread into the next prompt); Live published only after start() succeeds; finish_turn() safety net in run_turn's finally stops a Live left by a stream that raised before a terminal event; per-frame Markdown re-parse capped (_MARKDOWN_LIVE_MAX_CHARS) so a huge reply can't drive O(n^2) parsing; empty TextDelta is a no-op; any rich-path exception trips back to the raw renderer mid-turn. Non-TTY / --print stay byte-stable on the raw path. Also fixed a latent bug where an error reason like [rate_limit] was eaten as rich markup (markup=False). Gated by rich_ui = is_terminal and not print_mode. Reviewed by a 3-lens adversarial workflow; 5 confirmed findings fixed. Console suite: 134 green.
…r deploys
When the agent ships a file (send_attachment / image_generate output),
_register_tool_media registered it into the gateway file store and told the
model to embed [name](url) — but url was the RELATIVE /v1/files/{id}, which
is a host-less, unusable link on a server (CLI/text surfaces) and only
worked because the web UI prepends its own origin. Now the url is
absolutized against the gateway's public base URL so a server-deployed
agent hands the user a real clickable download link.
* New _resolve_public_base_url(): CORLINMAN_PUBLIC_URL env > [server].public_url
(py-config drop) > gateway-learned origin (<data_dir>/public_origin) — the
same precedence the status-card tool uses; the status-card method now
reuses it (DRY).
* _register_tool_media absolutizes /v1/files/{id} when a base resolves;
with no base it stays relative (web UI unaffected, prior behaviour).
The /v1/files endpoint is auth-gated, so the link opens for a logged-in
admin via the cookie bridge (user chose the gated-link option over a public
signed-token route). Tests: media suite green (9), status/token (22).
Repro: `uv run ruff check .` (and `make ci`) failed with I001 "Import block is un-sorted or un-formatted" at test_tool_aliases.py:10 — a stray blank line split the third-party (pytest) and first-party (corlinman_agent) import groups that the repo isort profile keeps contiguous. Root cause: the file (commit e3c2e5d) was committed without a final ruff pass. Autofix only; no behaviour change.
Repro: `uv run mypy python/packages/` (and `make ci`) failed with arg-type at console/render.py:139 — Text.append_text requires a Text, but Spinner.render() is typed RenderableType (ConsoleRenderable | RichCast | str). Root cause: the rich-REPL work (d6734e8) passed the spinner frame straight into append_text; correct at runtime (a text-less Spinner("dots") always renders to a Text) but unsound to the checker. Fix: isinstance-narrow to Text, degrade to just the label otherwise. Regression: test_working_spinner_renders_frame_label_and_elapsed drives _Working.__rich_console__ to completion (mypy is the primary gate).
Repro: configure an openai_compatible provider whose base_url is a full API root ending in a bare /openai mount — e.g. Google Gemini's documented OpenAI-compat endpoint https://generativelanguage.googleapis.com/v1beta/openai, or a relay served at https://host/openai. The provider constructs fine and "fetch models" may look plausible, but every chat message 404s. Root cause: the adaptive base-url normalizer complete_openai_base_url (openai_provider.py, since b3f6042) only recognized a path ending in /v<digits> as an already-complete root; any other non-empty path got /v1 appended. So /v1beta/openai became /v1beta/openai/v1 and the OpenAI SDK hit /v1beta/openai/v1/chat/completions -> 404. Silent regression on upgrade (worked before adaptive completion existed). The mirror probe normalizer _provider_models_url (_providers_lib.py) had the same blind spot (/v1beta/openai/v1/models). Fix: in BOTH mirror functions, treat a path ending in /openai (case-insensitive) as an already-complete API root, alongside the existing /v<digits> check. Chat and the model-probe again resolve to the same root. Regression: test_openai_compatible_base_url gains the /openai + Gemini cases (incl. full-endpoint trim under /openai); new test_provider_models_url_mirrors_base_url_completion locks the probe mirror.
Closes batch 1-2 of the zero-bug + parity work order: - L-001/L-002: greened the local CI gate (ruff import-sort, mypy spinner type). - L-101: openai_compatible /openai base-url mounts serve chat again (P2 regression). Adds audit/BUG_LEDGER_2026-07-02.md: the graded ledger with every known gap re-verified against the live tree (4 CLOSED / stale-doc, P6 product-gated-exempt, MCP-advertisement confirmed as the top open P1) + the recent-branch bug hunt (1 fixed, 2 deferred P3) + runtime smoke. Bumps version 1.21.8 -> 1.21.9.
Problem (BUG_LEDGER_2026-07-02 L-003, P1): external MCP servers were connected at boot but their tools were invisible to the model — McpClientManager.discovered_tools() had zero production consumers and agent_servicer never advertised them, so the model could never see or call an external MCP tool. Repro: configure an [mcp] server, chat — the tool never appears. Root cause / topology (4-agent research): mcp_manager lives ONLY in the gateway process; the AgentServicer never receives it (set_app_state has no callers); the default `direct` backend runs no tools; ChatStart.tools_json was hard-coded b"". So both halves must land gateway-side. Fix — one boot pass over discovered_tools() (gateway/mcp/advertise.py): - Execution: register_mcp_tools synthesizes one mcp-kind PluginEntry per ready server into state.plugin_registry (entrypoint, after connect_all). The existing _resolve_by_tool -> plugin_type=="mcp" -> McpToolBridge -> call_tool chain routes a bare tool call with NO new dispatch code. - Advertisement: the same pass builds a tools_json array, stashed on state.extras["mcp_tools_json"], injected into every ChatStart.tools_json via build_grpc_chat_service -> ChatService(advertised_tools_json=...) -> _build_chat_start. Threaded from gateway STATE, not the request, so the duck-typed channel contract is untouched. Guards: no-op-safe on absent registry/manager; never clobbers a real on-disk manifest; tool names de-duped across servers (first ready wins). Boot-time snapshot; hot-plug refresh is a follow-up. Tests: test_advertise.py proves schema conversion, entry synthesis, and that a synthesized entry routes a bare call end-to-end to call_tool (and that WITHOUT an entry it does not). test_chat_service_builtin.py proves tools_json injection + that a channel SimpleNamespace request is unaffected.
CHANGELOG + version bump for the MCP advertise/route feature; marks L-003 FIXED in audit/BUG_LEDGER_2026-07-02.md. Minor bump: new capability, config-compatible, no breaking change.
Repro (BUG_LEDGER L-102, P3): watch the live multi-agent panel during a fan-out run — tool_calls_made reads 2x-Nx the real count. Root cause: LiveSubagentRegistry._apply_child_event did tool_calls_made += 1 on every ToolStateRunning delivery, but the shared registry is fed that same frame once per open SSE client (sessions_events poll) AND via the emitter observer, so each tool start is counted (1 + N_clients) times. _apply_spawned/_apply_completed were already idempotent; only the counter was not. Fix: ToolStateRunning carries a stable tool_call_id (present in both the emitter envelope and the journal payload). Track a per-child set of counted ids and increment only on a first-seen id; frames without an id still count (never under-report). The set is pruned alongside the terminal row. Regression: test_tool_calls_deduped_by_tool_call_id_across_paths re-delivers one call 3x via the envelope path + 1x via the journal path -> count == 1, and a new tool_call_id -> count == 2.
CHANGELOG + version bump for the L-102 dedup fix; marks it FIXED in audit/BUG_LEDGER_2026-07-02.md.
Phase 2 deliverable. Benchmarks corlinman against a fresh hermes-agent clone (read-only) and the claude-code parity matrix (19 clusters), each dimension re-verified against the CURRENT tree with file:line evidence. Result: corlinman is far more complete than the stale gap/parity docs imply. REJECT 2 dims (subagents, structured output — corlinman >= both baselines). ADAPT-ADOPT/ADOPT the rest, ranked by value/cost into a landing plan. Verified stale-doc corrections: compaction /compact+breaker, CORLINMAN.md discovery/include, permission arg-pattern engine, MCP advertise+route — all already shipped (matrix predates them). No reference code copied — mechanism absorption only (license-clean).
Absorbed from hermes-agent's jittered backoff (mechanism only, re-implemented). Before: when a provider 429/5xx carries no retry-after hint, the reasoning loop's transient-retry backoff used a fixed exponential 0.5*2^(n-1) cap 16s (reasoning_loop.py:1759). A fleet of workers hitting the same overload resynchronises into a thundering herd — every worker retries at the same instant, re-triggering the overload. Fix: extract `_retry_backoff_seconds(attempt, delay_hint, *, rand)` — honours a positive provider hint verbatim, else applies equal jitter (half fixed + a random half) over [base/2, base]. RNG injectable for deterministic tests. Tests: test_retry_backoff_honors_provider_hint + _equal_jitter_range.
The 2026-06-11 wave status column is stale (several waves shipped since). Add a status-refresh banner pointing to audit/ABSORB_MATRIX_2026-07-02.md as the re-verified current truth (acceptance 2.4).
ABSORB_MATRIX Dim 5 — hardens the v1.22.0 MCP tool-face.
Problem: MCP tools were advertised by bare name with first-wins dedup
(advertise.py). A same-named tool on two servers dropped the second, and an
MCP tool named like a builtin (calculator/web_search) shadowed the builtin
(gateway-supplied tools win the tools_json merge).
Fix: advertise tools namespaced as {server}_{tool} (namespaced_tool_name) in
both the OpenAI schema and the synthesized registry entry's capabilities.tools,
so names are unique per server and can't collide with bare builtins. Execution:
McpToolBridge strips the {server}_ prefix back to the bare tool via
_strip_server_namespace, guarded by has_tool(server, bare) and NOT
has_tool(server, namespaced) so a real on-disk mcp manifest (bare names) is
never mis-stripped.
Add: server allow/deny policy (filter_servers_by_policy) — [mcp].deniedMcpServers
/ allowedMcpServers, deny wins, non-empty allow-list exclusive; applied in
register_mcp_tools and wired from config at boot (entrypoint).
Tests: cross-server no-drop, deny policy, and an end-to-end routing test proving
a namespaced call reaches call_tool with the bare tool after the bridge strip.
…IX Dim 2) corlinman already compacts well (two-tier _compact_history + console Compactor with circuit breaker); the audit's residual gap was that the model-aware budget reserve was a fixed 0.15-fraction/48k-cap with no override, vs claude-code's tunable window-minus-buffer. Change: _resolve_context_budget now derives the output reserve via _context_output_reserve — CORLINMAN_CONTEXT_RESERVE_TOKENS pins a fixed buffer (window - buffer, claude-code AUTOCOMPACT_BUFFER semantics), else the proportional reserve's fraction/cap are env-overridable (CORLINMAN_CONTEXT_RESERVE_FRACTION / _CAP). All default to the prior values, so behaviour is unchanged unless configured; reserve clamped <= window. Tests: fixed-buffer wins over fraction; fraction/cap overridable; defaults preserved (existing window-minus-reserve + capped tests still pass).
… Dim 4 Before: both the write tool and the edit tool opened the target with O_WRONLY|O_TRUNC and wrote in place. A crash, disk-full, or partial write mid-stream left the file TRUNCATED/corrupt — the exact failure claude-code's atomic Write avoids. Fix: stage the bytes into a unique sibling temp file (tempfile.mkstemp — O_EXCL, no symlink race), flush + os.fsync, then os.replace onto the target (atomic rename). The target file's mode is preserved (an existing executable bit is kept; a new file is 0644 as before). Safety posture preserved: a symlinked target is refused up front and os.replace never follows a link, matching the old O_NOFOLLOW behaviour (a symlinked parent was already caught by resolve_in_workspace). Temp is unlinked on any failure. Tests: mode preserved across overwrite; no staging temp left behind; existing 88 coding + edit-fidelity tests still green.
…IX Dim 12) The loop only had request-level spans (chat.completions / chat.service); tool execution was untraced. Wrap the gateway's per-call `executor.execute(tc)` in a `tool.execute` span carrying tool.name / tool.plugin / tool.is_error. No-op without a tracer; exceptions are recorded + set the span ERROR status via the existing telemetry.span helper. Test: test_tool_execution_emits_per_tool_otel_span drives the real _run_chat with a scripted tool_call + recording executor under an in-memory tracer and asserts the tool.execute span + tool.name attribute.
Adds the claude-code NotebookEdit analog: a workspace-confined tool to edit a Jupyter notebook by 0-based cell index — replace a cell's source (clearing a code cell's stale outputs/execution_count), insert a new code/markdown cell, or delete a cell. The notebook is rewritten atomically (shared _atomic_replace_write helper: tmp→fsync→os.replace, symlink-refused, mode-preserving). Wired: constant + schema + dispatch_notebook_edit in coding/files.py; exported from coding/__init__.py; added to CODING_TOOLS + coding_tool_schemas; routed in agent_servicer's builtin dispatch. Tests: replace (outputs cleared) / insert markdown / delete / out-of-range / missing-file; the coding-tool set/schema-count alignment tests updated 9→10.
…RIX Dim 12) The prompt_toolkit bottom bar showed only `model · session`. It now appends the running session token count and estimated USD cost (reusing the /cost estimator) once a turn has produced usage — a glanceable session-spend readout like claude-code's status line / hermes' status bar. Extracted the lambda into a testable `_bottom_toolbar` method; hidden when no tokens yet. Tests: shows tokens+cost with usage; hides them (model·session only) when idle.
notebook_edit (added v1.22.8) writes to a file but was absent from _EDIT_TOOLS and MUTATING_TOOLS, so plan mode did NOT deny it (a mutating tool escaping plan-mode's no-side-effects guard) and acceptEdits did not auto-allow it. Add it to both sets. Regression: plan denies + acceptEdits allows notebook_edit.
…ssion-mode ABSORB_MATRIX Dim 3, slice 1 (mode surface). The permission engine (PermissionGate: default/acceptEdits/plan/bypass + Bash(cmd:*) rules) existed but the mode was a process-level env default fixed at servicer construction — no way to see or change it from the console. Chain (research-verified seam: the gate re-reads _mode on every tool call, and the embedded console holds an in-process servicer handle, so a runtime swap needs no proto change): - PermissionGate.set_mode(mode) — normalizing runtime setter (coerce). - CorlinmanAgentServicer.set/get_permission_mode — next to set_approval_resolver. - EmbeddedBrain.set/get_permission_mode + set_approval_resolver forwarding (None/False in the direct-provider fallback, which runs no tools). - /permissions [mode] shows or switches the mode; UNKNOWN MODES DO NOT CHANGE ANYTHING (a typo must never silently coerce plan→default and re-enable mutations); bypass prints a warning. /plan [off] is the plan-mode toggle. - corlinman console --permission-mode <mode> seeds the gate via CORLINMAN_AGENT_PERMISSION_MODE before EmbeddedBrain.start (zero plumbing: PermissionGate.from_env reads it at construction). Tests: gate runtime-swap + coerce; /permissions show/set/case-insensitive/ typo-no-change/bypass-warns; /plan toggle; unavailable in attach shape.
…always/No ABSORB_MATRIX Dim 3, slice 2. The permission gate's `ask` verdict escalates to an async resolver (tool, args, ctx) -> bool via set_approval_resolver — but research confirmed NOTHING ever wired one (grep: zero assignments of app_state.approval_resolver), so every ask fail-closed to deny in every deployment. The console is the first wiring. Design (research-verified): the embedded servicer and the REPL share one process + event loop, and the console holds an in-process servicer handle — so an in-process resolver via the EXISTING set_approval_resolver seam needs zero proto/stream changes (the AwaitingApproval/ApprovalDecision frames stay for a future remote client). While the resolver awaits, the REPL task is parked on the event stream, so the resolver owns the prompt itself: pause the live spinner (renderer._stop_live), print the tool + truncated args preview, read y/a/N via a fresh prompt_toolkit session on the shared loop. - ConsoleApprovalResolver: y = allow once; a = allow + cache the tool for the session (a durable grant belongs in the rule list, not an interactive cache); ANYTHING else (empty, EOF, prompt failure) = deny — fail-closed, matching the gate. - Wired in run_console for the embedded interactive REPL only: --print has no user to ask (asks stay fail-closed — correct non-interactive posture) and attach mode has no in-process servicer. - /permissions (no args) now lists the session's always-allowed tools. Tests: y-once/always-cached/deny-by-default/prompt-failure/preview-truncation; prompter pauses the live spinner; end-to-end through the REAL ApprovalGate (ask rule + resolver → interactive ALLOW/DENY, "a" cached across decides); /permissions surfaces the always-allow set.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 1d15bc3d6c
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| if tool in self.always_allow: | ||
| return True |
There was a problem hiding this comment.
Clear cached approvals before honoring plan mode
When a session uses an ask rule and the user has answered always, this unconditional tool-name cache still approves the tool after the user switches to /plan. PermissionGate.resolve_with_args only applies plan mode after explicit rules, so an ask rule still reaches this resolver in plan mode; for example, a previously always-approved run_shell can keep mutating the workspace even though /plan is advertised as denying mutating tools. Clear or bypass these cached grants when entering plan mode, or make plan mode override ask rules.
Useful? React with 👍 / 👎.
| #: Tool names the user answered "always" for — session-scoped by | ||
| #: design (a durable grant belongs in the permission rule list, not an | ||
| #: interactive cache). | ||
| self.always_allow: set[str] = set() |
There was a problem hiding this comment.
Scope always approvals to the current session
The resolver is documented and presented as “always this session”, but the cache lives on the ConsoleApprovalResolver instance and /new only resets BrainSession, so starting a fresh console session keeps prior always grants. If a user approves run_shell with always, then runs /new to isolate a new task, run_shell remains auto-approved under the new session key instead of prompting again; scope this set by session_key or clear it when the session is reset.
Useful? React with 👍 / 👎.
| return True | ||
| desc = f"{tool} {_args_preview(args)}" | ||
| try: | ||
| answer = (await self._prompter(desc)).strip().lower() |
There was a problem hiding this comment.
Serialize interactive approval prompts
When two tool calls require approval concurrently, each call can reach this await with its own fresh PromptSession; for example, subagent_spawn_many fans child runs out with asyncio.gather, and child tool execution reuses the same console resolver. Two simultaneous prompt_toolkit prompts on the same terminal can interleave approval text/answers or make one prompt fail closed, so approvals should be guarded by a single asyncio.Lock or queued through one prompt loop.
Useful? React with 👍 / 👎.
Rewind + session ergonomics (8 findings): - --continue picks true max(last_seen_at_ms) — pinned sessions no longer hijack row 0 of the pinned-first listing. - fuzzy /resume proves uniqueness across progressively larger summary pages (50→250→1000) instead of one 50-row page. - turn-keyed /rewind rebuild: ownership probe (foreign/unknown turn ids degrade instead of truncating to an unrelated cutoff), stub-backend guard (Postgres list_session_turns [] with real turns → degrade, not wipe-and-report-success), pagination to exhaustion past 50 turns, numeric turn-id tie-break for same-millisecond starts, and the window swap is staged locally — any journal failure leaves it untouched. - degraded journal rebuild now falls back to the legacy label-match truncation instead of silently skipping window handling. - _sanitise_label neutralizes a leading '[turn:' so user text can never masquerade as the journal tag (write-side, keeps label-match byte-consistent). Approval resolver (3 findings): - always-grants cleared on /new, /clear, and permission-mode switches (a cached run_shell grant no longer bypasses /plan). - concurrent approval prompts serialized behind an asyncio.Lock (no more competing prompt_toolkit sessions on one terminal).
…ession, fallback signature strip, hermetic tests (v1.30.0) E1 Layered permission settings + console persist answer: - corlinman_agent.permission_settings: build_permission_gate() stacks <data_dir>/settings.json (user) -> ./.corlinman/settings.local.json (project) -> CORLINMAN_AGENT_PERMISSIONS env (final word); mode/strict follow the same precedence; byte-identical to from_env() when no file contributes. persist_allow_rule() writes durable allow rules atomically (tmp+rename, idempotent). ApprovalGate + servicer default gates now use the loader. - Console approval gains a third answer p/persist: allow + session cache + durable settings rule; a failing persist hook degrades to a session grant, never a deny. "a" stays session-scoped (Codex #104). E2 exit_plan_mode builtin (claude-code parity): - Model-callable plan->default flip with approval-cache reset across BOTH resolver sources (set_approval_resolver + app_state.approval_resolver); clean no-op outside plan mode; optional plan summary echo. - Child tool executor refuses exit_plan_mode: permission mode is servicer-global, only the top-level turn may end plan mode. - Skill allowed-tools control passthrough includes it so a skill can't strand the model in plan mode. E3 --fork-session (claude-code parity): - AgentJournal.fork_session copies only completed turns chronologically onto a fresh key (in_progress/errored skipped; per-turn best-effort; source read-only). Console flag mints a new console:<id> key and forks before resume; attach mode degrades with a note. E4 Cross-model thinking-signature strip on fallback: - Both reasoning-loop fallback branches (model-not-found, sustained overload) strip thinking/redacted_thinking blocks and signature keys from replayed history — per-model signatures otherwise turn a recoverable failover into a hard 400. E5 Hermetic tests: - Root conftest.py scrubs ANTHROPIC_BASE_URL + proxy env for non-live tests so host routing config can't reroute respx-mocked suites. Gate: ruff + mypy (595 files) + lint-imports + pytest 6708 passed / 4 skipped + ui typecheck/lint/build + gen-proto diff-clean.
…ession, fallback signature strip, hermetic tests (v1.30.0) (#119) E1 Layered permission settings + console persist answer: - corlinman_agent.permission_settings: build_permission_gate() stacks <data_dir>/settings.json (user) -> ./.corlinman/settings.local.json (project) -> CORLINMAN_AGENT_PERMISSIONS env (final word); mode/strict follow the same precedence; byte-identical to from_env() when no file contributes. persist_allow_rule() writes durable allow rules atomically (tmp+rename, idempotent). ApprovalGate + servicer default gates now use the loader. - Console approval gains a third answer p/persist: allow + session cache + durable settings rule; a failing persist hook degrades to a session grant, never a deny. "a" stays session-scoped (Codex #104). E2 exit_plan_mode builtin (claude-code parity): - Model-callable plan->default flip with approval-cache reset across BOTH resolver sources (set_approval_resolver + app_state.approval_resolver); clean no-op outside plan mode; optional plan summary echo. - Child tool executor refuses exit_plan_mode: permission mode is servicer-global, only the top-level turn may end plan mode. - Skill allowed-tools control passthrough includes it so a skill can't strand the model in plan mode. E3 --fork-session (claude-code parity): - AgentJournal.fork_session copies only completed turns chronologically onto a fresh key (in_progress/errored skipped; per-turn best-effort; source read-only). Console flag mints a new console:<id> key and forks before resume; attach mode degrades with a note. E4 Cross-model thinking-signature strip on fallback: - Both reasoning-loop fallback branches (model-not-found, sustained overload) strip thinking/redacted_thinking blocks and signature keys from replayed history — per-model signatures otherwise turn a recoverable failover into a hard 400. E5 Hermetic tests: - Root conftest.py scrubs ANTHROPIC_BASE_URL + proxy env for non-live tests so host routing config can't reroute respx-mocked suites. Gate: ruff + mypy (595 files) + lint-imports + pytest 6708 passed / 4 skipped + ui typecheck/lint/build + gen-proto diff-clean. Co-authored-by: Cornna <[email protected]>
Console permission surface — mode control + interactive approval (ABSORB_MATRIX Dim 3)
The permission engine (modes
default/acceptEdits/plan/bypass+Bash(cmd:*)-style arg rules) was already built, but had no user surface:the mode was a boot-time env default fixed at servicer construction, and every
askverdict fail-closed to deny in every deployment — research confirmednothing ever wired an approval resolver.
Built on two research-verified seams (no proto/stream changes needed):
_modeon every tool call, and the embedded consoleholds an in-process servicer handle → a runtime mode setter applies instantly;
set_approval_resolverseam carries an interactive prompt directly (theunwired
AwaitingApproval/ApprovalDecisionproto frames remain for afuture remote client).
What's in
/permissions [mode]— show/switch the runtime mode. A typo neverchanges the mode (silently coercing
plan→defaultwould re-enablemutations);
bypasswarns. Lists the session's always-allowed tools./plan [off]— plan-mode toggle (mutating tools denied while planning).--permission-mode <mode>— seeds the gate at boot.askverdicts pause the live spinner and prompty / always-this-session / No. Fail-closed on anything unexpected;
--printand attach mode deliberately keep the non-interactive fail-closedposture. "Always" is session-scoped by design — a durable grant belongs in
the permission rule list.
notebook_editwas missing from_EDIT_TOOLS/MUTATING_TOOLS, soplan mode didn't deny it.
Tests
Gate runtime-swap; command show/set/typo-guard/bypass-warn/plan-toggle/
unavailable-shapes; resolver y-once/always-cached/deny-by-default/fail-closed/
preview-truncation; prompter pauses the spinner; end-to-end through the real
ApprovalGate. Full non-live suite green.v1.22.9 → v1.23.0. Base:fix/zero-bug-parity(stacked on PR #103).