plans/g1-chat-stream-study.md
Decision: GO for C3, after the app-run remote-actor pilot proves the common transport. Do not lift the renderer controller into main unchanged.
Implementation update: C3 keeps chat queue, turn-intent, and plan-handoff coordination in main-process memory. It survives renderer reloads and window closure while the app process remains alive, but it is intentionally discarded on full app restart. The SQLite recovery design below remains the original study recommendation, not the implemented persistence model.
The feasible design is a main-owned per-chat lifecycle actor plus a
main-owned prompt queue with an explicit per-entry persistence policy. A
serialized turn intent replaces StreamRequest callbacks. SQLite acceptance
is the durable linearization point for a user message. Windows materialize
ordered message deltas and revisioned read models; they do not own stream,
queue, or handoff lifecycle.
This is a design gate, not implementation approval for every detail. C3 may start only with:
A callback-preserving process move, a renderer QueueStore, or a
revision-free renderer MessagesStore is a no-go.
This study uses:
plans/cleanup-state-machines.md;chat_stream actor lifecycle row;plans/distrbuted-machines.md;plans/claude-cleanup-machines.md;src/chat_stream/, queue persistence, plan handoff, and user-input
follow-up code.G1a is settled. This study assumes:
StreamState is the sole streaming-status authority;It does not reopen those choices.
One requested input has moved: src/user_input/follow_up_handoff.ts does not
exist in the current tree. Commit 39013e294 deleted it in favor of the
memory-owned recovery described in
docs/user-input-follow-up-recovery.md. The live callback bridge is now
createUserInputChatStreamFacade in
src/app_wiring/registerRendererIpcListeners.ts:25-62. The current contract
survives renderer reload while main remains alive, but intentionally drops the
owner on a full app-process restart
(docs/user-input-follow-up-recovery.md:41-51).
Main already owns admission barriers, the AbortController, the LLM/tool
execution, the durable message insert, cancellation unwind, and the unique
follow-up acceptance key
(src/ipc/handlers/chat_stream_handlers.ts:606-737,
src/ipc/handlers/chat_turn_acceptance.ts:27-90). The renderer owns a second
lifecycle controller, callback-bearing requests, the editable queue, queue
persistence orchestration, message materialization, finalization effects, and
completion listeners. Moving authority to main is therefore a consolidation,
but only after callbacks, browser File objects, Jotai transactions, and
presentation effects are replaced by explicit protocol boundaries.
Recommendation
Main/SQLite owns accepted messages. A window may show an initiator-local
optimistic row keyed by a stable intentId, but that row is presentation
state, is not broadcast, and is never considered accepted. It reconciles only
from a typed acceptance result or the message read model.
Every ordinary submit receives an intentId before crossing IPC. Durable
acceptance means one SQLite transaction has:
accepted;The current database insert is already the strongest existing boundary:
acceptChatTurn inserts the user message and latches chat mode atomically
(src/ipc/handlers/chat_turn_acceptance.ts:27-90). Follow-ups additionally
deduplicate on (chat_id, user_input_request_id)
(src/db/schema.ts:151-193). C3 generalizes this to every turn with an
immutable intentId and payload hash.
Accepted does not mean model/tool execution completed. Execution and terminal outcomes remain lifecycle facts.
Alternatives rejected
Recommendation
The actor snapshot carries low-frequency lifecycle and correlation only: phase, active intent/invocation, target app, cancellation/finalization facts, G1a error, capabilities, and revisions. LLM text, full message arrays, streaming patches, tool preview text, console output, and attachment bytes stay off actor snapshots.
The existing chunk protocol already separates full message replacement,
tail-patch updates, and preview overlays
(src/ipc/types/chat.ts:218-246). Retain that shape behind an
interest-keyed, per-webContents high-volume subscription. Add an ordered
cursor and bootstrap contract. A terminal flush supplies the canonical message
revision so an attached window can reconcile server-assigned fields such as
commitHash, which the renderer currently fetches at finalization
(src/chat_stream/commands.ts:560-591).
Runtime handles stay in an ephemeral table beside the actor, never in its
snapshot: AbortController, running promise/task, admission waiters, partial
response buffers, transport subscribers, and ack timers.
Alternatives rejected
StreamState. Every token would republish the
lifecycle snapshot to all status subscribers and make snapshots large.Recommendation
Main owns one per-chat queue aggregate containing entries and pause state.
Queue mutations are serialized through that aggregate. Ordinary user entries
and durable-protocol entries persist in SQLite. A memory-owned user-input
follow-up remains explicitly main-session and is not persisted until it
reaches the message-acceptance transaction; this preserves the current
full-process-restart boundary and the rule against persisted shells whose
owner is memory-only. Attachment bytes are staged before durable queue
acceptance and referenced by stable attachment IDs; they are not embedded in
lifecycle snapshots.
The aggregate preserves all existing behavior:
undefined versus null requested-chat-mode semantics;Queue mutation intent is state-sensitive and carries expectedQueueRevision.
Pause/resume, edit, reorder, remove, and clear return authoritative receipts.
A queued submit returns durably-queued or queued-for-main-session, not
stream success.
On full app restart, durable queued entries hydrate paused. Session-only follow-ups are absent with their memory owners. An intent already accepted as a message but interrupted during execution is not put back in the editable queue; it becomes an explicit interrupted turn eligible for user-directed retry. C3 does not promise exactly-once external tool effects.
Alternatives rejected
QueueStore. It would be a temporary
authority and remains unsafe with two windows..dyad/queue. The current store
assumes one writer (src/main/queue_store.ts:139-142) and cannot atomically
couple dequeue with message acceptance.src/hooks/useQueuePersistence.ts:16-45).Recommendation
Replace all four StreamRequest callbacks with IDs and typed outcomes:
intentId correlates one submission and all UI outcomes;invocationRef identifies the active execution and cancellation target;durably-queued,
message-accepted, message-replayed, or typed rejection;onSettled consumers subscribe by intentId or invocation and derive their
one-shot behavior from a deduplicated terminal record. Queue admission settles
the submission operation without pretending the queued turn completed.
Expected failures are data, not rejected transport promises. Presentation
consumers may register live callbacks in a window adapter, but those callbacks
are downstream of the serializable protocol and never stored in host state.
Alternatives rejected
dispatchAndWaitForEffects promise. It conflates actor commit,
durable acceptance, and long-running completion.Recommendation
Move the handoff entirely into main and use the user-input requestId as the
domain idempotency key. The current user-input request remains memory-owned
across the app-process lifetime; G1 does not restore the deleted second
user-input lifecycle table. “Durable handoff” here means receiver acceptance
is durable before user-input settles, not that the original parked waiter
survives process death.
The main user-input registry calls a typed chat-turn acceptance facade directly. Before the acceptance transaction, the facade verifies that the memory-owned request is still due. In the transaction, the receiver:
Only after commit does the in-memory user-input registry transition to
dispatched. If that local transition fails without process death, retry
observes the same accepted intent and completes settlement. Same-key/different
chat or different-payload replay is Conflict, not acceptance. This closes
the current gap in which a chunk callback fires and a second IPC separately
settles user-input
(src/user_input/projection.ts:298-341).
Full main-process restart retains the recorded product behavior: unaccepted memory-owned follow-ups are dropped. If the message transaction committed before the crash, the message remains accepted and the turn is reconciled as interrupted; it is not silently executed twice. If product later requires cross-restart delivery, that is a separate durable protocol actor with an explicit recovery UX, not a reason to persist callbacks.
Alternatives rejected
Recommendation
plan_handoff becomes a main-owned durable protocol actor and depends on the
main chat-turn facade. The acceptance boundary captures:
acceptInNewChat;The actor checkpoints plan persistence, chat creation or mode switch, waiting
for stream idle, and implementation-turn acceptance. Chat creation and final
/implement-plan= submission each have stable idempotency keys. It reports
“implementation started” only after the receiver accepts the implementation
turn, not immediately after calling submit as today
(src/plan_handoff/commands.ts:208-216).
Navigation, preview-mode changes, accepted badges, and failure toasts are renderer presentation/read-model consumers. The latest editable plan document remains renderer data until accept; accept captures an immutable version so a reload or another window cannot change the in-flight handoff.
Alternatives rejected
planStateAtom. Main cannot read a window-local atom,
and “latest” is undefined with multiple windows.Recommendation
The chat_stream lifecycle matrix becomes:
| Boundary | Required behavior |
|---|---|
| No subscribers | Active stream and queue continue. Idle actors may evict after their retained read models are safe. |
| Renderer reload | Release that webContents subscriptions and presentation callbacks. Main work continues. New renderer bootstraps lifecycle, messages, queue, handoff, and completion cursors. |
| Initiating window closes | Work continues. Operation presentation falls back by recorded product decision 5. Closing is not cancel. |
| Last window closes | Follow platform convention. On macOS, main work continues while the app remains alive. Windows/Linux follow actual app shutdown, not subscriber count. |
| App quit | Stop admission; mark executing turns interrupted; abort streams; await bounded write unwind; flush queue/intent transactions and actor read models; do not wait indefinitely for providers. |
| App restart | Hydrate queued entries paused. Reconcile accepted/executing intents to interrupted, never silently auto-run agent/tool work. Memory-owned user-input follow-ups are absent by the recorded current policy. |
| Chat/app deletion | Atomically claim queue and protocol records; settle/reject owners; cancel and unwind the active invocation; delete read models and staged attachments; only then delete the entity. Settlement failure restores the claim and blocks deletion. |
This preserves product decisions 2 and 3 while making the current renderer
disposal behavior obsolete. Today controller disposal settles callbacks,
publishes idle, and releases transport
(src/chat_stream/controller.ts:97-148); after C3, window disposal must not
publish authoritative idle or settle main-owned work.
Entity deletion uses the main registry's authoritative sweep, not a renderer callback. It first claims the affected queue entries so no driver can start them. If settling a session owner unexpectedly fails, the claim is restored and deletion fails visibly; the database cascade does not proceed. App quit uses its separately defined bounded shutdown sweep and may record interruption, but that does not weaken interactive chat/app deletion.
Alternatives rejected
src/hooks/useQueuePersistence.ts:165-185).Recommendation
Terminal domain facts commit in main before presentation routing.
(invocationRef, targetAppId, "post-stream"). WindowRegistry leases exactly
one visible, matching iframe capability. It is never broadcast
first-response-wins.skipped-no-capability; it does not fail stream
finalization or create an unbounded durable backlog.Each live presentation event has a stable presentationEventId. Windows
deduplicate it. Missing a toast, scroll, or screenshot never changes domain
state.
The current renderer command directly opens preview, bumps reload, and writes
a screenshot mailbox (src/chat_stream/commands.ts:518-529). Those become
post-commit routed effects; no actor state imports renderer atoms.
Alternatives rejected
Recommendation
No. Lifecycle state contains only the current phase and, while needed for G1a, the current/last error. Completion records live in a separate bounded, session-scoped read model:
interface ChatCompletionRecord {
completionSeq: number;
chatId: number;
intentId: string;
invocationRef: ChatStreamInvocationRef;
outcome: "completed" | "cancelled" | "errored" | "interrupted";
completedAt: number;
chatSummary?: string;
updatedFiles: boolean;
pausePromptQueue: boolean;
}
The actor appends after terminal commit. Consumers use
(actorInstanceId, completionSeq) for deduplication. Retain a small per-chat
ring plus a global bound; prune metadata with entries. The history survives
renderer reload while main remains alive but is not replayed as a fresh toast
after full app restart. Durable message/intent recovery facts supply crash
recovery.
This replaces the current ephemeral subscribeStreamFinished callback
(src/chat_stream/manager.ts:136-141,207-253) without growing lifecycle
snapshots or pinning terminal actors.
Alternatives rejected
StreamState. It prevents quiescent actor
eviction and republishes unrelated history on each transition.Recommendation
Every renderer read model has one main writer, an explicit schema version, and its own ordering identity:
| Read model | Ordering | Gap/bootstrap rule |
|---|---|---|
| Lifecycle | (actorInstanceId, actorRevision) | Replace on bootstrap; ignore older actor/revision; actor-disposed envelope means absent/idle per G1a. |
| Messages/materializer | (messageEpoch, messageRevision, invocationRef, deliverySeq) | Bootstrap canonical messages plus active cursor; patch base/hash or sequence gap triggers chat refetch and a new epoch. deliverySeq is host-assigned for every delta and is distinct from optional canned-stream chunkSeq. |
| Queue | queueRevision per chat | Full queue+pause bootstrap; state-sensitive mutations require expected revision; gap replaces from main. |
| Plan handoff | (actorInstanceId, handoffRevision) | Full protocol projection bootstrap; presentation events are separate and deduped. |
| Completion history | completionSeq within main session | Bootstrap retained records, then drain greater sequences; bootstrap records do not replay live-only presentation. |
| Query cache invalidation | one global epoch | Any gap conservatively invalidates affected query families. |
| Presentation | presentationEventId | Live-only dedupe; never used to reconstruct domain state. |
For each subscription, main:
webContents;webContents.destroyed.The renderer does not expose ready data until the bootstrap is applied. It
distinguishes uninitialized, bootstrapping, and ready even when a ready
chat has zero messages. This preserves the current Map.has(chatId)
loaded-empty distinction used by ChatPanel.
Alternatives rejected
| Current value | Why it cannot cross/persist as actor data | Target |
|---|---|---|
StreamRequest.attachments: FileAttachment[] (state.ts:45-65) | Contains browser File objects | Validate and stage serializable ChatAttachment data before durable intent acceptance; store stable attachment refs |
onAccepted, onAcceptanceError, onAcceptanceRejected, onSettled | Functions, Error, and promises are renderer-memory capabilities | intentId, typed dispatch receipt, acceptance result, terminal record |
Active StreamState.request (state.ts:92-120) | Contaminates every active snapshot with the fields above | Actor stores intentId and serializable immutable intent facts |
Commands carrying StreamRequest (state.ts:171-205) | Same contamination | Commands carry intent/invocation IDs and load immutable host intent |
Command emit/isStale closures (commands.ts:58-97) | Runtime behavior, not data | Host dispatcher/context and invocation checks |
Jotai store, QueryClient, PostHog getter (commands.ts:102-107) | Renderer resources | Renderer read-model/presentation adapters; main invalidation and telemetry facades |
AbortController, completion promises, admission waiter closures | Ephemeral main resources | Host runtime table keyed by invocation; reconstruct as absent/interrupted after restart |
WebContents sender and callback stream registry | Ephemeral renderer lifetime | webContents subscription ownership plus WindowRegistry routing |
Queue callbacks and browser attachments (chatAtoms.ts:528-548) | Cannot persist or transfer | Serializable queue entry plus optional protocol-owner key |
Queue WeakMap encoding cache (useQueuePersistence.ts:81-88) | Identity optimization tied to renderer objects | Main attachment staging/cache, outside read models |
TaskScope, timers, listener sets | Resource handles | Host-owned disposable runtime scopes |
| Plan navigation function and Jotai plan lookup | Window capability/local store | Captured plan version in durable handoff; routed presentation event |
Already serializable and reusable:
InvocationRef;redo, selected-component descriptors, and requested
mode after schema validation;ChatResponseEnd and error strings;The following are contract sketches, not production declarations.
interface SerializableChatTurnIntentEnvelope {
schemaVersion: 1;
intentId: string; // durable idempotency identity
chatId: number;
originWindowSessionId?: string; // presentation routing only
prompt: string;
payloadHash: string; // immutable replay validation
appId?: number;
redo?: boolean;
attachmentRefs: readonly string[];
selectedComponents: readonly ComponentSelection[];
requestedChatMode?: ChatMode | null;
owner?:
| { kind: "user-input-follow-up"; requestId: string }
| { kind: "plan-handoff"; handoffId: string };
}
interface DurableChatTurnIntentRecord {
envelope: SerializableChatTurnIntentEnvelope;
acceptance: "queued" | "message-accepted" | "rejected";
recovery: "not-started" | "started" | "interrupted" | "terminal";
acceptedMessageId?: number;
queuePosition?: number;
}
interface SessionQueueEntry {
// Never persisted. Its live user-input registry owner is the authority.
envelope: SerializableChatTurnIntentEnvelope & {
owner: { kind: "user-input-follow-up"; requestId: string };
};
persistence: "main-session";
}
type ChatStreamHostState =
| { type: "idle" }
| {
type: "admitting" | "streaming" | "cancelling" | "finalizing";
intentId: string;
invocationRef: ChatStreamInvocationRef;
targetAppId: number | null;
cancelRequested: boolean;
}
| {
type: "errored";
error: string; // follows G1a
};
interface ChatStreamLifecycleReadModel {
schemaVersion: 1;
chatId: number;
actorInstanceId: string;
actorRevision: number;
transactionSequence: number;
state: ChatStreamHostState;
capabilities: {
canSubmit: boolean;
canCancel: boolean;
canPauseQueue: boolean;
canResumeQueue: boolean;
};
}
interface ChatMessagesBootstrap {
schemaVersion: 1;
chatId: number;
messageEpoch: string;
messageRevision: number;
messages: readonly Message[];
activeCursor?: {
invocationRef: ChatStreamInvocationRef;
deliverySeq: number;
};
preview?: StreamingPreview;
}
interface ChatMessageDelta {
chatId: number;
messageEpoch: string;
messageRevision: number;
invocationRef: ChatStreamInvocationRef;
deliverySeq: number; // mandatory host order, not canned-stream chunkSeq
update:
| { kind: "replace"; messages: readonly Message[] }
| {
kind: "patch";
streamingMessageId: number;
patch: StreamingPatch;
}
| { kind: "preview"; preview?: StreamingPreview };
}
interface ChatQueueReadModel {
schemaVersion: 1;
chatId: number;
queueRevision: number;
paused: boolean;
entries: readonly {
itemId: string;
intentId: string;
prompt: string;
attachmentSummaries: readonly AttachmentSummary[];
selectedComponents: readonly ComponentSelection[];
redo?: boolean;
appId?: number;
requestedChatMode?: ChatMode | null;
persistence: "durable" | "main-session";
editable: boolean;
removable: boolean;
}[];
}
interface PlanHandoffReadModel {
schemaVersion: 1;
handoffId: string;
actorInstanceId: string;
handoffRevision: number;
sourceChatId: number;
targetChatId?: number;
planId: string;
planVersion: string;
phase:
| "accepted"
| "persisting"
| "preparing-chat"
| "awaiting-stream-idle"
| "submitting"
| "started"
| "failed"
| "cancelled";
failure?: string;
}
type RendererReadModel<T> =
| { status: "uninitialized" }
| { status: "bootstrapping"; subscriptionId: string }
| { status: "ready"; subscriptionId: string; value: T }
| {
status: "absent";
reason: "actor-disposed" | "entity-deleted";
};
// For RendererReadModel<ChatStreamLifecycleReadModel>, "absent" selects idle
// exactly as G1a requires. Messages, queue, plan handoff, and completion use
// the same explicit bootstrap union and their domain-specific revisions.
The completion schema is defined in decision 9. The host snapshot may contain main-only fields, but the remote lifecycle schema is an explicit safe projection and never includes secrets, paths, prompt content, or attachment bytes unnecessarily.
DurableChatTurnIntentRecord.acceptance and .recovery are persistence and
crash-reconciliation facts, not live streaming status and not renderer
capabilities. ChatStreamHostState remains the sole live lifecycle authority
under G1a. Completion history is a projection of terminal commits; it is not a
second writable status.
| Intent | Class and admission contract |
|---|---|
| Submit a new user turn | Idempotent/current-agnostic with immutable intentId; transition still validates current chat and chooses immediate versus queued |
| Retry an interrupted turn | State-sensitive, with expectedRevision and a new execution invocation while retaining the accepted intent facts |
| Cancel | Cancellation; must carry the active invocationRef, never just chat or window |
| Pause/resume queue | State-sensitive mutation with expectedQueueRevision |
| Edit/reorder/remove/clear queue | State-sensitive mutation with expectedQueueRevision; owner settlement is part of remove/clear semantics |
| User-input follow-up | Main-session handoff with requestId as receiver idempotency key; durable only at message acceptance |
| Plan implementation submit | Durable handoff with handoffId/step idempotency key |
| Toast/navigation/preview/screenshot | Presentation-only, emitted post-commit and routed by WindowRegistry |
| Message/chunk subscription | Idempotent read/subscription with explicit bootstrap cursors |
There are two related transactions.
Within the per-chat actor FIFO:
intentId;queueRevision, and
append the queue read-model outbox record; after commit return
durably-queued;main-session entry, validate its live owner, append it only to the
main-owned aggregate, and return queued-for-main-session; never write a
persistence shell;Same-key/same-payload retry returns the original result. Same key with a different chat or payload is a typed conflict.
The public submission facade does not report message-accepted before the
turn-acceptance transaction commits. The generic actor receipt may report that
the submit event committed, but callers cannot treat it as durable admission;
the domain acceptance result is authoritative.
In one SQLite transaction:
SessionQueueEntry,
persist its envelope as a durable intent for the first time in this
transaction, with no earlier owner-bearing persistence shell;(chatId, intentId) and, for
user-input, the owner key;message-accepted;queueRevision if applicable;messageRevision and append message/queue read-model outbox rows;After commit, the actor transitions to executing, publishes read models, settles any memory-owned user-input owner, and starts the provider/tool command. A crash after commit but before command start recovers as interrupted, never as an editable queued item and never as a second message.
Finalization is a separate actor transaction: persist terminal message facts
and intent recovery facts, atomically persist pausePromptQueue, append the
completion record, and, when the queue is unpaused, accept at most one head
intent using the same message-acceptance steps above. The head is removed only
as its message/intent acceptance commits. Validation failure leaves it queued
and pauses the queue with a typed error. Thus there is no free-standing
in-memory “next reservation” to lose on restart. Command/provider completion
cannot retroactively change the already-issued acceptance result.
.dyad/queue migration stages and validates all attachment payloads
before committing new queue rows. Failure leaves the legacy file untouched.Required fault-injection tests cut power/fail after every numbered database step and between database commit, actor commit, read-model publication, and command start.
getPlanData; make
no new message/queue/accepted-plan store.intentId at UI and machine-owner
boundaries. Replace the renderer user-input callback bridge with an
ID/outcome adapter and direct main facade. Preserve the existing renderer
controller behind an adapter during this step; callbacks may exist only in
the window adapter, never in intent/state/command data..dyad/queue entries once, paused, into the main aggregate. Keep the old
files read-only according to the durable migration/rollback retention
policy; never dual-write.Rollback boundaries exist after steps 4 and 8. Step 5 is irreversible after the new writer accepts its first mutation unless an explicit reverse export first merges current SQLite queue state back into the legacy format. Once step 7 makes main the single lifecycle authority, rollback must switch the complete protocol, never reenable a renderer writer beside it.
The budget is a set of obligations, not a promise to delete all chat code. Current candidate implementation totals approximately 3,947 lines across the renderer lifecycle, queue persistence, queue IPC/store, and callback wiring; main actor/read-model code replaces part of it.
src/chat_stream/controller.tssrc/chat_stream/manager.tssrc/chat_stream/ChatStreamProvider.tsxsrc/chat_stream/state.ts,
transition.ts, and commands.tsStreamRequest and QueuedMessageItemcreateUserInputChatStreamFacade callback/promise bridgequeuedMessagesByIdAtom and queuePausedByIdAtomuseQueuePersistence.ts, its pagehide flush, WeakMap encoder cache, and
full-snapshot writergetQueuedPrompts/setQueuedPrompts renderer-authority
contracts and the one-renderer write assumption in queue_store.tssyncProjection/atom cleanup for chat lifecycle after A6achatMessagesByIdAtom after all readers use the materializer facademark-plan-accepted, updatePlanState, and the fused planStateAtommain_model.ts with the production main transition while retaining
its invariants/cosim coverage; then delete the shadow model;protocol.ts to the production high-volume and compatibility wire
contracts;commands.ts, then delete the old mixed adapter;C3 passes the deletion gate only if it:
Adding remote wrappers while retaining the renderer controller or atom authorities fails the gate.
Only the following renderer work is safe regardless of whether C3 is delayed or the host implementation changes:
useChatMessages(chatId), useChatMessageCount(chatId), and
useLastChatMessage(chatId) as reader facades over the existing
chatMessagesByIdAtom, then migrate read-only consumers. The backing source
can later become the revisioned materializer without another component
migration.planStateAtom
and inject getPlanData(chatId) into plan-handoff. Do not split the atom
yet: acceptedChatIds would need a temporary renderer home. During C3, move
accepted state to the revisioned main read model and then rename the
remaining renderer-owned documents half to planDocumentsAtom (or the
established query cache). Preserve the current dual-source plan-document
race as an explicitly separate issue.undefined/null; plan acceptance invalidation on a new draft.useStreamChat facade so its backing can
switch later without changing components.Do not build a renderer MessagesStore, renderer QueueStore, or retained
renderer accepted-chat projection. Do not move message hydration into a
renderer machine command. Do not persist machine-owned queue entries.
GO for C3 with the design above. Main placement is feasible and removes a real multi-window authority split. The critical path is not chunk throughput; the existing patch channel is reusable. The critical path is the durable, immutable intent plus transactional queue/message acceptance boundary.
Proceed after the app-run pilot proves remote actor bootstrap and WindowRegistry routing. Treat the acceptance transaction, queue aggregate, user-input direct handoff, and plan-handoff checkpoints as one protocol review. If C3 cannot atomically couple queue claim with message acceptance, or cannot remove callback-bearing renderer authority, the implementation is no-go and A6b must stop at the host-independent reader/document subset above.
Implementation began on 2026-07-27 after the remote actor pilot and the
host-independent A6b subset landed. The cutover follows the study's ownership
decision: chat_stream and plan_handoff are main-hosted actors, streamed
bytes continue over the existing keyed high-volume channel, chat intent
acceptance is transactionally coupled to the user-message insert, and queue
mutations use an authoritative revision.
The implementation diverged from the proposed shape in four review-visible ways:
lastCompletion, not a separately addressed completion projection. No
consumer required history beyond the most recent terminal commit, so a
second read model would have introduced another synchronization boundary.FileAttachment values. Moving attachment bytes behind durable references
is deferred until the attachment store has a stable cross-process identity.savePlanToDisk step from a persisted
phase rather than persisting a separate plan-slug checkpoint. Target-chat
identity and implementation intent identity remain durable and idempotent..dyad/queue files are imported once and intentionally retained
during the cutover. SQLite stores the migration marker. The trailing
deletion removes the writer and renderer IPC while retaining a read-only
importer so existing queued prompts can still migrate.The stacked c3-chat-delete-adapters PR fulfills the Phase D budget: renderer
chat and plan controllers, mixed command adapters, the shadow main model,
queue atoms, full-snapshot queue IPC/persistence, and renderer follow-up
dispatch are deleted. Queue UI now reads the actor snapshot directly, and the
hybrid harness uses the real remote actors. The wave becomes complete when
that stacked PR lands.