Skip to content

feat(conditions): provider-agnostic condition-module registry + generic outlet (PR-1 framework) - #325

Merged
Juliusolsson05 merged 2 commits into
integration/conditions-frameworkfrom
feat/conditions-core-framework
Jun 21, 2026
Merged

Juliusolsson05 merged 2 commits into
integration/conditions-frameworkfrom
feat/conditions-core-framework

Conversation

@Juliusolsson05

Copy link
Copy Markdown
Owner

PR-1 — Conditions framework (renderer)

Introduces a provider-agnostic condition-module registry and routes the existing modals through it as drop-in view modules, replacing the two per-provider hand-switch outlets. Behavior is byte-identical — same modals, same keystrokes, same triggers.

This is the foundation. Later PRs migrate the headless emitters and the denormalization seam.

The registry framework (src/shared/conditions-core/)

  • contract.ts — the app-side source of truth for the generic wire action primitives (ConditionAction/ConditionPtyAction/ConditionCustomAction, now canonically here; providerConditions.ts re-exports them so every existing import keeps working byte-for-byte). Also the forward-looking headless ConditionModule contract — detect is a free-form function (the detection toolkit is an optional helper, never a straitjacket). Not consumed at runtime in this PR.
  • view.ts — ConditionView / ConditionViewProps / AttentionLevel. The attention field is defined for a later PR, not consumed yet.
  • ConditionOutlet.tsx — the one generic outlet: iterates the snapshot, routes each record by kind through a registry, preserves snapshot key order (modal stacking unchanged), and skips unknown kinds (graceful old-app / new-headless forward compat).
  • dispatch.ts — makeDispatch / makeDispatchFromOnSend. The pty arm calls the exact existing send path (onSend(data) → sendInput). The custom arm is intentionally dormant (throws): no live condition emits a custom action, and a resolver with zero callers would only rot. The first PR with a real custom condition adds the IPC + resolver and replaces the throw.

Routing existing modals byte-identically

Thin view adapters wrap the existing modal components — no modal was rewritten. Each adapter preserves the modal's exact prop shape and state→props mapping (incl. Codex approval's commandParts ?? command.split(/\s+/) fallback and compaction's visible && phase guard), and translates onSend(data) into dispatch({ kind:'pty', … }).

  • Codex (approvalView, codexTrustView) — live-proven through the new path (Codex already emits a conditions snapshot).
  • Claude (claudeTrustView, permissionView, resumeView, compactionView) — registered and ready, but dormant: Claude's headless emitter doesn't emit a snapshot yet, so these light up in a later PR. Wiring them now proves the registry is provider-symmetric and makes that PR "turn on the wire", not "also build the renderer".

ProviderConditionOutlet is rewired to pick the provider registry and feed ConditionOutlet; ClaudeConditionOutlet/CodexConditionOutlet are deleted (their logic now lives in the view modules).

Deferred on purpose (blast radius)

applyConditionSnapshot + runtime.pending* denormalization, selectors.ts (attention/unread labels), composer keybind gating, dispatch badges, agent-status — all untouched. No headless/submodule changes, no new IPC, no legacy-channel deletions.

Design doc

docs/design/conditions-system.md — 3-layer architecture, module + view contracts, wire format, the emit→sessionManager→forwarder→session:conditions→applyConditionSnapshot data-flow seam, registration model, the vendor-sync plan for the headless core (packages are submodules), and the full PR roadmap.

Verification

  • tsc -p tsconfig.web.json and tsconfig.node.json: zero errors in the changed files (web project 0 total; node project dropped from baseline 45 → 43 after a targeted JSX exclude for the one renderer-only .tsx).
  • Throwaway runtime check (run + deleted): pty arm forwards raw keystroke data verbatim to the send path; custom arm throws; outlet routes known kinds and skips unknown (e.g. codex.switch-model-prompt, which the old outlet also never rendered); snapshot key order preserved.
  • Byte-identical reasoning: when a condition is not live the headless emitter omits its key, so the old outlet rendered the modal with state=null (→ renders nothing) and the new outlet skips the kind (→ renders nothing) — identical output; when live, both pass the same mapped props.
  • Grep confirms no remaining imports of the deleted outlets.

🤖 Generated with Claude Code

Juliusolsson05 and others added 2 commits June 21, 2026 22:34
…ic outlet (PR-1 framework)

Introduce a provider-agnostic condition-module registry and route the
EXISTING modals through it as drop-in view modules, replacing the two
per-provider hand-switch outlets (ClaudeConditionOutlet/CodexConditionOutlet).
Behavior is byte-identical: same modals, same keystrokes, same triggers.

WHY a registry instead of a switch
----------------------------------
The old outlets enumerated each condition kind by hand — read state by
string key, map to modal props inline, mount the modal — coupling "which
conditions exist" to the outlet's source. The new core knows nothing about
specific kinds: it iterates the live snapshot, looks each kind up in a
provider view registry, and renders that view. Adding a condition becomes
"register one view", not "edit the switch + selectors + keybinds".

What lands here (app-repo only; NO submodule changes)
-----------------------------------------------------
- src/shared/conditions-core/: contract.ts (wire action primitives now have
  their canonical home here; providerConditions.ts re-exports them so every
  existing import keeps working byte-for-byte) + the forward-looking headless
  ConditionModule contract (detect is a FREE-FORM function — the detection
  toolkit is an optional helper, never a straitjacket); view.ts
  (ConditionView/ViewProps/AttentionLevel); ConditionOutlet.tsx (the ONE
  generic outlet — routes by kind, preserves snapshot order, skips unknown
  kinds for old-app/new-headless forward compat); dispatch.ts (pty arm wired
  to the exact existing send path; custom arm intentionally DORMANT — throws,
  because no live condition emits a custom action and a resolver with zero
  callers would only rot).
- Provider view modules wrapping the existing modals as thin adapters
  (Codex approval/trust live-proven; Claude trust/permission/resume/compaction
  registered but DORMANT until the Claude headless emitter lands in a later PR).
  Each adapter preserves the modal's exact prop shape + state->props mapping
  (incl. Codex approval's commandParts ?? command.split fallback and
  compaction's visible && phase guard).

Deferred ON PURPOSE (blast radius): applyConditionSnapshot + runtime.pending*
denormalization, selectors.ts, composer keybind gating, dispatch badges,
agent-status — ALL untouched. The `attention` field on views is defined for a
later PR and not consumed yet.

Design doc: docs/design/conditions-system.md (3-layer architecture, module
contract, wire format, data-flow seam, registration model, vendor-sync plan
for the headless core, PR roadmap).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ctx + tsconfig/doc — review fixes (PR-1)

Four type/build/doc-hardening fixes to the conditions-core framework. Runtime
behavior is byte-identical: the provider registries are the same object literals
with the same kind→view entries; eraseRegistry is an identity pass-through.

FIX 1 — sound registry typing (kill the `as unknown as ConditionView` cast).
The provider registries were built via `Object.fromEntries(LIST.map(v => [v.kind,
v as unknown as ConditionView]))`. That erasure happened BEFORE the kind→view
pairing existed as a checkable literal, so a kind could point at the wrong
component and still compile. Now each provider declares a `StateByKind` map (the
source of truth binding each kind to its concrete state type), and the registry
is an EXPLICIT object literal passed through `eraseRegistry<M>(reg:
Partial<ViewRegistry<M>>)`. The helper's parameter type checks every key↔view
binding against `StateByKind` BEFORE erasing — a wrong mapping is now a compile
error at the call site (proven: swapping codex.approval/codex.trust-dialog views
fails tsc with TS2322). `ViewRegistry<M> = { [K in keyof M & string]:
ConditionView<K, M[K]> }` and `eraseRegistry` live in view.ts.

Why a helper and not a plain `satisfies` + `Record` annotation: the outlet feeds
each Component `state: unknown`, and `state` sits in the Component's contravariant
parameter position, so a precise `ConditionView<K, S>` is genuinely NOT a subtype
of the erased `ConditionView<string, unknown>`. Assigning the precise literal to a
`Record<string, ConditionView>` annotation does not type-check — which is exactly
why the old code needed the unchecked cast. eraseRegistry localizes that one
unavoidable erasure to a SINGLE documented cast whose INPUT is fully checked,
replacing N unchecked per-view casts. No `any`. Thick WHY in view.ts.

FIX 2 — tsconfig.web include. `tsc -b tsconfig.web.json` (the real build, vs a
cache-masking `--noEmit`) emitted TS6307: ConditionOutlet.tsx was reached only
TRANSITIVELY and was not in any project's explicit file list. Added
`src/shared/conditions-core/**/*` to tsconfig.web.json include so the web project
authoritatively type-checks the renderer-only .tsx regardless of who imports it.
The node project keeps excluding ConditionOutlet.tsx (stays JSX-free). This
clears the pre-existing TS6307.

FIX 3 — typed resolve ctx generic. `ConditionModule` gains a 4th generic
`Ctx = unknown`, and `resolve` is narrowed to `(action: ConditionCustomAction,
ctx: Ctx)`. The future multi-step answering driver (sendThenReparse, roadmap
steps 4-5) needs to hand resolve a live terminal handle; keeping it a generic
(defaulting to unknown) means that handle can be specified per-module later
without a contract change or a re-sync of the vendored headless copy. pty actions
never reach resolve, so the action arg is narrowed to the custom arm. Type-only;
nothing constructs a module yet.

FIX 4 — doc render-order claim. Softened the design doc: the old per-provider
outlets used a fixed JSX order; the new outlet renders in
`Object.values(snapshot.conditions)` insertion order. These are NOT guaranteed
identical, but are behaviorally equivalent under the current "at most one live
overlay at a time" invariant. Noted that if multiple simultaneous overlays ever
ship, the outlet should sort by an explicit per-view `priority` (forward
reference). Also synced the Layer-2 code example and the type-safety guardrail
paragraph to the eraseRegistry form so the doc doesn't drift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Juliusolsson05
Juliusolsson05 changed the base branch from main to integration/conditions-framework June 21, 2026 21:10
@Juliusolsson05
Juliusolsson05 merged commit 98a307a into integration/conditions-framework Jun 21, 2026
@Juliusolsson05
Juliusolsson05 deleted the feat/conditions-core-framework branch June 21, 2026 21:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant