sparkquill_desktop_on_platform_plan - manishiitg/agentworks GitHub Wiki
Status (2026-09-06): direction decided β the SparkQuill desktop becomes an AgentWorks client.
desktop-sparkquill will spawn the same two binaries the AgentWorks desktop spawns (workspace-server +
the agent_go server) with SparkQuill as a product (internal/sparkquillproduct/product.yaml, on by default),
and cmd/family-server + the bubble chat in LearningApp.tsx are retired. Stated goal: "very less code
duplication between SparkQuill and AgentWorks β share the backend and frontend code as much as possible."
This reverses the 2026-09-03 decision to keep a separate family-server. Part A below is the research (A1βA9)
and the phased implementation plan (A10: P0 server enablers β P1 shell sharing β P2 delete the bubble chat and move
the learning app under frontend/src/products/sparkquill β P3 blocking parity β P4 gaps β P5 migration β P6
deletions β P7 live verification; ~25β30 dev-days to the first shippable milestone, ~8β11 trailing).
What follows is the research record from 2026-09-06. Sections 1β6 (facts, reuse seams, wire contracts,
mismatches) remain accurate and are the reference for how PlatformChat talks to the server. The "Plan",
"Verification" and "Risks" sections describe the rejected alternative β teaching family-server the
AgentWorks session protocol (~12β15 days) β and are kept for the record, not for execution.
desktop/main.js (AgentWorks) is the template; desktop-sparkquill/main.js re-implements ~250β300 lines of it
(login-shell PATH import desktop/main.js:54-92 vs desktop-sparkquill/main.js:44-68; bounded 25 MB log writer
:165-202 vs :74-87; detect(port); health poll :1358-1389 vs :138-158; external-URL interception
:1797-1838 vs :189-197; render-process-gone reload; cache-bust query; GitHub release listing + nohup install*.sh
:1401-1525,1675-1706 vs updater.js:30-97; tray; DEV_URL bypass; SIGTERM child kill). The two installers
(install.sh, install-sparkquill.sh) differ only in app name, tag prefix, dmg name and env prefix.
Genuinely SparkQuill-specific in the shell: light theme + cream #fbf7ef, dock icon when unpackaged, tray labels,
menu (Open SparkQuill Folder β ~/.sunlit-learning), close hides to tray (AgentWorks quits), voice
warm/unload on window show/hide (main.js:127-136,212-213), gentler notification-only updater, sparkquill-v*
tags with gh release create (.github/workflows/sparkquill-desktop.yml:112-119 explains why not electron-builder's
publisher).
Recommendation: extract desktop/lib/{loginEnv,boundedLog,spawnServer,health,updater,externalNav}.js and a
desktop/products/<id>.js descriptor (appId, productName, userDataDir, preferredAgentPort, preferredWorkspacePort, windowTitle, backgroundColor, theme, trayLabels, iconPath, tagPrefix, dmgName, installScriptUrl, closeBehavior, extraServerEnv), selected by AGENTWORKS_PRODUCT / a baked product.json. One app directory emits both apps via
electron-builder --config build/sparkquill.yml (two config files: appId, productName, icon, extraResources);
keep the two release workflows (independent cadence is intentional) but unify on the SparkQuill publish approach;
one parameterized installer. Note desktop/dev-setup.sh:32-38 does not produce resources/lib (only CI does via
scripts/build-darwin-voice-binary.sh), so a clean local npm run build can fail today.
-
spawnWorkspace():1108-1182:workspace-server server --port <detect(45679)> --docs-dir <docsDir>, envDOCS_DIR,DATA_DIR=<userData>/data,NATIVE_WORKSPACE=true,WORKSPACE_API_TOKEN(random 32 B hex,:23); ready on stdoutDynamicPort: N(workspace/server.go:202). -
spawnAgent():1184-1356: cwd = Resources dir (packaged) /agent_go(dev) β this is what makes./static/resolve; copies+rewritesconfigs/mcp_servers_clean.jsonβ<userData>/configs/mcp_servers.json; argsserver --port <detect(45678)> --log-file β¦ --log-level debug --mcp-config β¦; envWORKSPACE_API_URL,WORKSPACE_DOCS_PATH,DOCS_DIR,LOG_FILE,NATIVE_WORKSPACE=true,WORKSPACE_API_TOKEN, inheritedAUTH_SECRET; ready onDynamicPort:(server.go:2582). - Health
β¦/api/health+β¦/health, 90 s; window loadshttp://127.0.0.1:<agentPort>/?runloop_version=<v>β the server-served frontend (server.go:2533spaStaticFileHandler("./static/"), cwd-relative, no env override). - Packaging
desktop/package.json:28-53extraResources:agent-server,workspace-server,lib,configs,../agent_go/static β static.
-
JWT gate (always on):
AuthMiddlewareon all/api/*(server.go:1947). Desktop satisfies it silently:MULTI_USER_MODEunset βPOST /api/auth/loginmints a default-user JWT signed with the shell-generatedAUTH_SECRET(user_auth_routes.go:222-238). First launch asks nothing unlessconfig/provider-api-keys.jsonis encrypted with an unknown secret (auth-prompt.html#unlock,main.js:464-516). -
Claude-Code token gate:
handleQuery400s aclaude-codeprofile with no storedClaudeCodeOAuthTokenonly whenisSingleProductServerDeployment()(exactly one entry inAGENT_PRODUCTS,server.go:139-172, 3583-3594) orruntime.require_provider_token.AGENT_PRODUCTS=sparkquillwould 400 every SparkQuill turn (its default provider isclaude-code,product.yaml:100-105,216-222). The desktop must leaveAGENT_PRODUCTSunset (registering the other products costs a few ms and no runtime surface) or add an explicit desktop exemption. Also:registeredProductIDs()(user_directory.go:826-835) omitssparkquillβ fix. No other setup-token/preflight gate exists.
Working recipe already exists: scripts/run-local-instance.sh:277-297.
| Resource | Collides? | Fix in the SparkQuill shell |
|---|---|---|
Electron userData (appData/runloop-desktop, RUNLOOP_USER_DATA_DIR) |
yes if shell shared | per-product userData
|
Ports detect(45678/45679)
|
soft (origin flips β localStorage/JWT reset) | own preferred pair (e.g. 45778/45779) |
workspace-docs root (_system/costs.sqlite, config/provider-api-keys.json, config/whatsapp-sessions, .agentworks/product-dependencies.json) |
yes | separate RUNLOOP_DOCS_DIR/WORKSPACE_DOCS_PATH
|
CLI-security store UserConfigDir()/AgentWorks/cli-security (hardcoded, log.Fatalf, pkg/clisecurity/store.go:40-45) |
yes, global | add an env override, or accept sharing |
Voice models ~/.agentworks/voice-models/β¦ (.partial + rename, no lock, pkg/voicestt/model.go:253,282) |
first-run download race | lock or single warm; steady-state sharing is desirable |
tmux: cmd/server reaper/close match mlp-* only (coding_agent_tmux_reaper.go:209-214, session_lifecycle.go:178-185); startup sweep is ownership-tagged |
no cross-kill |
do not set *_SESSION_PREFIX=sq-* (sessions would leak); TMUX_TMPDIR for hard isolation |
Browser startup --kill-all/pkill chromium (server.go:1874-1919) |
destructive |
AGENTWORKS_SKIP_GLOBAL_BROWSER_CLEANUP=true, AGENTWORKS_BROWSER_SESSION_PREFIX=sparkquill, AGENT_BROWSER_CONFIG
|
Coding-CLI login (~/.claude) |
shared by design | leave |
Unconditional or independently gated in runServer (server.go:1450): tmux orphan sweep (MCP_DISABLE_TMUX_STARTUP_SWEEP),
EventStore, terminal store, chat store (needs workspace-server, log.Fatalf), cost-ledger SQLite,
NotificationManager + Org Dashboard connector, Slack/Gmail init (self-disable), CLI security store (log.Fatalf),
platform tools, WhatsApp (WHATSAPP_ENABLED=false to skip), inactive-session cleanup, Scheduler +
ProductScheduleService (SCHEDULER_ENABLED=false would also kill SparkQuill's product:sparkquill:pulse check-in),
browser kill-all (skip flag), pprof, tool-cache warm, ~50 route groups.
-
spaStaticFileHandler("./static/")is the single mount (server.go:2533), cwd-relative. - Dynamic
/runtime-config.js(server.go:2513-2521) emits onlyapiBaseUrl/workspaceApiBaseUrl, but the frontend already readsappName/faviconUrl(frontend/src/runtime-branding.ts:29-46) anddefaultProductSurface/enabledProductSurfaces(frontend/src/products/productSurfaceConfig.ts:10-38) β today set only by static files (deploy/aws-ec2/server/runtime-config.js,agent_go/run_server_with_logging.sh:1011-1021). -
sparkquillis not a surface:PRODUCT_SURFACES = ['agentworks','video-studio','finance','dominion'](productSurfaceConfig.ts:1, store unionuseProductSurfaceStore.ts:4,27-34, switchApp.tsx:910-914); selection is store-persisted, not URL-routed. -
Recommended: emit
defaultProductSurface/enabledProductSurfaces/appName/faviconUrlfrom/runtime-config.js(sources:AGENT_PRODUCTS,AGENTWORKS_APP_NAME/AGENTWORKS_FAVICON_URL, or the manifest βsparkquillproduct/product.yaml:250-259already declaresbranding.icon/faviconandui.surface: sparkquill); addsparkquilltoPRODUCT_SURFACES+ store +App.tsx; shell loads plain/. One Vite build, onestatic/. Add aSTATIC_DIRenv forserver.go:2533to drop the cwd coupling (and the log-symlink hackmain.js:891-924). Alternative with zero server change: SparkQuill'sResources/static= learning-app dist (keeps two builds).
How surfaces work today (no router): App.tsx:908-916 is a ternary on the persisted zustand value
useProductSurfaceStore (agentworks-product-surface, version 3, default agentworks); lazy imports App.tsx:45-47;
when a product surface is active the whole AgentWorks tree is simply not rendered. Allowlist/default come from
window.__APP_RUNTIME_CONFIG__.{enabledProductSurfaces,defaultProductSurface} (productSurfaceConfig.ts:10-39),
enforced at App.tsx:97-110; branding from appName/faviconUrl (runtime-branding.ts, applied once at boot).
product.yaml's branding:/ui: are not on the wire β only validated in Go (sparkquillproduct/product_config.go:49).
Video Studio-as-default exists only via the static deploy/aws-ec2/server/runtime-config.js; the desktop has no
product handling at all.
learning-app today: 9,095 lines / 37 files (LearningApp.tsx 4,629; learning-app.css 1,298). It is not
really a separate app: it imports its chat from ../../src, resolves deps from ../node_modules, is tested by the
parent's vitest and linted by the parent's eslint, and is type-checked by nothing ("build": "vite build", no
tsc). Costs of the split: four vite.config.ts hacks (fs.allow, @ alias, file-level lucide-react alias,
dedupe for zustand/react/react-query), two node_modules trees with real drift (lucide 0.525 vs 1.25, vite 7 vs 8,
typescript 5.8 vs 7.0, zustand 5.0.9 vs 5.0.14, "*" ranges in learning-app/package.json:20-30), and a doubled
npm ci in CI (sparkquill-desktop.yml:69-84, with a comment recording the failure that forced it).
Duplicate pairs to collapse (learning-app β frontend/src): voice MicButton/useMicDictation vs
src/voice/MicButton.tsx (a diverging port, both on shared/voice); shared/chat/SyntaxHighlightedCode vs
ui/SyntaxHighlightedCode (byte-level); shared/chat/ChatRenderer vs ui/MarkdownRenderer (two markdown stacks);
file viewer helpers vs FileContentViewer + ui/*Renderer (+ dead shared/files/ProjectFileBrowser); notifySound
vs utils/sound; BuildUpdateNotice vs staleChunkReload/UpdateProgressToast; secrets UI vs
components/secrets/*; stores/useWorkspaceStore name collision with src/stores/useWorkspaceStore (different
shape); useParentChatStore/useChildChatStore (bubble-only, going away); standaloneApi vs agentApi; and the
find-or-create-tab β restoreSession β ChatArea boilerplate copy-pasted 4Γ (PlatformChat, ChildPlatformChat,
VideoStudioSurface:1129, DominionSurface:406) β refactorable into useProductChatTab(profileId) once all live
under products/.
Move plan (after the bubble chat is deleted β never migrate code about to be removed):
-
git mv frontend/learning-app/src β frontend/src/products/sparkquill/; renameuseWorkspaceStoreβuseSparkQuillWorkspaceStore,stores/typesβsparkQuillTypes; rewrite../../../src/Xβ../../X(4 files). - Add
'sparkquill'toPRODUCT_SURFACES(productSurfaceConfig.ts:1), theProductSurfaceunion (useProductSurfaceStore.ts:4, bump version β 4, extendmigrate:24-35),ProductSurfaceSwitcher(:22-32, addSparkQuillMark), and a fourthApp.tsxbranchlazy(() => import('./products/sparkquill/SparkQuillSurface')).LearningAppalready returns a full-screen<main className="learning-app">β export it asSparkQuillSurface. - CSS: tokens (
learning-app.css:4-72) and the already-working product-owned shadcn overrides.fl-platform-chat(:1228-1294) move toproducts/sparkquill/sparkquill.css(lazy chunk); drop the two global lines (:1-2); nest the ~80 unscoped generic selectors (.primary-button,.chat-bubble,.composer-*,.engine-*, β¦) under.learning-app; the Phase-7 bubble ranges never move. -
Preflight β the real work:
learning-app/tailwind.config.js:12haspreflight: false; insidefrontend/srcit is on. Every.fl-*control was authored against browser defaults β explicit resets for ~10 element types or an@layerabovebase; mandatory visual pass on all five screens. -
Theme:
ThemeContext.tsx:4FORCED_THEME='dark'+index.html class="dark"would activate everydark:variant inside the hostedChatArea; SparkQuill is light-first with its own toggle β surface togglesdocumentElementclass on mount/unmount, orThemeProviderbecomes surface-aware. - Move
public/sparkquill-*.svg+public/lib/(JSXGraph, 1 MB) βfrontend/public/; drop the duplicateimport '../../../src/index.css'; re-establishruntimeConfig.ts's before-React ordering or drop it (index.html:18already loads/runtime-config.js); foldVITE_SPARKQUILL_BACKEND/VITE_FAMILY_API/VITE_PLATFORM_APIinto the main env surface (mostly deletable once standalone is gone). - Delete
frontend/learning-app/{package.json,package-lock.json,vite.config.ts,tsconfig.json,tailwind.config.js, postcss.config.js,index.html,node_modules}; retargetsparkquill-desktop.yml:20,80-90anddev-setup.sh:14-19tofrontend/+frontend/dist; confirmcheck:bundle-budget(lazy surface stays out of the eager set).
Server side (the ~10-line change that makes this work): server.go:2514-2521 must emit enabledProductSurfaces,
defaultProductSurface, appName, faviconUrl (from env β AGENTWORKS_APP_NAME/AGENTWORKS_FAVICON_URL already
exist in run_server_with_logging.sh:1013-1015 but Go ignores them β or from the manifest's branding/ui.surface,
finally giving those keys meaning). With enabledProductSurfaces:['sparkquill'] the switcher shows one entry and
App.tsx:97-110 pins the surface; the desktop loads plain /, same SPA as AgentWorks.
Effort: ~6β9 days after the bubble-chat deletion (βhalf is preflight/theme); the move + rewire + config deletion is
~2 mechanical days. Risks: preflight regression (230 hand-written colours, ~80 selectors); dark leakage;
stale persisted surface on upgrade (store migrate + version bump); eager-bundle budget; runtime-config keys not yet
served by Go.
A8. Feature parity β family-server β platform (cmd/server + workspace-server + internal/sparkquillproduct)
Ledger of what's stubbed: frontend/learning-app/src/api/platformApi.ts (notYet() at :35, used :375-378).
| Area | Status | Detail |
|---|---|---|
| Chat turns / steer / streaming | done |
/api/agent-profiles/{id}/query, /api/sessions/{id}/live-input (+status.can_steer), /events/stream. Parent = singleton conversation key main; child = keyed by project, resolved from activities/<slug>/product.json (product_conversation_registry.go:315-418, written by tools.go:340-352). |
| Engine onboarding | blocking gap |
enginedetect is family-server-only. Platform equivalent exists β GET /api/llm-config/providers (llm_provider_manifest.go:26-53: runtime_available, auth_configured, usable, setup_hint per provider) β but the adapter fakes engines() from provider_options (platformApi.ts:276-281), selectEngine is a no-op, setup() drops the engine step (:254-266). No persistence of the family's engine choice anywhere (FamilyState profile_runtime.go:24-30 has only child/parent_label/pin_hash/watch_sites). AgentWorks desktop does no CLI detection/login either; a fresh Mac gets nothing telling the family to install/log in to a coding CLI. Plus the trap: AGENT_PRODUCTS=sparkquill β isSingleProductServerDeployment() β every claude-code turn 400s without CLAUDE_CODE_OAUTH_TOKEN (server.go:139-172, 3591-3594; test claude_code_token_gate_test.go). |
| Models / Fast Mode | gap | family: models_api.go, model_tier.go (Fast Mode = lower reasoning effort, stored in family.json). Platform: stubbed (platformApi.ts:334-337); no per-user model/effort override in agentprofiles (resolveProfileRuntimeModel agent_profile_runtime.go:111-128 accepts only exact catalog pairs). Needs new storage (extend FamilyState) + a runtime hook β design work. |
| Voice | done (same engine, same WS) |
voicestt.ServeStream both sides; platform GET /api/voice/stream?profile_id=sparkquill&token= (voice_stt_routes.go:89-104, gated on runtime.capabilities.voice), /status, /warm wired. Missing on platform: /unload (menu-bar hide frees ~1 GB), /hardware (tiers), `/model/install |
| stub; designs incompatible | family: one paired self-chat, @child/@parent flip a persistent attachment-routing mode (whatsapp_routing.go), media dropped into the target conversation with the path in the prompt. Platform: per-user pairing with ownership claim (whatsapp_routes.go:52-66), routing = @<slug> β workflow map β no (profile, conversation-key) target, no persistent mode, no mediaβactivity step; send_whatsapp_file missing. Single-user pairing works (binds to default). Needs a SparkQuill route-table adapter in services/whatsapp_service.go. |
|
| Pulse / check-in | done structurally, 4 gaps | Both use pkg/productschedule; enable/disable/trigger wired (product:sparkquill:pulse). Gaps: quiet rule inert (ProductScheduleService.sinceInteractive nil, product_schedules.go:113-115) so check-ins can land mid-conversation; cadence_hours/preferred_hour not user-configurable (405, scheduler_routes.go:1176-1179); 3 of 6 checks dropped (watched sites, weekly schedule, backup); raw instruction shown as the user turn (no clean trigger line); no local desktop notification channel (notification_destination.go:11-14 = Slack/webhook/WhatsApp/Gmail). |
| PIN / handoff / child sandbox | done, better | Same sha256 PIN via family.json (workspace.ts:15-18,139-152); handoff writes the same current-activity.json. Child sandbox is real: server.go:5079-5100 roots the shell at _users/<id>/Chats/SparkQuill/activities/<slug>, strict + network off (product.yaml child sandbox); keys/ is outside activities/ (activities.go:76-93) β unreachable, unlike family-server where keys sat in-folder hidden only by prompt. Requires product.json per activity or the child gets no conversation. |
| Files / state / upload | done, one behaviour change | tree/file/raw/upload via /api/wp. state/<key>.json at family root instead of <activity>/attempts/<key>.json. RenderActivityPage, pins, progress report ported; create-academic-map/create-test/create-study-material skills not embedded (commands survive as prompts); archive = prompt concept, no auto-archive (by decision); no /api/reset. |
| Secrets | done | per-user store, flat namespace (SparkQuill vs AgentWorks name collisions) β open item unchanged. |
/api/execute, /api/cdp-check, browser |
done by architecture | family's loopback browser_backend.go deletes; real workspace-server endpoints (workspace/handlers/shell.go:24, cdp_check.go:21). /api/browser/status has no route (adapter hardcodes cli_installed:false). |
tmux sq-* + sweep |
delete |
cmd/server reaps by lease/ownership, not prefix; carrying sq-* over would blind the reaper. |
| Week / child-schedule / activity log | not in FamilyApi
|
standalone-only; port-or-drop decision. |
-
Where user paths resolve: workspace-server rewrites
Chats/β¦β_users/<uid>/Chats/β¦fromX-User-ID(workspace/utils/path.go:135-181; header stamped by the agent server's proxy from the verified identity,workspace_proxy.go:60-63); agent side does the same for tool roots (agentProfileRuntimeWorkspaceagent_profile_runtime.go:102-109). Single-user id =default(DEFAULT_USER_ID); workspace-server pre-creates_users/default/{Chats,memories,chat_history}. -
Template:
cmd/family-server/migrate.go(825 lines): destination marker (:24-45), structural probe,cp -abackup then abort-on-failure (:70-79), never-overwritemergeMovewith.migrated-dup[N](:126-165),uniqueDir/uniqueBase, fuzzy keyβactivity matching (:461-560), unplaceables β_legacy/,[migrate]logging on every decision. -
Mapping:
family.jsonfield-filtered (keepchild(dropcreated_at),parent_label,pin_hash;pulse.watch_sites βͺ school_portal_urlβ top-levelwatch_sites; dropengine/selected_models/fast_mode/child_fast_mode/pulse.*/schedule/whatsapp_voice_enabledβ no platform home). Optionally seed_users/default/chat_history/product-schedules.jsonwithenabled+last_run_atso the check-in doesn't fire immediately.Subject/Topic/<slug>/βactivities/<slug>/(lowercase, validateactivitySlugPatternactivities.go:52, numeric suffix on collision; Subject/Topic already live inactivity.json). Synthesizeactivities/<slug>/product.json{schema_version:1, product:"sparkquill-child", id, title, description, session_id:"product-<uuid>"}(activities.go:39-46; title+session_id required). Move*-KEY.mdβkeys/<slug>-KEY.mdviaanswerKeyDestination(activities.go:84-90) β security-relevant. Items verbatim (bare filenames);attempts/verbatim;current-activity.jsondirrewritten;materials/,inbox/,reports/,memory/*verbatim (activity-log/deadlines/child-schedule kept as evidence, no consumer);archive/flattened with the shared slug allocator; deleteskills/+.agents/skills/(platform embedsSkillFiles; note 3 skills not embedded = behaviour change to confirm); dropwhatsapp-routing.json, session handles (*.session.jsonβ CLI resume ids, one cold start), scratch dirs,whatsapp/session.db; secrets β re-enter (never copy the AES files into the docs dir). -
Conversations: archive, don't convert. Target is
llmtypes.MessageContent+ui_eventsunder_users/<id>/chat_history/<date>/session-<id>-conversation.jsonwith an overwrite guard; session ids are minted by the registry on first use. Keepparent.json/<activity>/conversation.jsonas_legacy/conversations/β¦/activities/<slug>/legacy-conversation.jsonso the agent can still read them for reports. Real history import = separate later slice. -
Where it runs: a
cmd/serverstartup hook next to the SparkQuill registration block (server.go:1758-1771, gated byproductEnabled("sparkquill")), non-fatal on error (no marker β retry next boot), plus a CLI subcommandagent-server migrate-sparkquill --from --docs-dir --user [--dry-run]. Not the Electron shell (slug rules,product.jsonshape,answerKeyDestinationlive in Go). Idempotency: destination marker.migrated-from-sunlit(source path + timestamp + hash) written only on full success; fresh install (no source) β write marker; non-empty target without marker β refuse and log (never overwrite a family already on the platform). Backupcp -a ~/.sunlit-learning ~/.sunlit-learning.pre-platform-backupfirst, abort if it fails; copy, don't move. Tests that assert the child sandbox must build fixtures under$HOME(characterization_test.go:252-263), nott.TempDir()(/var/foldersis sandbox-readable).
Build list from parity+migration β blocking: (1) engine onboarding + the single-product 400 trap; (2) product.json synthesis; (3) key relocation. Gaps ranked: (4) WhatsApp route table; (5) Pulse quiet rule / configurability / dropped checks / desktop notification; (6) model picker + Fast Mode; (7) voice unload/hardware/install/transcribe; (8) /api/browser/status, /api/reset; (9) week/schedule port-or-drop. Deletes: browser_backend.go, tmux_sweep.go + sq-* setenvs, skills.go seeding, enginedetect use, secrets_store.go, then all of cmd/family-server.
P0 server enablers ββ
βββΊ P1 shell sharing βββββββββββββββββββββββββββββββ
P2a delete bubble ββΊ P2b move learning-app ββΊ P3 blocking parity ββΊ P5 migration ββΊ P6 deletions ββΊ P7 live E2E = MVP
βββΊ P4 trailing gaps (after MVP)
MVP (P0βP3, P4 ship-first subset, P5βP7): ~25β30 dev-days. Trailing gaps: ~8β11 days.
First shippable milestone: a family installs SparkQuill.dmg; onboarding detects/selects an engine via
/api/llm-config/providers; ~/.sunlit-learning is migrated on first boot (or a fresh family scaffolded); the parent
chats in the shared ChatArea (tool rows, pills); handoff opens the child's sandboxed activity conversation; check-ins
run with the quiet rule; voice warms/unloads with window visibility; AgentWorks + SparkQuill run side by side. Trailing:
WhatsApp, Fast Mode/model picker, cadence configurability, desktop notifications, voice tiers/install, browser status,
week/schedule (dropped).
M1 first β the cheap live build (1β2 days, before P0/P1): platform binaries + existing learning-app dist
Rollout review verdict: get a family-installable build on the platform before the shell/frontend refactors, with
almost nothing thrown away. All in desktop-sparkquill/main.js (replacing startServer :94-119) + one preload line:
-
preload.js:8-11addsbackend: () => 'platform'β the learning app is already platform-capable (api/index.ts:11-14,platform/runtimeConfig.ts:8-12); nothing else in the frontend changes. - Stage
frontend/learning-app/distasResources/static(notresources/web) and set the agent server's cwd to Resources (server.go:2533serves./static/); ship../agent_go/configs β configslikedesktop/package.json:45-48. -
Generate + persist
AUTH_SECRETin<userData>/config.json(mirrordesktop/main.js:308-318,496-504) β the server fatals on an empty/default secret (server.go:1525,auth_middleware.go:143-150); the SparkQuill shell has no reference to it today. - Spawn
workspace-server server --port <detect(45779)> --docs-dir <userData>/workspace-docs(envDOCS_DIR,DATA_DIR=<userData>/data,NATIVE_WORKSPACE=true,WORKSPACE_API_TOKEN=<32 B hex>; ready onDynamicPort:), copyResources/configs/mcp_servers_clean.jsonβ<userData>/configs/mcp_servers.json, then spawnagent-server server --port <detect(45778)> --log-file β¦ --mcp-config β¦withAUTH_SECRET,WORKSPACE_API_URL,WORKSPACE_DOCS_PATH,DOCS_DIR,LOG_FILE,NATIVE_WORKSPACE=true,WORKSPACE_API_TOKEN,AGENTWORKS_SKIP_GLOBAL_BROWSER_CLEANUP=true,AGENTWORKS_BROWSER_SESSION_PREFIX=sparkquill;MULTI_USER_MODEandAGENT_PRODUCTSunset. Health on/api/health, then loadhttp://127.0.0.1:<agentPort>/?v=<version>. KeepResources/lib(voice dylibs, samebuild-darwin-voice-binary.shthe AgentWorks release uses). - Add a shell unit test asserting the spawn env contains no
AGENT_PRODUCTS. Gate:SPARKQUILL_PLATFORM_URL=http://127.0.0.1:<port> npx vitest run platformApi.liveagainst the packaged app's server + a live parent turn and a child handoff turn observed in ChatArea (agentic sign-off). Throwaway: only thewebβstaticstaging line; the spawn code becomesdesktop/lib/spawnServers.jsin P1. Note: without P3, a family on a fresh Mac has no engine onboarding (see P3) β M1 is for families who already haveclaude/codexlogged in.
-
/runtime-config.js(server.go:2514-2521) also emitsenabledProductSurfaces,defaultProductSurface,appName,faviconUrlfrom env (AGENTWORKS_ENABLED_PRODUCT_SURFACES,AGENTWORKS_DEFAULT_PRODUCT_SURFACE,AGENTWORKS_APP_NAME,AGENTWORKS_FAVICON_URLβ names already used byrun_server_with_logging.sh:1011-1021), keys omitted when unset so AgentWorks output is byte-identical. Newcmd/server/runtime_frontend_config.go+ test. Manifestui.surfacefallback only if it stays ~15 lines (branding.faviconis an icon name, not a URL). -
STATIC_DIRenv for the SPA mount (server.go:2533) so the shell can useuserDataas cwd and dropunifyAgentLogsDir(desktop/main.js:891-924);mcp_servers_clean.jsonpath βgetResourcesDir()(main.js:1215). -
registeredProductIDs()includessparkquill(user_directory.go:827-835). -
AGENTWORKS_CLI_SECURITY_DIRoverride inpkg/clisecurity/store.go:40-46(today hardcoded +log.Fatalf). - Voice-model first-download lock in
pkg/voicestt/model.go(~:253,282):O_EXCLlock besidedest, second process waits. -
Decision β token gate: leave
AGENT_PRODUCTSunset in the SparkQuill shell (no exemption code); the frontend allowlist hides the other surfaces.claude_code_token_gate_test.go:69already pins the unset-exempt case. -
Isolation env the shell passes (= descriptor
extraServerEnv; recipescripts/run-local-instance.sh:277-297): per-productuserData(β separateRUNLOOP_DOCS_DIR), preferred ports 45778/45779,AGENTWORKS_SKIP_GLOBAL_BROWSER_CLEANUP=true,AGENTWORKS_BROWSER_SESSION_PREFIX=sparkquill,AGENT_BROWSER_CONFIG,AGENTWORKS_CLI_SECURITY_DIR,AGENTWORKS_STRICT_PROCESS_OWNERSHIP=true. Not*_SESSION_PREFIX=sq-*, notTMUX_TMPDIR;WHATSAPP_ENABLED/SCHEDULER_ENABLEDat defaults (Pulse needs the scheduler).
- Add
desktop/lib/{loginEnv,boundedLog,health,externalNav,spawnServers,updater,productJson}.jsextracted fromdesktop/main.js(:54-92, :165-202, :1358-1389, :1797-1838, :1108-1356, :1401-1525+1675-1706),updaterwithupdateMode: install|notify. - Add
desktop/products/{agentworks,sparkquill}.jsdescriptors (appId, productName, userDataDirName, preferred ports, windowTitle, backgroundColor '#fbf7ef', theme, trayLabels, iconPath, tagPrefix, dmgName, installScriptUrl, closeBehavior quit|hide, voiceLifecycleOnVisibility, menuFolderLabel, updateMode, extraServerEnv); selectionAGENTWORKS_PRODUCTenv βResources/product.jsonβagentworks. - Add
desktop/build/{agentworks,sparkquill}.yml(differ inappId,productName, category, icon,extraResourcesincl.product.json); scriptsdist:agentworks/dist:sparkquill(electron-builder --config build/<id>.yml). - Modify
desktop/main.jsto uselib/*and descriptor-conditional bits (close-to-tray, tray/menu labels, background, deleteunifyAgentLogsDironceSTATIC_DIRis passed). Voice warm/unload on show/hide: shell emits an IPCwindow-visibilityevent viapreload.js; the SparkQuill surface (holding the JWT) calls/api/voice/warm|unloadβ no auth plumbing in the shell (/api/voice/*is behindAuthMiddleware, unlike family-server). -
install.shparameterized (APP_NAME,TAG_PREFIX,DMG_NAME,ENV_PREFIX);install-sparkquill.shbecomes a 3-line wrapper (public curl URL stays).sparkquill-desktop.ymlmirrorsdesktop-release.yml:60-106thennpm run dist:sparkquill, keepsgh release create sparkquill-v*.desktop/dev-setup.shbuildsagent-serverviabuild-darwin-voice-binary.shsoresources/libexists locally (fixes the known clean-build failure) and accepts--product sparkquill. -
desktop-sparkquill/keeps nothing; deleted in P6 after P1 is verified live.
-
learning-app/src/api/index.ts: remove thebackendswitch and token mirroring; singlecreatePlatformApi. DeleteapiBase.ts,VITE_SPARKQUILL_BACKEND/VITE_FAMILY_API/VITE_PLATFORM_API,api/standaloneApi.ts(+test),stores/useParentChatStore.ts,useChildChatStore.ts(fold surviving child-activity/viewer fields intouseFamilyStore/the renamed workspace store). -
LearningApp.tsx: remove everybackend !== 'platform'branch and standalone effect (history loads:1144,1685, 1771,1934,2545; watchers:1733,1822; send/steer:2211-2421; JSX:2829-2875, 3936+; guards:1768,1810,1933);PlatformChat(:2939) andChildPlatformChat(:4080) become unconditional. -
familyApi.ts: drop chat methods:94-108and theirplatformApi.ts:296-311implementations; keepengines/validateEngine/selectEnginefor P3. CSS: bubble-only ranges (:149-220, child bubble ~:596-668) β confirm each class by grep after the TSX deletion;.fl-thread/.fl-msg/.fl-bubblestay (landing greeting). -
platformApi.live.test.ts:60-61: drop the staleweekassertion. Two commits (stores, then JSX) with the parenttscas the guard β this file has never been type-checked.
Mechanical (~2 d): git mv; rename useWorkspaceStore β useSparkQuillWorkspaceStore, stores/types β
sparkQuillTypes; rewrite ../../../src/X β ../../X (4 files); check persist keys vs frontend/src/stores;
SparkQuillSurface.tsx = LearningApp renamed; delete main.tsx, platform/runtimeConfig.ts, BuildUpdateNotice
(β UpdateProgressToast/staleChunkReload); register the surface (productSurfaceConfig.ts:1, store union/version 4/
migrate, ProductSurfaceSwitcher.tsx:22-32 + SparkQuillMark, App.tsx:45-47,908-914) and update their tests; move
public/{sparkquill-*.svg,lib/jsxgraph*} β frontend/public/; delete the learning-app root configs/node_modules/dist;
retarget dev-setup.sh. Real work (~4β6 d): scoped preflight resets under .learning-app (~10 element types) +
mandatory visual pass on all five screens; CSS tokens (:4-72) + .fl-platform-chat (:1228-1294) β lazy
sparkquill.css, drop globals (:1-2, duplicate index.css import), nest ~80 unscoped selectors; theme: surface
toggles documentElement light/dark on mount/unmount (ThemeContext.tsx:4 forces dark); first-ever tsc on the
moved code (0.5β1 d); bundle budget (check-bundle-budget.mjs eager gzip β€ 1,030,000 β keep everything lazy);
trivial dedupe now (SyntaxHighlightedCode). Larger dedupes (MicButton, ChatRenderer vs MarkdownRenderer, file viewer,
secrets UI) and a shared useProductChatTab(profileId) across the four surfaces = post-MVP slice (2β3 d).
-
Engine onboarding + persisted choice: add
EnginetoFamilyState(profile_runtime.go:24-30, required orset-child-profilere-serialization drops it);engines()βGET /api/llm-config/providers(llm_provider_manifest.go:26-53) β© parentruntime.provider_options;validateEngineβusable;selectEngineβfamily.json.engine;setup()reinstatesnext_step:'engine'; existing.engine-cardUI showssetup_hint. Turn wiring A (ship):PlatformChat/ChildPlatformChatset the tab's provider/model fromfamily.json.engineat tab creation (PlatformChat.tsx:83-88; server honours exactprovider_optionspairs,agent_profile_runtime.go:111-128). B (later, with P4-6): server-side per-user product runtime preference so Pulse runs honour it too. Verify live first that the agent-profile/queryhandler forwardsreq.Provider(server.go:3578-3580falls back to it) β the one assumption under A. Facts that shape this: today a fresh Mac with no CLI reaches the parent screen (engine step skipped), and the first turn either fails with a dev-worded spawn error or, withclaudeinstalled but not logged in, hangs on the CLI's login prompt for up to the adapter's 20-min inactivity timeout (platformApi.ts:40; described atserver.go:3585-3589).handleQueryhas no runtime pre-check. In the manifestproviderAuthConfiguredreturnstrueunconditionally forclaude-code/codex-cli(multiagent_llm_tools.go:427-439), sousable == runtime_available(LookPath) β real login state needsPOST /api/llm-config/validate-keyβvalidateClaudeCodeCLI(llm_config_handlers.go:1023, a real turn, bounded) β same asenginedetect.Validatedid.setup_hintcopy is developer-worded; keep the learning app's parent-facing copy per engine (pres()LearningApp.tsx:4527). Reuse AgentWorks' status semantics (components/llm/providerStatus.ts:7-19), not its Model Library modal. -
product.jsonsynthesis: extractEnsureActivityProject(...)fromtools.go:340-352; call fromcreate-learning-activity, the migration, and as self-heal onopen-activity/handoff. -
Key relocation: reuse
ws.relocateAnswerKeys(tools.go:356) +answerKeyDestination; also on handoff.
| Gap | Call | Effort |
|---|---|---|
| Pulse quiet rule inert |
ship-first β wire ProductScheduleService.sinceInteractive (product_schedules.go:117, used :325) to last user-message time per (user, profile) |
0.5β1 d |
/api/voice/unload |
ship-first β Manager.Unload() exists (voicestt/manager.go:212); handler beside handleVoiceWarm (voice_stt_routes.go:124) |
0.5 d |
| Desktop notification for check-ins | trail β renderer toast + preload β Electron Notification; no new destination kind |
1 d |
| Cadence / preferred-hour configurability | trail β per-user override where scheduler_routes.go:1176-1179 405s |
1β2 d |
| Dropped checks / clean trigger line | drop the three checks (decision); trigger line trails | 0.5 d |
| Model picker + Fast Mode | trail β extend FamilyState + server runtime hook (also gives P3-B); hide Settings rows until then |
2β3 d |
WhatsApp @child/@parent route table |
trail β hide the WhatsApp button/modal for MVP; adapter in services/whatsapp_service.go (resolveSlugRoute:583) mapping to (profile, conversation key) + persistent mode + mediaβactivity + send_whatsapp_file; single-user pairing would bind to the default AgentWorks chat β don't half-ship |
3β4 d |
Voice /hardware, `/model/install |
remove, /transcribe` |
trail; /transcribe only with WhatsApp voice notes |
/api/browser/status, /api/reset
|
browser status trails (query cdp_check.go:21), hide row; /api/reset dropped
|
0.5 d |
| Week / child-schedule / activity log |
dropped; memory/* kept as evidence |
0 |
agent_go/internal/sparkquillproduct/migrate.go (+test), same package as activitySlugPattern/activityProject/
answerKeyDestination/EnsureActivityProject. Move the generic primitives from cmd/family-server/migrate.go
(copyDirCmd:114, mergeMove:131, copyFile:187, uniqueDir:370, uniqueBase:384, fuzzy matching :561-596,
marker pattern :29-47). Direct filesystem I/O under $WORKSPACE_DOCS_PATH/_users/default/Chats/SparkQuill/; skip
when the env is unset (cloud never migrates). Mapping per A9 (+ carry engine into family.json for P3). Seed
product-schedules.json (productScheduleStatePath product_schedules.go:100-102, key sparkquill/pulse) with
enabled and last_run_at=now so the first check-in doesn't fire mid-onboarding. Safety: backup then abort-on-failure;
copy not move; marker .migrated-from-sunlit only on full success; fresh install β marker; non-empty target without
marker β refuse + log; unplaceables β _legacy/. Entry points: startup hook inside the productEnabled("sparkquill")
block (server.go:1758-1771), gated on NATIVE_WORKSPACE=true and MULTI_USER_MODE unset, non-fatal; env
SPARKQUILL_LEGACY_DIR (default ~/.sunlit-learning), SPARKQUILL_SKIP_MIGRATION; cobra subcommand
migrate-sparkquill --from --docs-dir --user [--dry-run] (agent_go/cmd/root.go:104-106). Tests: fixture builder,
idempotency, refuse-non-empty, dry-run, slug collision, key relocation, product.json with product- id, archived
conversations; the sandbox assertion uses a $HOME fixture (characterization_test.go:252-263 pattern, ported).
cmd/family-server is one binary: piecemeal deletions inside it buy nothing, and internal/enginedetect has no other
importer β treat A8's per-file list as a ledger of behaviours that stop existing and delete the directory in one
flag-day commit. All code deletions are git-reversible; the family's data is not β hence copy-not-move in P5.
| Step | Delete | Gate before |
|---|---|---|
| D1 | standalone-gated effects in LearningApp.tsx (:1768-1830 20 s poll + watchChild, :1933, :2102, :2486-2491, :2541-2549) |
M1 live gate passed |
| D2 | bubble branches (:2938-2941 β PlatformChat only, :4078-4080 β ChildPlatformChat), useParentChatStore/useChildChatStore message state (keep focusInput/childSending, used :962-964), CSS .fl-thread/.fl-msg/.fl-bubble :297-323,634-641, .fl-tmsg/.fl-tbubble :597-628, .chat-bubble :201-207 (keep --reply-bubble-* tokens if .fl-platform-chat references them) |
vitest green + visual pass on the 5 screens |
| D3 |
standaloneApi.ts(+test), apiBase.ts, backend switch in api/index.ts, VITE_SPARKQUILL_BACKEND/VITE_FAMILY_API, FamilyApi narrowing |
D2 merged |
| D4 | Settings rows already hidden on the platform (:2829-2845 cadence/hour, :3936 Fast Mode/model picker), WhatsApp QR section (:3816 renders a broken <img> from whatsappPairImageUrl()='') |
per-behaviour decision below |
| D5 (done 2026-09-06) | whole agent_go/cmd/family-server, internal/enginedetect, desktop-sparkquill/ shell duplication (P1; updater.js stays, it is the only copy), install-sparkquill.sh data-path line, sparkquill-desktop.yml retarget |
migration verified on a copied real ~/.sunlit-learning (P5); every family on the new build β₯ 1 release |
| D6 |
frontend/learning-app root (after the P2b move) |
P2b preflight/theme pass |
Behaviours with no platform replacement β decisions: week/child-schedule/activity log β ship without (agent tools
only; the UI never called /api/week; keep memory/child-schedule.json as evidence). WhatsApp β hide the connector
section in platform mode (today the poll fails silently and the QR is broken); if the migration finds
whatsapp/session.db in the source, show a one-time "WhatsApp comes back in the next update" notice. Desktop
notification β stub in M2 with a local destination (osascript, gated on NATIVE_WORKSPACE=true, ~40 lines in
services/notification_destination.go); check-in is off by default (product.yaml:133) so not M1-blocking. Model
picker / Fast Mode β already hidden; ship without. Voice unload/install/hardware β the shell's hideβunload gets a
harmless 404 in M1 (~1 GB stays resident while hidden); add /api/voice/unload in M2. /api/reset β drop (a hidden
"Reset setup" that deletes family.json via /api/wp if ever needed). Sweep family-server mentions across docs/,
ROADMAP.md, task.md, family-learning-architecture.md, workflows; update this doc's status.
- Go:
go test ./cmd/server/... ./internal/sparkquillproduct/... ./pkg/clisecurity/... ./pkg/voicestt/... ./pkg/browser/...; new tests for runtime-config emission,registeredProductIDs, clisecurity override, download lock, migration suite. - Frontend:
npm run build(tsc + vite + bundle budget),npm run lint,npm test(moved tests; live test via env URL); surface tests for five surfaces. -
Live Electron E2E (project rule: LLM-driven paths verified only by a live run with agentic sign-off): fresh install
β onboarding shows engine detection, child, PIN; run with a copied real
~/.sunlit-learningβ migration log, marker, backup,product.json+keys/, archived conversations; parent turn with tool rows + pills; handoff β child turn with celebrate/scene; from the child shell assertkeys/unreachable; check-in "run now" β one saved check-in summary; relaunch restores history; AgentWorks + SparkQuill simultaneously (distinct ports/userData/docs roots, no browser kill cross-fire, tmux intact); hide β/api/voice/unload, show β/warm.
Executed in the order M1 β P0 β P3 β P5 (not the P2-first chain above): the engine onboarding and the data migration do not depend on the learning-app move, and running them first puts a real family on the platform before the CSS/consolidation work. Then P4, P2a, P1, P6 and P2b (all below). P7 remains.
-
M1 done, live-verified.
desktop-sparkquill/main.jsspawnsworkspace-server(45779) +agent-server(45778), persists a generatedAUTH_SECRETin<userData>/config.json, servesfrontend/learning-app/distasResources/static, never setsAGENT_PRODUCTS(lib/agentEnv.js+node --test).preload.jsreportsbackend: 'platform'. Onboarding, PIN, a parent turn (Claude Code, tool rows) and relaunch-with-history all observed in the real Electron window. -
P0 done.
runtime_frontend_config.go(AGENTWORKS_ENABLED_PRODUCT_SURFACES/_DEFAULT_PRODUCT_SURFACE/_APP_NAME/_FAVICON_URL, omitted when unset),STATIC_DIR,registeredProductIDs()+sparkquill,AGENTWORKS_CLI_SECURITY_DIR, voice-model download lock (pkg/voicesttacquireModelDownloadLock). -
P3 done, live-verified β with one contract change the plan missed. The product-chat route
(
AgentProfileChatRequest, strict decode) had no field through which a client could pick a provider, so "turn wiring A" could never have worked:queryRequestForAgentProfileChatalways leftProvider/ModelIDempty andresolveProfileRuntimeModelalways fell through to thedefault: trueoption. Addedengine(one of the profile'sprovider_options[].id; undeclared β 422; rawproviderstill β 400) and threaded itfamily.json.engineβ tab metadataagentProfileEngineβbuildAgentProfileChatRequestβ/query.platformApi.tsnow really implementssetup()(engine step first),engines()(/api/llm-config/providersβ©provider_options),validateEngine()(/api/llm-config/validate-key),selectEngine().FamilyStategainedEngineso Go-side rewrites offamily.jsonkeep it. Settings changes propagate to open tabs (applyFamilyEngineToOpenTabs). -
P5 done, verified against the real
~/.sunlit-learning(into a scratch docs dir: 16 live activities all with synthesizedproduct.json, 91 archived, 68 keys relocated tokeys/with none left in reach, 0 session handles, 0 secrets, pointer rewritten,engine: codex-clicarried, check-in seeded as just-run; source byte-count unchanged).internal/sparkquillproduct/migrate.go+cmd/server/sparkquill_migration.go(startup hook gated onNATIVE_WORKSPACE=true+ single-user; CLIagent-server server migrate-sparkquill --from --docs-dir --user --dry-run --allow-existing). Deviations from A9/P5 as written: nocp -abackup step β the migration is copy-only and never touches the source, which is the safety property (a 3.3 GB home full of voice models is not worth duplicating);_legacy/receives unplaceables verbatim; the startup hook refuses a non-empty unmarked target and points at--allow-existing, which merges never-overwriting and lets existingfamily.jsonchoices win. -
P4 ship-first done, live-verified. The quiet rule was not just switched off on the platform; it had two
further defects fixed here: (1) only a successful run moved
last_run_at, so a failing check-in re-fired on every 60 s tick βproductschedule.InputsgainedLastAttempt/ConsecutiveFailuresandDecideapplies an exponential retry backoff (30 min doubling, capped at 6 h / the cadence); (2) nothing survived a restart, so every due check-in fired on the first tick after launch βcmd/server/product_interactions.gokeeps a per-userchat_history/product-interactions.json(stamped by the product chat and conversation-open handlers only, never by scheduled runs, so a check-in's own turns cannot defer it) and feedsProductScheduleService.sinceInteractive. Deferral is surfaced asdeferred_reasonon the job response. Observed live: overdue check-in + app just opened βschedule.log: "deferring β¦ user active 59s ago (overdue by 1h1m, forcing after 4h)"./api/voice/unloadadded (gated like warm); the shell now sends awindow-visibilityIPC andlearning-app/src/platform/voiceLifecycle.tscalls unload on hide / warm-if-installed on show β observed live:released=trueon hide,loading=trueon show. Note for P1: the scheduler writeslogs/schedule.logrelative to the server's cwd (=Resources/), the same cwd couplingSTATIC_DIRwas added to remove; the shell should pass aLOG_DIR/cwd underuserDataonce it moves. -
Found by the first real family turn on Codex (2026-09-06):
product.yamlpinned the Codex option togpt-5.4, which Codex 0.147 rejects for a ChatGPT-account login ("not supported when using Codex with a ChatGPT account"); the family's own choice wasgpt-5.6-sol, and that pin is nowgpt-5.6-solfor both profiles. Worse, the failure was invisible: Codex records a failed turn astask_completewithlast_agent_message: nulland the API error inerror, then shows the ready prompt again, so the interactive adapter returned an empty reply thatprovider_runtime.goaccepted as a launch-only response (a session handle was attached) and the UI showed a bare "LLM Generation End Β· No content generated" card.multi-llm-provider-gonow readstask_complete.error(readCodexRolloutTurnError) and fails the turn with the unwrapped API message when there is no final answer. Open (P4 trail / model picker): the option is still a hardcoded pin; the catalog exposes tier aliases (high/medium/low) that would track the provider's defaults instead. -
P2a done. The standalone bubble chat is gone: every
backend !== 'platform'branch, the parent/child bubble renderers and composers, thesendParentText/sendChildText/kickoff handlers, the 20 s pollers and SSE watchers, queue drains, auto-scroll, upload/paste/mic composer plumbing,useParentChatStore, the message fields ofuseChildChatStore,standaloneApi.ts(+test),MicButton.tsx,TurnCollectorand the turn-transport types (TurnStreamEvent/TurnResult/TurnMessage/ToolEvent),FamilyApi's chat methods (onlyloadChildConversationsurvives, for the continue-or-fresh question), and 100 CSS rules that only styled those.api/index.ts,runtimeConfig.tsandapiBase.tsare platform-only (same-origin by default,VITE_PLATFORM_APIfor a browser dev session).LearningApp.tsx4,637 β ~3,220 lines; 2,673 lines deleted overall. Guarded bytsc(the file was never type-checked before; only the two pre-existingTerminalEventTranscriptprop errors remain), the Vite build and the learning-app vitest suite. Found on the way: importing the sharedsrc/api/secretsclient from a bare test hits a pre-existing circular import insrc/services(api.tsβmcpConfigApi.tsβuseMCPStore); the adapter now loads it lazily. Not done here (P2b/P4 trail): the voice Settings section still talks to family-server-only routes (/api/voice/model/*, unauthenticated/api/voice/streaminuseMicDictation). -
P1 done, live-verified. The two Electron shells were hand-copied files that had already drifted (the
SIGTERM/SIGINT orphaned-server fix existed in one and not the other). The shared mechanics now live in
desktop/libas theagentworks-desktop-libpackage (login-shell env import, bounded log writer,spawnServerwith dynamic ports,waitForHealth, external-navigation interception,installSignalShutdown), consumed by bothdesktop/main.jsanddesktop-sparkquill/main.js(file:../desktop/lib; electron-builder packs it intoapp.asar, verified).desktop-sparkquill/lib/agentEnv.jsstays: it is SparkQuill's own env builder. -
Codex 0.153 (found live during P1 verification, fixed in
multi-llm-provider-go). The CLI upgrade broke every pane heuristic the tmux transport relied on: a new fixed composer placeholder, a "usage limit resets" footer that classified as quota exhaustion, a "Conversation interrupted" notice that swallows the next Enter (the previous answer was then returned as the new reply),turn_abortedwith notask_complete(the turn hung), and a "Resuming sessionβ¦" banner that read as a ready composer. Submission is now confirmed from the rollout'stask_started, completion is bound to that turn_id, aborts fail the turn explicitly, resumed sessions pin their thread at creation, and a Codex pane left behind by a previous server process (which makescodex resumedie with "already has an active writer") is reaped by owner tag before relaunch. Seecodexcli_v0153_*_test.go. -
P6 done.
agent_go/cmd/family-server(68 files, 11,144 lines) andinternal/enginedetectdeleted;go mod tidy;sparkquill-desktop.ymlbuildsagent-server(cgo, fromagent_go) +workspace-server, stagesresources/staticandresources/configs, runs both shells' unit tests, and triggers ondesktop/libandfrontend/sharedtoo;dev-setup.shwas already the local mirror. Comments and docs that described the standalone as current were reworded; historical design records (family-learning-architecture.md, Part B below,docs/bugs,docs/refactor) keep their references and are marked superseded where they had a status. Deliberately not replaced (A8 decisions): WhatsApp,/api/reset, week/child-schedule routes, model picker. Stopped existing without a platform equivalent: the standalone'sfile://guard onagent_browser(validateBrowserFileURLs); the platform's browser tool has none, the child profile hasbrowser: disabled, the parent's browser can open local files. Open item, recorded indocs/core/browser.md. -
P2b done, visually verified.
frontend/learning-app/srcisfrontend/src/products/sparkquill/and SparkQuill is a product surface of the one Vite build (SparkQuillSurface.tsx, lazy; registered inproductSurfaceConfig, the surface store (version 4), the switcher withSparkQuillMark,App.tsx). The desktop shell pins the build to it throughAGENTWORKS_ENABLED_PRODUCT_SURFACES/_DEFAULT_PRODUCT_SURFACE/_APP_NAME/_FAVICON_URL(lib/agentEnv.js);dev-setup.shand the workflow buildfrontendand stagefrontend/dist. Deleted: the learning-app Vite root and its configs,main.tsx,platform/runtimeConfig.ts(runtime-config.js does this now),BuildUpdateNotice(staleChunkReloaddoes this now), the duplicateindex.cssimport. Firsttscunder the main config: three real errors fixed (a parameter property, a staleautoScrollModeprop). The voice modules now call the platform's authenticated routes (/api/voice/stream?profile_id&token, warm as "install"), closing the P2a trail. CSS: the main build ships preflight, which the app was never built against, solearning-app.cssopens with a scoped browser-defaults block (:where(.learning-app) h1 β¦ { UA values }, zero-specificity-plus-one so every app rule and Tailwind utility still wins, excluding.fl-platform-chat),line-height: normalfor the app's own markup, and the app's own copy of the chat resets is gone (loading after the utilities it beat them: the send button lost its fill).useWorkspaceStoreβuseSparkQuillWorkspaceStore;.leadβ.fl-lead(collided).LearningAppsyncs the document theme class while mounted (ThemeProvider forces dark) so the shared chat follows the family's theme. Visual pass: before/after screenshots of parent, activities, settings, child, dark mode, and the three onboarding steps on a throwaway profile (SPARKQUILL_USER_DATA_DIR, dev-only). Not done: the react-refresh lint findings the moved files already had, and the larger dedupes (MicButton, ChatRenderer vs MarkdownRenderer, file viewer).
Migration worst cases (always against cp -a ~/.sunlit-learning <fixture>; source flag β the copy, --docs-dir β a
scratch root; the original is never touched):
- Target
_users/default/Chats/SparkQuill/non-empty and unmarked β refuse + log + UI notice; test asserts zero writes. - Slug collisions / invalid slugs β lowercase + numeric suffix via
activitySlugPattern+uniqueDir; rewritecurrent-activity.json.dirto the allocated slug; test with twofractionsunder different subjects. - Missing
product.jsonβ synthesize (else the child getsproject "<slug>" was not found); invariant test + live handoff. - Keys left in-folder β
answerKeyDestinationβkeys/, plus an idempotent startup key-sweep incmd/server(protects partial migrations and future regressions); Go test under a$HOMEfixture (none exists incmd/servertoday β portcharacterization_test.go:250-263); live: childcat ../../keys/β¦denied. - Stale/old-layout
current-activity.jsonβ rewrite or delete the pointer (client returns null β "no activity"). -
family.jsonfields: keepenginewhen it is a knownprovider_optionsid; dropfast_mode/selected_models/pulse.*/ schedule/whatsapp_voice_enabledwith a one-time in-app notice. - Secrets: never copy
secrets.key/secrets.enc.jsoninto the docs dir; re-enter via the platform store; test asserts neither file exists under<docsDir>. - Check-in firing immediately:
Decide's cadence branch runs now on zeroLastRun(productschedule/schedule.go:178-190) β seedlast_run_at=nowwithenabled; the quiet rule is inert until P4-ship-first lands. - Two desktops: voice-model download race (
flockon<modelDir>.lock; SparkQuill delayswarmuntil/api/voice/status.installed), CLI-security store (AGENTWORKS_CLI_SECURITY_DIR).
One app with a product switch vs two apps from one shell codebase: one app wins on memory (one voice engine, one
server pair), zero collisions, one updater; it loses on everything the family sees β name/icon/tray/menus,
close-to-tray vs quit, light-first theme vs the forced dark class, a product switcher visible to a child, and coupled
release cadence. Verdict: two apps from one shell codebase (P1 descriptors); revisit after P2b and runtime-config
product pinning exist.
Top risks (ranked) β mitigation β proof
-
AGENT_PRODUCTS/token gate 400s every turn β never set it in the shell βclaude_code_token_gate_test.go+ shell env test + live parent turn. - Fresh Mac with no/unauthenticated CLI β silent failure or 20-min hang β P3 onboarding (
providers+validate-key+family.json.engine) β frontend mapping test with fixture manifests; live withclaudeoff PATH shows "Not installed", unauthenticatedclaudefails within the validate bound. - Answer keys readable by the child after a partial migration β relocation + startup sweep β
$HOME-fixture sandbox test- live denied read.
- Migration clobbers or double-runs a family already on the platform β marker-only-on-success, refuse non-empty, backup-then-abort, copy-not-move β run-twice / non-empty / read-only tests, then the copied-real-data dry run + run with a file-count diff.
- Server refuses to start in the packaged app (empty
AUTH_SECRETfatalserver.go:1525, CLI-securitylog.Fatalf:1713-1721, chat store needs workspace-server first, missingconfigs/) β mirror AgentWorks' startup order and settings persistence β live packaged launch on a clean userData: bothDynamicPort:lines and/api/health200 within 90 s.
Part B β Research record: teaching family-server the AgentWorks session protocol (rejected 2026-09-06)
SparkQuill ships as a standalone consumer desktop app (own .dmg, install-sparkquill.sh, own dock/tray; README: "not a fork" of the AgentWorks desktop). Its Electron shell (desktop-sparkquill/main.js) spawns agent_go/cmd/family-server, a deliberately small Go binary, and on 2026-09-03 the decision was desktop keeps family-server (not the full platform).
The user wants one chat UI to maintain: SparkQuill must render the exact AgentWorks ChatArea + ChatInput (and its transcript, tool-call rows, etc.), not the bespoke .fl-msg/.fl-bubble chat in frontend/learning-app/src/LearningApp.tsx.
That frontend already exists: frontend/learning-app/src/platform/PlatformChat.tsx / ChildPlatformChat.tsx mount the real ChatArea (product variant). It is selected when backend === 'platform' (frontend/learning-app/src/api/index.ts, via VITE_SPARKQUILL_BACKEND or window.sparkquill.backend() β desktop-sparkquill/preload.js does not expose backend() today, so the desktop always falls to standalone). It does not run against family-server because ChatArea is wired to the AgentWorks server protocol (agent-profile conversations, session event polling/SSE, session status/live-input, chat-history, /api/wp workspace documents, product schedule jobs, bearer-token login), none of which family-server serves.
Two options were weighed:
-
Spawn the real
cmd/server(+ separateworkspace-server) from the SparkQuill shell. Technically possible (same Go module;desktop/main.jsalready shows the spawn pattern), butStreamingAPI(agent_go/cmd/server/server.go:333, ~200 fields, no exported constructor) drags the entire platform surface (cost ledger, Gmail, notifications, multi-user auth, tmux reapers) into a family's install β the opposite of why family-server exists. Rejected for a standalone product. -
Teach family-server the protocol β chosen. Feasible because the risky pieces are already shared: turns run via the same
mcpagent/internal/agentsessionengine, tool calls already use the canonicalevents.ToolCallRecordwire type, and family-server already importspkg/productschedule(the identical scheduling engine behind the main server's product schedules).
Outcome: the desktop app keeps its small backend, and PlatformChat becomes THE SparkQuill chat; the bubble chat in LearningApp.tsx is deleted once the new path is verified live.
family-server today (agent_go/cmd/family-server):
- Turn engine:
runParentTurnchat.go:587,runChildTurnchild.go, seamnewAgentSessionturn_session.go:23(agentsession.Config,internal/agentsession/agentsession.go:65); one globalagentTurnMuchat.go:446; steering viasteer.go. - Streaming: bespoke fan-out SSE hub
status_stream.go:36emitting onlystatus|delta|tool_call, no history/cursor (a late subscriber misses everything; final reply returns in the POST body). - Tool calls:
toolCallCollectortool_call_events.go:14(mcpagentAgentEventListener,DirectToolExecutionEvents:true), typeevents.ToolCallRecord(mcpagent) β same as main server. - Storage: JSON files. Parent =
~/.sunlit-learning/workspace/conversations/parent.json(single canonical thread,conversation_store.go:32); child =<activityDir>/conversation.json. Message typeenginedetect.ChatMessage(internal/enginedetect/chat.go:19). Family state~/.sunlit-learning/family.json(state.go:52: engine, child, pin_hash, parent_label, pulse{β¦, watch_sites}, scheduleβ¦). Activities =Subject/Topic/<slug>/activity.jsonat workspace top level (activity.go:57-91), pointercurrent-activity.json. - Workspace HTTP: its own
/api/workspace/tree|file|raw|state+/api/upload(workspace.go,upload.go) β a different protocol from/api/wp/*. - Pulse check-in:
pulseRunner = productschedule.NewRunner(...)pulse.go:317, config infamily.json(PulseConfigstate.go:138), routes/api/pulse/run|status|config. - Router: plain
http.ServeMux, no path variables, no auth; CORS allow-list hardcoded to:5174(main.go:19-22). Optional static SPA viaFAMILY_WEB_DIR. - Imports already:
internal/agentsession,internal/enginedetect,internal/sparkquillproduct(onlySkillFiles),pkg/productschedule,pkg/voicestt, whatsapp pkgs. Not imported:pkg/agentprofiles,internal/events,pkg/chathistory,pkg/schedulerstate,pkg/workspace.
Main server (agent_go/cmd/server, importable package server, same module):
- Agent profiles:
AgentProfileRoutes(router, registry)agent_profile_routes.go:69(GET list/validate/{id}) β reusable withpkg/agentprofiles.Registry+internal/sparkquillproduct.BuiltinAgentProfiles()/RegisterProductSkills()/RegisterAgentProfileRuntime(...)(server.go:1758-1771)./query,/conversation,/conversation/neware*StreamingAPImethods β not reusable;/querybuilds aQueryRequest(AgentMode:"multi-agent",SelectedFolder: conversation.WorkspacePath,X-Session-ID) and callshandleQuery. - Sessions:
GET /api/sessions/{id}/events(polling.go:119),/events/streamSSE (sse.go:48),/status(polling.go:606),POST /live-input(server.go:8614). Backed byinternal/events.EventStore(events.NewEventStore(max), importable) +StreamingAPImaps (not reusable). Frontend:frontend/shared/session/events.ts:19(?since=),sse.ts:67,platformApi.ts:189,191. - Chat history:
GET /api/chat-history/sessions/{id}βserver.ReadChatHistoryConversation(userID, sessionID, workspacePath)chat_history_persistence.go:1977(exported, importable). -
/api/wp/*= reverse proxy (workspace_proxy.go:15) to the separate module/Users/mipl/ai-work/mcp-agent-builder-go/workspace(package main, spawned bydesktop/main.js:1108asworkspace-server). Not importable; family-server must serve the same document/upload contract itself. - Scheduler:
SchedulerRoutesβ product job idproduct:<profile>:<schedule>(product_schedules.go:83) βProductScheduleService(needs*StreamingAPI). Frontend usesCHECKIN_JOB_ID = product:sparkquill:pulse(platformApi.ts:233): GET β{enabled,last_run_at}, POST enable/disable/trigger. - Auth:
POST /api/auth/loginsingle-user path ignores body, returns a JWT for the default user (user_auth_routes.go:223-238);GetUserIDFromContextfalls back toGetDefaultUserID().
Frontend platform mode (frontend/learning-app/src/api/platformApi.ts, api/platform/workspace.ts): FAMILY_ROOT='Chats/SparkQuill', family.json at Chats/SparkQuill/family.json (same sha256 PIN hashing β deliberate compat), activities flat under Chats/SparkQuill/activities/<slug>/, state blobs state/<key>.json, uploads β inbox. Only three FamilyApi methods are still notYet(...): WhatsApp status/pair/unpair.
-
Event bridge is importable and already the right shape.
agent_go/internal/events/event_observer.go:21/30βevents.NewEventObserver(store, sessionID)/NewEventObserverWithLogger(...)implementsmcpagent.AgentEventListenerby wrapping each*mcpagent/events.AgentEventverbatim intoevents.Eventand callingstore.AddEvent. This is exactly what the main server's chat path does (cmd/server/server.go:6014,6047).EventStore.AddEvent(event_store.go:669β257,383-385) auto-stampsExecutionID="main:"+sid,ParentExecutionID="session:"+sid,ExecutionKind="main_agent"β precisely whatworking_set=sessionfiltering keeps β and assignsSequence. Store API:NewEventStore(max):575,GetEvents(sid, GetEventsOptions{SinceIndex,Limit,Offset}):918,Subscribe/Unsubscribe:643/:655,SetEventAddedCallback:634,InitializeSession:869,GetSessionStatus:1190. -
family-server's hook already exists:
agentsession.Config.Observers []mcpagent.AgentEventListener(internal/agentsession/agentsession.go:117, passed unfiltered at:288-290). Appending oneEventObservernext to the existingtoolCallCollector(chat.go:668-669,child.go) yields the FULL canonical stream for coding-CLI providers (streaming is auto-forced on for them:mcpagent/agent/agent.go:2062-2178):agent_start,user_message,conversation_start,llm_generation_start/end,conversation_thinking,streaming_chunk,tool_call_start/end/error,status_line,token_usage,unified_completion,agent_end. No event synthesis needed. -
One dedup decision:
DirectToolExecutionEvents:truemakes the bridge executor emit a second, authoritativetool_call_*pair (ServerName=="direct_execution", real args/result/duration) alongside the CLI-transcript pair;toolCallCollectortoday keeps onlydirect_execution. AnEventObserverstores both β either mirror whatevercmd/serverdoes for its product chat path, or wrap the observer to drop transcript-sidetool_call_*. (Verify main server's setting at implementation.) -
Stable session ids exist: parent =
parentConversationID = "parent"(conversation_store.go:32; Pulse/WhatsApp already use it), child =currentActivityDir()(activity.go:185). These are already whatConfig.SessionIDuses (chat.go:657,child.go:253) so event-store keys == CLI-resume keys for free./conversationresponse shape to mirror:{conversation_id, conversation_key, session_id}(cmd/server/agent_profile_routes.go:306-310). -
Router:
github.com/gorilla/mux v1.8.1is already a direct dep (agent_go/go.mod:17); module isgo 1.26so stdlibServeMuxpatterns ("GET /api/sessions/{id}/events",r.PathValue) also work with zero new imports and no churn on the ~50 existing fixed routes. -
Workspace chokepoint:
resolveWorkspacePath(rel)(materials.go:13) is the single path-safety gate every handler uses;treeNode{name,path,type,children,size}(workspace.go:12-25),handleWorkspaceFile{path,is_text,content|size},handleUpload(upload.go:53, fieldsfile,scope; parentβinbox/<safeName>),handleWorkspaceRawrewrites relative asset URLs to/api/workspace/raw?path=(conversation_store.go:344) β a/api/wpraw shim must NOT apply that query-string rewrite. -
CORS gaps for the platform client:
allowedOriginshardcoded to:5174(main.go:19-22);Allow-Headers: Content-Typeonly (noAuthorization), noPUT. Irrelevant when the SPA is served same-origin viaFAMILY_WEB_DIR(the desktop case), needed for Vite dev. -
Tests:
cmd/family-server/characterization_test.go:27-92βfakeSessionimplementsturnSession,installFakeSession(t, reply, deltas...)swaps thenewAgentSessionseam,setupFamily(t)usesFAMILY_DATA_DIR=t.TempDir(),postJSON; full-turn testsTestParentTurnPromptToolsAndPersistence :114,TestParentTurnStreamsDeltasToStatusSubscribers :292.tool_call_events_test.go:11feeds syntheticAgentEvents into a listener β template for assertingEventStore.GetEventsoutput. Acceptance test already exists:frontend/learning-app/src/api/platformApi.live.test.ts, gated bySPARKQUILL_PLATFORM_URL, drivescreatePlatformApiend-to-end (conversation β query β events?since&working_set=session + SSE β status β live-input β chat-history β/api/wptree/upload/raw). It asserts: tree has nodefamily.json; upload returnspath==='inbox/live-test.txt';rawUrlcontains/api/wp/api/documents/Chats/SparkQuill/inbox/live-test.txt/raw?token=;week(0).days.length===7; monotoniclast_processed_index(SSE baseline before query,platformApi.ts:144-162).
Two clients hit the same origin: SparkQuill's platformApi.request() (Authorization: Bearer) and AgentWorks' axios agentApi/workspaceApi (Authorization: Bearer from localStorage['auth_token'], plus X-Session-ID on every request; workspaceApi adds X-User-ID decoded from the JWT's user_id/sub β src/services/api.ts:605-634). platformApi.ensureSession() writes the token to both keys before ChatArea mounts.
Fatal if missing (chat never renders β PlatformChat.tsx:151-153):
-
POST /api/auth/login(body-less) β{token}non-empty. -
POST /api/agent-profiles/{id}/conversation({}parent /{conversation_key}child; headerX-Session-IDwhen a tab exists) β{conversation_id, conversation_key, session_id}. -
GET /api/sessions/{id}/events?working_set=session&limit=300&offset=0(initial hydrate, fires twice) β{events[], session_status, last_processed_index, has_more}; 404 tolerated only if chat-history returned something. -
GET /api/chat-history/sessions/{id}?resume_turns=100&include_ui_events=1&workspace_path=Chats/SparkQuillβ 2xx{session_id, conversation_history: [...]}or 404; any other error is fatal. -
POST /api/agent-profiles/{id}/query({message, conversation_key?}, headerX-Session-ID) β{status:"started"|"workflow_started"|"live_input_delivered", session_id, query_id, message?, delivery_status?}; any otherstatusrenders an inline error. - Live stream:
GET /api/sessions/{id}/events?working_set=session&since=Npolled every 750 ms while a turn runs (also 500 ms SSE-fallback), and SSEGET /api/sessions/{id}/events/stream?working_set=session&since=N&token=<jwt>(EventSource,withCredentials). SSE frames: namedevent/status(unnamed βevent);id:= store index used as resume cursor (Last-Event-IDmust be honored like?since=);eventdata{events, session_status?, display_status?, last_processed_index, has_running_background_agents?, is_synthetic_turn?, can_steer?, runtime_state?};statusdata same minus events;:comment lines OK as keep-alive. - Cursor/status sentinels:
last_processed_index:-1means "session unknown" (client resets cursor / follower gives up) β never send it for a healthy empty session;session_status β running|completed|error|stopped|inactive;completed|errorwithhas_running_background_agents:falsestops the catch-up loop β a turn that never reaches a terminal status leaves the client polling forever. Turn-finished event =unified_completion(data.data.final_result/status) oragent_end/conversation_end/conversation_error/agent_error. -
PollingEventfields read:id,type,timestamp,session_id,event_indexanddata.data.{content,tool_name,tool_call_id,tool_params.arguments,result,error,duration,final_result,status,chunk_index,is_delta,is_tool_call,source,kind,payload,metadata.assistant_turn_text}.
Optional but noisy (stub cheaply): GET /api/header-summary every 60 s (+ after each submit) β {active_sessions:[],total:0,schedule_summary:{...zeros}}; GET /api/llm-config/providers on chat mount β {providers:[]}; GET /api/agent-profiles/{id} (+?version=N) β declaration (tools[].interaction/presentation, commands[], runtime.provider_options[], runtime.capabilities, schedules[]) β used for suggestion pills, quick commands, engines list, mic gate, check-in cadence; /api/commands, /api/skills on demand.
Session control (needed for Stop / steering): POST /api/session/cancel-turn + POST /api/session/stop?cancelAgents=true (header X-Session-ID), POST /api/sessions/{id}/dismiss, GET /api/sessions/{id}/status ({status, agent_mode, last_activity, can_steer}), POST /api/sessions/{id}/live-input ({message} β {delivery_status: sent_to_cli|queued_for_injection|next_turn_started, message_id, success}; only used while a turn streams β failure falls back to /query).
Workspace (/api/wp, prefix Chats/SparkQuill/, segments percent-encoded, / kept): GET /api/wp/api/documents/{path} β {success, data:{filepath,content,is_binary,size}, error}; PUT same path {content}; GET /api/wp/api/documents?folder=<enc>&max_depth=-1|1|2 β {success, data:[{filepath,type:"folder"|"file",size,children[]}]}; POST /api/wp/api/upload multipart file, folder_path, commit_message? β filepath|data.filepath|data.file_path; GET /api/wp/api/documents/{path}/raw?[download=true&]token=<jwt> raw bytes with Range support (<img>/<video>/<audio>/<iframe> β header auth impossible). No DELETE issued.
Settings-only: /api/voice/status (path already matches family-server; platform reads available,installed,downloading,loading,ready,got_bytes,total_bytes,size_mb), /api/voice/warm, WS /api/voice/stream; /api/secrets/stored|encrypt|store|store/{name}; /api/scheduler/jobs/product:sparkquill:pulse GET ({enabled,last_run_at}) + /enable|/disable|/trigger (id percent-encoded as product%3Asparkquill%3Apulse).
Not called on the product surface: /api/terminals, main-terminal WS, /api/capabilities, /api/secrets/decrypt, ui-control.
Legacy duplicate traffic (frontend cleanup item): LearningApp.tsx gates the child legacy FamilyApi effects on backend==='platform' (1768,1810,1933) but not the parent ones β loadParentConversation on mount + every 20 s (:1144,:1685), watchParent (a second SSE stream via fetch transport, :1733), steerParent (:2211) still fire. Non-fatal, but gate them the same way (or delete with the bubble chat).
CORS (Vite dev only; desktop serves same-origin via FAMILY_WEB_DIR): echo Origin http://127.0.0.1:5174, Allow-Credentials: true, headers Authorization, Content-Type, X-Session-ID, X-User-ID, Accept, methods GET, POST, PUT, DELETE, OPTIONS.
Tool-call dedup β resolved: mirror the main server. pkg/agentwrapper/llm_agent.go:151 sets DirectToolExecutionEvents: true on the main chat path too, and the CLI-transcript pair carries the same chunk.ToolCallID (mcpagent/agent/llm_generation.go:690-760), so the transcript's pairToolCalls merges both pairs by id. Family-server stores both (no wrapper); toolCallCollector stays as-is for its own POST response.
-
Events poll
GET /api/sessions/{id}/eventsβcmd/server/polling.go:119. Params:since(poll mode) orlimit(>0)/offset(β₯0) (page mode); neither β400;working_set=sessionβevents.FilterSessionWorkingSet. EnvelopeGetEventsResponsepolling.go:96(events, has_more, session_id, session_status, display_status, last_processed_index, has_running_background_agents, is_synthetic_turn, can_steer, runtime_state). Unknown session β200 {events:[], has_more:false, last_processed_index:-1}(:214). Element =internal/events.Event(event_store.go:200) with customMarshalJSON:217(alwaysid,type,timestamp,session_id; conditionallyerror,execution_id,parent_execution_id,execution_kind,terminal_owner_id,terminal_id,sequence>0,data);data=*mcpagent/events.AgentEvent, typed payload flat atdata.data(+BaseEventDatafields incl.metadata).GetEventsalready appliesNEVER_SHOW_EVENTS/HIDDEN_EVENTS(event_store.go:18,59: streaming_, llm_generation_start, agent_start, system_prompt, conversation_start/turn hidden from polling; user_message, tool_call_, llm_generation_end, agent_end, unified_completion, token_usage kept),InitialEventsLimit=300+STRUCTURAL_EVENTS, cap 1000.Sequenceassigned inAddEvent:683. -
SSE
GET /api/sessions/{id}/events/streamβcmd/server/sse.go:48.sinceorLast-Event-ID(non-int β 400);?token=auth; headerstext/event-stream,no-cache,keep-alive,X-Accel-Buffering: no.writeSSEEvent:285:id: <n>(only nβ₯0) /event: <name>/data: <json>. Names:event(sseEventMessage:16, backfill N events withIncludeStreaming:true:112, then one event per frame; nosession_idfield),statusevery 2 s (sseStatusMessage:29, noid:),cursor(data: {}+id:when working_set filtered everything,:118,:221). Heartbeat = comment: heartbeat <ts>every 15 s (:273). Cold/unknown session β oneeventframe, no id,last_processed_index:-1(:162-172). Stream never self-terminates. -
Status
GET /api/sessions/{id}/statusβpolling.go:605. 404 for unknown session (unlike events). Hand-built map keys:agent_mode, can_steer, created_at, display_status(busy|idle|stopped), has_retained_tmux_session, last_activity, query, runtime_state, session_id, status(running|completed|error|stopped|inactive).RuntimeSnapshotruntime_coordinator.go:58(+RuntimeForegroundSnapshot{busy,has_cancel,can_steer,synthetic}, phasesstarting|running|waiting|idle|completed|failed|canceled). -
Live input
POST /api/sessions/{id}/live-inputβserver.go:8614;LiveInputRequest{message}:1387;LiveInputResponse{success,message,delivery_status,provider,message_id,query_id}:1391; statusessent_to_cli | queued_for_injection | next_turn_started;message_id="steer-message-<nanos>"; 409 plain text when no live turn. Frontend unblocks composer only onsent_to_cli|next_turn_started. -
Agent profile
GET /api/agent-profiles/{id}[?version=N]βagent_profile_routes.go:426; body =agentprofiles.Profile(pkg/agentprofiles/types.go:299) marshalled directly (tools[].presentation/interaction, commands[], runtime.provider_options[], runtime.capabilities, schedules[] =productschedule.Schedule); errors{"error":...}JSON;OPTIONSβ204. Reusable viaserver.AgentProfileRoutes(router, registry)(:69). -
Conversation
POST /api/agent-profiles/{id}/conversation[/new]β:275/:315; strict decode (DisallowUnknownFields, β€2 MiB) of{conversation_key?}; response exactly{conversation_id, conversation_key, session_id}(:33); honorsX-Session-IDhint (:359-374). -
Query
POST /api/agent-profiles/{id}/queryβ:200; strict{message, conversation_key?}(extra keys β 400); overwritesX-Session-IDwith the resolved session; returnshandleQuery's JSON ackQueryResponseserver.go:1257:{query_id:"query_<nanos>", session_id, status:"started", message:"Query processing started. Use polling API to get real-time updates."}. Result arrives via events/SSE. -
Chat history
GET /api/chat-history/sessions/{id}?workspace_path&resume_turns&resume_offset&include_ui_events=1βchat_history_routes.go:462; 404 plain"Session not found"; 200 = raw on-diskconversation.jsonbytes (:507) built atchat_history_persistence.go:151-179:{session_id, agent_mode, conversation_history:[llmtypes.MessageContent β {"Role":"human"|"ai"|"tool","Parts":[{"Text":β¦}|ToolCall|ToolCallResponse]} (no json tags β capitalized; reader accepts either casing:716)], updated_at, runtime?, ui_events?:[internal/events.Event]}.resume_turns=Nprojection (:589) strips ui_events/terminal_snapshots, keeps last N user-anchored turns withresume_order, addshistory_source_message_count+history_pagination{has_more,next_offset,start_turn,total_turns};include_ui_events=1re-attaches filteredui_events. -
Workspace (
/api/wpβ moduleworkspace, gin):handlers/documents.go. EnvelopeAPIResponse[T]{success,message,data?,error?}(models/document.go:31),Document{filepath,content?,type?,children?,is_image?,is_binary?,size?,mime_type?,encoding?,last_modified?}(:6). GET single (:787): text βencoding:"utf-8"; image βdata-url; binary βcontent:"",is_binary:true; missing file β200 {success:true,message:"File does not exist",data:{filepath:""},error:"File not found: β¦"}; traversal β 400. Listing (:402)?folder&max_depth(limit/offset ignored) β full hierarchical tree, folderstype:"folder"+children, no content; missing folder β 200data:[]. PUT (:990){content}creates dirs/file βdata:{filepath}. DELETE needs?confirm=true. Upload (:1927) multipartfile+folder_path(10 MiB, allow-list) βdata:FileUploadResponse{filepath,filename,file_size,content_type,folder}./rawβ bytes (family-server: usehttp.ServeContentfor Range). -
Product schedule
/api/scheduler/jobs/product:sparkquill:pulseβscheduler_routes.go:1103handleProductScheduleJob(id parsed on last:). GET/enable/disable βScheduledJobResponse(:21; frontend reads onlyenabled,last_run_at*time.Time); trigger β{session_id}; already running β 409 plain textproductschedule: a run is already in progress(pkg/productschedule/runner.go:25). Persisted state shapeproductScheduleUserState(product_schedules.go:40). -
Login
POST /api/auth/loginβuser_auth_routes.go:212; single-user branch:222-238ignores body βAuthResponse{token, user:UserInfo{id,username,β¦,can_*}}(:27,:33). Token must be a real HS256 JWT (GenerateJWTWithProviderauth_middleware.go:300, key =AUTH_SECRET, claimsUserClaims{user_id,username,email?,provider?,scope?,scope_workspace?}+sub=userID, iss="mcp-agent-builder", iat/nbf/exp(+7d));workspaceApidecodesuser_id/subclient-side. Middleware (:162) acceptsAuthorization: Beareror?token=; 401 bodies{"error":"Invalid or expired token"}/"Session expired: β¦"β frontend axios drops the token only when the 401 body contains "expired"/"invalid". -
Turn event order (mcpagent
agent/conversation.go):agent_start(hidden) βuser_messageβconversation_start,system_prompt(hidden) β per LLM callllm_generation_start(hidden),streaming_start/chunk/end(SSE only),conversation_thinkingβllm_generation_end(visible) β per tooltool_call_startβtool_call_end|tool_call_errorβconversation_turn(hidden) βtoken_usageβunified_completion(terminal, structural;data.data.{final_result,status,question,duration,turns,error}) βagent_end. Frontend "turn finished" =llm_generation_end|unified_completion|agent_end|conversation_end|conversation_error|context_cancelled(ChatArea.tsx:1703); transcript answer typesunified_completion|agent_end|orchestrator_agent_end|background_agent_completedreadingcontent||final_result||result; an empty successfulunified_completionis dropped. Composer busy/idle comes fromdisplay_status/runtime_state.foreground_turn, not events.
Import vs copy decision: family-server is in the agent_go module, so import internal/events (store/observer β needed for sequencing + identical MarshalJSON), pkg/agentprofiles, internal/sparkquillproduct, and mcpagent/events. For the thin wire structs (GetEventsResponse, SSE messages, RuntimeSnapshot subset, LiveInput*, AgentProfile*, QueryResponse, AuthResponse/UserInfo/UserClaims, ScheduledJobResponse subset) prefer copying into cmd/family-server/platform_wire.go with // mirrors cmd/server/<file>:<line> comments rather than linking all of package server into the desktop binary (binary size + package-level init side effects). Guard drift with a platform_wire_sync_test.go that imports package server only in tests and asserts json-tag parity via reflection. Exception: server.AgentProfileRoutes / server.GenerateJWT may be imported if their transitive init cost proves acceptable (measure binary size; decide in Phase 0).
- Transport/envelope:
/api/wp/api/documents{success,data:{filepath,type,content,size,is_binary,is_image,children}}vs family-servertreeNode{name,path,type,children,size}. - Root prefix
Chats/SparkQuill/(frontend) vs bare workspace root (family-server);familyRelative()tolerates absence. -
family.jsonlocation (~/.sunlit-learning/family.jsonvs<workspace>/Chats/SparkQuill/family.json) andwatch_sitesnesting (pulse.watch_sitesvs top-level). - Activity layout:
Subject/Topic/<slug>/vs flatactivities/<slug>/.
-
Router: Go 1.22+
http.ServeMuxpatterns ("GET /api/sessions/{id}/events",r.PathValue,{path...}wildcard). Zero churn on the ~50 existing fixed routes; percent-encoded ids (product%3Asparkquill%3Apulse) unescape viaPathValue. gorilla/mux not adopted. -
No
import "β¦/cmd/server"in the binary. Reusable handlers are 20β60 lines; importingpackage serverdrags the platform dependency graph +init()side effects into a 93 MB consumer binary. Import only leaves:internal/events,pkg/agentprofiles,internal/sparkquillproduct,pkg/orchestrator/events,pkg/productschedule,github.com/golang-jwt/jwt/v5(alreadygo.mod:12). Wire structs are copied intocmd/family-server/platform_wire.gowith// mirrors cmd/server/<file>:<line>comments; a test-only import ofpackage serverinplatform_wire_sync_test.goasserts json-tag parity by reflection (drift guard without linking it into the app). -
Session ids: parent =
"parent"(unchanged). Child platform sid =child:<slug>β the shared client interpolates the raw sid into/api/sessions/${sessionId}/β¦(frontend/src/services/api.ts:713,986,1292, verified), so a slash-containingcurrentActivityDir()can never route.agentsession.Config.SessionIDstays the real activity dir (CLI-resume key unchanged); theEventObserveris keyed by the platform sid (independent ofConfig.SessionID,event_observer.go:21)./conversationresolves slug β activity dir; 422 if unknown. -
/querysemantics: marksession_status="running"synchronously in the handler before the ack (verified:ChatArea.tsx:2220-2224stops the 750 ms loop oncompleted|error|stopped|inactivewith no bg agents β if the turn goroutine is still waiting onagentTurnMu(Pulse can hold it minutes) and status still readscompleted, the UI goes silent). Goroutine moves it tocompleted|error. Ack{status:"started", session_id, query_id}. -
Second
/querywhile a turn runs: trytrySteer(steer.go:77) β{status:"live_input_delivered", delivery_status:"sent_to_cli"}; else accept asstartedand let a per-sessionturnMuqueue it. Never 409. -
Pre-agent failure (engine unset,
newAgentSessionerror): no agent event exists, so store oneagent_errorevent with the friendly message so the transcript shows something; status βerror. -
Auth: family-server mints its own HS256 JWT (per-install random secret at
~/.sunlit-learning/auth.secret, 0600; claimssub+user_id="default",username="user",iss="mcp-agent-builder", 7 d).server.GenerateJWTis not usable (hard-requiresAUTH_SECRET,auth_middleware.go:295-302). Validation enforced on the platform prefixes only (/api/sessions/,/api/agent-profiles/,/api/chat-history/,/api/wp/,/api/secrets/,/api/scheduler/,/api/header-summary,/api/llm-config/,/api/commands,/api/skills), header or?token=; 401 body{"error":"Invalid or expired token"}. Existing standalone routes stay open during transition. -
Chat history is served, not 404'd.
hydrateTabEventsprefers durable history and the event store is memory-only β a 404 means an empty chat after every desktop relaunch (verifiedsessionRestore.ts:344-368). MapstoredConversation.Messagesβconversation_history[](~40 lines); double hydrate is deduped client-side. -
Product events come from the tools, not synthesis. Suggestion pills / celebrate / scene / viewer-open in
PlatformChat/ChildPlatformChatare driven byproduct_interaction/presentation_updatedevents. Emit them from family-server's existing tool sinks (chat.go:670-697,child.go:154-210) usingpkg/orchestrator/events.ProductInteractionEvent{Product:"sparkquill",Kind,Payload}/PresentationUpdatedEvent(pkg/orchestrator/events/data.go:207-238), wrapped asemitAgentProfileEventdoes (cmd/server/agent_profile_runtime.go:389-398). Kinds come fromproduct.yaml(suggestions,celebrate,scene,document.file,sparkquill.activity) which the client reads back fromGET /api/agent-profiles/{id}β consistent by construction. -
SSE
id:=Sequence-1(exact store index;Data.EventIndexis never set anywhere, so the main server'slastIndex+1scheme drifts on hidden events). Use a largemaxEventsso no pruning; fall back to the main-server scheme only if pruning is enabled. -
Frontend adapter: add
createFamilyServerApi()=createPlatformApifor chat/workspace + family-server-native routes for settings (/api/setup|engines|engine/selection|engines/validate|models|fast-mode|whatsapp/*,/api/pulse/config). KeepcreatePlatformApipure so a future platform-hosted SparkQuill still works. Reason (verified): platformsetup()never returnsnext_step:"engine"andselectEngineis a no-op (platformApi.ts:254-266,290-291), while family-server refuses turns withouts.Engine(chat.go:545). -
Mode switch:
desktop-sparkquill/preload.jsexposesbackend: () => 'platform'(runtime;runtimeConfig.ts/api/index.tsalready read it) β noVITE_SPARKQUILL_BACKENDbuild flag needed;dev-setup.shunchanged. Bubble chat deleted only after live verification (Phase 7). -
family.jsonwrites via/api/wp(incl.pin_hash, same sha256-hex ashandleSetPin) are accepted β that is how platform mode already works; server never materialises a physicalworkspace/family.json. -
Acceptance test fix first:
platformApi.live.test.ts:60-61callsapi.week(0)butFamilyApihas noweek(verified; removed in083474b88) β it throws before therawUrl/raw assertions run. Drop lines 60-61 in Phase 0 (/api/weeknot needed). Run vitest fromfrontend/(that's where it's installed,frontend/package.json:85). -
Turn context:
/queryruns the turn undercontext.Background()+turnTimeout, neverr.Context()βhandleParentMessagederives the turn ctx from the request (chat.go:564), and an async ack would cancel it. -
"Known session" is derived from conversation identity (
parent/ a real activity dir), never fromEventStoreβcleanupInactiveSessionsdeletes zero-event sessions on a ticker (event_store.go:1241-1255), so an initialized-but-empty session flips back to unknown. Unknown β 404 on events/status/SSE; known-but-empty β200 {events:[], session_status:"completed", last_processed_index:0}insincemode (never-1for a known session). Statuses are sticky until the next/query; a per-session status map set in adeferguarantees every turn reachescompleted|error|stopped. -
Mode switch naming + kill switch: the new frontend adapter is a
desktopbackend (hybrid: platform protocol for chat + workspace docs, existing standalone routes for settings/WhatsApp/PIN verify/pulse config).preload.jsexposesbackend: () => process.env.SPARKQUILL_UI ?? 'desktop'withmain.jsforwarding the env, giving families aSPARKQUILL_UI=legacyescape hatch for one release; the switch is deleted together with the bubble chat.
All new Go files under agent_go/cmd/family-server/ unless noted. Reuse targets in parentheses.
- Add
event_store.go:var platformEvents = events.NewEventStore(20000);platformObservers(sid string, extra ...mcpagent.AgentEventListener) []mcpagent.AgentEventListenerappendingevents.NewEventObserverWithLogger(platformEvents, sid, logger)(internal/events/event_observer.go:30,event_store.go:575). - Modify
chat.go:668βObservers: platformObservers("parent", toolCalls); temporaryGET /api/sessions/{id}/eventshandler. - Run one real claude-code and one codex-cli parent turn; dump
GetEvents(working_set=session); confirmuser_message,streaming_chunk(subscriber-only), bothtool_call_*pairs sharingtool_call_id,llm_generation_end.metadata.assistant_turn_text,unified_completion/agent_end. Measure binary size with leaf imports only. Fix the live test'sapi.week. - Exit criterion: real events, no synthesis, no
cmd/serverimport needed. If a provider path emits nounified_completion, Phase 1's status handling covers it.
-
platform_routes.go:registerPlatformRoutes(mux)β the single place the contract is visible. -
session_registry.go:platformSession{Scope, ConversationID, SessionID, Status, AgentMode, CreatedAt, LastActivity, Query, QueryID; cancel; turnMu};sessionFor(profileID, key)(parent β"parent"; child β slug vialistActivities()/currentActivityDir(),activity.go:123,185);setStatus;canSteer(sid)fromactiveTurns(steer.go:34-37). -
turn_runner.go:startPlatformTurn(ps, message) queryIDβ setrunning, goroutine:ps.turnMu.Lock(), ctx with timeout stored for cancel,messages := append(loadStoredConversation(...).Messages, user msg), call existingrunParentTurn(ctx, s, "parent", messages, "")/runChildTurn(ctx, s, dir, messages)(chat.go:587,child.go) βagentTurnMu, steer registry, persistence, session handles, Pulse/WhatsApp callers all untouched because they live inside those functions β thensetStatus(completed|error). - Modify
chat.go:668,child.go:261,pulse.go:481(runPulseCheckTurn) to add the platform observer; movesetStatus(running/completed)intorunParentTurn/runChildTurn(idempotent) so WhatsApp/Pulse-started turns stream into an open tab. - Modify tool sinks (
parent_tools.go,child.go:154-210): emitproduct_interaction/presentation_updatedper decision 9 (suggest_actionsβsuggestions{actions},set_child_profile/set_parent_label/set_child_scheduleβfamily_updated,create_learning_activityβactivity_created{dir,title},celebrateβcelebrate{stars,reason},show_sceneβscene{html},open_fileβpresentationdocument.file{path:"Chats/SparkQuill/"+rel,focus},open_activityβsparkquill.activity{dir}); thread the sid intoparentToolSinksand the child closure. -
session_events.go:handleSessionEvents(copypolling.go:119-261minus user/bg-agent/runtime branches; keep the!exists β -1branch),handleSessionSSE(copysse.go:48-313: subscribe-then-backfill,Last-Event-ID, 2 sstatus, 15 s heartbeat,cursorframes),handleSessionStatus(404 unknown;{session_id,status,agent_mode:"multi-agent",created_at,last_activity,query,can_steer,display_status}),handleLiveInput(trySteerβsent_to_cli+appendUserMessageToConversation, else 409),handleDismiss,handleCancelTurn/handleStopSession(cancel ctx βstopped). -
profile_routes.go: registry at startup (agentprofiles.NewRegistry(),sparkquillproduct.RegisterProductSkills(),BuiltinAgentProfiles(); skipRegisterAgentProfileRuntimeβ needs a workspace API URL and registers platform tool factories family-server doesn't run);GET /api/agent-profiles,GET /api/agent-profiles/{id}(copyagent_profile_routes.go:430-452),POST β¦/conversation(child: slug β real dir vialistActivities(); on slug collision prefer the current activity, then newestcreated_at, and log),β¦/conversation/new(child rotate = archive<dir>/conversation.json+ delete the session handle (session_handle_store.go) + close that activity's interactive session β otherwise the warm CLI keyed bySessionID: activityDirresumes the old context;agentsession.CloseAllInteractiveSessions()exists atstate.go:400, add a per-id variant β +platformEvents.RemoveSession),β¦/query. -
Pulse:
runPulseCheckTurn(pulse.go:490-500) builds its session with noObservers/StreamCallback, so check-ins would emit nothing. Attach the platform observer wrapped so theuser_messagecontent is the parent-facingc.trigger, not the rawc.instruction(pulse.go:30-45); keep the parent sessionrunningfor the whole multi-check run (Pulse holdsagentTurnMufor minutes,pulse.go:397-403). -
Waiting on
agentTurnMu: while/query's goroutine is blocked behind Pulse/WhatsApp, emit astatus_line/thinking event fromagentTurnBusy()(chat.go:519-527) so the wait is visible instead of a silentrunning. -
Cancel registry: store the turn
cancelalongside theactiveTurnsentry socancel-turn/stop?cancelAgents=truecancel the ctx;runParentTurnalready persistsfriendlyTurnErroron ctx errors (chat.go:724-737).
-
auth.go:loadOrCreateAuthSecret(),mintToken(),requireToken(next)on the platform prefixes (decision 7). -
main.go:19-22,220-236withCORS:Allow-Credentials: true; headersAuthorization, Content-Type, X-Session-ID, X-User-ID, Accept, Last-Event-ID; methodsGET, POST, PUT, DELETE, OPTIONS; keep allow-list. -
platform_stubs.go:/api/header-summary{active_sessions:[],total:0,schedule_summary:{}},/api/llm-config/providers{providers:[]},/api/commands[],/api/skills{skills:[]}. -
platform_secrets.go(storedβ[{name}]fromlistSecretNames();encrypt {value}β{encrypted}viasecretsGCM()secrets_store.go:52;PUT store;DELETE store/{name}),platform_scheduler.go(GET β{id,name,enabled:s.Pulse.Enabled,last_run_at}; enable/disable βsaveState; trigger βpulseRunner.RunNow(ctx, productschedule.SourceManual)pulse.go:558, 409 plain text onErrAlreadyRunning, β{session_id:"parent"}),voice_hardware.gosuperset (available,installed,downloading,loading,ready,got_bytes,total_bytes,size_mbfromfamilyVoice.Status()).
-
chat_history_routes.go:GET /api/chat-history/sessions/{id}β sid β(scope,id)βloadStoredConversation(conversation_store.go:34,250). 404 only for an unknown conversation; known-but-empty β200with emptyconversation_history(client treats non-2xx/non-404 as fatal). Body{session_id, conversation_history:[{Role:"human"|"ai", Parts:[{Type:"text",Text}], resume_order, resume_source_message_count}], history_pagination:{has_more,next_offset,start_turn,total_turns}}; client parsing accepts either casing and collapses consecutive assistant messages (frontend/shared/session/restore.ts:10-23,131-166). Honourresume_turns(client sends 100) βconversations/parent.jsonis append-only forever (Pulse adds two messages per check) and is fetched twice per mount. -
Role:"tool"rows:celebrate/scene(Tool:"celebrate"|"scene",Stars/Reason/HTML) β emit asui_eventsproduct_interactionwith kindscelebrate|scene(ChildPlatformChat already renders them);photo/video/voice_failed(WhatsApp media,whatsapp_bot.go:435-447) β a newmediaproduct row (product_interaction {kind:"media", payload:{path,tool}}, live + restored) β until built, WhatsApp media is a known gap in platform mode;Source:"pulse"badge β acceptable loss for v1 (restored user row shows the trigger label). -
Tool-call rows after restart (Phase 3b, can follow Phase 6): on turn end dump the session's
tool_call_*+product_interactionevents to a bounded sidecar (conversations/parent.ui-events.json) and return them asui_events; the client merges/dedups by id and back-fillstool_params.arguments(sessionRestore.ts:211-252). -
Text parity: the live test asserts
lastAssistant.text === result.reply(live.test.ts:41); the persisted reply hasappendSentFileLinksapplied (chat.go:749) whileunified_completion.final_resultmay not β emit the completion after the links are appended, or the mapper must use the same source the live path does.
-
wp_routes.go:wpTarget(raw) (kind, rel, ok): strip_users/<x>/, thenChats/SparkQuill(accept bare paths);family.jsonβ virtual;activitiesβ virtual listing; elseresolveWorkspacePath(materials.go:13). -
wp_documents.go:GET β¦/documents/{path...}(detect trailing/rawmanually), listing?folder&max_depthβ convertbuildTreeSized(workspace.go:45) toDocument{filepath:"Chats/SparkQuill/"+path,type,size,children}and prepend a virtualfamily.jsonnode (live test asserts it);PUTβwriteFileAtomic; missing file β200 {success:true,message:"File does not exist",data:{filepath:""},error}(mirror). -
wp_upload.go: multipartfile,folder_pathβ{success:true,data:{filepath:"Chats/SparkQuill/inbox/<safeName>",filename,file_size}}; if folder ==currentActivityDir()alsosaveCurrentUpload(parityupload.go:101-103); keep the 64 MB limit. -
wp_raw.go: token check,http.ServeContent(Range),Content-Typeby ext,inline; filename=;download=trueβ attachment; norewriteRelativeAssetURLs/withDiagramLibHTML. -
Virtual
family.json(never materialised on disk): GET returns only the projection{child:{name,grade,board}, parent_label, pin_hash, watch_sites: s.Pulse.Sites()}(Sites()folds legacyschool_portal_url,state.go:165-183; client typeworkspace.ts:13). PUT is a whitelisted merge understateMuof exactly those four keys:childmergesname/grade/boardpreservingLanguage/CreatedAt(on create setLanguage:"en",CreatedAt, runscaffoldFamilyFolders()+seedWorkspace()likehandleCreateChild,state.go:307,326);pin_hashstored as given (same sha256-hex ashandleSetPinstate.go:350-353);watch_sitesβPulse.WatchSitesand clearSchoolPortalURL; everything else in the body ignored β never let the client blob overwriteengine,pulse.enabled/cadence/preferred_hour,schedule,fast_mode,selected_models,whatsapp_voice_enabled. Inject afamily.jsonnode into theChats/SparkQuilllisting. -
Activities: only the
activitieslisting is virtual and its entries carry the real dir asfilepath(Chats/SparkQuill/Maths/Fractions/<slug>,type:"folder"). The client never parses theactivities/segment (workspace.ts:118-123,31-37,171;platformApi.conversationKeyFortakes the last segment), so its subsequentactivity.jsonread,current-activity.json {dir:<realDir>}write (identical toactivity.go:177-179), handoffnew_sessioncomparison and uploads all hit real paths β no other rewriting, child sandbox (child_workspace.go:19-31) untouched. Returningactivities/<slug>paths instead would danglecurrentActivityDir()and force perpetual "Start fresh". -
state/<key>.jsonβ<currentActivityDir>/attempts/<safeStateKey(key)>.jsonwhen an activity is bound (keeps the child's answers inside the sandbox andactivityLastTouchedhonest,archive.go:80-86), else workspace-rootstate/<key>.json(the live test saveslive:keywith no activity bound). Client envelope{key,data}; standalone addsupdated_atβ read.dataeither way. -
Reserve
activitiesandstateinreservedTopLevel(activity.go:60-87): today neither is reserved, so a physical folder of that name would be treated as a Subject byisSubjectDir, walked bylistActivities, and archived byarchiveStaleActivities(archive.go:37-62). -
Raw HTML (print/download/agent self-check only β the viewer renders HTML via
srcDocfrom the JSON GET,LearningApp.tsx:3408): reusewithDiagramLibHTML(conversation_store.go:383-394;/lib/ships viaextraResources) and the?printscript (:441-443); parameterizerewriteRelativeAssetURLswith a URL builder that emits<dir>/<asset>/raw?token=<same token>(its hardcoded/api/workspace/raw?path=at:355is wrong here, and a bare relativefoo.pngwould otherwise resolve to the JSON documents endpoint). Acceptdownload=1|true. Frontend nit:platformApi.rawUrldropsopts.print(platformApi.ts:318) β passprint=1through. - Upload: infer
scope=childwhenfolder_pathequalscurrentActivityDir()sosaveCurrentUploadstill queues the photo for the next child turn; keep family-server's 64 MB and no type allow-list (the workspace module's 10 MB +isAllowedFilewould reject files families upload today).
-
desktop-sparkquill/preload.js:backend: () => process.env.SPARKQUILL_UI ?? 'desktop';main.js:108-112forwardsSPARKQUILL_UI(kill switchlegacyfor one release, decision 17).dev-setup.sh/CI unchanged (noVITE_SPARKQUILL_BACKEND). -
frontend/learning-app/src/api/desktopApi.ts(new) +index.ts:backend === 'desktop'βcreateDesktopApi()=createPlatformApifor conversations +tree/readFile/rawUrl/upload/saveState/loadState/activities/handoff/childActivity, andstandaloneApiforsetup/engines/validateEngine/selectEngine/models/saveModel/fastMode/saveFastMode/secrets/voiceStatus/browserStatus/whatsapp*/pulseConfig/savePulseConfig/runPulse/verifyPin(all same-origin, already implemented). Keeps engine-selection onboarding, models, Fast Mode, WhatsApp pairing,preferred_hour, and the constant-time PIN compare (state.go:385).createPlatformApistays pure for a future platform-hosted SparkQuill.rawUrlpassesprint. -
runtimeConfig.ts: treatdesktoplikeplatformfor__APP_RUNTIME_CONFIG__(it's the same wire protocol). -
LearningApp.tsx: gate the legacy parent effects:1144,:1685,:1733,:2211onbackend === 'standalone'(same shape as the child gates at:1768,1810,1933); renderPlatformChat/ChildPlatformChatforbackend !== 'standalone'. -
platformApi.live.test.ts:60-61weekfix (if not done in Phase 0).
Phase 7 β cleanup (1β2 days, only after Phase 6 passes live, including against a copied real ~/.sunlit-learning)
- Delete the gated legacy parent effects (
LearningApp.tsx:1144, :1685, :1733, :2211). - Delete the bubble branches:
backend !== 'platform'blocks (:2829-,:3936-), the parent thread renderer (~:2938-3100, incl. photo/video/voice rows:2990-3035and Pulse badge:3059-3085), the child bubble branch (:4078-4135).PlatformChat's landing greeting still usesfl-msg/fl-bubble(:2946-2948) β restyle those two. - Remove
FamilyApimethods only the bubble used (sendParentTurn/steerParent/watchParent/loadParentConversation, child equivalents,TurnStreamEvent,TurnCollector/messagesFromEventsinplatform/events.ts); keephistory()only if still read. - CSS in
learning-app.css:.fl-thread/.fl-msg*/.fl-bubble*(297-322),.fl-tbubble*(601-666), markdown-in-bubble rules (634-652), Pulse variants (841-842),.chat-bubble(206), streaming cursor (164). Keep--navy/--cream(:5,:9,:57; 19 other shell usages) and all.fl-platform-*(1231-1294). OnlyLearningApp.tsxuses these classes (grep-confirmed). - Remove the
SPARKQUILL_UIswitch andstandaloneApichat methods. - family-server: keep
/api/parent/message|status|steer,/api/child/*one release (WhatsApp/Pulse don't depend on them;characterization_test.godoes), then remove withstatus_stream.go.
Total: ~12β15 days.
- Phase 0 dump proves real event shape (no synthesis) and binary-size delta.
-
Unit (Go) β pattern
characterization_test.go:27-95(fakeSession/installFakeSession,httptest.NewRecorder,FAMILY_DATA_DIR=t.TempDir(),postJSON):platform_routes_test.go(loginβtoken; conversation ids parent/child; events-1for unknown sid,0for known-empty;/querywith afakeSessionextended to callcfg.Observers[*].HandleEventwith syntheticAgentEvents (tool_call_events_test.go:13-19constructors) β ack,runningβcompleted, both tool_call pairs,product_interactionfromsuggest_actions; SSE via httptest + cancelled ctx assertingid:==Sequence-1,Last-Event-IDresume, status ticks; live-input steer path; cancel/stop),event_store_test.go(observer βGetEvents(working_set=session)shape,FilterSessionWorkingSet),wp_routes_test.go(tree includesfamily.json; upload βinbox/live-test.txt; raw 200/206 withRange+?token=;family.jsonround-trip intofamilyState; activities listing maps real dirs; traversal rejected),chat_history_routes_test.go,platform_scheduler_test.go,platform_secrets_test.go,platform_wire_sync_test.go(json-tag parity vspackage server).go vet,go test ./cmd/family-server/.... -
Acceptance:
SPARKQUILL_PLATFORM_URL=http://127.0.0.1:8010 npx vitest run platformApi.live(real engine + child set up) must pass unmodified after theweekfix β it drives conversation β query β events/SSE β status β live-input β chat-history β/api/wptree/upload/raw. -
Live Electron E2E (per project rule: LLM-driven code counts only with live verification, with the agent's JSON sign-off recorded):
dev-setup.sh && npm start(platform backend via preload); parent turn β tool-call rows render (the original complaint),suggest_actionspill appears and submits,open_fileopens the viewer; handoff β child turn withcelebrate/show_scene; upload from composer and drawer; Pulse "Run now" streams into the open tab; relaunch mid-conversation β history restored from chat-history and CLI--resumehandles still line up (session_handle_store.go); WhatsApp status/pair screens work via the hybrid adapter. -
Go/no-go for Phase 7 deletion: all of the above green + a real family workspace (
~/.sunlit-learningcopy) exercised without migration.
-
Activity path identity / handoff corruption (data). Virtual
activities/<slug>paths leaking intocurrent-activity.jsonwould danglecurrentActivityDir(), break child turns and the sandbox, and force perpetual "Start fresh". β Virtual folder with real child paths; reserveactivities/state; round-trip testactivities() β handoff β currentActivity β child /conversation; exercise a copied real workspace before Phase 7. -
Turn lifecycle (correctness).
r.Context()would cancel the async turn; a turn that never reaches a terminal status leaves the client polling forever; Pulse/WhatsApp holdingagentTurnMulooks like a hang. βcontext.Background()+timeout, deferred sticky status transitions, busystatus_lineevent, cancel registry, unit test that/queryacks beforeAskcompletes and the turn still finishes. -
family.jsondual-source merge (data). A naive PUT-to-disk would relocate the file, clobberengine/pulse/schedule/fast_mode/selected_models, dropchild.language/created_at, and expose the PIN hash. β Projection GET + whitelisted merge understateMu; never write the client blob; keepverifyPin/Settings on standalone routes via thedesktophybrid adapter. -
Restart amnesia and transcript gaps (UX/data). Tool rows, WhatsApp media, celebrate/scene and Pulse badges are lost or never shown;
parent.jsongrows unbounded and is fetched twice per mount. β Chat-history mapper withresume_turns;ui_eventssidecar for tool/product rows (3b);mediaproduct row; Pulse observer with trigger-label rewrite; agentic sign-off on restored transcripts. -
Silent Settings regression + stale acceptance test (rollout). Pure
platformmode nulls models/Fast Mode/WhatsApp/preferred_hourand skips engine selection; the live test throws onweek. βdesktophybrid adapter;SPARKQUILL_UI=legacykill switch for one release; fixlive.test.ts:60-61; passprintthroughrawUrl. -
Protocol drift vs
cmd/server(maintenance). Copied wire structs/handlers can diverge as ChatArea evolves. βplatform_wire_sync_test.go(test-only import ofpackage server, reflection json-tag parity) + the live vitest acceptance test in CI against family-server. -
Pre-existing platform-mode viewer limitations (not regressions, settle with user): raw HTML in an
<iframe>with relative<img src>needs the/raw?token=rewrite (same on the main server);rawUrlignoresprinttoday.