plans/shared-machines.md
Proposal only — no code changes are authorized by this document. Surveyed against main at 8ff895e37 (v1.9.0-beta.3), with eight machines in-tree: version_preview, plan_handoff, app_run, connection_flow, chat_stream, voice_to_text, image_generation, user_input (mcp_oauth and first_prompt are in open PRs; preview_iframe, github_ops, and the screenshot machine are Phase 5 of even-more-machines.md).
This is the successor question to
machine-followup.md: that plan extracted the
micro-kernel (src/state_machines/) from four machines. We now have twice
as many machines and a new generation of hand-rolled plumbing sitting just
above the kernel — providers, managers, projections, disposal call sites,
main-process registries. This document identifies which of those layers has
re-earned extraction and which should stay per-machine.
The decisions recorded in machine-followup and even-more-machines still bind:
Worth naming, because it calibrates the proposals: useManagerLifecycle,
KeyedControllerHost, SnapshotStore, createTraceObserver,
Clock/IdSource, and the testing kit are each consumed by 4-8 machines
with zero drift since extraction. The kernel bet paid off; these proposals
are the same move one layer up.
| Layer | Copies today | Lines (approx) | Drift visible? |
|---|---|---|---|
| React provider + context | 5 | 55-140 each, ~460 total | plan_handoff predates the Owned/Provided split |
| Keyed manager facade | 4 | 64-201 each | disposal method named disposeKey/disposeApp/disposeChat inconsistently |
| Atom projection wiring | 5 | inline in managers; user_input/projection.ts is 349 lines | single-writer rule enforced nowhere |
| Entity-deletion disposal | 3 call sites | 2 managers per app site today, growing | two app-deletion sites must be updated in lockstep |
| Main-process registry | 2 (3 when mcp_oauth lands) | 344 + 422 lines | per-key deadline maps duplicated verbatim |
Five providers (AppRunProvider, VersionPreviewProvider, ChatStreamProvider,
ImageGenerationProvider, PlanHandoffProvider) implement the same template:
a context, an Owned variant that constructs the manager from the Jotai
store, a Provided variant for test injection, useManagerLifecycle, and a
useXManager() hook that throws outside the provider. AppRunProvider.tsx
is 55 lines containing zero domain decisions; four of the five are
line-for-line the same shape, and plan_handoff's older shape is the drift
the factory would end.
Proposed addition to src/state_machines/react.ts (the one kernel file
allowed to import React):
createMachineProvider<M extends DisposableManager>(name: string, create: (store: JotaiStore) => M)
→ { Provider, useManager }
Provider accepts an optional manager prop (the Provided/test path) and
otherwise owns construction — exactly the split four providers already
implement.onMount?: (manager: M) => () => void slot covers the one
legitimate variation: ImageGenerationProvider's 140 lines include its
toast-orchestration subscription, which is provider-mounted by design
(the projection decision from even-more-machines Phase 2). The factory
must host that, not forbid it.usePlanHandoff, useAppRun, etc.) stay in the machine
directories; the factory replaces only the context/lifecycle shell.Migration is mechanical for all five; plan_handoff additionally converges on the Owned/Provided split it currently lacks. Est. net deletion ~350 lines. New machines (three arriving in Phase 5) stop copying the template.
rules/state-machines.md's Projections section mandates one writer per
projected atom, and even-more-machines 3.3 sketched an optional helper.
The evidence has since hardened: five machines now hand-write the
subscription-to-atom wiring (app_run/manager.ts writes
setPreviewRunStateForAppAtom inline; version_preview, chat_stream, and
image_generation managers each carry their own copy;
user_input/projection.ts is a 349-line module). Nothing enforces the
single-writer rule — it held so far because reviews caught violations, and
review is exactly what the rule was written to stop depending on.
Proposed src/state_machines/projection.ts:
projectToAtom(store, atom, source, select) — one subscription, writes
only on selected-value change (reference-stable per the snapshot rules).select is the machine's own
projection function; merge/hydration semantics (user_input's
events-win-during-hydration rule) stay in the machine's adapter.user_input/projection.ts does more than project (rehydration via getPending, the respondingRequestIds overlay); it would consume the helper for its write path only. That is fine — the helper is a primitive, not a replacement for projection modules.
Deleting an app must dispose per-app controllers in every per-app machine. Today that is a hand-maintained list duplicated at two call sites (apps.tsx:163-166 and app-details.tsx:200-202, currently version_preview + app_run + the preview-runtime cleanup), and chat deletion has its own pair (ChatList.tsx:244-245: plan_handoff + chat_stream). Phase 5 adds three more per-app machines (preview_iframe, screenshot, github_ops), taking the app-deletion list to five entries maintained in lockstep at two sites. The failure mode is silent: a machine missed at one site leaks controllers and atom residue exactly the way chat_stream did before its disposeChat landed.
Proposed src/state_machines/entity_disposal.ts:
onAppDeleted(fn) / onChatDeleted(fn)
returning unregister functions; providers register their manager's
disposeKey during mount.disposeForApp(appId) /
disposeForChat(chatId).Alternative considered: documentation only ("update both sites"). Rejected because the list is about to more than double and the two-site invariant is precisely the kind of comment-enforced rule this program keeps converting into structure.
Also fold in the naming drift: disposeKey/disposeApp/disposeChat become one conventional name during registration migration (no behavior change).
connection_flow/registry.ts (344 lines) and user_input/registry.ts (422
lines) share real, verbatim-level slices: injected Clock/IdSource/broadcast
construction, a keyed entry map with snapshot getters (getStates /
getPending), per-key deadline scheduling (user_input/registry.ts:93,
137-157 is the same schedule/cancel/fire-on-expiry map connection_flow
hand-rolls), and dispose-as-abort-all for before-quit. The mcp_oauth
registry (open PR) is the third instance.
Hold until mcp_oauth merges, then extract ONLY the identical slices:
DeadlineMap — keyed Clock-handle scheduling with cancel-on-settle.replayTrace(transition, capturedEntries) — the
documented technique (replay a window.__dyadMachines capture through a
pure transition) exists only as a hand-written test in trace.test.ts.
Promote it so bug reports carrying a trace dump become regression tests
in one line. Three immediate uses: any machine with a trace observer.window.__dyadMachines — plausible, zero pull so
far. Trigger: the first debugging session that wishes it existed.Each PR is behavior-preserving under the machines' existing suites; the provider and manager tests are the characterization net.
onMount escape hatch means domain behavior stays in machine
directories. If a migration needs a factory option that only one machine
uses, that machine keeps its bespoke provider instead.npm test -- src/state_machines/
npm test -- src/app_run/ src/version_preview/ src/plan_handoff/ src/chat_stream/ src/image_generation/ src/user_input/ src/connection_flow/ src/voice_to_text/
npm run fmt && npm run lint && npm run ts
plus the full unit suite per PR. boundaries.test.ts must stay green (projection.ts and entity_disposal.ts are kernel files: no domain imports; react.ts remains the only React importer).
createMachineProvider; the bespoke templates
are deleted; new-machine PRs get provider+hook in two lines.