@happyvertical/smrt-app-shell 0.60.18 → 0.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/AGENTS.md +51 -9
  2. package/README.md +378 -23
  3. package/agents/draft-ui.md +74 -0
  4. package/agents/engine.md +81 -0
  5. package/agents/shared-pieces.md +127 -0
  6. package/dist/engine/app-runtime.d.ts +7 -0
  7. package/dist/engine/app-runtime.js +30 -3
  8. package/dist/engine/commands.d.ts +23 -0
  9. package/dist/engine/commands.js +29 -0
  10. package/dist/engine/draft.d.ts +31 -0
  11. package/dist/engine/draft.js +88 -0
  12. package/dist/engine/host.d.ts +175 -1
  13. package/dist/engine/host.js +501 -9
  14. package/dist/engine/index.d.ts +4 -2
  15. package/dist/engine/index.js +2 -1
  16. package/dist/engine/publish.d.ts +100 -0
  17. package/dist/engine/publish.js +218 -0
  18. package/dist/engine/store.d.ts +8 -0
  19. package/dist/engine/store.js +8 -0
  20. package/dist/engine/worker-protocol.d.ts +1 -1
  21. package/dist/engine/worker-protocol.js +1 -0
  22. package/dist/lib/objects/App.d.ts +42 -1
  23. package/dist/lib/objects/App.js +72 -4
  24. package/dist/lib/objects/AppDraft.d.ts +31 -0
  25. package/dist/lib/objects/AppDraft.js +73 -0
  26. package/dist/lib/objects/AppDraftVersion.d.ts +25 -0
  27. package/dist/lib/objects/AppDraftVersion.js +68 -0
  28. package/dist/lib/objects/Approval.d.ts +4 -4
  29. package/dist/lib/objects/Approval.js +3 -3
  30. package/dist/lib/objects/index.d.ts +2 -0
  31. package/dist/lib/objects/index.js +2 -0
  32. package/dist/lib/server/approval-store.d.ts +6 -0
  33. package/dist/lib/server/approval-store.js +102 -0
  34. package/dist/lib/server/approvals.d.ts +15 -0
  35. package/dist/lib/server/approvals.js +62 -43
  36. package/dist/lib/server/builder.d.ts +18 -10
  37. package/dist/lib/server/builder.js +46 -18
  38. package/dist/lib/server/drafts.d.ts +100 -0
  39. package/dist/lib/server/drafts.js +324 -0
  40. package/dist/lib/server/events.d.ts +14 -0
  41. package/dist/lib/server/events.js +3 -0
  42. package/dist/lib/server/host.d.ts +44 -2
  43. package/dist/lib/server/host.js +155 -11
  44. package/dist/lib/server/results.d.ts +6 -0
  45. package/dist/lib/server/results.js +8 -0
  46. package/dist/lib/server/target.d.ts +24 -0
  47. package/dist/lib/server/target.js +64 -0
  48. package/dist/lib/server/version-store.d.ts +40 -1
  49. package/dist/lib/server/version-store.js +144 -32
  50. package/dist/manifest.json +699 -6
  51. package/dist/smrt-knowledge.okf.md +19 -2
  52. package/package.json +19 -15
package/AGENTS.md CHANGED
@@ -6,22 +6,30 @@ for one person: the owner. Epic #4867; this package is #4872. The engine is
6
6
  documented in [agents/engine.md](agents/engine.md) (it must not import
7
7
  SvelteKit or `src/lib`). README has the route and JSON contracts.
8
8
 
9
+ Shared assistant adapters and legacy approval storage: [agents/shared-pieces.md](agents/shared-pieces.md).
10
+
9
11
  ## Where things live
10
12
 
11
13
  | Path | What |
12
14
  |---|---|
13
- | `src/engine/` | `AppHost`, worker thread, migrations, QuickJS sandbox. Finished and tested on its own. |
15
+ | `src/engine/` | `AppHost`, worker thread, migrations, QuickJS sandbox, drafts (`forkDraft`/`previewPublish`/`publishDraft`/`undoTo`, frozen host, draft-only `autoApprove`; see engine.md "Drafts"). Finished and tested on its own. |
14
16
  | `src/lib/objects/` | Shell models (`objectsDir` in `vite.config.ts`): `App`, `AppVersion`, `Approval`, `AssistantThread`, `AssistantMessage`. |
15
17
  | `src/lib/server/smrt.ts` | `createSmrtSvelteKitRuntime`, `prepareDatabase`, `configureShell`. |
18
+ | `src/lib/server/registry.ts` | The ONE startup registration path (permissions, preference kinds); `smrt.ts` imports it before the runtime. app-runtime grants newly registered permissions to an existing owner on every start. `registry.test.ts` guards it. |
16
19
  | `src/lib/server/schema.ts` | `prepareShellSchema(db)`: additive first-run schema. |
17
- | `src/lib/server/host.ts` | Holders: shell db, data root, the one lazily opened `AppHost`. |
18
- | `src/lib/server/version-store.ts` | `ShellVersionStore` (engine `VersionStore` on `AppVersion` + the `App` row). |
20
+ | `src/lib/server/host.ts` | Holders: shell db, data root, the LIVE `AppHost` (`getHost`, never the draft) and the open-draft slot; `hostFor(target)` is how a request reaches either; restart adoption of an open draft (re-freeze live, reopen the draft with `autoApprove`). |
21
+ | `src/lib/server/target.ts`, `drafts.ts`, `view.ts` | The copy a request names (`live`/`draft`, `CallScope`), the draft lifecycle (start, describe, preview, publish and discard apply, History grouping) and the owner's saved view. README "Draft then publish (the shell)" lists routes, payloads, events. |
22
+ | `src/lib/server/version-store.ts` | `ShellVersionStore` (engine `VersionStore` on `AppVersion` + the `App` row; `commit` is one transaction, a publish's draft mark rides in it) and `DraftVersionStore` (`AppDraftVersion` rows of one `AppDraft`). |
19
23
  | `src/lib/server/owner.ts` | Owner check, `Principal`, `assertOwner`, `runAs`. |
20
- | `src/lib/server/approvals.ts` | Approval service; the only place `approved: true` is set. |
24
+ | `src/lib/server/approvals.ts`, `approval-store.ts` | Shared-authority storage adapter and shell action executor; the only place `approved: true` is set. |
21
25
  | `src/lib/server/builder.ts` | `runCommand`/`runAppTool`/`runRest`/`requestRecordDelete`: execute or turn into an Approval. |
22
26
  | `src/lib/server/catalog.ts`, `policy.ts`, `mcp-server.ts` | Merged tool catalog, the one policy, the `/mcp` server. |
23
27
  | `src/lib/server/http.ts`, `events.ts` | Route plumbing; in-process event bus for SSE. |
24
- | `src/lib/server/assistant/`, `src/lib/assistant/` | Assistant: turn loop, hold, config, routes (`mountAssistantRoutes`), thread store; dock UI, pure reducer/stream parser, `guidance/system.md`. |
28
+ | `src/lib/server/inference.ts`, `inference-registry.ts` | smrt-inference host + controller over shell.db and the runtime's master key; `asOwner` runs a settings request as the verified owner; `installShellInference` registers the user resolver (the assistant's key source). |
29
+ | `src/lib/server/assistant/`, `src/lib/assistant/` | Assistant: turn loop, hold, config, routes (`mountAssistantRoutes`), thread store; chat column UI (`AssistantPanel`), pure reducer/stream parser, `guidance/system.md`. |
30
+ | `src/routes/+layout.svelte`, `src/lib/ui/layout.ts`, `src/lib/ui/menu.ts` | Shell layout over smrt-svelte `AppShell`: left sidebar (the person's app only), the chat column (`panel`), footer voice/assistant toggles, user menu (Approvals with count, History, Theme, Settings, Sign out). Plugin mode (iframe or `?embed=plugin`) has no chat or toggles. Chat open state and the sidebar collapse (`edgeToggles.left`, icon rail) are per owner in localStorage; `sidebarFooterDivider={false}` removes the footer line so it does not clash with the chat composer's border. |
31
+ | `src/lib/server/agent-cli/`, `assistant/agent-turn.ts`, `onboarding.ts` | Thin host adapters over smrt-inference/server (CLI, token scope, shell guidance/approvals), the `assistant` preference (engine, onboarding), onboarding routes; UI `routes/welcome`, `components/ConnectAi.svelte`, `routes/settings/assistant`. |
32
+ | `src/routes/settings/` | Settings menu (`ShellSettingsPage`), built-in sections, owner-only `inference/` (panel + actions over the shared controller). |
25
33
  | `src/routes/` | `/api/shell/*`, `/api/app/[...path]`, `/api/events`, `/mcp`, `/setup`, health. UI pages are placeholders. |
26
34
 
27
35
  ## Invariants
@@ -49,6 +57,18 @@ SvelteKit or `src/lib`). README has the route and JSON contracts.
49
57
  reserved core endpoints are refused (only the app's own collections are
50
58
  served). Snapshots are retained (20 `method-*`; clean up prunes old
51
59
  `migrate-*`/`schema-*`).
60
+ - **Drafts and targets.** Every request names its copy; there is no ambient
61
+ "current host". REST: `?target=` (`x-shell-target` on `/api/app/*`); MCP:
62
+ the URL (`/mcp`, `?target=draft`, `?scope=assistant`); tools and the
63
+ assistant pass a `CallScope`. Default live. `getHost()` is live only; the
64
+ draft host is reached only via `hostFor('draft')`; `invariants.test.ts` keeps
65
+ request paths on `hostFor` and `autoApprove: true` in `openDraftHost`. While a
66
+ draft is open live is frozen (also after a restart): builder calls naming live
67
+ are refused naming the draft; live records keep working. `publishDraft` is
68
+ always an Approval (`publish-draft`, pinned to the preview digest; the engine
69
+ refuses at apply if the chain changed); a discard is direct from the UI route,
70
+ an Approval from a tool. Approvals only ever run against live. Table changes
71
+ here are new tables only (`prepareShellSchema` cannot ALTER).
52
72
  - **Decorator configs are literals** (`@smrt({...})` on `App`, `Approval`). The
53
73
  scanner reads them statically; a shared constant silently falls back to full
54
74
  CRUD routes. Collections are undecorated. The shell models expose only their
@@ -62,7 +82,9 @@ SvelteKit or `src/lib`). README has the route and JSON contracts.
62
82
  `<data root>/user-app/` (`app.db`, `snapshots/`, archives inside app.db):
63
83
  plain files and directories inside the app-runtime data root, no symlinks, so
64
84
  the encrypted backup seals them. `PORT` and `SMRT_DATA_DIR` are honored (the
65
- desktop sidecar contract); health is `/api/_runtime/health`.
85
+ desktop sidecar contract); health is `/api/_runtime/health` and the desktop
86
+ wrapper's owner link is `/api/_runtime/owner-link` (both app-runtime handlers;
87
+ the Tricia desktop product is `smrt-desktop/examples/tricia`).
66
88
  - **App-subclass rule** lives in the engine (`enableRecipe` creates app
67
89
  subclasses of shipped models). The shell never registers user models in its
68
90
  own registry.
@@ -84,14 +106,34 @@ SvelteKit or `src/lib`). README has the route and JSON contracts.
84
106
  decides approvals (`approval_decide` is not in the catalog, and
85
107
  `ASSISTANT_DENIED_TOOLS` repeats it): only the signed-in owner does, in the UI. A risky call is held (`holdForUser`) on its Approval; only the
86
108
  owner's `Approval.decide` applies it. The loop is bounded (`DEFAULT_LIMITS`),
87
- serial, aborts on disconnect. API key only: `config.ts` refuses
88
- `claude-cli`/`chatgpt-plan`; never spawn a CLI. Model errors are never
109
+ serial, aborts on disconnect. Three engines (`assistant` preference): `api`
110
+ (`config.ts` refuses `claude-cli` and any configured `chatgpt-plan`; no
111
+ in-app Claude subscription login), `chatgpt` (the owner's Sign in with
112
+ ChatGPT, a PREVIEW for open source apps: `chatgpt.ts`, `assistant/model.ts`,
113
+ README "Sign in with ChatGPT"; runs only on the target smrt-inference's
114
+ sign-in resolver made for the `AppShellAssistantChatGPT` request, never an
115
+ API key; `chat()` + `onProgress`, none of `maxTokens`/sampling/`user`/
116
+ response format/reasoning/forced tool choice; the SDK keeps the credentials
117
+ in `<data root>/chatgpt/chatgpt.json`, never read, logged or returned here)
118
+ and a local agent CLI (`claude-code`/`codex`, the person's own signed-in
119
+ tool; `agent-cli/`, README "The local agent CLI engine"). The CLI engine gets
120
+ tools only through `/mcp` under a short-lived owner-bound `assistant.use`
121
+ token in the child env (never argv, file or log), with built-ins absent (Claude) or OS-confined (Codex; inference
122
+ `agents/engines.md`), provider keys stripped and the stream guarded; never read a
123
+ CLI's credential files or keychain, never offer or proxy its login (it opens
124
+ the vendor's page itself), plain-text product names only. Approvals stay the
125
+ shell's: `invariants.test.ts` keeps `agent-cli/` and `agent-turn.ts` free of
126
+ `decideApproval` / `approved: true`. The API key is resolved by
127
+ `resolveInferenceTarget` through the resolver `installShellInference`
128
+ registered: the owner's key saved in Settings, then stored bindings, then
129
+ config/env as `base`. Keys are written only by the owner-only `saveKey`
130
+ action; none is logged or returned (`inference-settings.test.ts`). Model errors are never
89
131
  forwarded or logged beyond class/code (SDK messages can echo keys). Tests use
90
132
  a stub through `modelFactory`.
91
133
 
92
134
  ## Tests and build
93
135
 
94
- `vitest run` (in-process, SQLite, temp dirs; `test-support.ts` seeds an owner
136
+ `vitest run` then `vitest run -c vitest.dom.config.ts` (the layout's jsdom component tests, `*.dom.test.ts`, over smrt-svelte's built `dist`; rebuild smrt-svelte first). Node suite: in-process, SQLite, temp dirs; `test-support.ts` seeds an owner
95
137
  and a member; it opens the real driver because the vitest setup would
96
138
  pre-create tables). `pnpm build` (tsc engine, `svelte-kit sync`, `vite build`;
97
139
  the smrt() plugin generates `src/routes/api/{apps,approvals,_changes,_events,
package/README.md CHANGED
@@ -12,6 +12,32 @@ smrt-app-shell (SvelteKit + adapter-node, app-runtime local profile)
12
12
  └── <data root>/user-app/ app.db, snapshots (plain files; the newest 20 `method-*` are kept, clean up prunes old migration ones)
13
13
  ```
14
14
 
15
+ ## Shared assistant pieces (Tricia)
16
+
17
+ The saved data-shell product keeps its own assistant panel, routes, thread
18
+ history, builder and draft policy. Engines use smrt-inference; human tool
19
+ labels use smrt-chat; approval decisions/consumption use app-runtime's
20
+ `OwnerApprovalAuthority` and chat's observation-only hold bridge; connection
21
+ onboarding/settings use smrt-svelte's `ConnectAi` and `mountConnectAiRoutes`.
22
+
23
+ Existing approvals **stay in `approvals`**: no data copy, migration or new
24
+ approval ids. `Approval` is a compatibility model/endpoint facade and
25
+ `approval-store.ts` adapts those rows to the shared authority. Pending requests
26
+ retain their original indefinite lifetime, preview and requester. Decided
27
+ history retains its timestamps/results. Atomic conditional updates decide and
28
+ consume once across authorities; execution is recorded before live effects.
29
+ Interrupted approvals never replay automatically. An old approved row without a
30
+ result remains an unknown execution outcome and needs manual reconciliation.
31
+ The adapter refuses storage with more than one installation tenant because the
32
+ old rows do not encode tenancy; it cannot safely infer another ownership model.
33
+
34
+ Opt-in shared compatibility options preserve generic record chips, compact
35
+ connection wording/grouping, the settings-only ChatGPT model preference, API
36
+ selection without a configured key, and remote sign-out confirmation. The
37
+ shell keeps its existing sign-out fallback to the API preference, Fresh/Skip
38
+ steps, detailed builder/code previews and draft-review link. Other applications
39
+ keep the shared defaults. See [adapter contracts and validation](agents/shared-pieces.md).
40
+
15
41
  ## Run it
16
42
 
17
43
  Build once, then set up the data folder and the owner link, then start the
@@ -34,6 +60,17 @@ the app's state folder, not the data folder). If the link is lost, stop the
34
60
  server and run `smrt app recover` for a new one. Setup is loopback only and
35
61
  single use. Health: `GET /api/_runtime/health`.
36
62
 
63
+ Desktop: the shell also mounts `GET /api/_runtime/owner-link`
64
+ (`createOwnerLinkHandler`), the route the desktop wrapper opens its window on.
65
+ It is loopback-only and answers 403 without the wrapper's per-launch secret, so
66
+ it does nothing outside the wrapper. With that secret it uses `autoClaim`: the
67
+ first launch creates the local owner (named after the OS account, no email) and
68
+ signs them in, a later launch with a lost session cookie signs the sole owner
69
+ back in, and the window lands on `/`. No owner form is shown. Under the wrapper
70
+ the signed-out and `/setup` pages say to quit and reopen the app instead of
71
+ naming terminal commands. Tricia, the packaged desktop product, is
72
+ [`smrt-desktop/examples/tricia`](../smrt-desktop/examples/tricia/README.md).
73
+
37
74
  ## Builder operations (MCP tools and `POST /api/apps/<operation>`)
38
75
 
39
76
  The same names and parameters on both surfaces. A wrong-shaped argument is a
@@ -52,6 +89,8 @@ The same names and parameters on both surfaces. A wrong-shaped argument is a
52
89
  | `enableRecipe` | `recipe` |
53
90
  | `cleanUp` | `keep?` |
54
91
  | `importTemplate`, `exportTemplate` | `data`, `includeRecords?` |
92
+ | `undoTo` (`app_undoto`) | `version` |
93
+ | `startDraft`, `previewPublish`, `publishDraft`, `discardDraft` | `name?`; none; `expect?`; none. See [Draft then publish](#draft-then-publish-the-shell). `?target=live\|draft` (REST) names the copy a builder operation acts on |
55
94
 
56
95
  A FieldDef is `{ type, label?, required?, choices?, ref? }`; `type` is one of
57
96
  text, number, integer, boolean, date, money, choice, reference, json. Field names
@@ -65,16 +104,16 @@ owner). Writes must be same-origin `application/json`. Errors are
65
104
 
66
105
  | Route | Body | Response |
67
106
  |---|---|---|
68
- | `GET /api/shell/describe` | | `{ name, version, recipes, models: { [Model]: { kind: 'app'\|'shipped-subclass', label, table, className, extends, fields: { [name]: FieldDef }, ownFields: string[], methods: { [name]: { description, params } } } }, pendingApprovals }` |
69
- | `GET /api/shell/history` | | `{ versions: { version, parent, author, summary, createdAt, purged, current }[] }` oldest first |
70
- | `POST /api/shell/undo` | none | change result `{ version, summary, statements, snapshot, appRestartMs, ... }`; 409 if purged; **202** `PendingApproval` (with `preview.replaced`, the rows that would be overwritten) when the change being undone converted a field's type |
107
+ | `GET /api/shell/describe?target=` | | `{ mode: 'live'\|'draft', draft: DraftInfo \| null, frozen, name, version, recipes, models: { [Model]: { kind: 'app'\|'shipped-subclass', label, table, className, extends, fields: { [name]: FieldDef }, ownFields: string[], methods: { [name]: { description, params } } } }, pendingApprovals }` (see [Draft then publish](#draft-then-publish-the-shell)) |
108
+ | `GET /api/shell/history?target=` | | `{ mode, versions: { version, parent, author, summary, createdAt, purged, current, publish: { draftId, name } \| null }[] }` oldest first |
109
+ | `POST /api/shell/undo?target=` | none | change result `{ version, summary, statements, appRestartMs, ... }`; 409 if purged; **202** `PendingApproval` (with `preview.replaced`, the rows that would be overwritten) when the change being undone converted a field's type |
71
110
  | `GET /api/shell/approvals?status=` | | `{ approvals: ApprovalView[], pending }`; `status` is `pending`, `approved`, `denied` or `failed` |
72
111
  | `POST /api/shell/approvals/<id>` | `{ approve: boolean }` | `{ id, status: 'approved'\|'denied'\|'failed', result?, error? }`; 404 unknown, 409 already decided |
73
- | `GET /api/shell/records/<Model>?limit=` | | `{ records: object[] }` |
74
- | `POST /api/shell/records/<Model>` | the record's fields | 201 the record |
75
- | `GET /PUT /api/shell/records/<Model>/<id>` | PUT: changed fields | the record |
76
- | `DELETE /api/shell/records/<Model>/<id>` | | **202** `PendingApproval`; nothing is deleted until approved |
77
- | `/api/app/<route>` (any method) | as the app's REST | the app's generated REST (`/api/app/chores` is the app's `/api/chores`); `DELETE` answers **202** `PendingApproval`; only the app's own collection routes are served (`sync/apply`, `_changes`, `_events` and unknown collections are 404) |
112
+ | `GET /api/shell/records/<Model>?limit=&target=` | | `{ records: object[] }` |
113
+ | `POST /api/shell/records/<Model>?target=` | the record's fields | 201 the record |
114
+ | `GET /PUT /api/shell/records/<Model>/<id>?target=` | PUT: changed fields | the record |
115
+ | `DELETE /api/shell/records/<Model>/<id>?target=` | | live: **202** `PendingApproval`, nothing is deleted until approved; draft: **200** `{ deleted: id }` |
116
+ | `/api/app/<route>` (any method; copy in the `x-shell-target` header) | as the app's REST | the app's generated REST (`/api/app/chores` is the app's `/api/chores`); `DELETE` answers **202** `PendingApproval`; only the app's own collection routes are served (`sync/apply`, `_changes`, `_events` and unknown collections are 404) |
78
117
  | `GET /api/events` | | server-sent events |
79
118
  | `POST /mcp` | MCP Streamable HTTP | shell tools plus app tools, one policy |
80
119
 
@@ -83,8 +122,8 @@ owner). Writes must be same-origin `application/json`. Errors are
83
122
  where `action` is `{ kind: 'command', command } | { kind: 'tool', name, args } | { kind: 'rest', method, url } | { kind: 'record-delete', model, id }`.
84
123
 
85
124
  `GET /api/events` sends `event: ready`, then `event: shell` with JSON
86
- `{ type: 'changed', version, summary }` (definition changed: reload
87
- `describe`, the menu and the tool catalog), `{ type: 'records', model }`
125
+ `{ type: 'changed', target, version, summary }` (definition changed: reload
126
+ `describe`, the menu and the tool catalog), `{ type: 'records', target, model }`
88
127
  (`model` may be null: reload lists) and `{ type: 'approval', id, status, summary }`
89
128
  (`pending`, `approved`, `denied`, `failed`). A `: ping` comment every 25 s.
90
129
 
@@ -93,7 +132,8 @@ REST (`POST /api/apps/<op>`) and MCP tools (`app_definemodel`, `app_addfield`,
93
132
  `app_hidefield`, `app_renamefield`, `app_removefield`, `app_changefieldtype`,
94
133
  `app_removemodel`, `app_addmethod`, `app_enablerecipe`, `app_undo`,
95
134
  `app_cleanup`, `app_exporttemplate`, `app_importtemplate`, `app_describe`,
96
- `app_history`), plus `approval_pending`. There is no tool to decide an approval: only the
135
+ `app_history`, `app_undoto`, `app_startdraft`, `app_previewpublish`,
136
+ `app_publishdraft`, `app_discarddraft`), plus `approval_pending`. There is no tool to decide an approval: only the
97
137
  owner, signed in, decides in the UI, so no model-driven client (the assistant
98
138
  or an outside MCP client) can approve its own call. The risky ones
99
139
  (`addMethod`, `removeField`, `removeModel`, `changeFieldType`, `cleanUp`, an
@@ -105,18 +145,45 @@ or an outside MCP client) can approve its own call. The risky ones
105
145
 
106
146
  - `toolsFor(principal): Promise<CatalogTool[]>`: the merged catalog (`{ name, description, inputSchema, annotations?, side: 'shell'|'app', risky }`); empty unless `principal` is the owner.
107
147
  - `authorizeCall(principal, name): Promise<CatalogTool>`: throws `PolicyError` (`status` 401 no principal, 403 not the owner or unknown tool).
108
- - `callTool(principal, name, args)`: authorize and run. A risky call returns a `PendingApproval`.
148
+ - `callTool(principal, name, args, scope?)`: authorize and run on the copies `scope` names (default: live). A risky call returns a `PendingApproval`.
109
149
  - `Principal = { id, tenantId, scopes? }`; get one from the request with `principalFromLocals(locals)` (`owner.ts`).
110
150
 
151
+ ## Settings
152
+
153
+ `/settings` is the section menu (`ShellSettingsPage`): Appearance, Account,
154
+ Layout and Shortcuts, plus **Assistant** (`/settings/assistant`: the engine,
155
+ see Onboarding) and **Inference** (`/settings/inference`), the
156
+ `InferenceSettingsPanel` from smrt-inference. Owner only: every load and action
157
+ runs as the verified owner (a member or a stranger gets 403 before any body is
158
+ read). Saving a provider key (`saveKey`) binds it to its endpoint, stores it in
159
+ smrt-secrets (sealed under the app-runtime master key, created once into the
160
+ vault key store; `SMRT_SECRET_MASTER_KEY` wins), and `keySet` is read back from
161
+ the resolver's redacted view; the key itself never returns to the browser. The
162
+ shell registers smrt-inference's user resolver at startup, which is what the
163
+ assistant resolves through. The inference, preferences and secrets tables are
164
+ created by `prepareShellSchema` on first start (`consumer.packages` in
165
+ `smrt.config.ts` lists their packages). The load and writes are smrt-inference's
166
+ `createInferenceSettingsController`, shared with the template.
167
+
111
168
  ## The assistant
112
169
 
113
- The dock (`src/lib/assistant/AssistantDock.svelte`, mounted for the owner in
114
- the layout) talks to `mountAssistantRoutes` (`src/lib/server/assistant/`).
170
+ The chat column (`src/lib/assistant/AssistantPanel.svelte`, the `panel` of
171
+ smrt-svelte's `AppShell` in `src/routes/+layout.svelte`, mounted for the owner)
172
+ talks to `mountAssistantRoutes` (`src/lib/server/assistant/`). The layout is
173
+ left sidebar, chat, content; the voice and assistant toggles and the user menu
174
+ sit in the sidebar footer. The sidebar collapses to an icon rail (nav icons with
175
+ tooltips, the footer controls as icons) from the chevron above the footer; the
176
+ choice is remembered per owner, and the footer has no divider line (it would sit
177
+ beside the chat composer's top border). The boundary between chat and content is a draggable
178
+ separator (arrow keys, double-click resets); its width and the open state are
179
+ remembered per owner. As a plugin view (iframe, or `?embed=plugin`) there is no
180
+ chat and no toggles: the host app is the chat. Voice mode shows as not
181
+ available yet; nothing connects speech to the assistant.
115
182
  Owner session required on all of them (401/403); the POST is same-origin.
116
183
 
117
184
  | Route | Body | Response |
118
185
  |---|---|---|
119
- | `GET /api/assistant/status` | | `{ configured: boolean, code?, message? }`; never a key, provider or endpoint. `code` is `assistant_not_configured` (no API key), `assistant_provider_refused` (subscription provider configured) or `ai_unavailable` (inference off) |
186
+ | `GET /api/assistant/status` | | `{ configured: boolean, engine?, engineLabel?, code?, message?, action? }`; never a key, provider, endpoint or path. `engine` is `api`, `claude-code`, `codex` or `chatgpt`. `code` is `assistant_not_configured` (no API key), `assistant_provider_refused` (subscription provider configured), `ai_unavailable` (inference off), `agent_cli_missing` or `agent_cli_signed_out` (a local CLI engine that is not ready), `chatgpt_signed_out` (Sign in with ChatGPT not done); `action` is the link that fixes it |
120
187
  | `GET /api/assistant/threads` | | `{ threads: { id, title, updatedAt }[] }` newest first |
121
188
  | `GET /api/assistant/threads/<id>` | | `{ thread, messages: ThreadMessage[] }`; 404 for a missing or foreign thread |
122
189
  | `POST /api/assistant/turn` | `{ message, threadId? }` | `text/event-stream`, `event: turn`, JSON `TurnEvent` (below). **503** `{ type: 'unavailable', configured: false, code, error }` when the assistant cannot run; 409 while another turn is running |
@@ -124,7 +191,7 @@ Owner session required on all of them (401/403); the POST is same-origin.
124
191
  `TurnEvent` (`src/lib/assistant/events.ts`): `thread {threadId}`, `text {delta}`,
125
192
  `tool_call {id,name,args}`, `tool_result {id,name,ok,summary}`,
126
193
  `approval_required {id,name,approvalId,summary,preview}`,
127
- `approval_decided {id,approvalId,status}`, `error {code,message}`,
194
+ `approval_decided {id,approvalId,status}`, `error {code,message,action?}`,
128
195
  `done {stop}` (`complete | max_steps | max_tokens | timeout | aborted | waiting | error`).
129
196
  Closing the request (the dock's Stop button, a closed tab) aborts the model
130
197
  call, the running tool and any wait.
@@ -140,13 +207,28 @@ call, the running tool and any wait.
140
207
  wait times out or the client leaves, the model is told it is waiting, the
141
208
  turn ends as `waiting`/`aborted`, and the Approval stays pending, unapplied.
142
209
  The assistant has no way to set the engine's `approved` flag.
143
- - **Engine.** `@happyvertical/ai` through core's inference tiers
144
- (`resolveInferenceTarget`, tier `good`, override `SMRT_APP_SHELL_ASSISTANT_TIER`).
145
- The key comes from the `ai` block of `smrt.config.ts`, `SMRT_AI_*` or the
146
- provider's own variable (`ANTHROPIC_API_KEY`), read only server side, never
147
- logged or sent to the browser, until bring-your-own (#4666) lands. Subscription
148
- providers (`claude-cli`, `chatgpt-plan`) are refused. With no key,
149
- status/turn answer the typed not-configured state and the dock shows it.
210
+ - **Engines.** Three, chosen by the owner (the non-secret `assistant`
211
+ preference, `engine` = `api` | `claude-code` | `codex` | `chatgpt`; default
212
+ `api`):
213
+ - **API key** (`api`): `@happyvertical/ai` through core's inference tiers
214
+ (`resolveInferenceTarget`, tier `good`, override
215
+ `SMRT_APP_SHELL_ASSISTANT_TIER`). The key is the owner's, pasted once in
216
+ **Settings > Inference** (`/settings/inference`, see below), or the
217
+ deployment's: the `ai` block of `smrt.config.ts`, `SMRT_AI_*` or the
218
+ provider's own variable (`ANTHROPIC_API_KEY`). The saved key wins, then the
219
+ stored tier bindings, then config/env, so a desktop app launched from
220
+ Finder works with no environment. The key is read only server side, never
221
+ logged or sent to the browser. Subscription providers (`claude-cli`,
222
+ `chatgpt-plan` from configuration) are refused here, as is any in-app
223
+ Claude subscription login.
224
+ - **Local agent CLI** (`claude-code`, `codex`): the turn runs through the
225
+ person's own, already installed and signed-in Claude Code or Codex
226
+ (`src/lib/server/agent-cli/`, `assistant/agent-turn.ts`). See below.
227
+ - **ChatGPT** (`chatgpt`): the owner's own ChatGPT plan through OpenAI's
228
+ official Sign in with ChatGPT flow (see below).
229
+ With no usable engine, status/turn answer the typed not-configured state, the
230
+ dock shows it with a link to fix it, and it checks again each time it is
231
+ opened, so a saved key or a finished sign-in needs no restart.
150
232
  - **Bounds** (`DEFAULT_LIMITS` in `turn.ts`): 8 model rounds, 2048 output
151
233
  tokens per call, 60 000 tokens per turn, 4 calls per round, 60 s per model
152
234
  call, 180 s of model/tool time per turn, 15 min per approval wait.
@@ -154,6 +236,261 @@ call, the running tool and any wait.
154
236
  - **Threads** persist in shell.db (`assistant_threads`, `assistant_messages`).
155
237
  - **Tests** inject a stub model (`modelFactory` option, `test-helpers.ts`).
156
238
 
239
+ The reusable CLI engine, ChatGPT model adapter and connection diagnostics live in
240
+ [smrt-inference/server](../inference/agents/engines.md). The shell supplies its
241
+ owner token scope, MCP route, thread persistence, draft guidance and approval
242
+ presentation through thin host adapters; its policy remains the authority.
243
+
244
+ ### Sign in with ChatGPT (a preview for open source apps)
245
+
246
+ OpenAI's official flow for local open source apps, a **preview**: the
247
+ authorization basis and its limits are in OpenAI's
248
+ [preview limitations](https://developers.openai.com/siwc/token-sharing-open-source/preview-limitations).
249
+ Claude has no equivalent here: its subscription stays on the Claude Code
250
+ engine below, and `claude-cli` and any in-app Claude subscription login stay
251
+ refused.
252
+
253
+ - **Sign-in** (`lib/server/chatgpt.ts`, smrt-inference's
254
+ `createChatGPTPlanConnection`): "Sign in with ChatGPT" on `/welcome` and in
255
+ Settings > Assistant. Under the desktop wrapper this process opens the system
256
+ browser (`open` / `xdg-open` / `cmd /c start`) after checking the address is
257
+ https on `auth.openai.com`; in a plain browser session the page shows a normal
258
+ link. The SDK's loopback listener (127.0.0.1, PKCE, dynamic client
259
+ registration, OIDC validation) receives the callback, the page polls until it
260
+ is signed in and then selects the engine.
261
+ - **Credentials** live only in the SDK's own owner-only file,
262
+ `<data root>/chatgpt/chatgpt.json` (directory `0700`, file `0600`, outside any
263
+ source tree; the encrypted backup that seals the data root seals it too).
264
+ This app never reads it and never logs, returns or copies a token: the
265
+ provider gets a `{ accessToken(clientId) }` handle, the browser sees
266
+ signed in / signing in / an account label. Sign out clears it and asks OpenAI
267
+ to revoke. `SMRT_APP_SHELL_NAME` sets the app name OpenAI's page shows.
268
+ - **The turn** (`assistant/model.ts`): the `chatgpt` engine asks the resolver
269
+ smrt-inference composed (`installShellInference`) with its own request name,
270
+ and runs ONLY on the `chatgpt-plan` target that sign-in produced; with nobody
271
+ signed in it is unavailable ("Sign in with ChatGPT"), never an API key, never
272
+ another provider. The loop uses `chat()` with `onProgress` (the provider's
273
+ `stream()` rejects tools) and a tool namespace (`shell_tools`); it sends no
274
+ `maxTokens`, sampling, `user`, response format, reasoning option or forced
275
+ tool choice, which the preview refuses. The model is the owner's pick in
276
+ Settings > Assistant, else the first model in the account's catalog.
277
+ Approvals, the offer gate and the deny list are unchanged.
278
+ - **Failures** become dock messages: usage limit, revoked session and workspace
279
+ refusals (the last two with "Sign in again", linking to
280
+ Settings > Assistant), "unavailable, still signed in", and the preview's
281
+ unsupported-capability refusal. Only the error class and code are logged.
282
+ - **Tests** stub the provider (`assistant/chatgpt-engine.test.ts`), the
283
+ connection (`routes/onboarding-chatgpt.test.ts`) and the opener
284
+ (`lib/server/chatgpt.test.ts`); nothing signs in.
285
+
286
+ ### The local agent CLI engine
287
+
288
+ **Rule (two lines).** No in-app Claude subscription login: this app never
289
+ offers Claude.ai sign-in and never collects, stores or passes on those
290
+ credentials. The local-CLI engine only runs the person's own, unmodified Claude
291
+ Code or Codex, signed in through the vendor's own flow and billed to them
292
+ ([Claude Code legal and compliance](https://code.claude.com/docs/en/legal-and-compliance)).
293
+ Product names appear as plain text, no logos.
294
+
295
+ - **Detection** (`agent-cli/locate.ts`, `detect.ts`): the executable on PATH,
296
+ then the usual install places (`~/.local/bin`, `~/.claude/local`, Homebrew,
297
+ `/usr/local/bin`, npm/pnpm/volta/bun/nvm bins, Windows equivalents), then the
298
+ person's login shell (`command -v`), so an app started from Finder finds it.
299
+ Version and sign-in come only from the tool's own commands (`--version`,
300
+ `claude auth status`, `codex login status`); no credential file or keychain
301
+ item is read, and the browser sees only installed / version / signed in. Not
302
+ installed: a link to the vendor's install page, nothing is installed for them.
303
+ Windows runs a real `.exe` (a `.cmd` shim is not started, there is no shell).
304
+ - **Connect** (`agent-cli/login.ts`): signed out, it starts the tool's own
305
+ sign-in (`claude auth login`, `codex login`), which opens the vendor's page
306
+ in the system browser; the app only watches the process end, then asks the
307
+ status command again.
308
+ - **A turn** (`agent-cli/session.ts`, `assistant/agent-turn.ts`):
309
+ `claude -p --output-format stream-json --verbose --include-partial-messages`
310
+ or `codex exec --json`, the prompt on stdin, streamed JSON mapped onto the
311
+ same `TurnEvent`s (the dock has no engine-specific code). Locked down:
312
+ - Tools: only the shell's, from `/mcp`, with the same owner policy and
313
+ Approval gate. Every built-in tool that touches the machine is off (Claude:
314
+ `--tools ""` plus a strict MCP config; Codex: shell, exec, apps, plugins,
315
+ hooks, sub-agents, code mode and more disabled, read-only sandbox, approvals
316
+ never, no web search). The stream is also checked: any other tool, or an
317
+ unknown Codex item kind, stops the turn.
318
+ - Token: a short-lived (15 min), owner-bound, `assistant.use`-scoped
319
+ app-runtime local MCP token, issued under one holder (each turn rotates the
320
+ last) and revoked when the turn ends. It is in the child's environment only:
321
+ never argv, a file or a log, and only ever sent to a loopback URL. Claude
322
+ reads it through `${VAR}` in its MCP header config; Codex through
323
+ `bearer_token_env_var`.
324
+ - Environment: an allow-list (`agent-cli/env.ts`); `ANTHROPIC_*`, `OPENAI_*`,
325
+ `CODEX_*`, `CLAUDE_CODE_*` and `SMRT_*` never reach the child, so the tool
326
+ uses the person's own login. Working directory: an empty private folder
327
+ `<data root>/agent-cli/work`. Claude loads none of the person's settings,
328
+ hooks, skills or plugins; Codex ignores their `config.toml` and rules.
329
+ - Approvals: a risky call returns `pending_approval` from `/mcp`; the turn
330
+ shows the card (`approval_required`) and ends `waiting`. Only the owner's
331
+ decision applies it (`Approval.decide`). The tool is told not to retry.
332
+ - Bounds: 10 minutes per turn; cancel (the Stop button) and timeout kill the
333
+ whole process tree. A later turn resumes the tool's own session (stored on
334
+ the thread's last assistant message), else starts a new one.
335
+ - Known gaps: Codex still offers its own code-mode `exec` and `apply_patch`
336
+ tools to the model (no flag removes them; the read-only sandbox and the
337
+ stream guard are what stop them), and it still reads the person's own global
338
+ Codex instructions file (its home folder AGENTS.md). Codex 0.162.1 needs `--enable mcp_2026_07_28` to
339
+ reach `/mcp`. The tool keeps its own session transcript in its own folder.
340
+ - **Tests:** stub CLIs (shared `smrt-inference/test-support`) over the real
341
+ `/mcp` route (`assistant/agent-turn.test.ts`); fake binaries on a temp PATH
342
+ (`agent-cli/detect.test.ts`). An opt-in run against a real installed CLI:
343
+ `REAL_AGENT_CLI=claude-code|codex npx vitest run
344
+ src/lib/server/assistant/agent-cli.manual.test.ts` (spends the person's own
345
+ subscription, one short turn).
346
+
347
+ ## Onboarding
348
+
349
+ `/welcome` (owner only; an owner with an empty app who has not finished or
350
+ skipped it is sent there once): **1. Connect your AI** (Use Claude Code,
351
+ Sign in with ChatGPT, plus Advanced: the full inference setup at
352
+ `/settings/inference`, which links back; Codex is an advanced option under
353
+ Settings > Assistant, not on this page) and **2. How do you want to start?** (Fresh: an empty app with the
354
+ assistant dock open; Wizard: shown, disabled, coming soon). Skippable. Finished
355
+ or skipped is stored as `onboarding` in the `assistant` preference
356
+ (`agent-cli/settings.ts`, the smrt-preferences store smrt-inference also uses;
357
+ not a member of the `inference` payload, which the settings panel replaces whole,
358
+ and not a tier target of core's inference model). **Settings > Assistant**
359
+ (`/settings/assistant`) switches engine and re-opens the steps. JSON routes
360
+ (owner only, same-origin writes): `GET /api/shell/onboarding[?fresh=1]`,
361
+ `POST /api/shell/onboarding/{connect,engine,complete,skip}`, and
362
+ `GET`/`POST /api/shell/onboarding/chatgpt` (start, sign-out, model;
363
+ `lib/server/onboarding.ts`).
364
+
365
+ ## Draft then publish (the shell)
366
+
367
+ A draft is a test copy of the person's app (its definition and its records at
368
+ that moment) that they build in while the live app keeps working. Rules the UI
369
+ can rely on:
370
+
371
+ - One draft at a time. While it is open **live structure is frozen** (frozen
372
+ again after every restart); live **records** keep working (create, edit,
373
+ delete, reports, the app's own REST and tools).
374
+ - A draft's own records are throw-away test data and are **not carried** at
375
+ publish; records added to live meanwhile are kept.
376
+ - A publish lands as N ordinary live versions (summaries tagged
377
+ `[draft: <name>] ...`) and can be undone in one step with `undoTo`.
378
+ - **Every request names its copy**, there is no "current host": REST uses
379
+ `?target=live|draft` (absent = `live`; anything else is 400), `/api/app/*`
380
+ uses the `x-shell-target` header (its query string belongs to the app), MCP
381
+ uses the URL. `target=draft` with no draft open is 404 `no draft open`.
382
+ - A structural request that names `live` while a draft is open is **409**
383
+ `The draft "<name>" is open, so the live app's structure is frozen. Make this
384
+ change in the draft (target=draft), or publish or discard the draft first.`
385
+ - A risky change in the draft (remove a field or model, change a type, add a
386
+ method, delete a record, a DELETE through the app's REST) applies at once: the
387
+ draft host approves its own. On live it is an Approval, as before.
388
+
389
+ ### Routes (owner only: 401 signed out, 403 anyone else; writes same-origin JSON)
390
+
391
+ | Route | Body | Response |
392
+ |---|---|---|
393
+ | `GET /api/shell/draft` | | `{ draft: DraftInfo \| null, view: 'live'\|'draft' }` |
394
+ | `POST /api/shell/draft` | `{ name? }` | **201** `DraftInfo`; 409 if one is already open |
395
+ | `POST /api/shell/draft/preview` | none | `{ draft: DraftInfo, preview: PublishPreview }`; 404 no draft; 409 unpublishable |
396
+ | `POST /api/shell/draft/publish` | `{ expect? }` (the preview's `digest`) | **always 202** `PendingApproval`; 409 when `expect` no longer matches (review again) |
397
+ | `POST /api/shell/draft/discard` | `{ draftId? }` | `{ discarded: name }`; the owner's direct action (the UI confirms first); 404 none, 409 wrong id |
398
+ | `GET /api/shell/draft/view` | | `{ view }` (`live` whenever no draft is open) |
399
+ | `PUT /api/shell/draft/view` | `{ view: 'live'\|'draft' }` | `{ view }`; `draft` with no usable draft is 409 |
400
+ | `POST /api/shell/undo-to?target=` | `{ version }` | the change result; 202 `PendingApproval` when an undone change converted a field's type; 409 purged |
401
+ | `POST /api/apps/{startDraft,previewPublish,publishDraft,discardDraft,undoTo}` | as the tools | the same operations as generated REST; `publishDraft` and `discardDraft` answer **202** (an Approval) |
402
+
403
+ `DraftInfo = { id, name, status: 'open', baseVersion, version, changes,
404
+ createdAt, createdBy, liveVersion, liveFrozen, publishable, problem, recovery }`.
405
+ `changes` counts the changes made in the draft; `version` is null when its copy
406
+ cannot be opened. `publishable: false` comes with a `problem` sentence (the data
407
+ folder was restored or moved so the draft names another live app; its files are
408
+ missing; it cannot be opened): show it and offer Discard (the only way out).
409
+ `recovery: 'rolled-back'` means a restart interrupted a publish and it was
410
+ undone; the draft is still open, publish again.
411
+
412
+ `PublishPreview = { digest, risky: true, fromVersion, toVersion, versions:
413
+ { version, draftVersion, author, summary, steps, statements }[], definition:
414
+ DefinitionDiff, archives: { values, tables }, methods: { model, name, change:
415
+ 'added'\|'changed'\|'unchanged', description, code, ... }[] }`. EVERY method of
416
+ the final definition is listed in full (draft code reaches live at publish, show
417
+ it). Archive counts are of that moment and can drift. The publish Approval's
418
+ `preview` is this object plus `draft` (the name); its `action` is
419
+ `{ kind: 'publish-draft', draftId, draftName, digest }`. Approving applies
420
+ exactly that chain and **fails** (status `failed`, the draft stays open) if the
421
+ draft changed since. Approve through `POST /api/shell/approvals/<id>` as for any
422
+ Approval. A discard asked by a tool is `{ kind: 'discard-draft', draftId,
423
+ draftName }`. A successful publish result is `{ version, versions, summary,
424
+ archives, appRestartMs, draft }`.
425
+
426
+ `describe` for each copy: `GET /api/shell/describe?target=live` is
427
+ `{ mode: 'live', frozen: true, draft: DraftInfo, ... }` while a draft is open;
428
+ `?target=draft` is `{ mode: 'draft', frozen: false, draft: DraftInfo, ... }`;
429
+ with no draft `draft` is null and `frozen` false. A view of live therefore has
430
+ what it needs for the draft strip from the same call.
431
+
432
+ History: `GET /api/shell/history` (live) tags every version a publish made with
433
+ `publish: { draftId, name }` (group rows by `draftId`; "Undo this publish" is
434
+ `POST /api/shell/undo-to { version: <parent of the group's first row> }`).
435
+ `?target=draft` lists only changes made in the draft (the live base version is
436
+ left out).
437
+
438
+ Events (`GET /api/events`) now say which copy changed:
439
+ `{ type: 'changed', target: 'live'|'draft', version, summary }`,
440
+ `{ type: 'records', target, model }` (reload only the view of that copy) and
441
+ `{ type: 'draft', status: 'started'|'published'|'discarded'|'unpublishable', id, name }`
442
+ (everything is stale: reload describe, history, lists).
443
+
444
+ ### Tools and MCP
445
+
446
+ Tools: `app_startdraft` (`name?`), `app_previewpublish`, `app_publishdraft`
447
+ (`expect?`; always a `pending_approval`), `app_discarddraft` (a
448
+ `pending_approval`), `app_undoto` (`version`). `/mcp` names its copy in the URL,
449
+ fixed per endpoint: `/mcp` is live (builder tools are refused while a draft is
450
+ open; record tools work), `/mcp?target=draft` is the draft, and
451
+ `/mcp?scope=assistant` is the assistant's split, resolved per call. In code,
452
+ `callTool(principal, name, args, scope)` takes a
453
+ `CallScope = { records, structure }` (`scopeFor('draft')` puts both on the
454
+ draft); the default is live.
455
+
456
+ ### The assistant
457
+
458
+ All engines (API key, ChatGPT plan, Claude Code, Codex) use the same scope
459
+ (`assistant/scope.ts`), resolved at every call. No draft: everything is live,
460
+ approvals as before. A draft open: builder tools go to the draft with no
461
+ per-change approval and record tools to the copy the person is viewing (the
462
+ saved view). The system prompt and every turn input carry the current mode (including resumed CLI sessions); stored user messages stay unchanged. `tool_result` events
463
+ carry `copy: 'live'|'draft'` while a draft is open, and a turn that changed
464
+ structure in the draft closes with "I made that change in the draft ... so the
465
+ live app is unchanged". Publishing through the assistant raises the publish
466
+ Approval and holds the turn; it never applies.
467
+
468
+ ## Draft then publish (engine API)
469
+
470
+ The engine can run a second host, a draft, over a copy of the live data and
471
+ later replay the draft's changes onto live. Rules and failure behaviour are in
472
+ [agents/engine.md](agents/engine.md); the API on `AppHost` (exported from
473
+ `src/engine`):
474
+
475
+ ```ts
476
+ const live = await AppHost.open(dir, { store });
477
+ const draft = await live.forkDraft(draftDir, { store: emptyDraftStore }); // autoApprove on
478
+ live.setFrozen(true); // live keeps serving records; structural commands are refused
479
+ // ...build in `draft` (draft.execute / draft.create ...)...
480
+ const preview = await live.previewPublish(draft); // diff, archives, all method code, digest
481
+ await live.publishDraft(draft, {
482
+ author, approved: true, expect: preview.digest, draftName: 'Spring cleanup',
483
+ }); // N ordinary live versions; any failure leaves live's app.db untouched
484
+ live.setFrozen(false);
485
+ await draft.discard(); // closes it and deletes only the directory fork made
486
+ await live.execute({ type: 'undoTo', version: base, from: live.version }, { author });
487
+ ```
488
+
489
+ `AppHost.open(dir, { store, autoApprove: true })` only works on a directory the
490
+ engine marked as a draft. A host start reconciles an interrupted publish
491
+ (`host.recovery`). A `VersionStore` may implement the optional atomic
492
+ `commit(records, current)`.
493
+
157
494
  ## First-run schema
158
495
 
159
496
  `prepareShellSchema(db)` (`src/lib/server/schema.ts`) creates the tables of every
@@ -172,3 +509,21 @@ pnpm --filter @happyvertical/smrt-app-shell typecheck
172
509
  ```
173
510
 
174
511
  See [AGENTS.md](AGENTS.md) for the invariants and [agents/engine.md](agents/engine.md) for the engine.
512
+
513
+ ### Draft UI
514
+
515
+ The content header offers **Start draft**, then **Live / Draft**, the unpublished
516
+ change count, **Discard** (confirmed), and **Review and publish**. Starting a draft
517
+ opens its view; switching copies saves the owner's choice and returns home. In
518
+ Draft, a warning explains that test records are thrown away and draft-only models
519
+ have a **new** marker. Chat shows **Building in draft**; tool chips name the copy,
520
+ including after reopening a thread.
521
+
522
+ `/draft/review` rehearses the publish and shows definition changes, conversions,
523
+ archive counts, and all final method code. **Publish** requests a digest-pinned
524
+ Approval; the existing approval card resolves it. A changed-chain failure offers
525
+ **Review again**. Counts can drift as live records change. History groups each
526
+ publish under its draft name; **Undo this publish** returns to its first parent
527
+ (and takes back subsequent changes), with an Approval if needed. Lifecycle events
528
+ refresh the bar and pages without reloading. An unusable draft explains the problem
529
+ and keeps Discard available. See [UI validation](agents/draft-ui.md).