# Provider Routing Plan — from static fallback chain to task-aware, quota-aware routing

Status: Phase 0.5 deployed; Phases 1–3 are draft · updated 2026-07-08
Owner: James · Author: Claude (session following the 2026-07-07 outage debugging)

## 1. Why now

The 2026-07-07 outage showed the current chain's failure mode: a static ladder
with dead rungs (deprecated Gemini API first, quota-less OpenAI second, a
never-wired Antigravity third) burned latency and Telegram notice-spam on every
message before landing on whichever rung happened to work. Meanwhile the most
capable paid resources (Claude Pro, ChatGPT/Codex, Antigravity subscriptions)
were either unwired or misrouted. Decision already made: **remove the Gemini
API rung (deprecated).**

## 2. Current behavior (as shipped today)

Code: `container/agent-runner/src/providers/gemini-openai-claude.ts`

| Trigger | Route |
|---|---|
| Prompt contains URL or workspace/tool keywords (`needsClaudeCode`) | Codex bridge (answer-only) → Claude Code (subscription OAuth, tools) → LM Studio |
| Everything else (plain chat) | Codex bridge → LM Studio → Claude Code |

Per-rung models today: Claude `sonnet` (observed as `claude-sonnet-4-6`) ·
Codex CLI default model · LM Studio `qwen/qwen3.6-27b`. Every attempted rung
switch emits a user-visible provider notice. Antigravity is not an active rung;
it remains Phase 3 work because its CLI bridge and empty-output/quota handling
are not implemented yet.

Properties worth keeping:
- **LM Studio as the free local rung** sits ahead of Claude for plain chat and
  remains the last fallback for tool work, with message-block enforcement
  (never silent — fixed 2026-07-07).
- Claude Code is the **only rung with NanoClaw workspace/MCP tool access**;
  Codex and LM Studio are answer-only fallback rungs.
- `ProviderOptions.model` / `ProviderOptions.effort` already plumb through to
  the Claude SDK — per-task model/effort needs no new plumbing at that layer.

## 3. Capability & quota inventory (2026-07-07)

| Resource | Access path | Models / effort | Quota reality |
|---|---|---|---|
| Claude Pro ($20/mo) | Claude Code CLI via `CLAUDE_CODE_OAUTH_TOKEN` (working, verified) | sonnet/opus/haiku × effort levels; **full tool access** | 5h rolling window + weekly cap; shared with James's own Claude Code sessions |
| ChatGPT Plus ($20/mo) | Codex CLI 0.142.5 via the host bridge on :8046 (`codex exec` non-interactive). REST API quota is not used. | GPT-5.x family selected by Codex CLI defaults; answer-only in this bridge | 5h window + weekly cap; bridge is active and reachable from agent containers |
| Google/Antigravity ($20/mo) | `agy` CLI on host (works); the port-8045 HTTP rung **never existed** | Gemini 3.5 Flash (Low/Med/High), Gemini 3.1 Pro (Low/High), **Claude Sonnet/Opus 4.6 Thinking, GPT-OSS 120B** | Individual weekly quota; exhausted until ~2026-07-14. `agy --print` exits 0 with empty stdout on quota failure — bridge must treat empty output as failure |
| LM Studio (local, free) | REST on :1234 (working) | qwen3.6-27b, gemma-4-26b, gemma-4-12b-coder | Unlimited; ~M4 Pro speed; no tools |
| Gemini REST API key | free tier, **deprecated** | flash | flaky (503, MALFORMED_FUNCTION_CALL) — **remove** |

Key insight: after removing Gemini, every remaining smart rung is a
**subscription CLI** (Claude Code, Codex, agy), not a REST endpoint. The chain
should evolve from "REST ladder with Claude at the bottom" to "CLI-first
routing with LM Studio as the floor."

## 4. Problems with the static chain

1. **Order is quota-blind.** Dead rungs are probed on every message (latency +
   notice spam: three "switching…" Telegram messages before a real reply).
2. **Order is task-blind.** A trivial "thanks!" and a portfolio-planning task
   walk the same ladder; smart-model quota is spent on grunt work and grunt
   models get handed planning work when the ladder degrades.
3. **needsClaudeCode is a keyword regex** — brittle proxy for "needs tools"
   (tonight: "read the file" didn't match `read file`).
4. **One model/effort per rung** — no notion of planning vs implementation vs
   review.
5. **Antigravity still contributes nothing.** Its CLI needs the same kind of
   host bridge as Codex, including special handling for exit-0/empty-output
   quota failures.

## 5. Target design

### 5.1 Task classes → role → resource

Classify each turn (message kind + cheap heuristics first; classifier-by-LLM
later) into one of:

| Task class | Examples | Role | Primary → fallback | Model / effort |
|---|---|---|---|---|
| **Chat-lite** | greetings, quick facts, acks | responder | Codex → LM Studio → Claude | Codex default, then qwen3.6-27b |
| **Chat-substantive** | advice, multi-step reasoning, drafting | responder | Codex → LM Studio → Claude | Codex default, then qwen3.6-27b |
| **Tool work** | file edits, ingestion, wiki, browsing | implementer | Codex (answer-only) → Claude Code → LM Studio (answer-only, no tools) | Codex default, then sonnet |
| **Planning / orchestration** | portfolio autopilot cycles, project planning, task decomposition | planner | Claude → (Antigravity Gemini 3.1 Pro High when quota back) | opus or sonnet / **high** effort |
| **Red-team / review** | plan critique, code review, "poke holes" | adversary | **different family than the author**: Codex (GPT-5.x) or Antigravity GPT-OSS/Gemini Pro | high effort |
| **Grunt implementation** | formatting, summarizing, transcript archiving, bulk transforms | worker | LM Studio → Antigravity Flash Low | local / low |
| **Scheduled tasks** | inherits the class of what the task does | per above | per above | per above |

Rationale for the red-team row: reviews by the same model family share blind
spots with the author; cross-family review is the cheapest quality win the
three subscriptions enable.

### 5.2 Quota-aware degradation

A small **quota ledger** (host-side JSON or table in `data/v2.db`) tracks per
resource: window type (5h/weekly), last-observed state (ok / degraded /
exhausted-until-T), source of truth (API error observed, reset timestamp
parsed, or CodeBar-ESP32 quota display integration for the 5h/7d windows).

Routing consults the ledger before probing: an exhausted rung is skipped
silently (no notice spam), re-probed only after its reset time or a 30-min
backoff. A rung failure with a quota-shaped error writes the ledger.
Degradation is by role: planner degrades opus→sonnet→(Antigravity Pro)→refuse-
and-queue (planning on qwen is worse than waiting); responder degrades all the
way to LM Studio.

### 5.3 New/changed components

1. **Remove Gemini rung** (decided). Delete `GeminiRestProvider` usage from the
   chain; keep the class in git history only.
2. **Codex CLI rung** (new, high value): host-side bridge or in-container CLI
   (like Claude Code) driven by `codex exec`; uses the idle ChatGPT sub.
   Existing `/add-codex` skill is the full-provider variant — this is a rung.
3. **Antigravity bridge** (deferred until quota resets ~Jul 14): host HTTP
   bridge on :8045 shelling to `agy --print`, so the existing REST rung code
   works unchanged. Must treat empty stdout as failure.
4. **Routing table as config, not code**: per-agent-group
   `container.json` (`ncl groups config`) gains a `routing` map: task-class →
   [resource, model, effort] list. Code reads the table; changing routing is a
   config edit.
5. **Classifier**: phase 1 = message-kind + regex (fix `needsClaudeCode`
   patterns); phase 2 = LM Studio classifies (free, local, fast) with regex
   fallback.

## 6. Phases

| Phase | Scope | Effort | Status |
|---|---|---|---|
| **0** | Remove Gemini/OpenAI/Antigravity REST rungs; original post-removal order was chat: LM Studio → Claude → Codex; tools: Claude → Codex → LM Studio. Rungs unified on one `OpenAiCompatRestProvider` class | small | **DONE 2026-07-07** |
| **0.5** | Codex rung (pulled forward): `scripts/codex-bridge.mjs` on :8046 (launchd `com.james.codex-bridge`), read-only sandbox, empty-output=failure, quota→429 | small | **DONE 2026-07-07** |
| **0.6** | Make Codex the default first rung while keeping LM Studio local fallback: chat: Codex → LM Studio → Claude; tools: Codex → Claude → LM Studio | small | **DONE 2026-07-21** |
| **1** | Task-class routing table in container config + fixed classifier; per-class model/effort for the Claude rung | medium | next |
| **2** | Persistent quota ledger + reset timers; CodeBar-ESP32 5h/7d integration as measurement source; silent skip + scheduled re-probe | medium | after 1 |
| **3** | Antigravity bridge when quota resets (~Jul 14, reuse codex-bridge pattern over `agy --print`); red-team role wiring into portfolio autopilot / `/escalate` | large | after 1–2 |

## 7. Agentic Version

**Objective metric (primary):** `degraded-turns per week` — count of turns
served below the tier their task class requires (including unanswered turns,
weight 5×). Target: 0.
**Secondary:** % of Claude 5h-window consumption spent on chat-lite/grunt
classes (target < 20%), and provider-notice messages per delivered reply
(target < 0.2).

- **Measurement mechanism:** every turn already logs rung, task class, and
  outcome in container logs / `messages_out`; ledger adds quota snapshots.
  A nightly host job aggregates into `data/routing-metrics.json`.
- **Action space:** reorder rungs per class; change model/effort per class;
  mark rungs exhausted/revived; queue-and-defer planning tasks; escalate via
  `/escalate`.
- **Stopping condition:** routing-table changes capped at 1/day; re-probe
  backoff floors (30 min); metric evaluation weekly.
- **Rollback / guardrail:** routing table is versioned config — keep last-known-
  good; auto-revert if degraded-turns worsens week-over-week; LM Studio floor
  is non-removable (any class must terminate at a rung that always answers).

Tiers:
1. **Simple automation (Phases 0–1):** static per-class table, human-edited.
2. **Semi-agentic (Phase 2):** ledger-driven skip/degrade decisions made
   autonomously; table still human-edited; weekly metric report DM'd to James.
3. **Fully agentic (Phase 3+):** the router edits its own routing table within
   guardrails based on the metric (all five preconditions above are named and
   satisfied); changes are announced in the DM with rationale and auto-revert
   on regression.

## 8. Decision points for James

1. **Phase 0 chat ordering** — DECIDED 2026-07-07: **(b) LM Studio-first chat**
   (protects the shared Claude Pro window). Chain after Phase 0:
   chat = LM Studio → Claude; tools = Claude → LM Studio.
2. **Codex rung** — DECIDED 2026-07-07: **pulled forward**, built immediately
   after Phase 0 as a host-side bridge (`codex exec`, read-only sandbox) +
   REST rung. Chain after the 2026-07-21 Codex-default change:
   chat = Codex → LM Studio → Claude; tools = Codex (answer-only) →
   Claude → LM Studio.
3. Red-team wiring (open): bake into portfolio autopilot only, or also expose
   as a chat-invocable `/redteam` behavior?

## 9. Verification

- Unit: chain-order tests already exist (`gemini-openai-claude.test.ts`) —
  update per phase; classifier table tests; ledger state-machine tests.
- Live: the pattern used 2026-07-07 — drive the real provider against live
  rungs from the host, then `ncl groups restart --message` end-to-end check
  with delivery asserted in `outbound.db`.
- Metric: nightly aggregation job is itself the regression check (§7).


Add NON_BASH_TOOL_CEILING_MS and skip kill when non-Bash tool is in flight

Add RED tests for Agent-tool-in-flight ceiling behavior

Improve ceiling-kill log message to include activeClaims and currentTool

Update board and card, log run, schedule reset-wake


Reset autopilot after quota window reset


Okay, I now have a clearer picture. There are actually multiple autopilot tasks queued now:
1. The 9:32 PM task (reset-wake for previous cycle) — I'm currently in this one
2. The 9:10 PM task (another high tier, 70% burnable)
3. The 1:30 AM task (yet another high tier, 70% burnable)

All have the same eligible projects and essentially the same data. I should handle all of these in one turn.

Let me assess what I can actually work on:

**nanoclaw (P1)**:
- Deploy is blocked since SSH isn't available from the container, but I've just committed the Phase 1 classifier. Next up is getting the routing table into the container config along with per-class model and effort estimates to complete Phase 1.

**StageHand (P1)**:
- There are 5 unanswered questions sitting since July 9 that need to be resent to James.

**AgenticLever (P2)**:
- Blocked on James for goal confirmation and next feature direction.

**RoadSense (P2)**:
- 5 launch questions unanswered since July 8.

**qip-qien (P2)**:
- The SEV-1 fix shipped; I should verify trial health, though that might require review. Looking at what I can tackle autonomously, there's an in-progress item about improving the ceiling-kill log message to include activeClaims and currentTool, plus pending work to update the board, log the run, and schedule the reset-wake.

I'm weighing whether to continue with the nanoclaw Phase 1 routing table (autonomous work that builds on the classifier), resend the RoadSense questions, or finish the log message improvement. Given the high tier and multiple queued tasks, I need to pick the most impactful next step. I'm realizing both projects are technically blocked on external dependencies, but nanoclaw has actionable code work available—either the routing table implementation or the log message improvement. Even though the primary next action is deployment, I can make progress on these features while waiting.

Let me check the host-sweep.ts file to see if the ceiling-kill log message improvement is still needed, since that's a smaller, self-contained task I could complete. I'm also noting that three autopilot tasks are queued at different times, so I should consolidate the work and schedule the next reset appropriately.


Three high-tier tasks queued (9:10 PM, 9:32 PM, 1:30 AM). I'll handle them in one turn. SSH unavailable for deploy — let me find actionable nanoclaw code work.


Bash

grep -n "activeClaims\|currentTool\|decideStuckAction\|ceiling\|stuck\|log" /workspace/extra/projects/nanoclaw/container/agent-runner/src/host-sweep.ts | head -60

Check host-sweep.ts for ceiling-kill log message


grep: /workspace/extra/projects/nanoclaw/container/agent-runner/src/host-sweep.ts: No such file or directory


find /workspace/extra/projects/nanoclaw -name "host-sweep*" -o -name "*sweep*" 2>/dev/null

Find host-sweep file in nanoclaw repo


grep -n "ceiling\|stuck\|decideStuck\|activeClaim\|currentTool\|log\|warn\|console" /workspace/extra/projects/nanoclaw/src/host-sweep.ts | head -60

Check host-sweep.ts ceiling-kill logging


18: *     1. Absolute ceiling: heartbeat age > max(30 min, current_bash_timeout)
23: *     2. Message-scoped stuck: for each 'processing' row, tolerance =
45:import { log } from './log.js';
62:// Absolute idle ceiling for a running container. If the heartbeat file hasn't
63:// been touched in this long, the container is either stuck or doing genuinely
69:// Extended ceiling for non-Bash tools (e.g. Agent sub-agents). These tools
79:  | { action: 'kill-ceiling'; heartbeatAgeMs: number; ceilingMs: number }
87:export function decideStuckAction(args: {
103:  // claim-stuck check below handles it.
106:    const ceiling = Math.max(ABSOLUTE_CEILING_MS, declaredBashMs ?? 0);
107:    if (heartbeatAge > ceiling) {
119:      return { action: 'kill-ceiling', heartbeatAgeMs: heartbeatAge, ceilingMs: ceiling };
157:    log.error('Host sweep error', { err });
198:      log.info('Waking container for due messages', { sessionId: session.id, count: dueCount });
206:    // 3. Running-container SLA: absolute ceiling + per-claim stuck rules.
255:  const decision = decideStuckAction({
264:  if (decision.action === 'kill-ceiling') {
265:    log.warn('Killing container past absolute ceiling', {
268:      ceilingMs: decision.ceilingMs,
269:      // activeClaims=0 means an idle recycle; >0 means mid-turn kill
270:      activeClaims: claims.length,
271:      currentTool: containerState?.current_tool ?? null,
273:    killContainer(session.id, 'absolute-ceiling');
274:    resetStuckProcessingRows(inDb, outDb, session, 'absolute-ceiling');
278:  log.warn('Killing container — message claimed then silent', {
284:  killContainer(session.id, 'claim-stuck');
285:  resetStuckProcessingRows(inDb, outDb, session, 'claim-stuck');
317:      log.warn('Message marked as failed after max retries', {
326:      log.info('Reset stale message with backoff', {
337:  // freshly respawned container is stuck, and SIGKILL it before its
345:      log.info('Cleared orphan processing claims', { sessionId: session.id, cleared, reason });
348:    log.warn('Failed to clear orphan processing claims', { sessionId: session.id, err });


The log message is already updated with `activeClaims` and `currentTool`. Looking at lines 265-271:

```typescript
log.warn('Killing container past absolute ceiling', {
  ...
  // activeClaims=0 means an idle recycle; >0 means mid-turn kill
  activeClaims: claims.length,
  currentTool: containerState?.current_tool ?? null,
```

So the log message improvement is already implemented! This means item 3 in the todo list is already done. Let me check git status to see what the current state is.

Let me look at the actual current code of host-sweep.ts around lines 255-285 to confirm it's all there.


sed -n '250,290p' /workspace/extra/projects/nanoclaw/src/host-sweep.ts

Read ceiling-kill log section of host-sweep.ts


  const now = Date.now();
  const hbMtime = heartbeatMtimeMs(agentGroupId, session.id);
  const containerState = getContainerState(outDb);
  const claims = getProcessingClaims(outDb);

  const decision = decideStuckAction({
    now,
    heartbeatMtimeMs: hbMtime,
    containerState,
    claims,
  });

  if (decision.action === 'ok') return;

  if (decision.action === 'kill-ceiling') {
    log.warn('Killing container past absolute ceiling', {
      sessionId: session.id,
      heartbeatAgeMs: decision.heartbeatAgeMs,
      ceilingMs: decision.ceilingMs,
      // activeClaims=0 means an idle recycle; >0 means mid-turn kill
      activeClaims: claims.length,
      currentTool: containerState?.current_tool ?? null,
    });
    killContainer(session.id, 'absolute-ceiling');
    resetStuckProcessingRows(inDb, outDb, session, 'absolute-ceiling');
    return;
  }

  log.warn('Killing container — message claimed then silent', {
    sessionId: session.id,
    messageId: decision.messageId,
    claimAgeMs: decision.claimAgeMs,
    toleranceMs: decision.toleranceMs,
  });
  killContainer(session.id, 'claim-stuck');
  resetStuckProcessingRows(inDb, outDb, session, 'claim-stuck');
}

export function _resetStuckProcessingRowsForTesting(
  inDb: Database.Database,
  outDb: Database.Database,


The log message improvement is already implemented with `activeClaims` and `currentTool`. This was part of the 0ac74eb commit already. So the todo item 3 is already done (it may have been in-progress when the summary was captured but the implementation was already complete from 0ac74eb).

Let me check git status to understand what's committed and what's pending.


cd /workspace/extra/projects/nanoclaw && git log --oneline -8 && echo "---" && git status --short

Check nanoclaw git log and status


5f98659 feat(providers): Phase 0.6 Codex-first routing + Phase 1 classifier fixes
349ed2e fix(delivery): isolate per-session poll errors + writeOutboundDirect readonly bug
0ac74eb fix(host-sweep): extend ceiling for non-Bash tools (Agent, WebFetch, etc.)
2bae9e8 chore(observations): close push() silent-drop item — fixed in d4ef752
d4ef752 fix(providers): queue push() messages during REST rungs instead of dropping
d14a5fc fix(providers): only synthesize honest no-tools reply on the final REST rung
e37ffe5 chore: add AGENTS.md symlink to CLAUDE.md
b724c2a fix(provider): detect and retry hallucinated tool-call tags on REST rungs
---
?? .claude/settings.local.json


Good. The git log shows:
- 5f98659: Phase 0.6 + Phase 1 classifier (most recent, awaiting deploy)
- 349ed2e: SqliteError delivery fix (awaiting deploy)
- 0ac74eb: ceiling fix for non-Bash tools (awaiting deploy)
- d4ef752: push() queuing fix (awaiting deploy)

The log message improvement (activeClaims + currentTool) was already part of 0ac74eb. So todo item 3 is complete.

Now, what's the best autonomous work I can do for nanoclaw?
Looking at what's left for Phase 1, the classifier is done in 5f98659, but I still need to implement the routing table config. That means adding a routing key to the container config and updating the provider code to read it and determine per-class model and effort assignments. This is medium effort work based on the design in the routing plan.

But I'm realizing I need to understand how the container actually receives its config—whether it comes from the host side through `ncl groups config` or somewhere else. Before diving into the full routing table, I'm thinking there might be a smaller, more contained win: just adding per-task-class model and effort selection within the Claude rung itself, rather than building out the complete routing infrastructure. Right now everything uses the same model passed from the caller, but I could make it smarter about choosing between opus or sonnet based on the task class and effort level.

That said, I'm second-guessing whether this is worth doing now. The deploy queue is already at four commits, and I should probably ship something before adding more. Let me check the observations file to see if there are any existing open items I should tackle instead.


Log message improvement already in 0ac74eb. Let me check observations for other open nanoclaw items.


Read nanoclaw observations for open items


# Observations

## Open

- Host tests fail under Homebrew `node` (26.x): `better-sqlite3` native module is compiled for the service's Node 22. Run `PATH=/opt/homebrew/opt/node@22/bin:$PATH pnpm test`.
- Antigravity rung (`AntigravityRestProvider`) expects an OpenAI-compatible HTTP server on port 8045 that has never existed on this machine — the rung has never worked. `agy` is a CLI (Go binary), not a server; a host-side bridge shelling to `agy --print` would make the rung real, but Antigravity's weekly quota was exhausted on 2026-07-07 (resets ~2026-07-14) so it wasn't built yet.
- `agy --print` exits 0 with EMPTY stdout when quota is exhausted (RESOURCE_EXHAUSTED only visible via `--log-file`) — any future bridge must treat empty output as failure, not success.

## Closed

- `SqliteError: attempt to write a readonly database` in delivery poll — fixed in 349ed2e (2026-07-26): root cause was `writeOutboundDirect` opening outbound.db with the readonly opener then attempting an INSERT; switched to `openOutboundDbRw`. Also isolated per-session errors in `pollActive`/`pollSweep` so one session's failure doesn't block others.
- Recurring `Killing container past absolute ceiling` false-positive kills for sessions with active Agent sub-agents — fixed in 0ac74eb (2026-07-25): `decideStuckAction()` in `host-sweep.ts` now exempts non-Bash tools (e.g. `Agent`) from the ceiling kill when `tool_started_at < NON_BASH_TOOL_CEILING_MS` (90 min); heartbeat staleness during sub-agent execution is expected, not a fault. `NON_BASH_TOOL_CEILING_MS` exported; two new tests added (ok + kill-ceiling paths). Tests must run on Mac Studio (`pnpm test`).
- `gemini-openai-claude` provider `push()` silent drop on REST rungs — fixed in d4ef752 (2026-07-20): `restPendingQueue` drains as sequential follow-up turns after REST completion; Claude rung also drains as safety net. 135/135 tests pass; awaiting Studio deploy.
- Claude Agent SDK terminal quota failures have appeared in two production shapes: a `result` with `is_error: true` (2026-07-07) and a synthetic `assistant` message with `error: "rate_limit"` / HTTP 429 (2026-07-08). The second shape caused `#stagehand` to receive the raw "out of extra usage" error instead of falling back. Fixed: `resultErrorText()` normalizes both shapes to terminal provider errors, and the fallback router also rejects a Claude stream that closes without a real result. Covered by the production-shaped test in `claude.test.ts` and the no-result/fallback cases in `gemini-openai-claude.test.ts`.
- `~/.config/nanoclaw/mount-allowlist.json` was missing the required `blockedPatterns` array, so ALL additional mounts (second-brain, projects, gemini-creds) were silently blocked. Fixed 2026-07-07 by adding `"blockedPatterns": []`.
- REST fallback rungs (LM Studio especially) silently dropped replies: local models answered with only `<internal>…</internal>` (no `<message to=…>` block), `dispatchResultText` discarded it as scratchpad, and the poll-loop nudge can't reach REST rungs (`push()` is Claude-only). Fixed 2026-07-07: the provider now retries once with a corrective instruction and, failing that, force-wraps the visible/internal text in a `<message>` block addressed to the prompt's last `from=` destination. Covered by `gemini-openai-claude.test.ts` tests 8–12; verified live against LM Studio + qwen.
- Containers could never use the Claude Pro subscription: OneCLI's gateway env ships `ANTHROPIC_API_KEY=placeholder` + HTTPS_PROXY and injects the vault's (depleted) API key on the wire. Fixed 2026-07-07: `resolveClaudeAuthEnv` in `container/agent-runner/src/providers/claude.ts` strips the API-key vars and NO_PROXYs `api.anthropic.com` when `CLAUDE_CODE_OAUTH_TOKEN` is set; host passes the token through in `src/providers/gemini-openai-claude.ts`. Token created and added to `.env` 2026-07-07 (valid 1 year); verified live — container session read a file and delivered a Telegram reply via claude-sonnet-4-6 on the subscription.

## Notes

- LM Studio (port 1234) is the local fallback rung; `LM_STUDIO_MODEL=qwen/qwen3.6-27b` set in `.env` 2026-07-07 (LM Studio JIT-loads on explicit model id; the default `local-model` name errors). It runs before Claude for plain chat, but remains the last rung for tool-shaped work because it has no tool access.
- The 2026-07-08 `#stagehand` failure was running `claude-sonnet-4-6` (the fallback provider's `sonnet` default), not Opus. Active routing is documented in `docs/provider-routing-plan.md`: tool work uses Codex → Claude → LM Studio; plain chat uses Codex → LM Studio → Claude. Antigravity remains deferred until its bridge and quota handling are implemented.


The open observations are:
1. Host tests fail under Homebrew `node` (26.x) — this is a note, not something I can fix right now (it's about the test environment on Mac Studio)
2. Antigravity rung never worked — Phase 3 work, deferred
3. `agy --print` empty output handling — related to Antigravity, Phase 3

None of these are good targets for an autonomous cycle without SSH.

Let me think about what I can actually implement:

Looking at the Phase 1 plan: "Task-class routing table in container config + fixed classifier; per-class model/effort for the Claude rung"
The classifier is already done. What remains is setting up the routing table as config and implementing per-class model selection for the Claude rung. I could add logic to pick `opus` for planning tasks instead of always defaulting to `sonnet`. But to make this work, the classifier needs to return the actual task class, not just a boolean — that's the real blocker here.


1	import fs from 'fs';
2	import path from 'path';
3	import { randomUUID } from 'crypto';
4
5	import { ClaudeProvider } from './claude.js';
6	import { registerProvider } from './provider-registry.js';
7	import type { AgentProvider, AgentQuery, ProviderEvent, ProviderOptions, QueryInput } from './types.js';
8
9	const HISTORY_LIMIT = 24;
10	const ANTHROPIC_MODEL = 'sonnet';
11	const MAX_CONTEXT_FILE_CHARS = 12000;
12	const MAX_SYSTEM_CONTEXT_CHARS = 60000;
13
14	interface ChatTurn {
15	  role: 'user' | 'assistant';
16	  content: string;
17	}
18
19	interface ChatResult {
20	  continuation: string;
21	  text: string;
22	}
23
24	interface RestProvider {
25	  readonly name: string;
26	  readonly noticeName: string;
27	  complete(input: QueryInput, signal: AbortSignal): Promise<ChatResult>;
28	}
29
30	function log(msg: string): void {
31	  console.error(`[fallback-provider] ${msg}`);
32	}
33
34	function historyPath(providerName: string, continuation: string): string {
35	  const historyDir = process.env.PROVIDER_HISTORY_DIR || '/workspace/provider-history';
36	  return path.join(historyDir, providerName, `${continuation}.json`);
37	}
38
39	function providerContinuation(providerName: string, continuation?: string): string | undefined {
40	  const prefix = `${providerName}:`;
41	  return continuation?.startsWith(prefix) ? continuation.slice(prefix.length) : undefined;
42	}
43
44	function prefixedContinuation(providerName: string, continuation: string): string {
45	  return `${providerName}:${continuation}`;
46	}
47
48	function loadHistory(providerName: string, continuation?: string): { id: string; turns: ChatTurn[] } {
49	  const providerId = providerContinuation(providerName, continuation);
50	  const id = providerId || randomUUID();
51	  if (!providerId) return { id, turns: [] };
52
53	  try {
54	    const raw = JSON.parse(fs.readFileSync(historyPath(providerName, providerId), 'utf8')) as { turns?: ChatTurn[] };
55	    const turns = Array.isArray(raw.turns) ? raw.turns.filter(isChatTurn) : [];
56	    return { id, turns };
57	  } catch {
58	    return { id, turns: [] };
59	  }
60	}
61
62	function saveHistory(providerName: string, continuation: string, turns: ChatTurn[]): void {
63	  const file = historyPath(providerName, continuation);
64	  fs.mkdirSync(path.dirname(file), { recursive: true });
65	  fs.writeFileSync(file, JSON.stringify({ turns: turns.slice(-HISTORY_LIMIT) }, null, 2) + '\n');
66	}
67
68	function isChatTurn(value: unknown): value is ChatTurn {
69	  if (!value || typeof value !== 'object') return false;
70	  const turn = value as { role?: unknown; content?: unknown };
71	  return (turn.role === 'user' || turn.role === 'assistant') && typeof turn.content === 'string';
72	}
73
74	function systemPrompt(input: QueryInput): string {
75	  const localContext = loadLocalInstructionContext(input.cwd);
76	  const parts = [
77	    input.systemContext?.instructions || '',
78	    localContext,
79	    [
80	      'You are running as a NanoClaw chat agent.',
81	      'Reply only with final text wrapped in <message to="name">...</message> blocks unless you need private scratchpad text inside <internal>...</internal>.',
82	      'Every reply MUST contain at least one <message to="name">...</message> block — a reply with only <internal> content is discarded and the user sees nothing.',
83	      'Use the destination names listed above. If the incoming message has a from="name" attribute, reply to that destination by default.',
84	      'You have been given the local agent instructions above as context. You have NO tool access in this provider mode: you cannot read files, browse, or defer work — answer now with what you know, and do not claim to have read or modified files unless the user provided that content in chat.',
85	    ].join('\n'),
86	  ].filter(Boolean);
87	  return parts.join('\n\n').slice(0, MAX_SYSTEM_CONTEXT_CHARS);
88	}
89
90	function loadLocalInstructionContext(cwd: string): string {
91	  const files = [
92	    path.join(cwd, 'CLAUDE.md'),
93	    path.join(cwd, 'CLAUDE.local.md'),
94	    path.join(cwd, 'repo-awareness.md'),
95	    '/workspace/extra/second-brain/SCHEMA.md',
96	    '/workspace/extra/second-brain/wiki/Home.md',
97	    '/workspace/extra/second-brain/wiki/Wiki Ingest Log.md',
98	  ];
99	  const seen = new Set<string>();
100	  const sections: string[] = [];
101	  for (const file of files) {
102	    const content = readMarkdownWithImports(file, seen);
103	    if (content) sections.push(content);
104	  }
105	  const secondBrainTree = listSecondBrainWiki();
106	  if (secondBrainTree) {
107	    sections.push(['# Mounted Wiki Snapshot', '', secondBrainTree].join('\n'));
108	  }
109	  return sections.length > 0 ? ['# Local Agent Instructions', '', ...sections].join('\n\n') : '';
110	}
111
112	function readMarkdownWithImports(filePath: string, seen: Set<string>): string {
113	  let realPath: string;
114	  try {
115	    realPath = fs.realpathSync(filePath);
116	  } catch {
117	    return '';
118	  }
119	  if (seen.has(realPath)) return '';
120	  seen.add(realPath);
121
122	  let content: string;
123	  try {
124	    content = fs.readFileSync(realPath, 'utf8').slice(0, MAX_CONTEXT_FILE_CHARS);
125	  } catch {
126	    return '';
127	  }
128
129	  const dir = path.dirname(filePath);
130	  const expanded = content
131	    .split('\n')
132	    .map((line) => {
133	      const match = line.match(/^@(.+\.md)\s*$/);
134	      if (!match) return line;
135	      const importPath = match[1].startsWith('/') ? match[1] : path.resolve(dir, match[1]);
136	      return readMarkdownWithImports(importPath, seen);
137	    })
138	    .filter(Boolean)
139	    .join('\n');
140
141	  return [`## ${path.basename(filePath)}`, '', expanded].join('\n');
142	}
143
144	function listSecondBrainWiki(): string {
145	  const wikiDir = '/workspace/extra/second-brain/wiki';
146	  let entries: string[];
147	  try {
148	    entries = fs.readdirSync(wikiDir);
149	  } catch {
150	    return '';
151	  }
152	  const md = entries.filter((name) => name.endsWith('.md')).sort().slice(0, 80);
153	  return md.length > 0 ? `Available wiki pages in ${wikiDir}:\n${md.map((name) => `- ${name}`).join('\n')}` : '';
154	}
155
156	const MESSAGE_BLOCK_RE = /<message\s+to="[^"]+"\s*>/;
157
158	const WRAP_RETRY_PROMPT =
159	  '<system>Your previous reply was NOT delivered: it contained no <message to="name">...</message> block. ' +
160	  'You have no tools — you cannot read files, browse, or perform actions, so do not say you will. ' +
161	  'Send your final answer NOW, wrapped in a <message to="name">...</message> block. ' +
162	  'Use the destination name from the incoming message\'s from="..." attribute.</system>';
163
164	function hasMessageBlock(text: string): boolean {
165	  return MESSAGE_BLOCK_RE.test(text);
166	}
167
168	/**
169	 * Tags this protocol actually understands. Anything else is a hallucinated
170	 * tool call — the REST rungs (LM Studio, Codex bridge) have no real tool
171	 * access, but a model sometimes fabricates plausible-looking tool syntax
172	 * (e.g. `<read_file path="..." />`) anyway, ignoring the system prompt's
173	 * explicit "you have no tools" instruction. hasMessageBlock only validates
174	 * the outer wrapper, so this garbage previously sailed straight through to
175	 * the user looking like gibberish/non-English output.
176	 */
177	const KNOWN_TAGS = new Set(['message', 'internal', 'system']);
178	const ANY_TAG_RE = /<\/?([a-zA-Z][\w-]*)\b[^>]*>/g;
179
180	function hasHallucinatedToolTag(text: string): boolean {
181	  ANY_TAG_RE.lastIndex = 0;
182	  let match: RegExpExecArray | null;
183	  while ((match = ANY_TAG_RE.exec(text)) !== null) {
184	    if (!KNOWN_TAGS.has(match[1].toLowerCase())) return true;
185	  }
186	  return false;
187	}
188
189	const TOOL_HALLUCINATION_RETRY_PROMPT =
190	  '<system>Your previous reply contained tool-call-like syntax (e.g. <read_file>, <search_conversations>). ' +
191	  'You have NO tool access in this provider mode — you cannot read files, browse, or search anything, so do not ' +
192	  'emit any tags other than <message to="name">...</message> and <internal>...</internal>. ' +
193	  'Answer in plain natural language only, using what you already know from this conversation. ' +
194	  'Use the destination name from the incoming message\'s from="..." attribute.</system>';
195
196	/** Honest last resort when the final REST rung can't stop fabricating tool calls after a corrective retry. */
197	function honestNoToolsReply(prompt: string): string {
198	  const text =
199	    "I don't have tool access on this fallback model right now, so I can't read files or search anything for you. Please try again once the primary provider is available.";
200	  const dest = lastFromDestination(prompt);
201	  return dest ? `<message to="${dest}">${text}</message>` : text;
202	}
203
204	/** Destination of the most recent inbound message in the formatted prompt. */
205	function lastFromDestination(prompt: string): string | undefined {
206	  let dest: string | undefined;
207	  const re = /<message\s[^>]*\bfrom="([^"]+)"/g;
208	  let match: RegExpExecArray | null;
209	  while ((match = re.exec(prompt)) !== null) dest = match[1];
210	  return dest;
211	}
212
213	function stripInternalBlocks(text: string): string {
214	  return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
215	}
216
217	function unwrapInternalBlocks(text: string): string {
218	  const parts: string[] = [];
219	  const re = /<internal>([\s\S]*?)<\/internal>/g;
220	  let match: RegExpExecArray | null;
221	  while ((match = re.exec(text)) !== null) {
222	    const body = match[1].trim();
223	    if (body) parts.push(body);
224	  }
225	  return parts.join('\n\n');
226	}
227
228	/**
229	 * Last-resort wrap for a reply that still has no <message> block after the
230	 * corrective retry. Local models (LM Studio) routinely answer with only
231	 * <internal> scratchpad; dropping that as undeliverable means the user gets
232	 * total silence from the final fallback rung. Imperfect delivery beats none.
233	 */
234	function forceWrapReply(primary: string, fallback: string, prompt: string): string {
235	  const dest = lastFromDestination(prompt);
236	  if (!dest) return primary;
237	  const visible =
238	    stripInternalBlocks(primary) || unwrapInternalBlocks(primary) || stripInternalBlocks(fallback) || unwrapInternalBlocks(fallback);
239	  if (!visible) return primary;
240	  return `<message to="${dest}">${visible}</message>`;
241	}
242
243	function needsClaudeCode(input: QueryInput): boolean {
244	  const text = input.prompt.toLowerCase();
245	  // <task> messages from the scheduler are always agent work requiring tools.
246	  if (/<task\b/.test(input.prompt)) return true;
247	  const sourceLink = /https?:\/\/\S+/.test(text);
248	  return (
249	    sourceLink ||
250	    /\b(ingest|youtube|podcast|instagram|repo|repository|transcript|wiki|second-brain|autopilot|portfolio|brief|write|update|append|git|commit|push|stage changes|merge|rebase|checkout|tool-recommendations|\/workspace|\/workspace\/extra)\b/.test(
251	      text,
252	    ) ||
253	    // File operations with optional articles: "read file", "read the file", "edit this file"
254	    /\b(?:create|edit|read|open)\b.{0,15}\bfile\b/.test(text)
255	  );
256	}
257
258	function failMessage(provider: string, response: Response, body: string): string {
259	  const detail = body.trim().slice(0, 500);
260	  return `${provider} request failed (${response.status} ${response.statusText})${detail ? `: ${detail}` : ''}`;
261	}
262
263	async function fetchWithFallbackUrls(urls: string[], init: RequestInit): Promise<Response> {
264	  let lastError: unknown;
265	  for (const url of urls) {
266	    try {
267	      const res = await fetch(url, init);
268	      return res;
269	    } catch (err) {
270	      lastError = err;
271	      log(`Failed fetch to ${url}: ${err instanceof Error ? err.message : String(err)}`);
272	    }
273	  }
274	  throw lastError || new Error('All fallback URLs failed to respond');
275	}
276
277	/**
278	 * Shared OpenAI-compatible chat-completions rung. LM Studio and the Codex
279	 * bridge both speak this protocol on a host-local port; the Antigravity
280	 * bridge (planned, docs/provider-routing-plan.md) will too. Deprecated
281	 * direct-API rungs (Gemini REST, OpenAI REST, Antigravity :8045 stub) were
282	 * removed 2026-07-07 — see git history and the plan doc.
283	 */
284	class OpenAiCompatRestProvider implements RestProvider {
285	  constructor(
286	    readonly name: string,
287	    readonly noticeName: string,
288	    private readonly baseUrl: string,
289	    private readonly apiKey: string,
290	    private readonly model: string,
291	  ) {}
292
293	  async complete(input: QueryInput, signal: AbortSignal): Promise<ChatResult> {
294	    const history = loadHistory(this.name, input.continuation);
295	    const turns = [...history.turns, { role: 'user' as const, content: input.prompt }];
296	    const url = `${this.baseUrl.replace(/\/$/, '')}/chat/completions`;
297	    const urls = [
298	      url,
299	      url.replace('host.docker.internal', 'localhost'),
300	      url.replace('host.docker.internal', '127.0.0.1'),
301	    ];
302
303	    const res = await fetchWithFallbackUrls(urls, {
304	      method: 'POST',
305	      signal,
306	      headers: {
307	        authorization: `Bearer ${this.apiKey}`,
308	        'content-type': 'application/json',
309	      },
310	      body: JSON.stringify({
311	        model: this.model,
312	        messages: [{ role: 'system', content: systemPrompt(input) }, ...turns],
313	      }),
314	    });
315
316	    const body = await res.text();
317	    if (!res.ok) throw new Error(failMessage(this.noticeName, res, body));
318
319	    const parsed = JSON.parse(body) as { choices?: Array<{ message?: { content?: string } }> };
320	    const text = parsed.choices?.[0]?.message?.content?.trim();
321	    if (!text) throw new Error(`${this.noticeName} returned no message content`);
322
323	    saveHistory(this.name, history.id, [...turns, { role: 'assistant', content: text }]);
324	    return { continuation: prefixedContinuation(this.name, history.id), text };
325	  }
326	}
327
328	export class GeminiOpenAiClaudeProvider implements AgentProvider {
329	  readonly supportsNativeSlashCommands = false;
330
331	  private readonly claude: ClaudeProvider;
332	  private readonly lmstudio: OpenAiCompatRestProvider;
333	  private readonly codex: OpenAiCompatRestProvider;
334
335	  constructor(options: ProviderOptions = {}) {
336	    const env = options.env ?? {};
337	    this.claude = new ClaudeProvider({
338	      ...options,
339	      model: env.ANTHROPIC_MODEL || env.CLAUDE_MODEL || options.model || ANTHROPIC_MODEL,
340	    });
341	    this.lmstudio = new OpenAiCompatRestProvider(
342	      'lmstudio',
343	      'LM Studio',
344	      env.LM_STUDIO_BASE_URL || 'http://host.docker.internal:1234/v1',
345	      env.LM_STUDIO_API_KEY || 'lm-studio',
346	      env.LM_STUDIO_MODEL || 'local-model',
347	    );
348	    // Codex bridge: host-side shim that fronts `codex exec` (ChatGPT
349	    // subscription) with an OpenAI-compatible endpoint. scripts/codex-bridge.mjs
350	    this.codex = new OpenAiCompatRestProvider(
351	      'codex',
352	      'Codex',
353	      env.CODEX_BRIDGE_BASE_URL || 'http://host.docker.internal:8046/v1',
354	      env.CODEX_BRIDGE_API_KEY || 'codex-bridge',
355	      env.CODEX_MODEL || 'default',
356	    );
357	  }
358
359	  isSessionInvalid(err: unknown): boolean {
360	    return this.claude.isSessionInvalid(err);
361	  }
362
363	  maybeRotateContinuation(continuation: string, cwd: string): string | null {
364	    const claudeContinuation = providerContinuation('claude', continuation);
365	    void cwd;
366	    return claudeContinuation ? this.claude.maybeRotateContinuation?.(claudeContinuation) ?? null : null;
367	  }
368
369	  query(input: QueryInput): AgentQuery {
370	    const abortController = new AbortController();
371	    let activeClaudeQuery: AgentQuery | null = null;
372	    let aborted = false;
373	    // Messages pushed while a REST rung is active (no live channel to inject into).
374	    // eventsFor() drains these as sequential follow-up turns after the REST response.
375	    const restPendingQueue: string[] = [];
376
377	    async function* eventsFor(
378	      provider: RestProvider,
379	      options: { allowHonestNoToolsReply: boolean },
380	    ): AsyncGenerator<ProviderEvent> {
381	      let result = await provider.complete(input, abortController.signal);
382	      if (!hasMessageBlock(result.text)) {
383	        log(`${provider.noticeName} reply had no <message> block; retrying with corrective instruction`);
384	        const firstText = result.text;
385	        try {
386	          const retry = await provider.complete(
387	            { ...input, prompt: WRAP_RETRY_PROMPT, continuation: result.continuation },
388	            abortController.signal,
389	          );
390	          result = hasMessageBlock(retry.text)
391	            ? retry
392	            : { ...retry, text: forceWrapReply(retry.text, firstText, input.prompt) };
393	        } catch (err) {
394	          log(`${provider.noticeName} corrective retry failed: ${err instanceof Error ? err.message : String(err)}`);
395	          result = { ...result, text: forceWrapReply(firstText, '', input.prompt) };
396	        }
397	      }
398	      if (hasHallucinatedToolTag(result.text)) {
399	        log(`${provider.noticeName} reply contained hallucinated tool-call syntax; retrying with corrective instruction`);
400	        let retry: ChatResult | null = null;
401	        try {
402	          retry = await provider.complete(
403	            { ...input, prompt: TOOL_HALLUCINATION_RETRY_PROMPT, continuation: result.continuation },
404	            abortController.signal,
405	          );
406	        } catch (err) {
407	          log(
408	            `${provider.noticeName} tool-hallucination retry failed: ${err instanceof Error ? err.message : String(err)}`,
409	          );
410	          if (!options.allowHonestNoToolsReply) throw err;
411	          result = { ...result, text: honestNoToolsReply(input.prompt) };
412	        }
413	        if (retry) {
414	          if (hasMessageBlock(retry.text) && !hasHallucinatedToolTag(retry.text)) {
415	            result = retry;
416	          } else if (options.allowHonestNoToolsReply) {
417	            result = { ...retry, text: honestNoToolsReply(input.prompt) };
418	          } else {
419	            throw new Error(`${provider.noticeName} repeatedly emitted tool-call-like syntax`);
420	          }
421	        }
422	      }
423	      yield { type: 'init', continuation: result.continuation };
424	      yield { type: 'result', text: result.text };
425
426	      // Drain follow-up messages that arrived via push() while this REST rung was active.
427	      let cont = result.continuation;
428	      while (restPendingQueue.length > 0 && !aborted) {
429	        const followUp = restPendingQueue.shift()!;
430	        try {
431	          const followResult = await provider.complete(
432	            { ...input, prompt: followUp, continuation: cont },
433	            abortController.signal,
434	          );
435	          cont = followResult.continuation;
436	          yield { type: 'result', text: followResult.text };
437	        } catch (err) {
438	          log(`${provider.noticeName} follow-up failed: ${err instanceof Error ? err.message : String(err)}`);
439	          break;
440	        }
441	      }
442	    }
443
444	    // Set when the Claude rung has already delivered a result — a later
445	    // stream error must then propagate instead of triggering a fallback
446	    // (the reply is already on its way; a second rung would double-send).
447	    let claudeDelivered = false;
448	    const self = this;
449
450	    async function* claudeEvents(): AsyncGenerator<ProviderEvent> {
451	      let claudeContinuation = providerContinuation('claude', input.continuation);
452	      activeClaudeQuery = self.claude.query({
453	        ...input,
454	        continuation: claudeContinuation,
455	      });
456	      for await (const event of activeClaudeQuery.events) {
457	        if (event.type === 'init') {
458	          claudeContinuation = event.continuation;
459	          yield { ...event, continuation: prefixedContinuation('claude', event.continuation) };
460	        } else {
461	          yield event;
462	        }
463	        if (event.type === 'error' && !event.retryable) {
464	          throw new Error(event.message);
465	        }
466	        // Only a delivered result counts as success — init/activity events
467	        // arrive before billing/quota failures and must not block fallback.
468	        if (event.type === 'result') claudeDelivered = true;
469	      }
470	      if (!claudeDelivered) {
471	        throw new Error('Anthropic query ended without a result');
472	      }
473
474	      // Safety net: drain any messages queued during a REST rung that fell back to Claude.
475	      while (restPendingQueue.length > 0 && !aborted) {
476	        const followUp = restPendingQueue.shift()!;
477	        activeClaudeQuery = self.claude.query({
478	          ...input,
479	          prompt: followUp,
480	          continuation: claudeContinuation,
481	        });
482	        for await (const event of activeClaudeQuery.events) {
483	          if (event.type === 'init') {
484	            claudeContinuation = event.continuation;
485	            yield { ...event, continuation: prefixedContinuation('claude', event.continuation) };
486	          } else {
487	            yield event;
488	          }
489	        }
490	      }
491	    }
492
493	    // Rung order (docs/provider-routing-plan.md):
494	    //   tool work  → Codex → Claude (only rung with tools) → LM Studio
495	    //   plain chat → Codex → LM Studio (free, local)       → Claude
496	    const events = (async function* (): AsyncGenerator<ProviderEvent> {
497	      const toolsFirst = needsClaudeCode(input);
498	      if (toolsFirst) log('Prompt appears to need workspace/wiki access; trying Codex before Claude Code');
499
500	      const rungs: Array<{ name: string; run: (isFinal: boolean) => AsyncGenerator<ProviderEvent>; isClaude?: boolean }> = toolsFirst
501	        ? [
502	            { name: 'Codex', run: (isFinal) => eventsFor(self.codex, { allowHonestNoToolsReply: isFinal }) },
503	            { name: 'Anthropic', run: claudeEvents, isClaude: true },
504	            { name: 'LM Studio', run: (isFinal) => eventsFor(self.lmstudio, { allowHonestNoToolsReply: isFinal }) },
505	          ]
506	        : [
507	            { name: 'Codex', run: (isFinal) => eventsFor(self.codex, { allowHonestNoToolsReply: isFinal }) },
508	            { name: 'LM Studio', run: (isFinal) => eventsFor(self.lmstudio, { allowHonestNoToolsReply: isFinal }) },
509	            { name: 'Anthropic', run: claudeEvents, isClaude: true },
510	          ];
511
512	      for (let i = 0; i < rungs.length; i++) {
513	        const rung = rungs[i];
514	        const next = rungs[i + 1];
515	        try {
516	          yield* rung.run(!next);
517	          return;
518	        } catch (err) {
519	          if (aborted) return;
520	          if (rung.isClaude && claudeDelivered) throw err;
521	          log(`${rung.name} failed: ${err instanceof Error ? err.message : String(err)}`);
522	          if (!next) throw err;
523	          yield {
524	            type: 'provider_notice',
525	            message: `${rung.name} is unavailable right now, so I am switching to ${next.name} for this reply.`,
526	          };
527	        }
528	      }
529	    })();
530
531	    return {
532	      push: (message) => {
533	        if (activeClaudeQuery) {
534	          activeClaudeQuery.push(message);
535	        } else {
536	          restPendingQueue.push(message);
537	        }
538	      },
539	      end: () => activeClaudeQuery?.end(),
540	      events,
541	      abort: () => {
542	        aborted = true;
543	        abortController.abort();
544	        activeClaudeQuery?.abort();
545	      },
546	    };
547	  }
548	}
549
550	// Registered name kept for compatibility with existing container.json configs
551	// even though the Gemini/OpenAI REST rungs were removed (see
552	// docs/provider-routing-plan.md). Rename to a routing-table provider in Phase 1.
553	registerProvider('gemini-openai-claude', (opts) => new GeminiOpenAiClaudeProvider(opts));
554

import fs from 'fs';
import path from 'path';
import { randomUUID } from 'crypto';

import { ClaudeProvider } from './claude.js';
import { registerProvider } from './provider-registry.js';
import type { AgentProvider, AgentQuery, ProviderEvent, ProviderOptions, QueryInput } from './types.js';

const HISTORY_LIMIT = 24;
const ANTHROPIC_MODEL = 'sonnet';
const MAX_CONTEXT_FILE_CHARS = 12000;
const MAX_SYSTEM_CONTEXT_CHARS = 60000;

interface ChatTurn {
  role: 'user' | 'assistant';
  content: string;
}

interface ChatResult {
  continuation: string;
  text: string;
}

interface RestProvider {
  readonly name: string;
  readonly noticeName: string;
  complete(input: QueryInput, signal: AbortSignal): Promise<ChatResult>;
}

function log(msg: string): void {
  console.error(`[fallback-provider] ${msg}`);
}

function historyPath(providerName: string, continuation: string): string {
  const historyDir = process.env.PROVIDER_HISTORY_DIR || '/workspace/provider-history';
  return path.join(historyDir, providerName, `${continuation}.json`);
}

function providerContinuation(providerName: string, continuation?: string): string | undefined {
  const prefix = `${providerName}:`;
  return continuation?.startsWith(prefix) ? continuation.slice(prefix.length) : undefined;
}

function prefixedContinuation(providerName: string, continuation: string): string {
  return `${providerName}:${continuation}`;
}

function loadHistory(providerName: string, continuation?: string): { id: string; turns: ChatTurn[] } {
  const providerId = providerContinuation(providerName, continuation);
  const id = providerId || randomUUID();
  if (!providerId) return { id, turns: [] };

  try {
    const raw = JSON.parse(fs.readFileSync(historyPath(providerName, providerId), 'utf8')) as { turns?: ChatTurn[] };
    const turns = Array.isArray(raw.turns) ? raw.turns.filter(isChatTurn) : [];
    return { id, turns };
  } catch {
    return { id, turns: [] };
  }
}

function saveHistory(providerName: string, continuation: string, turns: ChatTurn[]): void {
  const file = historyPath(providerName, continuation);
  fs.mkdirSync(path.dirname(file), { recursive: true });
  fs.writeFileSync(file, JSON.stringify({ turns: turns.slice(-HISTORY_LIMIT) }, null, 2) + '\n');
}

function isChatTurn(value: unknown): value is ChatTurn {
  if (!value || typeof value !== 'object') return false;
  const turn = value as { role?: unknown; content?: unknown };
  return (turn.role === 'user' || turn.role === 'assistant') && typeof turn.content === 'string';
}

function systemPrompt(input: QueryInput): string {
  const localContext = loadLocalInstructionContext(input.cwd);
  const parts = [
    input.systemContext?.instructions || '',
    localContext,
    [
      'You are running as a NanoClaw chat agent.',
      'Reply only with final text wrapped in <message to="name">...</message> blocks unless you need private scratchpad text inside <internal>...</internal>.',
      'Every reply MUST contain at least one <message to="name">...</message> block — a reply with only <internal> content is discarded and the user sees nothing.',
      'Use the destination names listed above. If the incoming message has a from="name" attribute, reply to that destination by default.',
      'You have been given the local agent instructions above as context. You have NO tool access in this provider mode: you cannot read files, browse, or defer work — answer now with what you know, and do not claim to have read or modified files unless the user provided that content in chat.',
    ].join('\n'),
  ].filter(Boolean);
  return parts.join('\n\n').slice(0, MAX_SYSTEM_CONTEXT_CHARS);
}

function loadLocalInstructionContext(cwd: string): string {
  const files = [
    path.join(cwd, 'CLAUDE.md'),
    path.join(cwd, 'CLAUDE.local.md'),
    path.join(cwd, 'repo-awareness.md'),
    '/workspace/extra/second-brain/SCHEMA.md',
    '/workspace/extra/second-brain/wiki/Home.md',
    '/workspace/extra/second-brain/wiki/Wiki Ingest Log.md',
  ];
  const seen = new Set<string>();
  const sections: string[] = [];
  for (const file of files) {
    const content = readMarkdownWithImports(file, seen);
    if (content) sections.push(content);
  }
  const secondBrainTree = listSecondBrainWiki();
  if (secondBrainTree) {
    sections.push(['# Mounted Wiki Snapshot', '', secondBrainTree].join('\n'));
  }
  return sections.length > 0 ? ['# Local Agent Instructions', '', ...sections].join('\n\n') : '';
}

function readMarkdownWithImports(filePath: string, seen: Set<string>): string {
  let realPath: string;
  try {
    realPath = fs.realpathSync(filePath);
  } catch {
    return '';
  }
  if (seen.has(realPath)) return '';
  seen.add(realPath);

  let content: string;
  try {
    content = fs.readFileSync(realPath, 'utf8').slice(0, MAX_CONTEXT_FILE_CHARS);
  } catch {
    return '';
  }

  const dir = path.dirname(filePath);
  const expanded = content
    .split('\n')
    .map((line) => {
      const match = line.match(/^@(.+\.md)\s*$/);
      if (!match) return line;
      const importPath = match[1].startsWith('/') ? match[1] : path.resolve(dir, match[1]);
      return readMarkdownWithImports(importPath, seen);
    })
    .filter(Boolean)
    .join('\n');

  return [`## ${path.basename(filePath)}`, '', expanded].join('\n');
}

function listSecondBrainWiki(): string {
  const wikiDir = '/workspace/extra/second-brain/wiki';
  let entries: string[];
  try {
    entries = fs.readdirSync(wikiDir);
  } catch {
    return '';
  }
  const md = entries.filter((name) => name.endsWith('.md')).sort().slice(0, 80);
  return md.length > 0 ? `Available wiki pages in ${wikiDir}:\n${md.map((name) => `- ${name}`).join('\n')}` : '';
}

const MESSAGE_BLOCK_RE = /<message\s+to="[^"]+"\s*>/;

const WRAP_RETRY_PROMPT =
  '<system>Your previous reply was NOT delivered: it contained no <message to="name">...</message> block. ' +
  'You have no tools — you cannot read files, browse, or perform actions, so do not say you will. ' +
  'Send your final answer NOW, wrapped in a <message to="name">...</message> block. ' +
  'Use the destination name from the incoming message\'s from="..." attribute.</system>';

function hasMessageBlock(text: string): boolean {
  return MESSAGE_BLOCK_RE.test(text);
}

/**
 * Tags this protocol actually understands. Anything else is a hallucinated
 * tool call — the REST rungs (LM Studio, Codex bridge) have no real tool
 * access, but a model sometimes fabricates plausible-looking tool syntax
 * (e.g. `<read_file path="..." />`) anyway, ignoring the system prompt's
 * explicit "you have no tools" instruction. hasMessageBlock only validates
 * the outer wrapper, so this garbage previously sailed straight through to
 * the user looking like gibberish/non-English output.
 */
const KNOWN_TAGS = new Set(['message', 'internal', 'system']);
const ANY_TAG_RE = /<\/?([a-zA-Z][\w-]*)\b[^>]*>/g;

function hasHallucinatedToolTag(text: string): boolean {
  ANY_TAG_RE.lastIndex = 0;
  let match: RegExpExecArray | null;
  while ((match = ANY_TAG_RE.exec(text)) !== null) {
    if (!KNOWN_TAGS.has(match[1].toLowerCase())) return true;
  }
  return false;
}

const TOOL_HALLUCINATION_RETRY_PROMPT =
  '<system>Your previous reply contained tool-call-like syntax (e.g. <read_file>, <search_conversations>). ' +
  'You have NO tool access in this provider mode — you cannot read files, browse, or search anything, so do not ' +
  'emit any tags other than <message to="name">...</message> and <internal>...</internal>. ' +
  'Answer in plain natural language only, using what you already know from this conversation. ' +
  'Use the destination name from the incoming message\'s from="..." attribute.</system>';

/** Honest last resort when the final REST rung can't stop fabricating tool calls after a corrective retry. */
function honestNoToolsReply(prompt: string): string {
  const text =
    "I don't have tool access on this fallback model right now, so I can't read files or search anything for you. Please try again once the primary provider is available.";
  const dest = lastFromDestination(prompt);
  return dest ? `<message to="${dest}">${text}</message>` : text;
}

/** Destination of the most recent inbound message in the formatted prompt. */
function lastFromDestination(prompt: string): string | undefined {
  let dest: string | undefined;
  const re = /<message\s[^>]*\bfrom="([^"]+)"/g;
  let match: RegExpExecArray | null;
  while ((match = re.exec(prompt)) !== null) dest = match[1];
  return dest;
}

function stripInternalBlocks(text: string): string {
  return text.replace(/<internal>[\s\S]*?<\/internal>/g, '').trim();
}

function unwrapInternalBlocks(text: string): string {
  const parts: string[] = [];
  const re = /<internal>([\s\S]*?)<\/internal>/g;
  let match: RegExpExecArray | null;
  while ((match = re.exec(text)) !== null) {
    const body = match[1].trim();
    if (body) parts.push(body);
  }
  return parts.join('\n\n');
}

/**
 * Last-resort wrap for a reply that still has no <message> block after the
 * corrective retry. Local models (LM Studio) routinely answer with only
 * <internal> scratchpad; dropping that as undeliverable means the user gets
 * total silence from the final fallback rung. Imperfect delivery beats none.
 */
function forceWrapReply(primary: string, fallback: string, prompt: string): string {
  const dest = lastFromDestination(prompt);
  if (!dest) return primary;
  const visible =
    stripInternalBlocks(primary) || unwrapInternalBlocks(primary) || stripInternalBlocks(fallback) || unwrapInternalBlocks(fallback);
  if (!visible) return primary;
  return `<message to="${dest}">${visible}</message>`;
}

function needsClaudeCode(input: QueryInput): boolean {
  const text = input.prompt.toLowerCase();
  // <task> messages from the scheduler are always agent work requiring tools.
  if (/<task\b/.test(input.prompt)) return true;
  const sourceLink = /https?:\/\/\S+/.test(text);
  return (
    sourceLink ||
    /\b(ingest|youtube|podcast|instagram|repo|repository|transcript|wiki|second-brain|autopilot|portfolio|brief|write|update|append|git|commit|push|stage changes|merge|rebase|checkout|tool-recommendations|\/workspace|\/workspace\/extra)\b/.test(
      text,
    ) ||
    // File operations with optional articles: "read file", "read the file", "edit this file"
    /\b(?:create|edit|read|open)\b.{0,15}\bfile\b/.test(text)
  );
}

function failMessage(provider: string, response: Response, body: string): string {
  const detail = body.trim().slice(0, 500);
  return `${provider} request failed (${response.status} ${response.statusText})${detail ? `: ${detail}` : ''}`;
}

async function fetchWithFallbackUrls(urls: string[], init: RequestInit): Promise<Response> {
  let lastError: unknown;
  for (const url of urls) {
    try {
      const res = await fetch(url, init);
      return res;
    } catch (err) {
      lastError = err;
      log(`Failed fetch to ${url}: ${err instanceof Error ? err.message : String(err)}`);
    }
  }
  throw lastError || new Error('All fallback URLs failed to respond');
}

/**
 * Shared OpenAI-compatible chat-completions rung. LM Studio and the Codex
 * bridge both speak this protocol on a host-local port; the Antigravity
 * bridge (planned, docs/provider-routing-plan.md) will too. Deprecated
 * direct-API rungs (Gemini REST, OpenAI REST, Antigravity :8045 stub) were
 * removed 2026-07-07 — see git history and the plan doc.
 */
class OpenAiCompatRestProvider implements RestProvider {
  constructor(
    readonly name: string,
    readonly noticeName: string,
    private readonly baseUrl: string,
    private readonly apiKey: string,
    private readonly model: string,
  ) {}

  async complete(input: QueryInput, signal: AbortSignal): Promise<ChatResult> {
    const history = loadHistory(this.name, input.continuation);
    const turns = [...history.turns, { role: 'user' as const, content: input.prompt }];
    const url = `${this.baseUrl.replace(/\/$/, '')}/chat/completions`;
    const urls = [
      url,
      url.replace('host.docker.internal', 'localhost'),
      url.replace('host.docker.internal', '127.0.0.1'),
    ];

    const res = await fetchWithFallbackUrls(urls, {
      method: 'POST',
      signal,
      headers: {
        authorization: `Bearer ${this.apiKey}`,
        'content-type': 'application/json',
      },
      body: JSON.stringify({
        model: this.model,
        messages: [{ role: 'system', content: systemPrompt(input) }, ...turns],
      }),
    });

    const body = await res.text();
    if (!res.ok) throw new Error(failMessage(this.noticeName, res, body));

    const parsed = JSON.parse(body) as { choices?: Array<{ message?: { content?: string } }> };
    const text = parsed.choices?.[0]?.message?.content?.trim();
    if (!text) throw new Error(`${this.noticeName} returned no message content`);

    saveHistory(this.name, history.id, [...turns, { role: 'assistant', content: text }]);
    return { continuation: prefixedContinuation(this.name, history.id), text };
  }
}

export class GeminiOpenAiClaudeProvider implements AgentProvider {
  readonly supportsNativeSlashCommands = false;

  private readonly claude: ClaudeProvider;
  private readonly lmstudio: OpenAiCompatRestProvider;
  private readonly codex: OpenAiCompatRestProvider;

  constructor(options: ProviderOptions = {}) {
    const env = options.env ?? {};
    this.claude = new ClaudeProvider({
      ...options,
      model: env.ANTHROPIC_MODEL || env.CLAUDE_MODEL || options.model || ANTHROPIC_MODEL,
    });
    this.lmstudio = new OpenAiCompatRestProvider(
      'lmstudio',
      'LM Studio',
      env.LM_STUDIO_BASE_URL || 'http://host.docker.internal:1234/v1',
      env.LM_STUDIO_API_KEY || 'lm-studio',
      env.LM_STUDIO_MODEL || 'local-model',
    );
    // Codex bridge: host-side shim that fronts `codex exec` (ChatGPT
    // subscription) with an OpenAI-compatible endpoint. scripts/codex-bridge.mjs
    this.codex = new OpenAiCompatRestProvider(
      'codex',
      'Codex',
      env.CODEX_BRIDGE_BASE_URL || 'http://host.docker.internal:8046/v1',
      env.CODEX_BRIDGE_API_KEY || 'codex-bridge',
      env.CODEX_MODEL || 'default',
    );
  }

  isSessionInvalid(err: unknown): boolean {
    return this.claude.isSessionInvalid(err);
  }

  maybeRotateContinuation(continuation: string, cwd: string): string | null {
    const claudeContinuation = providerContinuation('claude', continuation);
    void cwd;
    return claudeContinuation ? this.claude.maybeRotateContinuation?.(claudeContinuation) ?? null : null;
  }

  query(input: QueryInput): AgentQuery {
    const abortController = new AbortController();
    let activeClaudeQuery: AgentQuery | null = null;
    let aborted = false;
    // Messages pushed while a REST rung is active (no live channel to inject into).
    // eventsFor() drains these as sequential follow-up turns after the REST response.
    const restPendingQueue: string[] = [];

    async function* eventsFor(
      provider: RestProvider,
      options: { allowHonestNoToolsReply: boolean },
    ): AsyncGenerator<ProviderEvent> {
      let result = await provider.complete(input, abortController.signal);
      if (!hasMessageBlock(result.text)) {
        log(`${provider.noticeName} reply had no <message> block; retrying with corrective instruction`);
        const firstText = result.text;
        try {
          const retry = await provider.complete(
            { ...input, prompt: WRAP_RETRY_PROMPT, continuation: result.continuation },
            abortController.signal,
          );
          result = hasMessageBlock(retry.text)
            ? retry
            : { ...retry, text: forceWrapReply(retry.text, firstText, input.prompt) };
        } catch (err) {
          log(`${provider.noticeName} corrective retry failed: ${err instanceof Error ? err.message : String(err)}`);
          result = { ...result, text: forceWrapReply(firstText, '', input.prompt) };
        }
      }
      if (hasHallucinatedToolTag(result.text)) {
        log(`${provider.noticeName} reply contained hallucinated tool-call syntax; retrying with corrective instruction`);
        let retry: ChatResult | null = null;
        try {
          retry = await provider.complete(
            { ...input, prompt: TOOL_HALLUCINATION_RETRY_PROMPT, continuation: result.continuation },
            abortController.signal,
          );
        } catch (err) {
          log(
            `${provider.noticeName} tool-hallucination retry failed: ${err instanceof Error ? err.message : String(err)}`,
          );
          if (!options.allowHonestNoToolsReply) throw err;
          result = { ...result, text: honestNoToolsReply(input.prompt) };
        }
        if (retry) {
          if (hasMessageBlock(retry.text) && !hasHallucinatedToolTag(retry.text)) {
            result = retry;
          } else if (options.allowHonestNoToolsReply) {
            result = { ...retry, text: honestNoToolsReply(input.prompt) };
          } else {
            throw new Error(`${provider.noticeName} repeatedly emitted tool-call-like syntax`);
          }
        }
      }
      yield { type: 'init', continuation: result.continuation };
      yield { type: 'result', text: result.text };

      // Drain follow-up messages that arrived via push() while this REST rung was active.
      let cont = result.continuation;
      while (restPendingQueue.length > 0 && !aborted) {
        const followUp = restPendingQueue.shift()!;
        try {
          const followResult = await provider.complete(
            { ...input, prompt: followUp, continuation: cont },
            abortController.signal,
          );
          cont = followResult.continuation;
          yield { type: 'result', text: followResult.text };
        } catch (err) {
          log(`${provider.noticeName} follow-up failed: ${err instanceof Error ? err.message : String(err)}`);
          break;
        }
      }
    }

    // Set when the Claude rung has already delivered a result — a later
    // stream error must then propagate instead of triggering a fallback
    // (the reply is already on its way; a second rung would double-send).
    let claudeDelivered = false;
    const self = this;

    async function* claudeEvents(): AsyncGenerator<ProviderEvent> {
      let claudeContinuation = providerContinuation('claude', input.continuation);
      activeClaudeQuery = self.claude.query({
        ...input,
        continuation: claudeContinuation,
      });
      for await (const event of activeClaudeQuery.events) {
        if (event.type === 'init') {
          claudeContinuation = event.continuation;
          yield { ...event, continuation: prefixedContinuation('claude', event.continuation) };
        } else {
          yield event;
        }
        if (event.type === 'error' && !event.retryable) {
          throw new Error(event.message);
        }
        // Only a delivered result counts as success — init/activity events
        // arrive before billing/quota failures and must not block fallback.
        if (event.type === 'result') claudeDelivered = true;
      }
      if (!claudeDelivered) {
        throw new Error('Anthropic query ended without a result');
      }

      // Safety net: drain any messages queued during a REST rung that fell back to Claude.
      while (restPendingQueue.length > 0 && !aborted) {
        const followUp = restPendingQueue.shift()!;
        activeClaudeQuery = self.claude.query({
          ...input,
          prompt: followUp,
          continuation: claudeContinuation,
        });
        for await (const event of activeClaudeQuery.events) {
          if (event.type === 'init') {
            claudeContinuation = event.continuation;
            yield { ...event, continuation: prefixedContinuation('claude', event.continuation) };
          } else {
            yield event;
          }
        }
      }
    }

    // Rung order (docs/provider-routing-plan.md):
    //   tool work  → Codex → Claude (only rung with tools) → LM Studio
    //   plain chat → Codex → LM Studio (free, local)       → Claude
    const events = (async function* (): AsyncGenerator<ProviderEvent> {
      const toolsFirst = needsClaudeCode(input);
      if (toolsFirst) log('Prompt appears to need workspace/wiki access; trying Codex before Claude Code');

      const rungs: Array<{ name: string; run: (isFinal: boolean) => AsyncGenerator<ProviderEvent>; isClaude?: boolean }> = toolsFirst
        ? [
            { name: 'Codex', run: (isFinal) => eventsFor(self.codex, { allowHonestNoToolsReply: isFinal }) },
            { name: 'Anthropic', run: claudeEvents, isClaude: true },
            { name: 'LM Studio', run: (isFinal) => eventsFor(self.lmstudio, { allowHonestNoToolsReply: isFinal }) },
          ]
        : [
            { name: 'Codex', run: (isFinal) => eventsFor(self.codex, { allowHonestNoToolsReply: isFinal }) },
            { name: 'LM Studio', run: (isFinal) => eventsFor(self.lmstudio, { allowHonestNoToolsReply: isFinal }) },
            { name: 'Anthropic', run: claudeEvents, isClaude: true },
          ];

      for (let i = 0; i < rungs.length; i++) {
        const rung = rungs[i];
        const next = rungs[i + 1];
        try {
          yield* rung.run(!next);
          return;
        } catch (err) {
          if (aborted) return;
          if (rung.isClaude && claudeDelivered) throw err;
          log(`${rung.name} failed: ${err instanceof Error ? err.message : String(err)}`);
          if (!next) throw err;
          yield {
            type: 'provider_notice',
            message: `${rung.name} is unavailable right now, so I am switching to ${next.name} for this reply.`,
          };
        }
      }
    })();

    return {
      push: (message) => {
        if (activeClaudeQuery) {
          activeClaudeQuery.push(message);
        } else {
          restPendingQueue.push(message);
        }
      },
      end: () => activeClaudeQuery?.end(),
      events,
      abort: () => {
        aborted = true;
        abortController.abort();
        activeClaudeQuery?.abort();
      },
    };
  }
}

// Registered name kept for compatibility with existing container.json configs
// even though the Gemini/OpenAI REST rungs were removed (see
// docs/provider-routing-plan.md). Rename to a routing-table provider in Phase 1.
registerProvider('gemini-openai-claude', (opts) => new GeminiOpenAiClaudeProvider(opts));


Now I understand the full code. The current state is:
- Phase 0.6: Codex-first routing is in place
- Phase 1 classifier: `needsClaudeCode()` is fixed (handles `<task>`, "read the file", autopilot keywords)

What remains for Phase 1:
1. **Routing table as config** (config-driven rung ordering, not hardcoded)
2. **Per-class model/effort** for the Claude rung

Looking at the code, the routing table is currently hardcoded in the `query()` method. The comment at line 550-553 says "Rename to a routing-table provider in Phase 1."
I'm weighing two approaches: I could implement per-class model selection to route planning tasks to `opus` with higher effort, which would immediately improve autopilot performance. Or I could tackle the bigger refactor of making the rung ordering configurable. The first option feels like the clearer win since planning tasks genuinely need the stronger model.

So I'll refactor `needsClaudeCode` to return a task class enum instead of just a boolean—distinguishing between `planning`, `tool-work`, and `chat`—then use that classification to pick the right model for each task type.

Now I need to check what models are actually available in the provider configuration. The Claude provider reads from environment variables and defaults to `sonnet`, but I should verify whether it supports `opus` or if there's an effort-level system I can leverage.

The challenge is that the provider gets constructed once with a fixed model, so to use different models per query, I'd either need to create a new provider instance for each request or pass the model selection through the query parameters. A simpler path forward would be to detect when a task is planning-related and then instantiate a new provider with the appropriate model for that specific query.

Looking at the cost implications, opus is significantly more expensive per token, but since we're working with subscription-based quota rather than per-token billing, the real concern is quota consumption. The routing plan does explicitly call for opus on planning tasks though, so that's the direction to go. I'll implement this by extending the task classification to return task types, then in the query method, check if it's a planning task and create a new provider with opus if needed.

Writing the task classification logic... Storing the provider options in the constructor so I can access them later in the query method. For planning tasks, I need to instantiate a new Claude provider with the opus model instead of using the default Sonnet instance. The routing logic should prioritize Claude for planning work rather than starting with Codex. The real value here is recognizing that autopilot cycles need Claude with tools access (so the Codex → Claude → LM Studio routing is correct), but within the Claude rung, planning-focused tasks should use opus instead of sonnet for better quality. I can detect planning tasks in the `<task>` messages and route them to the stronger model while keeping the tool-access priority intact. I need to handle per-model selection by storing the provider options and creating opus-specific instances on demand when needed for high-value tasks, rather than trying to swap models on an existing provider instance.

Now I'm weighing whether this is worth adding to the deploy queue—there are already four commits waiting, but since this is autonomous work and they'll deploy together anyway, the real question is whether the benefit of using opus for planning tasks justifies the additional change.

I've got the budget for it with 5 hours available at full capacity. My approach is to introduce a task classifier that returns planning, tool-work, or chat categories, then route planning tasks through opus while keeping the others on the existing rung order. I'll add the type definition, replace the existing classifier, store the base options, and create an opus provider conditionally in the query method. The rung ordering for planning should prioritize Claude-first with opus and tools available.

Actually, planning tasks are rare and mostly involve file operations like autopilot cycles, so they fit the same Codex-first pattern as tool-work. I'll keep the rung order unchanged for now and just add opus model selection for planning tasks—that's the pragmatic move since the rung ordering is a separate concern.

The key insight is that autopilot tasks where I'm making architectural decisions would benefit most from opus, but I need to account for the fact that the environment variable already sets the model to sonnet, which would override any code-level selection.

Given the complexity of working around that precedence issue, I think the more practical first step is to refactor the task classification to return an enum instead of just a boolean — that's a smaller change that keeps things forward-compatible and sets up the foundation for model selection later. Looking at the qip-qien project, I could check the repo health after the SEV-1 fix was shipped—reviewing CI status, recent commits, and any lingering concerns—then produce a summary for James since it's marked as review-required.

For congressional-trading, the threshold decision is blocked on James, but I could verify whether the weekly precision monitor is actually running as scheduled on Mondays at 9am by checking recent execution logs.

That said, the priority guidelines push me toward P1/P2 work. I'm leaning toward implementing the autopilot skip-lock resend for StageHand questions — those five unanswered questions have been sitting for 17 days, and the task explicitly says to resend if there's no response yet. This feels like the most concrete, immediately actionable step I can take.

So my plan is to resend those StageHand questions to James via Telegram, then tackle the nanoclaw classifier refactor to extend it with a typed enum and planning detection logic. SSH check is available, so moving on to assess the projects. StageHand needs those five questions resent to James—that's quick work. For nanoclaw, I should tackle the Phase 1 classifier refactor with the classifyTask enum as the main effort. The instructions ask for one unit of work, but resending questions is just a five-minute action, so I can do both. Then I'll update the cards, log the runs, schedule the next reset-wake for late July, report to slack-infrastructure, and clean up the running lock.

Actually, let me reconsider the scope here. Nanoclaw is the real code work—implementing the routing table in the container config and the fixed classifier. I'll treat that as the primary unit of work and handle the StageHand questions as housekeeping since it's just composing a message. For nanoclaw, I need to figure out the right implementation scope by checking what routing config or schema already exists in the codebase.

The most practical Phase 1 work is implementing per-class model and effort selection. I'll extend the task classification to detect planning tasks versus implementation tasks, then use different Claude models based on the task class—checking for environment variables like `CLAUDE_PLANNING_MODEL` to override the default model for planning work.

Defining the task classifier... Setting up the provider configuration... The `ANTHROPIC_MODEL` env var from the OneCLI proxy forces sonnet everywhere, so I'd need to bypass it specifically for planning tasks to use opus instead. That's getting messy.

Let me step back and do the simplest valuable thing: rename `needsClaudeCode` to `classifyTask` and return a typed enum instead of a boolean, keeping the same binary behavior for now and adding logging. This is a clean, forward-compatible refactor that sets us up for Phase 2 when we add the full task classification system. Since `needsClaudeCode` is private and not exported, the tests only check its effect through the rung ordering, so I need to verify what actually breaks.

My plan is to implement the `classifyTask` refactor with a new `'planning'` class that detects `<task>` tags with autopilot/portfolio/brief keywords, update the log message to include the task class, add a few tests for planning classification, and keep the routing behavior identical to what exists now. Model selection gets pushed to Phase 1.5 once we finalize the routing table config design.

Now I'll write the actual code changes to `gemini-openai-claude.ts`: adding the `TaskClass` type, replacing `needsClaudeCode()` with `classifyTask()`, updating the log message to include the task class, and adjusting the rung selection logic to check `taskClass !== 'chat'` for tool routing.


1	import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
2	import fs from 'fs';
3	import path from 'path';
4	import { GeminiOpenAiClaudeProvider } from './gemini-openai-claude.js';
5	import { ClaudeProvider } from './claude.js';
6	import type { QueryInput, ProviderEvent } from './types.js';
7
8	// Chain order:
9	//   plain chat → Codex → LM Studio → Claude
10	//   tool work  → Codex → Claude → LM Studio
11	describe('GeminiOpenAiClaudeProvider Fallback Path', () => {
12	  let originalFetch: typeof fetch;
13	  let originalClaudeQuery: typeof ClaudeProvider.prototype.query;
14	  let fetchedUrls: string[] = [];
15	  let fetchedBodies: any[] = [];
16	  let fetchResult: (url: string, init?: RequestInit) => Promise<Response>;
17	  let claudeQueryResult: () => { events: AsyncIterable<ProviderEvent> };
18	  const testTmpDir = path.join(import.meta.dirname || __dirname, '../../.tmp-test-history');
19
20	  beforeAll(() => {
21	    originalFetch = globalThis.fetch;
22	    originalClaudeQuery = ClaudeProvider.prototype.query;
23
24	    process.env.PROVIDER_HISTORY_DIR = testTmpDir;
25	    fs.mkdirSync(testTmpDir, { recursive: true });
26
27	    globalThis.fetch = async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
28	      const urlStr = typeof url === 'string' ? url : url.toString();
29	      fetchedUrls.push(urlStr);
30	      if (init?.body) {
31	        try {
32	          fetchedBodies.push(JSON.parse(init.body as string));
33	        } catch {
34	          fetchedBodies.push(init.body);
35	        }
36	      }
37	      return fetchResult(urlStr, init);
38	    };
39
40	    ClaudeProvider.prototype.query = function () {
41	      return {
42	        push: () => {},
43	        end: () => {},
44	        abort: () => {},
45	        ...claudeQueryResult(),
46	      };
47	    };
48	  });
49
50	  afterAll(() => {
51	    globalThis.fetch = originalFetch;
52	    ClaudeProvider.prototype.query = originalClaudeQuery;
53	    try {
54	      fs.rmSync(testTmpDir, { recursive: true, force: true });
55	    } catch {
56	      // ignore
57	    }
58	  });
59
60	  beforeEach(() => {
61	    fetchedUrls = [];
62	    fetchedBodies = [];
63	    claudeQueryResult = () => ({
64	      events: (async function* () {
65	        yield { type: 'init', continuation: 'claude-session' };
66	        yield { type: 'result', text: 'claude-reply' };
67	      })(),
68	    });
69	  });
70
71	  const chatInput: QueryInput = {
72	    prompt: '<message id="1" from="telegram-mg-17796" sender="James" time="now">what is the quota situation?</message>',
73	    cwd: '/workspace',
74	  };
75
76	  const toolInput: QueryInput = {
77	    prompt: 'summarize https://example.com/post',
78	    cwd: '/workspace',
79	  };
80
81	  const telegramToolInput: QueryInput = {
82	    prompt:
83	      '<message id="2" from="telegram-mg-17796" sender="James" time="now">summarize https://example.com/post</message>',
84	    cwd: '/workspace',
85	  };
86
87	  const providerOpts = {
88	    env: {
89	      LM_STUDIO_API_KEY: 'mock-lmstudio-key',
90	      LM_STUDIO_BASE_URL: 'http://host.docker.internal:1234/v1',
91	      CODEX_BRIDGE_BASE_URL: 'http://host.docker.internal:8046/v1',
92	    },
93	  };
94
95	  const wrapped = (text: string) => `<message to="telegram-mg-17796">${text}</message>`;
96
97	  function lmStudioReply(text: string) {
98	    return new Response(JSON.stringify({ choices: [{ message: { content: text } }] }), { status: 200 });
99	  }
100

import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'bun:test';
import fs from 'fs';
import path from 'path';
import { GeminiOpenAiClaudeProvider } from './gemini-openai-claude.js';
import { ClaudeProvider } from './claude.js';
import type { QueryInput, ProviderEvent } from './types.js';

// Chain order:
//   plain chat → Codex → LM Studio → Claude
//   tool work  → Codex → Claude → LM Studio
describe('GeminiOpenAiClaudeProvider Fallback Path', () => {
  let originalFetch: typeof fetch;
  let originalClaudeQuery: typeof ClaudeProvider.prototype.query;
  let fetchedUrls: string[] = [];
  let fetchedBodies: any[] = [];
  let fetchResult: (url: string, init?: RequestInit) => Promise<Response>;
  let claudeQueryResult: () => { events: AsyncIterable<ProviderEvent> };
  const testTmpDir = path.join(import.meta.dirname || __dirname, '../../.tmp-test-history');

  beforeAll(() => {
    originalFetch = globalThis.fetch;
    originalClaudeQuery = ClaudeProvider.prototype.query;

    process.env.PROVIDER_HISTORY_DIR = testTmpDir;
    fs.mkdirSync(testTmpDir, { recursive: true });

    globalThis.fetch = async (url: string | URL | Request, init?: RequestInit): Promise<Response> => {
      const urlStr = typeof url === 'string' ? url : url.toString();
      fetchedUrls.push(urlStr);
      if (init?.body) {
        try {
          fetchedBodies.push(JSON.parse(init.body as string));
        } catch {
          fetchedBodies.push(init.body);
        }
      }
      return fetchResult(urlStr, init);
    };

    ClaudeProvider.prototype.query = function () {
      return {
        push: () => {},
        end: () => {},
        abort: () => {},
        ...claudeQueryResult(),
      };
    };
  });

  afterAll(() => {
    globalThis.fetch = originalFetch;
    ClaudeProvider.prototype.query = originalClaudeQuery;
    try {
      fs.rmSync(testTmpDir, { recursive: true, force: true });
    } catch {
      // ignore
    }
  });

  beforeEach(() => {
    fetchedUrls = [];
    fetchedBodies = [];
    claudeQueryResult = () => ({
      events: (async function* () {
        yield { type: 'init', continuation: 'claude-session' };
        yield { type: 'result', text: 'claude-reply' };
      })(),
    });
  });

  const chatInput: QueryInput = {
    prompt: '<message id="1" from="telegram-mg-17796" sender="James" time="now">what is the quota situation?</message>',
    cwd: '/workspace',
  };

  const toolInput: QueryInput = {
    prompt: 'summarize https://example.com/post',
    cwd: '/workspace',
  };

  const telegramToolInput: QueryInput = {
    prompt:
      '<message id="2" from="telegram-mg-17796" sender="James" time="now">summarize https://example.com/post</message>',
    cwd: '/workspace',
  };

  const providerOpts = {
    env: {
      LM_STUDIO_API_KEY: 'mock-lmstudio-key',
      LM_STUDIO_BASE_URL: 'http://host.docker.internal:1234/v1',
      CODEX_BRIDGE_BASE_URL: 'http://host.docker.internal:8046/v1',
    },
  };

  const wrapped = (text: string) => `<message to="telegram-mg-17796">${text}</message>`;

  function lmStudioReply(text: string) {
    return new Response(JSON.stringify({ choices: [{ message: { content: text } }] }), { status: 200 });
  }