Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
90c7578
feat(flags): typed registry — Phase 1 discriminated-union flag defs +…
dean0x Aug 23, 2026
e75904c
feat(flags): Phase 2 — manifest FlagsRecord type, in-reader heal, typ…
dean0x Aug 23, 2026
2893406
feat(flags): Phase 3 — typed flags CLI rewrite (createFlagsCommand fa…
dean0x Aug 23, 2026
31da4ab
feat(proxy): Phase 4 — pair UNKNOWN_MODEL_WINDOW_ENV with relay URL (…
dean0x Aug 23, 2026
35035d0
feat(flags-view): Phase 5 — generic TUI shell + flags-view + agents-v…
dean0x Aug 23, 2026
5487e97
feat(init): Phase 6 — FlagsRecord init integration, TUI flags editor,…
dean0x Aug 23, 2026
fca49ff
refactor(flags): post-implementation simplification pass
dean0x Aug 23, 2026
3ac14d6
fix(flags): scrutinize pass — silent CLI success, vacuous e2e, TUI ra…
dean0x Aug 23, 2026
3352da7
fix(flags): alignment pass — ADR-016 vocabulary, sanitizeCell, edit l…
dean0x Aug 24, 2026
1fcd7f0
docs(flags): typed registry, record storage, CLI reference — Phase 7 …
dean0x Aug 24, 2026
b815d8b
refactor(manifest): extract parseManifestFlags helper; fix D39 test m…
dean0x Aug 25, 2026
cc77d49
fix(init): TS-M1 ViewMode orphan, ARCH-S3 aliasing, REL-S2/S3, CPLX-S…
dean0x Aug 25, 2026
d4d4e08
fix(proxy): strip UNKNOWN_MODEL_WINDOW_ENV unconditionally on disable…
dean0x Aug 25, 2026
e2a7357
refactor(cli): promote ANSI primitives to src/core/ansi.ts
dean0x Aug 25, 2026
82a2804
fix(tui): RunTuiSpec<S,A,C> — Exclude<A,C> return eliminates adapter …
dean0x Aug 25, 2026
0dcc550
docs(knowledge): rewrite installer-shadowing KB for FlagsRecord model…
dean0x Aug 25, 2026
1a614c5
docs: branch-scoped doc sweep — flags TUI, window env var, structure …
dean0x Aug 25, 2026
19784ca
test(tui): pin cells contract and frame escape-sequence composition
dean0x Aug 25, 2026
8b80995
fix(flags): SEC-M1/TS-H1/TS-M3/REL-S1 — validation sink hardening
dean0x Aug 25, 2026
57c6165
fix(flags): fold-before-strip pipeline — SEC-M3 ARCH-H1 REG-H1, appli…
dean0x Aug 25, 2026
80c6ab3
fix(flags): vocabulary, defaultValueOf, findFlag, dead-code removal
dean0x Aug 25, 2026
97638da
docs(flags): JSDoc on FlagKind/FLAG_REGISTRY, proxy-owned env note, e…
dean0x Aug 25, 2026
a91d6a7
refactor(flags-view): hoist emptiness guard, collapse move/cycle, uni…
dean0x Aug 25, 2026
8a7d869
fix(flags-view): header alignment, viewportHeight owner, label saniti…
dean0x Aug 25, 2026
5bfa696
refactor(flags): extract handlers + loadFlagContext + formatStatusRows
dean0x Aug 25, 2026
33dc2e0
fix(flags): truthful persistFlagConfig result + bare TTY manifest guard
dean0x Aug 25, 2026
076f2a9
fix(flags-view): ARCH-M7 chevron styling, caret survival, deviation s…
dean0x Aug 25, 2026
3bbf259
fix(flags): harden TTY gate, settings parse, manifest threading, re-r…
dean0x Aug 25, 2026
489e344
refactor(flags): extract describeFlagKind + expectedInputFor; p.outro…
dean0x Aug 25, 2026
5d1a7a2
fix(flags): TS-M2 redundant casts, TS-S1 non-null assertions, TEST-M5…
dean0x Aug 25, 2026
1003d72
refactor(flags-view): self-sufficient FlagRow, one-definition seams, …
dean0x Aug 25, 2026
89e05d3
fix(cli): wrap runTui awaits; switch to parseAsync (REG-SF1)
dean0x Aug 25, 2026
640b88f
test(flags-view): repair vacuous assertions, clamp exactness, self-co…
dean0x Aug 25, 2026
b9ebed1
test(flags): caret-branch coverage, normalizeKey table, whole-post-st…
dean0x Aug 25, 2026
b0f01a4
refactor(flags): simplification pass — resolution follow-up
dean0x Aug 25, 2026
82a9c83
feat(flags): user-feedback pass — effective display, blurb column, in…
dean0x Aug 25, 2026
e9b8c98
docs(knowledge): refresh installer-shadowing KB for flags UX pass
dean0x Aug 25, 2026
abed12f
refactor(init): remove flags TUI from init flow; apply seeded default…
dean0x Aug 25, 2026
451ae85
docs: fix flags-editor drift in CLAUDE.md and file-organization.md
dean0x Aug 25, 2026
8c2dac6
docs(knowledge): refresh external-model-routing KB
dean0x Aug 25, 2026
9f50b81
docs(knowledge): installer-shadowing KB — init applies seeded flags n…
dean0x Aug 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 15 additions & 10 deletions .devflow/features/external-model-routing/KNOWLEDGE.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
---
feature: external-model-routing
name: External Model Routing & Per-Agent Model Config
description: "Use when working on the proxy lifecycle (enable/disable/status/preflight), the ensure-proxy hook, per-agent model mapping, agent frontmatter rewriting, or the agents TUI. Keywords: proxy, external-model-routing, GPT, agent-models, ensure-proxy, frontmatter, devflow proxy, devflow agents, subswitch, ANTHROPIC_BASE_URL, dormancy, reapplyAgentMapping."
description: "Use when working on the proxy lifecycle (enable/disable/status/preflight), the ensure-proxy hook, per-agent model mapping, agent frontmatter rewriting, or the agents TUI. Keywords: proxy, external-model-routing, GPT, agent-models, ensure-proxy, frontmatter, devflow proxy, devflow agents, subswitch, ANTHROPIC_BASE_URL, CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT, dormancy, reapplyAgentMapping, runTui, flags-view, tui."
category: architecture
directories: [src/core/proxy-state.ts, src/core/external-models.ts, src/core/agent-models.ts, src/core/agent-state.ts, src/core/agent-frontmatter.ts, src/core/codex-auth-inspect.ts, src/core/model-discovery.ts, src/core/cache.ts, src/core/proxy-log.ts, src/cli/commands/proxy.ts, src/cli/commands/agents.ts, src/cli/agents-view, src/assets/scripts/hooks/ensure-proxy]
directories: [src/core/proxy-state.ts, src/core/external-models.ts, src/core/agent-models.ts, src/core/agent-state.ts, src/core/agent-frontmatter.ts, src/core/codex-auth-inspect.ts, src/core/model-discovery.ts, src/core/cache.ts, src/core/proxy-log.ts, src/cli/commands/proxy.ts, src/cli/commands/agents.ts, src/cli/agents-view, src/cli/tui, src/assets/scripts/hooks/ensure-proxy]
created: 2026-07-24
updated: 2026-08-19
updated: 2026-08-25
---

# External Model Routing & Per-Agent Model Config
Expand Down Expand Up @@ -51,7 +51,7 @@ Hard failures at any step (steps 1–9) set `process.exitCode = 1` and return

The relay process is intentionally left running on `--disable` for any live Claude Code sessions. The disable path:
1. Read `proxy.json` first to determine `managedPort` for the URL strip.
2. `applyDisableToSettings(parsedSettings, managedPort)` — removes hooks AND strips `ANTHROPIC_BASE_URL` (see invariant below).
2. `applyDisableToSettings(parsedSettings, managedPort)` — removes hooks AND strips `ANTHROPIC_BASE_URL` (port-scoped) and `CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT` (unconditional) (see invariant below).
3. Writes `proxy.json` `enabled:false` — **keeps** `port`, `binPath`, `configPath`, `resolvedAt`, `devflowVersion` for the next enable.
4. Syncs manifest to `proxy: false`.
5. `revertExternalAgents()` — rewrites installed agent files to shipped default models.
Expand All @@ -62,15 +62,18 @@ Hard failures (e.g., malformed `settings.json`) set `process.exitCode = 1` and r
### `applyDisableToSettings` — both-operations invariant

```typescript
// CORRECT — both operations run unconditionally; managedPort scopes the URL strip:
// CORRECT — both operations run unconditionally; managedPort scopes only the URL strip.
// _stripProxyEnvFromObject removes CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT
// unconditionally (Devflow is its only producer) and removes ANTHROPIC_BASE_URL only
// when it exactly matches http://127.0.0.1:<managedPort> (port-scoped).
export function applyDisableToSettings(settings: Settings, managedPort: number): boolean {
const removedHooks = removeProxyHooks(settings);
const strippedEnv = _stripProxyEnvFromObject(settings, managedPort);
return removedHooks || strippedEnv;
}
```

The regression that this guards against: `removeProxyHooks(s) || _stripProxyEnvFromObject(s, port)` short-circuits when hooks are present — `_stripProxyEnvFromObject` never runs, leaving `ANTHROPIC_BASE_URL` pointing at a disabled relay in new sessions. Both calls must always evaluate regardless of the other's return value.
The regression that this guards against: `removeProxyHooks(s) || _stripProxyEnvFromObject(s, port)` short-circuits when hooks are present — `_stripProxyEnvFromObject` never runs, leaving `ANTHROPIC_BASE_URL` and `CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT` in the settings file pointing at a disabled relay in new sessions. Both calls must always evaluate regardless of the other's return value.

### Preflight checks (4 in order, hard-gated)

Expand Down Expand Up @@ -302,13 +305,13 @@ The TUI follows a pure-reducer / pure-renderer / thin-terminal-shell split (appl

- **`state.ts`** — pure keypress reducer. `reduce(state, key) → {state, intent}`. `buildRow()` calls `isDormantExternalModel()` (from external-models) to set dormancy state; `rowState()` delegates to `classifyAgentState()` (from agent-state.ts) so the TUI STATE column and `--list` share one classification vocabulary. `persistedModelFor(row)` and `persistedEffortFor(row)` are exported predicates consumed by both `rowState` (STATE column display) and `mergeTuiRowsIntoMapping` (save merge) — the two sites cannot drift on what value gets written. All types and dirty helpers exported. No I/O.
- **`render.ts`** — pure renderer. `renderFrame(state, dims) → string[]`. Exports `FIXED_ROWS` and `computeViewportHeight` — consumed by `terminal.ts` (single source of truth for viewport constants). `COL_STATE = 14` — sized so `'saved-inactive'` (13 chars) renders unclipped at 80-column terminals; row budget is 79 chars total (2 prefix + 18 agent + 32 model + 13 effort + 14 state).
- **`terminal.ts`** — impure shell. Manages alt-screen, raw mode, SIGINT/SIGTERM handlers, SIGWINCH resize. All cleanup wired via `resolve()` inside the Promise constructor — never `process.exit()` inside a finally-guarded scope (avoids PF-014).
- **`terminal.ts`** — thin adapter over the shared generic `runTui` driver (`src/cli/tui/`). Calls `runTui` with `signalAction: 'cancel'`, `continueIntent: 'none'`, and an `onResize` callback (updates `viewportHeight`); no `screen` override means the default `'alt'` is used. Alt-screen management, raw mode, SIGINT/SIGTERM, SIGWINCH, and event-loop cleanup are all handled by the generic driver (avoids PF-014).

**`TuiIO` injectable seam** (`terminal.ts`): `runAgentsTui(initialState, io?)` accepts an optional `TuiIO` override with fake `stdin`/`stdout` for testing. The default is `process.stdin`/`process.stdout`. Tests pass `PassThrough` streams to drive the TUI without a real TTY.

**`MAX_KEYPRESSES = 50_000`**: Exported constant — hard upper bound on the event loop. Resolves with `action: 'cancel'` on exhaustion. Tests pin this value directly (agents-terminal.test.ts).

**`stdin.pause()` in cleanup**: `runAgentsTui` calls `stdin.resume()` at startup and `stdin.pause()` in cleanup. Without `stdin.pause()`, the resumed stdin TTY handle keeps the Node event loop alive after the TUI resolves and the CLI hangs.
**`stdin.pause()` in cleanup**: The generic `runTui` driver calls `stdin.resume()` at startup and `stdin.pause()` in cleanup. Without `stdin.pause()`, the resumed stdin TTY handle keeps the Node event loop alive after the TUI resolves and the CLI hangs.

**`FIXED_ROWS`/`computeViewportHeight` single-sourced from `render.ts`**: `terminal.ts` imports both from render.ts — no duplication.

Expand Down Expand Up @@ -344,7 +347,7 @@ A user who hardened `settings.json` to `0600` (to protect `ANTHROPIC_API_KEY`) n

- **`proxy.json` ENOENT is not an error**: `readProxyState()` returns a default disabled state when the file is missing. Callers that treat ENOENT as an error will get a false negative on fresh installs.
- **Port adoption path**: if a relay is already accepting connections on the target port and the health check confirms our identity (`name === 'subswitch'`), preflight returns `adopted: true` and `spawnRelayAndWaitForPort` skips spawning. `spawnedPid` will be absent from `SpawnRelayResult` on this path — `runPostSpawnVerification` must never kill an adopted relay.
- **`stripProxyEnv` is port-scoped (REG-1)**: `stripProxyEnv(settingsJson, managedPort)` removes `ANTHROPIC_BASE_URL` **only when its value exactly matches `http://127.0.0.1:<managedPort>`**. A localhost URL on any other port classifies as `'ours-other-port'` or `'foreign'` and is never touched. Callers must pass the port Devflow owns (from `proxy.json.port` or `DEFAULT_PROXY_PORT`). `readProxyEnvState` uses the pattern `^http://127\.0\.0\.1:\d+$` to classify any localhost URL as `'ours-other-port'` for display purposes only — the strip never uses that broad pattern.
- **`stripProxyEnv` is port-scoped for the URL, unconditional for the window var (REG-1)**: `stripProxyEnv(settingsJson, managedPort)` removes `ANTHROPIC_BASE_URL` **only when its value exactly matches `http://127.0.0.1:<managedPort>`** (protecting foreign gateways on any other port), but removes `CLAUDE_CODE_DISABLE_UNKNOWN_MODEL_WINDOW_ENFORCEMENT` unconditionally — Devflow is its sole producer, so there is no foreign value to protect. A localhost URL on any other port classifies as `'ours-other-port'` or `'foreign'` and is never touched. Callers must pass the port Devflow owns (from `proxy.json.port` or `DEFAULT_PROXY_PORT`). `readProxyEnvState` uses the pattern `^http://127\.0\.0\.1:\d+$` to classify any localhost URL as `'ours-other-port'` for display purposes only — the strip never uses that broad pattern.
- **Remembered port on re-enable**: `--port` has no commander default. When `--port` is omitted, `portOption` is `undefined` and `resolvePort(undefined, priorPort)` returns the remembered port from `proxy.json`.
- **Dormant TUI rows**: when proxy is off and an agent has a saved GPT model, `buildRow()` calls `isDormantExternalModel()` and sets `configuredModel='default'` with the GPT name in `dormantModel`. `persistedModelFor(row)` returns `dormantModel` for an untouched dormant row, so `mergeTuiRowsIntoMapping` preserves the GPT mapping entry byte-identical on save even though `configuredModel` shows `'default'`.
- **`binPath` must be spawned with `node <path>`**: npm does not guarantee executable bits on installed package binaries. Always spawn as `node <binPath>`, never `<binPath>` directly.
Expand All @@ -371,7 +374,9 @@ A user who hardened `settings.json` to `0600` (to protect `ANTHROPIC_API_KEY`) n
- `src/cli/commands/agents.ts` — `agentsCommand`, `validateSetArgs()` (calls `isValidModelName`, zero-spawn), `applySetMapping()`, `buildListRows()`, `mergeTuiRowsIntoMapping()` (consumes `persistedModelFor`/`persistedEffortFor`)
- `src/cli/agents-view/state.ts` — pure reducer, `buildRow()`, `isDirtyModel()`, `isDirtyEffort()`, `persistedModelFor()`, `persistedEffortFor()`, `rowState()` (delegates to `classifyAgentState`), `unsavedCount()`
- `src/cli/agents-view/render.ts` — pure frame renderer; `COL_STATE = 14`; exports `FIXED_ROWS`, `computeViewportHeight`
- `src/cli/agents-view/terminal.ts` — impure TUI shell, `runAgentsTui()`, `TuiIO`, `MAX_KEYPRESSES`
- `src/cli/agents-view/terminal.ts` — thin adapter over the shared `runTui` driver (`src/cli/tui/terminal.ts`); exports `runAgentsTui()`, re-exports `TuiIO` and `MAX_KEYPRESSES` from tui/
- `src/cli/tui/terminal.ts` — generic `runTui<S,A,C>` driver (`RunTuiSpec`: `signalAction: Exclude<A,C>`, `continueIntent: C`, `screen?: 'alt'|'inline'`), `normalizeKey`, `TuiIO`, `MAX_KEYPRESSES`, `RenderDims`, `INLINE_MARGIN`; agents-view uses `signalAction='cancel'` + default `'alt'` screen; flags-view uses `signalAction='abort'` + `'inline'` screen
- `src/cli/tui/cells.ts` — cell helper utilities (shared across TUI modules)
- `src/assets/scripts/hooks/ensure-proxy` — SessionStart + UserPromptSubmit hook; writes `proxy.pid` after spawn; UserPromptSubmit exits before proxy-state reads; relay spawned via `env -i` 6-var allowlist
- `src/cli/commands/init.ts` — proxy preflight block (4-check, no doctor, no spawn); `reapplyAgentMapping` guard after preflight; convergence writes `proxy.json enabled:false` on preflight failure

Expand Down
Loading