Engine (statecharts) → substrate
Query (EQL/Pathom3) → capability surface
AI (providers) → streaming LLM layer
Agent (statechart) → per-turn lifecycle
App Runtime (interactive) → shared adapter-neutral UI/session domain
RPC (transport) → remote adapter over app-runtime
TUI (charm.clj) → terminal adapter over app-runtime
Emacs (rpc client) → editor adapter over app-runtime
| Component | Role |
|---|---|
engine |
Statechart infrastructure, system state |
query |
Pathom3 EQL registry, query-in |
ai |
Provider streaming, model registry (Anthropic, OpenAI) |
agent-core |
LLM agent lifecycle statechart + EQL resolvers |
agent-session |
Full coding-agent session: tools, extensions, OAuth, canonical state |
app-runtime |
Shared interactive application runtime for adapter-neutral session/UI semantics |
history |
Git log resolvers |
introspection |
Engine queries itself — self-describing graph |
rpc |
Transport, framing, subscriptions, request/response adaptation |
tui |
JLine3 + charm.clj terminal adapter |
emacs-ui |
Emacs RPC client adapter |
The architecture target is:
app-runtimecontains everything common between TUI and Emacs. RPC is a transport layer on top ofapp-runtime, not a second home for session or UI-domain logic.
Current duplication pressure exists where the same user-visible question is answered in more than one adapter path, for example:
- session selector/tree ordering and fork-point interleaving
- footer/status semantic composition
- session summary fragments used by headers/diagnostics
- picker definitions (
/tree,/resume,/model,/thinking) - session navigation result shaping (
new/resume/switch/fork) - background job and context snapshot presentation data
If both TUI and Emacs need the same answer, app-runtime should answer it once.
Adapters should differ only in:
- rendering
- local interaction mechanics
- transport/protocol concerns
For runtime-owned interactive projections, canonical state changes first and public payloads are derived later:
- session/runtime handlers mutate canonical state
- handlers emit semantic invalidations such as
:projection/context-changedand:projection/ui-changed app-runtimeremains the owner of canonical public projection models- RPC delivers those projections to subscribed clients by recomputing payloads from current canonical state plus connection-local focus
- runtime-owned context/session-tree and shared UI updates are event-driven rather than polling-driven
- adapter-neutral session navigation operations
- focus-scoped session operations parameterized by adapter-owned focus
- selector/picker models and item ordering
- footer semantic model
- shared session-summary/model-label/status fragments for adapter diagnostics/header use
- context snapshot / session tree model, including canonical session-tree widget projection when adapters need the same rendered structure
- canonical transcript message reconstruction from journal state
- transcript rehydration packages and other shared presentation-facing domain projections
- canonical UI action/result vocabulary
- shared public summaries for jobs, statuses, and extension UI state where both adapters need the same meaning
- transport framing
- subscriptions and event delivery
- request/response correlation
- transport-focused handshake / protocol negotiation
- adaptation of
app-runtimemodels onto the RPC protocol - explicit
session-idrouting whenever the operation can reasonably carry it - RPC-local focus pointer only as transport-scoped adapter fallback state
- subscriber-aware fanout of runtime-owned projection invalidations (
:projection/context-changed,:projection/ui-changed) with per-connection payload recomputation
RPC should not be the long-term home for selector semantics, footer semantics, or session navigation domain logic.
- terminal layout
- key handling
- local widget/view state
- adapter-specific rendering concerns
- buffer rendering
- minibuffer completion
- overlays/faces
- local widget/view state
- adapter-specific rendering concerns
- Query only attributes that exist in the graph; unknown attrs can cause the whole
psi-toolrequest to fail. - For the active system prompt, use:
[:psi.agent-session/system-prompt]
- For extension UI behaviour, prefer capability/action discovery through the
:psi.ui/...query surface when available:[:psi.ui/type :psi.ui/available? :psi.ui/capabilities :psi.ui/actions :psi.ui/make-visible-action]- branch on
:psi.ui.capability/...keywords and action descriptor availability, not concrete frontend types
- For runtime UI type diagnostics and compatibility with older callers, use:
[:psi.agent-session/ui-type];:console|:tui|:emacs- treat this as low-level introspection/compatibility data, not the normative extension-authoring contract for invokable UI behaviour
- For prompt sizing (chars + estimated tokens), use:
[{:psi.agent-session/request-shape [:psi.request-shape/system-prompt-chars :psi.request-shape/estimated-tokens :psi.request-shape/total-chars]}]
- For the slash-command surface offered to UIs, the backend is the single
authoritative source. Both the TUI and Emacs build their slash autocomplete by
querying the graph — they hold no hardcoded built-in command lists:
- extension commands:
[:psi.extension/command-names] - built-in commands:
[:psi.agent-session/builtin-command-specs](a vector of{:name :description}, bare names, in table order) - built-in command identity lives in exactly one place — the
builtin-command-specstable inpsi.agent-session.commands.builtin-specs. The routing maps (exact-command-handlers,prefixed-command-prefixes),format-help's built-in lines, and thebuiltin-commands-resolverare all pure projections of that table, so a routed-but-undescribed (or vice-versa) command is structurally unrepresentable, and adding a built-in flows to both UIs with no UI-side list edit. - the Emacs
psi-emacs-slash-command-specsdefcustomsurvives as a user override/supplement (default trimmed to the Emacs-only/skill:affordance, which is not a backend routing target); its entries are merged after the backend specs, so backend built-in descriptions win on any name collision.
- extension commands:
- For prompt lifecycle introspection summaries, use:
[:psi.agent-session/last-prepared-request-summary :psi.agent-session/last-execution-result-summary]
- For normalized prompt lifecycle fields, use attrs such as:
:psi.agent-session/last-prepared-turn-id:psi.agent-session/last-prepared-message-count:psi.agent-session/last-prepared-tool-count:psi.agent-session/last-execution-turn-id:psi.agent-session/last-execution-turn-outcome:psi.agent-session/last-execution-stop-reason
- Anthropic prompt caching is session policy projected into request shape:
- session state stores
:cache-breakpointssuch as:systemand:tools - executor projects those into conversation
:system-prompt-blocks/ tool:cache-control - the Anthropic provider emits
cache_controlonly for supported directives ({:type :ephemeral})
- session state stores
- Avoid non-existent attrs like
:psi.agent-session/prompt,:psi.agent-session/instructions,:psi.agent-session/messagesunless resolvers are added for them.
:state* owns queryable session truth — one atom, one root. Everything
else on ctx is a handle to a running subsystem.
Principle: when a subsystem has observable status worth querying
(OAuth login state, nREPL endpoint, workflow progress), that status is
projected into :state* as canonical data through dispatch. The handle
itself stays external.
A runtime handle is any object that:
- owns internal mutable lifecycle (atoms, watches, threads)
- performs side-effecting I/O (disk, network, locks)
- is infrastructure machinery (compiled envs, registries, engines)
Current runtime handles on ctx:
| Handle | What it is | Projection in :state* |
|---|---|---|
:agent-ctx |
agent-core loop, queues, event stream | turn context, provider captures |
| extension registry | loaded extensions, flags, event bus | extension prompt contributions |
| workflow registry | workflow instances, pump thread, statechart env | background jobs, workflow public data |
:oauth-ctx |
credential store, token refresh, file locks | authenticated providers, login status |
| nREPL server | live server object | [:runtime :nrepl] endpoint metadata |
| project nREPL registry | managed project/worktree nREPL runtime handles | :psi.project-nrepl/* projected instance state |
| query context | Pathom3 registry, compiled env | (is the query infrastructure itself) |
| engine context | statechart engines, system state, transition log | (is the engine infrastructure itself) |
| memory context | memory stores, store registry | (is the memory infrastructure itself) |
These are all the same kind of thing: opaque subsystems with their own internal mutable lifecycle. They are not queryable domain state.
dispatch!is active and queryable via the retained dispatch event log.- Current dispatch ownership is partial, not full-system.
- Migrated families include:
- statechart action handlers
- auto flags / ui type
- model / thinking
- session name / worktree / cache breakpoints
- active tools
- system prompt recomposition
- prompt contribution mutations
- startup/bootstrap lifecycle + summary writes
- context usage / extension prompt telemetry / runtime prompt retargeting
- rpc trace / oauth projection / recursion projection setters
- extension UI mutations (widget/widget-spec/status/notify/dialog + renderer registration)
- Remaining direct mutation pockets still exist outside those migrated slices.
- Treat
dispatch_pipeline_activeas "dispatch active for migrated slices" during migration, not yet "all mutations converge through dispatch".
Current agent-session dispatch sequencing for pure handler results is:
- handler computes a pure result
- apply writes state and surfaces declared effects onto interceptor context
- validate checks the post-apply interceptor context
- replay trimming may suppress effects
- effects execute last
Current scaffold semantics:
- validation is post-apply, not pre-commit
- invalid validation suppresses effects but does not roll back already-applied state
- replay suppresses effects but preserves state application and return values
Current default interceptor ids:
:permission:log:statechart:handler:effects:trim-effects-on-replay:validate:apply
Because after fns run in reverse order, the effective after-order is:
:apply -> :validate -> :trim-effects-on-replay -> :effects
The retained dispatch log now exposes more architectural debugging signal than just event type and timing. Current log entries include:
- event identity:
- event type
- event data
- origin
- ext id
- control flow:
- blocked?
- block reason
- replaying?
- statechart-claimed?
- validation error
- pure-result/effect shape:
- pure-result kind (
:db,:root-state-update,:session-update, etc.) - declared effects
- applied effects
- pure-result kind (
- bounded state summaries:
- db-summary-before
- db-summary-after
- timing:
- timestamp
- duration-ms
Retention/volume tradeoff:
- the log keeps bounded summaries rather than full root-state snapshots
- all entries are replay-safe by construction: replay suppresses effects and applies only pure state transforms, so no classification is needed to determine safety
- this log is the coarse-grained dispatch journal: one summarized entry per dispatch
- it is the preferred surface for replay-oriented questions like "what events happened?" and "what broad state/effect shape did they produce?"
In addition to the retained event log, agent-session now keeps a bounded
canonical dispatch trace keyed by dispatch-id.
Current trace entry kinds include:
:dispatch/received:dispatch/interceptor-enter:dispatch/interceptor-exit:dispatch/handler-result:dispatch/effects-emitted:dispatch/effect-start:dispatch/effect-finish:dispatch/service-request:dispatch/service-response:dispatch/service-notify:dispatch/completed:dispatch/failed
Current guarantees:
- every dispatch-created trace has one stable
dispatch-id - dispatch-owned traces now include interceptor stage boundaries, handler-result summaries, and emitted-effect summaries where the flow passes through the dispatch pipeline
- post-tool flows can create and explicitly thread a
dispatch-idthrough nested extension/service activity - managed-service protocol helpers record service request/response/notify events
under the explicitly supplied
dispatch-id - dispatch effect execution records effect start/finish entries including
:effect-type - trace storage is bounded in memory
Current EQL surface:
:psi.dispatch-trace/count{:psi.dispatch-trace/recent [...]}{:psi.dispatch-trace/by-id [...]}from seed[:psi.dispatch-trace/dispatch-id some-id]
Useful attrs on trace entries include:
:psi.dispatch-trace/trace-kind:psi.dispatch-trace/dispatch-id:psi.dispatch-trace/event-type:psi.dispatch-trace/interceptor-id:psi.dispatch-trace/method:psi.dispatch-trace/effect-type:psi.dispatch-trace/tool-call-id:psi.dispatch-trace/error-message
This canonical trace is the preferred observability surface for end-to-end runtime coordination. It is the fine-grained complement to the dispatch event-log:
- use the event-log for replay-oriented, one-entry-per-event journaling
- use the dispatch trace for correlated stage-by-stage diagnosis under one
dispatch-id
Adapter-local debug atoms remain useful for low-level transport diagnosis, but normal architectural debugging should prefer the queryable dispatch trace.
The first explicit conforming vertical slice target is manual compaction.
Current intended slice flow:
- public API entry via
manual-compact-in! - dispatch-routed statechart transition via
:session/compact-start - synchronous dispatch-owned compaction execution via
:session/manual-compaction-execute - dispatch-visible session-data cleanup via
:session/compaction-finished - dispatch-routed statechart completion via
:session/compact-done
Current intentional boundary:
- the compaction execution step itself is still synchronous so the caller can receive the compaction result directly
- the surrounding control flow is dispatch-visible and statechart-visible
Current proof surface for the slice:
- focused core tests now prove dispatch-visible event sequences for:
- default stub compaction
- custom compaction function
- extension-cancelled compaction
- extension-supplied compaction result
- the dispatch event log is the primary slice observability surface; no bespoke local-only debug hooks are required to understand the slice flow
This slice is the proving ground for broader convergence from partial dispatch ownership toward more reference-architecture-conforming vertical behavior.
The next target slice after manual compaction is prompt / turn lifecycle.
Current implemented outer shell:
- public API entry via
prompt-in! - dispatch-visible prompt submission via
:session/prompt-submit - dispatch-routed statechart transition via
:session/prompt - dispatch-owned request preparation via
:session/prompt-prepare-request - runtime execute-and-record boundary via
:runtime/prompt-execute-and-record - dispatch-owned response recording via
:session/prompt-record-response
Architectural convergence target:
- public API entry via
prompt-in! - dispatch-visible prompt submission via
:session/prompt-submit - dispatch-routed statechart transition via
:session/prompt - dispatch-owned request preparation via
:session/prompt-prepare-request - runtime execute-and-record boundary via
:runtime/prompt-execute-and-record - dispatch-owned response recording via
:session/prompt-record-response - dispatch-owned continuation / terminalization via
:session/prompt-continueor:session/prompt-finish
Current converged slice semantics:
:session/prompt-submit- normalize the submitted user message
- append the user journal entry
- establish the requested turn as dispatch-visible state
:session/prompt-prepare-request- project canonical session state into a prepared provider request artifact
- assemble prompt layers (base prompt, extension contributions, profiles/skills, runtime metadata)
- project cache policy into system/tool/message cache controls
- emit the runtime execute-and-record effect
:runtime/prompt-execute-and-record- perform provider streaming against the prepared request artifact
- capture provider request/response telemetry
- dispatch
:session/prompt-record-responsewith the shaped execution result
:session/prompt-record-response- append assistant output deterministically
- record usage / telemetry / tool-call outcomes
- decide continuation from canonical recorded state
:session/prompt-continue/:session/prompt-finish- route tool execution or follow-up turn continuation
- return the session lifecycle to its terminal state for the turn
Current intentional boundary:
- prompt journal append, request preparation, assistant result recording, and continuation decisions are now dispatch-visible, while provider streaming and turn accumulation remain concentrated in the runtime execute-and-record boundary
- the active slice currently reads as prepare -> execute-and-record -> continue/finish; this is an intentional convergence waypoint toward the stricter prepare -> execute -> record model
- request preparation is the architectural center for prompt lifecycle convergence; prompt layering, cache breakpoint policy, and provider request shaping should become explicit there rather than remain distributed across string concatenation and runtime orchestration paths
- tool execution is now dispatch-owned end-to-end:
:session/tool-runcomposes two dispatch-owned phases::session/tool-execute-prepared— may run concurrently, emits start/executing lifecycle, performs runtime tool execution through:runtime/tool-execute, and returns a shaped result without final recording:session/tool-record-result— records the final tool result in deterministic tool-call order, including lifecycle projection, telemetry, journal append, and agent-core tool-result recording
- the executor now owns only batch scheduling and deterministic ordered recording, not tool transaction semantics
Near-term architectural direction:
- move shared selector/session-tree semantics into
app-runtime - move shared footer semantic projection into
app-runtime - define a canonical adapter-neutral picker/action vocabulary in
app-runtime - converge navigation result shaping, context snapshots, and transcript rehydration packages into
app-runtime - leave RPC as protocol adaptation and adapters as rendering/mechanics
What success looks like:
- TUI and Emacs consume the same selector, footer, navigation, context, and transcript rehydration models
- RPC projects shared runtime models onto transport events instead of owning their semantics
- adapter bugs no longer require re-solving shared domain questions in multiple places
- explicit
session-idrouting becomes the default for targetable RPC operations, with adapter focus used only as fallback
- ✓ Engine + Query substrate
- ✓ AI provider layer (Anthropic, OpenAI)
- ✓ Agent core loop
- ✓ Coding-agent session
- ✓ TUI (charm.clj / JLine3)
- ✓ Extension system + Extension UI
- ✓ OAuth (PKCE, Anthropic, OpenAI)
- ✓ Git history resolvers
- ✓ Session persistence
- ◇ HTTP API (openapi + martian)