Minimal agent harness. Effect-first. Small seams. One owner per concern.
gent is organized around six nouns:
Server— process-wide services only: storage, auth stores, platform, transport wiring, connection tracking.Profile— cwd-scoped extension graph: drivers, hooks, resources, capability leaves.SessionRuntime— the single public session engine: inbox, queue, checkpoint, watch state, turn orchestration.Tool/Request— independent callable leaves for model tools and typed extension RPC. Requests with aslash:block also surface as human slash commands.Resource— long-lived scoped services and extension-owned state.Hook—systemPrompt,turnProjection, andturnAfterhandlers registered withhost.onfor prompt, policy, and turn follow-up.
Everything else is adapter code around those nouns.
Numbered invariants. Each carries the file that enforces it, so a reviewer can check the claim instead of trusting it. A change that breaks an invariant updates this list in the same commit.
-
Effect-native end to end. No
Promise<in an extension surface; noasync/awaitin tests. Receipts:packages/core/tests/extensions/api.test.ts(the extension surface rejects a Promise at compile time) and.oxlintrc.json(effect/noAsyncFunction,gent/no-promise-control-flow-in-tests). -
One actor per (workspace, session, branch). The agent loop is an effect-encore entity; every session mutation crosses its mailbox. Receipts:
packages/core/src/runtime/agent-loop.ts,packages/core/src/domain/agent-loop.ts. -
Everything is an extension of the loop. Core registers zero tools; drivers, tools, requests, resources, hooks, and TUI facets arrive through the extension API. Receipts:
packages/core/src/extensions/api.ts,packages/extensions/src/index.ts. -
Schema-first transport contract. Every RPC input and output is a Schema; thin adapters carry it. Receipts:
packages/core/src/server/rpc.ts. -
Event and projection commit together. A session mutation writes its row and its events in one transaction, so a reader never sees one without the other. Receipt:
transactWithEventsinpackages/core/src/server/server.ts. -
Tool calls replay from durable bindings. A resumed turn re-delivers a tool result from
tool_call_bindings; it never re-runs the tool. Receipts:packages/core/src/storage/storage.ts,packages/core/src/runtime/tools.ts. -
Approvals are one-shot and fail closed. A call that asks asks once through the durable interaction request; nothing is saved; no answerer means no. Core has no rule schema, rule storage, or
permission.*RPC. Receipts:packages/core/src/runtime/extension-host.ts,packages/core/src/domain/interaction.ts. -
Each model step is classified once. The stream fold produces a
StepOutcome; persistence and the continue/stop/run-tools policy are exhaustive matches on it, and the tag travels onStreamEnded.outcome. Receipts:classifyStepinpackages/core/src/runtime/turn.ts,packages/core/src/domain/event.ts. -
Retry policy belongs to the driver. The loop re-runs a step; the driver says which failures are transient and how long to wait. Receipts:
RetryPolicyinpackages/core/src/domain/driver.ts,packages/core/src/runtime/provider.ts. -
Tool results are bounded before the model sees them. At most 8,000 characters inline (head and tail); the rest is paged through
context.read. Receipt:maximumModelToolResultCharsinpackages/core/src/runtime/model-context.ts. -
A model change is a durable user-role notice the loop writes. The settings update only records the choice. At each step boundary the loop compares the model the branch last ran on or was told it continues with (its last
StreamEnded, or the last model-change notice) with the model this step resolves; when they differ it writes onemodel-changemessage, and that step reads it. The id names both models, so a replay after a further switch writes the right one. A turn under an agent or run-spec model override writes none; the turn after it notices the change back. An effort change, or a branch with no settled step, writes nothing. Receipts:modelChangeNoticeinpackages/core/src/runtime/model-context.ts,lastKnownModelinpackages/core/src/runtime/turn.ts. -
Tool guidance lives on the tool and follows the active tool list.
promptGuidelinesare deduped per turn from the post-policy tools only. Receipts:buildTurnPromptSectionsinpackages/core/src/runtime/turn.ts,packages/core/src/runtime/turn.ts. -
The cell runs in full Bun. No sandbox, no interpreter; network reads, HTML parsing, and past-session queries happen in the cell. Receipt:
packages/extensions/src/cell.ts. -
A child's completion arrives as a user message, never a tool result. Receipt:
packages/extensions/src/delegate.ts. -
Platform edges stay explicit. Extensions reach files, paths, processes, and ids through the Effect platform services (
FileSystem,Path,ChildProcessSpawner,Crypto) and resolve relative paths againstctx.cwd;runProcessis the one command helper. NoExtensionContextfacet duplicates an Effect platform service; the facets are host authority only (Session,Interaction,FileLock,State). An atomic write has one owner,writeFileAtomicinpackages/core/src/runtime/gent-platform.ts; core config, extensions (through@gent/core/extensions/api) and the TUI (through@gent/core/host) all call it. Host facts core cannot get from Effect (OS info, executable path, home directory) stay onGentPlatform. The TUI session controller owns screen state, views render and dispatch; app-specific UI facets live at the app edge. Receipts:packages/core/src/runtime/gent-platform.ts,packages/core/src/domain/extension.ts,apps/tui/src/session.tsx,apps/tui/src/app.tsx. -
RPC is the application transport. No parallel REST surface. Receipt:
apps/server/src/. -
Context leaves the window as a handoff, never as a loss. When the window overflows or the model asks, the history before the newest user message is summarised into one durable user-role marker that names the session, the branch, and the id range it replaced; every replaced message stays readable from the cell through
context.historyandcontext.read. When the newest turn alone overflows, the handoff anchors inside the turn at a step boundary and keeps the newest steps that fit half the budget. A summary that cannot be produced degrades to truncation with a visible notice. The budget is the smaller of the model's input cap (Model.inputLimit, from models.devlimit.input) and its window less the output reserve. The reserve is the model's output cap (Model.outputLimit, from models.devlimit.output) up to 32k, and at most a quarter of the window (outputReserveTokens). The same number is the output cap each step's request asks for (ProviderHints.maxTokens), so input within the budget plus the reply never passes the window. The estimate is chars/4. When the last step's reply is still in the window, the messages before that reply count at least as much as that step's reported input, less the system and tool size its own request carried (StreamEnded.requestOverheadTokens). Its output never counts. The TUI'sctx N%andcontext.status().percentdivide that estimate by the budget the projection records (availableInputTokens), so both read 100% where the window hands off, also when the input cap is below the window. A session with no projection yet reads the last step's input againstmodelInputCeilingTokens(@gent/core/protocol): the window less the output reserve, never past the input cap, the same ceiling the turn's budget uses. A request the provider refuses as too long (RetryPolicy.contextOverflow, one pattern list inpackages/core/src/domain/driver.ts; the byte caprequest_too_largeis not an overflow) hands the window off once and runs the step again. The step drops the history, or the summary of an earlier handoff when nothing else is left. A second refusal fails the turn with an error that says so. A reply the provider stops because the window filled (Anthropic'smodel_context_window_exceeded) is cut and a refusal both: its text stays, a continuation asks for the rest, and that step hands the window off first. Effect AI maps that stop reason to"unknown"and keeps no copy, so the driver reports the raw word throughProviderStopReason, which the loop provides to each step's stream. An"unknown"finish alone is a finished answer. Receipts:packages/core/src/runtime/model-context.ts,packages/core/src/runtime/turn.ts,packages/extensions/src/compaction.ts.
Kept here so the next pass starts from them, not from a fresh survey. Each names the decision that left it open.
- Typed fan-in for children (ledger A3) is rejected, not open. A
delegate.startreturns at admission and the child's completion arrives as a user message that wakes the parent (gamut run 34: six starts in one cell, six wakes), withdelegate.listfor inspection andread_sessionfor the transcript. The model never blocks on a child, as in prime-agent; acollectwould add a blocking surface the owner rejected on 2026-09-18. Reopen only if a run shows a child result needed inside a later cell before its message lands. - The agents view keeps a server half. The live catalog
(
ExtensionContext.Session.listActiveLoops) and the stored catalog (session.list,packages/core/src/server/rpc.ts) differ after a restart; folding the view into the client would need a core RPC or one snapshot read per session per tick. Rejected as R6 in the same ledger. - Compaction is measured on long sessions only by hand. The handoff
count (
ModelContextProjected.compacted) after the spill comes from gamut runs, not from a test; the receipt inplans/core-extension-reduction-receipt.mdrecords the last measurement.
apps/
├── tui/ # OpenTUI client over the shared transport contract
└── server/ # HTTP + RPC adapter over the same app services
packages/
├── core/ # entries: extensions/api, extensions/branch-tools, protocol, host, test-utils
│ ├── domain/ # Schemas, ids, events, service tags, pure domain helpers
│ ├── storage/ # Storage tags, schema ownership, SQLite assembler, focused repositories
│ ├── runtime/ # SessionRuntime, agent-loop internals, provider stack and scripted model
│ ├── extensions/# api.ts public extension surface
│ ├── server/ # transport contract, handlers, commands, queries, startup wiring
│ └── test-utils/# test layers, recorders, fixtures
├── extensions/ # shipped extension set
└── sdk/ # direct + RPC transports over one client contract
@gent/core/protocol contains shared client schemas, message projections, and the RPC contract. The SDK uses this entry point for client data. It does not expose server storage or runtime service tags. Core implementation files keep relative imports; they do not import through the public protocol entry point. The private alias remains while host and test consumers move to supported contracts.
TUI / SDK / HTTP client
│
▼
transport contract
│
┌──────┴──────┐
│ │
▼ ▼
direct RPC / HTTP
adapter adapter
│ │
└──────┬──────┘
▼
app services
│
┌──────┴──────┐
▼ ▼
commands queries/events
│
▼
runtime + boundaries
Process topology is secondary. Default CLI topology is not.
Default gent resolves a shared server via Gent.server({ cwd, state: Gent.state.sqlite() }). SQLite-backed local clients share one server per database, decided by the lock files beside data.db (see Shared Server Discovery); workspace routing is carried by the x-gent-workspace-id RPC header. Topology derives from configuration: Gent.state.memory() for in-process owned, Gent.state.sqlite() for shared local server, Gent.client({ url }) for remote.
Source of truth:
packages/core/src/server/rpc.ts
That module owns:
- client-facing types
- queue/session/message projections
- contract semantics
Adapters:
packages/sdk/src/client.tspackages/core/src/server/rpc.tspackages/core/src/server/server.ts
Rule:
- no client-specific DTO remodeling
- no parallel application contract surfaces
- handlers and adapters derive from the same contract types
The app surface is split by concern:
SessionMutations(packages/core/src/domain/extension.ts;SessionMutationsLiveinpackages/core/src/server/server.ts) — every durable session/branch mutation, includingcreateSession; request-id-bearing mutations replay their durable operation row and collapse concurrent same-request fibers in-processSessionQueriesInteractionCommands
message.send request-id dedup lives in server/server.ts next to the handler; the runtime keys the actor command on the same request id.
The app services are one layer, createDependencies in packages/core/src/server/server.ts; no separate app-services layer exists. The SDK builds it in the server scope and hands the context to buildServerRoutes; the test harness provides it as a layer.
packages/core/src/server/server.ts owns startup wiring:
- runtime platform
- storage/event store
- auth/config/model registry
- provider stack
- extension loading and live Profile ownership
- actor/runtime services
It is the composition boundary. Not the domain boundary.
packages/core/src/runtime/extension-host.ts owns the shared profile pipeline.
loadRuntimeProfileDeclarations discovers extensions, runs trusted setup,
validates declarations, and loads the core prompt sections. It does not build
Resource layers. Trusted setup can still perform its own effects; this is not a
sandbox boundary.
SessionProfileCache builds one profile per (workspace, cwd, set of
extensions the config leaves active or failed, versions of the extension files
on disk). Each resolve reads the config and lists the user and project
extension directories as they are now, under the place's lock, so an edit to
disabledExtensions and an added, fixed or edited extension file reach the
next turn and the next session without a restart; a file is imported under its
version (mtime, size, inode), so Bun's module cache does not serve the old one.
A directory extension's version is its index file's. Project trust comes from
isProjectExtensionDirectoryTrusted (runtime/config.ts), the reader the TUI's
client-extension loader calls too: it reads trustedProjects from the user
config file as it is now, and a file that does not decode trusts no project.
Launched from home, the project's .gent is the user's, so there is no project
scope: hasProjectScope (runtime/config.ts) keeps the config read, the
extension scan and the TUI loader from reading ~/.gent a second time. A
list that leaves the
same extensions, such as one that names an unknown id, finds the profile
already built. Finding or building the profile, its lease and making it
current are one step no interrupt can split. resolve takes a
lease in the caller's scope: a turn and an extension request hold it until they
end, a branch loop holds it while its branch Resources live, and a query holds
it for its read. The newest profile of a (workspace, cwd) stays cached; a
superseded one closes its scope when its last lease is released. It builds
every extension's process-scope resources in resolution order, each in its own
scope, and reports an extension whose layer fails as failed at the startup
phase. An extension's process resources are shared by every profile of the
place that builds them over the same context: the same resource-bearing
extensions before it, and itself (id, source, file version). So a profile
rebuilt for a config edit keeps the resources the edit leaves alone, and their
state with them (an open /btw fork, the agents-view watchers, a running
background job); a resource closes when the last profile that holds it
retires. buildSessionProfile then stages the ExtensionRegistry and the base
prompt sections over the built resource context. Profile tests use the live
cache. The tool test layer uses the production composition root. Neither has a
separate activation implementation.
The production server uses one live profile owner:
runtime/extension-host.tsowns entries by workspace, canonical cwd and disabled list. Each entry is built once: declarations load, every extension's process resources build into a child of the server scope, andbuildSessionProfilestages the catalog from that context. An extension whose process resource fails to build is reported as failed at thestartupphase; the rest of the profile stays live. There is no reconciler, publication, or admission lease. Gent owns resource identity, configuration and graph policy; Effect owns Context, Layer, Scope and finalization; effect-encore owns durable actor commands and recovery. Test roots (createE2ELayer) setfailOnExtensionFailure, so a failed extension is a defect that names it;allowFailedExtensions: truekeeps a failure-path test running. The harness loads everyextensionInputs/extensionsentry at builtin scope; a scope test builds its profile fromLoadedExtensions and passes it assessionProfileCacheLayer.server/server.tsselects the launch profile from that cache.
Turn profiles carry the process identity that built them. A process-local tool
binding names that process and is valid only inside it.
Native source-mode approval, public repair, direct-command cleanup, and external
callback limits have focused validation. Full gate and terminal/server E2E pass.
See plans/live-composition-review.md for evidence and recovery limits.
Core writes two prompt sections: the environment, once per profile, and the
local date, per turn (a profile outlives midnight; the date changes the cached
prefix at most once a day). Extensions add sections
only from turnProjection hooks, which run each turn inside the extension
service context.
A turnProjection hook returns standing content as promptSections and
show-until-read content as notices (TurnNotice: id, content, keys). The
system prompt holds only the sections, so it stays byte-identical while a
notice comes and goes. Core places the turn's notices after the
conversation, as one system message at the end of each step's request, and
stores none (toPrompt in runtime/model-context.ts). The Anthropic driver
sends it as a <host-context-update> user message, which takes no cache
marker, so the tail marker stays on the last conversation block; the OpenAI
driver sends it as a developer message after the conversation, so the prefix
it caches is unchanged. The message opens with "Host status for this turn,
not a message from the user." Both drivers send it in a role below the
system prompt, so a user instruction wins over a notice; the opening line
keeps the model from reading host facts as the user speaking. A notice with
blank content is dropped, so it is never recorded as shown. The turn ledger records the keys each step's request
carried, per extension; turnAfter hands an extension back its own keys as
readNotices when the turn answered, and an empty set for an interrupted,
failed or unanswered turn. The marks live in the turn's memory: a turn a
restart cut short shows its notices again.
Core orchestration lives in:
packages/core/src/runtime/session.tspackages/core/src/runtime/agent-loop.tspackages/core/src/domain/agent-loop.tspackages/core/src/runtime/turn.tspackages/core/src/runtime/model-context.ts
Shape:
SessionRuntimeis the single public session engine.AgentLoopis an actor-backed internal control plane. There is no publicAgentLoopservice facade;runtime/session.tstalks to the actor inagent-loop.tsdirectly. The actor entity id includes(workspaceId, sessionId, branchId).- Residency (
AgentLoopResidencyinagent-loop.ts): the cluster reaper passivates an entity idle pastentityMaxIdleTime(one minute), and its runtime-state stream and branch scope close with it. Four things hold the entity resident, counted on its one keep-alive switch (first hold on, last release off; a failed switch-on takes its count back): a turn, from its hand-over to the worker until the worker is done with it, receipt and hooks included (the mailbox request can return before the model finishes); a re-entrant admission's asked-for start (wakeAfterPermit), until the start is decided; each watcher of the loop's runtime state (session.watchRuntime, so an idle client's stream does not end); and each extension hold throughExtensionContext.Session.holdResident(a pending wake alarm or monitor). Every hold is scoped, so completion, failure, interruption, a fire, or a cancel releases it, and an entity nothing holds expires. Each hand-over takes the next hold before the last one ends (a wake's send, then its start, then its turn): the switch-on is an asynchronous keep-alive message, so a count that reached zero in between would let the reaper take a loop whose woken turn is about to start. - Runtime commands resolve an existing
(sessionId, branchId)target before loop dispatch. - The
@gent/delegateextension is the helper-agent boundary, built only on the public extension API. Every child is a session in the one runtime, created through the addressedSessionfacade verbs. The delegate owns its own child registry on disk; core carries no runner and no child-run events. - Child admission uses one shared ancestry check on the
session.createcommand path (admitChildSessionDepth). Missing or incomplete ancestry is an error, not root depth. A parent at the depth limit cannot spawn. This check is not a concurrency or token budget. - The
@gent/delegateextension admits a child by creating a session through theSessionfacade withparentSessionId/parentBranchId, thensending the child's first message.delegate.startreturns the handle at admission, keyed by the host tool call id so a replayed call finds its child, and never the answer: the model does not block on a child. The delegate reserves at most four unfinished children per parent branch, counted from its own on-disk registry, not live actors. - The delegate keeps one child registry per parent branch as a JSON file under
<data dir>/delegates/<branchId>.json(the data directory,resolveDataDir:GENT_DATA_DIR, else~/.gent).delegate.listreads it; the delegate reconciles it when the parent's loop opens (loopOpen), on the parent's first turn in the process, and on every read, so a caller that died between the child's receipt and delivery leaves a child the registry still resolves, never a running one nobody delivers. Reconcile re-sends the start of a child with no receipt. The repeat admits nothing new, but a durableturnsend reads the target's state first, which opens its loop, so a child the previous process stopped mid-turn resumes and its completion wakes the parent. Without that, a restart left the child stuck and counting toward the cap. - One writer settles a child's completion. The delegate's
turnAfterhook on the child branch delivers every receipt as one idempotent follow-up message on the parent branch (metadatacustomType: "child-completion",wakeset so a parent with no prior turn still starts one). Nothing waits on a child. - The same hook, read on the parent side, cascades an interrupt: when a turn
ends interrupted, every child it started and had not heard from is settled
as interrupted and then stopped with
Session.stopMessageon its start message, outside the registry lock, so a parent Escape stops the whole subtree and no completion message wakes the parent the user just interrupted. A child turn that completed before the stop reached it still holds its loop while its hooks run, so the stop answers true for it too; its own hook delivers the completion over the claim, and reconcile does when that hook failed. A failed stop hands the row back. The hook's child, parent and notice steps each fail alone. The settled row keeps a stop notice (stopNoticeAt, additive optional): the delegate'sturnProjectionreads such rows into a# Stopped childrenturn notice (one line per child, newest first, at most eight, then a count), as@gent/wakedoes for anotifyfire.turnAfterremoves exactly the rows inreadNotices, which an answered turn hands back; a notice the turn did not show, because its read failed or the cap left it out, stays. The stop starts no turn; the parent's next turn, whoever starts it, knows the children are not running. The notice asks the model to tell the user which children stopped and to start one again only when the user asks: the user's interrupt stopped them. Prime-agent does not cascade a turn abort; opencode does, and the gamut testbed's six children editing files after an Escape decided it. This cascade is the delegate's and covers the runsdelegate.startopened; the turns asession.sendopened are@gent/session-tools' cascade (below). delegate.cancelstops the child's start message through the facade (Session.stopMessage); a finished child is a no-op.- Every session can message another:
session.send(@gent/session-tools) takes a session id orparentand steers anInterjectwithwakeonto the receiver's active branch, carryingcustomType: "session-message"anddetails.from(sender id, sender branch id, name, relation). The message id isinterjectionMessageId(requestId), exported from@gent/core/extensions/api, so a sender can name what it sent. A send to a child is recorded in memory (process resource, keyed by child session and message id), and the sender's interrupted turn stops each recorded message withSession.stopMessage; only a stop that answers true becomes a# Stopped child turnsnotice, read and cleared as the delegate's is. A failed stop is logged and the record stays for the next interrupt. The record drops a message when its turn ends, when a step joins it into another turn (turnAfter'sjoinedMessageIds), when a stop settles it, and when the sender's answered turn finds the child session gone; a restart forgets it. A correction taken back from a child turn the delegate's cascade already stops is the delegate's news:stopMessageanswers false for it, so the child is named once. A running receiver reads it at its next step; an idle one wakes and answers in a turn of its own. This is the child-to-parent channel (a blocked child asks instead of guessing) and the parent-to-child correction in one verb; there is no separatedelegate.send. Delivery is not bound to the delegate registry, so a message to a finished child wakes it for another turn and no second completion follows. A message from a child adds a line saying the child is still running and this is not its completion. The TUI draws the row as a muted sender line (» from your parent "name" · <id>, the name cut at 32 terminal columns on a grapheme) over the text;sessionMessageBodyremoves the header, with or without the child line, so older rows render the same. Full detail shows the header the model reads. Interjectsteering never interrupts an open stream. The item is admitted to the durable steering queue; a running turn delivers it at its next safe step boundary (tool results stored, no stream open) by persisting the interjection as a transcript message before the next model call, so the same turn continues.- Follow-up admission from inside a held side-mutation permit (a running turn, a
tool invocation, an extension request) only appends to the durable queue. The
turn starts from a wake that runs after the permit is released, or at the next
turn boundary.
QueueFollowUpcarries an explicitwakeflag; without it a branch with no prior history keeps the item queued until a real turn arrives. - Orphan reconciliation: a stored tool result that has no terminal tool event
(a recovered cell, a binding replay failure, a host that died mid-call) gets
its
ToolCallSucceeded/ToolCallFailedevent fromreconcileToolProjectionswhen the result is persisted, before the turn reads the transcript for new model work. Clients replayingToolCallStartednever keep a stale running projection. Ambiguous side effects are not replayed. When a turn resumes, a pending call runs again only if its last run parked on an interaction: the turn record marks itparked(an optional field on its pending entry) as soon as the call parks, while its siblings may still run, so an answer given before a restart is taken; the mark clears before the call runs again. A mark or a clear that cannot be written fails the step. Any other pending call was cut short while it ran; the model reads a failed result with reasonInterruptedand the call does not run again (a cell with a receipt still settles from it first). The binding replay rules then decide whether a parked call can run again or fails. - Narrow retry:
retryProviderCallretries transient provider failures with bounded exponential backoff plus jitter, and only before observable output. The policy lives on theModelDriverContribution(retry: RetryPolicy), because the driver knows its own overload and rate-limit shapes; the loop only re-runs the step. Transient means the provider library's typedAiErrorsays so, or a mid-stream error event matches the driver'stransientStreamEventschema (Anthropic names atype, OpenAI acode). A typed rate limit'sretryAfterreplaces the backoff. Nothing is inferred from message text. After partial output the partial assistant message stays, a durable continuation instruction (<turn>:continuation:<step>,customTypecontinuation) follows it, and the same turn runs one more model step. Two continuations per turn; a further partial failure ends the turn asstreamFailed. CancelandInterruptsteering can include an expected message ID. Omission preserves branch-wide behavior. The worker checks the target before signaling and again when resuming an interaction. A local interruption permit serializes running-turn cancellation cleanup with turn completion and next-turn selection. It is separate from the side-mutation permit held by the running turn, so cancellation can stop active work without waiting for that work to finish.RequestExtensiontakes the side-mutation permit unless the request declaredanswersDuringTurn: true; such a request answers while the turn runs and must not change the branch's loop state.- Targeted cancellation records
turn.cancelin the existing workspace-scoped durable-operation table before the steering handler starts the branch owner. The receipt belongs to the child session/branch and survives until that branch is deleted. Repeated cancellation is idempotent; a conflicting owner fails. Turn entry checks this receipt before model work. It persists the user message and clears the in-flight queue marker once per turn, including early-cancelled turns, so cancellation has a stored message and terminal receipt. This does not replay cell source, and it does not prevent effects that ran before cancellation. - Child cancellation also commits that intent before submitting steering, whose acknowledgement does not wait for its handler. Start and cancel share message submission from the saved start receipt and original command ID. This lets an admitted but unsubmitted child complete as interrupted without a model call. Retrying start or cancel does not create another child message.
- local CLI routing uses the shared server lock by default; remote routing is explicit server topology
- queue ownership is structural
- turn resolution streams through
LanguageModel.streamTextfromModelResolver, with durable stream/tool/finalization events derived from the response stream. - New
TurnCompletedreceipts includestreamFailed, including explicit false. Historical receipts can omit it; absence does not prove model success. The receipt commits with turn duration. This flag reports a failed turn only (a broken model stream or a failed turn phase), not task success or a complete child outcome. Actor Idle is not completion proof. - Every admitted turn ends with exactly one
TurnCompleted. A turn phase that fails (a storage write, a profile resolve) publishesErrorOccurred, thencompleteFailedTurnappends the receipt withstreamFailed: true, and the turn'sturnAfterhooks run once after it, under the turn's profile. It shares the receipt and hook steps withfinalizeTurn(appendTurnReceipt,emitTurnAfterinturn.ts). The stored turn duration is the receipt's mark, so a failure afterfinalizeTurnstored it appends no second receipt and runs no second hook. As on a normal turn, the receipt and hooks run outside the interrupt permit; only the hand-over to the next item takes it, so a hook may stop its own branch and a Cancel meanwhile stops this turn.ErrorOccurredwithnotice: trueis a notice the turn goes on past (a compaction that fell back to truncation); a client ends the turn onTurnCompleted, never onErrorOccurred. - New turn-stream start/end receipts include the user-message ID and model-step number. Model, failure, and interruption paths keep that identity. Historical receipts can omit it and must not be treated as exact per-turn budget evidence. Token-budget enforcement remains unfinished; compaction invokes a separate model and needs accounting within the same policy.
- Context compaction is a seam, not a core feature. The loop checks the window,
and with no
ModelContextCompactorprocess resource installed it truncates and reports the omission. The@gent/compactionextension installs the summariser; core keeps only the window marker shape (context-window, optionalsummarizedrange) that status and the TUI read. A compactor that fails degrades to truncation with a visible notice. The marker's notice names the session id, the branch id, and the replaced id range so the model can page the replaced history from the cell. - Project instructions are an extension, not a profile field.
@gent/agentsreadsAGENTS.md(orCLAUDE.md) from the gent home, the project and the project-local.gent/on every turn and contributes theproject-instructionsprompt section at priority 70 beside the persona sections. Launched from home (hasProjectScopeis false), the project-local.gent/is the gent home and is read once. Core builds no instruction text and the profile carries none; an edit toAGENTS.mdreaches the next turn. - The system prompt has two parts. Sections below
AGENT_PROMPT_PRIORITY(packages/core/src/domain/capability.ts: persona, boundaries, environment, date, project instructions, skills) are the part a session shares with its children, byte for byte, whatever tools each has; the agent's own sections (tool list, tool guidelines, the cell guide, sessions and children guidance, an agent addendum) and whatsystemPrompthooks append (the host tool list, session naming) follow. The turn sends the parts as two system blocks (systemPromptBlocks), and the Anthropic driver marks the end of the shared one on the API-key path, so a fresh child with its parent's tool set reads it from its parent's cache entry (the tool definitions come first in the cached prefix, so a child with other tools reads none of it). - Tool-result spill: the model sees at most 8,000 characters of any tool
result (head plus tail); the stored message and its events keep the full
result, and the bounded result carries a
readlocator forcontext.read(toolCallId, { offset, limit }). The bounded result keeps the result's shape with each long string cut, so the provider encodes the text once; only a result of many short strings is cut as JSON text. Context pressure drops before compaction ever runs. - The cell subsumes host tools the Bun runtime already provides:
fetchfor network reads andbun:sqliteon the data directory'sdata.dbfor past sessions. Nowebfetchorsearch_sessionstool ships;read_sessionstays as the parent-to-child output seam. - Response projection treats token usage as known only when both totals are
nonnegative safe integers. Missing or invalid totals remain absent, not zero.
Compaction uses the same conversion and stores reported usage plus model ID in
the handoff marker's
summarizeddetails. Failed attempts and crashes before marker persistence still need durable attempt accounting; this metadata alone does not enforce a budget. - interactions are cold machine states, not blocked fibers
- machine inspection events are published as diagnostics
Do not rebuild business logic from inspection events. They are receipts, not inputs.
- The
@gent/delegateextension owns child runs. It is built only on the public extension API — no core runner, no privileged seam. Every child is a session created through the addressedSessionfacade verbs (createwithparentSessionId/parentBranchId,send,stop,events,delete). The delegate keeps its own child registry as one JSON file per parent branch under<data dir>/delegates/<branchId>.json, so adelegate.listsurvives restarts without anydurable_operationsrow. - Completion has one writer: the delegate's
turnAfterhook on the child branch delivers every receipt as one ordinary user message on the parent branch (metadatacustomType: "child-completion") with the outcome and a bounded preview, and the message wakes the parent. Nothing waits on a child. A turn that ended badly also carries the error it ended on (the last non-noticeErrorOccurredsince the previous receipt, one line of at most 1,000 characters) in the text and in the optionaldetails.error, so a parent tells a sign-in that will fail again from a flake. Lazy reconcile covers the crash window between the receipt and the hook: the delegate reconciles its registry when the parent's loop opens, on its first turn, and on everydelegate.list, so a caller that died mid-op leaves a child the registry still resolves, never a running one nobody delivers. - The delegate ships three ordinary tools:
delegate.start,delegate.cancel,delegate.list; messaging a child issession.send. A child's first message opens withTask from your parent session <id>.and says where its final reply goes, so the child does not take a bare instruction for an injection.delegate.startaccepts RunSpec overrides for model, reasoning, tool selection, and added instructions, and acontextoffresh(the child sees only its todo) orfork(the child is created withhistoryBranchId= the caller's branch, so it starts from the caller's current context window). A history copy is settled first: a tool call with no result in the source, such as thedelegate.startcall that is making the fork, is left out with its step, and a result whose call the window cut away is left out too, or the child's first projection would reject the group.delegate.startalways denies the child the delegation tools: fan-out is the caller's decision, and a project prompt that addresses "the orchestrator" reaches children too. Parents read child output throughread_sessionon the returned session/branch IDs. The session is the only copy of a child's output; the completion message carries the outcome and a preview. - The TUI agents pane lists children through
AgentsViewRpc.ListAgentsand refreshes on the delegate'sExtensionStateChangedpulses, matched byDELEGATE_EXTENSION_ID. Completion rows read the completion message's details. Core publishes noAgentRun*events. - Child session nesting depth is admitted on the
session.createcommand path (admitChildSessionDepth). Missing or incomplete ancestry is an error, not root depth; a parent at the depth limit cannot spawn. Only spawn edges count: a handoff (continueThread) keeps its parent's thread, is not admitted, and keeps its parent's depth. - Two shipped agents:
main, the orchestrator, anddelegate, registered by the delegate extension as the agent every child runs as. A child inherits nothing from its caller: its model and effort come from thedelegatedefinition, reshaped byagents.delegatein.gent/config.json, and a call's RunSpec overrides (model, tools, prompt addendum) win over both. That config entry is where a pairing such as fable → opus or opus → sonnet is declared. /btw(@gent/btw) forks the branch:btw.forkcreates a child session withhistoryBranchIdset to this branch, so the fork starts from this branch's context window and runs as the session's own agent with its tools — a parallel session, not a side channel. Nothing it does lands on the branch it forked from. The copy keeps a request the session is still working on, because a question is usually about it; so each question goes to the fork under a header (forkQuestionText,customType: "btw-question") that names the session it forked from, says a request with no answer above is that session's work and not the fork's, and asks for changes only when the question does. Without it a fork opened mid-turn took the session's task as its own and did it again in the same working tree. The pane and the fork's transcript row show the question without the header (forkQuestionBody). The pane asks it throughbtw.askand reads it throughbtw.progress(turns after the fork point plus the reply streaming now, folded from the fork's event stream by a process resource). Each state pulse the follower sends is a stored event on the branch and a re-read of the fork in the open pane, so a fork event that leaves the pane's view as it was pulses nothing, and streamed text pulses at once and then at most once per 250 ms, with the last change always pulsed;^oopens the fork as the shell's session, which isswitchSession, because the fork already is one. The open fork per branch is process state; the fork itself is durable and listed with every other child session.- Alarms and monitors (
@gent/wake) live in<data dir>/wakes/<branchId>.json(resolveDataDir(ctx.home):GENT_DATA_DIR, else~/.gent;ctx.homeis the OS home); timers are branch-scoped.wakefires at a time, and again everyeverySecondswhen it repeats (the stored due time advances on each fire; ticks missed while the process was down fold into one fire);monitorpolls a shell command on an interval until it exits 0 or its stdout matchesuntil, or its deadline passes. Both write the entry, capture the session facade of their call, and fork work into the branch resource scope that queues a user-rolewakemessage (details: { outcome, note, firedAt };firedis an alarm,matched/timed-outa monitor). Inwakemode (default) the line carrieswake: trueand starts a turn on an idle loop; a line the session refuses (a full follow-up queue, for one) is logged (wake.fire.refused) and stored as anoticeentry instead, so the fire is not lost. Innotifymode no line is queued (a queued follow-up always runs a turn on a branch with history): the fire stores anoticeentry in the same file and pulses the tray;turnProjection(every step) reads the notices into a# Noticesturn notice, andturnAfterclears exactly the notices inreadNotices, the ones an answered turn's steps showed (a lost process shows them again), so a failed, interrupted or unanswered turn keeps them, and a notice written after the last step read the file waits for the next turn;wake.canceldismisses one unread. A settled one-shot fire removes its entry; an interrupt (branch close, shutdown) leaves the row for the next re-arm; a repeat only ends on cancel.wake.cancelinterrupts one timer by id, or every pending one on the branch, and drops the entries; the resource keeps fibers by id for that. Branch resources start without anExtensionContext, so after a branch close or a server restart the stored entries get their timers back when the branch's loop opens (theloopOpenhook re-arms them under the branch file's lock, the lock a fire takes to drop its entry, so a fire that ends during a re-arm is not armed and fired again; past-due alarms fire at once, and a past-duenotifyalarm leaves its notice without a turn). Opening the session is enough; no message is needed. The TUI collapses awakerow to◷ alarm fired · <note>or◉ monitor matched · <note>, and a wake tray under the status line lists pending entries from thewake.pendingrequest with their cadence and(notify)when the fire starts no turn; the model reads the same entries with thewake.listtool, in ISO times like thewakeandmonitorresults (a tool and a request cannot share an id inside one extension). The status bar shows onlyctx N%; the messages the projection omitted show on the live window in the/threadpane. - Background shell jobs (
@gent/exec-tools,bashwithrun_in_background) keep one row each in thebackground_bash_jobstable and run in a process-scoped resource. A finished job queues its terminal notice (bash:<toolCallId>:completeor:failure), which wakes the branch. The notice carries the head and tail of the output withinmaximumModelToolResultChars. An output it cuts is written whole, once, to<data dir>/background-bash/<sessionId>/<branchId>/<toolCallId>.txt, and the notice names that file for the read tool; the start stub is the only tool result the call stores, socontext.readof the call id cannot reach the output. The files are kept like the job rows. When the session refuses that message (a full follow-up queue, for one), the refusal is logged (exec-tools.background.follow-up.refused) and the row getsundelivered_at: every step reads the branch's unread undelivered jobs into a# Background commands finishedturn notice with each outcome and the head and tail of its output (2,000 characters; a cut output names its file the same way; a row from a build before that bound holds more, and its cut names the job's file when the file exists, with nothing written), cleared bynotice_read_atlike an interrupted job. A later accepted send of the same job (the replay of aTerminalclaim, for one) clearsundelivered_at, so the model does not get the result twice. A job that cannot finish becomesinterrupted: its fiber marks the row when it is stopped (server stop, or its resource closed), and a new process marks rows an earlier process left running. The process died with the job, so nothing is reattached. An interrupted job wakes nobody: opening a session must not spend a turn with no user present.turnProjection(every step) reads the branch's interrupted jobs whose row has nonotice_read_atinto a# Interrupted background commandsturn notice, which says the jobs stopped before they finished (a server restart or a reload, without naming one), names a job's output file only when the file holds output (else it says no output was saved, or that the job wrote none), and tells the model to report them and to start one again only when the user asks;turnAftersetsnotice_read_aton exactly the jobs inreadNotices, the ones an answered turn's steps showed, so a failed, interrupted or unanswered turn keeps them. A repeated start of an interrupted call queues nothing. A table from before the column keeps its interrupted jobs unread: the earlier code told a branch only when its loop opened, so a job shows once more at worst and is never lost. Two processes that add the column at once both start: a failed add reads the table again. A session nobody sends to is not told. - Persistent goals (
@gent/goal) live in<data dir>/goals/<branchId>.json. After every uninterrupted turn while a goal is active, the goalturnAfterhook charges the turn's usage to the goal (a turn that started before the goal existed is charged only its time since the goal's creation, byturnAfter'sstartedAtMs) and queues agoal-contextuser message; a spent token budget flips the goal tobudget_limitedinstead. Only thegoaltool'scompleteaction ends a goal. The TUI collapsesgoal-contextrows to one line unless full detail is on. - Foreground runs persist a child session/branch and can be revisited with
read_session. Private runs leave no session behind; they return text/usage/tool-call metadata only. TurnCompletedcarries the turn's token totals, summed over its model steps. It is absent when any step reported no usage or an unusable count, when the turn had no step, and on historical receipts. Explicit zero is retained. Run results and child completions read usage from that receipt and the answer from the branch's last assistant message; nothing scans the event log. These are reported stream totals, not model-attempt accounting.
Session snapshots and event replay use the same storage-backed event service,
including when SQLite runs in memory. EventStore.Memory is an explicit test
override, not the default for in-memory application sessions. This keeps the
snapshot cursor aligned with replayed navigation and interaction events.
Slow-client policy: the session PubSub registry in domain/event.ts gives each session one sliding
PubSub of event ids, not envelopes. Publishing never waits for a subscriber, so a
stalled client cannot block tool execution. makeCursorReplayStream opens the
subscription first, drains the durable store from the subscriber's cursor, and
drains again on every notification burst. Lost or coalesced notifications cost
one extra read, never an event, and replay and live delivery share one ordered
source, so there is no replay/live race. Both EventStoreLive and
EventStore.Memory use this path; tests/domain/event.test.ts
proves the stalled-subscriber, race, and branch-filter properties for both.
Synchronization marker: a subscription opened with synchronize emits one
StreamSynchronized envelope after the replay drain and before the first live
event. Its id and lastEventId are the replay cursor, so a client that resumes
from the last id it saw neither skips nor repeats an event. The RPC
session.events stream always asks for it; in-process consumers that await a
specific event do not. The marker is never stored: both event stores reject it
in append. The TUI feed reads it as the end of replay and keeps it out of its
duplicate set, since it shares an id with the last replayed event.
One interaction primitive: ctx.Interaction.approve({ text, metadata? }) → { approved, notes?, editedContent? }.
Tools that need human input call ctx.Interaction.approve(), which delegates to
ApprovalService. The turn parks without keeping a blocked tool fiber. Cold
replay also requires a trusted, unchanged saved tool binding.
Whether a turn can ask comes from its session and its origin, not from a
stored flag (turnCanAsk in domain/message.ts): a turn can ask unless its
session was spawned and no client opened it. A spawned session
(isSpawnedSession) has a parent and starts its own thread: a delegate child
or a /btw fork. A handoff has a parent too, but it joins the parent's
thread, so it is the user's own conversation; spawn depth counts the same
rule. Deleting a session deletes the same way (deleteSession): the session,
what it spawned and each spawn's own handoffs go; a handoff that continues the
deleted session's thread stays, detached from its parent, and its runtime is
not stopped. Child sessions stored before bded8dce carry their parent's
thread, so they read as handoffs: they ask, do not count toward spawn depth,
and survive a parent delete (accepted in the pass-10 ledger). A top-level or handoff session's user watches every turn there, so its
wake, monitor, delegate-completion and slash-command turns ask. In a spawned
session, only a turn a client opened asks (a user who prompts or steers the
child); a turn its parent's delegate.start or session.send, a wake or a
monitor opened declines. btw opens each fork turn with Session.send, so a
fork turn declines too: the /btw pane shows the fork's reply, not its
approvals. The origin is trusted: the server stamps
metadata.fromClient on every message a client sends (message.send, a
session's initial prompt, a steer.command interjection) over whatever the
client set, and removes a client-supplied extensionId; an extension's
Session.send stamps its own id and removes fromClient. One exception keeps
a slash command a user types in a spawned child able to ask: while a client's
extension request runs, a message it sends to the request's own branch keeps
the client origin. A send to any other branch, or one made after the request
ended, is an extension send. Every extension gets the same rule. Neither
boundary passes the loop's marks: joinedTurn and a runtime custom type
(continuation, steering, ...) would make recovery skip the turn, so both
are removed; an extension keeps its own custom types, a client sets none. A child row stored
before the stamp existed has no origin, so its turn declines on recovery. A
declined turn's approve answers at once, and the tools that ask the user are
withheld. The loop reads the fact from the turn's opening message and the
stored session, so it survives a restart. The decline's notes say to report
what the call asked for the way the turn reports its result (a child's task
turn: its reply, which its completion carries; a later turn: session.send),
and that no message can grant it: the reader acts on the report, or a user
prompts the session directly.
An inner call of a dispatching tool (a cell) is the exception: its dispatcher
cannot replay its source, so the call waits for its answer in place through the
InteractionOwnership seam, and the other inner calls keep running. The turn
stays Running while the dialog is open. A native call that asks while such a
call's question is open waits for the slot, then asks its own question. A call
that asks after a sibling parked with an answered question parks behind that
answer; the sibling takes it when the step runs again. A source-only tool
without durable identity can resume in the same loaded generation, but not after
an unsupported restart or replacement.
native call: tool calls ctx.Interaction.approve({ text, metadata? })
→ ApprovalService.present() checks for a stored answer (cold resume)
→ if found: takes it, returns { approved, notes?, editedContent? }
→ if not: persists to InteractionStorage, publishes InteractionPresented
→ InteractionPendingError thrown
→ machine parks in WaitingForInteraction (cold, no turn fiber)
inner call of a cell (owned ask: `CurrentInteractionOwner` provided, which
the interaction service reads itself)
→ waits for the slot while the open request's owner still runs
(refused with InteractionSlotBusyError when that owner parked: it would
never free the slot)
→ persists through InteractionOwnership (operation → Waiting), publishes
InteractionPresented
→ waits in place for the answer; the turn stays Running, siblings run on
→ answer: InteractionOwnership.take (operation → Started), request settles,
the cell code gets the answer and continues
client responds via respondInteraction RPC
→ storeResolution(branch, requestId, { approved, notes?, editedContent? })
→ first answer wins: storage keeps it with one conditional UPDATE and
memory keeps the same rule; the same answer again stores nothing, a
different one fails with InteractionDecisionConflictError
→ the same answer again while it is stored and not yet taken wakes the
loop and publishes InteractionResolved again (a first attempt may have
failed after the store); once taken, it does nothing
→ a request the branch does not show and that keeps no answer (a wrong
id, another branch's request, one closed without an answer) fails with
InteractionRequestMismatchError
→ parked native call: machine receives InteractionResponded
→ WaitingForInteraction → ExecutingTools
→ tool re-runs, calls ctx.Interaction.approve(), takes the answer
→ owned call waiting in place: takes the answer at once
cancel while an owned call waits: the cell cancels, its call ends, the turn
ends and dismisses the dialog (InteractionResolved dismissed: true)
loop close (server stop) while an owned call waits: the turn is interrupted,
then BranchToolWork.stop ends the cell and records nothing, as a crash
would; after a restart the request is rehydrated, the turn resumes on it
(cell recovery suspends), and an answer runs the waiting operation once;
the cell then reports its worker state as lost
Event-driven UI. The @gent/interaction-tools extension emits typed interaction events (InteractionPresented and friends on the session stream) and the client renders those directly. The source of truth is the storage row plus the durable interaction events (derive-do-not-create-states).
Key properties:
- No blocked fiber for a native call.
WaitingForInteractionis a cold state — no background turn work. The machine is checkpointed and survives restarts. Only an owned call (a cell's inner call) waits in place, because its dispatcher cannot replay its source. - The first answer wins.
storeResolutionis the one check on a reply.InteractionStorage.decidestores only when the row has no answer, and memory keeps the same rule. A retried reply with the same answer succeeds. While the answer is stored and not yet taken, the retry wakes the loop and publishesInteractionResolvedagain, because the first attempt may have failed after the store and the wake is idempotent by request id. After the call took the answer, the retry does nothing (the socket client retries transient errors); a different one fails withInteractionDecisionConflictError, so a late approval cannot flip a decline. A reply to a request that closed with no answer is refused. A reply reaches only the branch it names:InteractionStorage.decidewrites and reads back only that session branch's row, so another branch's request, open or answered, is refused as a mismatch. - Crash-safe resume.
rehydrate()rebuilds the in-memory context lookup and re-publishes the event. If the process dies before wake,listOpen()inInteractionStorageprovides the open requests for recovery: pending ones, andtakenones whose call still keeps the answer. - An answer goes to its owner. The owner of a request is the tool call that asked and the index of that ask in the call's run; the row stores both (
owner_tool_call_id,owner_occurrence, nullable for older rows). A branch shows one request at a time. Other owners queue in the order they asked, and a call that asks the same question never takes another call's answer. An answer whose owner ends its run without taking it is settled as abandoned, so the next owner asks. A dispatching tool's inner call (a cell) waits for the slot while the open request's owner still runs, and is refused when that owner parked; after a crash it resumes by its request id. An answer matches its question as well as its owner; a changed question asks again, for a dispatching owner too. A call keeps the answers it took (row statustaken) until it ends, so a call that asks twice takes both, also across a restart. Only a tool call the loop runs can ask natively; an ask with no call and no dispatching owner is refused. - A request lives no longer than its turn. A turn that ends without parking settles its open request and its kept answers, and publishes
InteractionResolvedwithdismissed: truefor a dialog nobody answered. A cancel sets the turn's interrupt latch even while the loop is parked, and an answer that arrived while a sibling call still ran resumes the turn as soon as it parks. - Exact replay. Resume uses the saved assistant message, call ID, input, and binding. Completed sibling results are reused with their structured values. Only unfinished calls execute again. A pending call can repeat work before its interaction point; authors must make that work safe to repeat. This is not an exactly-once guarantee for arbitrary external effects.
- Invalid replay. Changed bindings and corrupt completed-result data fail explicitly. A paired failed result keeps later model turns usable. A corrupt completed result does not give permission to repeat the tool.
- One binding policy.
runtime/tools.tsowns current capture, durable lookup, identity checks, and invalid local-binding cleanup for native and direct tool adapters. A missing durable row permits only a same-process, same-generation capability with no durable identity. A local copy cannot replace a missing durable row. Dynamic durable markers cannot replay. Each adapter owns result persistence and interaction handling.resolveStoredToolBindingvalidates an already-owned durable identity without reading an assistant-message binding row. Native replay uses this same check. Inner-operation storage can use it without synthetic transcript tool calls. Its caller must verify receipt ownership. - No permission rules. A tool call that asks the user asks once through the durable approval request (
ApprovalService); the answer is not saved, and a request with no answerer fails closed. Core has no rule schema, no rule storage, and nopermission.*RPC.
Files: domain/interaction.ts (InteractionPendingError, makeInteractionService), runtime/extension-host.ts (ApprovalService), storage/storage.ts (InteractionStorage, the pending read seam), domain/agent-loop.ts (WaitingForInteraction), runtime/agent-loop.ts (respond orchestration).
Core runtime should not reach for ambient process state unless the app shell is the real owner.
The Bun cell implementation in packages/extensions/src/cell.ts is the shipped model
execution surface. Its extension section registers the @gent/cell tool and selects it
through the ordinary turnProjection hook. ToolPolicyFragment.modelSet narrows
the final admitted host tools for model calls. The last explicit set wins; an
empty set advertises no tools. It cannot restore unknown, denied, or filtered
interactive tools. Without a set, the model receives the admitted tools directly.
The cell extension also renders its catalog through systemPrompt, whose
hostTools input contains the admitted host bindings' capabilities. getToolPrompt
exposes catalog text without the private execution metadata. Projection hooks see
the dispatched agent (config agents[name] and run overrides applied) with its
own driver; a config driverOverrides entry routes the model call only. .gent/config.json agents
reshapes an agent per name (modelId, reasoningEffort, contextLength, tool lists, prompt
addendum); project entries shadow user entries and a run's RunSpec.overrides
shadows both, so a workspace pins its orchestrator model under main and its
children's model under delegate, and a delegate.start call can still pick a
different model and effort for one child. Each turn reads the config files as
they are then, so an edit reaches the next turn without a restart. The loop has no cell name rule
for selection or allow lists. The server root still composes the extension before
the extension package builtins; its branch lifetime and worker build still belong
to core. Test presets that exercise host tools directly omit it.
The kernel section of cell.ts owns serialized evaluate/reset operations,
evaluation deadlines, host-call dispatch, and worker disposal. Each evaluation
receives its host service from the caller's Effect context. Worker faults lose
working state; ordinary cell errors preserve it. No cell is automatically replayed.
The process section of cell.ts owns the worker process and its bounded pipes. The worker runs
with the host's working directory, environment, and OS permissions, the same
authority the bash tool grants, so cells use the full Bun runtime, require,
and dynamic import directly. Protocol frames travel on dedicated descriptors
(3 worker to host, 4 host to worker); the worker keeps stdout and stderr for cell
output. Each Evaluate carries an unpredictable output token; after the cell the
worker writes an end-of-cell marker carrying that token to both streams and only
then sends the result frame. The host returns the cell once both marks arrived,
so the text before them belongs to that cell in full. A marker with any other
token is ordinary text. Each stream is complete and ordered on its own; stdout
and stderr are not ordered against each other. Writes after the marks (such as a
lingering child process) are dropped when the next Evaluate is sent, and writes
after that belong to the next cell. The output returns
ahead of the cell's display, as Prime Agent's kernel does; a bounded prefix also
serves as diagnostics when the worker fails. Cells evaluate in the worker's
own realm, so the process is the isolation unit. This is not
a second agent engine or persistence owner. After a fault, only explicit reset
can replace the worker. Each kernel permits three replacement attempts by
default, including failed starts. Close cancels active work and waits for its
cleanup. The Gent policy bridge and other platform isolation remain unfinished.
The @gent/extensions build compiles src/cell-worker-boundary.ts into dist/gent-cell.
Turbo builds that declared dependency before the TUI copies the worker into
bin/gent-cell beside bin/gent. @gent/extensions owns the worker build; the TUI only
packages it. The worker embeds Bun and needs no external Bun executable. Its
compile options disable automatic dotenv, bunfig, tsconfig, and package.json
loading. The process launcher uses this artifact as both its runtime
and worker path. Turbo caches core's dist output and both TUI binaries. The
TUI task hashes its build script. Run the root build for dependency ordering.
This is a packaged worker, not a daemon or a new session owner.
The cell owns where its worker lives: cellWorkerLaunch in cell.ts selects it
without opening it. The compiled host runs its sibling gent-cell. A source run
executes this checkout's src/cell-worker-boundary.ts with the running Bun, so it
never launches a stale built worker and needs no build first. The two are the
Compiled and Script cases of CellWorker. A script worker starts with
--config=/dev/null --no-env-file, the source-run match for the compiled
worker's disabled bunfig and dotenv autoload, so a project preload or .env
never runs inside the worker. The worker starts in its session's working
directory: the loop resolves it once per branch with sessionWorkingDirectory
(the stored session cwd, else the host's, the same rule as
ExtensionContext.cwd) and gives it to the branch-tool layer as cwd. The TUI
build sets the compiled-host marker __GENT_COMPILED__ explicitly.
The actor section of runtime/agent-loop.ts allocates a child of the actor scope for each loop rebuild.
It publishes the loop handle before it transfers scope ownership. Failure or
interruption during construction closes that child immediately. A build that
starts cleanly forks the extensions' loopOpen hooks into the loop scope, after
the recovered turn (if any) started; closing the loop interrupts them. Loops are
lazy: no startup pass rebuilds them, so a session nobody opens runs no
loopOpen until a client or another loop reaches it (a snapshot is enough).
The loop behavior in runtime/agent-loop.ts uses this supplied scope to build CellExecution.Branch
and supplies the service to turn execution. Each branch owns a separate service and lazy
worker. Closing the loop scope closes that worker. Source runs have no
build artifact, so builtin tools carry no durable identity there; cells record a
ProcessLocal binding that names the live resource generation instead. Such an
operation resumes only inside that generation and is rejected with
SourceMismatch after a restart or replacement. Compiled hosts keep durable
artifact identities. The existing interrupt
command now also calls the branch cell service's cancellation operation. It
signals active evaluation and waits for cleanup. Cells queued before cancellation
cannot evaluate; the branch interrupt flag also stops later calls in that turn.
Inner calls a cell admits publish the ordinary tool events with a
parentToolCallId naming the cell. The operation receipt section of cell.ts attaches compact
receipts (tool, outcome, summary) to the saved cell result whenever a cell
made inner calls, so the transcript keeps effects visible after reload. A
receipt summary, like the summary on a terminal tool event, comes from the
tool's optional summary(input, output) over wire values when the tool has one
(read, write, edit, grep and bash do); otherwise, or when it throws, the head of
the output. Recovery resolves each completed operation's recorded binding the
way a resume does; a binding that no longer resolves keeps the head of the
output. The TUI
nests live inner calls under the cell, counts them in the compact tree, and shows
receipts in the cell renderer. The headless runner indents nested calls, and
declines every interaction unless --approve-all is set (apps/tui/AGENTS.md).
When policy selects cell for a native model turn, only cell is advertised.
ResolvedTurnContext keeps separate model and host binding maps. Both derive from
the same policy result. The full host map supplies cell callbacks and recovery;
the outer map cannot directly dispatch unadvertised host tools. Tool discovery
returns the selected declaration's input schema and usage guidelines. External
drivers and turns that do not select cell keep their existing tool surface.
Cancellation saves a failed outer receipt without replaying source. An active
worker loses state and requires explicit reset; a cell stopped before evaluation
reports that it did not start. The steering RPC still acknowledges durable
delivery, not completed cancellation. Clients observe completion through events.
The execution storage section of cell.ts provides outer cell admission in the existing
SQLite database. A claim must refer to one cell call in an assistant message
owned by the current workspace, session, and branch. Only the first claim returns
the stored source for evaluation. Repeat claims return the saved tool result or
Incomplete; that state does not prove a live worker and never permits another
evaluation. Results are immutable. Receipts cascade with their assistant message.
Admission rejects a caller-owned SQL transaction so its claim commits before
external effects start. Cell execution must remain outside SQL transactions.
cell.ts joins this store to the real kernel. Each
layer fixes one session and branch. It serializes admission, evaluation, result
storage, and reset. It opens a worker only after a fresh claim. Saved results and
incomplete claims do not start a worker. Initial startup failures consume the
same bounded replacement budget as later worker failures. Interruption leaves
the claim incomplete; the next call reports a typed unknown outcome without
running the source again. A saved result does not restore VM working state.
Inner operation bindings and durable approvals use the stores described below.
The namespace section of cell.ts keeps the top-level bindings of the last good
cell per branch (cell_namespaces) and restores them into a replacement worker;
the first result after a restore names what came back and what was omitted.
A binding is a global the cell added, or a worker global it rebound, deleted or
redefined with other flags (the worker keeps each global's starting descriptor
and compares value, accessors and flags), so prompt or performance declared
by a cell is saved, reported and reset like a new name. tools and context
are the host's: the worker installs them once per realm as accessors that are
not configurable, with a setter that throws, so a cell that declares, assigns,
deletes or redefines either fails or is refused, and neither can be lost. A
reset puts every global back as the worker found it; the Reset reply names
(additive, optional unrestored) any global the realm refused to remove or
put back, such as one a cell made not configurable, and the kernel then
replaces the worker, so a reset never leaves a global behind.
The worker reads each binding from its property descriptor, so a snapshot
never calls a global accessor (it is omitted as a function). The snapshot
encoder (with its tagged-JSON codec), the error renderer and the value display
live in cell-value.ts and share one value reader that never runs cell code
attached to a value: it reads data descriptors, runs only host getters (those
of every global error type, taken at load, compared by identity), never
touches a Proxy (Bun's util.types.isProxy), reads built-ins by brand
(util.types) and reads their slots through getters saved at load. A value
it cannot read that way (a cell getter, a Proxy, an object where a string
belongs) is omitted as unsupported; the worker and the rest of the namespace
stay. The worker runs in full Bun in the host's realm, so a cell can also
replace a built-in the worker itself calls (Map.prototype.set,
Object.prototype.toJSON, the global Reflect or Symbol, an iterator
prototype). The worker does not defend each call against that. At load it
takes a baseline of the built-ins' descriptors: the ECMAScript constructors
and namespaces on globalThis with TextEncoder and TextDecoder, their
prototypes, and the intrinsics only syntax reaches (%TypedArray%, the iterator
prototypes, the generator prototypes). putBackBuiltins compares every
descriptor, prototype and extensibility with that baseline and puts back what
changed. The Effect runtime calls a promise's then and Array push and
pop as soon as cell code returns, so the put-back runs in the same turn as
the cell's return or throw, inside the evaluation thunk. A cell's promise is
awaited only when util.types.isPromise says the realm made it, through the
then saved at load (whenSettled), and its callbacks put back before the
fiber resumes. The put-back runs again before the display and the snapshot,
as a timer the cell left can change a built-in between cells, and in the
process's uncaught handler and the report, before an error is queued and
rendered. The cell's result names what was put back. A change the realm
refuses to undo (a built-in a cell made not configurable, an object it froze)
goes back as unrestored (additive, optional, on the Evaluated, Failed,
Snapshot and Reset frames): the kernel then replaces the worker, and the next
cell restores the namespace saved before it. A snapshot that fails for any
reason marks the kernel for that same recovery (recoveryPending), and the
host adds that failure to the cell's display: what the cell bound is not
kept. The encoder stops a
binding at a running UTF-8 byte budget: each value charges at least its
encoded size, a string longer than the budget is refused uncounted, and the
exact size comes from Buffer.byteLength saved at load, so a 5 MB string or
a 100 000-item array is omitted as too-large without being read to its end.
A result's bindings names only the bindings its cell added or bound to
another value (compared by Object.is), and bindingCount (additive,
optional) counts the whole namespace: every result stays in the history, so a
full list on each would grow with cells times bindings. A result stored before
bindingCount lists every binding. A thrown value renders through the same
reader, one part at a time: its name and message, its detail (each inner
BuildMessage of a split syntax error keeps its position), and its cause. A
part that cannot be read gives a fixed text in its place and the other parts
stand; a thrown value whose prototype chain holds a Proxy gives one fixed
text. The display of a logged or returned value, a thrown non-Error, a cause
and an uncaught report (displayValue) follows the shape of util.inspect
(depth: 4, 100 items, 8192 characters, no custom inspection) through the
reader, with a plainer layout: strings always take single quotes and are not
split at newlines, and a list packs its items onto lines of at most 80
characters instead of aligned columns. Where inspect would run cell code it
shows a marker instead: [Proxy] for a Proxy, [Object: unreadable prototype] for a Proxy on the prototype chain, [Getter] for an accessor,
and it never reads a Symbol.toStringTag getter.
A nested error shows as [Name: message] without its stack; an object lists
at most 100 properties, and one display formats at most 20 000 values.
The first kernel start of a branch with no saved namespace fixes its starting
namespace in its own row. For the opening branch (the oldest) of a handoff
session (a parent, not spawned: isSpawnedSession), that is a copy of the one
its parent saved on parentBranchId, and the report carries
previousSession; for every other branch it is empty. The copy is one hop and
happens once: neither side sees the other's later writes, and a parent that
saves only later is not inherited. The cell reads the session and its
branches through ExtensionContext.Session (getSession, listBranches). A
worker is kept only after its restore succeeds; a failed restore closes it,
and the next cell starts a clean one. A delegate child or /btw fork starts
empty. A reset saves an empty namespace, so a later restart inherits nothing.
runtime/tools.ts carries the transcript-owned call address
and the turn's selected tool bindings.
The shared turn dispatcher supplies it for each bound tool invocation, including
explicit tool invocation. cell.ts uses that address
and the current turn profile to enter branch-owned cell execution.
It does not search messages or accept a call address from model input. It rejects
an inner host operation that attempts to dispatch another outer cell. Missing
branch ownership or recorded turn context returns an AgentLoopError, not a
missing-service defect. The RPC test catches a nested-dispatch rejection inside
the worker and verifies that the attempted source did not change retained state.
The RPC lifetime test uses this adapter and checks two identical sources in one model
response. Each source runs once and receives a distinct result receipt.
cell.ts declares the cell tool and is exercised by
this RPC test. It returns saved
JSON success data or fails with the public ToolResultFailure type. The normal
tool runner preserves that failure's JSON data and still supplies transcript
identity itself. The type cannot select a call ID or tool name. Permission checks
and preflight hooks still run before execution. An inner call that asks waits
for its answer in place: sibling calls keep running, and the cell code continues
with the approved result or the declined failure. The approval RPC tests check
allow, deny, a sibling that finishes while the dialog is open, and a native call
beside the cell, through the real compiled worker. Only a lost worker leaves an
operation waiting; recovery then returns the saved operation results in a
failed outer result with visible worker state loss.
The input schema in cell.ts accepts optional reset: true. Admission
reads this flag from the stored assistant call. After a fresh claim commits, the
branch execution permit covers reset and evaluation together. Completed or
incomplete calls do not reset the worker. A repeated reset call returns its saved
result without clearing newer working state. Cancellation checks run before reset.
Reset uses the existing worker reset and bounded replacement path; it does not
replay old source or erase operation receipts.
A cell script can finish before its host calls settle: a rejected
Promise.all leaves the other calls in flight. The worker ends the cell only
after the last pending call has a reply, then reports the script's own result,
so the host never sees a cell end with orphaned calls and never tears down
work those calls admitted.
Fresh cell host calls select only from the supplied turn bindings. They do not
search the full extension registry by name. Thus an agent-denied tool cannot be
reached through a cell, and a newly registered replacement cannot replace the
captured capability. The host still enters the current turn profile and checks
permissions before execution. The RPC lifetime test verifies a registered but
agent-denied tool receives no calls. Catalog discovery must use this selected
set. Default cutover must retain a host binding set when the advertised model
surface narrows to cell; it cannot reuse a cell-only advertised map for discovery.
The catalog is instruction plus data, not a tool. When the surface narrows to
cell, the cell extension's systemPrompt hook adds a ## Host Tools section
with one signature line per selected host tool: its callable path, an input
type and a result type rendered from the JSON Schema, and the first line of its
prompt snippet or description (- tools.wake.cancel(input?: { wakeId?: string }): Promise<{ cancelled: string[] }> // Cancel a pending alarm ...). Nested objects
inline while short and otherwise render as object. The section is rebuilt
each turn, so live composition changes reach the model as ordinary instruction
changes. cell.ts builds the data half from the same selected map: name,
description, guidelines, and the actual Effect AI input schema, hashed over its
encoding. dispatchCell hands it to the cell host; the kernel sends it inside
Evaluate only when the hash differs from what the current worker holds, and
clears that memory when a replacement worker starts, so the first cell on a new
worker carries the full catalog. The worker keeps the catalog beside the
namespace: reset clears bindings, not the catalog, and a snapshot never
contains it.
Inside the cell tools is a namespace over that catalog: every selected id is
a callable path, split on . (delegate.start is tools.delegate.start(input),
must-not-run is tools["must-not-run"](input)). Each node is a Proxy that
reads the current catalog, so one node can be a tool and a namespace at once
(tools.wake(...) and tools.wake.cancel(...)). A call sends the id itself as
the host call name, so operation receipts and replay key on the tool id. An
unknown path throws and names the three closest ids. A key JavaScript reads on
its own or defines on a function (then, toJSON, constructor, call,
name, ...; reservedToolSegments in cell-protocol.ts) keeps its JavaScript
meaning and never names a tool, so await, JSON.stringify, and inspection
never call one. tools(id) is the one lookup by string: a local synchronous
read that returns the tool as a function carrying its catalog entry (id,
description, guidelines, parameters). It reaches an id with a reserved
segment, which the prompt renders as tools("read.then")(input); it records
no operation receipt and grants no execution permission. A call with no
argument sends {}; the signature marks input? only when the schema accepts
{}. Enums past eight literals, and input or result types past 300 characters,
render as their outer shape; a test holds every shipped tool's result under
that bound, so the cell code reads each result whole. The RPC lifetime test
checks the namespace keys and a host schema through the compiled worker, and
that a later cell without a catalog still describes the tool. An agent-denied
tool and the outer cell are absent from the namespace.
Tool dispatch accepts separate outer and host binding maps. Normal turns supply
the selected map for both. Recovery validates pending outer calls against their
saved binding identities. If an outer cell was never admitted, recovery also
resolves the current agent policy to obtain its host set. It does not limit that
set to pending outer call names or search the unrestricted registry. If current
policy removes cell, the replay dispatcher receives no outer cell binding and
returns a failure without executing it. Already admitted cells still use saved
operation recovery and never evaluate their source again.
cell.ts adapts one already-bound host call to
ToolRunner.runBound. It does not resolve a missing binding by name. The caller still owns the durable operation
receipt. Execution returns the original tool result.
A separate result conversion runs after persistence and maps failures to cell errors.
An inner call never parks the turn: it waits for its answer in place. A call
that parks anyway fails closed as a cell error. Only recovery suspends: an
operation a lost worker left waiting, with no answer yet, parks the turn on its
request. Durable inner operation resume uses the recorded host below; suspension
never authorizes cell replay.
The tool operation storage section of cell.ts records inner operations under an
admitted outer cell in the same SQLite database. The operation's input, tool
binding, and derived tool-call ID are immutable. Its state is Started, Waiting,
Resuming, or Completed. Repeated admission never grants another execution.
Resume requires the exact waiting request and a valid saved approval decision.
It commits Resuming before tool execution; that state cannot be reclaimed after
a crash. A call that takes its answer (in place, or on resume) returns its
operation to Started before the request settles, so its next ask starts fresh. A unique request link prevents two operations from sharing an approval.
The existing interaction service still owns the request and decision. Completed
cells cannot admit or resume more host effects. Message deletion cascades to
these receipts. Suspension persists a new approval through InteractionStorage
and links its operation in one transaction. It rejects caller transactions and
completed cells. The production approval callback uses this operation when the
host supplies CurrentCellToolOperation. ApprovalService validates that owner and
selects only its saved resume request. A fresh operation cannot consume a branch
decision. The existing one-pending-request-per-branch constraint stays in place.
The cell host supplies this address from admitted operation storage and validates
the recorded binding under its publication lease.
The store does not itself run tools or restore a worker continuation.
cell.ts joins fresh inner-operation admission to
the tool adapter. It enters the existing live turn publication lease, captures
the exact capability, and requires a source identity before admission. It gives
the approval service the host-owned operation address. It saves the original
tool result before returning a JSON value or failure to the cell. Equal completed
calls reuse their receipt; unfinished calls report an unknown outcome and never
run again. resumeCellToolOperation enters the selected live publication, reads
the owned operation, and validates its saved binding with the shared replay
policy before committing one resume attempt. It executes only that inner call
with its saved input and identity, then stores the original result. It never
evaluates outer cell source or restores the worker stack. Concurrent or repeated
resume attempts cannot execute the same waiting operation twice. Initial model
dispatch does not select this host yet. Saved turn recovery does.
CellToolOperationStorage.listForToolCall provides the durable recovery input for
that integration. It checks workspace, session, branch, and the admitted outer
call. It returns all operation receipts without granting another execution.
The branch phase is held in memory; queue storage persists the in-flight item,
not the complete Running state. Recovery must use stored cell receipts rather
than depend on a new field in that transient phase.
cell.ts builds the paired outer result after the
branch owner has stopped cell execution. It preserves undecided approvals,
resumes only waiting operations with saved decisions, and reuses completed
receipts. Started or Resuming operations remain unknown; recovery does not
execute them again. Its saved outer result reports lost worker state and lists
completed tool results separately from unknown operations. Repeated recovery
returns that result. This adapter has no evaluator call. Callers must not run
recovery beside an active cell.
runtime/turn.ts routes admitted saved cell calls through this
adapter before native binding replay. CellExecutionStorage.get reads admission
without creating it. Unadmitted calls keep the native path. Recovery requires a
live publication. A suspended inner operation parks the actor with the outer
cell call ID and the original request ID. After the response, the existing turn
worker resumes recovery. Recovered cell results join native sibling results in
one complete transcript message; no partial message marks the step complete.
The branch runtime context supplies the existing storage services. This does not
add a worker, runtime owner, or model-facing cell dispatch path.
Explicit platform/runtime seams:
GentPlatformowns host capabilities such as process identity, signals, env, executable path, ids, hashing, and OS info. Time comes from Effect'sClock, so a test can drive it.RuntimeEnvironmentcarries launch/session configuration values:cwd,home, and platform name.- tracer/logger services
- file system / path / OS services
File discovery is owned by the @gent/fs-tools extension, not core. packages/extensions/src/fs-tools.ts holds one stateless listFiles function over the platform services and one listing rule: an ignore authority decides which files grep may read. Inside a git work tree the authority is git: git ls-files -z -t --cached --others --exclude-standard below the search path, so every git exclude source applies; a sparse checkout's skip-worktree entries are dropped before the 100,000-file bound, and a name that is not valid UTF-8 is counted in grep's unreadable field. A git that does not answer within 10 seconds fails the search and asks for a narrower path; the walk does not stand in for it, because it misses info/exclude and the global excludes. A tracked path under a directory that is now a symbolic link is not listed. Outside a work tree a FileSystem walk reads each .gitignore from the search root down by gitignore(5); a test checks that matcher against real git. An ignored target named explicitly (dist/) is walked from its own root. No listing follows a symbolic link. grep reads 16 files at a time and reports matches in path order; it decodes a UTF-16 file by its byte order mark, skips binary files (a NUL byte in the first 8 KB), skips and counts files over 10 MB in oversized, and cuts a line over 500 characters around the match without splitting a surrogate pair. read, write, edit and grep decode a file once: a strict decode fails exactly when the bytes are not valid in the file's encoding, and only then does the replacing decode run and the text count as lossy. read streams a UTF-8 file over 4 MB in 1 MB chunks: it counts every line and decodes only the lines it shows, so its lossy covers those lines. read cuts a line over 2,000 characters with a [N chars cut] marker. The listing holds no state, so there is no Tag and no resource. The TUI's @ popup reads the same listing through the read request FilesRpc.List (paths relative to the session cwd, sorted), so a user can name exactly the files the model can search. fff (@ff-labs/fff-bun) ranks them: it scans the session's directory, keeps its own pick frecency under ~/.gent/fff, and the popup keeps only the listed paths, paging through fff's ranking until it holds 50 or has read five pages of 200; when the five pages run out first, the shared autocomplete matcher fills the rest from the listing. The listing and a read in flight are keyed by session, so a switch never ranks the session it left. Where fff cannot run, the shared autocomplete matcher ranks the listing. Core has no file-index concept, and there is no ExtensionContext.Files facet: tools yield FileSystem and Path.
App entrypoints bind concrete Bun/OS behavior:
apps/tui/src/main.tsxapps/server/src/main.ts
Production rule:
apps/tui/src/main.tsxresolves a server viaGent.server()+Gent.client()--connect <url>attaches to a remote server viaGent.client({ url })apps/server/src/main.tsis the standalone durable server boundary
packages/sdk/src/server.ts owns shared-server discovery. Two files sit beside data.db in the data directory (GENT_DATA_DIR, else ~/.gent):
server.lock.dbis the kernel lock. The owning server holds an exclusive SQLite lock on it (BEGIN EXCLUSIVE,busy_timeout0) for the life of its scope. The OS releases it when the process exits. A server is alive exactly when this lock cannot be taken, so a crash, a reboot, or a reused pid cannot leave a live-looking lock, and two concurrent starts give one owner: the other waits for the owner's entry and attaches.server.lockis the discovery entry the owner writes once it listens: url, pid, and the identity tuple. Clients attach only after/_gent/identityconfirms the full tuple. An entry whose endpoint confirms the tuple counts as alive even when the kernel lock is free (a server from before the kernel lock), and a start probes it after it takes the lock, before it replaces the entry.gent server stopsends SIGTERM only after the same probe;--allremoves an entry whose kernel lock is free and whose endpoint does not answer, and holds the kernel lock through that removal so a new owner's entry is never deleted.
A start that finds a confirmed server of another build on the database fails with a message that names its pid; it never signals it. gent server stop is the explicit way to stop it.
A fixed port (apps/server, GENT_PORT) changes only the attach decision. A SQLite server on a fixed port still takes the kernel lock and writes its entry, so the TUI finds and attaches to it; it never attaches to another server itself, and fails with the holder's pid when the database is owned. The standalone server runs until a signal stops it: there is no idle shutdown and no shared launch mode.
packages/sdk/src/server.ts resolves SQLite-backed clients through this single shared server record. Workspace isolation comes from the x-gent-workspace-id RPC header and workspace-prefixed AgentLoop actor entity IDs, not from per-workspace server processes.
The old SDK worker supervisor and worker-http transport are deleted. E2E coverage that needs process boundaries uses focused server-process fixtures; transport contract tests run through the in-process direct transport.
TUI is a client over the shared contract, not a parallel app.
The session feed uses the durable input-message and step identity for each assistant message. The protocol exports the shared answer-ID rule. Stream chunks update that message only. Tool events locate their owning message by call or assistant ID, including late child results. Historical streams without IDs receive one local ID per stream.
The native transcript sends whole completed items to terminal scrollback when the live view overflows. An item stays entirely in scrollback or entirely in the live view; the TUI does not split it by row. The session feed retains the data for disclosure and resize replay. User messages use an OpenTUI heavy left border, so snapshot layout does not depend on a later height callback. Streaming still stays in the live view until the turn settles. Incremental one-shot output remains separate work.
Production shape:
- local mode: one process owns renderer, runtime, storage, and reconnect UX under one root scope
- remote mode: TUI shell attaches to an external server boundary and rehydrates from transport state
- reconnect logic rehydrates from runtime state, not UI guesses
Main boundaries:
apps/tui/src/client.tsxfor client/session/event stateapps/tui/src/session.tsxfor session-screen orchestrationapps/tui/src/extensions/client-facets.tsfor TUI-owned extension facets- route state machines for modal/session surfaces
- components like
composer.tsxandmessage-list.tsxas presentation + local interaction
Rules:
- one screen-level owner for session state
- one keyboard owner per route surface
- overlays/composer flows modeled explicitly
- renderer tests cover critical capture/focus paths
Extension shape lives in:
packages/core/src/extensions/api.ts— public authoring surface (defineExtension+ smart-constructor re-exports)packages/core/src/domain/extension.ts—ExtensionContributionstyped-bucket carrier (core primitives only)packages/core/src/domain/extension.ts— server contract (GentExtension,ExtensionSetupServices)apps/tui/src/extensions.ts— public client authoring surface (@gent/tui/extensions); every shipped client extension imports the TUI only through itapps/tui/src/extensions/client-facets.ts— TUI-owned client facet modelpackages/core/src/runtime/extension-host.ts— server registrypackages/extensions/src/— shipped extension implementationsapps/tui/src/extensions/— TUI discovery, loading, resolution
apps/tui, apps/server, packages/sdk
↓ ↓ ↓
@gent/extensions → @gent/core
(no reverse dep)
Core never imports from extensions. Composition roots (apps, SDK) pass BuiltinExtensions into DependenciesConfig.extensions.
Extensions may import from:
@gent/core/extensions/api— the authoring surface@gent/core/extensions/branch-tools— the branch-tool entryeffect,effect/*,@effect/*— as peer deps. A user or project extension run by the compiled binary resolves only the ones listed below.
Extensions may NOT import Gent domain, runtime, storage, server, or provider
internals, nor the @gent/core/host and @gent/core/test-utils entries. The
core-entry-boundary oxlint rule enforces this for shipped and reference
extensions, and the same rule defines the contract for user/project
extensions. The same rule keeps @gent/core/test-utils out of product code:
only tests, packages/e2e/, and the harness itself read it, by package
specifier or by a relative path that resolves into it. Nothing outside
packages/core/, tests included, reads core source by relative path. A test
that needs host state arranges it through a test-utils operation. A host
name needs a product caller. "Builtin" means "included in the default distribution", not
privileged.
TUI client extensions may also import shared client data from @gent/core/protocol. This exception does not apply to server extension implementations or to nested protocol paths.
The loaders enforce the same contract at runtime. The compiled binary has no
node_modules and runs with --no-install, so a user or project extension
resolves only a specifier a loader binds to the module the process already
runs (GentPlatform.bindModules, a Bun runtime plugin). It gets the same Tags
and Schema classes as a shipped extension, and an unbound specifier is never
fetched from npm. The bound specifiers are exact:
- Every extension file:
@gent/core/extensions/api,@gent/core/extensions/branch-toolsandeffect, bound by the server loader (extensionEntryModules,runtime/extension-host.ts) and by the TUI loader. - The peers the shipped extensions import:
BuiltinExtensionModulesin@gent/extensions, bound by the SDK server root before it loads extensions. It holds eacheffect/*and@effect/*specifier thatpackages/extensions/src/orexamples/extensions/imports, and no other.packages/extensions/tests/index.test.tsderives that set from the sources and fails when the map differs, so a shipped extension never reads a module a user extension cannot. A user extension is as capable as a shipped one. - Client files only:
@gent/core/protocol,@gent/tui/extensions,@gent/extensions/client(the shipped extensions' RPCs, ids and message types),solid-js,solid-js/storeand@opentui/solid. A test inapps/tui/tests/extensions/loader-boundary.test.tsreads every@gent/*specifier the shipped client files import and fails on one the loader does not bind, so a shipped client never reads a module a user client cannot. Bun resolves a bare import from a runtime plugin without the importer, so these are bound under a prefix drawn for each TUI load. The TUI loader compiles each*.client.*file withBun.buildand the OpenTUI Solid transform, and renames these imports to the prefixed names in the file, in the relative modules it imports, and in the packages it bundles. A server extension in the same process imports@gent/core/protocoland gets "Cannot find package".
Nothing else resolves, internal entries such as @gent/core/host included.
@gent/core/extensions/api is the extension API. Anything an extension needs
must either live here as a stable authoring primitive or move behind a
host-owned design. It should expose:
- extension shape:
defineExtension,GentExtension,ExtensionHost; - typed leaves:
tool,request,defineRequests,ref; - scoped resources:
defineResource; - turn hooks: the public hook input/output types needed to author
host.on(kind, handler); - agents and model ids:
AgentDefinition,AgentName,ModelId, run-spec helpers needed for turn-scoped subagent dispatch; - stable ids and author-facing schemas:
ExtensionId,ToolCallId, output/message projection helpers that are safe to serialize across the extension boundary; - host facts:
ExtensionHost.host, a small public view over host-owned platform facts such as OS info, executable path, and home directory; runProcess/ProcessError: the one command helper over the EffectChildProcessSpawner;- author-facing errors: capability, provider-auth, agent-run, and typed transition errors that extension code can intentionally return or inspect.
Everything else is builtin/internal:
- raw runtime host context and hook plumbing (
ExtensionHostContext, permission/context message internals); - storage, event publisher, event store, session mutation services, and interaction pending readers;
- runtime/platform services (
GentPlatform,ToolRunner); - agent loop/session runtime internals and process runners that are only host implementation details;
- raw event/message domain internals that are not part of the serialized authoring contract;
- the extension registry's driver maps and provider auth persistence machinery;
- test-only helpers such as raw metadata tags and fixture constructors.
Rules:
- registration shape is structural — builtins, user, and project extensions share the same setup path
- builtins are only the initial extension set — app code consumes registry or
transport projections instead of privileged
@gent/extensionsregistries - dispatch compiles once, then runs from typed registries and explicit runtime slots
- public snapshot schema is enforced at runtime — invalid snapshots are dropped, not passed through
- declaration setup, validation, and process-resource build failures exclude the affected extension; a branch-resource build failure fails its loop
- stateful side effects cross explicit typed slots (
host.onhooks, resources, or extension-owned services), not private host imports
For the full authoring guide, see docs/extensions.md. Example extensions in examples/extensions/.
One authoring shape: defineExtension({ id, setup }). setup is an Effect that yields ExtensionHost (packages/core/src/domain/extension.ts) and calls host.register(domain, ...values) for leaves (tool, request, resource, agent, modelDriver) and host.on(kind, handler) for hooks. Setup-time host facts (cwd, home, host) live on the same service; runtime host authority comes from yield* ExtensionContext. The domain string IS the discriminator — TypeScript checks the value type per domain at the call site. The loader (runtime/extension-host.ts) provides a collecting host, seals the registrations into ExtensionContributions, binds requests to the extension id, and runs validateExtensionPackage so malformed registrations fail activation instead of dispatch.
There is no flat Contribution[] and no _kind discriminator. ExtensionContributions (packages/core/src/domain/extension.ts) is the compiled record consumed by the registry, hook compiler, and profile build; adding a new kind means adding a registration domain and a record field, not a new union arm. Each extension's process resources build once into their own child of the profile scope, which owns acquisition and release.
- Resource —
defineResource({ id, scope, layer }). Start work runs in the layer build and disposal is a finalizer in it. Long-lived state has a stable identity and explicitscope; resources build in extension resolution order.scopeis"process"(built once per profile, released when the profile scope closes) or"branch"(built per branch loop, released when the loop closes). Stateful extension logic is either a normal scoped service/resource or, for true actor protocols, an Effect Entity/RPC owner at the runtime boundary. Seepackages/core/src/domain/extension.tsandbuildScopeResourcesinruntime/extension-host.ts. - Callable leaves —
tool(...)/request(...)smart constructors registered under thetoolandrequestdomains.tool= model-facing tool;request= typed extension RPC, optionally decorated withslash: { trigger?, name, description, category?, keybind? }to surface as a human slash command. Handlers receive input only. Host authority comes from theExtensionContextfacade (Session,Interaction,FileLock,State); files, paths, processes, and ids come from the Effect platform services (FileSystem,Path,ChildProcessSpawner,Crypto); extension-private authority comes from extension-owned Effect service Tags. TheFileLockfacet wraps the host-internalFileLockService, and theStatefacet publishesExtensionStateChangedonEventStore, so shipped and external extensions share the same surface. Seepackages/core/src/domain/capability.ts;runtime/extension-host.tscompiles the model, RPC, and slash registries. - Addressed session verbs —
ExtensionContext.Sessionreaches other branches through the same verbs the server uses:create(durable-once byrequestId, optionalhistoryBranchIdcopies the visible rows in, depth admitted by the host),send(one user message with adeliverymode:"turn"starts a turn on another branch with the loopcompletionmodes, and the own branch refuses it and points at"queue";"queue"is a follow-up keyed bysourceId;"steer"joins the running turn as anInterject),stop(writes aCancelsteer that stops the running turn, whichever message opened it;Interrupthas no writer and only decodes),stopMessage(the persistedStopMessageactor operation, throughstopMessageOn: it records the durable cancellation of one message id, so a turn that message opens later starts interrupted, takes back a steer with that id that no step has read, and interrupts the running turn that message opened. A step's join of steers and the take-back hold the same queue permit, so a message is either joined or taken back, never both. It answers true only when this stop reached the message; false when the loop no longer holds it (its turn ended, or a step joined it into a turn another message opened, which runs on) or an earlier interrupt already stops its turn. A steer taken back answers true, unless an earlier stop from the same requesting branch already stops the turn the steer waited to join: the turn's latch records the requester of its first stop, and that stop's caller already reports the branch. A stop that reaches a running turn also takes back, under the interrupt permit, every waiting steer its own requesting branch sent (thesendera loop records on a steer into another branch'sSteeroperation and queue item, never a client): the turn's end cannot hand such a steer on as the next turn, so a later stop of that steer reaches nothing and the branch is reported once, however late that stop comes. ACancelsteer that names amessageIdstill decodes and runs the same routine),events(replay, then theStreamSynchronizedmarker, then live;from: "now"skips the replay and starts at the newest stored event),delete(cascade),dequeueFollowUp, andholdResident(a scoped hold that keeps the own branch's loop resident; see Residency).sendandstopare a facade over the unchanged actor operationsSubmitDurable,QueueFollowUp, andSteer, except that aqueueorsteerinto the loop's own branch is admitted re-entrantly inside the caller: a client request's grant is read at admission, while the request provably runs, and a send from a context kept past it is an extension send; the rawSteerCommandstays on the RPC contract, not in the extension API. The bodies live in theagent-loop.clientsection ofpackages/core/src/domain/agent-loop.ts; The loop'ssessionControlcalls the queue bodies (queueFollowUpOn,dequeueFollowUpOn), and bothSessionRuntimeandsessionControlcallsubmitUserMessageandsteerLoop, so the facade and the RPC path cannot drift. The verbs are uniform: no extension, shipped or not, holds a grant another lacks.AgentDefinition.maxModelAttempts(and the RunSpec override) is the generic per-turn model-attempt budget, reserved durably per turn message id. - Hooks —
host.on("systemPrompt" | "turnProjection" | "turnAfter" | "loopOpen", handler)registers the four runtime hooks; each kind is typed byExtensionHookSignatures.loopOpentakes no input and runs once each time a branch's loop is built in this process (the first operation after a restart, or after the loop closed), after the loop resumed any turn a restart cut short. It is where an extension repairs what a previous process left on the branch: re-arm timers, resume children, report lost work. It runs as the loop's own fiber, with the branch Resources and a non-client opener (it cannot ask). Nothing in it takes the side-mutation permit, which a running turn holds: the profile resolves under the profile cache's place lock and the Resources build under the branch's own lock, so the hooks run beside a turn the open resumed. Each hook runs on its own fiber, so a hook that never returns delays no turn and no other hook. A follow-up it queues on its own branch starts a turn like any other send, and the operation that opened the loop never waits on it. User extensions register it the same way. Hooks, tools, and requests all cross one membrane:provideExtensionLeaf(frame)inruntime/extension-host.tsreads the run'sCurrentExtensionHostContextand providesExtensionContext;turnProjectionreceives the agent the turn dispatches (TurnProjectionInput). Hook handlers receive event input only and yieldExtensionContextor extension-owned service Tags when they need authority.turnAftercarriesusage: { known, complete }: the tokens of the steps that reported usable counts, and whether that is the whole turn (TurnCompleted.usagecarries a total only when it is complete). Seepackages/core/src/domain/extension.tsandruntime/extension-host.ts. - Driver — the
modelDriverdomain takes aModelDriverContribution. Model drivers provide LLM provider layers + auth and list their own catalog (listModels(auth); core concatenates every driver's list and fetches nothing — the shipped drivers read models.dev through the catalog section ofpackages/extensions/src/providers.ts, cached on disk for a day). An agent'sdriver(or adriverOverridesconfig entry) names a model driver; a stored override that names a removed external (ACP) driver decodes as no override and logs one warning per config file. Seepackages/core/src/domain/driver.ts,domain/agent.ts, andruntime/extension-host.ts.
Other notes:
- Process and branch Resources build through one builder,
buildScopeResourcesinruntime/extension-host.ts: extension by extension in resolution order, each in its own child scope, over the services the extensions before it built. A layer that fails to build closes what it acquired, is logged naming its extension (extension.resource.failed), and leaves every other extension's Resources live. A process Resource that fails rejects its extension: the profile reports it failed at thestartupphase. Branch resources come from the session's profile (the extensions set up for the session's cwd) and build on the loop's first turn or extension request, so a control-plane write never resolves a profile. A branch Resource that fails, or that needs a service a failed one would have built, is named once in the transcript (anErrorOccurrednotice with the extension id) and suspends its extension for that loop the way a failed process Resource does for the profile (suspendExtensions): the loop's turns, requests and hooks read the registry without it, so none of its tools, requests or hooks is offered or dispatched, and the branch's turns run with the others. The turn profile's registry is provided inside the profile's capability context, so the narrowed registry is the one a turn reads. Release runs in reverse build order when the owning scope closes. - Prompt shaping, input normalization, permission policy, and turn hooks are explicit runtime slots compiled from extension hooks and typed leaves, not generic middleware buckets.
- The agent is a session property.
Session.admission(agent, run spec;sessions.admission_json, migration 023) is fixed at creation, and every turn of the session runs under it: the first, a wake, a queued follow-up, a steer that starts a turn, and a recovered turn after a restart.resolveSessionRoute(runtime/turn.ts) resolves it once for the turn, the snapshot (SessionSnapshot.agent) and the auth check. No queue item, steering command, turn record or loop state carries an agent. A handoff (continueThread) keeps its parent's admission unless it names one. A row stored while admission carriedinteractivestill decodes; the key is ignored and dropped on the next write. - Model precedence: the session's
/modelsetting, then the admission's run overrides, then configagents[name], then the agent definition. createSessionaccepts optionalinitialPrompt+admissionfor atomic create-and-send.
Runtime code yields EventStore (domain/event.ts) directly. publish appends an event and delivers the envelope to subscribers; append and deliver are also separate so a mutation can append inside its transaction and deliver after commit. The extension State facet publishes only ExtensionStateChanged on the same store. Publishing is cwd-agnostic; per-cwd extension behavior comes from the turn's profile.
- Builtins live in
apps/tui/src/extensions/builtins.tsx; a builtin with its own view keeps its ownapps/tui/src/extensions/*.client.tsxfile - Each is an
ExtensionClientModulefromdefineClientExtension— same pipeline as user/project extensions - Loader (
apps/tui/src/extensions/loader-boundary.ts) acceptsdisabledlist to filter extensions by id beforesetupruns - Client extensions author against one public entry,
@gent/tui/extensions(apps/tui/src/extensions.ts):defineClientExtension,ClientContext, the contribution constructors,sessionQueryand the rendering kit. A shipped*.client.tsximports the TUI only through that entry. - One
setupshape:Effect<ClientContributions, never, R>. A setup handles its own failures; a defect is recorded as a load failure. Setups yield from the per-providerclientRuntime, which providesFileSystem | Path | ClientContext.ClientContextis the client twin ofExtensionContext: one Tag with thetransport,shell,workspace,lifecycle, andactivityfacets, which a setup yields (const { transport, shell } = yield* ClientContext) and never threads as a parameter. There is no imperativectxargument, no sync(ctx) => Arrayarm, and no package wrapper around paired server/client modules: a server extension and its.client.{ts,tsx}module are separate artifacts that share an extension id. - Widgets are transport-only: subscribe to
transport.onSessionEventfor event-backed invalidation ortransport.onExtensionStateChangedfor explicit extension-state notifications, then call typed extension RPC viatransport.requestfor current state. Each widget owns its own Solid signal, keyed on(sessionId, branchId)so a stale model from the prior session never renders. Seeapps/tui/src/extensions/builtins.tsxfor the canonical pattern. lifecycle.addCleanupregisters SolidcreateRoot(dispose)disposers and event unsubscribes; the provider'sonCleanupreaps them on unmount, so widget setups leave no detached roots behind.lifecycle.scopedallocates Effect resources in the client-provider lifetime. The main TUI scope awaits provider disposal before process exit.activityexposes a reactive view of the active UI session and its working, blocked, idle, or unavailable state. A surface with no activity to report reads"unknown".@gent/herdris a built-in client extension. It reports that UI activity through Herdr's local socket whenHERDR_ENV=1,HERDR_SOCKET_PATH, andHERDR_PANE_IDare present. It sends ordered reports with the session ID and releases its authority on exit. The shared server and child agents do not own this reporter.useExtensionUI()(extensions/host.tsx) is host-side, not extension API: the shell reads the resolved contributions, load failures andclientRuntimethrough it. A widget reads the active session fromtransport.currentSession()orsessionQuery.- Widgets are zero-prop components that self-source from context hooks.
- An extension draws its own transcript rows with
messageRendererContribution, keyed by the message'smetadata.customType. The core transcript names only the runtime's own kinds and falls back to the plain row. - A row that is not a message comes from
noticeRowContribution: the extension derives it per branch fromtransport.onSessionEvent, and the session view merges it into the transcript by time. Nothing stores it and the model never reads it. The session feed opens without waiting for client extensions; the client keeps what the feed delivered on the branch and hands it to a subscriber that joins late before the live envelopes, so each one sees the branch from its first event. A reconnect repeats envelope ids, which the subscriber skips. A source answersNoneuntil it can say its rows, and native history commits nothing until every source answers, so a committed row never changes. History holds for a source only 5 s after the client extensions loaded, so a source that never answers cannot hold it for good; after that, history commits without the source. The source is not a failure and stays: a later answer draws its rows among those history has not yet committed. @gent/cache(cache.client.tsx) folds a branch's stream, tool, interaction, message and compaction events into prompt-cache misses above a 1,024-token noise floor. Each miss gets a cause over the interval the TTL runs on, from the start of the request that refreshed the cache: a model switch, a changed prefix inside the 5-minute TTL (the regression alarm for a moved cache marker), or an expiry during a long response, a tool call, an approval wait, a paused turn, before a child completion, before a wake, or after idle time. The missed tokens fill the step's cache writes first, at the write rate, and the rest paid the input rate; each is priced over the cache-read rate, by the model the runtime priced the step by (StreamEnded.pricedModel, which follows a driver override) intransport.modelCatalog(). A miss is priced once, when the catalog is there; its row is born with that text. A row shows a miss of 20k tokens or $0.10; the status row shows the branch'scache waste $X. The row is telemetry for the reader, not context for the model.
Extension state lives in scoped Effect services/resources and publishes product events through the normal session event stream.
True actor protocols should be introduced at their owning runtime boundary using Effect Entity/RPC rather than recreating mailbox, discovery, persistence, or ask/reply infrastructure inside extension authoring.
Event-backed client invalidation:
- Server event publishing appends and broadcasts committed
AgentEvents only; it does not synthesize extension invalidation events from registry metadata. - TUI widgets that derive state from events subscribe with
transport.onSessionEventand refetch their typed extension RPC when relevant event tags arrive.@gent/goalis an event-backed widget. ExtensionStateChangedremains available as an explicit, payload-free notification event for extensions that choose to publish it directly.
Use the smallest honest boundary:
- pure helpers: unit tests
- transport/app services: Effect tests
- TUI render/capture: OpenTUI renderer tests
- runtime ordering/turn semantics: recording layers + runtime tests
Banned test primitives: Provider.Test, provider-wrapper statics, and EventStore.Test are deleted. Use LanguageModelLayers.debug() / LanguageModelLayers.sequence([...]) from @gent/core/test-utils for model mocking and EventStore.Memory for in-memory event stores.
Banned test control flow: test files do not use async/await, Promise chains, raw Promise-returning test bodies, or hook cleanup patterns. Use it.live / it.scopedLive and scoped Effect resources so finalizers run under the test runtime.
Names describe behavior: active test modules are behavior-named. Historical process names belong only in plans/ and dated audit receipts.
| Command | Scope | Target |
|---|---|---|
bun run test |
product behavior: core + tui + sdk + fast integration | ~2-4s |
bun run test:e2e |
PTY e2e + focused server-process lifecycle coverage | ~50-70s |
bun run gate |
typecheck + lint + fmt + build + test | ~15s |
packages/core/tests/ mirrors packages/core/src/. Implementation tests use relative imports into that source tree. They do not depend on the package entries:
tests/
├── domain/ # auth, agent, event, message, skills, ...
├── extensions/ # api, registry, compile-tool-policy, hooks, loader, ...
├── providers/ # provider, provider-auth, provider-resolution, anthropic-keychain
├── runtime/ # session-runtime, agent-loop, retry, agent-runner, tool-runner, ...
├── server/ # rpcs, session-queries, system-prompt
├── storage/ # sqlite-storage and the focused sub-storages
└── test-utils/ # sequence
One test file per source file. No god tests. Names match source owners.
packages/e2e/tests/ holds only the slow end-to-end suite; in-process contract tests live in each package's test task:
test:e2e— PTY TUI tests and focused server-process lifecycle coverage
packages/core/src/test-utils/index.ts—SequenceRecorderand the recording layers;baseLocalLayer, a production-root preset overcreateDependencieswith in-memory SQLite, storage-backed events, debug providers, and test service overrides;createE2ELayer, a preset that keeps realToolRunner.Live, extension setup/resource startup, event publishing, and interaction recovery while expressing test storage/provider/auth/approval differences through dependency overrides; andcreateRpcHarness, the thin RPC acceptance helper that chainscreateE2ELayer→createRpcClient(the in-process RPC client,makeInProcessClientinpackages/core/src/server/server.ts) → seededsession.createpackages/core/src/test-utils/language-model.ts—LanguageModelLayers.debug,sequence,signal,failing+ stream-part helpersapps/tui/tests/render-harness-boundary.tsx— TUI render test harness
@gent/interaction-tools — ask_user and prompt tools.
The TUI renders interactions from the typed event feed (InteractionPresented etc.) routed by metadata.type. Pending interaction storage remains the durable source of truth for crash-safe resume. A branch has at most one open request. A second call that asks in the same step parks on the open one; when the step runs again, it waits until the first call takes its answer (matched by the encoded request), then asks its own question.
Working values live in the kernel. Durable results are ordinary files. There is no separate result store, result RPC, or count badge. File links refer to the current content, not an immutable revision. A branch fork does not copy a saved plan; adoption requires an explicit file copy.
Workflow commands (/plan, /review, /audit, /counsel, /research) live in @gent/workflows as outcome prompts. They retain no workflow state and impose no fixed child count. The model uses cell bindings and files for working data, and delegates independent work when useful. Final plans, reviews, and audits use atomic writes to .gent/results/<session>/<branch>/<kind>.md in the server working directory. Explicit exports are separate files. Empty /plan reads that branch-local file without kernel bindings; it does not infer a plan from another branch. A plan saves its result and ends that cell before it requests approval in a separate cell, so the last good namespace precedes suspension. Review and audit prompts allow saving their reports but prohibit source edits; this instruction is not a sandbox boundary.
Wide event boundaries (one structured log per unit of work) via effect-wide-event:
| Boundary | Service | File |
|---|---|---|
| Agent turn | agent-loop |
runtime/agent-loop.ts |
| Tool call | tool-runner |
runtime/tools.ts |
| Model stream | model |
runtime/agent-loop.ts |
| RPC request | rpc |
server/server.ts |
Logging conventions:
- Structured annotations:
Effect.logInfo("noun.verb").pipe(Effect.annotateLogs({ key: value })) - Never
Effect.logWarning("msg", error)— always.pipe(Effect.annotateLogs({ error: String(e) })) - Tool-level errors captured via
WideEvent.set({ toolError: "..." })(value-level, not effect failures)
Log destinations:
- One directory,
/tmp/gent/logs/, or<GENT_DATA_DIR>/logswhenGENT_DATA_DIRis set (resolveLogDirinpackages/sdk/src/server.ts), so an isolated run keeps its logs beside its database <hash>-<ts>-server.log— server-side JSON lines (via the SDK'sGentObservability)<hash>-<ts>-client.log— TUI-side JSON lines (clientLogandclientTraceLoggerinapps/tui/src/client.tsx);<hash>names the cwd,<ts>the process start- Spans go to an OTLP endpoint when
OTEL_EXPORTER_OTLP_ENDPOINTis set (GentTracerLive); no trace file is written
Request-ID correlation: TUI generates crypto.randomUUID() at sendMessage/createSession, passes via requestId field in transport contract. Server threads into log annotations and RPC wide event boundaries.
- No cluster/distribution roadmap in this document.
- No compatibility notes for deleted facades.
- No process-purity dogma. Same-process direct transport is fine.
This doc describes the architecture we want to keep, not the migration history we already paid for.
Skills are discovered once per branch resource; an unreadable entry or dangling link in a skills directory is skipped with a warning. Frontmatter is YAML; a missing name falls back to the file name. Turn prompts list skills under a Local and a Global heading, grouped by skills directory. The model reads these files through the existing read tool or cell runtime. There are no skill search/load model tools. Typed skill RPCs still serve TUI discovery and content access; $skill, $skill:local, and $skill:global retain local-first or explicit-scope selection.
The skills listing goes into every request, so its size is a per-step input cost. The listing names each skills directory once and gives each skill its name and lead sentence (about 110 characters); the file is <directory>/<name>/SKILL.md unless the line names another file. The model reads the full text through the same read path: names and paths up front, content on read. On the owner's home this listing is 6.7k characters, against 21.5k for the former listing that gave every skill its full description and absolute path. The measurement that made it the only listing (a $0 Codex run, two prompts, each listing once): the first request of every turn fell from 8,905 to 5,162 input tokens (−42%), turn totals from 52,956 to 32,647, and both listings found the right skill for both prompts (the short listing read a wrong skill first once and corrected itself in the same turn).
Principles ship as an ordinary principles skill with Markdown reference files. The skills resource materializes the embedded bundle in a content-addressed directory under ~/.cache/gent/skills/. It publishes the complete directory by rename, so concurrent profiles do not expose partial files. The separate cell process reads real paths. User global skills override bundled defaults; project skills retain local-first selection. There is no separate principles tool or principle-content registry.
Repository research uses the bundled repositories skill and supervised native commands. Git and package tools own authentication, fetches, revision reads, and command errors. Gent has no repository service, repository model tool, or native Git dependency. The skill preserves existing caches and requires exact revision receipts.
Saved-result writes use the existing write tool with atomic: true. The file facade writes a scoped temporary file on the destination filesystem, then renames it over the destination under the tool's existing file lock. Atomic mode replaces a symlink entry and preserves its target; default writes retain ordinary follow-symlink behavior. Atomic replacement creates a new inode with temporary-file permissions. Ordinary completion, failure, and scoped interruption clean temporary files. Abrupt process death can leave a staging directory, but does not expose a partial destination. This does not claim power-loss durability.