Skip to content

Console permission surface: /permissions, /plan, --permission-mode + interactive approval (v1.23.0)#104

Merged
sweetcornna merged 46 commits into
mainfrom
feat/permission-console
Jul 3, 2026
Merged

Console permission surface: /permissions, /plan, --permission-mode + interactive approval (v1.23.0)#104
sweetcornna merged 46 commits into
mainfrom
feat/permission-console

Conversation

@sweetcornna

Copy link
Copy Markdown
Owner

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
ask verdict fail-closed to deny in every deployment — research confirmed
nothing ever wired an approval resolver.

Built on two research-verified seams (no proto/stream changes needed):

  1. the gate re-reads its _mode on every tool call, and the embedded console
    holds an in-process servicer handle → a runtime mode setter applies instantly;
  2. the servicer and REPL share one process + event loop → the existing
    set_approval_resolver seam carries an interactive prompt directly (the
    unwired AwaitingApproval/ApprovalDecision proto frames remain for a
    future remote client).

What's in

  • /permissions [mode] — show/switch the runtime mode. A typo never
    changes the mode (silently coercing plandefault would re-enable
    mutations); bypass warns. 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.
  • Interactive approvalask verdicts pause the live spinner and prompt
    y / always-this-session / No. Fail-closed on anything unexpected;
    --print and attach mode deliberately keep the non-interactive fail-closed
    posture. "Always" is session-scoped by design — a durable grant belongs in
    the permission rule list.
  • Fix: notebook_edit was missing from _EDIT_TOOLS/MUTATING_TOOLS, so
    plan 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).

sweetcornna and others added 30 commits June 18, 2026 22:07
… 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.
@github-actions github-actions Bot added codex:needs-review A Codex review is needed for the current PR head. codex:review-requested A Codex review was requested or should be running automatically. status: 🔁 re-review loop A fresh Codex review was requested after the latest change or comment. labels Jul 2, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +55 to +56
if tool in self.always_allow:
return True

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge 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 👍 / 👎.

@github-actions github-actions Bot added codex:reviewed Codex posted a review result or thumbs-up after the latest request. status: 👀 ready for maintainer look No automation blocker is known; a maintainer should inspect the current result. and removed codex:review-requested A Codex review was requested or should be running automatically. codex:needs-review A Codex review is needed for the current PR head. status: 🔁 re-review loop A fresh Codex review was requested after the latest change or comment. labels Jul 2, 2026
sweetcornna added a commit that referenced this pull request Jul 3, 2026
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).
@sweetcornna
sweetcornna changed the base branch from fix/zero-bug-parity to main July 3, 2026 01:54
@sweetcornna
sweetcornna merged commit 1d15bc3 into main Jul 3, 2026
11 checks passed
@github-actions github-actions Bot added the status: ✅ merge-ready Evidence and review are clear; normal merge gates may proceed. label Jul 3, 2026
sweetcornna added a commit that referenced this pull request Jul 20, 2026
…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.
sweetcornna added a commit that referenced this pull request Jul 20, 2026
…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]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

codex:reviewed Codex posted a review result or thumbs-up after the latest request. status: ✅ merge-ready Evidence and review are clear; normal merge gates may proceed. status: 👀 ready for maintainer look No automation blocker is known; a maintainer should inspect the current result.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant