feat: external model routing (devflow proxy) + per-agent model config (devflow agents) - #269
feat: external model routing (devflow proxy) + per-agent model config (devflow agents)#269dean0x wants to merge 54 commits into
Conversation
Implements plan sections A (shared core) and B (mapping engine, TDD) for the external model routing (Devflow proxy) + per-agent model config feature. A — Shared core: - package.json: pin subswitch@0.1.0 as exact-version dependency; add Guard 3 packaging test asserting no ^/~ range prefix - src/core/external-models.ts: EXTERNAL_GPT_MODELS registry (gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna, gpt-5.5) + externalModelIds() accessor - src/core/manifest.ts: add features.proxy: boolean with self-heal absent→false, following the hud idiom; extend manifest tests for proxy field; fix init.ts manifest construction to carry proxy through re-init - src/core/proxy-state.ts: Result-typed readProxyState/writeProxyState (tolerant parse), buildRoutingConfigJson, buildProxyState, proxyBaseUrl, isProxyEnabled, resolveProxyBin (createRequire-based bin resolution, npx warning, user-facing error "routing runtime missing — reinstall devflow-kit") B — Mapping engine (TDD — tests written before implementation): - src/core/agent-frontmatter.ts: rewriteAgentFrontmatter + readFrontmatterModel, pure/zero-I/O, regex scoped to first ---…--- block, CRLF-safe EOL detection, effort add/replace/remove with double-blank-line collapse, changed: bool idempotency; 88 tests covering all 17 real agent files + synthetic cases - src/core/agent-models.ts: AgentMappingFile schema, readAgentMapping (tolerant parse, invalid effort drop+warn, unknown agents preserved), saveAgentMapping (atomic write), resolveEffective (dormancy semantics: GPT model dormant when proxy disabled, effort always applies), reapplyAgentMapping (convergence, reads shipped defaults live from src/assets/agents/), revertExternalAgents, countExternalMappedAgents; 31 tests covering schema, matrix, idempotency applies ADR-013 (new modules in src/core, agent-neutral) avoids PF-014 (Result types, no process.exit() in business logic) Co-Authored-By: Claude <noreply@anthropic.com>
…oxy hook - src/cli/commands/proxy.ts: devflow proxy --enable/--disable/--status Pure env trio (applyProxyEnv, stripProxyEnv, readProxyEnvState), pure hook helpers (addProxyHooks, removeProxyHooks, hasProxyHooks), dependency-injected runProxyPreflight (5 ordered checks), full enable/disable/status action handlers. Single atomic settings.json pass in enable/disable (removeProxyHooks → stripProxyEnv → addProxyHooks → applyProxyEnv). Snyk MEDIUM http→https fix applied (realHttpGet selects module from URL scheme). - src/assets/scripts/hooks/ensure-proxy: SessionStart + UserPromptSubmit hook; NOT git-gated (user-scope feature); /dev/tcp TCP probe (bash 3.2-safe, no nc); event detection via '"prompt"' key; SessionStart: spawn relay on port-down with learning_lock_acquire spawn-lock, 80×0.1s bounded wait, emit additionalContext warning if relay never comes up; UserPromptSubmit: fast silent exit on port-up, silent on port-down; 2MB proxy.log tail-guard. No "subswitch" in any user-visible string (PF-001 + branding constraint). - src/cli.ts: register proxyCommand - tests/proxy.test.ts: 59 tests (pure functions + runProxyPreflight all 5 checks with injected deps) - tests/shell-hooks.test.ts: ensure-proxy added to HOOK_SCRIPTS syntax check + 14 behavioral tests (disabled, absent, re-entrancy guard, missing prerequisites, UserPromptSubmit silent path, port-up fast-exit via ephemeral TCP server) applies ADR-013 (cli vs core boundary) avoids PF-014 (no process.exit while holding lock/fd; use return throughout) avoids PF-001 (port digit-validated before /dev/tcp and string interpolation)
Implements plan steps 9–11:
Step 9 — src/cli/agents-view/state.ts (pure keypress reducer):
- AgentRow + AgentsViewState types; immutable throughout
- reduce(state, key) → { state, intent } with exhaustive switch
- buildRow() handles dormancy (GPT saved + proxy off → default+dormantModel)
- Model cycle: default→haiku→sonnet→opus→fable→[GPT bracket when proxy on]→default
- Effort cycle: default→low→medium→high→xhigh→max→default
- Dirty detection: current !== original (touch-then-revert → not dirty)
- Viewport scroll with adjustViewport(); unsavedCount() derived
Step 9 — src/cli/agents-view/render.ts (pure frame renderer):
- renderFrame(state, dims) → string[] using src/hud/colors.ts helpers
- Layout: title+proxy header / column headers / scroll-up indicator /
viewport rows / scroll-down indicator / unsaved count / keybinding footer
- ❯ cursor marker, ‹ › active-field brackets, ● dirty markers
- Dormant rows show "gpt-x.x saved" dim annotation
- Proxy-off footer gains "devflow proxy --enable" hint
- Narrows gracefully (truncate not wrap); never mid-row newlines
Step 9 — src/cli/agents-view/terminal.ts (impure shell):
- Enters alt-screen, hides cursor, setRawMode
- MAX_KEYPRESSES = 50_000 hard bound (reliability rule)
- Cleanup idempotent: runs on save, cancel, SIGINT, SIGTERM (avoids PF-014)
- Resize re-render via stdout resize event
- Returns Promise<{ action: 'save'|'cancel', state }>
Step 10 — src/cli/agents-view/index.ts (barrel)
Step 11 — src/cli/commands/agents.ts:
- validateSetArgs() — model/effort allowlist validation (exported, testable)
- applySetMapping() — immutable mapping delta (exported, testable)
- buildListRows() — async list builder with installed-file probing (exported, testable)
- devflow agents (bare): TUI if TTY; --list output + exit 1 if not
- devflow agents --list: AGENT/DEFAULT/CONFIGURED/EFFORT/STATE table
- devflow agents --set <agent> --model <m> --effort <e>: validates + saves
+ reapplyAgentMapping; warns "saved — inactive" when GPT + proxy off
- devflow agents --reset [--yes]: clears mapping, restores shipped defaults
Step 11 — src/cli.ts: registered agentsCommand
Tests: 103 new (45 state + 29 render + 29 command); full suite 2237 passed.
applies ADR-013 (cli vs core boundary); avoids PF-014 (no process.exit in finally)
Step 12 — init-seed.ts:
- Add `proxy: boolean` to FeatureSeed (Advanced-only, off by default)
- Add `proxy: false` to FEATURE_DEFAULTS
- resolveSeedFeatures: read proxy from manifest group (ADR-001, like ambient/hud/rules)
- applyCliToggles: propagate proxy toggle
Step 12 — init.ts:
- Add --proxy/--no-proxy CLI options
- Advanced path: proxy confirm block seeded from manifest, guarded by p.note
- Recommended path: CLI toggle propagation only (no interactive prompt)
- Post-install: reapplyAgentMapping after file copy
- Preflight: runProxyPreflight before settings mutation; warning + force-disable on failure (PF-009)
- Settings pass: removeProxyHooks/addProxyHooks + stripProxyEnv/applyProxyEnv
- Manifest write: proxy: proxyEnabled
- Outro: external model routing enabled/disabled log line
- Fix ESM require() issue: replace require('net'/'http'/'https'/'child_process')
with static imports (net, http, https, spawn)
Step 13 — uninstall.ts:
- Add removeProxyHooks + stripProxyEnv to settings cleanup chain
- Add revertExternalAgents before removeAllDevFlow (non-fatal, guards agents dir)
- Add proxy artifacts to removeDevFlowInstallArtifacts (proxy.json, proxy-routing.json,
proxy.pid, .proxy-spawn.lock, logs/proxy.log) — non-fatal per PF-009
- Check proxy.pid process alive and emit informational note (never kill)
- Add agent-models.json to enumerateUserDevFlowContent
Step F — Docs:
- README.md: add devflow proxy + devflow agents to CLI Reference snippet
- docs/cli-reference.md: add --proxy/--no-proxy to Init Options; add External Model
Routing section (devflow proxy); add Per-Agent Model Config section (devflow agents)
- docs/reference/agent-design.md: add Per-Agent Model Overrides section
- CLAUDE.md: External Model Routing + Per-Agent Model Config blurbs; update Project
Structure (cli/agents-view, core files, ensure-proxy hook); extend Two-Mode Init;
add proxy.json/proxy-routing.json/agent-models.json to runtime data listing; update
Model Strategy paragraph
Tests:
- init-seed.test.ts: add proxy: false to makeManifest fixture; update resolveSeedFeatures
and applyCliToggles assertions; add proxy seeding suite (11 proxy-specific tests)
Applies ADR-001, ADR-013, ADR-014; avoids PF-009 (per-item failure isolation), PF-014
(process.exit inside finally guard)
- proxy.ts: merge duplicate fs/path imports; drop named dirname/join in favour of path.* namespace already in scope; remove redundant = undefined on _cachedVersion; drop settingsPath2 closure alias (settingsPath is already in scope) - render.ts: drop no-op ternary in renderEffortCell (both branches returned row.configuredEffort unchanged) - init.ts: remove inline type-alias inside httpGet dep; convert spawnDoctor from .then().finally() chain to async/await, matching the realSpawnDoctor pattern in proxy.ts No behaviour change. 2247 tests green.
…n cleanup - init.ts: move reapplyAgentMapping to run AFTER the proxy preflight block. Preflight can force proxyEnabled=false on failure; running reapply earlier materialized GPT models into agent frontmatter even when preflight later disabled the proxy (dormancy-invariant violation → agents left pointing at gpt-5.x with no relay on re-init with saved GPT mappings). Now converges against the final proxyEnabled value. - terminal.ts: pause stdin in cleanup() to release the ref'd TTY handle, mirroring the startup stdin.resume(). The CLI has no forced process.exit, so a resumed stdin kept the event loop alive and hung 'devflow agents' after save/cancel. - proxy.ts: drop unused installDir in runStatus (dead code).
…l example; fix(proxy): add kill hint to --status running output - agents --set: correct syntax to `--set <agent> --model <m> [--effort <e>]`; remove the bogus `=` form and add --effort and default-clears examples - agents --reset: correct to boolean flag (clears ALL); document --yes to skip confirm; remove the nonexistent per-agent form - TUI keybindings: add Tab (switch field), Space (cycle active field), d (reset field to default), j/k (down/up); clarify ←/→/Space cycle the active field (model or effort), not just the model - dormant annotation example: replace non-existent gpt-4.5 with gpt-5.5 (real registry model) in both cli-reference.md and agent-design.md - proxy --status: add "stop manually with: kill <pid>" hint to the running-ours branch (plan D3 requirement); mirrors --disable phrasing Co-Authored-By: Claude <noreply@anthropic.com>
…DisableToSettings The || operator in runDisable short-circuited when removeProxyHooks returned true, leaving ANTHROPIC_BASE_URL in settings.json and keeping new sessions pointed at a disabled relay. Fix: extract applyDisableToSettings() which always evaluates both removeProxyHooks() and _stripProxyEnvFromObject() unconditionally, then use it in runDisable. Regression test (TDD): settings with BOTH hooks and ANTHROPIC_BASE_URL set → after applyDisableToSettings, both hooks and env var are gone. Co-Authored-By: Claude <noreply@anthropic.com>
On first run when proxy.log does not exist, the wc -c fallback used a shell redirect (<"$LOG_FILE") that bash evaluated before wc started. The redirect failure was emitted to stderr by bash itself, bypassing the 2>/dev/null that only covered wc's stderr. Result: a benign "No such file or directory" line on stderr for every first-run invocation. Fix: wrap the entire size-detection chain in [ -f "$LOG_FILE" ] so the redirect is only attempted when the file exists. Shell test added (spawnSync captures stderr for exit-0 processes): first-run with proxy enabled but no proxy.log produces empty stderr. Co-Authored-By: Claude <noreply@anthropic.com>
Two stale examples in the agents Management code block: - --set reviewer=gpt-4.5 used wrong key=value syntax and a nonexistent model → corrected to --set reviewer --model gpt-5.5 - --reset reviewer documented a per-agent reset that does not exist → replaced with the real --reset (clears all + prompts) and --reset --yes Grep audit confirms no other --set.*= or --reset [a-z] or gpt-4.5 instances remain in docs/ or README.md. Co-Authored-By: Claude <noreply@anthropic.com>
Refresh covers proxy footprint in install/uninstall pipeline and init seeding layer added by feat/external-model-routing.
Findings SummaryThis PR adds comprehensive external model routing support. The code quality is generally strong with well-defended security boundaries and pure/testable architecture. The review found several issues worth addressing before merge: HIGH-Confidence Blocking Issues (≥80%)1. Remembered-port logic is dead (proxy.ts:492, TypeScript Review, 85% confidence) The .option('--port <n>', 'Port for the local relay (default: 4141)', String(DEFAULT_PROXY_PORT))Because Commander fills Expected behavior: Fix: Drop the default so omission is detectable: .option('--port <n>', 'Port for the local relay (default: remembered or 4141)')
// portOption stays undefined → priorPort is used2. Preflight production implementations duplicated (Architecture Review, 90%; Complexity Review, 88%) The three production preflight dependencies (
The comment at init.ts:1257 acknowledges this: "see proxy.ts for identical patterns". This creates:
Fix: Export a factory from proxy.ts: export function buildRealPreflightDeps(
settingsPath: string,
onWarn?: (msg: string) => void,
): ProxyPreflightDeps {
return {
resolveProxyBin,
fileExists: async (p) => { try { await fs.access(p); return true; } catch { return false; } },
tcpConnectable: realTcpConnectable,
httpGet: realHttpGet,
readSettingsJson: () => fs.readFile(settingsPath, 'utf-8').catch(() => '{}'),
spawnDoctor: realSpawnDoctor,
onWarn,
};
}Then both 3. Inconsistent CLI error exit codes (Consistency Review, 85%) The // proxy.ts - exits 0 even on preflight failure
if (isNaN(parsed) || parsed < 1 || parsed > 65535) {
p.log.error(`Invalid port: ${portOption}`);
return; // ← no exit code
}This breaks failure detection in CI or scripts. Impact: Fix: Set 4. ensure-proxy hook sources json-parse without diagnostic (Consistency Review, 82%) Line 28 of ensure-proxy: source "$SCRIPT_DIR/json-parse" || { exit 0; }All 11 sibling hooks (including source "$SCRIPT_DIR/json-parse" || { echo "<hook>: failed to source json-parse" >&2; exit 1; }The ensure-proxy form is silent, making a broken hook invisible during debugging. The Fix: Emit the diagnostic: source "$SCRIPT_DIR/json-parse" || { echo "ensure-proxy: failed to source json-parse" >&2; exit 0; }5. ensure-proxy uses curl despite claiming portability (Consistency Review, 80%) Line 110–111 (TCP probe) avoids curl: # TCP probe (bash built-in, no nc/curl, Bash 3.2-safe)
exec 3<>/dev/tcp/127.0.0.1/$_p 2>/dev/nullBut line 131 unconditionally uses curl: HEALTH_BODY=$(curl -s --max-time 2 "http://127.0.0.1:${PROXY_PORT}/__subswitch/health")This creates:
Fix: Either drop the health-check identity check on the hook side (the CLI if command -v curl >/dev/null 2>&1; then
HEALTH_BODY=$(curl -s --max-time 2 "http://127.0.0.1:${PROXY_PORT}/__subswitch/health")
# ... health check
else
# Assume ours if curl unavailable
:
fiAdditional FindingsMedium-Confidence Items (60–79% confidence) are summarized in the full review reports and available for reference. High-level:
All review reports are available in Next Steps: Address the 5 blocking items above, then re-run the review to verify fixes. The codebase quality is strong; these are refinements to robustness and consistency. CC devflow-kit maintainer | Review cycle 1 |
dean0x
left a comment
There was a problem hiding this comment.
Line 492: Remembered-port logic is dead (TypeScript Review, 85%)
The default value defeats the remembered-port feature. When --port is omitted, Commander fills options.port = '4141', making the check at line 680 (if (portOption !== undefined)) always true. The prior port from proxy.json (computed at line 673) is discarded.
Expected: Re-enable on the remembered port
Actual: Re-enables on 4141, spawning a second relay
Fix: Drop the default:
.option('--port <n>', 'Port for the local relay (default: remembered or 4141)')
….devflow tree DOC-1: CLAUDE.md — split ensure-proxy hook responsibility from CLI-side env injection. The hook auto-starts the relay; ANTHROPIC_BASE_URL is injected/stripped in settings.json at `devflow proxy --enable/--disable` (and init) time via applyProxyEnv/stripProxyEnv, not by the hook. DOC-2: external-model-routing KNOWLEDGE.md — "Enable path" step 4 cited the hook's probe loop numbers (80×100ms, 8s, 15s hook timeout). The CLI enable loop in runEnable is 50×100ms = 5s max with no hook timeout. Correct to "≤50×100ms probe loop (5s maximum)". Hook Contract section numbers are unchanged. DOC-6: CLAUDE.md ~/.devflow/ file tree — add proxy.pid (relay PID written at enable time, transient) and .proxy-spawn.lock/ (hook spawn lock dir, transient), both confirmed in proxy.ts:750 and ensure-proxy:187 respectively.
Before the `fs.rename(tmp, filePath)`, stat the target to read its current permission mode and apply it to the .tmp file. This prevents settings.json (and any other target hardened to 0600) from being silently widened back to the umask default (~0644) on every proxy enable/disable, post-install, or init rewrite. Non-fatal path: if stat fails (ENOENT for a fresh file, or any I/O error) the chmod is skipped and the write completes normally with umask default permissions. A chmod failure also never corrupts the write (avoids PF-009 isolated-failure principle). Adds tests/fs-atomic.test.ts covering: content correctness, stale .tmp recovery, fresh-target default behavior, and the 0600/0644 mode-preservation regression (SEC-1). Mode tests are skipped on win32. Co-Authored-By: Claude <noreply@anthropic.com>
…apse
Two bugs in the effort:null removal path (lines 207-224):
(a) When effort: is the last frontmatter key, removing it left a stray
blank line before the closing --- delimiter. The body ends with \n
(the EOL preceding effort:) and reassembly prepends another \n,
producing \n\n--- that no downstream collapse could catch.
(b) The global /\n{2,}/g collapse (and its CRLF twin) ran over the
ENTIRE frontmatter body — silently corrupting any multi-line YAML
value that legitimately contains blank lines.
Fix: remove exactly one adjacent EOL alongside the matched effort line.
• Not the last line → swallow the trailing \r?\n after the line.
• Last line → swallow the preceding \r?\n before the line.
• First+only line → clear the body entirely.
Drop the global collapse entirely (D-EFR-1).
Regression tests (RED→GREEN verified):
- effort as last key (LF): no \n\n--- in output
- effort as last key (CRLF): no \r\n\r\n--- in output, no bare LF
- intentional blank line inside YAML value survives byte-identically
All 17 real shipped agent file cases remain green (91 total tests).
Co-Authored-By: Claude <noreply@anthropic.com>
…te, buildRoutingConfigJson
TEST-2: proxy-state.ts had zero direct test coverage. New test file
(tests/proxy-state.test.ts) covers all critical contracts over a real
temp directory:
- readProxyState: ENOENT → ok with default disabled state (not an Err)
- readProxyState: malformed JSON → tolerant Result, no throw
- writeProxyState → readProxyState round-trip: port/binPath/configPath/
models/devflowVersion preserved byte-faithfully
- Field tolerance: wrong-typed fields (non-boolean enabled, string port,
negative port, non-array models, mixed-type model arrays) self-heal
to documented defaults
DEP-4: buildRoutingConfigJson shape assertion — parses emitted JSON and
deep-asserts {port, codex:{models:[...]}} with no extra keys; verifies
port is a number (not string); verifies models array is a copy (mutation
after call does not affect the already-serialised JSON string).
DEP-3 (packaging.test.ts): add lockfile assertion inside Guard 3 —
package-lock.json's node_modules/subswitch entry must resolve to
version 0.1.0 and carry a sha512- integrity field. This closes the gap
where package.json pin was checked but lockfile tamper was not detected.
Co-Authored-By: Claude <noreply@anthropic.com>
…, Result discipline, terminal tests CPLX-5: Export FIXED_ROWS and computeViewportHeight from render.ts as the single source of truth; import in terminal.ts (removes local copies) and agents.ts (replaces bare `- 9` in buildTuiState). CPLX-6: Extract replaceRow() and cycleField() pure helpers in state.ts; collapse the 5× duplicated `rows.map((r,i) => i===cursor ? newRow : r)` and the structurally identical left/right/space branches to a direction argument. Reducer behaviour is byte-identical — all existing state tests pass unmodified. TS-3: Change applyTuiSave to return Promise<Result<…>> instead of throw; handle at the call site with early-return Result discipline (no try/catch in business logic). TEST-8a: Replace the dead-duplicate 'not dirty after touch-then-revert' test with one that actually drives reduce (right then left) to exercise the revert path. TEST-8b: Rename 'removes agent entry entirely' to accurately describe the contract (applySetMapping leaves an empty entry; removal is applyTuiSave's job) and add `'coder' in result.agents === true` assertion. TEST-1: Add tests/agents-terminal.test.ts with a minimal injectable stdin/stdout seam (TuiIO interface, optional `io` param on runAgentsTui). Two tests pin the load-bearing guards: (a) settle always calls stdin.pause() so the event loop releases; (b) feeding MAX_KEYPRESSES+1 synthetic bytes resolves with cancel. Co-Authored-By: Claude <noreply@anthropic.com>
…attach error listeners, extract spawn helper
ARCH-1: Export buildRealPreflightDeps() factory from proxy.ts.
- Consolidates realTcpConnectable / realHttpGet / realSpawnDoctor into a
single reusable factory with a swallowSettingsReadError parameter that
preserves the deliberate caller difference (runEnable propagates read
errors; init.ts swallows to '{}' because it creates settings.json itself).
- init.ts inline deps block (40 lines, byte-identical copy) replaced with
buildRealPreflightDeps({settingsPath, onWarn, swallowSettingsReadError: true}).
- Removes net / http / https / spawn imports that were only needed for the
inline block.
REL-1: Attach proc.on('error', ...) at all spawn sites.
- realSpawnDoctor: error event resolves(1) + clears timer so the finally
block closes logFd — prevents uncaught exception + fd leak on EMFILE/ENOMEM.
- buildRealSpawnAndWaitDeps spawnProcess: error event captured via onError
callback; propagated into the wait loop via spawnError flag.
REL-2: Guard all unguarded writes in runEnable and runDisable.
- routing-config write (fs.writeFile) and settings write
(writeFileAtomicExclusive via applyEnableSettingsPass) now return errors
instead of crashing with an unhandled rejection on ENOSPC/EACCES.
- runDisable writeFileAtomicExclusive and writeProxyState both guarded; set
process.exitCode = 1 on hard failures (avoids PF-014).
- On enable failure (spawn or settings), proxy.json is rolled back to
enabled:false to avoid partial-enabled state.
CPLX-2: Extract spawnRelayAndWaitForPort and applyEnableSettingsPass.
- spawnRelayAndWaitForPort owns spawn + adopted check + 50×100ms bounded
wait + process-alive check + EADDRINUSE race detection. Accepts adopted
boolean so the entire if(!adopted) branch is internalized — cleaner call
site in runEnable.
- applyEnableSettingsPass owns the atomic 4-call settings mutation
(removeProxyHooks + stripEnv + addProxyHooks + applyEnv) plus the guarded
writeFileAtomicExclusive call.
- runEnable collapses from 182 lines to ~90 focused lines.
TEST-3: New tests/proxy-enable.test.ts — 12 tests for spawn paths.
- adopted=true: spawnProcess not called, returns ok:true.
- relay never accepts (50-iteration timeout): returns ok:false.
- process dies early (isProcessAlive=false): returns ok:false.
- OS-level error event (REL-1): synchronous onError call → ok:false without
uncaught exception; also verifies early loop termination.
- Port accepts on second probe: returns ok:true.
- Process dies + port up (EADDRINUSE race): returns ok:true.
- PID write: called with correct args when pid present; skipped when absent.
applies ADR-013 (core/adapter boundary — factory stays in CLI layer)
avoids PF-009 (failure isolation — each write independently guarded)
avoids PF-014 (no process.exit in finally-guarded scopes)
avoids PF-015 (toggle fanout — all 4 settings mutations evaluated unconditionally)
Co-Authored-By: Claude <noreply@anthropic.com>
…, tests
PERF-1: UserPromptSubmit fast exit before log setup and TCP probe — binPath/configPath
reads, mkdir/stat size guard, and CWD extraction are deferred to SessionStart-only
branches; the prompt hot path now exits immediately after the enabled+port check,
eliminating ~5 JSON/stat subprocesses per prompt when proxy is enabled.
CONS-3: source json-parse failure emits named stderr diagnostic
("ensure-proxy: failed to source json-parse") matching sibling hook pattern; exit 0
preserved (fail-open intent).
CONS-4: curl health check guarded by "command -v curl >/dev/null 2>&1"; when curl is
absent, the port-up path exits 0 silently ("assume ours") instead of falling through
to the empty-HEALTH_BODY "*)" branch and emitting a spurious port-conflict warning.
CPLX-8: log size magic numbers (_LOG_MAX_BYTES=2097152, _LOG_TAIL_BYTES=1048576) named
as variables with a comment explaining why sharing hook-log-init is not feasible (that
helper requires $CWD and targets the per-project log path; ensure-proxy uses the
user-scope $DEVFLOW_DIR/logs path).
TEST-6: adds shell-hooks.test.ts case feeding proxy.json="not-json{{{"— asserts exit 0,
empty stdout, empty stderr (spawnSync, mirrors first-run stderr test pattern).
CONS-4 regression test: shadow-bin approach (symlinks dirname+node into a controlled
dir, omits curl, sets PATH=shadowBin:/bin) — failing before the guard, passing after.
Co-Authored-By: Claude <noreply@anthropic.com>
…y allocated ones All port-DOWN cases in the ensure-proxy block previously hard-coded ports 49180–49189 in the OS ephemeral range. If any of those ports happened to be transiently bound on the host, the affected test silently exercised the port-UP path instead, masking behavioral drift. Fix: add an allocateFreePort() helper that binds a net.Server on port 0, reads the OS-assigned port, closes the server, and returns the port number. Tests that need a port-DOWN scenario call await allocateFreePort() so the port is valid (OS-issued), freshly released, and overwhelmingly likely to remain free for the duration of the hook call. All 11 affected test cases converted to async and updated accordingly. No assertions changed. writeProxyJson.port made required (non-optional) to prevent future callers from relying on a hard-coded default.
…tests from shipped defaults
ARCH-2: Export isDormantGptModel(model, proxyEnabled) from external-models.ts (leaf module,
no project imports — prevents import cycles with agents-view/state.ts). Replace the four
duplicated dormancy predicates: resolveEffective (agent-models.ts), buildRow (state.ts),
buildListRows, and the --set warning (agents.ts). Behavior identical; one authoritative site.
PERF-2: Parallelize two sequential for-await loops with Promise.all. loadShippedDefaults
now fans out all per-agent readFile calls concurrently (was serialized over ~34 files).
reapplyAgentMapping fans out per-agent read/rewrite/write calls concurrently; results
aggregated in allNamesList insertion order for deterministic warning collection and bucket
assignment. Exact failure semantics preserved (ENOENT→skipped, malformed→skipped,
write-error→warn+no-bucket). onWarning callback fires immediately for live feedback; the
returned warnings[] is collected in stable agent-name order after Promise.all completes.
TEST-7: In the two reapplyAgentMapping tests that asserted coder's shipped default ('sonnet'),
replace the literal with a dynamic read via loadShippedDefaults() at describe-block init time.
The same function reapplyAgentMapping calls at runtime — so the assertion now matches what the
implementation writes, regardless of future model-strategy changes to coder.md.
Co-Authored-By: Claude <noreply@anthropic.com>
…stants, readPidFile
TS-1: Drop commander default on --port so omission is detectable as undefined.
Extract resolvePort(portOption, priorPort) — when portOption is undefined, the
remembered port from proxy.json is used. Previously String(DEFAULT_PROXY_PORT) was
the commander default so portOption was never undefined, making the fallback dead
code: a user who enabled on port 5000, disabled, then re-enabled without --port
silently reverted to 4141 (second relay spawned, old one leaked). RED-GREEN: 8
regression tests in describe('resolvePort') confirmed fail before, pass after.
CONS-1: Sweep all remaining hard-failure paths in runEnable/runDisable to set
process.exitCode = 1 before return — invalid port, preflight failure, malformed
settings (avoids PF-014; bare return kept, never process.exit).
CPLX-3: Extract resolveProcessState(featureEnabled, port) and
formatProcessLine(processState, pidAlive, pidFromFile, port) from runStatus,
collapsing the mirrored 3-way PID cross-check branches. Behavior/output byte-identical.
CPLX-4: Name all magic timeouts and loop bounds near DEFAULT_PROXY_PORT:
PROBE_TIMEOUT_MS=2000, DOCTOR_TIMEOUT_MS=10_000, RELAY_SPAWN_MAX_PROBES=50,
RELAY_SPAWN_PROBE_INTERVAL_MS=100, RELAY_SPAWN_PER_PROBE_TIMEOUT_MS=500.
Comment documents the intentional CLI-5s vs hook-8s budget difference.
CPLX-7: Extract readPidFile(pidPath): Promise<number|null> and reuse in
runStatus and runDisable, replacing the duplicated read/parseInt/isNaN idiom.
Co-Authored-By: Claude <noreply@anthropic.com>
…KILL escalation, timeout test
CPLX-9: Extract isOurRelayBody(body: string): boolean — shared helper for the
JSON.parse+name==='subswitch' check; removes the duplicate try/catch in
runProxyPreflight and the duplicate parse in resolveProcessState. Shell hook
retains its own copy (different language, cannot share the TS module).
CONS-5: Expand the RELAY_SPAWN_MAX_PROBES comment to explain the intentional
CLI-5s vs ensure-proxy-hook-8s budget difference (interactive wait vs 15s
platform-timeout revival window). Comment was terse; now self-documenting.
SEC-3: Before printing the 'kill <pid>' hint in runDisable, cross-check relay
identity via realTcpConnectable + realHttpGet + isOurRelayBody. When identity
is confirmed the hint is the same as before. When port is down or the health
response is foreign the hint is softened ('verify before stopping manually').
Never kills programmatically.
REL-3: Add SIGKILL escalation in realSpawnDoctor timeout path. After sending
SIGTERM, schedule proc.kill('SIGKILL') after a 2s grace period. The escalation
timer is unref()'d so it never prevents the CLI from exiting on its own.
TEST-11: Add preflight test for the health-check timeout path — httpGet
Err('timeout') must land in the same port-conflict Err as connection-refused.
Also add isOurRelayBody unit tests (true/false/invalid-JSON/empty/case-sensitive).
Co-Authored-By: Claude <noreply@anthropic.com>
…REG-1)
The old OUR_BASE_URL_PATTERN matched ANY localhost URL, so a user routing
Claude Code through their own LiteLLM or similar gateway on 127.0.0.1:4000
had ANTHROPIC_BASE_URL silently deleted on every `devflow init` and uninstall.
Fix: _stripProxyEnvFromObject now accepts a `managedPort` parameter and strips
only when the URL exactly matches proxyBaseUrl(managedPort). All callers are
updated:
- stripProxyEnv(json, managedPort) — new required parameter
- applyDisableToSettings(s, managedPort) — new required parameter
- applyEnableSettingsPass — passes `port` (new port being applied)
- runDisable() — reads proxy.json BEFORE settings pass
to resolve managedPort; reorders steps
- init.ts settings pass — reads proxy.json after preflight block
to resolve managedPort for strip call
- uninstall.ts settings cleanup — reads proxy.json per-scope to resolve
managedPort for strip call
Regression tests (RED-GREEN) pinned in proxy.test.ts:
REG-1: foreign-localhost (4000) survives strip with managed port 4141
REG-1: our-port (5000 custom) is stripped when passed as managedPort
REG-1: ours-other-port (5000) NOT stripped when managed port is 4141
REG-1: same checks for applyDisableToSettings
applies ADR-014 (managed port read from proxy.json, self-heals to DEFAULT_PROXY_PORT)
avoids PF-009 (readProxyState ENOENT → safe default, never throws)
avoids PF-014 (no process.exit in business logic)
Co-Authored-By: Claude <noreply@anthropic.com>
…y tests
PERF-3: early-return guard at the init call site skips reapplyAgentMapping
when agent-models.json has no entries AND proxy is off — avoids walking ~34
installed agent files with zero writes. Guard lives at init.ts call site
only (shared disable/revert paths bypass it).
TEST-5 (tests/init-proxy.test.ts): three integration tests driving
runProxyPreflight + reapplyAgentMapping via the injectable seams to pin the
dormancy ordering invariant. Correct-order tests assert that a failing
preflight forces proxyEnabled=false BEFORE reapply so GPT models are
suppressed; a third test documents the violation that occurs if the order
is reversed.
TEST-4 (tests/uninstall-logic.test.ts): temp-dir tests for
removeDevFlowInstallArtifacts covering per-artifact removal (proxy.json,
proxy-routing.json, proxy.pid, .proxy-spawn.lock/, logs/proxy.log),
per-artifact non-fatal isolation when absent (PF-009), live-PID warning
without process kill, dead-PID clean removal, and a full-pass composite.
Bug found by TEST-4: { recursive: artifact.isDir } passes undefined for
non-directory entries; fs.rm treats that as a type error and throws.
The catch block swallowed it silently, leaving every non-directory proxy
artifact in place. Fix: artifact.isDir === true (explicit boolean).
avoids PF-009 (per-item failure isolation in removeDevFlowInstallArtifacts)
pins ordering invariant for PF-015 (feature-toggle fan-out — GPT dormancy)
Co-Authored-By: Claude <noreply@anthropic.com>
Strip resolution-pass issue-reference prefixes (ARCH-N, CPLX-N, REL-N, SEC-N, TS-N, PERF-N, Phase N) from section headers and inline comments across proxy.ts, init.ts, fs-atomic.ts — leave only the descriptive rationale. Git holds the traceability; the code should read as end-state. proxy.ts: 18 comment/header cleanups; section headers no longer encode review ticket IDs. All ADR/PF permanent doc references preserved intact. uninstall.ts: consolidate two-block proxy settings strip into a single parse-mutate-serialize pass using applyDisableToSettings (same function runDisable uses), replacing the removeProxyHooks + stripProxyEnv pair. Removes one redundant JSON round-trip; updates import accordingly. agents.ts: merge duplicate agents-view import blocks — computeViewportHeight was imported directly from render.ts while sibling exports used index.js; now all agents-view imports flow through the barrel. terminal.ts: remove comment explaining that FIXED_ROWS comes from render.ts (visible from the import; comment described the refactoring, not the state).
… before fast-path exits
CONS-4 test (shell-hooks.test.ts): restrict PATH to shadowBin only to prevent
"command -v curl" from succeeding via /bin/curl on Ubuntu merged-usr (/bin →
/usr/bin). Symlink all binaries the hook needs for the SessionStart+port-UP path:
- bash: spawnSync resolves 'bash' via the child's PATH; absent = ENOENT
- dirname: for SCRIPT_DIR resolution
- node: for json-parse / json_field_file
- cat: for INPUT=$(cat); command substitution inherits stderr
- mkdir: for log directory creation; without it log() >> $LOG_FILE fails with
ENOENT on missing parent — macOS bash 3.2 terminates via signal instead
of a clean non-zero exit that || true could handle
- date: for log() timestamp; $(date ...) inherits stderr
EPIPE fix (session-start-context): move INPUT=$(cat) to before the DEVFLOW_BG_UPDATER
re-entrancy guard so stdin is always drained. Without this, the hook exits at line 30
before reading its pipe, and on Linux the parent's write to the pipe gets EPIPE.
… path in tests The SessionStart spawn branch never recorded the relay pid, so a hook-started relay showed up in 'devflow proxy --status' as port-up with no process line (and no kill hint). Write proxy.pid best-effort after spawn, mirroring the CLI enable path. Replace the stale test comment claiming a docker-integration suite covers the spawn path (no such suite exists) with real coverage: a stub relay that reads SUBSWITCH_CONFIG and binds the port, asserting silent exit, live-pid record, and spawn-lock release.
The relay's doctor subcommand probes the relay port to verify it is running. A pre-spawn gate is always unsatisfiable on a cold path: the port is down by definition before spawn, so doctor exits 1 every time, blocking enable even when all prerequisites (bin, codex auth, free port, clean settings) are healthy. Fix: move doctor from runProxyPreflight (check ⑤) to a new runPostSpawnVerification step that runs after spawnRelayAndWaitForPort confirms the port is up. Doctor now sees "running: YES" on the healthy path, fulfilling its actual role (validate codex auth + TLS reachability against a live relay). Kill-on-rollback when doctor fails is restricted to self-spawned relays: adopted relays may be serving other live sessions and must not be killed. spawnedPid is now returned from SpawnRelayResult so the caller knows whether this enable originated the relay. init.ts is unchanged in behaviour — it only runs checks ①–④ via runProxyPreflight and lets ensure-proxy start the relay at next session. Tests: preflight suite asserts spawnDoctor is never called; new runPostSpawnVerification suite covers the four ordering cases (doctor called, zero→Ok, non-zero+self-spawned→rollback+kill, non-zero+ adopted→rollback without kill); proxy-enable suite covers spawnedPid in the result.
…flow - CLAUDE.md: update External Model Routing paragraph — preflight is now 4 checks (doctor removed), relay spawned post-preflight, doctor runs as a post-spawn verification against the live relay; init never spawns/doctors - CLAUDE.md: proxy.pid comment now reflects dual write-path (CLI enable + ensure-proxy hook spawn) - docs/cli-reference.md: --enable row now mentions relay start and verification step
… to 0600/0700 SEC-2 remediation — proxy subprocess env scoping + proxy.log permission hardening: **Child env (TS):** - New `buildChildEnv(configPath)` helper in `src/core/proxy-log.ts` (targeted unset, not an allowlist — subswitch reads Codex creds from ~/.codex/auth.json, not env). - Applied at `spawnRelayAndWaitForPort` (relay env) and `runPostSpawnVerification` (doctor env) in `src/cli/commands/proxy.ts`. **Child env (shell):** - `ensure-proxy` relay spawn prefixed with `env -u ANTHROPIC_API_KEY` — env(1) and nohup(1) both exec through, so $! still captures the relay PID correctly. Verified working on macOS /usr/bin/env (Bash 3.2 compatible). **Log file hardening:** - New `openProxyLog(logPath)` in `src/core/proxy-log.ts`: mkdir parent 0700 + open 0600 + best-effort chmod for pre-existing wider files (non-fatal, avoids PF-009). Follows SEC-1 precedent in `src/core/fs-atomic.ts` (commit 5755d56). - `realSpawnDoctor` and `buildRealSpawnAndWaitDeps.openLog` in `proxy.ts` now call `openProxyLog` instead of bare `fs.open(..., 'a')`. - logs mkdir in `runEnable` (`proxy.ts`) and in `init.ts` proxy block now use `{ recursive: true, mode: 0o700 }`. - `ensure-proxy`: `chmod 700 "$LOG_DIR"` after mkdir; `(umask 077 && touch "$LOG_FILE")` + `chmod 600` before relay spawn (matching queue-append precedent). **Rotation mode fix:** - New `rotateProxyLogIfLarge(logPath)` in `src/core/proxy-log.ts`: writes tail to tmp at 0o600, then renames — post-mv inode carries 0600, no race window. Called pre-spawn in `runEnable` (Step 2, before `spawnRelayAndWaitForPort`) so no live relay fd can be orphaned by the rename. Constants (2MB/1MB) match ensure-proxy. - `ensure-proxy` rotation tmp created under `umask 077` subshell for the same reason. **Tests (14 new):** - `tests/proxy-log.test.ts`: openProxyLog fresh+pre-existing, chmod-failure non-fatal, buildChildEnv strips key, rotateProxyLogIfLarge size+mode+tail-content assertions. Co-Authored-By: Claude <noreply@anthropic.com>
… validated reads
- git mv src/hud/cache.ts src/core/cache.ts; update sole consumer
(version-badge.ts) to pass ctx.devflowDir-derived cacheDir and a
typed validator function. Removes getCacheDir() which duplicated and
diverged from getDevFlowDirectory().
- writeCache becomes async (writeFileAtomicExclusive is async; no sync
variant exists). version-badge.ts now awaits the write.
- Validate on read: readCache/readCacheStale accept a validator function
called on every read; no more blind JSON.parse-as-T cast. Validates
envelope structure (finite timestamp/ttl), future-timestamp rejection,
MAX_TTL_MS clamping, and data schema via the injected validator.
- Path containment: safeEntryPath() rejects any key component that
resolves outside cacheDir — an unvalidated key is an
arbitrary-file-overwrite primitive via path.join normalization.
- Permissions: mkdir at 0700, entries at 0600 (mirrors proxy-log.ts).
- PID-scope the atomic tmp name in fs-atomic.ts (${filePath}.tmp.PID)
to prevent concurrent-process tmp collisions (applies PF-011). Mirror
the same naming in json-helper.cjs and decisions-usage-scan.cjs (per
the comment at fs-atomic.ts:10-13). Update fs-atomic and
json-helper-write-exclusive tests to use PID-scoped paths.
Co-Authored-By: Claude <noreply@anthropic.com>
Safety commit: separates the offer set (runtime discovery) from the
recognition set (dormancy predicate), preventing a discovery failure
from inverting the safety property and writing GPT model IDs into agent
frontmatter while the proxy is off. applies PF-015.
- Add CLAUDE_MODEL_ALIASES to external-models.ts (moved from
agent-models.ts — leaf module, no project imports avoids cycles).
Includes 'fable': devflow's set is a superset of the routing runtime's
passthrough regex so fable is never misclassified as external (T9).
- Add isClaudeModelName() — pure complement predicate. Returns true for
aliases, 'inherit', and any 'claude-' prefixed name.
- Add isDormantExternalModel() — single dormancy export (AC-C6).
Classification is by the complement: external iff not Claude and not
'default'. Supersedes isDormantGptModel() (alias-shaped non-Claude
names are now dormant-when-off; documented in commit body).
- Convert all four recognition-set consumers to isDormantExternalModel:
src/core/agent-models.ts:resolveEffective
src/core/agent-models.ts:countExternalMappedAgents (was inlining
externalModelIds() set — live correctness bug: proxy --status
showed "0 external agents" for alias-shaped mappings)
src/cli/agents-view/state.ts:buildRow
src/cli/commands/agents.ts:buildListRows + --set warning
- Move CLAUDE_MODEL_ALIASES import at five call sites (state.ts,
agents.ts, agents-state.test.ts, agent-models.test.ts,
agents-command.test.ts) from agent-models to external-models.
- Remove dead isProxyEnabled import from agent-models.ts (ADR-003).
- T9 and countExternalMappedAgents complement-predicate tests in
tests/external-models.test.ts.
Documented behaviour change: a hand-edited non-Claude model name (not
just a known GPT ID) is now dormant-when-off. Not self-healing — the
value is preserved on-disk until --set, TUI save, proxy toggle, or init.
Co-Authored-By: Claude <noreply@anthropic.com>
S1 (CRITICAL — pre-existing): rewriteAgentFrontmatter interpolated
opts.model raw into the frontmatter body with no charset check. A
newline-embedded payload (e.g. "gpt-4\ntools:\n - bash") would inject
arbitrary YAML keys. Fix: export MODEL_NAME_RE and isValidModelName;
return Err('invalid-model') before any string replacement when the name
fails the regex. The same regex is exported for reuse at the discovery
boundary in a later phase.
S2 (HIGH — pre-existing): stripAnsi matched only SGR sequences
(\x1b\[[0-9;]*m). CSI with non-SGR final bytes, OSC sequences, two-byte
C1 escapes, and raw C0 control characters all passed through unstripped.
Fix: broaden ANSI_PATTERN to cover CSI/OSC/C1 families; add CTRL_PATTERN
for C0 controls (0x00–0x08, 0x0b–0x1f, 0x7f); apply stripAnsi to all
user-derived column fields in the --list path before padEnd/slice.
T10 (tests/agent-frontmatter-injection.test.ts): injection matrix covering
MODEL_NAME_RE charset boundaries (valid names accepted, 30+ invalid payloads
rejected), rewriteAgentFrontmatter Err('invalid-model') gate, and stripAnsi
across SGR/CSI/OSC/C1/C0 families.
Replace buildChildEnv (denylist: spread all 61 vars, remove
ANTHROPIC_API_KEY) with scrubChildEnv (allowlist: PATH, HOME, TMPDIR,
LANG, LC_ALL; win32 adds SystemRoot/APPDATA/USERPROFILE/ComSpec).
Verified by whole-dist grep of the routing runtime 0.2.0 package: it
reads exactly three env vars (ANTHROPIC_API_KEY, FORCE_COLOR,
SUBSWITCH_CONFIG). An allowlist is the correct shape — 61 vars → 5.
Call sites in proxy.ts compose on top of scrubChildEnv() rather than
the function taking parameters:
relay spawn: { ...scrubChildEnv(), SUBSWITCH_CONFIG: configPath }
doctor spawn: { ...scrubChildEnv(), SUBSWITCH_CONFIG: configPath }
Also strips the tombstone comment in external-models.ts that narrated
the removed isDormantGptModel behaviour change (applies ADR-003).
Test T11 (AC-S3): exact-key-set assertions across scrubChildEnv() and
both spawn-path compositions; poisoned env includes ANTHROPIC_API_KEY,
OPENAI_API_KEY, SSH_AUTH_SOCK, and two additional credential vars.
Co-Authored-By: Claude <noreply@anthropic.com>
…ing config
- pin subswitch to 0.2.0 (exact); run npm install to update lockfile
- rewrite buildRoutingConfigJson(port) → bare {port} only (AC-C4)
- delete ProxyState.models entirely: field, parse branch, buildProxyState
parameter, and all call sites in proxy.ts + init.ts (AC-C5)
- extend resolveProxyBin() to return version? validated against
RUNTIME_VERSION_RE /^[A-Za-z0-9.+-]{1,32}$/ (AC-S4)
- remove unused externalModelIds import bindings from proxy.ts + init.ts
(EXTERNAL_GPT_MODELS and externalModelIds() are preserved in external-models.ts)
- update tests: packaging.test.ts (SUBSWITCH_VERSION constant), proxy-state.test.ts
(AC-C4/AC-C5/AC-S4), proxy.test.ts (0.2.0 health fixture with providers array)
- applies ADR-001 (clean break, no migration for unreleased surface)
…mmand tree The dest-safety and empty-output-dir tests in tests/build-mds.test.ts wrote temporary .mds files into src/assets/commands/ and removed them in an async finally block. tests/packaging.test.ts reads that same directory concurrently under vitest's parallel worker pool, so it could observe the transient file and fail Guard 4 non-deterministically. Fix at the source of the hazard (avoids PF-011): add a DEVFLOW_MDS_ROOT env-var override to scripts/build-mds.ts so tests can point the script at an isolated temp directory that mirrors the needed src/assets/commands/ sub-tree. The real command tree is never touched; no concurrent reader can observe a transient state. 5-run verification tally: 5/5 pass (was: intermittently failing).
Implements src/core/model-discovery.ts with full test coverage in
tests/model-discovery.test.ts (83 test files, 2543 tests green).
Core API:
- parseModelsJson(raw): Result<ParsedCatalog> — pure, never throws;
hard-gates schemaVersion===1 and kind==='models'; drops non-codex,
non-routable, retired, and passthrough-provider rows; strips aliases
matching CLAUDE_MODEL_ALIASES or any canonical id in the payload (two-pass)
- discoverExternalModels(cacheDir, logPath, deps?): async, never throws;
resolveProxyBin() live each call (AC-C7); cache key external-models-v1-<ver>;
SIGTERM + unreffed SIGKILL escalation; 256KB stdout cap; stale-cache fallback
- getExternalModelsCached(cacheDir): sync, cache-only; picks newest entry
by embedded envelope timestamp (not mtime); for --set path that must not spawn
Constraints:
- applies ADR-013: no src/targets/ or src/hud/ imports in src/core/
- applies PF-013: cwd=os.tmpdir() so devflow dir need not exist on cold path
- avoids PF-009: all failure paths return {known:false}, never throw
- avoids PF-016: real-binary stub tests (T4, AC-P8) in tests/ NOT tests/integration/
- SEC-2: scrubChildEnv() strips ANTHROPIC_API_KEY; openProxyLog for 0600 log writes
- AC-F9: no user-visible string contains the routing runtime package name
- pruneOldEntries keeps at most 3 external-models-v1-* cache entries after each
successful live write (ordered by embedded timestamp, not file mtime)
Tests (42 tests in tests/model-discovery.test.ts):
- parseModelsJson: happy path, hard gates, row-level tolerance, branding constraint
- getExternalModelsCached: miss, empty dir, round-trip, newest-entry selection
- discoverExternalModels: injectable deps covering all degradation paths
- T6: asserts argv===["models","--json"], cwd===os.tmpdir(), ANTHROPIC_API_KEY absent
- T12: pruneOldEntries keeps ≤3 entries after live write
- AC-P8: SIGTERM-ignoring stub killed by SIGKILL in <7.5s; process.kill(pid,0) confirms dead
…-set Wire AgentsViewState with catalog and modelCycle fields (AC-P6: prebuilt once, Object.is stable across keypresses). Add offCyclePin to AgentRow for retired-model cycle reachability (AC-F4). Show alias resolution in render: "sol (gpt-5.6-sol)" for aliases, bare for canonical IDs (AC-F2). Show "(unavailable)" for off-cycle pins. TUI path: start discoverExternalModels without awaiting, with 250ms spinner threshold. --set path: cache-only via getExternalModelsCached (0 spawns, AC-P9). --list, --reset: never discover. Tests: remove externalModelIds() references from agents-command, agents- state, init-proxy tests; replace with literal list (ADR-003 prep for registry deletion). Add T8, AC-F1 through AC-F5, AC-P6 tests in agents-state. Update agents-render helpers to include catalog/modelCycle. External-model-registry-discovery task, commit 8 of 10.
Delete EXTERNAL_GPT_MODELS, externalModelIds(), and the ExternalModel interface from src/core/external-models.ts. The TUI picker and --set validation now use the live ExternalModelCatalog from model-discovery.ts (discoverExternalModels / getExternalModelsCached). Dormancy and the Claude alias set remain untouched. Update tests/external-models.test.ts to use a literal ID list rather than the deleted exports. Update stale JSDoc in agent-models.ts to remove externalModelIds() references. Grep of src/ and tests/ confirms zero remaining imports. applies ADR-003: end-state only — no compatibility re-exports. External-model-registry-discovery task, commit 9 of 10.
Add T2 (real-binary + hostile SUBSWITCH_CONFIG) to model-discovery.test.ts:
a real shell script exits 0 if SUBSWITCH_CONFIG is absent from its env,
2 if it leaked through. Proves scrubChildEnv strips the var before the
child process runs — verifying that no caller re-injects it (applies PF-016:
real binary, not a vitest mock). 5/5 runs: 205 tests pass, 0 leaked relays.
Fix stub relay process leak in shell-hooks.test.ts:
- Upgrade afterEach SIGKILL to SIGTERM → 200ms grace → SIGKILL escalation
with process.kill(pid, 0) verification after teardown.
- In test 2 ("writes proxy.pid with the live relay pid"): read and register
spawnedPid BEFORE assertions so afterEach always cleans up even when an
assertion throws (prior race: leak if existsSync assertion failed).
External-model-registry-discovery task, commit 10 of 10.
…y in --status AC-F6: devflow proxy --status now shows an "External models:" line using getExternalModelsCached (zero spawns, cache-only) — instant with no multi-second silent pause. When the cache is unavailable the line names the concrete log path. Cache warming: after a successful enable, discoverExternalModels is fire-and-forget (void + catch non-fatal) so the next --status and agents TUI load instantly without any user-visible delay. Strictly non-fatal per PF-009 — a discovery failure must never affect the enable result. T7/AC-F8 (PF-015 whole-end-state assertion): new describe block asserts the FULL settings post-state from a fully-enabled starting state across three discovery scenarios (cache-hit, cache-miss, no-binary), all producing identical results — applyDisableToSettings is a pure Settings function that never calls discovery.
… key order
The old case *'"name":"subswitch"'* pattern required the "name" key to
appear FIRST in the JSON health body. When the relay returns fields in a
different order (e.g. {"version":"0.2.0","providers":[...],"name":"subswitch"})
the pattern fails to match, causing the hook to emit a false "port occupied
by another application" warning even when the relay is legitimately ours.
Fix: replace the substring case-match with a json_field "name" "" call
that parses the body key-order-independently via jq or node. json_field
is already sourced (json-parse, line 32) and is always available at the
health-check call site because line 33 exits if _JSON_AVAILABLE=false.
CONS-5 regression test: uses a child-process HTTP stub (separate event
loop from the test runner's execSync) returning the health body with
"name" as the LAST field. Verifies that the hook exits 0 with no
"port occupied" output.
Add cache/models to the proxy artifact removal list so that discoverExternalModels cache entries (external-models-v1-*.json) are cleaned up on uninstall alongside proxy.json, proxy-routing.json, proxy.pid, .proxy-spawn.lock, and logs/proxy.log. The cache directory is removed with isDir:true (recursive) so all versioned cache entries under cache/models/ are covered. Per-item failure isolation preserved: PF-009 — a missing cache/models never blocks removal of the other proxy artifacts. Tests: adds two new cases to TEST-4: - cache/models present → removed by removeDevFlowInstallArtifacts - cache/models absent → other artifacts still removed (PF-009) Updates the full-pass test to assert cache/models alongside all five existing proxy artifacts.
Strip tombstone comments per ADR-003 (end-state only): - external-models.ts: remove "was deleted here" block for EXTERNAL_GPT_MODELS; simplify "moved here from" to describe current state - agent-models.ts: remove "(plan D5):" suffix from Dormancy semantics/rule JSDoc sections — plan references are transition residue Docs updated for subswitch@0.2.0 family aliases: - cli-reference.md: gpt-5.5 example → sol; add alias auto-tracking sentence - agent-design.md: gpt-5.5 examples → sol; add alias auto-tracking sentence KNOWLEDGE.md refresh (AC-C5/AC-C7): - Version: 0.1.0 → 0.2.0 - isDormantGptModel → isDormantExternalModel throughout; code example updated to complement-based implementation (no EXTERNAL_GPT_MODELS) - New Model Discovery section: discoverExternalModels vs getExternalModelsCached, cache dir convention, ExternalModelCatalog union, uninstall coverage - Enable path step 10: cache warming fire-and-forget documented - ensure-proxy: health body parsed via json_field (key-order-independent) - Anti-patterns: isDormantExternalModel reference updated
countExternalMappedAgents was expanding isDormantExternalModel inline (!isClaudeModelName) instead of calling the single dormancy export directly (AC-C6). Replace with isDormantExternalModel(entry.model, false) which is byte-for-byte equivalent but routes through the canonical predicate. Replace the vacuous isDormantExternalModel guard test (which only checked numeric agreement) with a source-grep test that fails if any file outside external-models.ts re-introduces the inlined predicate (!isClaudeModelName). Also fix two stale comments: - render.ts layout comment said MODEL : 24; the constant COL_MODEL is 32. - packaging.test.ts test name said 0.1.0; the pinned version is 0.2.0. Co-Authored-By: Claude <noreply@anthropic.com>
T5: proves that both alias-shaped ('sol') and canonical-id ('gpt-5.6-sol')
external mapping entries remain dormant when proxy is OFF, leaving installed
files at their shipped defaults. The alias-shaped case was previously
untested on the reapplyAgentMapping path. No mocking of isDormantExternalModel
or isClaudeModelName — mocking the predicate would test our assumption not
the production guard (avoids PF-016).
AC-S1: proves that a hostile mapping entry with a newline-injected model name
('gpt\ntools:\n - bash') is rejected by rewriteAgentFrontmatter (invalid-model
error), the installed file is byte-identical to before, and a warning names
'invalid-model'. Code is correct; this test now proves it.
Co-Authored-By: Claude <noreply@anthropic.com>
…roduction spawn
T3: replace vacuous typeof-boolean check with a real cwd-isolation test —
shell stub exits 1 if subswitch.config.json exists in its cwd; injectable
spawnAndCollect forwards opts.cwd (os.tmpdir()); process.chdir to legacy
config dir proves production never uses process.cwd() for spawn.
T4: replace single conditional exit-1 case (could assert nothing) with 6
unconditional cases backed by a beforeEach stale-cache seed (key
external-models-v1-stale): exit-1 → stale-cache; garbage binary stdout
(null bytes) → stale-cache; schemaVersion:2 → stale-cache; {} payload
→ stale-cache; SIGTERM-ignoring .js stub (production spawn, no injection)
→ stale-cache after SIGKILL; 300KB .js stub (STDOUT_CAP overflow path,
zero prior coverage) → stale-cache. Removes dead deps const that was
shadowed by deps2. All assertions unconditional. Applies PF-016.
AC-P8: switch from shell stub + custom spawnAndCollect injection (which
re-implemented SIGTERM→SIGKILL internally, exercising a copy not the
production path) to a .js stub (process.execPath-compatible) with only
resolveProxyBin injected; production buildRealSpawnAndCollect now runs end-
to-end. PID-file orphan-death assertion retained.
…9, T7 revert
render: alias rendering test ("sol (gpt-5.6-sol)" from aliasToId map),
(unavailable) off-cycle pin test (model absent from modelCycle), column
bounds test (visible width ≤ declared cols), escape sequence injection
safety (ANSI codes in agent/model name do not expand column width).
proxy: extract formatExternalModelsLine(catalog, logPath) as exported pure
function (AC-F6); update --status handler to use it; add 3 formatter tests
(unknown catalog, known with names, empty model list, branding rule).
T7: replace label-only "three scenarios" loop with real tmpDir cache states
(cache-hit, cache-miss, no-cache-dir) — applyDisableToSettings post-state
is now proven identical across actual differing cache conditions (non-vacuous
PF-015 assertion). Add 3 revertExternalAgents tests covering each cache state:
proves GPT agent files revert to Claude defaults regardless of discovery state.
agents-command: AC-P4 — dual proof that buildListRows makes 0 cache reads:
(1) source-grep confirms function body has no getExternalModelsCached/
discoverExternalModels call; (2) functional test succeeds with no cache dir
present. AC-P9 — validateSetArgs and applySetMapping proven spawn-free by
synchronous return type check (a spawning function cannot return sync) and
source-grep confirming no child_process import at module level.
cache.ts: export parseRawEnvelope(raw: string) — shared envelope reader that
validates timestamp (finite, not future) and defaults ttl to 0 when absent
or invalid. Deletes readCacheStale: zero production call sites, its
ignoreExpiry use case is served by findStaleFallback (which reads files
directly) and does not need a public API.
model-discovery.ts: replace 3 separate inline envelope parsings with
parseRawEnvelope calls:
- findStaleFallback: eliminates manual JSON.parse + timestamp/data guard
- pruneOldEntries: eliminates manual JSON.parse + timestamp check
- getExternalModelsCached: eliminates manual JSON.parse + ts/ttl/data guards
All 3 now share one validated extraction path — no logic change.
tests/cache.test.ts: remove readCacheStale test block; add 8 parseRawEnvelope
tests covering: malformed JSON, non-object JSON, missing/non-finite
timestamp, future timestamp (poisoned entry), valid round-trip, ttl
defaulting to 0 when absent or NaN, arbitrary data shapes.
…ledge base - Remove deleted models[] field from ProxyState field list - Fix buildRoutingConfigJson signature and shape (port-only, no codex block) - Fix enable path step 2 (writes port only, no model list) - Fix TUI vs --set discovery split: TUI calls discoverExternalModels (async, gated on proxyEnabled); --set calls getExternalModelsCached (sync, zero spawns); --list/--reset/--status never touch discovery - Replace all isDormantGptModel() references with isDormantExternalModel() - Fix Key Files entry for external-models.ts: CLAUDE_MODEL_ALIASES, isClaudeModelName(), isDormantExternalModel() (was stale deleted symbols) - Add anti-pattern D-EFR-3: mock-only subprocess tests must be paired with a CI-executed real-binary test; documents tests/integration/ CI-exclusion trap (PF-016 reproduced) - Add gotcha: leaked stub relays must be reaped on the failure path too - Add cache details: version key format, 0700/0600 permissions, stale fallback by embedded timestamp, 3-entry prune, resolveProxyBin() version validation (RUNTIME_VERSION_RE); HUD-sharing note on uninstall - docs/cli-reference.md: use concrete alias (sol) in --set example - Update frontmatter updated: to 2026-08-14 Co-Authored-By: Claude <noreply@anthropic.com>
Summary
Adds integrated support for routing GPT model IDs through your OpenAI/Codex subscription:
devflow proxy: Interactive CLI (--enable/--disable/--status) that routes configured GPT model IDs (gpt-5.6-sol/terra/luna, gpt-5.5) through a local relay to ANTHROPIC_BASE_URL; all other models pass through to Anthropicdevflow agents: Interactive TUI + CLI (--list/--set/--reset) for per-agent model/effort overrides, stored deviations-only in~/.devflow/agent-models.jsonand converged into installed agent frontmatterKey Design Points
ensure-proxyhook (SessionStart synchronous + UserPromptSubmit probe) revives the relay with ≤8s bounded windowImplementation
New modules and integration:
Quality Gates
Fully implemented and tested through 4-phase sequential pipeline:
--disableenv-strip short-circuit bug with regression testsNote: Local Snyk scan unavailable (broken npx wrapper on this machine); relying on the PR's Snyk CI check for security verification.
Test Plan
ensure-proxyhook behavior and relay auto-revivalRelated Issues
Closes: external model routing feature request