diff --git a/.c3/README.md b/.c3/README.md
new file mode 100644
index 000000000..4091856af
--- /dev/null
+++ b/.c3/README.md
@@ -0,0 +1,33 @@
+---
+id: c3-0
+c3-version: 4
+c3-seal: b5e913fe120829f2ba2c77e05e71abfbe5a23eed841b79c03e6c8b3025330589
+title: Kanna
+goal: ${GOAL}
+summary: Bun+React web app that drives Claude Agent SDK and Codex App Server over WebSocket, persisting all state as append-only JSONL and rendering live transcripts with hydrated tool calls.
+---
+
+# ${PROJECT}
+
+## Goal
+
+${GOAL}
+
+## Abstract Constraints
+
+| Constraint | Rationale | Affected Containers |
+| --- | --- | --- |
+| Event sourcing for all state mutations | Replayable history, crash-safe, debuggable audit trail | c3-2 |
+| CQRS: write path (events) decoupled from read path (derived models) | UI subscribes to fast snapshots without touching the log | c3-1, c3-2 |
+| Reactive WebSocket broadcasting of snapshots on every state change | Multiple tabs and agents stay consistent in real time | c3-1, c3-2 |
+| Local-first: all user data under ~/.kanna/data, default bind is localhost | Zero server infra, user owns their data, safe by default | c3-2 |
+| Provider-agnostic agent coordination (Claude Agent SDK + Codex App Server) | Per-turn provider/model/effort picks without forking transcript model | c3-1, c3-2 |
+| Strong TypeScript typing — no any/untyped shapes at boundaries | Shared types guarantee client+server agree on protocol + events | c3-1, c3-2, c3-3 |
+
+## Containers
+
+| ID | Name | Boundary | Status | Responsibilities | Goal Contribution |
+| --- | --- | --- | --- | --- | --- |
+| c3-1 | Client | app | implemented | Render transcript, accept chat input, manage sidebar + settings, subscribe to WebSocket pushes | Provides the browser UX that makes Claude/Codex usable through a beautiful chat view |
+| c3-2 | Server | service | implemented | Host HTTP+WS on localhost, drive agents, persist events, derive read models | Single-binary local backend that coordinates providers and owns all state |
+| c3-3 | Shared | library | implemented | Define protocol, types, tool normalization, ports, branding shared by client and server | Guarantees client + server agree on wire format and domain types |
diff --git a/.c3/_index/structural.md b/.c3/_index/structural.md
new file mode 100644
index 000000000..6df246e56
--- /dev/null
+++ b/.c3/_index/structural.md
@@ -0,0 +1,477 @@
+# C3 Structural Index
+
+
+## adr-00000000-c3-adoption — C3 Architecture Documentation Adoption (adr)
+blocks: Goal ✓
+
+## adr-20260420-import-button-mobile-visible — import-button-mobile-visible (adr)
+blocks: Goal ✓
+
+## c3-0 — Kanna (context)
+reverse deps: adr-00000000-c3-adoption, c3-1, c3-2, c3-3
+blocks: Abstract Constraints ✓, Containers ✓, Goal ✓
+
+## c3-1 — Client (container)
+context: c3-0
+reverse deps: c3-101, c3-102, c3-103, c3-110, c3-111, c3-112, c3-113, c3-114, c3-115, c3-116, c3-117, c3-118
+constraints from: c3-0
+blocks: Complexity Assessment ✓, Components ✓, Goal ✓, Responsibilities ✓
+
+## c3-101 — socket-client (component)
+container: c3-1 | context: c3-0
+refs: ref-ws-subscription, ref-strong-typing
+files: src/client/app/socket.ts, src/client/app/socket.test.ts
+constraints from: c3-0, c3-1, ref-ws-subscription, ref-strong-typing
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-102 — state-stores (component)
+container: c3-1 | context: c3-0
+refs: ref-zustand-store, ref-strong-typing, ref-colocated-bun-test
+files: src/client/stores/**/*.ts
+constraints from: c3-0, c3-1, ref-zustand-store, ref-strong-typing, ref-colocated-bun-test
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-103 — ui-primitives (component)
+container: c3-1 | context: c3-0
+refs: ref-strong-typing
+files: src/client/components/ui/**/*.tsx
+constraints from: c3-0, c3-1, ref-strong-typing
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-110 — app-shell (component)
+container: c3-1 | context: c3-0
+refs: ref-ws-subscription, ref-cqrs-read-models
+files: src/main.tsx, src/client/app/App.tsx, src/client/app/App.test.tsx, src/client/app/useKannaState.ts, src/client/app/useKannaState.test.ts, src/client/app/derived.ts, src/client/app/chatFocusPolicy.ts, src/client/app/chatFocusPolicy.test.ts, src/client/app/chatNotifications.ts, src/client/app/PageHeader.tsx, src/client/components/LocalDev.tsx, src/client/hooks/**/*.ts, src/client/hooks/**/*.tsx, src/client/lib/**/*.ts
+constraints from: c3-0, c3-1, ref-ws-subscription, ref-cqrs-read-models
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-111 — sidebar (component)
+container: c3-1 | context: c3-0
+refs: ref-cqrs-read-models, ref-zustand-store
+files: src/client/app/KannaSidebar.tsx, src/client/app/sidebarNumberJump.ts, src/client/app/sidebarNumberJump.test.ts
+constraints from: c3-0, c3-1, ref-cqrs-read-models, ref-zustand-store
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-112 — chat-page (component)
+container: c3-1 | context: c3-0
+refs: ref-ws-subscription, ref-cqrs-read-models
+files: src/client/app/ChatPage/**/*.ts, src/client/app/ChatPage/**/*.tsx, src/client/app/ChatPage.test.ts, src/client/app/useStickyChatFocus.ts, src/client/app/useRightSidebarToggleAnimation.ts, src/client/app/useTerminalToggleAnimation.ts
+constraints from: c3-0, c3-1, ref-ws-subscription, ref-cqrs-read-models
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-113 — transcript (component)
+container: c3-1 | context: c3-0
+refs: ref-tool-hydration, ref-provider-adapter
+files: src/client/app/KannaTranscript.tsx, src/client/app/KannaTranscript.test.tsx
+constraints from: c3-0, c3-1, ref-tool-hydration, ref-provider-adapter
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-114 — messages-renderer (component)
+container: c3-1 | context: c3-0
+refs: ref-tool-hydration, ref-strong-typing
+files: src/client/components/messages/**/*.tsx, src/client/components/messages/**/*.ts
+constraints from: c3-0, c3-1, ref-tool-hydration, ref-strong-typing
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-115 — chat-ui-chrome (component)
+container: c3-1 | context: c3-0
+refs: ref-provider-adapter, ref-zustand-store
+files: src/client/components/chat-ui/**/*.tsx, src/client/components/chat-ui/**/*.ts
+constraints from: c3-0, c3-1, ref-provider-adapter, ref-zustand-store
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-116 — settings-page (component)
+container: c3-1 | context: c3-0
+refs: ref-zustand-store, ref-local-first-data
+files: src/client/app/SettingsPage.tsx, src/client/app/SettingsPage.test.tsx
+constraints from: c3-0, c3-1, ref-zustand-store, ref-local-first-data
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-117 — local-projects-page (component)
+container: c3-1 | context: c3-0
+refs: ref-ws-subscription, ref-local-first-data
+files: src/client/app/LocalProjectsPage.tsx, src/client/components/NewProjectModal.tsx
+constraints from: c3-0, c3-1, ref-ws-subscription, ref-local-first-data
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-118 — terminal-workspace (component)
+container: c3-1 | context: c3-0
+refs: ref-zustand-store, ref-ws-subscription
+files: src/client/app/ChatPage/TerminalWorkspaceShell.tsx, src/client/app/terminalToggleAnimation.ts, src/client/app/terminalToggleAnimation.test.ts, src/client/app/terminalLayoutResize.ts, src/client/app/terminalLayoutResize.test.ts
+constraints from: c3-0, c3-1, ref-zustand-store, ref-ws-subscription
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-2 — Server (container)
+context: c3-0
+reverse deps: c3-201, c3-202, c3-203, c3-204, c3-205, c3-206, c3-207, c3-208, c3-209, c3-210, c3-211, c3-212, c3-213, c3-214, c3-215, c3-216, c3-217, c3-218, c3-219, c3-220, c3-221, c3-222
+constraints from: c3-0
+blocks: Complexity Assessment ✓, Components ✓, Goal ✓, Responsibilities ✓
+
+## c3-201 — cli-entry (component)
+container: c3-2 | context: c3-0
+refs: ref-local-first-data
+files: src/server/cli.ts, src/server/cli-runtime.ts, src/server/cli-runtime.test.ts, src/server/cli-supervisor.ts
+constraints from: c3-0, c3-2, ref-local-first-data
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-202 — http-ws-server (component)
+container: c3-2 | context: c3-0
+refs: ref-ws-subscription, ref-local-first-data
+files: src/server/server.ts
+constraints from: c3-0, c3-2, ref-ws-subscription, ref-local-first-data
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-203 — auth (component)
+container: c3-2 | context: c3-0
+refs: ref-local-first-data
+files: src/server/auth.ts, src/server/auth.test.ts
+constraints from: c3-0, c3-2, ref-local-first-data
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-204 — paths-config (component)
+container: c3-2 | context: c3-0
+refs: ref-local-first-data
+files: src/server/paths.ts, src/server/machine-name.ts
+constraints from: c3-0, c3-2, ref-local-first-data
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-205 — events-schema (component)
+container: c3-2 | context: c3-0
+refs: ref-event-sourcing, ref-strong-typing
+files: src/server/events.ts, src/server/harness-types.ts
+constraints from: c3-0, c3-2, ref-event-sourcing, ref-strong-typing
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-206 — event-store (component)
+container: c3-2 | context: c3-0
+refs: ref-event-sourcing, ref-local-first-data, ref-colocated-bun-test
+files: src/server/event-store.ts, src/server/event-store.test.ts
+constraints from: c3-0, c3-2, ref-event-sourcing, ref-local-first-data, ref-colocated-bun-test
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-207 — read-models (component)
+container: c3-2 | context: c3-0
+refs: ref-cqrs-read-models, ref-strong-typing
+files: src/server/read-models.ts, src/server/read-models.test.ts
+constraints from: c3-0, c3-2, ref-cqrs-read-models, ref-strong-typing
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-208 — ws-router (component)
+container: c3-2 | context: c3-0
+refs: ref-ws-subscription, ref-cqrs-read-models, ref-colocated-bun-test
+files: src/server/ws-router.ts, src/server/ws-router.test.ts
+constraints from: c3-0, c3-2, ref-ws-subscription, ref-cqrs-read-models, ref-colocated-bun-test
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-209 — process-utils (component)
+container: c3-2 | context: c3-0
+refs: ref-strong-typing
+files: src/server/process-utils.ts, src/server/process-utils.test.ts
+constraints from: c3-0, c3-2, ref-strong-typing
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-210 — agent-coordinator (component)
+container: c3-2 | context: c3-0
+refs: ref-provider-adapter, ref-event-sourcing, ref-tool-hydration, ref-colocated-bun-test
+files: src/server/agent.ts, src/server/agent.test.ts
+constraints from: c3-0, c3-2, ref-provider-adapter, ref-event-sourcing, ref-tool-hydration, ref-colocated-bun-test
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-211 — codex-app-server (component)
+container: c3-2 | context: c3-0
+refs: ref-provider-adapter, ref-strong-typing
+files: src/server/codex-app-server.ts, src/server/codex-app-server.test.ts, src/server/codex-app-server-protocol.ts
+constraints from: c3-0, c3-2, ref-provider-adapter, ref-strong-typing
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-212 — provider-catalog (component)
+container: c3-2 | context: c3-0
+refs: ref-provider-adapter
+files: src/server/provider-catalog.ts, src/server/provider-catalog.test.ts
+constraints from: c3-0, c3-2, ref-provider-adapter
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-213 — quick-response (component)
+container: c3-2 | context: c3-0
+refs: ref-provider-adapter
+files: src/server/quick-response.ts, src/server/quick-response.test.ts, src/server/generate-title.ts, src/server/title-generation.live.test.ts, src/server/generate-commit-message.ts, src/server/generate-commit-message.test.ts, src/server/llm-provider.ts, src/server/llm-provider.test.ts
+constraints from: c3-0, c3-2, ref-provider-adapter
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-214 — discovery (component)
+container: c3-2 | context: c3-0
+refs: ref-local-first-data
+files: src/server/discovery.ts, src/server/discovery.test.ts
+constraints from: c3-0, c3-2, ref-local-first-data
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-215 — diff-store (component)
+container: c3-2 | context: c3-0
+refs: ref-tool-hydration
+files: src/server/diff-store.ts, src/server/diff-store.test.ts
+constraints from: c3-0, c3-2, ref-tool-hydration
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-216 — terminal-manager (component)
+container: c3-2 | context: c3-0
+refs: ref-ws-subscription
+files: src/server/terminal-manager.ts, src/server/terminal-manager.test.ts
+constraints from: c3-0, c3-2, ref-ws-subscription
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-217 — uploads (component)
+container: c3-2 | context: c3-0
+refs: ref-local-first-data
+files: src/server/uploads.ts, src/server/uploads.test.ts
+constraints from: c3-0, c3-2, ref-local-first-data
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-218 — share (component)
+container: c3-2 | context: c3-0
+refs: ref-local-first-data
+files: src/server/share.ts, src/server/share.test.ts
+constraints from: c3-0, c3-2, ref-local-first-data
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-219 — update-manager (component)
+container: c3-2 | context: c3-0
+refs: ref-cqrs-read-models
+files: src/server/update-manager.ts, src/server/update-manager.test.ts
+constraints from: c3-0, c3-2, ref-cqrs-read-models
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-220 — restart (component)
+container: c3-2 | context: c3-0
+refs: ref-ws-subscription
+files: src/server/restart.ts, src/server/restart.test.ts
+constraints from: c3-0, c3-2, ref-ws-subscription
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-221 — external-open (component)
+container: c3-2 | context: c3-0
+refs: ref-local-first-data
+files: src/server/external-open.ts, src/server/external-open.test.ts
+constraints from: c3-0, c3-2, ref-local-first-data
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-222 — keybindings (component)
+container: c3-2 | context: c3-0
+refs: ref-local-first-data
+files: src/server/keybindings.ts, src/server/keybindings.test.ts
+constraints from: c3-0, c3-2, ref-local-first-data
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-3 — Shared (container)
+context: c3-0
+reverse deps: c3-301, c3-302, c3-303, c3-304, c3-305, c3-306
+constraints from: c3-0
+blocks: Complexity Assessment ✓, Components ✓, Goal ✓, Responsibilities ✓
+
+## c3-301 — types (component)
+container: c3-3 | context: c3-0
+refs: ref-strong-typing
+files: src/shared/types.ts
+constraints from: c3-0, c3-3, ref-strong-typing
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-302 — protocol (component)
+container: c3-3 | context: c3-0
+refs: ref-ws-subscription, ref-strong-typing
+files: src/shared/protocol.ts
+constraints from: c3-0, c3-3, ref-ws-subscription, ref-strong-typing
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-303 — tools (component)
+container: c3-3 | context: c3-0
+refs: ref-tool-hydration, ref-strong-typing, ref-colocated-bun-test
+files: src/shared/tools.ts, src/shared/tools.test.ts
+constraints from: c3-0, c3-3, ref-tool-hydration, ref-strong-typing, ref-colocated-bun-test
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-304 — ports (component)
+container: c3-3 | context: c3-0
+refs: ref-strong-typing
+files: src/shared/ports.ts, src/shared/dev-ports.ts, src/shared/dev-ports.test.ts
+constraints from: c3-0, c3-3, ref-strong-typing
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-305 — branding (component)
+container: c3-3 | context: c3-0
+refs: ref-local-first-data
+files: src/shared/branding.ts, src/shared/branding.test.ts
+constraints from: c3-0, c3-3, ref-local-first-data
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## c3-306 — share-shared (component)
+container: c3-3 | context: c3-0
+refs: ref-strong-typing
+files: src/shared/share.ts
+constraints from: c3-0, c3-3, ref-strong-typing
+blocks: Container Connection ✓, Dependencies ✓, Goal ✓, Related Refs ✓
+
+## ref-colocated-bun-test — Colocated Bun Test (ref)
+reverse deps: c3-102, c3-206, c3-208, c3-210, c3-303
+files: **/*.test.ts, **/*.test.tsx, **/*.live.test.ts
+blocks: Choice ✓, Goal ✓, How ✓, Why ✓
+
+## ref-cqrs-read-models — CQRS Read Models (ref)
+reverse deps: c3-110, c3-111, c3-112, c3-207, c3-208, c3-219
+files: src/server/read-models.ts, src/server/read-models.test.ts
+blocks: Choice ✓, Goal ✓, How ✓, Why ✓
+
+## ref-event-sourcing — Event Sourcing (ref)
+reverse deps: c3-205, c3-206, c3-210
+files: src/server/events.ts, src/server/event-store.ts, src/server/event-store.test.ts
+blocks: Choice ✓, Goal ✓, How ✓, Why ✓
+
+## ref-local-first-data — Local-First Data (ref)
+reverse deps: c3-116, c3-117, c3-201, c3-202, c3-203, c3-204, c3-206, c3-214, c3-217, c3-218, c3-221, c3-222, c3-305
+files: src/server/paths.ts, src/shared/branding.ts, src/server/cli.ts, src/server/auth.ts
+blocks: Choice ✓, Goal ✓, How ✓, Why ✓
+
+## ref-provider-adapter — Provider Adapter (ref)
+reverse deps: c3-113, c3-115, c3-210, c3-211, c3-212, c3-213
+files: src/server/agent.ts, src/server/provider-catalog.ts, src/server/codex-app-server.ts, src/server/codex-app-server-protocol.ts, src/server/quick-response.ts, src/server/llm-provider.ts
+blocks: Choice ✓, Goal ✓, How ✓, Why ✓
+
+## ref-strong-typing — Strong Typing Policy (ref)
+reverse deps: c3-101, c3-102, c3-103, c3-114, c3-205, c3-207, c3-209, c3-211, c3-301, c3-302, c3-303, c3-304, c3-306
+files: src/shared/**/*.ts, tsconfig.json
+blocks: Choice ✓, Goal ✓, How ✓, Why ✓
+
+## ref-tool-hydration — Tool Call Hydration (ref)
+reverse deps: c3-113, c3-114, c3-210, c3-215, c3-303
+files: src/shared/tools.ts, src/shared/tools.test.ts, src/client/components/messages/**/*.tsx, src/server/agent.ts
+blocks: Choice ✓, Goal ✓, How ✓, Why ✓
+
+## ref-ws-subscription — WebSocket Subscription (ref)
+reverse deps: c3-101, c3-110, c3-112, c3-117, c3-118, c3-202, c3-208, c3-216, c3-220, c3-302
+files: src/shared/protocol.ts, src/server/ws-router.ts, src/client/app/socket.ts
+blocks: Choice ✓, Goal ✓, How ✓, Why ✓
+
+## ref-zustand-store — Zustand Store Pattern (ref)
+reverse deps: c3-102, c3-111, c3-115, c3-116, c3-118
+files: src/client/stores/**/*.ts
+blocks: Choice ✓, Goal ✓, How ✓, Why ✓
+
+## File Map
+**/*.live.test.ts → ref-colocated-bun-test
+**/*.test.ts → ref-colocated-bun-test
+**/*.test.tsx → ref-colocated-bun-test
+src/client/app/App.test.tsx → c3-110 | refs: ref-cqrs-read-models, ref-ws-subscription
+src/client/app/App.tsx → c3-110 | refs: ref-cqrs-read-models, ref-ws-subscription
+src/client/app/ChatPage.test.ts → c3-112 | refs: ref-cqrs-read-models, ref-ws-subscription
+src/client/app/ChatPage/**/*.ts → c3-112 | refs: ref-cqrs-read-models, ref-ws-subscription
+src/client/app/ChatPage/**/*.tsx → c3-112 | refs: ref-cqrs-read-models, ref-ws-subscription
+src/client/app/ChatPage/TerminalWorkspaceShell.tsx → c3-118 | refs: ref-ws-subscription, ref-zustand-store
+src/client/app/KannaSidebar.tsx → c3-111 | refs: ref-cqrs-read-models, ref-zustand-store
+src/client/app/KannaTranscript.test.tsx → c3-113 | refs: ref-provider-adapter, ref-tool-hydration
+src/client/app/KannaTranscript.tsx → c3-113 | refs: ref-provider-adapter, ref-tool-hydration
+src/client/app/LocalProjectsPage.tsx → c3-117 | refs: ref-local-first-data, ref-ws-subscription
+src/client/app/PageHeader.tsx → c3-110 | refs: ref-cqrs-read-models, ref-ws-subscription
+src/client/app/SettingsPage.test.tsx → c3-116 | refs: ref-local-first-data, ref-zustand-store
+src/client/app/SettingsPage.tsx → c3-116 | refs: ref-local-first-data, ref-zustand-store
+src/client/app/chatFocusPolicy.test.ts → c3-110 | refs: ref-cqrs-read-models, ref-ws-subscription
+src/client/app/chatFocusPolicy.ts → c3-110 | refs: ref-cqrs-read-models, ref-ws-subscription
+src/client/app/chatNotifications.ts → c3-110 | refs: ref-cqrs-read-models, ref-ws-subscription
+src/client/app/derived.ts → c3-110 | refs: ref-cqrs-read-models, ref-ws-subscription
+src/client/app/sidebarNumberJump.test.ts → c3-111 | refs: ref-cqrs-read-models, ref-zustand-store
+src/client/app/sidebarNumberJump.ts → c3-111 | refs: ref-cqrs-read-models, ref-zustand-store
+src/client/app/socket.test.ts → c3-101 | refs: ref-strong-typing, ref-ws-subscription
+src/client/app/socket.ts → c3-101, ref-ws-subscription | refs: ref-strong-typing, ref-ws-subscription
+src/client/app/terminalLayoutResize.test.ts → c3-118 | refs: ref-ws-subscription, ref-zustand-store
+src/client/app/terminalLayoutResize.ts → c3-118 | refs: ref-ws-subscription, ref-zustand-store
+src/client/app/terminalToggleAnimation.test.ts → c3-118 | refs: ref-ws-subscription, ref-zustand-store
+src/client/app/terminalToggleAnimation.ts → c3-118 | refs: ref-ws-subscription, ref-zustand-store
+src/client/app/useKannaState.test.ts → c3-110 | refs: ref-cqrs-read-models, ref-ws-subscription
+src/client/app/useKannaState.ts → c3-110 | refs: ref-cqrs-read-models, ref-ws-subscription
+src/client/app/useRightSidebarToggleAnimation.ts → c3-112 | refs: ref-cqrs-read-models, ref-ws-subscription
+src/client/app/useStickyChatFocus.ts → c3-112 | refs: ref-cqrs-read-models, ref-ws-subscription
+src/client/app/useTerminalToggleAnimation.ts → c3-112 | refs: ref-cqrs-read-models, ref-ws-subscription
+src/client/components/LocalDev.tsx → c3-110 | refs: ref-cqrs-read-models, ref-ws-subscription
+src/client/components/NewProjectModal.tsx → c3-117 | refs: ref-local-first-data, ref-ws-subscription
+src/client/components/chat-ui/**/*.ts → c3-115 | refs: ref-provider-adapter, ref-zustand-store
+src/client/components/chat-ui/**/*.tsx → c3-115 | refs: ref-provider-adapter, ref-zustand-store
+src/client/components/messages/**/*.ts → c3-114 | refs: ref-strong-typing, ref-tool-hydration
+src/client/components/messages/**/*.tsx → c3-114, ref-tool-hydration | refs: ref-strong-typing, ref-tool-hydration
+src/client/components/ui/**/*.tsx → c3-103 | refs: ref-strong-typing
+src/client/hooks/**/*.ts → c3-110 | refs: ref-cqrs-read-models, ref-ws-subscription
+src/client/hooks/**/*.tsx → c3-110 | refs: ref-cqrs-read-models, ref-ws-subscription
+src/client/lib/**/*.ts → c3-110 | refs: ref-cqrs-read-models, ref-ws-subscription
+src/client/stores/**/*.ts → c3-102, ref-zustand-store | refs: ref-colocated-bun-test, ref-strong-typing, ref-zustand-store
+src/main.tsx → c3-110 | refs: ref-cqrs-read-models, ref-ws-subscription
+src/server/agent.test.ts → c3-210 | refs: ref-colocated-bun-test, ref-event-sourcing, ref-provider-adapter, ref-tool-hydration
+src/server/agent.ts → c3-210, ref-provider-adapter, ref-tool-hydration | refs: ref-colocated-bun-test, ref-event-sourcing, ref-provider-adapter, ref-tool-hydration
+src/server/auth.test.ts → c3-203 | refs: ref-local-first-data
+src/server/auth.ts → c3-203, ref-local-first-data | refs: ref-local-first-data
+src/server/cli-runtime.test.ts → c3-201 | refs: ref-local-first-data
+src/server/cli-runtime.ts → c3-201 | refs: ref-local-first-data
+src/server/cli-supervisor.ts → c3-201 | refs: ref-local-first-data
+src/server/cli.ts → c3-201, ref-local-first-data | refs: ref-local-first-data
+src/server/codex-app-server-protocol.ts → c3-211, ref-provider-adapter | refs: ref-provider-adapter, ref-strong-typing
+src/server/codex-app-server.test.ts → c3-211 | refs: ref-provider-adapter, ref-strong-typing
+src/server/codex-app-server.ts → c3-211, ref-provider-adapter | refs: ref-provider-adapter, ref-strong-typing
+src/server/diff-store.test.ts → c3-215 | refs: ref-tool-hydration
+src/server/diff-store.ts → c3-215 | refs: ref-tool-hydration
+src/server/discovery.test.ts → c3-214 | refs: ref-local-first-data
+src/server/discovery.ts → c3-214 | refs: ref-local-first-data
+src/server/event-store.test.ts → c3-206, ref-event-sourcing | refs: ref-colocated-bun-test, ref-event-sourcing, ref-local-first-data
+src/server/event-store.ts → c3-206, ref-event-sourcing | refs: ref-colocated-bun-test, ref-event-sourcing, ref-local-first-data
+src/server/events.ts → c3-205, ref-event-sourcing | refs: ref-event-sourcing, ref-strong-typing
+src/server/external-open.test.ts → c3-221 | refs: ref-local-first-data
+src/server/external-open.ts → c3-221 | refs: ref-local-first-data
+src/server/generate-commit-message.test.ts → c3-213 | refs: ref-provider-adapter
+src/server/generate-commit-message.ts → c3-213 | refs: ref-provider-adapter
+src/server/generate-title.ts → c3-213 | refs: ref-provider-adapter
+src/server/harness-types.ts → c3-205 | refs: ref-event-sourcing, ref-strong-typing
+src/server/keybindings.test.ts → c3-222 | refs: ref-local-first-data
+src/server/keybindings.ts → c3-222 | refs: ref-local-first-data
+src/server/llm-provider.test.ts → c3-213 | refs: ref-provider-adapter
+src/server/llm-provider.ts → c3-213, ref-provider-adapter | refs: ref-provider-adapter
+src/server/machine-name.ts → c3-204 | refs: ref-local-first-data
+src/server/paths.ts → c3-204, ref-local-first-data | refs: ref-local-first-data
+src/server/process-utils.test.ts → c3-209 | refs: ref-strong-typing
+src/server/process-utils.ts → c3-209 | refs: ref-strong-typing
+src/server/provider-catalog.test.ts → c3-212 | refs: ref-provider-adapter
+src/server/provider-catalog.ts → c3-212, ref-provider-adapter | refs: ref-provider-adapter
+src/server/quick-response.test.ts → c3-213 | refs: ref-provider-adapter
+src/server/quick-response.ts → c3-213, ref-provider-adapter | refs: ref-provider-adapter
+src/server/read-models.test.ts → c3-207, ref-cqrs-read-models | refs: ref-cqrs-read-models, ref-strong-typing
+src/server/read-models.ts → c3-207, ref-cqrs-read-models | refs: ref-cqrs-read-models, ref-strong-typing
+src/server/restart.test.ts → c3-220 | refs: ref-ws-subscription
+src/server/restart.ts → c3-220 | refs: ref-ws-subscription
+src/server/server.ts → c3-202 | refs: ref-local-first-data, ref-ws-subscription
+src/server/share.test.ts → c3-218 | refs: ref-local-first-data
+src/server/share.ts → c3-218 | refs: ref-local-first-data
+src/server/terminal-manager.test.ts → c3-216 | refs: ref-ws-subscription
+src/server/terminal-manager.ts → c3-216 | refs: ref-ws-subscription
+src/server/title-generation.live.test.ts → c3-213 | refs: ref-provider-adapter
+src/server/update-manager.test.ts → c3-219 | refs: ref-cqrs-read-models
+src/server/update-manager.ts → c3-219 | refs: ref-cqrs-read-models
+src/server/uploads.test.ts → c3-217 | refs: ref-local-first-data
+src/server/uploads.ts → c3-217 | refs: ref-local-first-data
+src/server/ws-router.test.ts → c3-208 | refs: ref-colocated-bun-test, ref-cqrs-read-models, ref-ws-subscription
+src/server/ws-router.ts → c3-208, ref-ws-subscription | refs: ref-colocated-bun-test, ref-cqrs-read-models, ref-ws-subscription
+src/shared/**/*.ts → ref-strong-typing
+src/shared/branding.test.ts → c3-305 | refs: ref-local-first-data
+src/shared/branding.ts → c3-305, ref-local-first-data | refs: ref-local-first-data
+src/shared/dev-ports.test.ts → c3-304 | refs: ref-strong-typing
+src/shared/dev-ports.ts → c3-304 | refs: ref-strong-typing
+src/shared/ports.ts → c3-304 | refs: ref-strong-typing
+src/shared/protocol.ts → c3-302, ref-ws-subscription | refs: ref-strong-typing, ref-ws-subscription
+src/shared/share.ts → c3-306 | refs: ref-strong-typing
+src/shared/tools.test.ts → c3-303, ref-tool-hydration | refs: ref-colocated-bun-test, ref-strong-typing, ref-tool-hydration
+src/shared/tools.ts → c3-303, ref-tool-hydration | refs: ref-colocated-bun-test, ref-strong-typing, ref-tool-hydration
+src/shared/types.ts → c3-301 | refs: ref-strong-typing
+tsconfig.json → ref-strong-typing
+
+## Ref Map
+ref-colocated-bun-test cited by: c3-102, c3-206, c3-208, c3-210, c3-303
+ref-cqrs-read-models cited by: c3-110, c3-111, c3-112, c3-207, c3-208, c3-219
+ref-event-sourcing cited by: c3-205, c3-206, c3-210
+ref-local-first-data cited by: c3-116, c3-117, c3-201, c3-202, c3-203, c3-204, c3-206, c3-214, c3-217, c3-218, c3-221, c3-222, c3-305
+ref-provider-adapter cited by: c3-113, c3-115, c3-210, c3-211, c3-212, c3-213
+ref-strong-typing cited by: c3-101, c3-102, c3-103, c3-114, c3-205, c3-207, c3-209, c3-211, c3-301, c3-302, c3-303, c3-304, c3-306
+ref-tool-hydration cited by: c3-113, c3-114, c3-210, c3-215, c3-303
+ref-ws-subscription cited by: c3-101, c3-110, c3-112, c3-117, c3-118, c3-202, c3-208, c3-216, c3-220, c3-302
+ref-zustand-store cited by: c3-102, c3-111, c3-115, c3-116, c3-118
diff --git a/.c3/adr/adr-20260420-c3-adoption.md b/.c3/adr/adr-20260420-c3-adoption.md
new file mode 100644
index 000000000..7b318029a
--- /dev/null
+++ b/.c3/adr/adr-20260420-c3-adoption.md
@@ -0,0 +1,233 @@
+---
+id: adr-00000000-c3-adoption
+c3-version: 4
+c3-seal: 104ece0accbf901190d1aa57904ccb29db0e106f997949cdb7ed4739e498f66f
+title: C3 Architecture Documentation Adoption
+type: adr
+goal: Adopt C3 methodology for kanna.
+status: implemented
+date: "2026-04-20"
+affects:
+ - c3-0
+---
+
+# C3 Architecture Documentation Adoption
+
+## Goal
+
+Adopt C3 methodology for kanna.
+
+## Workflow
+
+```mermaid
+flowchart TD
+ GOAL([Goal]) --> S0
+
+ subgraph S0["Stage 0: Inventory"]
+ S0_DISCOVER[Discover codebase] --> S0_ASK{Gaps?}
+ S0_ASK -->|Yes| S0_SOCRATIC[Socratic] --> S0_DISCOVER
+ S0_ASK -->|No| S0_LIST[List items + diagram]
+ end
+
+ S0_LIST --> G0{Inventory complete?}
+ G0 -->|No| S0_DISCOVER
+ G0 -->|Yes| S1
+
+ subgraph S1["Stage 1: Details"]
+ S1_CONTAINER[Per container] --> S1_INT[Internal comp]
+ S1_CONTAINER --> S1_LINK[Linkage comp]
+ S1_INT --> S1_REF[Extract refs]
+ S1_LINK --> S1_REF
+ S1_REF --> S1_ASK{Questions?}
+ S1_ASK -->|Yes| S1_SOCRATIC[Socratic] --> S1_CONTAINER
+ S1_ASK -->|No| S1_NEXT{More?}
+ S1_NEXT -->|Yes| S1_CONTAINER
+ end
+
+ S1_NEXT -->|No| G1{Fix inventory?}
+ G1 -->|Yes| S0_DISCOVER
+ G1 -->|No| S2
+
+ subgraph S2["Stage 2: Finalize"]
+ S2_CHECK[Integrity checks]
+ end
+
+ S2_CHECK --> G2{Issues?}
+ G2 -->|Inventory| S0_DISCOVER
+ G2 -->|Detail| S1_CONTAINER
+ G2 -->|None| DONE([Implemented])
+```
+
+## Stage 0: Inventory
+
+### Context Discovery
+
+| Arg | Value |
+| --- | --- |
+| PROJECT | Kanna |
+| GOAL | Beautiful browser UI for Claude Code + Codex CLIs with project-first navigation, multi-provider agent coordination, and event-sourced local persistence |
+| SUMMARY | Bun+React app driving Claude Agent SDK and Codex App Server over WebSocket, persisting state as append-only JSONL, rendering hydrated tool calls in real time |
+
+### Abstract Constraints
+
+| Constraint | Rationale | Affected Containers |
+| --- | --- | --- |
+| Event sourcing for all state mutations | Replayable history, crash-safe, debuggable audit trail | c3-2 |
+| CQRS: write (events) decoupled from read (derived models) | UI subscribes to fast snapshots without touching the log | c3-1, c3-2 |
+| Reactive WebSocket broadcasting on every state change | Multiple tabs and agents stay consistent in real time | c3-1, c3-2 |
+| Local-first: data under ~/.kanna/data, default bind localhost | Zero server infra, user owns data, safe by default | c3-2 |
+| Provider-agnostic agent coordination (Claude + Codex) | Per-turn provider/model/effort picks without forking transcript model | c3-1, c3-2 |
+| Strong TypeScript typing — no any at boundaries | Client + server agree on protocol + events | c3-1, c3-2, c3-3 |
+
+### Container Discovery
+
+| N | CONTAINER_NAME | BOUNDARY | GOAL | SUMMARY |
+| --- | --- | --- | --- | --- |
+| 1 | client | app | Render chat, accept input, subscribe to WS pushes | React + Zustand SPA under src/client |
+| 2 | server | service | Drive agents, persist events, broadcast snapshots | Bun HTTP+WS runtime under src/server |
+| 3 | shared | library | Publish wire protocol + domain types used by both sides | Code under src/shared imported by client and server |
+
+### Component Discovery (Brief)
+
+| N | NN | COMPONENT_NAME | CATEGORY | GOAL | SUMMARY |
+| --- | --- | --- | --- | --- | --- |
+| 1 | 01 | socket-client | foundation | Connect WS, route messages, emit commands | src/client/app/socket.ts |
+| 1 | 02 | state-stores | foundation | Zustand stores for chat/terminal/sidebar/prefs | src/client/stores/* |
+| 1 | 03 | ui-primitives | foundation | Radix + shadcn primitives (button, dialog, popover...) | src/client/components/ui/* |
+| 1 | 10 | app-shell | feature | Router, top-level page hookup, central state hook | src/client/app/App.tsx + useKannaState.ts |
+| 1 | 11 | sidebar | feature | Project-first sidebar with drag ordering, jump shortcuts | src/client/app/KannaSidebar.tsx |
+| 1 | 12 | chat-page | feature | Chat route shell: transcript viewport + input dock + terminal | src/client/app/ChatPage/* |
+| 1 | 13 | transcript | feature | Render hydrated transcript entries | src/client/app/KannaTranscript.tsx |
+| 1 | 14 | messages-renderer | feature | Render each transcript entry type (tool calls, text, diffs) | src/client/components/messages/* |
+| 1 | 15 | chat-ui-chrome | feature | Input, composer controls, provider/model pickers | src/client/components/chat-ui/* |
+| 1 | 16 | settings-page | feature | Settings dialogs and preferences | src/client/app/SettingsPage.tsx |
+| 1 | 17 | local-projects-page | feature | List/open locally discovered projects | src/client/app/LocalProjectsPage.tsx |
+| 1 | 18 | terminal-workspace | feature | Embedded xterm panel + layout animation | src/client/app/ChatPage/TerminalWorkspaceShell.tsx |
+| 2 | 01 | cli-entry | foundation | CLI parsing, supervisor, runtime, browser launcher | src/server/cli*.ts |
+| 2 | 02 | http-ws-server | foundation | HTTP + WebSocket server, static serving, auth hookup | src/server/server.ts |
+| 2 | 03 | auth | foundation | Password gate + session cookie for API/WS | src/server/auth.ts |
+| 2 | 04 | paths-config | foundation | Data paths, machine name, branding helpers | src/server/paths.ts + machine-name.ts |
+| 2 | 05 | events-schema | foundation | Event type definitions for JSONL logs | src/server/events.ts |
+| 2 | 06 | event-store | foundation | Append-only JSONL with replay + snapshot compaction | src/server/event-store.ts |
+| 2 | 07 | read-models | foundation | Derive sidebar/chat/project views from event state | src/server/read-models.ts |
+| 2 | 08 | ws-router | foundation | Subscribe/command routing over WebSocket | src/server/ws-router.ts |
+| 2 | 09 | process-utils | foundation | Process spawning + lifecycle helpers | src/server/process-utils.ts |
+| 2 | 10 | agent-coordinator | feature | Multi-provider turn management | src/server/agent.ts |
+| 2 | 11 | codex-app-server | feature | JSON-RPC client for Codex App Server | src/server/codex-app-server*.ts |
+| 2 | 12 | provider-catalog | feature | Provider/model/effort normalization | src/server/provider-catalog.ts |
+| 2 | 13 | quick-response | feature | Structured Haiku queries with Codex fallback (titles, commits) | quick-response.ts + generate-title.ts + generate-commit-message.ts + llm-provider.ts |
+| 2 | 14 | discovery | feature | Auto-discover Claude + Codex local projects | src/server/discovery.ts |
+| 2 | 15 | diff-store | feature | Per-chat diff state for hydrated file-change UI | src/server/diff-store.ts |
+| 2 | 16 | terminal-manager | feature | PTY sessions for embedded terminal | src/server/terminal-manager.ts |
+| 2 | 17 | uploads | feature | File uploads + attachment handling | src/server/uploads.ts |
+| 2 | 18 | share | feature | Cloudflare quick-tunnel + named tunnel + QR | src/server/share.ts |
+| 2 | 19 | update-manager | feature | Self-update notifications | src/server/update-manager.ts |
+| 2 | 20 | restart | feature | In-place restart flow | src/server/restart.ts |
+| 2 | 21 | external-open | feature | Open URLs/files in external apps | src/server/external-open.ts |
+| 2 | 22 | keybindings | feature | User keybinding persistence | src/server/keybindings.ts |
+| 3 | 01 | types | foundation | Core domain types, provider catalog, transcript entry types | src/shared/types.ts |
+| 3 | 02 | protocol | foundation | WebSocket wire protocol shapes | src/shared/protocol.ts |
+| 3 | 03 | tools | foundation | Tool call normalization + hydration | src/shared/tools.ts |
+| 3 | 04 | ports | foundation | Port allocation + dev-port helpers | src/shared/ports.ts + dev-ports.ts |
+| 3 | 05 | branding | foundation | App name + data dir paths | src/shared/branding.ts |
+| 3 | 06 | share-shared | foundation | Share feature types shared with client | src/shared/share.ts |
+
+### Ref Discovery
+
+| SLUG | TITLE | GOAL | Scope | Applies To |
+| --- | --- | --- | --- | --- |
+| ref-event-sourcing | Event Sourcing | All mutations go through append-only JSONL; readers replay | cross-container | c3-2 event-store, events-schema, read-models |
+| ref-cqrs-read-models | CQRS Read Models | Derive view models from event state; broadcast diffs | cross-container | c3-1 state-stores, c3-2 read-models + ws-router |
+| ref-ws-subscription | WebSocket Subscription | Single WS with typed subscribe/command envelope | cross-container | c3-1 socket-client, c3-2 ws-router, c3-3 protocol |
+| ref-provider-adapter | Provider Adapter | Normalize Claude Agent SDK and Codex into one transcript model | cross-container | c3-2 agent-coordinator, provider-catalog, codex-app-server, quick-response |
+| ref-zustand-store | Zustand Store Pattern | Per-concern store, persist via localStorage as needed | client | c3-1 state-stores |
+| ref-colocated-bun-test | Colocated Bun Test | *.test.ts next to impl, runs under bun test | cross-container | all |
+| ref-strong-typing | Strong Typing Policy | No any/unknown at boundaries; prefer shared types | cross-container | all |
+| ref-local-first-data | Local-First Data | All persistence under ~/.kanna/data; localhost-default binding | server | c3-2 event-store, paths-config |
+| ref-tool-hydration | Tool Call Hydration | Normalize provider tool calls into unified transcript entries | cross-container | c3-3 tools, c3-1 messages-renderer, c3-2 agent-coordinator |
+
+### Overview Diagram
+
+```mermaid
+graph LR
+ User((User)) --> Browser
+ Browser[Browser React + Zustand c3-1] <-->|WebSocket| Server
+ Server[Bun Server HTTP + WS c3-2]
+ Server --> ClaudeSDK[Claude Agent SDK]
+ Server --> CodexRPC[Codex App Server]
+ Server --> FS[(~/.kanna/data/ JSONL + snapshot)]
+ Server --> ProjFS[(Project Dirs)]
+ Browser -.-> Shared[src/shared/ c3-3]
+ Server -.-> Shared
+```
+
+### Gate 0
+
+- [x] Context args filled
+- [x] Abstract Constraints identified
+- [x] All containers identified with args (including BOUNDARY)
+- [x] All components identified (brief) with args and category
+- [x] Cross-cutting refs identified
+- [x] Overview diagram generated
+
+## Stage 1: Details
+
+### Container: c3-1
+
+**Created:** [ ] `.c3/c3-1-{slug}/README.md`
+
+| Type | Component ID | Name | Category | Doc Created |
+| --- | --- | --- | --- | --- |
+| Internal | | | | [ ] |
+| Linkage | | | | [ ] |
+
+### Container: c3-N
+
+_(repeat per container from Stage 0)_
+
+### Refs Created
+
+| Ref ID | Pattern | Doc Created |
+| --- | --- | --- |
+| | | [ ] |
+
+### Gate 1
+
+- [ ] All container README.md created
+- [ ] All component docs created
+- [ ] All refs documented
+- [ ] No new items discovered (else -> Gate 0)
+
+## Stage 2: Finalize
+
+### Integrity Checks
+
+| Check | Status |
+| --- | --- |
+| Context <-> Container (all containers listed in c3-0) | [ ] |
+| Container <-> Component (all components listed in container README) | [ ] |
+| Component <-> Component (linkages documented) | [ ] |
+| * <-> Refs (refs cited correctly, Cited By updated) | [ ] |
+
+### Gate 2
+
+- [ ] All integrity checks pass
+- [ ] Run audit
+
+## Conflict Resolution
+
+If later stage reveals earlier errors:
+
+| Conflict | Found In | Affects | Resolution |
+| --- | --- | --- | --- |
+| | | | |
+
+## Exit
+
+When Gate 2 complete -> change frontmatter status to `implemented`
+
+## Audit Record
+
+| Phase | Date | Notes |
+| --- | --- | --- |
+| Adopted | 20260420 | Initial C3 structure created |
diff --git a/.c3/adr/adr-20260420-import-button-mobile-visible.md b/.c3/adr/adr-20260420-import-button-mobile-visible.md
new file mode 100644
index 000000000..53fef5b6a
--- /dev/null
+++ b/.c3/adr/adr-20260420-import-button-mobile-visible.md
@@ -0,0 +1,19 @@
+---
+id: adr-20260420-import-button-mobile-visible
+c3-seal: c38090e41e252fed5cde7b0dcd3a6cea8a24acfedd7faf88ef2ca20d77f8f73f
+title: import-button-mobile-visible
+type: adr
+goal: --value
+status: implemented
+date: "2026-04-20"
+---
+
+# import-button-mobile-visible
+
+## Goal
+
+--value
+
+## Work Breakdown
+
+## Risks
diff --git a/.c3/adr/adr-20260421-pm2-update-reloader.md b/.c3/adr/adr-20260421-pm2-update-reloader.md
new file mode 100644
index 000000000..7c6a55117
--- /dev/null
+++ b/.c3/adr/adr-20260421-pm2-update-reloader.md
@@ -0,0 +1,53 @@
+---
+id: adr-20260421-pm2-update-reloader
+c3-seal: 9b2b7a5c2ed2d6659771c633b243b4875c75ecbaea474b6edfb93c7c55168285
+title: pm2-update-reloader
+type: adr
+goal: Replace macOS launchd supervision with pm2 for the dev deploy path, and wire the in-app Update button to trigger a pm2-reload pipeline (git pull → build → `pm2 reload`). Abstract the update mechanism so the existing npm/self-update path and the new git/pm2 path coexist and can be swapped without touching `UpdateManager` or server wiring.
+status: implemented
+date: "2026-04-21"
+---
+
+# pm2-update-reloader
+
+## Goal
+
+Replace macOS launchd supervision with pm2 for the dev deploy path, and wire the in-app Update button to trigger a pm2-reload pipeline (git pull → build → `pm2 reload`). Abstract the update mechanism so the existing npm/self-update path and the new git/pm2 path coexist and can be swapped without touching `UpdateManager` or server wiring.
+
+## Decision
+
+Introduced two interfaces in `src/server/update-strategy.ts`:
+
+- `UpdateChecker.check()` — returns `{ latestVersion, updateAvailable }`
+- `UpdateReloader.reload()` — performs install / reload, throws `UpdateInstallError` on failure
+
+Shipped two implementations of each, wired by a factory `createUpdateStrategy` keyed on `KANNA_RELOADER`:
+
+| Mode | Checker | Reloader | Default? |
+| --- | --- | --- | --- |
+| supervisor (or unset) | NpmChecker (npm registry) | SupervisorExitReloader (install → restart_pending → process exit 76 → parent respawn) | yes |
+| pm2 | GitChecker (git fetch → HEAD vs origin/branch) | Pm2Reloader (git pull → cond. bun install → bun run build → pm2.reload) | opt-in |
+
+`UpdateManager` depends only on the interfaces; no knowledge of npm/git/pm2.
+
+## Env Vars
+
+- `KANNA_RELOADER` — `supervisor` (default) or `pm2`
+- `KANNA_REPO_DIR` — required when `KANNA_RELOADER=pm2`; absolute path to the git worktree pm2 runs from
+- `KANNA_PM2_PROCESS_NAME` — optional; defaults to `kanna`; must match the `name:` field in the pm2 ecosystem config
+
+## Ops
+
+- `scripts/pm2.config.cjs.tmpl` — templated pm2 ecosystem file (envsubst renders `${REPO_DIR}` + `${PM2_NAME}` into `scripts/pm2.config.cjs`, which is gitignored)
+- `scripts/deploy.sh` — now installs pm2 if missing, renders the config, and runs `pm2 reload` (or `pm2 start` on first run). Drops `launchctl kickstart`.
+
+## Work Breakdown
+
+Done across 11 tasks (see `docs/plans/2026-04-21-pm2-update-reloader.md`): interfaces + npm/supervisor impl → factory → UpdateManager refactor → server wiring → pm2 dep → GitChecker → Pm2Reloader → pm2 ecosystem template → deploy.sh rewrite → manual verification.
+
+## Risks
+
+- `detectLockfileChange` returns `true` on any git error (fresh clone, no `HEAD@{1}`) — conservatively over-installs rather than skipping a needed `bun install`.
+- pm2 self-reload race: pm2 signals the current process immediately; `cli.ts` may exit with 0 instead of 76 if pm2's SIGTERM wins over the `restart_pending` listener. Harmless because `autorestart: true` restarts regardless of exit code.
+- `branch: "main"` is hardcoded in the factory's `GitChecker` wiring; dev-only scope, low risk.
+- pm2 is a `devDependency`, lazy-imported only in pm2 mode — end users on the supervisor path never pull it.
diff --git a/.c3/adr/adr-20260513-promote-refs-to-rules.md b/.c3/adr/adr-20260513-promote-refs-to-rules.md
new file mode 100644
index 000000000..805bf78b8
--- /dev/null
+++ b/.c3/adr/adr-20260513-promote-refs-to-rules.md
@@ -0,0 +1,128 @@
+---
+id: adr-20260513-promote-refs-to-rules
+c3-seal: 65c300282f6470f340235278f770afde9ce50f62ff8fcec6f61f1953b15f5fbc
+title: promote-refs-to-rules
+type: adr
+goal: 'Promote three project-wide patterns from advisory refs into enforceable C3 rules so compliance is checked with literal Golden Examples, not directional prose. Targets: strong typing at boundaries, colocated Bun tests, Zustand store shape. The decision being authorized is to add `rule-strong-typing`, `rule-colocated-bun-test`, `rule-zustand-store` and re-wire every component currently citing the parent ref so the rule travels alongside the ref.'
+status: implemented
+date: "2026-05-13"
+---
+
+# promote-refs-to-rules
+
+## Goal
+
+Promote three project-wide patterns from advisory refs into enforceable C3 rules so compliance is checked with literal Golden Examples, not directional prose. Targets: strong typing at boundaries, colocated Bun tests, Zustand store shape. The decision being authorized is to add `rule-strong-typing`, `rule-colocated-bun-test`, `rule-zustand-store` and re-wire every component currently citing the parent ref so the rule travels alongside the ref.
+
+## Context
+
+The 2026-05-13 C3 audit (Phase 7/9) flagged three refs whose `## How` rows describe single-correct-form patterns (not preference) yet are stored as refs. Audit recommendation: promote to rules with literal Golden Examples from the repo. Refs cite 22 unique components total — `ref-strong-typing` (15), `ref-colocated-bun-test` (5), `ref-zustand-store` (5). Without rules, drift is detected only by reviewer judgment, so identical boilerplate variations slip through review. The change touches only C3 docs and `code-map.yaml`; no source code moves.
+
+## Decision
+
+Add three rule entities. Keep the parent refs (they retain Why/Choice context); rules add the enforceable one-line statement + Golden Example. Every component currently wired to the parent ref gets an additional `uses` link to the new rule via `c3x wire `. Rule code-map entries reuse the parent ref's code-map so coverage signal is unchanged. Pattern: `rule-*` is the enforcement contract, `ref-*` is the rationale; both can coexist on a component.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| ref-strong-typing | ref | Becomes parent rationale; rule-strong-typing carries enforcement | Confirm ## How stays narrative; no enforcement leakage |
+| ref-colocated-bun-test | ref | Same: rationale parent of rule-colocated-bun-test | Same |
+| ref-zustand-store | ref | Same: rationale parent of rule-zustand-store | Same |
+| c3-101 | component | Cites ref-strong-typing on WebSocket envelope types | Confirm Governance lists rule-strong-typing |
+| c3-102 | component | Cites all three refs (state-stores hub) | Confirm Governance lists all three rules |
+| c3-103 | component | Cites ref-strong-typing on UI prop types | Confirm Governance lists rule-strong-typing |
+| c3-111 | component | Cites ref-zustand-store for sidebar store | Confirm Governance lists rule-zustand-store |
+| c3-114 | component | Cites ref-strong-typing on transcript entry kinds | Confirm Governance lists rule-strong-typing |
+| c3-115 | component | Cites ref-zustand-store for chat-ui chrome stores | Confirm Governance lists rule-zustand-store |
+| c3-116 | component | Cites ref-zustand-store for settings store | Confirm Governance lists rule-zustand-store |
+| c3-118 | component | Cites ref-zustand-store for terminal-workspace store | Confirm Governance lists rule-zustand-store |
+| c3-205 | component | Cites ref-strong-typing on events union | Confirm Governance lists rule-strong-typing |
+| c3-206 | component | Cites ref-colocated-bun-test for event-store tests | Confirm Governance lists rule-colocated-bun-test |
+| c3-207 | component | Cites ref-strong-typing on read-model projections | Confirm Governance lists rule-strong-typing |
+| c3-208 | component | Cites ref-colocated-bun-test for ws-router tests | Confirm Governance lists rule-colocated-bun-test |
+| c3-209 | component | Cites ref-strong-typing on process-utils contracts | Confirm Governance lists rule-strong-typing |
+| c3-210 | component | Cites ref-colocated-bun-test for agent-coordinator tests | Confirm Governance lists rule-colocated-bun-test |
+| c3-211 | component | Cites ref-strong-typing on codex protocol | Confirm Governance lists rule-strong-typing |
+| c3-219 | component | Cites ref-strong-typing on update-manager projection | Confirm Governance lists rule-strong-typing |
+| c3-223 | component | Cites ref-strong-typing on cloudflare-tunnel projection | Confirm Governance lists rule-strong-typing |
+| c3-301 | component | Cites ref-strong-typing — owns shared types | Confirm Governance lists rule-strong-typing |
+| c3-302 | component | Cites ref-strong-typing — owns WS protocol envelopes | Confirm Governance lists rule-strong-typing |
+| c3-303 | component | Cites ref-strong-typing AND ref-colocated-bun-test | Confirm Governance lists rule-strong-typing and rule-colocated-bun-test |
+| c3-304 | component | Cites ref-strong-typing on port constants | Confirm Governance lists rule-strong-typing |
+| c3-306 | component | Cites ref-strong-typing on share-shared types | Confirm Governance lists rule-strong-typing |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-strong-typing | Parent rationale for rule-strong-typing — rule cites ref as source-of-truth Why | review |
+| ref-colocated-bun-test | Parent rationale for rule-colocated-bun-test | review |
+| ref-zustand-store | Parent rationale for rule-zustand-store | review |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-strong-typing | This ADR creates it; one-line enforcement of no any at boundaries with Golden Example from src/shared/types.ts | create-rule |
+| rule-colocated-bun-test | This ADR creates it; enforces .test.ts(x) colocation with literal example from src/server/auth.test.ts | create-rule |
+| rule-zustand-store | This ADR creates it; enforces create() + colocated test shape with literal example from src/client/stores/preferences.ts | create-rule |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| rule-strong-typing | c3x add rule strong-typing --file body.md with literal discriminated-union example from src/shared/types.ts | .c3/rules/rule-strong-typing.md |
+| rule-colocated-bun-test | c3x add rule colocated-bun-test --file body.md with literal example from src/server/auth.test.ts | .c3/rules/rule-colocated-bun-test.md |
+| rule-zustand-store | c3x add rule zustand-store --file body.md with literal preferences.ts content | .c3/rules/rule-zustand-store.md |
+| Wire citations | c3x wire for each of 25 component→rule edges (15 + 5 + 5; c3-102 cites all three; c3-303 cites two) | component frontmatter uses: |
+| Code-map | c3x set codemap "" mirroring parent ref's code-map | .c3/code-map.yaml |
+| ADR transition | c3x set adr-20260513-promote-refs-to-rules status accepted then implemented after verify | adr frontmatter |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| .c3/rules/ | Three new files: rule-strong-typing.md, rule-colocated-bun-test.md, rule-zustand-store.md | c3x list shows three new rule rows |
+| .c3/code-map.yaml | Three new top-level keys mirroring parent ref code-map patterns | grep '^rule-' .c3/code-map.yaml lists three keys |
+| Component frontmatter uses: | 25 wire edges added across 22 components | c3x graph rule-strong-typing shows 15 inbound; rule-colocated-bun-test 5; rule-zustand-store 5 |
+| Validator surface | None changed — c3x check already enforces rules require ## Rule + ## Golden Example and that citing components exist | c3x check exits 0 with 60 docs |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| c3x check Phase 7 | Rejects rule entities missing Rule + Golden Example | Three rules pass structural after add |
+| c3x check orphan scan | Rejects rule with zero citing components | All three rules have ≥5 cites after wire |
+| c3x lookup | Returns rule id for matched source files so future edits surface rule constraint | c3x lookup src/shared/types.ts returns rule-strong-typing |
+| Audit Phase 7b | Rule VIOLATION = FAIL severity; spot-check derives YES/NO from Rule + Golden Example | Rule body lists 1-3 YES/NO compliance questions in Not This or Scope |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Leave as refs only | Audit Phase 9 already flagged identical boilerplate in 5+ components as enforcement gap; refs can't be checked YES/NO |
+| Replace refs with rules (delete refs) | Refs hold Why/Choice that doesn't fit one-line rule; schema says "Rule primarily about rationale → that's a ref, not a rule" |
+| Promote only strong-typing | Audit found three patterns with single correct form; partial promotion leaves the other two gaps |
+| Defer until next edit to each component | Coverage gain is per-edit; bulk wire pays once and immediately surfaces violations on every future c3x lookup |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Rule Golden Example drifts when src/shared/types.ts or auth.test.ts is refactored | Rule body cites file path explicitly; audit Phase 7b re-runs spot-check on referenced file | c3x check + cross-check Golden file exists via c3x lookup |
+| Component uses both ref and rule with conflicting precedence | Rules strict, refs directional; rule wins per audit Phase 7b | Spot-check 2 components citing both; confirm rule is stricter subset of ref ## How |
+| Wire edge missed | c3x graph --direction reverse lists every citer | Counts match: strong-typing inbound 15, colocated-bun-test 5, zustand-store 5 |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| c3x check after all rules + wires applied | exits 0; 60 docs; zero issues |
+| c3x graph rule-strong-typing --direction reverse | 15 inbound component edges |
+| c3x graph rule-colocated-bun-test --direction reverse | 5 inbound component edges |
+| c3x graph rule-zustand-store --direction reverse | 5 inbound component edges |
+| c3x lookup src/shared/types.ts | matches include rule-strong-typing |
+| c3x lookup src/client/stores/preferences.ts | matches include rule-zustand-store and ref-zustand-store |
+| c3x lookup src/server/auth.test.ts | matches include rule-colocated-bun-test |
+| bun test (full suite in worktree) | passes — no source code touched |
diff --git a/.c3/adr/adr-20260518-subagent-delegation-tool.md b/.c3/adr/adr-20260518-subagent-delegation-tool.md
new file mode 100644
index 000000000..23272e6cf
--- /dev/null
+++ b/.c3/adr/adr-20260518-subagent-delegation-tool.md
@@ -0,0 +1,67 @@
+---
+id: adr-20260518-subagent-delegation-tool
+c3-seal: 44cc8fe38942370aff0a5121e4a3852836e2a95d712b31c4a6e0719b17d2b754
+title: subagent-delegation-tool
+type: adr
+goal: Replace `@agent/` server-side mention routing with the Anthropic Task-tool pattern. The main agent now always runs, sees every configured subagent's name + id + description in its system prompt, and decides whether to delegate by calling `mcp__kanna__delegate_subagent({ subagent_id, prompt })`. The MCP tool blocks until the subagent finishes and returns its final reply as a string. Subagents can in turn delegate to other subagents (sub-spawn-sub) bounded by the orchestrator's existing depth + cycle guards.
+status: implemented
+date: "2026-05-18"
+---
+
+# subagent-delegation-tool
+
+## Goal
+
+Replace `@agent/` server-side mention routing with the Anthropic Task-tool pattern. The main agent now always runs, sees every configured subagent's name + id + description in its system prompt, and decides whether to delegate by calling `mcp__kanna__delegate_subagent({ subagent_id, prompt })`. The MCP tool blocks until the subagent finishes and returns its final reply as a string. Subagents can in turn delegate to other subagents (sub-spawn-sub) bounded by the orchestrator's existing depth + cycle guards.
+
+## Context
+
+Before this change, an `@agent/` mention in a user message was parsed in `chat_send` (and `dequeueAndStartQueuedMessage` for queued messages) and short-circuited the main turn — `subagentOrchestrator.runMentionsForUserMessage` started the subagent run directly, the main model never ran, and the subagent received the user's raw text via `composeInitialPrompt`. A secondary path (`dispatchAssistantMentions`) scanned the main assistant's reply text for `@agent/...` and dispatched there too.
+
+This diverged from Anthropic's own `Task` tool pattern (Claude Code), where the main model orchestrates and a `Task({subagent_type, prompt})` tool hands off focused work. The differences mattered:
+
+- The main model could not enrich the prompt with chat-history context the subagent needed.
+- The main model could not pick a different subagent than the one the user mentioned when the actual ask was a better match elsewhere.
+- The main model could not multi-step (delegate → read result → delegate again) within a single turn.
+- The main model never even knew which subagents existed — `KANNA_SYSTEM_PROMPT_APPEND` was a static refusal-policy blurb with no roster.
+
+The 2026-05-18 design conversation concluded the best path was option A (pure Task-tool pattern) per Anthropic best practice, accepting the latency cost of an extra LLM turn per delegation.
+
+## Decision
+
+Adopt the Task-tool pattern fully. Specifically:
+
+1. **Dynamic system prompt.** `KANNA_SYSTEM_PROMPT_APPEND` becomes `KANNA_SYSTEM_PROMPT_BASE` (unchanged content); a new builder `buildKannaSystemPromptAppend(subagents)` concatenates the base + a `## Available subagents` section + delegation guidance. Computed per-spawn in `agent.ts` from `getSubagents()`, passed to both drivers (SDK `systemPrompt.append`, PTY `--append-system-prompt`). Truncated at 20 entries by `updatedAt` desc.
+2. **`SubagentOrchestrator.delegateRun(args)`.** Public async method that awaits a single run and returns `DelegationOutcome = {status:"completed", runId, text} | {status:"failed", runId, errorCode, errorMessage}`. Internally delegates to the existing `spawnRun` (refactored to return outcome instead of `void`). Cycle + depth guards mirror the chained-mention path: `LOOP_DETECTED` when target subagent appears in the ancestor chain, `DEPTH_EXCEEDED` when `depth > maxChainDepth` (default 1).
+3. **`mcp__kanna__delegate_subagent` tool.** Registered in `kanna-mcp.ts` only when the spawn supplies both `subagentOrchestrator` and `delegationContext`. Args: `{subagent_id, prompt}`. Main-agent spawns set `{depth:0, ancestorSubagentIds:[], parentRunId:null, parentSubagentId:null, getParentUserMessageId:() => activeTurn.userMessageId}`. Subagent spawns (sub-spawn-sub) set the caller's run context so cycle / depth checks apply. Returns the subagent's final reply text in `content[0].text`, JSON-wrapped with status + run_id; sets `isError: true` on failure.
+4. **Short-circuit removal.** `chat_send` and `dequeueAndStartQueuedMessage` no longer route `parseMentions` results through the orchestrator. `dispatchAssistantMentions` and `ActiveTurn.assistantTextAccum` are deleted. `parseMentions` still runs inside `appendUserPrompt` so the `user_prompt` entry continues to carry `subagentMentions` + `unknownSubagentMentions` metadata for UI badges and analytics.
+5. **Driver parity.** Both SDK (`startClaudeSession`) and PTY (`startClaudeSessionPTY` + `buildPtyCliArgs`) accept `systemPromptAppend`, `subagentOrchestrator`, `delegationContext` and forward them to `kanna-mcp` (in-process for SDK, in-process HTTP for PTY). D8 parity test rewritten to cover both the static default and the dynamic-roster override.
+
+## Affected Topology
+
+| Entity | Type | Why affected |
+| --- | --- | --- |
+| c3-210 agent-coordinator | component | Loses the @mention short-circuit; gains delegationContext wiring for kanna-mcp; subagent starter forwards orchestrator + context for sub-spawn-sub |
+| src/shared/kanna-system-prompt.ts | shared | Static const split into base + dynamic builder |
+| src/server/kanna-mcp.ts | server | delegate_subagent tool registered when subagentOrchestrator + delegationContext are supplied |
+| src/server/kanna-mcp-tools/delegate-subagent.ts | server | New MCP tool module |
+| src/server/subagent-orchestrator.ts | server | spawnRun returns DelegationOutcome; new public delegateRun entry point; startProviderRun callback gains depth / ancestorSubagentIds / parentUserMessageId |
+| src/server/subagent-provider-run.ts | server | startClaudeSession signature extended for orchestrator + delegationContext to enable sub-spawn-sub |
+| src/server/claude-pty/driver.ts | server | StartClaudeSessionPtyArgs and buildPtyCliArgs accept systemPromptAppend, subagentOrchestrator, delegationContext; CLI arg switched from constant to dynamic |
+
+## Consequences
+
+- Every `@agent/...` mention now costs an extra LLM turn (main model receives, decides, delegates). Acceptable given Pro/Max subscription billing for PTY mode and the design preference for best-of-Anthropic-pattern over token economy.
+- The main model can pick the wrong subagent. Mitigation: the delegation guidance in the system prompt explicitly tells the model to treat `@` as a suggestion and confirm fit. Future work: telemetry on `delegate_subagent` call rate vs. user-mentioned subagent for drift analysis.
+- The main model can loop (delegate → read → delegate again). Mitigation: existing `maxChainDepth` (default 1) + cycle guard prevents runaway. Subagent timeout (600s) still applies.
+- Sub-spawn-sub via the tool is now possible. Mitigation: same `LOOP_DETECTED` / `DEPTH_EXCEEDED` guards apply, fed from the spawn's `delegationContext`.
+
+## Verification
+
+- `bun test src/shared/kanna-system-prompt.test.ts` — 8 tests covering empty roster, roster building, ordering, truncation, guidance content.
+- `bun test src/server/kanna-mcp-tools/delegate-subagent.test.ts` — 4 tests covering input forwarding, completed payload shape, failed payload shape, no-active-turn guard, sub-spawn-sub context threading.
+- `bun test src/server/subagent-orchestrator.test.ts` — 5 new `delegateRun` tests (completed, UNKNOWN_SUBAGENT, DEPTH_EXCEEDED, LOOP_DETECTED, PROVIDER_ERROR) plus all existing `runMentionsForUserMessage` tests still pass.
+- `bun test src/server/claude-pty/driver.test.ts` — updated D8 test confirms `KANNA_SYSTEM_PROMPT_APPEND` is the default; new D8b confirms `systemPromptAppend` override path.
+- `bun test src/server/agent.test.ts` — short-circuit tests rewritten to call `getSubagentOrchestrator().delegateRun(...)` directly.
+- Full suite: `bun test` — 1957 pass / 0 fail.
+- `bunx eslint src/ --max-warnings=0` — clean.
diff --git a/.c3/adr/adr-20260518-version-pinned-update-install.md b/.c3/adr/adr-20260518-version-pinned-update-install.md
new file mode 100644
index 000000000..ce6f4495d
--- /dev/null
+++ b/.c3/adr/adr-20260518-version-pinned-update-install.md
@@ -0,0 +1,109 @@
+---
+id: adr-20260518-version-pinned-update-install
+c3-seal: 5c6db2d6ced3f422939c89fd405d7f58000d53600b018d7af2e09221f1f4a97d
+title: version-pinned-update-install
+type: adr
+goal: Let users install any published kanna-code release from the Settings → Changelog UI — not just the latest. The `update.install` command now accepts an optional `version`; the supervisor reloader pins npm to that exact tag so users can roll back to a known-good release or jump forward without waiting for `check-for-updates` to flag an update available.
+status: implemented
+date: "2026-05-18"
+---
+
+# adr-20260518-version-pinned-update-install
+
+## Goal
+
+Let users install any published kanna-code release from the Settings → Changelog UI — not just the latest. The `update.install` command now accepts an optional `version`; the supervisor reloader pins npm to that exact tag so users can roll back to a known-good release or jump forward without waiting for `check-for-updates` to flag an update available.
+
+## Context
+
+`UpdateManager.installUpdate()` previously had no version argument. The supervisor reloader called `installPackageVersion(PACKAGE_NAME, latestVersionHint())` and the UI rendered an "Update" button only on the release that matched `updateSnapshot.latestVersion` while `canInstallUpdate` was true. Users hitting a regression had no in-app path to install an older release; the only remedy was a manual `bun add -g kanna-code@x.y.z` from a terminal, which most non-developer users cannot do. The npm `installPackageVersion(name, version)` helper already accepts any tag — the constraint lived only in the manager and the UI gating, not in the install pipeline. Affected topology: c3-219 update-manager, c3-302 protocol, c3-208 ws-router, c3-116 settings-page. The pm2 reloader pulls `origin/main --ff-only` and cannot pin to an arbitrary tag, so version pinning is supervisor-only.
+
+## Decision
+
+Add an optional `version: string` to the `update.install` WebSocket command. Plumb it through `WsRouter → UpdateManager.installUpdate({version}) → runInstall(targetVersion) → UpdateReloader.reload(version)`. `SupervisorExitReloader.reload(version)` uses the explicit version when supplied (stripping a leading `v`), else falls back to `targetVersion()` (latest). `Pm2Reloader.reload(version)` throws `UpdateInstallError("Version pin not supported", "install_failed", "Version pin not supported")` when a version is passed, because git-pull mode cannot resolve an arbitrary tag. When `targetVersion` is set, `runInstall` skips the `updateAvailable` gate so rollback (older than current) and side-grade work. The Changelog UI now renders an install button on every non-current release: "Update" for the latest+available release (existing wording preserved), "Rollback" when the tag is older than the current installed version (compared via a client-side `compareSemverTags`), and "Install" otherwise. The current release still renders only the "Current" badge with no button.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-219 | component | New installUpdate({version?}) signature, runInstall(targetVersion?) bypass of updateAvailable gate, reloader interface widened to reload(version?), snapshot currentVersion written from targetVersion | Review Contract row "applyUpdate() / Strategy factory" — interface now optional-version-aware |
+| c3-302 | component | update.install envelope gains optional version: string discriminated-union field | Review WsInbound contract — new optional field is backward-compatible |
+| c3-208 | component | update.install handler forwards command.version to manager | Review envelope dispatch row — no new envelope kind, only forwarded payload |
+| c3-116 | component | Changelog section renders Install/Rollback/Update button on every non-current release; adds compareSemverTags helper; handleInstallUpdate accepts version? | Review settings setters contract — new setter forwards optional version to update.install |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-strong-typing | New optional version field on protocol + reloader interface must stay strictly typed; no any | comply |
+| ref-ws-subscription | update.install keeps WS subscription/command envelope contract; payload backward-compatible | comply |
+| ref-cqrs-read-models | Update snapshot remains the projection of update state; currentVersion now reflects the chosen target after install | comply |
+| ref-zustand-store | Settings-page reuses the existing kanna state store; no new store added | comply |
+| ref-local-first-data | Install path still resolves through local npm/bun toolchain on the user's machine | comply |
+| ref-colocated-bun-test | ws-router handler change is exercised by src/server/ws-router.test.ts colocated next to the source; manager + strategy edits covered by their colocated suites | comply |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-strong-typing | Optional version?: string typed on protocol union, reloader, manager method, state setter, UI prop — no untyped escape | comply |
+| rule-zustand-store | handleInstallUpdate continues to live on the kanna state hook; signature widened only | comply |
+| rule-colocated-bun-test | Existing update-manager.test.ts / update-strategy.test.ts colocated tests cover the new branches | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| Protocol | Add optional version?: string to update.install discriminated union | src/shared/protocol.ts |
+| Update strategy | UpdateReloader.reload(version?); SupervisorExitReloader honors override and strips ^v; Pm2Reloader throws on version pin | src/server/update-strategy.ts |
+| Update manager | installUpdate({version?}), runInstall(targetVersion?) bypass updateAvailable when target set, snapshot currentVersion derived from target | src/server/update-manager.ts |
+| WS router | Forward command.version to manager | src/server/ws-router.ts |
+| Client state | handleInstallUpdate(version?) sends {type:"update.install", version} | src/client/app/useKannaState.ts |
+| Settings UI | Render Install/Rollback/Update button on every non-current release; add compareSemverTags helper | src/client/app/SettingsPage.tsx |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| c3-219 Contract | No structural row change — surfaces stay (Update projection, applyUpdate(), Strategy factory). Behavior delta captured in this ADR; component body still derives. | c3x read c3-219 --full |
+| c3-302 Contract | WsInbound row already covers the union; optional field is additive. No row mutation needed. | c3x read c3-302 --section Contract |
+| c3-116 Contract | Setting setters row already covers typed commands; no row mutation needed for an optional argument extension. | c3x read c3-116 --section Contract |
+| N.A - no rules/refs/recipes added or removed | N.A - no rules/refs/recipes added or removed | N.A - no rules/refs/recipes added or removed |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| bun test src/server/update-manager.test.ts | Existing tests assert lifecycle + tracking; signature widening must not regress them | bun test output: 24 pass on update-manager + update-strategy suites |
+| bun test src/server/ws-router.test.ts | Asserts envelope routing; forwarded version must not break existing handlers | bun test output: 53 pass |
+| tsc --noEmit | Discriminated union + reloader interface change must compile across client + server | bunx tsc --noEmit clean |
+| bun run lint | ESLint --max-warnings=0 must stay green with the new client helper | bun run lint clean |
+| SupervisorExitReloader.reload guard | Throws UpdateInstallError("Unable to determine target version.") if neither override nor targetVersion() resolves | src/server/update-strategy.ts |
+| Pm2Reloader.reload guard | Throws UpdateInstallError("Version pin not supported") if a version is supplied in pm2 mode | src/server/update-strategy.ts |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Add a second update.installVersion command kind | Doubles the protocol surface for the same operation; the discriminated union already supports optional fields, and update.install semantics are unchanged when version is omitted |
+| Allow pm2 mode to checkout an arbitrary tag (git checkout v1.2.3 && build) | Out of scope for this change — pm2 reloader assumes a tracking branch and lockfile diff against HEAD@{1}; arbitrary checkout breaks both. Deferred behind an explicit ADR |
+| Server-side semver comparison to label the button | Forces a round trip and duplicates logic already present in cli-runtime.compareVersions; client compare keeps the UI snappy and labels are advisory only |
+| Hide the Install button on pm2 deployments | Client cannot detect the server-side reloader mode without a new snapshot field; falling back to a user-visible error from the Pm2Reloader guard is simpler and surfaces the limitation honestly |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| User installs an incompatible older version and breaks state migrations | Rollback prompts the same restart/reload path; users can roll forward again from the UI. No migration safety net is added in this ADR | bun test src/server/update-manager.test.ts asserts snapshot transitions on install path |
+| pm2-mode users click Install and see a generic error | Pm2Reloader throws a typed UpdateInstallError with "Version pin not supported" title that surfaces in the existing dialog | grep src/server/update-strategy.ts "Version pin not supported" |
+| Concurrent installs of two different versions race | UpdateManager.installPromise remains a single global lock; second click during install short-circuits via the existing status === "updating" branch | src/server/update-manager.ts installUpdate early return |
+| compareSemverTags mislabels prereleases | Helper drops the suffix after - like server compareVersions; mismatch only affects button label, not install correctness | bun test src/server/update-manager.test.ts (compareVersions logic), src/client/app/SettingsPage.tsx inline parse |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun test src/server/update-manager.test.ts src/server/update-strategy.test.ts | 24 pass, 0 fail |
+| bun test src/server/ws-router.test.ts | 53 pass, 0 fail |
+| bunx tsc --noEmit | clean |
+| bun run lint | clean (--max-warnings=0) |
diff --git a/.c3/adr/adr-20260519-migrate-codemap-to-component-frontmatter.md b/.c3/adr/adr-20260519-migrate-codemap-to-component-frontmatter.md
new file mode 100644
index 000000000..9778f1a23
--- /dev/null
+++ b/.c3/adr/adr-20260519-migrate-codemap-to-component-frontmatter.md
@@ -0,0 +1,153 @@
+---
+id: adr-20260519-migrate-codemap-to-component-frontmatter
+c3-seal: 1781b01eb250081920906800e65c970f7f29f12cfaca89d38893eaa98c40bd7e
+title: migrate-codemap-to-component-frontmatter
+type: adr
+goal: |-
+ Repair the C3 code map so the bundled c3x 9.9.0 owns it. The hand-edited
+ `.c3/code-map.yaml` was unsealed and carried 9 unsupported `ref-*`
+ codemap entries, which made `c3x check`/`repair` report
+ `ONLY_IN_TREE code-map.yaml` + "canonical markdown drift" and made
+ `c3x lookup ` return empty. The decision being authorized:
+ re-author the code map through `c3x set codemap` for all
+ 41 components so c3x writes and seals a component-only `code-map.yaml`,
+ and let c3x drop the `ref-*` entries (refs are governed via component
+ `uses` wiring, not codemap). This restores `c3x lookup`, `c3x check`,
+ and `c3x repair`.
+status: implemented
+date: "2026-05-19"
+---
+
+## Goal
+
+Repair the C3 code map so the bundled c3x 9.9.0 owns it. The hand-edited
+`.c3/code-map.yaml` was unsealed and carried 9 unsupported `ref-*`
+codemap entries, which made `c3x check`/`repair` report
+`ONLY_IN_TREE code-map.yaml` + "canonical markdown drift" and made
+`c3x lookup ` return empty. The decision being authorized:
+re-author the code map through `c3x set codemap` for all
+41 components so c3x writes and seals a component-only `code-map.yaml`,
+and let c3x drop the `ref-*` entries (refs are governed via component
+`uses` wiring, not codemap). This restores `c3x lookup`, `c3x check`,
+and `c3x repair`.
+
+## Context
+
+The skill bundles c3x 9.9.0. The project's `.c3/` is doc-format
+`c3-version: 4`. The code map lived only in a hand-curated
+`.c3/code-map.yaml` (211 lines: 41 component blocks + 9 `ref-*`
+blocks) that was never authored through c3x, so it sat outside the
+canonical seal. Symptoms: `c3x check`/`repair` reported
+`ONLY_IN_TREE code-map.yaml` and "canonical markdown drift detected";
+`c3x lookup ` returned empty `matches:`; `c3x repair`
+"resolved" the drift by deleting the whole file, destroying the only
+file→component map. CLAUDE.md mandates `c3x lookup ` before ANY
+code edit, so the mandated workflow was broken. c3x 9.9.0 stores the
+code map in a c3x-managed, sealed `code-map.yaml` written via
+`c3x set codemap ""`; it does not support `ref-*`
+codemap blocks (audit Phase 9: "Ref WITH code-map file patterns →
+VIOLATION"). Affected topology: every component in containers c3-1
+(Client, 12), c3-2 (Server, 23), c3-3 (Shared, 6).
+
+## Decision
+
+Run `c3x set codemap ""` for
+all 41 components, copying the exact glob/path lists verbatim from the
+original `code-map.yaml` (recovered from git HEAD). c3x re-authors and
+seals `code-map.yaml` as a c3x-managed, component-only artifact and
+drops the 9 `ref-*` blocks automatically. The file is kept (not
+deleted) — c3x owns it as sealed canonical state. `ref-*` codemap is
+intentionally not retained: c3x 9.9.0 surfaces governing refs for a
+file through the owning component's `uses` wiring (verified: a lookup
+of `src/server/agent.ts` returns c3-210 plus its 4 governing refs +
+1 rule), and Phase 9 flags ref codemap as a VIOLATION. Right fit:
+aligns the doc store with the bundled CLI's actual data model, zero
+source-code changes, mechanical + verifiable, component coverage
+provably unchanged (component blocks byte-identical to HEAD).
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-1 | container | All 12 client component codemap blocks re-authored through c3x | Component blocks byte-identical to HEAD; no boundary/responsibility change |
+| c3-2 | container | All 23 server component codemap blocks re-authored through c3x | Component blocks byte-identical to HEAD; no boundary/responsibility change |
+| c3-3 | container | All 6 shared component codemap blocks re-authored through c3x | Component blocks byte-identical to HEAD; no boundary/responsibility change |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-colocated-bun-test | Cited by affected components (c3-102,206,208,210,303); this ADR only re-authors their codemap blocks, not their code | N.A - codemap-only reseal; no code change to review for compliance |
+| ref-cqrs-read-models | Cited by affected components (c3-110,111,112,207,208,219,223); codemap blocks re-authored, code untouched | N.A - codemap-only reseal; no code change to review for compliance |
+| ref-event-sourcing | Cited by affected components (c3-205,206,210); codemap blocks re-authored, code untouched | N.A - codemap-only reseal; no code change to review for compliance |
+| ref-local-first-data | Cited by affected components (c3-116,117,201,202,203,204,206,214,217,218,221,222,305); codemap blocks re-authored, code untouched | N.A - codemap-only reseal; no code change to review for compliance |
+| ref-provider-adapter | Cited by affected components (c3-113,115,210,211,212,213); codemap blocks re-authored, code untouched | N.A - codemap-only reseal; no code change to review for compliance |
+| ref-strong-typing | Cited by affected components (c3-101,102,103,114,205,207,209,211,219,223,301,302,303,304,306); codemap blocks re-authored, code untouched | N.A - codemap-only reseal; no code change to review for compliance |
+| ref-tool-hydration | Cited by affected components (c3-113,114,210,215,303); codemap blocks re-authored, code untouched | N.A - codemap-only reseal; no code change to review for compliance |
+| ref-ws-subscription | Cited by affected components (c3-101,110,112,117,118,202,208,216,220,223,302); codemap blocks re-authored, code untouched | N.A - codemap-only reseal; no code change to review for compliance |
+| ref-zustand-store | Cited by affected components (c3-102,111,115,116,118); codemap blocks re-authored, code untouched | N.A - codemap-only reseal; no code change to review for compliance |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-colocated-bun-test | Cited by affected components (c3-102,206,208,210,303); this ADR only re-authors their codemap blocks, not their code | N.A - codemap-only reseal; no code change to review for compliance |
+| rule-strong-typing | Cited by affected components (c3-101,102,103,114,205,207,209,211,219,223,301,302,303,304,306); codemap blocks re-authored, code untouched | N.A - codemap-only reseal; no code change to review for compliance |
+| rule-zustand-store | Cited by affected components (c3-102,111,115,116,118); codemap blocks re-authored, code untouched | N.A - codemap-only reseal; no code change to review for compliance |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| Recover source map | git show HEAD:.c3/code-map.yaml → parse 41 component key→patterns pairs | /tmp/c3_pairs.tsv, 41 rows |
+| Component codemap (client) | c3x set codemap "" for c3-101,102,103,110..118 | original code-map.yaml lines 1-56 |
+| Component codemap (server) | c3x set codemap "" for c3-201..c3-223 | original code-map.yaml lines 57-158 |
+| Component codemap (shared) | c3x set codemap "" for c3-301..c3-306 | original code-map.yaml lines 159-174 |
+| c3x re-seal | c3x set re-authors + seals code-map.yaml; ref-* blocks dropped by c3x | git diff = 37 deletions (9 ref-* keys only), 0 additions |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| code-map.yaml | Re-authored via 41 c3x set codemap; c3x dropped 9 ref-* blocks; component blocks (lines 1-174) byte-identical to HEAD | diff of HEAD vs new lines 1-174 = IDENTICAL |
+| Canonical seal | code-map.yaml now c3x-managed + sealed; ADR 20260518 reseal-normalized (c3-seal added) | c3x check → no ONLY_IN_TREE, no drift, no issues |
+| Lookup resolution | c3x lookup resolves file→component+refs+rules again | c3x lookup src/server/agent.ts → c3-210 + 4 refs + 1 rule |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| c3x check | Fails on seal drift / coverage regression | clean exit (issues: empty) after migration |
+| c3x lookup | Resolves file→component+refs (CLAUDE.md-mandated pre-edit step) | non-empty matches: for mapped files + globs |
+| git diff .c3/code-map.yaml | Catches any unintended component-block change | only 9 ref-* key deletions, 0 additions |
+| CI bun test | Guards no source regression (none expected; C3-metadata-only) | green run in worktree |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Keep hand-edited code-map.yaml, pin older c3x that tolerates it | Skill cache only ships 9.9.0; no older binary available; freezes the project on an unmaintained CLI |
+| Accept c3x repair deleting code-map.yaml with no migration | Destroys the only file→component map; c3x lookup stays permanently broken; violates CLAUDE.md pre-edit mandate |
+| Defer / document as known-broken | c3x lookup is mandated before every code edit; leaving it broken degrades every future change |
+| Retain ref-* codemap blocks | Audit Phase 9 flags ref codemap as VIOLATION; c3x 9.9.0 drops them; refs already surface via component uses wiring |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Pattern transcription error (wrong glob on a component) | Patterns copied verbatim from git-HEAD code-map.yaml via scripted parse, no hand-typing | Component blocks (lines 1-174) byte-identical to HEAD; spot lookups per container |
+| Component coverage regression vs legacy map | All 41 component keys re-set 1:1; none dropped | diff HEAD vs new lines 1-174 = IDENTICAL; c3x check clean |
+| Ref governance lost by dropping ref-* codemap | Refs surface via component uses wiring instead | c3x lookup src/server/agent.ts returns c3-210 + 4 refs + 1 rule |
+| Source code accidentally touched | Change is c3x set only (C3 store) | git diff --stat shows only .c3/ paths |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| c3x check (in worktree) | clean: no ONLY_IN_TREE, no canonical drift, issues: empty |
+| c3x lookup src/server/agent.ts | c3-210 + ref-colocated-bun-test, ref-event-sourcing, ref-provider-adapter, ref-tool-hydration + rule-colocated-bun-test |
+| c3x lookup src/client/stores/**/*.ts | resolves to c3-102 |
+| spot lookups (socket.ts, types.ts, uploads.ts, cloudflare-tunnel/gateway.ts) | c3-101 / c3-301 / c3-217 / c3-223 |
+| diff HEAD vs new code-map.yaml lines 1-174 | IDENTICAL — zero component coverage regression |
+| git diff --stat | only .c3/ paths changed (no src/) |
+| bun test (in worktree) | passes (C3-metadata-only change; no source regression) |
diff --git a/.c3/adr/adr-20260519-pty-driver-stdout-event-source.md b/.c3/adr/adr-20260519-pty-driver-stdout-event-source.md
new file mode 100644
index 000000000..5036cdb28
--- /dev/null
+++ b/.c3/adr/adr-20260519-pty-driver-stdout-event-source.md
@@ -0,0 +1,154 @@
+---
+id: adr-20260519-pty-driver-stdout-event-source
+c3-seal: d494ed5f63653d33df6c326ec73f3abc807dd8fa0d30b420de17e110f5d9c6f2
+title: pty-driver-stdout-event-source
+type: adr
+goal: |-
+ Authoritatively document, in C3, the PTY Claude driver's runtime event
+ source: it parses the `claude` CLI subprocess **stdout** as a live JSONL
+ stream and never reads the on-disk `~/.claude/projects//.jsonl`
+ transcript. Create a `claude-pty-driver` component under container c3-2
+ (server) to chart the currently-uncharted `src/server/claude-pty/**`
+ subtree (~40 files, 0 components today), governed by the provider-adapter
+ ref, and record that `claude-pty/jsonl-path.ts` is dead code. This ADR
+ authorizes the C3 charting + the parallel correction of the stale
+ CLAUDE.md "Architecture note", not any production code change.
+status: superseded
+date: "2026-05-19"
+---
+
+## Goal
+
+Authoritatively document, in C3, the PTY Claude driver's runtime event
+source: it parses the `claude` CLI subprocess **stdout** as a live JSONL
+stream and never reads the on-disk `~/.claude/projects//.jsonl`
+transcript. Create a `claude-pty-driver` component under container c3-2
+(server) to chart the currently-uncharted `src/server/claude-pty/**`
+subtree (~40 files, 0 components today), governed by the provider-adapter
+ref, and record that `claude-pty/jsonl-path.ts` is dead code. This ADR
+authorizes the C3 charting + the parallel correction of the stale
+CLAUDE.md "Architecture note", not any production code change.
+
+## Context
+
+A debug of chat `7b818c13-83d1-47fc-8fa1-f948d8e30c5a` (slow
+`ask_user_question`) required reasoning about PTY event latency. The
+project CLAUDE.md "Architecture note" claimed PTY mode "uses the on-disk
+JSONL transcript ... as the sole event source" and "output is drained,
+not parsed". Code contradicts this: `src/server/claude-pty/driver.ts:453`
+`pumpStdout` reads the subprocess stdout `ReadableStream` via
+`reader.read()` (driver.ts:459), splits on `\n`, and feeds each line to
+`createJsonlEventParser` (driver.ts:449,468). No source file outside
+tests references `.claude/projects` or `*.jsonl` on-disk reads (verified:
+zero non-test matches). `claude-pty/jsonl-path.ts`
+(`computeJsonlPath`/`encodeCwd`) has zero production callers — only its
+own colocated test references it. C3 topology has no component for
+`src/server/claude-pty/**`; `c3x lookup 'src/server/claude-pty/**'`
+returns `components:` empty (codemap coverage gap). The Codex transport
+sibling already has a dedicated component (c3-211 codex-app-server) under
+the same container, so the Claude PTY transport is the asymmetric gap.
+
+## Decision
+
+Create one component `claude-pty-driver` under c3-2, codemap
+`src/server/claude-pty/**`, governed by `ref-provider-adapter` (it is the
+Claude PTY transport adapter, parallel to c3-211 for Codex). Its body
+states the authoritative event-source contract: the driver owns the
+`claude` CLI subprocess, parses its **stdout** JSONL stream
+event-driven via `pumpStdout`/`reader.read()` (no poll loop, no
+`fs.watch`, no on-disk file tail, no `sleep`), and emits normalized
+`HarnessEvent`s upstream to c3-210 agent-coordinator. The on-disk
+`~/.claude/projects/...jsonl` transcript is written by the CLI but never
+read by Kanna; `jsonl-path.ts` is recorded as dead code (cleanup
+deferred to a separate code ADR — this is a charting change, not a code
+removal). The stale CLAUDE.md note is corrected in the same change to
+match the code. This wins over documenting the finding inside c3-210
+(wrong boundary — that component owns orchestration, not transport) and
+over an ADR-only record (leaves the 40-file codemap gap and keeps
+`c3x lookup` empty for the largest uncharted server subtree).
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-2 | container | Gains a new child component claude-pty-driver (net-new, id assigned by c3x add component — see Work Breakdown); ## Components + ## Responsibilities must list the Claude PTY transport | Parent Delta: container Components/Responsibilities updated with evidence |
+| c3-210 | component | Upstream consumer that drives this transport adapter; must confirm its generic provider Contract still holds with the transport now charted | No-delta review: c3-210 Contract already provider-agnostic (ref-provider-adapter), driver detail does not change its surface — evidence recorded, no body edit |
+| c3-211 | component | Sibling Codex-transport component used as the modeling precedent for a dedicated Claude-transport component under the same container | No-delta review: c3-211 unchanged; cited only to justify boundary symmetry |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-provider-adapter | The PTY driver normalizes the Claude CLI transport into the provider-agnostic turn/event shape; it is a provider adapter by definition | comply + wire to claude-pty-driver |
+| ref-event-sourcing | Driver emits transcript-bound events; event ordering/log-before-broadcast is owned upstream by c3-210/c3-206 but the driver must not break the invariant | review (driver emits; ordering not owned here) + wire |
+| ref-colocated-bun-test | driver.test.ts, jsonl-to-event.test.ts, jsonl-path.test.ts already sit beside their sources under src/server/claude-pty/ | comply + wire |
+| ref-strong-typing | Driver casts the Bun subprocess streams as unknown as ReadableStream at the external-runtime boundary | review — documented boundary cast against an external Bun API surface, acceptable under the ref's boundary clause |
+| ref-cqrs-read-models | Affected Topology includes container c3-2; this ref governs sibling components (c3-207/c3-208/c3-219/c3-223), so it must be reviewed to confirm the new transport does not alter read-model projection — the PTY driver only emits events upstream to c3-210 and builds no read models | review — confirmed no impact, no compliance change |
+| ref-local-first-data | Affected Topology includes container c3-2; this ref governs sibling persistence components (c3-201..c3-222), so it must be reviewed to confirm the new transport adds no persistent state — the PTY driver holds only a per-spawn subprocess and reads/writes no ~/.kanna data | review — confirmed no impact, no compliance change |
+| ref-tool-hydration | Affected Topology includes container c3-2; this ref governs sibling hydration paths (c3-210/c3-215), so it must be reviewed to confirm the transport does not bypass hydration — the PTY driver emits raw normalized HarnessEvents and hydration stays owned by c3-210/c3-303 | review — confirmed no impact, no compliance change |
+| ref-ws-subscription | Affected Topology includes container c3-2; this ref governs sibling WebSocket components (c3-202/c3-208/c3-216/c3-220/c3-223), so it must be reviewed to confirm the transport adds no WS surface — the PTY driver exposes none and streams only to c3-210 | review — confirmed no impact, no compliance change |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-colocated-bun-test | Every Kanna test must sit next to the file under test; the claude-pty subtree already satisfies this and the new component must keep enforcing it | comply + wire to claude-pty-driver |
+| rule-strong-typing | All values crossing a Kanna boundary must be typed; the only escape (as unknown as) is the documented external Bun subprocess boundary, not an internal contract | review — boundary cast documented in component body, no internal any/untyped shape introduced |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| ADR | This ADR adr-*-pty-driver-stdout-event-source, proposed → accepted → implemented | c3x read --full |
+| Component create | c3x add component claude-pty-driver --container c3-2 --file | c3x list shows new c3-2XX child |
+| Codemap | c3x set claude-pty-driver codemap src/server/claude-pty/** closes the lookup gap | c3x lookup 'src/server/claude-pty/**' returns the component (was empty) |
+| Wire governance | c3x wire claude-pty-driver → ref-provider-adapter, ref-event-sourcing, ref-colocated-bun-test, rule-colocated-bun-test | c3x read claude-pty-driver Governance table |
+| Parent Delta | c3-2 ## Components + ## Responsibilities updated to include Claude PTY transport | c3x read c3-2 diff |
+| Dead-code record | Component body marks jsonl-path.ts (computeJsonlPath/encodeCwd) dead code, cleanup deferred | grep computeJsonlPath src → only jsonl-path.ts + its test |
+| CLAUDE.md correction | "Architecture note" + driver-flag line rewritten to stdout-stream truth | CLAUDE.md lines 83-86, 183+ in worktree docs/pty-jsonl-stream-note |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| Codemap coverage validator | c3x check codemap-gap detector currently flags src/server/claude-pty/** as uncharted; adding the component codemap closes that gap so the validator stays green only while the subtree is owned | c3x check issues: (none); c3x lookup 'src/server/claude-pty/**' non-empty |
+| Component schema enforcement | New component body authored to c3x schema component (Contract / Change Safety / Governance); thin sections rejected at c3x add | c3x add component ... --file succeeds; c3x check --only clean |
+| Colocated-test enforcement surface | driver.test.ts named in Change Safety as the regression guard for the stdout-parse path; rule-colocated-bun-test wired so the validator enforces test colocation | bun test src/server/claude-pty/driver.test.ts passes |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| c3x check | Fails if the src/server/claude-pty/** codemap gap reappears or component sections drift | c3x check → issues: (none) |
+| c3x lookup 'src/server/claude-pty/**' | Must resolve to claude-pty-driver, not empty | lookup output components: non-empty |
+| src/server/claude-pty/driver.test.ts | Regression guard: proves pumpStdout parses subprocess stdout, not an on-disk file | bun test src/server/claude-pty/driver.test.ts |
+| grep -rn computeJsonlPath | encodeCwd src | Dead-code claim stays true only while matches = jsonl-path.ts + its test |
+| grep -rn '.claude/projects' src (non-test) | Stays empty — re-introducing an on-disk transcript reader is a contract violation | 0 non-test matches |
+| CLAUDE.md "Architecture note" | Human-facing drift guard; must read "parses stdout stream", not "on-disk ... drained, not parsed" | CLAUDE.md worktree edit |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Document the finding inside c3-210 agent-coordinator body | c3-210 owns provider-agnostic turn orchestration, not Claude transport detail; Codex transport already has its own component (c3-211), so the Claude PTY transport must mirror that boundary or 40 files stay uncharted |
+| ADR-only, no component (option B) | Leaves the codemap coverage gap; c3x lookup 'src/server/claude-pty/**' keeps returning empty; the largest uncharted server subtree gets no code-ownership |
+| Delete jsonl-path.ts in this change | Out of scope — this is a charting/doc-accuracy change; mixing a code deletion needs its own code ADR with its own Change Safety; recorded as deferred dead code instead |
+| Attach codemap to existing c3-212 provider-catalog | provider-catalog normalizes provider/model/reasoning metadata, not the PTY transport runtime; wrong component boundary |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Future edit re-introduces an on-disk .claude/projects transcript reader, silently contradicting the component contract | Component Change Safety names driver.test.ts as the parse-path guard; Enforcement Surfaces include a grep tripwire | grep -rn '.claude/projects' src non-test stays 0; bun test src/server/claude-pty/driver.test.ts passes |
+| jsonl-path.ts later gains a real caller, making the "dead code" note stale | Note scoped to "zero production callers"; grep tripwire flags any third referencing file | grep -rn computeJsonlPath |
+| Single broad codemap (src/server/claude-pty/**) hides finer sub-contracts (preflight/, sandbox/) as those subtrees grow | One component now; split into sub-components via a later ADR if preflight/sandbox develop independent contracts | c3x list child count under c3-2 reviewed at next sweep |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| C3X_MODE=agent c3x check | issues: (none) — no codemap gap for src/server/claude-pty/** |
+| C3X_MODE=agent c3x lookup 'src/server/claude-pty/**' | components: resolves to the new claude-pty-driver id (was empty) |
+| grep -rn 'computeJsonlPath | encodeCwd' src (*.ts) |
+| grep -rn '\.claude/projects' src (non-test) | 0 matches (no on-disk transcript reader) |
+| bun test src/server/claude-pty/driver.test.ts | Suite passes (stdout-parse path intact) — single suite per CLAUDE.md, not a full build |
diff --git a/.c3/adr/adr-20260519-split-oauth-pool-from-auth.md b/.c3/adr/adr-20260519-split-oauth-pool-from-auth.md
new file mode 100644
index 000000000..b3442f470
--- /dev/null
+++ b/.c3/adr/adr-20260519-split-oauth-pool-from-auth.md
@@ -0,0 +1,101 @@
+---
+id: adr-20260519-split-oauth-pool-from-auth
+c3-seal: 8591470393fa096e0dd8c05f8ba61c62346377253364734289c693c8773e9359
+title: split-oauth-pool-from-auth
+type: adr
+goal: Split OAuth multi-token rotation pool out of c3-203 (auth) into a new server-side component c3-224 (oauth-token-pool) so the documented surface matches the code. c3-203 explicitly declares OAuth a non-goal yet code-map.yaml has src/server/oauth-pool/** trained on it; the actual responsibilities (token state machine, per-chat reservation, rate-limit/auth-error rotation, refusal payload for the UI) need their own contract.
+status: implemented
+date: "2026-05-19"
+---
+
+## Goal
+
+Split OAuth multi-token rotation pool out of c3-203 (auth) into a new server-side component c3-224 (oauth-token-pool) so the documented surface matches the code. c3-203 explicitly declares OAuth a non-goal yet code-map.yaml has src/server/oauth-pool/** trained on it; the actual responsibilities (token state machine, per-chat reservation, rate-limit/auth-error rotation, refusal payload for the UI) need their own contract.
+
+## Context
+
+Today src/server/oauth-pool/oauth-token-pool.ts owns four state buckets per OAuth token (active/limited/error/disabled), a per-chat reservation map preventing two concurrent chats from sharing one token, eligibility + auto-revive on pickActive, and a refusal classifier describeUnavailability landed in PR #235. The pool is consumed by c3-210 (agent-coordinator) on every Claude turn spawn and by both the SDK and PTY drivers for token rotation. c3-203's documented purpose is single launch-password cookie middleware — its body says "Non-goals: ... OAuth, multi-tenant auth". code-map.yaml line 67 maps src/server/oauth-pool/**/*.ts under c3-203, which makes c3x lookup return the wrong contract. CLAUDE.md mentions OAuth pool rotation only as a one-line PTY parity note; no doc covers reservation semantics, rotation flow, or the new refusal path that ws-router surfaces to ChatTranscriptViewport as a clickable link.
+
+## Decision
+
+Create c3-224 oauth-token-pool as a feature-category component under c3-2. Move src/server/oauth-pool/** to it in code-map.yaml. Document token state machine, per-chat 1:1 reservation (with subagent-same-chat exception), pickActive eligibility + LRU + revive, rotation flow consumed by c3-210 on rate-limit and auth-error detection, and the PR #235 refusal payload contract (markdown chat-link parsed by c3-112 chat-page). Update c3-203 derived materials to drop src/server/oauth-pool/**. No code change; this is documentation realignment only.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-203 | component | Owns code-map entry for src/server/oauth-pool/** today, must release it | Drop oauth-pool path from code-map; confirm Derived Materials still match |
+| c3-2 | container | Components table must list the new oauth-token-pool component with goal contribution; child being introduced under this container | Append row for new component; verify parent Goal Slice still holds |
+| c3-210 | component | Consumes oauth pool on every Claude turn spawn and rotation | Add wire to new component; document dependency in component body |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-local-first-data | Pool reads/writes settings under ~/.kanna/data via app-settings; binding stays local-first | comply |
+| N.A - oauth pool state is settings-backed, not event-sourced; intentional out-of-scope for event log | N.A | N.A |
+| ref-strong-typing | Public surface (OAuthTokenEntry, TokenUnavailability, EphemeralLease) must stay precisely typed at the chat/agent boundary | comply |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-strong-typing | Pool API crosses chat/agent boundary; no any / untyped patch payloads allowed | comply |
+| rule-colocated-bun-test | oauth-token-pool.test.ts sits next to oauth-token-pool.ts (already true) | comply |
+| N.A - rule-zustand-store does not apply: pool is server-side, not client zustand state | N.A | N.A |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| c3x add component | Create c3-224 oauth-token-pool under c3-2 with feature category | c3x add component c3-224 oauth-token-pool --container c3-2 |
+| c3-224 body | Write Parent Fit, Purpose, Foundational Flow, Business Flow, Governance, Contract, Change Safety, Derived Materials | c3x write c3-224 --file body.md |
+| code-map.yaml | Move src/server/oauth-pool/**/*.ts pattern from c3-203 to c3-224 | c3x set c3-203 codemap-remove; c3x set c3-224 codemap-add |
+| c3-203 Derived Materials | Drop oauth-pool material rows from c3-203 if any | c3x write c3-203 --section "Derived Materials" |
+| c3-2 Components | Append c3-224 row to Components table | c3x write c3-2 --section Components |
+| Wire c3-224 | Wire c3-210 -> c3-224 dependency and any refs (ref-local-first-data, ref-strong-typing) | c3x wire c3-210 c3-224; c3x wire c3-224 ref-local-first-data; c3x wire c3-224 ref-strong-typing |
+| Verify | Run c3x check after each mutation; ensure no drift | c3x check |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| .c3/code-map.yaml | Add c3-224: src/server/oauth-pool/**/*.ts; remove that pattern from c3-203 entry | c3x lookup src/server/oauth-pool/** returns c3-224 |
+| .c3/c3-2-server/c3-224-oauth-token-pool.md | New component doc file created by c3x add | c3x read c3-224 --full |
+| .c3/c3-2-server/c3-203-auth.md Derived Materials section | Confirm row set no longer references oauth-pool path | c3x read c3-203 --section "Derived Materials" |
+| .c3/c3-2-server/README.md Components table | Append c3-224 row | c3x read c3-2 --section Components |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| c3x lookup | Maps src/server/oauth-pool/** to c3-224 not c3-203 | c3x lookup src/server/oauth-pool/oauth-token-pool.ts |
+| c3x check | Validates every component-file relationship and rejects drift | c3x check exits clean post-mutation |
+| c3-224 Contract section | Names public surface (pickActive, pickEphemeral, markLimited, markError, markDisabled, markEnabled, markUsed, describeUnavailability, hasUsable, hasAnyToken, allLimited, earliestUnlimit) | c3x read c3-224 --section Contract |
+| oauth-token-pool.test.ts | Existing unit tests assert state machine + reservation + refusal classification | bun test src/server/oauth-pool/ |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Keep oauth-pool under c3-203 and extend c3-203 to cover OAuth | c3-203 body explicitly lists OAuth as Non-goal; widening it conflates launch-password middleware with multi-account token rotation and breaks Parent Fit |
+| Document oauth-pool only in CLAUDE.md | Defeats the c3 architecture-as-docs invariant: c3x lookup must surface the contract for any file; CLAUDE.md is unstructured prose, not the source of truth |
+| Inline oauth-pool docs into c3-210 (agent-coordinator) | agent-coordinator is the consumer, not the owner; mixing the two hides the pool's state machine + reservation invariants that survive across multiple coordinator paths (SDK + PTY) |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| code-map.yaml ends up mapping oauth-pool to neither component | Apply add-to-c3-224 and remove-from-c3-203 in same change set, then c3x check | c3x lookup src/server/oauth-pool/oauth-token-pool.ts returns c3-224 |
+| c3-2 Components table drifts (missing c3-224 row) | c3x check enforces parent-child link; verify with c3x graph c3-2 --depth 1 | c3x check && c3x graph c3-2 --depth 1 |
+| c3-210 wire missing - dependency invisible | Explicit c3x wire c3-210 c3-224 step; verify via c3x graph c3-210 | c3x graph c3-210 --depth 1 |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| c3x check after final mutation | issues: 0 |
+| c3x lookup src/server/oauth-pool/oauth-token-pool.ts | components: c3-224 |
+| c3x read c3-224 --section Contract | Lists pickActive/pickEphemeral/mark*/describeUnavailability surface |
+| c3x graph c3-224 --depth 1 | Shows c3-2 parent + ref-local-first-data + ref-strong-typing + c3-210 dependency |
+| c3x graph c3-2 --depth 1 | Includes c3-224 child node |
diff --git a/.c3/adr/adr-20260519-subagent-live-progress-decouple.md b/.c3/adr/adr-20260519-subagent-live-progress-decouple.md
new file mode 100644
index 000000000..7f1958aca
--- /dev/null
+++ b/.c3/adr/adr-20260519-subagent-live-progress-decouple.md
@@ -0,0 +1,153 @@
+---
+id: adr-20260519-subagent-live-progress-decouple
+c3-seal: 708673007f96ccb557f2d9b24e328285178d1ddac6d6264ef4a3a19096679395
+title: subagent-live-progress-decouple
+type: adr
+goal: |-
+ Decouple subagent live-progress visibility from the global serialized disk
+ `writeChain` so a delegated subagent's transcript entries and streamed text
+ appear incrementally in the UI while the run is in flight, instead of staying
+ blank then dumping in one burst at terminal. Concretely: for the ephemeral
+ `subagent_*` event family only, apply the read-model projection to in-memory
+ state synchronously and fire `onRunProgress` immediately, while the durable
+ JSONL append continues asynchronously. Durable/structural events keep their
+ current Append→fsync(apply)→notify ordering unchanged.
+status: implemented
+date: "2026-05-19"
+---
+
+## Goal
+
+Decouple subagent live-progress visibility from the global serialized disk
+`writeChain` so a delegated subagent's transcript entries and streamed text
+appear incrementally in the UI while the run is in flight, instead of staying
+blank then dumping in one burst at terminal. Concretely: for the ephemeral
+`subagent_*` event family only, apply the read-model projection to in-memory
+state synchronously and fire `onRunProgress` immediately, while the durable
+JSONL append continues asynchronously. Durable/structural events keep their
+current Append→fsync(apply)→notify ordering unchanged.
+
+## Context
+
+`mcp__kanna__delegate_subagent` blocks the main turn for the whole subagent
+run, so the main loop emits nothing meanwhile; subagent progress is the only
+signal. Commit #237 added `onRunProgress` (subagent-orchestrator.ts:528,
+650-654 → agent.ts:1144-1151 `emitStateChange`) to broadcast per entry. It
+does not work in practice: `appendSubagentEvent` (event-store.ts:1682) routes
+through the single global `append()` (event-store.ts:1032-1039) whose pattern
+is `this.writeChain = this.writeChain.then(async () => { await
+appendFile(...); this.applyEvent(event) })`. `writeChain` is one
+process-wide serial promise shared by every write (main transcript, turns
+log, sidebar, subagent). `appendSubagentEvent` returns that chain tail, and
+the orchestrator's `.then(onRunProgress)` therefore fires only after every
+queued `await appendFile` (plus `capTranscriptEntry` for tool_result,
+event-store.ts:1672) ahead of it drains. During a busy main turn the chain is
+saturated, so the read-model projection and the broadcast are starved → UI
+shows the subagent as hung, then all entries appear at once. Additionally
+`subagent_message_delta` (onChunk, subagent-orchestrator.ts:624-637) never
+calls `onRunProgress`, so streamed assistant text is invisible until a later
+entry forces a snapshot. Affected topology: c3-206 (event-store) owns the
+write path; c3-207 (read-models) projects; c3-210 (agent-coordinator /
+subagent-orchestrator) wires progress; c3-205 (events-schema) defines the
+unchanged event union. Constraint: this is a local-first, single-user tool
+(ref-local-first-data) — subagent progress events are regenerable cosmetic
+liveness, not authoritative user data.
+
+## Decision
+
+Add a scoped synchronous-apply path used only by `appendSubagentEvent`: apply
+the event to in-memory state synchronously at call time, then enqueue a
+disk-only append on `writeChain` (no second `applyEvent` in the chained
+callback, so the entry is applied exactly once per process lifetime).
+`appendSubagentEvent` no longer makes UI visibility wait on disk I/O. The
+orchestrator calls `onRunProgress` directly (not chained on the returned
+write promise) for `onEntry`, and adds a trailing-edge throttled
+`onRunProgress` to `onChunk` so streamed text becomes visible incrementally.
+This wins for this repo because the bottleneck is provably the serialized
+`await appendFile` backlog, not ws-router (its 16ms coalesce + signature
+dedup already pass subagent deltas since `subagentRuns` is in the chat
+snapshot signature). Scoping the decouple to the `subagent_*` ephemeral
+family keeps the c3-206 durability contract intact for structural events
+(`chat_created`, `user_prompt`, `turn_finished`, result) which must not
+advance in-memory ahead of disk. It is far smaller and lower-risk than a
+per-chat write-chain refactor while fully removing the hang/burst symptom.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-206 | component | event-store: adds a scoped synchronous in-memory apply for subagent_* events; disk append stays async. Changes the Business-Flow ordering ("Append→fsync→notify", "write error → log not advanced") for this event family only. | ref-event-sourcing Override scope; update c3-206 Business Flow via /c3 change in same PR |
+| c3-210 | component | agent-coordinator: subagent-orchestrator onEntry/onChunk progress wiring changes: fire onRunProgress without awaiting the store write chain; add throttled progress on text deltas. | ref-cqrs-read-models broadcast-on-change compliance |
+| c3-207 | component | read-models: projection logic unchanged but now invoked synchronously/earlier for subagent events; output shape identical. | Confirm projection stays pure (no I/O) — review only |
+| c3-205 events-schema | N.A - no new or modified event types; the subagent_* event union is unchanged | N.A - no schema change | N.A - no schema change |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-event-sourcing | The decision changes "mutation emit → derivation follows" timing: for subagent_* events the derivation (in-memory apply + notify) now runs before the durable disk append completes. | review + Override: document scope = only ephemeral subagent_entry_appended / subagent_message_delta / subagent_run_started; append-only JSONL, replay, and compaction are unchanged |
+| ref-cqrs-read-models | Governs "broadcast diffs on change, not on request" and "pure projections, no I/O". The fix makes broadcast actually fire on change (immediately) and must not introduce I/O into projection. | comply |
+| ref-local-first-data | Durability story: data under ~/.kanna, no remote replication. The crash-window for unflushed ephemeral subagent events is acceptable only because of single-user local-first scope. | comply |
+| ref-strong-typing | New throttle helper and progress wiring cross the orchestrator↔store boundary; must be named-typed, no any/untyped. | comply |
+| ref-colocated-bun-test | Cited by c3-206 and c3-210 (both affected). New/changed tests must sit next to source and run under bun test. | comply |
+| ref-provider-adapter | Cited by c3-210. The decision changes write/notify timing only; subagent entries are still produced via the existing Claude/Codex provider normalization, which is not modified. | N.A - provider normalization unchanged by this ADR |
+| ref-tool-hydration | Cited by c3-210. Tool-call entries are already normalized by src/shared/tools.ts upstream; the ordering/timing change does not alter hydration. | N.A - tool-call hydration unchanged by this ADR |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-colocated-bun-test | New/changed tests must sit next to source under bun test (event-store.test.ts, subagent-orchestrator.test.ts), no separate test dir. | comply |
+| rule-strong-typing | All values crossing the store/orchestrator boundary (throttle handle, callbacks) must have a named TypeScript type; no any/untyped object literals. | comply |
+| N.A - no client UI-local store changed by this ADR (server-only change; positional/render #4 explicitly out of scope) | N.A - rule-zustand-store does not apply: no Zustand store touched | N.A |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| event-store.ts | In appendSubagentEvent, apply the event to in-memory state synchronously (this.applyEvent(event)), then enqueue a disk-only append on writeChain whose chained callback does NOT call applyEvent again; keep .catch logging on disk failure. Refactor append() to allow a disk-only enqueue variant without duplicating the reducer. | src/server/event-store.ts:1032-1039,1666-1683 |
+| subagent-orchestrator.ts | onEntry: call this.deps.onRunProgress?.(chatId, runId) directly after appendSubagentEvent (drop the .then(writeChain) dependency); keep .catch log. onChunk: add a trailing-edge throttled (~100ms) onRunProgress. | src/server/subagent-orchestrator.ts:624-665 |
+| event-store.test.ts | New cases: subagent event visible via getSubagentRuns() before writeChain settles; no entry duplication (entries length == event count); disk-failure path still logs and in-memory remains advanced. | src/server/event-store.test.ts |
+| subagent-orchestrator.test.ts | New cases: onChunk triggers throttled onRunProgress; onEntry fires onRunProgress without awaiting the store write chain; final text visible after terminal. | src/server/subagent-orchestrator.test.ts |
+| C3 doc sync | Update c3-206 Business Flow rows (Primary path / Failure) to record the scoped ephemeral exception, via /c3 change in the same PR. | c3-206 Business Flow section |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| N.A - product code only | N.A - no C3 CLI command, validator, schema row, hint, help, or template is changed by this decision; enforcement is via colocated bun tests named in Enforcement Surfaces | N.A - c3x check unaffected; product-code drift caught by bun test src/server/event-store.test.ts src/server/subagent-orchestrator.test.ts |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| bun test src/server/event-store.test.ts | Asserts a subagent_entry_appended is observable via getSubagentRuns() synchronously (before the write chain resolves) and is applied exactly once. | New test cases in src/server/event-store.test.ts |
+| bun test src/server/subagent-orchestrator.test.ts | Asserts onEntry and throttled onChunk invoke onRunProgress without awaiting the store write chain; final text present after run. | New test cases in src/server/subagent-orchestrator.test.ts |
+| bun run lint | Strong-typing guard: no any/untyped at the new orchestrator↔store boundary; warnings ≤ cap. | CLAUDE.md lint ratchet, .github/workflows/test.yml |
+| c3-206 Business Flow doc | Records the scoped ephemeral ordering exception so future readers/audits see the Override, not silent drift. | c3x read c3-206 --section "Business Flow" after /c3 change |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Per-chat write chains instead of one global chain (option #3) | Large refactor touching every append() caller and the c3-206 replay/compaction contract broadly; high regression risk for durable events; the scoped sync-apply removes the symptom without that blast radius. |
+| Change global append() to apply-before-fsync for ALL events | Weakens durability ordering for structural events (chat_created, user_prompt, turn_finished, result) → real user-data loss window on crash; a broad c3-206 contract break rather than a scoped Override. |
+| Anchor the subagent block to the delegate_subagent tool-use id for in-sequence placement (#4) | Different concern (visual placement, not liveness); does not fix hang/burst; deferred to a separate follow-up ADR to keep this work order tight. |
+| Tighten/shorten ws-router coalesce (16ms) or its signature dedup | Not the bottleneck — subagentRuns is already in the chat snapshot signature so deltas are not deduped; the backlog is the serialized await appendFile, not ws-router. |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Crash between synchronous in-memory apply and the async disk append loses the last subagent progress event(s). | Scope limited to ephemeral subagent_* events (regenerable, cosmetic); durable/structural events keep strict Append→fsync→notify; .catch logs disk failure; boot replay rebuilds from disk. | event-store.test.ts crash-window case: simulate disk-append rejection, assert it is logged and in-memory state is still advanced; replay-from-disk excludes the unwritten event without corrupting the run. |
+| Double application (synchronous apply + chained apply) duplicates run.entries. | The disk-only enqueue variant does NOT call applyEvent in its chained callback; reducer runs exactly once per process lifetime; boot replay applies from disk in a separate process. | event-store.test.ts: assert entries.length === number of appended events after several appendSubagentEvent calls. |
+| Throttling onChunk drops the final streamed-text frame. | Trailing-edge throttle (fires after the quiet period) plus terminal onRunProgress/snapshot on run completion guarantees the last state is delivered. | subagent-orchestrator.test.ts: stream deltas then complete; assert final text visible in snapshot. |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun test src/server/event-store.test.ts | Pass, including new synchronous-visibility, no-duplication, and disk-failure crash-window cases |
+| bun test src/server/subagent-orchestrator.test.ts | Pass, including onEntry/onChunk progress-without-await and final-text cases |
+| bun run lint | 0 errors; warning count ≤ CLAUDE.md cap |
+| Manual: spawn delegate_subagent during a busy main turn | Subagent transcript entries and streamed text appear incrementally in the UI (no blank-then-burst, no perceived hang) |
diff --git a/.c3/adr/adr-20260520-system-prompt-snippets.md b/.c3/adr/adr-20260520-system-prompt-snippets.md
new file mode 100644
index 000000000..e60e6f4af
--- /dev/null
+++ b/.c3/adr/adr-20260520-system-prompt-snippets.md
@@ -0,0 +1,143 @@
+---
+id: adr-20260520-system-prompt-snippets
+c3-seal: 3148083d8a61822bc569534c0fc04dd6361f56cf8dc95d81af0812fb6044250e
+title: system-prompt-snippets
+type: adr
+goal: Replace the larger five-source proposal (~/.claude/CLAUDE.md, ~/.codex/AGENTS.md, project CLAUDE.md, project AGENTS.md, user snippets) with a single app-global user-editable text field `globalPromptAppend`. When non-empty the value is injected as additional system-level instructions on every main-agent turn — appended to the Claude system prompt (`KANNA_SYSTEM_PROMPT_APPEND` / `--append-system-prompt`) and sent to Codex via `collaborationMode.settings.developer_instructions`. One textarea in Settings, one persisted string, applied to Claude (SDK + PTY) and Codex symmetrically, inherited by subagent turns of both providers. No filesystem inheritance, no per-project field, no snippet list — those remain explicitly out of scope until evidence shows the simple form is insufficient.
+status: proposed
+date: "2026-05-20"
+---
+
+# adr-system-prompt-snippets
+
+## Goal
+
+Replace the larger five-source proposal (~/.claude/CLAUDE.md, ~/.codex/AGENTS.md, project CLAUDE.md, project AGENTS.md, user snippets) with a single app-global user-editable text field `globalPromptAppend`. When non-empty the value is injected as additional system-level instructions on every main-agent turn — appended to the Claude system prompt (`KANNA_SYSTEM_PROMPT_APPEND` / `--append-system-prompt`) and sent to Codex via `collaborationMode.settings.developer_instructions`. One textarea in Settings, one persisted string, applied to Claude (SDK + PTY) and Codex symmetrically, inherited by subagent turns of both providers. No filesystem inheritance, no per-project field, no snippet list — those remain explicitly out of scope until evidence shows the simple form is insufficient.
+
+## Context
+
+`src/shared/kanna-system-prompt.ts:14` declares `KANNA_SYSTEM_PROMPT_BASE` — the static refusal-policy paragraph appended to every Claude turn via `systemPrompt.append` (SDK driver, `src/server/agent.ts`) and `--append-system-prompt` (PTY driver, `src/server/claude-pty/driver.ts`). `buildKannaSystemPromptAppend(subagents)` splices a subagent roster after the base. The Codex JSON-RPC adapter (`src/server/codex-app-server.ts:1065`) calls `turn/start` per turn and hardcodes `collaborationMode.settings.developer_instructions: null` (`src/server/codex-app-server.ts:1083`) even though the protocol carries the field (`src/server/codex-app-server-protocol.ts:72`). Codex CLI itself reads `~/.codex/AGENTS.md` at startup, but the `codex app-server` JSON-RPC mode that Kanna integrates with does not — instructions must arrive on the wire as `developer_instructions`. Users today cannot inject persistent project guidance into Kanna chats without editing source; the only escape hatch is pasting into every chat. App settings already persist through `AppSettingsManager` (`src/server/app-settings.ts`) with watcher-backed reload, atomic write, and the patch path used by `SettingsPage` (`src/client/app/SettingsPage.tsx`) + `appSettingsStore` (`src/client/stores/appSettingsStore.ts`); subagent turns route through the same Claude/Codex paths via `buildClaudeSubagentStarter` and `CodexAppServerManager.startTurn`. Affected components: c3-116 settings-page (UI), c3-210 agent-coordinator (per-turn wiring for both providers + subagent), c3-211 codex-app-server (developer_instructions plumb). Two files are uncharted in the codemap (`c3x lookup` returns no matches) and this ADR closes the gap: `src/shared/kanna-system-prompt.ts` and `src/server/app-settings.ts`. The earlier draft of this ADR proposed a five-source surface (four inherited files + user snippets); this rewrite supersedes that scope.
+
+## Decision
+
+1. Add `globalPromptAppend: string` (default `""`, trimmed-empty treated as absent, hard cap 8000 chars) to `AppSettingsSnapshot` / `AppSettingsPatch` / `AppSettingsFile`. Normalize in `app-settings.ts` (trim trailing newlines, cap with warning), exposed through a new `AppSettingsManager.setGlobalPromptAppend(text)` method routed via the existing `appSettings/patch` WebSocket command.
+2. Extend `buildKannaSystemPromptAppend(subagents: Subagent[], opts?: { globalPromptAppend?: string })` in `src/shared/kanna-system-prompt.ts` to splice a `## Project instructions` block carrying the user text immediately after `KANNA_SYSTEM_PROMPT_BASE` and before the subagent roster. Empty / whitespace-only text emits nothing — byte-for-byte legacy output preserved.
+3. Plumb the same resolved string into both Claude entry points (`agent.ts` SDK path and PTY driver) and the Codex path. For Codex, extend `StartCodexTurnArgs` with `developerInstructions?: string` and replace the hardcoded `developer_instructions: null` with `args.developerInstructions?.trim() ? args.developerInstructions.trim() : null`. Subagents inherit by virtue of `subagent-provider-run.ts` calling the same builder + Codex starter — no separate field, no separate code path.
+4. `agent-coordinator` reads the snapshot once per turn (existing `AppSettingsManager.getSnapshot()`); live edits apply to the next turn without restart.
+5. UI: new "Global instructions" section in `SettingsPage` with a multi-line textarea bound to `appSettingsStore`, helper text "Appended to every Claude and Codex turn (main + subagents)", live char counter, save disabled above 8000.
+6. **Explicit non-goals (was in superseded draft):** no filesystem inheritance (`~/.claude/CLAUDE.md`, `~/.codex/AGENTS.md`, project `CLAUDE.md`, project `AGENTS.md` are NOT read); no write-back-to-disk editor; no user-snippet list; no per-project override; no per-snippet enable toggles. Reasons in Alternatives.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-116 | component | New textarea section in SettingsPage bound to existing appSettingsStore patch action | Foundational Flow: confirm preferences-store input row still holds; rule-zustand-store: setter reuses existing appSettingsStore, no new local store; ref-local-first-data: persisted under settings.json |
+| c3-210 | component | Reads globalPromptAppend per turn from settings snapshot; passes to both providers + subagent starters | ref-provider-adapter: both Claude and Codex receive equivalent injection so adapter normalization stays untouched; ref-tool-hydration: review confirms tool hydration unaffected (suffix string only) |
+| c3-211 | component | StartCodexTurnArgs extended with developerInstructions; turn/start payload sets developer_instructions per turn | ref-provider-adapter: adapter shape extended symmetrically with Claude path; rule-strong-typing: new typed field, no any |
+| c3-301 | component | Adopts src/shared/kanna-system-prompt.ts into codemap (currently uncharted) so future lookups resolve | Codemap update: c3x set c3-301 codemap-include 'src/shared/kanna-system-prompt.ts' |
+| c3-2 | container | Owns src/server/app-settings.ts which gains the new field; file currently uncharted | Codemap update: c3x set c3-2 codemap-include 'src/server/app-settings.ts' (or attach to an existing server component if owner prefers); update Responsibilities only if app-settings is split into its own component |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-provider-adapter | Global prompt must reach Claude (systemPrompt.append) AND Codex (developer_instructions) on identical contract so transcript UI never branches per provider | comply |
+| ref-local-first-data | New setting persists to ~/.kanna settings file via the existing AppSettingsManager atomic-write path | comply |
+| ref-zustand-store | UI bind uses the existing appSettingsStore patch action; no new local Zustand store | comply |
+| ref-strong-typing | New field crosses client↔server (patch envelope), server↔provider (turn args), and shared types — every boundary named | comply |
+| ref-event-sourcing | Cited by c3-210 which this ADR touches; review confirms the global prompt is configuration state in settings.json, not an event-sourced domain mutation, so the event log path is untouched | review |
+| ref-cqrs-read-models | Cited by c3-207 / c3-208 in adjacent paths; review confirms settings have no read-model projection, UI consumes the manager snapshot directly via the existing app-settings broadcast — pattern preserved | review |
+| ref-tool-hydration | Cited by c3-210 which this ADR touches; review confirms tool-call hydration is downstream of streamed transcript events and never reads the system-prompt suffix, so c3-303 normalization is out of path | review |
+| ref-colocated-bun-test | New .test.ts files sit next to changed source | comply |
+| ref-ws-subscription | Patch envelope reuses the existing appSettings/patch command; no new WS message kind | comply |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-strong-typing | globalPromptAppend flows across four typed boundaries (WS envelope, AppSettings types, Codex turn args, shared prompt builder) — each gets a concrete named type | comply |
+| rule-colocated-bun-test | New tests for normalizeAppSettings, buildKannaSystemPromptAppend, codex-app-server developer_instructions wiring, SettingsPage UI sit next to their source files | comply |
+| rule-zustand-store | UI state for the textarea is server-derived; writes use the existing appSettingsStore patch action — no new local Zustand store, server truth stays in useKannaState | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| Shared types | Add globalPromptAppend: string to AppSettingsSnapshot, AppSettingsPatch, AppSettingsFile in src/shared/types.ts and src/server/app-settings.ts; default "" | typed field declared in both shared and server modules |
+| Settings normalize | Add normalizeGlobalPromptAppend(value, warnings) — trim, cap 8000 chars, warn on overflow; wire into normalizeAppSettings, toFilePayload, toSnapshot, applyPatch, toComparablePayload | helper exported; default ""; warnings emitted on overflow |
+| Settings setter | Add AppSettingsManager.setGlobalPromptAppend(text) that calls writePatch({ globalPromptAppend: text }) | manager method present; reused by WS handler |
+| Prompt builder | Extend buildKannaSystemPromptAppend(subagents, opts?) with opts.globalPromptAppend; splice ## Project instructions block after BASE, before roster; trim and skip if blank | snapshot test confirms ordering; omitted opts = byte-identical legacy output |
+| Claude SDK wiring | src/server/agent.ts and src/server/subagent-provider-run.ts read appSettings.getSnapshot().globalPromptAppend and pass via opts to buildKannaSystemPromptAppend | both paths call same builder |
+| Claude PTY wiring | src/server/claude-pty/driver.ts and the subagent starter receive the builder output unchanged via --append-system-prompt | string passed through unmodified |
+| Codex args | Extend StartCodexTurnArgs with developerInstructions?: string in src/server/codex-app-server.ts; replace developer_instructions: null (line 1083) with args.developerInstructions?.trim() ? args.developerInstructions.trim() : null | grep developer_instructions: null returns 0 hits after change |
+| Codex caller | agent-coordinator Codex branch (agent.ts) and subagent Codex starter (subagent-provider-run.ts) pass settings value into startTurn | both main + subagent Codex paths fed |
+| UI field | New section in src/client/app/SettingsPage.tsx with Textarea primitive bound to appSettingsStore; helper text "Appended to every Claude and Codex turn (main + subagents)" with char counter (limit 8000); save disabled when over cap | snapshot test; appSettingsStore patch action exercised |
+| WS patch | Confirm existing appSettings/patch envelope accepts new field via existing generic AppSettingsPatch typing | src/shared/protocol.ts compiles without new variants |
+| Tests | app-settings.test.ts (normalize default, overflow warning, patch round-trip), kanna-system-prompt.test.ts (builder splices, empty parity, ordering), codex-app-server.test.ts (developer_instructions plumbed, null when blank), SettingsPage.test.tsx (textarea + char counter + save flow), subagent-provider-run.test.ts (subagent inheritance both providers) | bun test paths green |
+| Codemap | c3x set c3-301 codemap-include 'src/shared/kanna-system-prompt.ts'; c3x set c3-2 codemap-include 'src/server/app-settings.ts' (or component-level if owner splits app-settings) | c3x lookup returns owner for both files |
+| ADR Parent Delta | After implementation: confirm c3-116, c3-210, c3-211 contracts updated only if Components / Foundational Flow / Business Flow tables shifted; record no-delta evidence otherwise via c3x read --section | per-component c3x read diff |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| Codemap c3-301 | c3x set c3-301 codemap-include 'src/shared/kanna-system-prompt.ts' | c3x lookup src/shared/kanna-system-prompt.ts returns c3-301 |
+| Codemap c3-2 | c3x set c3-2 codemap-include 'src/server/app-settings.ts' | c3x lookup src/server/app-settings.ts returns c3-2 owner |
+| c3-116 settings-page | c3x write c3-116 --section 'Foundational Flow' to record the new global-instructions input row only if section actually changes; otherwise record no-delta in PR | c3x read c3-116 --section 'Foundational Flow' |
+| c3-211 codex-app-server | c3x write c3-211 --section 'Business Flow' to mention developer_instructions plumb on the primary path | c3x read c3-211 --section 'Business Flow' |
+| c3x check | Re-run after every mutation; must end with total ≥ 71 and issues empty | c3x check output |
+| N.A surfaces | No new c3x command, validator, schema row, or hint added — feature does not change the CLI contract | N.A - ADR adds product feature, not CLI surface |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| bun test src/shared/kanna-system-prompt.test.ts | Asserts (a) omitted opts → byte-identical legacy output, (b) non-empty globalPromptAppend → ## Project instructions block between BASE and roster, (c) whitespace-only treated as empty, (d) BASE remains first paragraph | green |
+| bun test src/server/app-settings.test.ts | Asserts normalize default, overflow warning + truncation at 8000, patch round-trip, watcher reload preserves field | green |
+| bun test src/server/codex-app-server.test.ts | Asserts turn/start payload carries developer_instructions: when set, null when blank, null when whitespace-only | green |
+| bun test src/client/app/SettingsPage.test.tsx | Asserts textarea renders, dispatches appSettingsStore patch action, char counter caps at 8000, save disabled when over | green |
+| bun test src/server/subagent-provider-run.test.ts | Asserts subagent turn (Claude and Codex) carries the global prompt | green |
+| bun run lint | --max-warnings=0 catches regressions; new types must not introduce any/unknown at boundaries | green |
+| c3x check | Validates docs / codemap match after edits | total ≥ 71, issues empty |
+| Manual smoke | Set textarea, send one Claude turn + one Codex turn; clear textarea, send turn; Codex turn/start payload shows null when blank, populated when set | recorded in PR description |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Five-source surface from the superseded draft (4 inherited files + user snippets) | User asked for the simple form today; file inheritance requires write-back, watcher, allowlist security check, project-root resolution per chat — none of which deliver value over a single textarea until evidence shows duplication pain. The bigger ADR remains a viable v2 if usage proves the limitation |
+| Per-provider fields (claudePromptAppend + codexPromptAppend) | One global prompt was the explicit request; two fields invite drift between providers and break ref-provider-adapter symmetry; subagent inheritance would need duplicate plumbing |
+| Per-project field stored on the Project type | App-global was explicitly chosen; per-project would require Project type extension, project-page settings UI, project ID propagation into prompt builder — out of scope |
+| Inline edit of KANNA_SYSTEM_PROMPT_BASE constant | Constant is the refusal-policy contract; user edits would override safety language; not user-editable by design |
+| New globalSystemPrompt/* WS message kinds | Existing appSettings/patch already covers the patch shape generically; new envelopes would duplicate validation and watcher wiring |
+| Append to Codex same buffer as Claude (no developer_instructions) | Codex JSON-RPC has a first-class developer_instructions field; using the wire-native path is more discoverable, future-proof against Codex behavior changes, and keeps the suffix builder Claude-specific |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| User pastes a 50KB prompt — blows past context budget or trips API limits | Hard cap 8000 chars in normalizeGlobalPromptAppend; UI shows live char count + over-limit error before save; save disabled above cap | normalize test asserts truncation + warning; UI test asserts counter + disabled save above cap |
+| User pastes a malicious "ignore previous instructions" override that flips refusal policy | KANNA_SYSTEM_PROMPT_BASE ships first; user text appears in a clearly-delimited ## Project instructions section the model can scope; matches Anthropic guidance for user-authored sections. No security guarantee for self-targeting jailbreaks since user operates on their own codebase by design | snapshot test confirms BASE precedes user text; documented in builder JSDoc |
+| Codex developer_instructions semantics differ subtly from Claude systemPrompt.append (Codex may weight differently) | Document tradeoff in kanna-system-prompt.ts JSDoc; ship same string to both; codex-app-server test asserts wire payload | codex-app-server test green; jsdoc present |
+| Subagent inheritance surprises a user who wanted clean subagent prompts | Settings textarea help text states "Applies to main and subagent turns of both providers"; subagent UI unchanged so per-subagent overrides remain available via existing subagent systemPrompt field | UI snapshot test |
+| Race: watcher reloads settings mid-turn — turn uses stale value | agent-coordinator already reads snapshot once per turn at start; live edits apply to next turn (documented behavior) | unit test ensures getSnapshot() called once per turn start |
+| Codemap gap means future c3x lookup on changed files still misses | This ADR schedules c3x set codemap-include for both uncharted files in Underlay C3 Changes | c3x lookup for both files returns owner after work |
+| Users expect ~/.claude/CLAUDE.md inheritance based on existing CLI behavior and are surprised when Kanna ignores it | Settings section copy explicitly says "Kanna does not read CLAUDE.md or AGENTS.md from disk — paste your global instructions here"; future v2 (the superseded draft) can layer file inheritance on top | copy review during UI implementation |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun test src/shared/kanna-system-prompt.test.ts | green |
+| bun test src/server/app-settings.test.ts | green |
+| bun test src/server/codex-app-server.test.ts | green |
+| bun test src/server/subagent-provider-run.test.ts | green |
+| bun test src/client/app/SettingsPage.test.tsx | green |
+| bun test (full suite) | green |
+| bun run lint | 0 errors, warnings ≤ current ratchet cap |
+| c3x check after each mutation | total ≥ 71, issues empty |
+| c3x lookup src/shared/kanna-system-prompt.ts | returns c3-301 owner |
+| c3x lookup src/server/app-settings.ts | returns c3-2 owner |
+| Manual Claude turn with text set | suffix contains the user text under ## Project instructions; observable via temporary debug log or transcript inspection |
+| Manual Codex turn with text set | turn/start payload carries developer_instructions: ; observable via JSON-RPC log |
+| Manual turn with text cleared | Codex turn/start shows developer_instructions: null; Claude suffix carries BASE only |
diff --git a/.c3/adr/adr-20260521-c3-docs-codemap-sync.md b/.c3/adr/adr-20260521-c3-docs-codemap-sync.md
new file mode 100644
index 000000000..4204a26c4
--- /dev/null
+++ b/.c3/adr/adr-20260521-c3-docs-codemap-sync.md
@@ -0,0 +1,186 @@
+---
+id: adr-20260521-c3-docs-codemap-sync
+c3-seal: f235da4233d63abe8c360928298c3a3ea9115b3e44b644ddc647321e22380f4f
+title: c3-docs-codemap-sync
+type: adr
+goal: |-
+ Bring the `.c3/` topology back into agreement with the current `src/` tree.
+ Add two missing server feature components (`c3-226 kanna-mcp-host` and
+ `c3-227 auto-continue`) and extend code-map patterns on existing client,
+ server, and shared components so `c3x lookup` resolves every shipping
+ source file. Establish `_exclude` patterns for client testing helpers
+ that should not factor into coverage.
+status: implemented
+date: "2026-05-21"
+---
+
+# c3-docs-codemap-sync
+
+## Goal
+
+Bring the `.c3/` topology back into agreement with the current `src/` tree.
+Add two missing server feature components (`c3-226 kanna-mcp-host` and
+`c3-227 auto-continue`) and extend code-map patterns on existing client,
+server, and shared components so `c3x lookup` resolves every shipping
+source file. Establish `_exclude` patterns for client testing helpers
+that should not factor into coverage.
+
+## Context
+
+Audit on 2026-05-21 (`c3x check` + per-file `c3x lookup`) found ~40
+uncharted source files. The largest gaps are:
+
+- `src/server/kanna-mcp.ts`, `src/server/kanna-mcp-http.ts`,
+`src/server/kanna-mcp-tools/**` (24 files), `src/server/tool-callback.ts`,
+`src/server/permission-gate.ts` — the entire MCP host surface that
+`CLAUDE.md` already documents under "Kanna-MCP Built-in Shims" and
+"Tool Callback Feature Flag" has no owning component.
+- `src/server/auto-continue/**` (11 files: limit-detector, schedule-manager,
+auth-error-detector, read-model, events, plus tests) has no owning
+component and is not described in `CLAUDE.md`.
+- `src/client/app/AppBootstrap.tsx`, `src/client/components/editor-icons.tsx`,
+`src/client/components/open-external-menu.tsx`,
+`src/client/components/settings/PushNotificationsSection*` —
+unowned client surfaces.
+- `src/shared/analytics.ts`, `mask-oauth-key.*`, `mention-pattern.ts`,
+`permission-policy.*`, `projectFileRelocation.*`, `projectFileUrl.*`,
+`types.test.ts`, `kanna-system-prompt.test.ts` — shared utilities
+not mapped to any of `c3-301..c3-306`.
+
+Constraint: `.c3/` is CLI-only (HARD RULE). All edits go through
+`c3x add` / `c3x set` / `c3x write`. ADRs cannot be created as
+`implemented`; transition `proposed → accepted → implemented` after the
+sync work lands.
+
+## Decision
+
+Treat the audit-surfaced drift as a single, atomic doc-sync change:
+
+1. Create `c3-226 kanna-mcp-host` (feature) under `c3-2 Server`, owning
+the MCP host runtime + 8 built-in shims + durable approval protocol
+(`tool-callback.ts`, `permission-gate.ts`). Cite `ref-tool-hydration`,
+`ref-strong-typing`, `ref-local-first-data`, `rule-strong-typing`,
+`rule-colocated-bun-test`.
+2. Create `c3-227 auto-continue` (feature) under `c3-2 Server`, owning the
+provider rate-limit / auth-error detection + scheduled resume + read
+model under `src/server/auto-continue/**`. Cite `ref-event-sourcing`,
+`ref-cqrs-read-models`, `ref-strong-typing`, `rule-colocated-bun-test`,
+`rule-strong-typing`.
+3. Append `c3-2 Components` table rows for `c3-226` and `c3-227`.
+4. Extend code-map patterns on existing components:
+`c3-110 app-shell` += `src/client/app/AppBootstrap.tsx`
+
+`c3-116 settings-page` += `src/client/components/settings/**/*.tsx`
+
+`c3-115 chat-ui-chrome` += `src/client/components/open-external-menu.tsx`
+
+`c3-103 ui-primitives` += `src/client/components/editor-icons.tsx`
+
+`c3-301 types` += `src/shared/kanna-system-prompt.test.ts`,
+`src/shared/types.test.ts`, `src/shared/mask-oauth-key.{ts,test.ts}`,
+`src/shared/mention-pattern.ts`, `src/shared/permission-policy.{ts,test.ts}`,
+`src/shared/projectFileRelocation.{ts,test.ts}`,
+`src/shared/projectFileUrl.{ts,test.ts}`, `src/shared/analytics.ts`
+
+1. Add `_exclude` for `src/client/lib/testing/**` (test plumbing, not
+feature code) — codemap append with `_exclude` prefix per c3x convention.
+2. Run `c3x check` until clean; mark ADR `accepted` then `implemented`.
+
+This is preferred over piecemeal ADRs because every drift item shares a
+single root cause (audit catch-up after MCP host + auto-continue features
+shipped without doc updates), and one ADR keeps the cascade gate (Phase 3a)
+simple: one parent-delta entry per affected container, one verification pass.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-2 | container | Two new feature components join ## Components; Responsibilities row added for MCP host + auto-continue | Update Components table + Responsibilities |
+| c3-110 | component | code-map extension adds AppBootstrap.tsx and surrounding shell file | Frontmatter codemap append only; body unchanged |
+| c3-103 | component | code-map extension adds editor-icons.tsx UI primitive | Frontmatter codemap append only; body unchanged |
+| c3-115 | component | code-map extension adds open-external-menu.tsx chrome surface | Frontmatter codemap append only; body unchanged |
+| c3-116 | component | code-map extension adds settings/PushNotificationsSection panel | Frontmatter codemap append only; body unchanged |
+| c3-301 | component | code-map extension absorbs shared utilities (kanna-system-prompt.test, mask-oauth-key, mention-pattern, permission-policy, projectFile*, types.test, analytics) that all live at the shared-type boundary | Frontmatter codemap append only; body unchanged |
+| N.A - new components c3-226 + c3-227 are created by this same ADR; they cannot be listed as pre-existing affected entities, see Work Breakdown | N.A - reason above | N.A - reason above | N.A - reason above |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-tool-hydration | c3-226 owns MCP-side normalization of tool calls before they hit the agent loop | comply |
+| ref-local-first-data | MCP shims and tool-callback persist pending requests under ~/.kanna/data, must stay local-first | comply |
+| ref-event-sourcing | c3-227 schedules retries via event log (auto_continue_scheduled / triggered events) and persists state through event-store | comply |
+| ref-cqrs-read-models | c3-227 derives its current schedule view from event replay | comply |
+| ref-strong-typing | New MCP tool surface + auto-continue read-model cross client↔server boundary; need named types | comply |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-strong-typing | All new MCP shim args/results and auto-continue events cross WebSocket + JSONL boundaries | comply |
+| rule-colocated-bun-test | Every new component already has colocated .test.ts files; documentation must keep that fact mapped | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| Create c3-226 | c3x add component kanna-mcp-host --container c3-2 --feature --goal ... --file body.md | .c3/c3-2-server/c3-226-kanna-mcp-host.md exists; c3x list shows it |
+| Wire c3-226 refs/rules | c3x wire c3-226 ref-tool-hydration ref-strong-typing ref-local-first-data rule-strong-typing rule-colocated-bun-test | c3x read c3-226 shows uses: line |
+| Create c3-227 | c3x add component auto-continue --container c3-2 --feature --goal ... --file body.md | .c3/c3-2-server/c3-227-auto-continue.md exists |
+| Wire c3-227 refs/rules | c3x wire c3-227 ref-event-sourcing ref-cqrs-read-models ref-strong-typing rule-strong-typing rule-colocated-bun-test | c3x read c3-227 shows uses: line |
+| Update c3-2 Components | c3x write c3-2 --section Components --file components.md (regenerate table including 226+227) | c3x read c3-2 --section Components shows both rows |
+| Extend codemaps | c3x set codemap "" --append for c3-103, c3-110, c3-115, c3-116, c3-301 | c3x lookup resolves |
+| Add exclude | c3x set c3-1 codemap "_exclude:src/client/lib/testing/**" --append (or owning component) | c3x check no longer counts testing helpers |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| Component files | New files .c3/c3-2-server/c3-226-kanna-mcp-host.md and .c3/c3-2-server/c3-227-auto-continue.md written via c3x add | ls .c3/c3-2-server/ lists both |
+| Container body | c3-2 README updated via c3x write c3-2 --section Components | c3x read c3-2 --section Components includes both new rows |
+| Frontmatter codemap | c3x set codemap "..." --append on c3-103, c3-110, c3-115, c3-116, c3-301, c3-226, c3-227 | c3x lookup resolves the previously uncharted paths |
+| Cache | .c3/c3.db cache reseals via the same CLI calls | c3x check exits 0 |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| c3x check | Reports coverage gap if any of the newly-mapped files become uncharted again | c3x check exits 0 post-sync |
+| c3x lookup | Resolves every src/server/kanna-mcp*, kanna-mcp-tools/**, tool-callback.ts, permission-gate.ts to c3-226 | per-file c3x lookup returns the component |
+| c3x lookup | Resolves src/server/auto-continue/** to c3-227 | per-file c3x lookup returns the component |
+| CI bun test | Existing colocated tests still run unchanged | bun test src/server/auto-continue/ green |
+| CI bun run lint | No code edits in this PR, so lint must still pass | bun run lint green |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Single mega-component "server-other" absorbing all unowned files | Hides two distinct features (MCP host vs auto-continue) behind one node; defeats the audit signal that produced this ADR |
+| Two separate ADRs (one per new component, one per codemap patches) | Triples ADR overhead for a single doc-sync moment with one root cause; cascade gate is simpler with one ADR |
+| Map every shared utility into a new c3-307 file-relocation component | Premature; current shared utilities are small enough to live under c3-301 types until a cohesive boundary emerges |
+| Leave MCP host unowned because tool-callback.ts is already documented in CLAUDE.md | CLAUDE.md is not the c3 source of truth; lookups against the file return nothing today |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Over-claiming scope on c3-226 (includes tool-callback.ts which is general-purpose approval, not MCP-specific) | Document Purpose section to clarify approval protocol is the MCP-facing surface; if a non-MCP caller later emerges, split | c3x read c3-226 Purpose mentions approval-protocol scope |
+| Component-schema rejection on creation due to thin sections | Author full body per c3x schema component before c3x add | c3x add exits 0 |
+| Code-map glob explosion masking future drift | Keep glob patterns narrow (src/server/auto-continue/** not src/server/auto-**) | c3x lookup on adjacent paths still returns "no match" outside the intended scope |
+| Cache reseal drift on local .c3/c3.db after batch edits | Run c3x repair if c3x check reports seal drift | c3x check exits 0 |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| c3x check | exits 0, total entity count increases by 2 (components) + 1 (this ADR), issues: empty |
+| c3x lookup src/server/kanna-mcp.ts | resolves to c3-226 |
+| c3x lookup src/server/kanna-mcp-tools/bash.ts | resolves to c3-226 |
+| c3x lookup src/server/tool-callback.ts | resolves to c3-226 |
+| c3x lookup src/server/auto-continue/schedule-manager.ts | resolves to c3-227 |
+| c3x lookup src/client/app/AppBootstrap.tsx | resolves to c3-110 |
+| c3x lookup src/client/components/settings/PushNotificationsSection.tsx | resolves to c3-116 |
+| c3x lookup src/shared/projectFileUrl.ts | resolves to c3-301 |
+| bun test | exits 0 (no code touched) |
+| bun run lint | exits 0 |
+| PR CI | All checks green on cuongtranba/kanna |
diff --git a/.c3/adr/adr-20260521-mask-oauth-key-in-account-info.md b/.c3/adr/adr-20260521-mask-oauth-key-in-account-info.md
new file mode 100644
index 000000000..4be39f597
--- /dev/null
+++ b/.c3/adr/adr-20260521-mask-oauth-key-in-account-info.md
@@ -0,0 +1,109 @@
+---
+id: adr-20260521-mask-oauth-key-in-account-info
+c3-seal: 9884d3869b5f942aa2623fe3e503a1bead2a7368947a9158f4efdfb729232097
+title: mask-oauth-key-in-account-info
+type: adr
+goal: Replace the OAuth-pool token label with a masked OAuth key (e.g. `sk-ant-oat01-...XXXX`) as the primary identifier shown in the chat `AccountInfoMessage`. The label remains available in the expanded panel as "Organization". The masked key surfaces in both the collapsed row and the expanded "OAuth key" code block in place of the label echo that ships today (#254). Full token value is never serialized to the JSONL event store or rendered in any UI surface.
+status: proposed
+date: "2026-05-21"
+---
+
+## Goal
+
+Replace the OAuth-pool token label with a masked OAuth key (e.g. `sk-ant-oat01-...XXXX`) as the primary identifier shown in the chat `AccountInfoMessage`. The label remains available in the expanded panel as "Organization". The masked key surfaces in both the collapsed row and the expanded "OAuth key" code block in place of the label echo that ships today (#254). Full token value is never serialized to the JSONL event store or rendered in any UI surface.
+
+## Context
+
+`AccountInfoMessage.tsx` reads `organization` (= OAuth token label from `OAuthTokenPool`) as `primaryKey` and shows the same label in the expanded "OAuth key" `MetaCodeBlock`. Operators who run multiple pool tokens with non-unique labels cannot tell which underlying credential served a given turn from chat alone. The recent #254 work surfaced the field but still echoed the label.
+
+`AccountInfo` lives in `src/shared/types.ts` and crosses the WS boundary as part of `account_info` transcript entries persisted to the JSONL event log. Both the SDK driver (`q.accountInfo()`) and the PTY driver (`deriveAccountInfoFromLabel`) feed the same shape. The actual `OAuthTokenEntry.token` value is held in-process by `OAuthTokenPool` and is never persisted today; the design must keep it that way — only a non-reversible mask of the key is appended to the event log.
+
+## Decision
+
+Add `oauthKeyMasked?: string` to `AccountInfo`. Compute it in `AgentCoordinator` at the point a turn is started with a pool-picked token, from `picked.token` via a new shared `maskOauthKey(token)` helper that returns `...` for tokens of length ≥ 20 and `***` otherwise. Pass `oauthKeyMasked` into the PTY driver alongside `oauthLabel`; `deriveAccountInfoFromLabel` becomes `deriveAccountInfoFromOauth({ label, oauthKeyMasked })`. For the SDK driver, augment the `accountInfo` returned by `q.accountInfo()` with `oauthKeyMasked` before appending the event. The renderer prefers `oauthKeyMasked` over `organization` / `email` as the primary identifier and the expanded "OAuth key" block; label moves to a dedicated "Organization" row regardless of equality with `primaryKey`. No raw token ever leaves `AgentCoordinator`.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-301 | component | New field oauthKeyMasked on AccountInfo interface — crosses client↔server WS boundary. | rule-strong-typing |
+| c3-210 | component | Masks picked.token and augments accountInfo before appending the account_info event for both providers. | rule-colocated-bun-test, rule-strong-typing |
+| c3-225 | component | StartClaudeSessionPtyArgs gains oauthKeyMasked; deriveAccountInfoFromLabel renamed / rewritten to read both label and masked key. | rule-colocated-bun-test, rule-strong-typing |
+| c3-114 | component | AccountInfoMessage.tsx uses oauthKeyMasked as primary identifier; "Organization" row always rendered when label present. | rule-strong-typing |
+| c3-224 | component | No schema change; picked.token consumed by the new masker. Read of OAuthTokenEntry.token is already in-coordinator. | N.A - read-only consumer |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-strong-typing | New optional field on a shared boundary type; mask helper return shape must be string, never any. | comply |
+| ref-local-first-data | Masked key persists to local JSONL event log under ~/.kanna/data; raw token must not. | comply |
+| ref-colocated-bun-test | New unit tests for the masker and for the augmentation path live alongside their source files. | comply |
+| ref-event-sourcing | account_info entries are appended to the JSONL log and replayed; new field must survive replay losslessly. | comply |
+| ref-provider-adapter | SDK and PTY paths must produce identical AccountInfo shape for the same pool token. | comply |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-strong-typing | New field added to a cross-boundary interface; no any. | comply |
+| rule-colocated-bun-test | New mask-oauth-key.test.ts next to mask-oauth-key.ts; agent + driver tests extend existing *.test.ts siblings. | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| Mask helper | Add src/shared/mask-oauth-key.ts exporting maskOauthKey(token: string): string returning ... for length ≥ 20, otherwise ***. | new file + colocated test |
+| Shared type | Add oauthKeyMasked?: string to AccountInfo in src/shared/types.ts. | diff on types.ts |
+| Agent coordinator | At both startTurn and runSubagent sites that hold picked, compute oauthKeyMasked once, pass into driver args, and augment SDK accountInfo before appendMessage of the account_info event. | diff on src/server/agent.ts lines 1525-1535, 1980-1998, 2130-2140 |
+| PTY driver | Add oauthKeyMasked?: string to StartClaudeSessionPtyArgs; rewrite deriveAccountInfoFromLabel as deriveAccountInfoFromOauth({ label, oauthKeyMasked }) returning { organization?, oauthKeyMasked?, tokenSource: "kanna-oauth-pool" } when either field is present; thread arg through cachedAccountInfo seed. | diff on src/server/claude-pty/driver.ts lines 77-89, 345 |
+| Renderer | AccountInfoMessage.tsx: primaryKey = oauthKeyMasked ?? organization ?? email ?? "Unknown account"; "Organization" row in expanded panel renders whenever organization is set (not only when organization !== primaryKey). | diff on AccountInfoMessage.tsx |
+| Tests | New src/shared/mask-oauth-key.test.ts; extend src/server/agent.test.ts and src/server/claude-pty/driver.test.ts for the augmented AccountInfo. No raw-token leak assertion in the agent test (assert masked output only). | bun test src/shared/mask-oauth-key.test.ts src/server/agent.test.ts src/server/claude-pty/driver.test.ts |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| codemap | None — affected files already under existing component patterns. | c3x check clean after edits |
+| component bodies | None — responsibilities unchanged. | c3x list topology unchanged |
+| ADR | This ADR added under .c3/adr/adr-20260521-mask-oauth-key-in-account-info.md. | c3x list --include-adr shows the ADR |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| src/shared/mask-oauth-key.test.ts | Asserts mask format and that no input substring of length > 4 leaks past the suffix. | bun test src/shared/mask-oauth-key.test.ts |
+| src/server/agent.test.ts | Asserts account_info event appended after pool pick carries oauthKeyMasked and never carries picked.token. | bun test src/server/agent.test.ts |
+| src/server/claude-pty/driver.test.ts | Asserts getAccountInfo() returns oauthKeyMasked when seeded from oauthKeyMasked arg. | bun test src/server/claude-pty/driver.test.ts |
+| TypeScript build | Optional field on AccountInfo flows through hydrated transcript type into the renderer prop. | bun run lint + bun run build |
+| c3x check | No drift after ADR + ref/rule wiring. | bash .../c3x.sh check |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Show full OAuth token in chat | User explicitly chose masking; full token in JSONL event log is a credential-leak vector. |
+| Show only token id (OAuthTokenEntry.id) | The id is internal; the masked key prefix/suffix lets the operator cross-reference settings UI which displays the same shape. |
+| Keep label as primary, add masked key only in expanded view | User asked to replace name in primary view; partial change keeps the ambiguity for collapsed display. |
+| Compute mask in renderer from a new oauthKey field | Would require serializing full token through WS + JSONL — exactly the leak surface this ADR avoids. |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Raw token accidentally serialized | Mask helper is the only path to oauthKeyMasked; coordinator never reads picked.token outside the masker call site. | Unit test in agent.test.ts asserts appended event contains no substring of picked.token beyond the 4-char suffix. |
+| SDK driver accountInfo shape regression | Augmentation is additive; existing fields unchanged. | bun test src/server/agent.test.ts |
+| PTY parity-matrix drift | parity-matrix.test.ts does not assert on oauthKeyMasked (SDK path has it, CLI stream never emits it); augmentation happens in coordinator, not driver stream. | bun test src/server/claude-pty/parity-matrix.test.ts |
+| Short / malformed tokens (length < 20) | Helper returns *** rather than leaking prefix. | Unit test case in mask-oauth-key.test.ts |
+| Existing replayed account_info events from JSONL lack the field | Field is optional; renderer falls back to organization/email. | Manual replay smoke against an existing chat (no migration needed). |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun test src/shared/mask-oauth-key.test.ts | passes |
+| bun test src/server/agent.test.ts src/server/claude-pty/driver.test.ts | passes |
+| bun test (whole suite) | passes |
+| bun run lint | 0 errors, warnings ≤ current cap |
+| bash /bin/c3x.sh check | clean |
+| Manual: start a chat under PTY with an OAuth-pool token, confirm primary row shows sk-ant-...XXXX and expanded "Organization" row shows the label. | matches |
diff --git a/.c3/adr/adr-20260521-notice-banner-extract.md b/.c3/adr/adr-20260521-notice-banner-extract.md
new file mode 100644
index 000000000..c7c0b2ff9
--- /dev/null
+++ b/.c3/adr/adr-20260521-notice-banner-extract.md
@@ -0,0 +1,96 @@
+---
+id: adr-20260521-notice-banner-extract
+c3-seal: 1fb7be75fe8e25ef33f49b300823d1275554ebecb1d9cf488a7ad97fda365e81
+title: notice-banner-extract
+type: adr
+goal: Replace the inline PTY-driver banner in `src/client/app/App.tsx` with a generic, variant-driven `NoticeBanner` primitive under `src/client/components/ui/`. The primitive must accept a `variant` (`warning | info | error | success`) and arbitrary message content, so future top-of-shell notices (new Kanna update available, GitHub CI status failure, OAuth-pool exhausted, etc.) can be added without re-deriving banner markup.
+status: proposed
+date: "2026-05-21"
+---
+
+# Extract NoticeBanner UI primitive
+
+## Goal
+
+Replace the inline PTY-driver banner in `src/client/app/App.tsx` with a generic, variant-driven `NoticeBanner` primitive under `src/client/components/ui/`. The primitive must accept a `variant` (`warning | info | error | success`) and arbitrary message content, so future top-of-shell notices (new Kanna update available, GitHub CI status failure, OAuth-pool exhausted, etc.) can be added without re-deriving banner markup.
+
+## Context
+
+App.tsx currently inlines a 15-line JSX block (lines 437–452) for the "PTY driver active" notice. The block hard-codes the dot color (`var(--warning)`), background tint (`bg-warning/[0.06]`), and layout classes. There is no reusable banner primitive in `src/client/components/ui/`. The shell will soon need to surface additional notices (update detector via `c3-219 update-manager`, CI status, OAuth alerts). Copy-pasting the inline block per notice would diverge tone, spacing, and a11y attrs and would scatter the rule-of-thumb (one notice strip at the top of the shell). Topology affected: `c3-103 ui-primitives` gains a new primitive; `c3-110 app-shell` switches from inline JSX to composition.
+
+## Decision
+
+Add `NoticeBanner` to `src/client/components/ui/notice-banner.tsx`. Props: `variant: "warning" | "info" | "error" | "success"`, `children: ReactNode`, optional `className`, optional `dot?: boolean` (default true). The primitive renders a flex strip with role="status", a tone-colored dot, and the children — preserving the current PTY-banner layout. Variant maps to a `--` CSS variable for the dot and a `bg-/[0.06]` background tint via a single lookup table. `App.tsx` composes the primitive: `PTY driver active. Tools run under the claude CLI ... `. This fits c3-103 (low-level brand-aligned primitive) and keeps c3-110 in composition mode, matching the existing `` / `` pattern.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-103 | component | Gains new primitive NoticeBanner under src/client/components/ui/notice-banner.tsx | Derived Materials row stays "src/client/components/ui/**/*.tsx" — no signature change |
+| c3-110 | component | Inline PTY banner removed; composes NoticeBanner instead | Contract / Derived Materials unchanged; App.tsx still owns the conditional render |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-strong-typing | Variant union must be a discriminated string literal type, no any for ReactNode children spread | comply |
+| ref-cqrs-read-models | Cited by c3-110 (consumer of NoticeBanner); banner is presentational only and does not read events, but c3-110's CQRS contract is unaffected | review (no change required) |
+| ref-ws-subscription | Cited by c3-110 (consumer); NoticeBanner has no WS coupling so the single-socket subscription contract is preserved | review (no change required) |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-strong-typing | Variant + props typed concretely; no any for HTML attribute spreading | comply |
+| rule-colocated-bun-test | New primitive must ship with notice-banner.test.tsx next to it | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| Primitive | Add src/client/components/ui/notice-banner.tsx with typed NoticeBannerProps and variant tone table | src/client/components/ui/notice-banner.tsx |
+| Test | Add src/client/components/ui/notice-banner.test.tsx covering each variant and role="status" attr | src/client/components/ui/notice-banner.test.tsx |
+| Wire | Replace PTY-banner JSX block in App.tsx (lines 437–452) with ... | src/client/app/App.tsx |
+| Lint/test | bun run lint and bun test src/client/components/ui/notice-banner.test.tsx must pass | CI |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| N.A - no C3 CLI / validator / schema change | N.A - this ADR adds a UI primitive only; no .c3/ CLI surface modified | N.A - no underlay surface touched |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| TypeScript compiler | bunx tsc --noEmit catches any consumer that passes an unknown variant | tsc run in CI |
+| ESLint (--max-warnings=0) | Rejects any / hook misuse in the new primitive | bun run lint in CI |
+| Bun test | notice-banner.test.tsx asserts each variant renders the right tone class + role="status" | bun test src/client/components/ui |
+| c3x check | Verifies c3-103 Derived Materials glob still matches the new file path | c3x check --only c3-103 |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Keep banner inline in App.tsx; copy-paste for each future notice | Defeats the user's stated goal of one place to add notices; tone drift inevitable |
+| Build a full notification-stack component (toast + banner + modal) | Out of scope for this change; only the top-of-shell banner is required now; YAGNI per project rules |
+| Put banner under src/client/components/chat-ui/ | chat-ui (c3-115) is scoped to composer/chrome; banner is shell-wide, fits ui-primitives (c3-103) |
+| Use shadcn Alert directly without a wrapper | shadcn Alert is not present in this repo's primitive set today; adding our own narrower primitive matches existing kbd/tooltip pattern |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Variant tone class typo silently falls back to no background | Map variants via const record; tsc proves exhaustiveness | bunx tsc --noEmit |
+| Banner breaks a11y if role/aria attrs dropped | Hard-code role="status"; test asserts presence | notice-banner.test.tsx |
+| Future consumers nest interactive content; banner role="status" announces children | Document children type as inline message text only; recommend separate Alert primitive for actionable notices | ADR follow-up tracked here |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun run lint | 0 errors, warnings <= existing cap |
+| bun test src/client/components/ui/notice-banner.test.tsx | All variant tests pass |
+| bunx tsc --noEmit | Type-clean |
+| Manual smoke: toggle KANNA_CLAUDE_DRIVER=pty, load app, confirm banner renders with warning tone | Banner visible above shell content; same look as before |
+| C3X_MODE=agent c3x check --only c3-103,c3-110 | Pass |
diff --git a/.c3/adr/adr-20260522-api-error-entry-kind.md b/.c3/adr/adr-20260522-api-error-entry-kind.md
new file mode 100644
index 000000000..b5d9999a2
--- /dev/null
+++ b/.c3/adr/adr-20260522-api-error-entry-kind.md
@@ -0,0 +1,98 @@
+---
+id: adr-20260522-api-error-entry-kind
+c3-seal: a34ec349ae4dead4bafda81507427b386058d997dbdfd1a56f033e868fbbbfd3
+title: api-error-entry-kind
+type: adr
+goal: Introduce a first-class transcript entry kind `api_error` so synthetic Claude CLI API-error assistant messages (e.g. `529 Overloaded`) render with dedicated error styling, status badge, and optional request id, instead of being rendered as plain assistant text. The decision authorizes a new discriminant in the `TranscriptEntry` union — not just a flag on `AssistantTextEntry`.
+status: proposed
+date: "2026-05-22"
+---
+
+# adr-20260522-api-error-entry-kind
+
+## Goal
+
+Introduce a first-class transcript entry kind `api_error` so synthetic Claude CLI API-error assistant messages (e.g. `529 Overloaded`) render with dedicated error styling, status badge, and optional request id, instead of being rendered as plain assistant text. The decision authorizes a new discriminant in the `TranscriptEntry` union — not just a flag on `AssistantTextEntry`.
+
+## Context
+
+Today the Claude CLI writes synthetic assistant messages with `model:""`, `isApiErrorMessage:true`, `apiErrorStatus:` and human-readable error text. `normalizeClaudeStreamMessage` in `src/server/agent.ts` ignores these flags and emits a normal `assistant_text` entry. `KannaTranscript.tsx` routes that through `TextMessage`, so a 529 looks indistinguishable from a model reply. Found via session `d4386ad9-005c-413f-947a-9150c3f48185`. The transcript event union, hydration types, history primer, snapshot, subagent-orchestrator scans, and the renderer switch all branch on `entry.kind`, so a new kind is the lowest-drift carrier for retry metadata and analytics later.
+
+## Decision
+
+Add a new `ApiErrorEntry { kind:"api_error", status:number, requestId?:string, text:string }` to the `TranscriptEntry` union in `src/shared/types.ts`. Server normalize emits this kind when the Claude CLI synthetic API-error markers are present (status parsed from `apiErrorStatus`, fallback regex on text). Hydration, history primer, snapshot, and subagent-orchestrator mention scans treat the kind as a non-text, non-tool entry (no mention parsing, no tool grouping). Client adds an `ApiErrorMessage` component and a dedicated `case "api_error"` in `KannaTranscript.tsx`. Chosen over option A (annotate `AssistantTextEntry`) because the kind is semantically distinct, easier to carry retry metadata, and the user explicitly accepted the larger blast radius.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-301 | component | Adds new discriminant in TranscriptEntry union | ref-strong-typing: named type at shared boundary |
+| c3-210 | component | normalizeClaudeStreamMessage emits new kind | ref-provider-adapter: provider-agnostic transcript |
+| c3-206 | component | JSONL replay must accept new kind in hydration paths | ref-event-sourcing: append-only, replay-safe |
+| c3-114 | component | New per-kind component + exhaustive switch | ref-tool-hydration, ref-strong-typing |
+| c3-113 | component | KannaTranscript switch gains case | ref-strong-typing |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-strong-typing | New union member must be named, no any at boundary | comply |
+| ref-event-sourcing | New kind must replay cleanly from existing JSONL | comply |
+| ref-provider-adapter | API errors normalized to provider-agnostic kind | comply |
+| ref-tool-hydration | Confirm api_error does not pass through tool hydration | comply |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-strong-typing | All boundary values typed; new union member declared with named fields | comply |
+| rule-colocated-bun-test | New ApiErrorMessage component ships colocated .test.tsx | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| shared types | Add ApiErrorEntry to TranscriptEntry union | src/shared/types.ts |
+| server normalize | Detect synthetic + isApiErrorMessage in normalizeClaudeStreamMessage; emit api_error | src/server/agent.ts |
+| event-store / hydration | Exhaustive switches accept api_error (no special replay logic) | src/server/event-store*.ts, hydration types |
+| history primer / snapshot | Pass-through; no mention scan, no tool grouping | src/server/history-primer*.ts, snapshot*.ts |
+| subagent-orchestrator | Mention parsers skip api_error entries | src/server/subagent*.ts |
+| client renderer | New ApiErrorMessage component + case in KannaTranscript | src/client/components/messages/ApiErrorMessage.tsx, src/client/components/KannaTranscript.tsx |
+| tests | Normalize unit test, renderer test fixture | colocated *.test.ts(x) |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| N.A - no c3x CLI / validator surface affected | N.A | N.A |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| TypeScript exhaustive switches | Compiler flags missing api_error case in any switch | tsc / bun run lint |
+| ApiErrorMessage.test.tsx | Snapshot test ensures status + text render | bun test |
+| normalize unit test | Asserts synthetic message → api_error entry | bun test src/server/agent.test.ts |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Option A: annotate AssistantTextEntry with optional apiError field | Mixes error semantics into text kind; harder to carry retry metadata; user explicitly chose B |
+| Replace synthetic message with toast / banner outside transcript | Loses chronological context; error vanishes on reload; breaks event-sourcing invariant |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Missing kind handler in some exhaustive switch | TypeScript compile-time exhaustiveness in shared union | bun run lint |
+| Old JSONL events not re-emitted as api_error | Pre-existing 529 entries stay as assistant_text; only new sessions get the new kind | manual: load old session, confirm no crash |
+| Subagent mention parser scans new kind | Update parser allowlist to skip api_error | unit test on parser |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun run lint | exit 0, no new warnings |
+| bun test src/server/agent.test.ts src/client/components/messages/ApiErrorMessage.test.tsx | pass |
+| Manual: load session d4386ad9-005c-413f-947a-9150c3f48185 | 529 entry renders as ApiErrorMessage with red badge |
diff --git a/.c3/adr/adr-20260522-oauth-token-share-cap.md b/.c3/adr/adr-20260522-oauth-token-share-cap.md
new file mode 100644
index 000000000..111fe469d
--- /dev/null
+++ b/.c3/adr/adr-20260522-oauth-token-share-cap.md
@@ -0,0 +1,125 @@
+---
+id: adr-20260522-oauth-token-share-cap
+c3-seal: b7607344a6b49cdb2bf6e7023304bbc1730229fae3e0f5eb95e9eaf08d6f8ac4
+title: oauth-token-share-cap
+type: adr
+goal: Relax c3-224 oauth-token-pool's single-owner invariant ("prevent two chats from sharing one token") to a configurable per-token concurrency cap. Each `OAuthTokenEntry` carries a `maxConcurrent` field (1 = current behavior, default; user-raisable to N in the settings UI), and AppSettings carries a global `oauthTokenConcurrencyDefault` that supplies the field's default when a token entry omits it. The pool's reservation index becomes refcounted (`Map>`). Rotation, refusal UI, and the PTY smoke-test path get hardened against the new failure modes that sharing introduces (rotation herd, smoke-probe thrash, 401 cascade). This authorizes the OAuth-pool component to operate in cap-bounded shared-ownership mode instead of mutually-exclusive ownership.
+status: accepted
+date: "2026-05-22"
+---
+
+## Goal
+
+Relax c3-224 oauth-token-pool's single-owner invariant ("prevent two chats from sharing one token") to a configurable per-token concurrency cap. Each `OAuthTokenEntry` carries a `maxConcurrent` field (1 = current behavior, default; user-raisable to N in the settings UI), and AppSettings carries a global `oauthTokenConcurrencyDefault` that supplies the field's default when a token entry omits it. The pool's reservation index becomes refcounted (`Map>`). Rotation, refusal UI, and the PTY smoke-test path get hardened against the new failure modes that sharing introduces (rotation herd, smoke-probe thrash, 401 cascade). This authorizes the OAuth-pool component to operate in cap-bounded shared-ownership mode instead of mutually-exclusive ownership.
+
+## Context
+
+Today c3-224 enforces 1 token = 1 chat via `reservedBy: Map` in `src/server/oauth-pool/oauth-token-pool.ts:28`. When all tokens are reserved, spawn refuses with `OAuthPoolUnavailableError` (`src/server/agent.ts:2152`) and the chat sees an "in use by [chat X]" message. Power users with one Pro/Max OAuth and several concurrent chats either get blocked or must buy more subscriptions. Subagent runs against the parent chat's only token are starved by the same gate. The user-visible cost outweighs the rate-limit isolation gain for many real workflows.
+
+Affected topology: c3-224 (oauth-token-pool) owns the reservation index and refusal classification; c3-210 (claude-driver / agent rotation in `src/server/agent.ts`) holds rotation logic; c3-213 (quick-response / ephemeral pickers) uses `pickEphemeral` and must remain compatible; the PTY driver in `src/server/claude-pty/` adds new sharing-specific risks (cold-boot herd, smoke-probe race, 401 detector multiplication). The OAuth token storage lives under app-settings `claudeAuth.tokens` (c3-204 / c3-206 boundary) per ref-local-first-data; tokens never leave that surface.
+
+Constraints: must preserve ref-local-first-data (no token egress); must preserve ref-strong-typing / rule-strong-typing (typed contract surfaces — no `any` at the pool API or at the agent boundary); must keep rule-colocated-bun-test (tests stay next to code); must keep the persisted-state invariant from c3-224 ("Reservation pinned across restart" → reservedBy stays in-memory only — the new Set-based map is still in-memory only).
+
+## Decision
+
+Switch the in-memory reservation index from `Map` to `Map>`. `isEligible(token, now, reservedFor)` admits a token when the caller is already in the set OR `set.size < cap(token)`. `pickActive(reservedFor)` adds the caller to the picked token's set and removes the caller from any other token's set (a chat owns at most one token at a time across the pool, but a token can be owned by up to `cap(token)` chats). `release(reservedFor)` scans every set, removes the caller, and drops empty sets. `markLimited`/`markError`/`markDisabled` no longer silently drop reservations; they call a new `takeStaleOwners(id): string[]` helper that returns the set's owners and clears the set, so the agent layer can drive a coordinated re-pick instead of a herd.
+
+`OAuthTokenEntry` gains an optional `maxConcurrent?: number` field. `OAuthTokenPool` resolves the cap at pick time via `tokenCap(token) = token.maxConcurrent ?? globalDefault ?? 1`. The global default is read from `AppSettings.oauthTokenConcurrencyDefault` via the existing `readTokens` closure shape — the pool grows a second closure `readGlobalCap(): number` injected at construction. Defaulting to 1 preserves current behavior on existing installs.
+
+`describeUnavailability(reservedFor)` returns `byChatIds: string[]` (was `byChatId: string`) inside `reason: "reserved"`. `agent.ts:buildPoolUnavailableMessage` renders "in use by N chats" with one `/chat/` link per current owner.
+
+`agent.ts` adds a per-token rotation dedupe map (`Map`, 5 s TTL). On `markLimited` / synthetic `oauth_invalid_token` for token T, the agent reads `takeStaleOwners(T.id)`, calls `pickActive(firstOwner)` once to pick the rotation target, caches that target for the window, and triggers respawn for each stale owner staggered by 250 ms.
+
+PTY driver: `src/server/claude-pty/smoke-test.ts` adds an in-process singleflight (`Map>`, key = `${binarySha256}:${model}`) wrapping the live probe so concurrent shared-token spawns share one probe instead of racing two probes on the same OAuth.
+
+Settings UI: each token row gains a number input (1–5) bound to `maxConcurrent`; the OAuth pool settings panel gains a global default input bound to `oauthTokenConcurrencyDefault` (1–5, fallback 1).
+
+Why this wins for this repo: the in-memory map change is localized, refcounting via Set is the smallest semantically-correct replacement, the global+per-token default split lets the user opt in per-account without forcing a flag day, and the rotation/smoke hardening matches PTY's known cold-boot cost (`KANNA_PTY_TUI_BOOT_MS=3000`). It keeps every contract surface c3-224 lists in its Contract table (signatures change shape but every method retains its callsites).
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-224 | component | Goal inverted from "prevent two chats from sharing one token" to "cap-bounded shared ownership". Contract for pickActive, describeUnavailability, markLimited/Error/Disabled changes shape. Change Safety row "Same token handed to two chats" deletes and is replaced by a "Cap exceeded by concurrent picks" row. | Rewrite Goal, Contract, Change Safety, Derived Materials |
+| c3-210 | component | Rotation now drives staggered respawn for multiple owners of a limited/errored token; needs dedupe state and ordered respawn surface in Contract. | Add rotation-dedupe row to Contract; add herd-mitigation row to Change Safety |
+| c3-213 | component | pickEphemeral still uses synthetic key; cap applies to ephemeral leases. No contract change but Change Safety must note ephemeral consumes one cap slot. | Append Change Safety row |
+| c3-116 | component | settings-page renders the new per-token maxConcurrent input and the new global oauthTokenConcurrencyDefault input. Persisted via the existing settings store; new fields cross the client↔server boundary. | Update Contract row for OAuth pool settings panel |
+| c3-2 | container | Server boot wires the new global-cap closure into OAuthTokenPool constructor. | No-delta if Responsibilities table still covers "boot wiring"; record Parent Delta evidence |
+| c3-225 | component | claude-pty-driver gains smoke-test singleflight surface; its Contract must reference concurrent-safe canSpawn. | Append Contract row for smoke-test gate concurrency semantics |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-local-first-data | New settings field and per-token field still persist under ~/.kanna/data via app-settings; no token egress. | comply |
+| ref-strong-typing | New maxConcurrent, oauthTokenConcurrencyDefault, and byChatIds: string[] payload must be typed at the shared boundary. | comply |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-strong-typing | Pool API (pickActive, describeUnavailability, takeStaleOwners), the AppSettings extension, and the AgentCoordinator rotation-dedupe map must use named types. No any, no untyped object literals. | comply |
+| rule-colocated-bun-test | All new tests (oauth-token-pool.test.ts additions, smoke-test singleflight test, agent rotation herd test) sit beside the file under test. | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| shared types | Add maxConcurrent?: number to OAuthTokenEntry; add oauthTokenConcurrencyDefault?: number to AppSettings. | src/shared/types.ts |
+| oauth pool | Refactor reservedBy to Map>; add tokenCap, takeStaleOwners; update pickActive, release, mark{Limited,Error,Disabled}, describeUnavailability. Add readGlobalCap constructor closure. | src/server/oauth-pool/oauth-token-pool.ts |
+| agent rotation | Add tokenRotationDedupe map in AgentCoordinator; consume takeStaleOwners on rotation; stagger respawn by 250 ms; update buildPoolUnavailableMessage for byChatIds. | src/server/agent.ts |
+| pty smoke-test | Wrap canSpawn (or its underlying probe) in a per-(sha,model) singleflight cache. | src/server/claude-pty/smoke-test.ts |
+| settings UI | Per-token number input + global default input in OAuth pool settings panel. | src/client/components/settings/ |
+| tests | Update oauth-token-pool.test.ts, agent.oauth-pool.test.ts, agent.oauth-release.test.ts, agent.oauth-rotation.test.ts; new smoke-test.test.ts singleflight case; new PTY rotation-herd test. | src/server/, src/server/claude-pty/ |
+| c3 docs | c3x write c3-224 (Goal + Contract + Change Safety + Derived Materials); c3x write c3-210 (rotation rows); c3x write c3-213 (ephemeral cap row); c3x write c3-204 c3-206 (settings shape); ADR Parent Delta evidence. | .c3/ via c3x CLI |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| c3-224 body | Goal rewrite + Contract surfaces updated for cap-aware semantics + Change Safety row replacement + Derived Materials row for the new closure | c3x read c3-224 --full shows new Goal; c3x check --only c3-224 passes |
+| c3-210 body | Append rotation-dedupe Contract row + Change Safety herd row | c3x read c3-210 --section Contract |
+| c3-213 body | Append ephemeral cap Change Safety row | c3x read c3-213 --section "Change Safety" |
+| ADR Parent Delta | This ADR records the goal inversion on c3-224 with goal: field rewritten | c3x set c3-224 goal "..." audited via c3x check |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| oauth-token-pool.test.ts | Cap=2 admit; cap=1 reject; refcount release; takeStaleOwners returns + clears | bun test src/server/oauth-pool/oauth-token-pool.test.ts |
+| agent.oauth-rotation.test.ts | 3 owners on 1 token, force limit → 1 rotation target chosen, 3 staggered respawns, no double-pick on the new target | bun test src/server/agent.oauth-rotation.test.ts |
+| agent.oauth-release.test.ts | Chat A release does not affect Chat B's reservation on the same token | bun test src/server/agent.oauth-release.test.ts |
+| smoke-test.test.ts singleflight case | 5 concurrent canSpawn calls → 1 probe invocation | bun test src/server/claude-pty/smoke-test.test.ts |
+| bun run lint | New types named; no any; warning cap respected | bun run lint |
+| Settings UI | Number input rendered for maxConcurrent per token and global default; persisted via existing settings store | manual smoke + existing settings test if present |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Unconditional sharing (no cap) | Maximally amplifies Anthropic-side 429s and rotation herd; violates current change-safety posture without a knob to back off. |
+| Per-token request serializer (queue turn-spawns per token, 1 concurrent stream) | Adds end-to-end turn latency every time chats overlap; complicates PTY (each PTY is long-lived, not request-shaped). Cap is the simpler first step; serializer can layer later if cap=N still produces 429s. |
+| Global "share tokens" boolean flag | Coarser than per-token cap; cannot mix isolated and shared tokens in the same pool, which is the realistic mixed-account setup. |
+| Env var KANNA_OAUTH_TOKEN_CAP_DEFAULT only | User wants a settings-page control. Env var would be invisible to non-CLI users and adds a config surface that fights the settings UI. Global default belongs in AppSettings. |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Anthropic-side 429 / concurrent-session enforcement on shared OAuth | Default cap 1 preserves current behavior; per-token cap is user opt-in, fully reversible. | Manual: raise cap=2 on one token, run two chats, observe; rollback by lowering cap. |
+| Rotation herd on markLimited / 401 with N shared owners | tokenRotationDedupe 5 s window + 250 ms staggered respawn; takeStaleOwners returns ordered owner list. | agent.oauth-rotation.test.ts herd case |
+| Reservation leak when one of N owners crashes without closeClaudeSession | release(chatId) scans every set; existing closeClaudeSession + spawn-failure release paths already invoke it; new test covers refcount. | agent.oauth-release.test.ts refcount case |
+| PTY smoke-probe double-fire on cold cache | Per-(sha,model) singleflight in smoke-test.ts. | smoke-test.test.ts singleflight case |
+| describeUnavailability shape change breaks renderChatLinks UI parser | byChatIds: string[] rendered as N markdown links; chat-link regex unchanged; UI test updated. | ResultMessage.test.tsx covers multi-link case |
+| Global default of 1 silently rolls forward; users do not discover the feature | Settings UI exposes the field with helper text; CHANGELOG entry on release. | Manual UI smoke + CHANGELOG diff |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun test src/server/oauth-pool/ | All cases pass including cap-admit, cap-reject, refcount release, takeStaleOwners. |
+| bun test src/server/agent.oauth-rotation.test.ts src/server/agent.oauth-pool.test.ts src/server/agent.oauth-release.test.ts | Rotation herd dedupe + staggered respawn; refcount release. |
+| bun test src/server/claude-pty/smoke-test.test.ts | Singleflight collapses concurrent probes to one. |
+| bun run lint | No new errors; warning cap honored. |
+| c3x check --only c3-224 --only c3-210 --only c3-213 | Passes after c3x write updates and ADR Parent Delta. |
+| Manual smoke: cap=2 on one token, two chats turn concurrently | Both chats receive responses; no "in use by" refusal; rotation on forced limit hits both chats and recovers. |
diff --git a/.c3/adr/adr-20260523-lint-side-effects-pure-layers.md b/.c3/adr/adr-20260523-lint-side-effects-pure-layers.md
new file mode 100644
index 000000000..ce0ffc211
--- /dev/null
+++ b/.c3/adr/adr-20260523-lint-side-effects-pure-layers.md
@@ -0,0 +1,99 @@
+---
+id: adr-20260523-lint-side-effects-pure-layers
+c3-seal: 8da2d4a431374aa0d4bc61bf9847e51c64c8483a4df119ab39556d6f92e978ff
+title: lint-side-effects-pure-layers
+type: adr
+goal: Ban direct side-effect imports/globals (`fs`, `chokidar`, db clients, `child_process`, `node:http`/`https`, `Bun.*` globals) in `src/shared/**` and `src/client/**` via ESLint. Force every side-effect call to live in `src/server/**` (adapter layer) or behind an injected port. Apply rule as `error` from the first PR; relocate the only two pre-existing violations into `src/server/**` so the rule lands with zero suppressions.
+status: proposed
+date: "2026-05-23"
+---
+
+## Goal
+
+Ban direct side-effect imports/globals (`fs`, `chokidar`, db clients, `child_process`, `node:http`/`https`, `Bun.*` globals) in `src/shared/**` and `src/client/**` via ESLint. Force every side-effect call to live in `src/server/**` (adapter layer) or behind an injected port. Apply rule as `error` from the first PR; relocate the only two pre-existing violations into `src/server/**` so the rule lands with zero suppressions.
+
+## Context
+
+Today the Shared container's Responsibilities (`c3-3`) state it only owns types, WS protocol, tool hydration, and port/branding constants. Yet `src/shared/projectFileRelocation.ts` and its colocated test call `node:fs` `copyFileSync`/`mkdirSync`/`existsSync`/`node:fs/promises`. Only consumer is `src/server/codex-app-server.ts` — file is misplaced. No mechanical guard exists to keep new code from doing the same in `src/shared/**` or `src/client/**`. Repo-wide audit found 0 such imports in `src/client`, 2 in `src/shared`, and ~185 in `src/server`. Server layer is intentionally out-of-scope for v1 — adding the rule there now would mean ~185 eslint-disable comments with zero refactor value. v1 locks the pure layers; future ADRs will tighten the server layer one component at a time.
+
+## Decision
+
+Add one new ESLint flat-config override block scoped to `src/shared/**/*.{ts,tsx}` and `src/client/**/*.{ts,tsx}`. Block uses two rules:
+
+1. `no-restricted-imports` with `patterns` covering: `fs`/`fs/*`/`node:fs`/`node:fs/*`/`chokidar` (filesystem); `bun:sqlite`/`better-sqlite3`/`pg` (db); `child_process`/`node:child_process`/`http`/`node:http`/`https`/`node:https` (process + raw network).
+2. `no-restricted-globals` banning the `Bun` identifier (covers `Bun.spawn`, `Bun.$`, `Bun.file`).
+Browser-native `fetch` is intentionally allowed — it is the canonical HTTP API on both runtimes and the 9 existing client call sites are legitimate. Move `projectFileRelocation.ts` + test into `src/server/` and fix the single import in `codex-app-server.ts`. Rule severity is `error`; no `eslint-disable` comments are introduced anywhere in this PR.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-3 | container | Pure-layer Responsibilities are now mechanically enforced for src/shared/**; a misplaced IO module is being removed from this container | Container Responsibilities already say "no IO" — no edit needed; verify no component cites projectFileRelocation |
+| c3-2 | container | Receives projectFileRelocation.ts from Shared; gains lint exemption (no rule applied here in v1) | Confirm file lands inside Server scope; no component membership added since the file was already uncharted in the codemap |
+| c3-1 | container | Pure-layer rule extends to src/client/**; 0 current violations | Verify no client component imports newly-banned modules |
+| eslint.config.js | N.A - eslint config is repo-root tooling, not a c3 component | Enforcement surface for the decision | None — config files are excluded from c3 ownership by convention |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-strong-typing | Shares the same philosophy of pushing impurity to boundaries; the new lint rule extends boundary policing from types to side effects | review — no edit to ref; cite it in commit message as adjacent policy |
+| N.A - no existing ref about side-effect isolation | Repo has no port-and-adapter ref yet; v1 ships only the lint surface, so a new ref is premature | create-ref deferred to v2 (per-component server refactor) |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-strong-typing | Same boundary-enforcement family as the new lint rule; both run in bun run lint and fail CI on violation | comply — no edit; new rule slots beside it in eslint.config.js |
+| N.A - no existing rule about side-effect ports | Rule for v1 lives in eslint.config.js itself, not as a c3 rule entity; no golden-example markdown is required because the enforcement is fully declarative ESLint config | create-rule deferred — revisit if v2 introduces a custom AST plugin |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| File move | git mv src/shared/projectFileRelocation.ts src/server/projectFileRelocation.ts and same for the colocated .test.ts | git status -s shows two R entries |
+| Import fix | src/server/codex-app-server.ts:16 updated from ../shared/projectFileRelocation to ./projectFileRelocation | bun test src/server/codex-app-server passes (40 tests) |
+| Lint config | New override block appended to eslint.config.js (~40 LOC) with no-restricted-imports patterns and no-restricted-globals for Bun | bun run lint exits 0 on repo head; synthetic probe _lint_probe.ts triggers 3 expected errors |
+| Test verification | bun test src/server/projectFileRelocation.test.ts 6/6 pass after move | Test output recorded above |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| N.A - no c3 entity body changes | This ADR adds an enforcement surface (ESLint) without changing component bodies, refs, or rules. projectFileRelocation was uncharted in the codemap on both sides of the move, so no c3x lookup ownership row shifts. | c3x lookup src/shared/projectFileRelocation* returns empty components: both before and after the move |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| bun run lint | Fails CI when src/shared/** or src/client/** imports any banned module or references the Bun global | bunx eslint src/shared/_lint_probe.ts returns 3 errors with the expected messages; full bun run lint exits 0 on this branch |
+| .github/workflows/test.yml | Already runs bun run lint before tests; merges block on lint failure per existing CLAUDE.md --max-warnings=0 policy | No workflow change required |
+| ESLint override block in eslint.config.js | Single source of truth; new banned module = one line in patterns | Diff localized to eslint.config.js and the moved files |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Apply rule to src/server/** with eslint-disable on all ~185 existing sites | Pure suppression noise, zero architectural value, blocks PR review with mechanical churn, makes future migration harder because every disable becomes precedent |
+| warn-only ratchet across the whole repo | Repo CLAUDE.md sets --max-warnings=0; warnings would either immediately fail CI (same as error) or need a per-rule cap raise that the ratchet pattern doesn't naturally model; cleaner to start at error on the layers that are already clean |
+| Custom AST plugin (eslint-plugin-kanna-purity) catching call sites, not just imports | ~200 LOC + tests for an enforcement that no-restricted-imports + no-restricted-globals already cover on the pure layers; revisit in v2 when extending to server layer where call-site granularity (e.g. "fs only inside specific files") starts mattering |
+| C3-only ref/rule with /c3 audit enforcement | Not lint-time; misses regressions until a manual audit; user explicitly asked for lint as primary enforcement |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Future contributor adds an fs import in src/shared/** to "just get something working" | Rule is error, fails CI before merge; rule message names the exact required remediation (move to src/server/** or inject a port) | bun run lint in CI |
+| Browser-native fetch ban added later by accident, breaking 9 client call sites | Rule deliberately omits fetch from no-restricted-globals; ADR documents the carve-out so a later editor knows it is intentional | Comment-free config diff is small enough that a reviewer notices any fetch addition |
+| New banned module surfaces (e.g. mongodb, redis) not covered by v1 list | Adding a module is a one-line addition to the patterns array; no schema change required | Future PR adds the module to the array + an entry to this ADR's successor |
+| File move silently breaks a runtime import not caught by tests | The only consumer (codex-app-server.ts) is heavily tested (40 tests pass); tsc import resolution would fail at build time on a broken relative path | bun test src/server/codex-app-server + repo-wide bun test |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun run lint | exit 0 |
+| bun test src/server/projectFileRelocation.test.ts | 6 pass / 0 fail |
+| bun test src/server/codex-app-server.test.ts | 40 pass / 0 fail |
+| Synthetic probe: write src/shared/_lint_probe.ts with import "node:fs", import "chokidar", Bun.spawn(...) and run bunx eslint on it | 3 errors with expected messages (recorded in this session) |
+| c3x check after ADR creation | exit 0 |
diff --git a/.c3/adr/adr-20260523-paths-config-purify-io.md b/.c3/adr/adr-20260523-paths-config-purify-io.md
new file mode 100644
index 000000000..14bbcd122
--- /dev/null
+++ b/.c3/adr/adr-20260523-paths-config-purify-io.md
@@ -0,0 +1,100 @@
+---
+id: adr-20260523-paths-config-purify-io
+c3-seal: 68380442003000f1d90378cd73acf2edfb77a5b51ba804f3a7add63193937624
+title: paths-config-purify-io
+type: adr
+goal: Make `src/server/paths.ts` (component `c3-204 paths-config`) match its own stated contract by removing the only function that does filesystem IO. `ensureProjectDirectory` moves to a new sibling helper `src/server/project-directory.ts`; `paths.ts` keeps only pure path-resolution functions.
+status: proposed
+date: "2026-05-23"
+---
+
+## Goal
+
+Make `src/server/paths.ts` (component `c3-204 paths-config`) match its own stated contract by removing the only function that does filesystem IO. `ensureProjectDirectory` moves to a new sibling helper `src/server/project-directory.ts`; `paths.ts` keeps only pure path-resolution functions.
+
+## Context
+
+`c3-204 paths-config` documents its non-goals as "I/O, persistence, schema decisions", yet `paths.ts` ships an `ensureProjectDirectory(localPath)` function that calls `mkdir` and `stat`. The only consumer is `src/server/ws-router.ts` (two call sites in the project-create / project-import handlers). The mismatch was a small instance of doc-code drift and a useful first step in the per-component side-effect cleanup track set up by ADR `adr-20260523-lint-side-effects-pure-layers`.
+
+## Decision
+
+Split `paths.ts`:
+
+1. New file `src/server/project-directory.ts` exports `ensureProjectDirectory(localPath)`, imports `node:fs/promises` `mkdir`/`stat` and `resolveLocalPath` from `./paths`. Behavior preserved bit-for-bit.
+2. `paths.ts` loses the import of `node:fs/promises` and the `ensureProjectDirectory` export. It keeps `resolveLocalPath`, `getProjectUploadDir`, `getProjectExportDir` — all pure.
+3. `ws-router.ts` updates its import: `resolveLocalPath` still from `./paths`, `ensureProjectDirectory` now from `./project-directory`.
+
+No port-and-adapter interface is introduced. The full ports pattern is overkill for a single 9-line function on the server side, where `node:fs/promises` remains an allowed dependency. The narrower win is contract alignment for `c3-204`.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-204 | component | Drops its only IO call so the documented "non-goals: I/O" actually holds; Derived Materials row for paths.ts is unchanged in path but narrower in scope | Update Derived Materials to acknowledge the sibling project-directory.ts as the extracted IO helper |
+| c3-2 | container | Gains one uncharted file src/server/project-directory.ts; no new component is introduced because the helper is too small and is owned at the container level | Confirm the file is server-only and used only by ws-router |
+| c3-208 | component | Sole consumer of ensureProjectDirectory; updates its import path | No behavior change |
+| N.A - eslint config | N.A - tooling | No lint-scope change in this PR; future ADR may extend the rule to server modules | None |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-local-first-data | c3-204 cites this ref for "all paths under ~/.kanna/data"; the path-resolution functions still satisfy it | comply — no edit |
+| N.A - no port/adapter ref exists yet | This PR deliberately does not introduce a port; full port-and-adapter pattern deferred to a future ADR targeting a fatter server component | create-ref deferred |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-colocated-bun-test | New project-directory.test.ts sits next to project-directory.ts | comply — test file colocated |
+| N.A - no rule about server purity | Server layer remains exempt from the v1 lint scope, so no rule is violated or created | N.A |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| New helper | src/server/project-directory.ts exports ensureProjectDirectory with verbatim behavior from old paths.ts | File diff |
+| Purify paths.ts | Remove node:fs/promises import and ensureProjectDirectory export; keep only resolveLocalPath, getProjectUploadDir, getProjectExportDir | File diff |
+| Update consumer | src/server/ws-router.ts:19 import split into two lines (./paths and ./project-directory) | File diff |
+| Tests | src/server/project-directory.test.ts covers create-new, idempotent-existing, file-exists-error, empty-path-error | bun test src/server/project-directory.test.ts 4/4 pass |
+| Consumer regression | bun test src/server/ws-router 62/62 pass after the import change | Recorded in this session |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| c3-204 Derived Materials | Add a row noting src/server/project-directory.ts as the extracted IO helper sibling | c3x read c3-204 --section "Derived Materials" after the edit shows both files |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| bun run lint | Continues to allow node:fs/promises in src/server/**; no new rule in this PR | bun run lint exit 0 on branch |
+| bun test src/server/project-directory.test.ts | Covers happy path + every error branch reachable on a real filesystem | 4/4 pass |
+| bun test src/server/ws-router | Confirms no regression in the only consumer | 62/62 pass |
+| c3x check | Validates that updated Derived Materials still match the codemap | Run before commit |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Keep ensureProjectDirectory inside paths.ts and update the c3-204 doc to allow IO | Would weaken the stated contract for the sake of one function; the doc was right, the code was wrong |
+| Introduce a full DirectoryEnsurer port + adapter interface | Two call sites, one impl, no test-double need today — the abstraction would be pure ceremony |
+| Promote project-directory.ts to its own c3 component | A 10-line helper does not earn its own component; container-level ownership in c3-2 plus a Derived Materials breadcrumb is sufficient |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Hidden re-export breaks an external consumer | Repo files field in package.json publishes src/server/; consumers should import from the package root, not ./paths directly. None do today | grep -r "ensureProjectDirectory" src shows only the two ws-router call sites + the new file + tests |
+| Future contributor adds a new IO function to paths.ts again | The c3-204 Purpose still names IO as a non-goal; a follow-up ADR can wire ESLint into src/server/paths.ts specifically once the per-component lint pattern lands | c3x read c3-204 --section Purpose |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun test src/server/project-directory.test.ts | 4 pass / 0 fail |
+| bun test src/server/ws-router | 62 pass / 0 fail |
+| bun run lint | exit 0 |
+| c3x check | exit 0 |
+| grep -r "ensureProjectDirectory" src | Three sites: project-directory.ts (definition), project-directory.test.ts (tests), ws-router.ts:1442/1454 (consumer) |
diff --git a/.c3/adr/adr-20260523-pty-instances-status-panel.md b/.c3/adr/adr-20260523-pty-instances-status-panel.md
new file mode 100644
index 000000000..e4a68d530
--- /dev/null
+++ b/.c3/adr/adr-20260523-pty-instances-status-panel.md
@@ -0,0 +1,165 @@
+---
+id: adr-20260523-pty-instances-status-panel
+c3-seal: 139e4cea0a52d79b99365fb6b9f3b67838f17b5f005d6d98b6920074f7bce039
+title: pty-instances-status-panel
+type: adr
+goal: |-
+ Add a user-facing surface in the Kanna client that lists every live `claude`
+ PTY child (only relevant when `KANNA_CLAUDE_DRIVER=pty`) with its full
+ runtime status — identity (pid, sessionId, chat, cwd, model, OAuth account
+ label), lifecycle phase, live counters (turns, tokens, last-event age),
+ and health/debug fields (smoke-test result, plan-mode flag, masked token,
+ output-ring tail) — backed by a live WebSocket push channel, and supports
+ per-row actions: open chat, cancel turn, kill process. The decision being
+ authorized is: (a) introduce a single in-memory `PtyInstanceRegistry` as
+ the canonical aggregator of PTY runtime state, (b) extend the WS protocol
+ with a `pty:*` subscription family, (c) ship a `PtyStatusBadge` +
+ `PtyInstancesPopover` in the app shell.
+status: proposed
+date: "2026-05-23"
+---
+
+# pty-instances-status-panel
+
+## Goal
+
+Add a user-facing surface in the Kanna client that lists every live `claude`
+PTY child (only relevant when `KANNA_CLAUDE_DRIVER=pty`) with its full
+runtime status — identity (pid, sessionId, chat, cwd, model, OAuth account
+label), lifecycle phase, live counters (turns, tokens, last-event age),
+and health/debug fields (smoke-test result, plan-mode flag, masked token,
+output-ring tail) — backed by a live WebSocket push channel, and supports
+per-row actions: open chat, cancel turn, kill process. The decision being
+authorized is: (a) introduce a single in-memory `PtyInstanceRegistry` as
+the canonical aggregator of PTY runtime state, (b) extend the WS protocol
+with a `pty:*` subscription family, (c) ship a `PtyStatusBadge` +
+`PtyInstancesPopover` in the app shell.
+
+## Context
+
+Today PTY mode is opaque from the UI. Driver state is split across
+`ClaudePtyRegistry` (on-disk reap registry; chatId/sessionId/pid/cwd only),
+`pid-registry.adapter.ts`, ad-hoc fields inside `driver.ts`, and
+transcript JSONL files. There is no aggregate view of live children, no
+phase enumeration (`spawning|trust|ready|streaming|cancelling|exited`),
+no token counters surfaced to the client, and no way for a user to cancel
+or kill a stuck PTY without restarting the server. With multiple chats
+running in PTY mode simultaneously (subagent delegation chains, multiple
+projects) the user has no situational awareness. Affected topology:
+c3-225 (claude-pty-driver) owns the spawn lifecycle; c3-208 (ws-router)
+multiplexes the channel; c3-110 (app-shell) hosts the status surface;
+c3-302 (protocol) defines the wire envelope. Constraints: must obey
+ref-strong-typing on the wire, ref-side-effect-adapter in the server
+layer, rule-zustand-store and rule-colocated-bun-test on the client.
+
+## Decision
+
+Introduce `PtyInstanceRegistry` (pure in-memory, no IO, no adapter file)
+in `src/server/claude-pty/pty-instance-registry.ts`. Driver emits
+lifecycle transitions and counter deltas to it; ws-router subscribes
+sockets and broadcasts snapshot + delta envelopes. Client `pty-instances`
+zustand store mirrors registry state via the existing WS multiplexer.
+App-shell renders ` ` (compact: dot + count) which opens
+` ` (radix popover) with one row per instance.
+Chosen because: (1) the in-memory registry is the smallest aggregator
+that matches c3-225's existing per-spawn lifecycle and avoids polluting
+the on-disk reap registry with transient runtime fields; (2) WS push
+reuses the protocol already governed by ref-ws-subscription; (3) status
+bar dropdown keeps the surface out of the sidebar (sidebar is project-
+first by c3-111's contract) and visible from every route.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-225 | component | Driver emits phase transitions + counter deltas; new actions cancel/kill route through driver handles | Review Contract row for new emit surface |
+| c3-208 | component | New pty:* command routes + delta fan-out | Review Contract row for new WS surface |
+| c3-110 | component | Hosts new status badge in shell chrome | Review Contract row for new shell slot |
+| c3-302 | component | New WS envelope types pty:snapshot/delta/subscribe/cancel/kill | Review Contract row for new wire types |
+| c3-1 | container | New child component pty-instances-panel will be added under this container | Update Components + Responsibilities rows |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-strong-typing | All new WS envelopes cross the client/server boundary | comply |
+| ref-side-effect-adapter | Registry is pure in-memory; no IO. Any disk/process touch must remain in existing .adapter.ts files | comply |
+| ref-ws-subscription | New pty:* family rides the single typed WS | comply |
+| ref-zustand-store | Client store for instance list | comply |
+| ref-event-sourcing | Registry is derived runtime state, NOT persisted; transcript events remain the source of truth for replay | comply |
+| ref-colocated-bun-test | All new files get sibling .test.ts(x) | comply |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-strong-typing | New PtyInstanceState, PtyInstanceDelta, WsPty* types cross boundaries | comply |
+| rule-zustand-store | Client store must return stable EMPTY ref per render-loop regression rule in CLAUDE.md | comply |
+| rule-colocated-bun-test | All new modules ship .test.ts(x) next to source | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| Server registry | New src/server/claude-pty/pty-instance-registry.ts exposing snapshot(), subscribe(fn), upsert(chatId, patch), remove(chatId) | pty-instance-registry.ts + pty-instance-registry.test.ts |
+| Driver hook | driver.ts reports phase transitions on spawn/trust-dismiss/ready/cancel/exit + token/turn counters from jsonl-to-event | driver.ts diff |
+| Protocol types | Add WsPtyInstanceState, WsPtySnapshot, WsPtyDelta, WsPtyCancelCommand, WsPtyKillCommand, WsPtySubscribeCommand discriminated unions | src/shared/protocol.ts |
+| WS router | Route new commands; fan out snapshot on subscribe + deltas on registry change | src/server/ws-router.ts |
+| Client store | src/client/state-stores/pty-instances-store.ts with stable EMPTY ref + useShallow selectors | pty-instances-store.ts + .test.ts |
+| Status badge | src/client/components/pty-status-badge.tsx in app shell footer | pty-status-badge.tsx + .test.tsx |
+| Popover | src/client/components/pty-instances-popover.tsx with row, pills, action buttons + kill-confirm dialog | pty-instances-popover.tsx + .test.tsx |
+| Component doc | New c3-1-client/c3-119-pty-instances-panel.md wired to c3-1 | c3x add component |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| New component card | c3x add component pty-instances-panel --container c3-1 with Goal/Contract/ParentFit filled per schema | c3x read c3-119 |
+| c3-225 Contract row | Add new OUT surface "PtyInstance lifecycle deltas" pointing to registry | c3x write c3-225 --section Contract |
+| c3-208 Contract row | Add new IN surface "pty:* command routing" | c3x write c3-208 --section Contract |
+| c3-302 Contract row | Add new "WsPty* envelope family" surface | c3x write c3-302 --section Contract |
+| c3-110 Contract row | Add new "PTY status badge slot in shell chrome" | c3x write c3-110 --section Contract |
+| c3x check | Re-run after every mutation; must remain green | c3x check exit 0 |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| pty-instance-registry.test.ts | Asserts upsert/remove/subscribe semantics, no leaks | bun test src/server/claude-pty/pty-instance-registry.test.ts |
+| driver.test.ts | Asserts driver emits phase transitions in expected order | bun test src/server/claude-pty/driver.test.ts |
+| ws-router.test.ts | Asserts pty:subscribe → snapshot + later deltas, cancel/kill routed to registry | bun test src/server/ws-router.test.ts |
+| pty-instances-store.test.ts | renderForLoopCheck asserts no React error #185 | bun test src/client/state-stores/pty-instances-store.test.ts |
+| pty-instances-popover.test.tsx | Asserts row render, action buttons wired, kill requires confirm | bun test src/client/components/pty-instances-popover.test.tsx |
+| bun run lint | Side-effect lint rejects any new IO outside .adapter.ts; ratchet stays at 0 | bun run lint --max-warnings=0 |
+| c3x check | Validates topology + refs/rules | c3x check |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Persist runtime status in event store as new event kind | Adds event volume per token delta, pollutes replay log, breaks ref-event-sourcing intent (events are user-visible turn state, not process metrics) |
+| Reuse ClaudePtyRegistry (on-disk reap registry) | That registry is fsync-on-write for crash recovery; per-token writes would thrash disk and conflate "alive across restart" with "live runtime" |
+| Sidebar section instead of status-bar badge | Sidebar contract (c3-111) is project-first navigation; PTY ops view is global cross-project state and doesn't belong in project navigation |
+| Poll /api/pty/instances every 1s | Wastes WS multiplexer already governed by ref-ws-subscription; latency higher than push; user explicitly chose live push |
+| Server-Sent Events channel | Duplicates transport; we already have one typed WS per ref-ws-subscription |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Token-delta storm overwhelms WS | Coalesce counter deltas at 100 ms trailing edge in registry before broadcast | Unit test asserts ≤ 1 delta per 100 ms window under burst |
+| Kill action races driver cleanup leaving zombie | Kill routes through driver's existing SIGTERM→SIGKILL escalation path (5 s grace) | driver.test.ts kill-from-registry case |
+| Stable EMPTY ref violated → render loop | Use module-scope const EMPTY: PtyInstanceState[] = [] per CLAUDE.md rule | renderForLoopCheck assertion |
+| Subagent chains spawn many PTYs and clutter popover | Group child PTYs under parent run id; collapse by default | Visual review + popover test asserts grouping |
+| Plan-mode flag drift if user toggles via Shift+Tab in TUI | Surface "unknown" state explicitly, mirroring driver warning path | popover renders "plan: unknown" when flag false but TUI may differ |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun run lint --max-warnings=0 | exit 0 |
+| bun test | all green |
+| bun test src/server/claude-pty/pty-instance-registry.test.ts | green |
+| bun test src/client/components/pty-instances-popover.test.tsx | green |
+| c3x check | exit 0, no drift |
+| Manual smoke: start two chats with KANNA_CLAUDE_DRIVER=pty, open popover | both rows visible, phase advances ready→streaming→ready, cancel + kill buttons act on correct row |
diff --git a/.c3/adr/adr-20260524-pty-cpu-tracking.md b/.c3/adr/adr-20260524-pty-cpu-tracking.md
new file mode 100644
index 000000000..260234d9b
--- /dev/null
+++ b/.c3/adr/adr-20260524-pty-cpu-tracking.md
@@ -0,0 +1,114 @@
+---
+id: adr-20260524-pty-cpu-tracking
+c3-seal: 0b2a34236c7696cc813a1a4b7d47543ff581c46c8ebefeea1c3cd0ced9cace2b
+title: pty-cpu-tracking
+type: adr
+goal: 'Extend the PTY live-status panel with realtime CPU% per instance: each tracked `claude` PTY exposes current CPU% plus session-peak CPU%, summed across the process tree (`claude` + descendants), polled on the same 2 s tick as the existing memory sampler. Adds two nullable-number fields (`cpuPercent`, `cpuPeakPercent`) to `PtyInstanceState`, widens the sampler API from `sampleProcessTreeRssBytes` to `sampleProcessTreeUsage` (returns `{rssBytes, cpuPercent}`), and adds a `cpu` cell to `PtyInstancesIndicator`.'
+status: proposed
+date: "2026-05-24"
+---
+
+## Goal
+
+Extend the PTY live-status panel with realtime CPU% per instance: each tracked `claude` PTY exposes current CPU% plus session-peak CPU%, summed across the process tree (`claude` + descendants), polled on the same 2 s tick as the existing memory sampler. Adds two nullable-number fields (`cpuPercent`, `cpuPeakPercent`) to `PtyInstanceState`, widens the sampler API from `sampleProcessTreeRssBytes` to `sampleProcessTreeUsage` (returns `{rssBytes, cpuPercent}`), and adds a `cpu` cell to `PtyInstancesIndicator`.
+
+## Context
+
+ADR `adr-20260524-pty-memory-tracking` added RSS + peak to the panel and shipped a 2 s `ps` poller. Users in long PTY sessions also need to know which instance is burning CPU — memory alone does not distinguish an idle multi-GB resident session from one stuck in a tight loop. Since the sampler already invokes `ps -A` once per tick, adding the `pcpu` column is free: the process spawn count stays at one per instance per tick, and the BFS tree-collect is unchanged.
+
+Topology involved is identical to the prior ADR:
+
+- `c3-225 claude-pty-driver` — sampler interval already wired here; switches from RSS-only to RSS+CPU value object.
+- `c3-102 state-stores` — `PtyInstanceState` widens with two more nullable-number fields; selectors auto-pick.
+- `c3-1 Client` — `PtyInstancesIndicator` adds a sibling `cpu` cell next to `mem`.
+
+Constraint: BSD `ps` (macOS) and GNU `ps` (Linux) both support `pcpu` in the same `-o` syntax, so the existing platform-uniform `ps -A` call only needs one extra column added.
+
+## Decision
+
+1. Add `cpuPercent: number | null` and `cpuPeakPercent: number | null` to `PtyInstanceState`. Registry baseline initialises both to `null`.
+2. Rename sampler API: `parsePsOutput` returns `PsProcessRow` rows that now carry `cpuPercent`; `sumTreeRssBytes` becomes `sumTreeUsage` returning `ProcessTreeSample = {rssBytes, cpuPercent}`; entry point becomes `sampleProcessTreeUsage(rootPid): Promise`. `ps` arg list grows by `pcpu=`. No backwards-compat wrapper kept — only one in-process consumer (driver) needs the update.
+3. Driver tick computes peak for both metrics independently and upserts all four fields atomically per tick.
+4. `PtyInstancesIndicator` adds a `cpu X% · peak Y%` cell using a new `formatPercent` helper (sub-100% rendered with one decimal; ≥100% rounded, since multi-core PTYs can easily hit 200–800%).
+5. Poll interval stays 2 s; injection point on driver renamed from `sampleProcessTreeRssBytes` to `sampleProcessTreeUsage`.
+
+CPU% sums per-process `pcpu` values across the tree. On multi-core hosts each process can exceed 100%, and the sum can exceed `N * 100%` for an `N`-core machine — the UI accepts this and renders the raw number (clarified via tooltip `>100% = multi-core`).
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-225 | component | Sampler is owned here; signature changes from rss-only to rss+cpu value object | Refresh Contract row to mention cpu fields; refresh Derived Materials adapter description |
+| c3-102 | component | PtyInstanceState widens with two more nullable-number fields; no store-shape change | No Parent Delta — selector contract unchanged |
+| c3-1 | container | PtyInstancesIndicator adds a sibling cpu cell | Parent Delta no-op — only chat-ui component edited |
+| c3-2 | container | No new file added — sampler updated in place | Parent Delta no-op |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-side-effect-adapter | Sampler still shells ps from the existing .adapter.ts file; no new IO surface | comply |
+| ref-strong-typing | New cpuPercent / cpuPeakPercent fields + new ProcessTreeSample type cross WS + JSONL boundary | comply |
+| ref-colocated-bun-test | Sampler + UI tests updated next to source | comply |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-strong-typing | New fields ship in shared protocol; no any allowed | comply |
+| rule-colocated-bun-test | Test edits live next to widened sampler + indicator | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| shared types | Add cpuPercent + cpuPeakPercent to PtyInstanceState; update registry baseline | src/shared/pty-instance.ts, src/server/claude-pty/pty-instance-registry.ts |
+| sampler adapter | Add pcpu column to ps args; widen row shape; rename entry point to sampleProcessTreeUsage; sumTreeUsage returns {rssBytes,cpuPercent} | src/server/claude-pty/pty-memory-sampler.adapter.ts |
+| sampler tests | Update fixtures + expectations to include cpuPercent column; integration test asserts finite cpu | src/server/claude-pty/pty-memory-sampler.adapter.test.ts |
+| driver wiring | Track cpuPeakPercent in scope; upsert all four fields per tick; rename injection points | src/server/claude-pty/driver.ts |
+| client UI | Add formatPercent helper + cpu cell mirroring mem cell pattern | src/client/components/chat-ui/PtyInstancesIndicator.tsx |
+| client tests | formatPercent unit tests; cpu cell render branches | src/client/components/chat-ui/PtyInstancesIndicator.test.tsx |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| c3-225 Contract | Rewrite live-status registry row to mention cpuPercent + cpuPeakPercent and the renamed sampleProcessTreeUsage entry point | c3x read c3-225 --section Contract |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| eslint side-effect seal | Sampler stays in .adapter.ts; new column addition introduces no new IO surface | bun run lint passes with 0 warn |
+| bun test | Parser test fixtures updated to 4-column ps output; integration test asserts cpu is finite | bun test src/server/claude-pty/pty-memory-sampler.adapter.test.ts |
+| bunx tsc --noEmit | New fields typed end-to-end; renamed sampler signature compiles across all callers | tsc exit 0 |
+| Manual smoke | Open PTY chat, observe cpu cell ticking every 2 s alongside mem cell; peak monotonic non-decreasing | screenshot in PR |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Keep sampler API as RSS-only and add a second ps call for CPU | Doubles the per-tick spawn cost for zero gain — pcpu is already in the default ps output, one extra column is free |
+| Add a third time column for elapsed CPU seconds (sum-of-thread CPU time) | The up cell already shows wall-clock uptime; adding CPU-seconds duplicates intent and clutters the panel |
+| Use BSD-only ps -o pcpu= with macOS-specific code path | Both BSD ps (macOS) and procps-ng ps (Linux) accept -o pcpu= identically; no need to branch |
+| Render CPU as a color-coded badge over a threshold | Out of scope — current panel uses text-only style; consistent with the mem cell already shipped |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| pcpu field meaning differs subtly across ps implementations (BSD averages over process lifetime, GNU samples last interval) | Document semantics in tooltip; users care about relative ordering of instances, not absolute precision | Manual smoke compares ordering against top |
+| CPU sum exceeds intuition on multi-core hosts (e.g. 800% on 8-core), causing user confusion | Tooltip says ">100% = multi-core"; formatPercent uses no decimals at ≥100% so the number reads as a large integer | Code review + manual smoke |
+| Rename of sampler entry point breaks external callers | Only the driver consumes it; verified with rg sampleProcessTreeRssBytes returning no other matches | grep evidence in PR |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun test src/server/claude-pty/pty-memory-sampler.adapter.test.ts | green; parser handles 4-column output, sumTreeUsage returns object, integration cpu finite |
+| bun test src/client/components/chat-ui/PtyInstancesIndicator.test.tsx | green; formatPercent branches + cpu cell render/hide |
+| bun test | full suite green |
+| bun run lint | 0 warnings |
+| bunx tsc --noEmit | exit 0 |
+| c3x check --include-adr | this ADR has no errors; pre-existing warnings unchanged |
+| Manual | dev kanna, open PTY chat, expand panel: cpu cell visible + ticking every 2 s |
diff --git a/.c3/adr/adr-20260524-pty-memory-tracking.md b/.c3/adr/adr-20260524-pty-memory-tracking.md
new file mode 100644
index 000000000..1ab9e25b1
--- /dev/null
+++ b/.c3/adr/adr-20260524-pty-memory-tracking.md
@@ -0,0 +1,123 @@
+---
+id: adr-20260524-pty-memory-tracking
+c3-seal: 93b2b9f2deb53443848930370ce8e05ff182fb7c23e0c2fda0027e132f154ab4
+title: pty-memory-tracking
+type: adr
+goal: 'Add realtime per-process memory tracking to the live PTY status panel: each tracked `claude` PTY shows current RSS plus session peak RSS, summed across the child process tree (`claude` + descendants), refreshed every 2 s while the instance is alive. The decision authorizes adding two `number | null` fields (`rssBytes`, `rssPeakBytes`) to `PtyInstanceState`, a new memory sampler adapter that shells out to `ps`, a driver-side interval poller, and a new "mem" cell in `PtyInstancesIndicator`.'
+status: implemented
+date: "2026-05-24"
+---
+
+## Goal
+
+Add realtime per-process memory tracking to the live PTY status panel: each tracked `claude` PTY shows current RSS plus session peak RSS, summed across the child process tree (`claude` + descendants), refreshed every 2 s while the instance is alive. The decision authorizes adding two `number | null` fields (`rssBytes`, `rssPeakBytes`) to `PtyInstanceState`, a new memory sampler adapter that shells out to `ps`, a driver-side interval poller, and a new "mem" cell in `PtyInstancesIndicator`.
+
+## Context
+
+Issue #309 shipped the PTY live status panel (`PtyInstancesIndicator`) with phase, pid, model, uptime, account, plan flag, and smoke-test status, but the panel has no resource-usage signal. Users running multiple long-lived `claude` PTYs in parallel cannot tell which instance is consuming RAM, which makes it hard to decide which one to cancel/kill when the host gets memory-pressured. `claude` plus its node/MCP children can grow into multi-hundred-MB territory on long sessions, so the panel needs to expose memory.
+
+Topology involved:
+
+- `c3-225 claude-pty-driver` — owns spawn / pid / phase upserts into `PtyInstanceRegistry`; the natural place to wire the poll loop and to obtain the child pid.
+- `c3-102 state-stores` — `ptyInstancesStore` already fans `PtyInstanceDelta` updates into the indicator; new fields ride existing delta channel for free.
+- `c3-1 Client` — `PtyInstancesIndicator` renders the panel; new "mem" cell added to its grid.
+- `ref-side-effect-adapter` — calling `ps`/`pgrep` is `node:child_process` IO, must live in `*.adapter.ts`.
+- `ref-strong-typing`, `rule-strong-typing` — new fields cross the WS/JSONL boundary and must be named.
+
+Constraint: side-effect lint seals `node:child_process` outside adapter files; cannot use raw `Bun.spawn` from driver core. Constraint: sampler must not block driver event loop, must clear on exit, and must survive a missing pid (pre-spawn / exited).
+
+## Decision
+
+1. Extend `PtyInstanceState` (shared) with `rssBytes: number | null` and `rssPeakBytes: number | null`. Registry baseline initialises both to `null`.
+2. Add `src/server/claude-pty/pty-memory-sampler.adapter.ts` exporting `sampleProcessTreeRssBytes(rootPid: number): Promise`. Implementation: single `ps -A -o pid=,ppid=,rss=` spawn → parse into `{pid, ppid, rssKb}[]` → BFS collect descendants of `rootPid` → sum RSS in bytes. One process spawn per sample regardless of tree depth. Pure parsing helpers (`parsePsOutput`, `collectTreePids`) exported separately for testing.
+3. In `driver.ts`, after `pid` is known, start a `setInterval(2000)` that calls the sampler, computes `peak = max(prev.rssPeakBytes ?? 0, curr)`, and `args.ptyInstanceRegistry?.upsert(chatId, { rssBytes, rssPeakBytes: peak })`. Clear the interval on `pty.exited` (next to existing `phase: "exited"` upsert).
+4. Poll interval fixed at 2 s (no env var, no per-instance override) — matches user decision in design Q&A.
+5. `PtyInstancesIndicator` adds one row cell: `mem · peak ` formatted via a small `formatBytes` helper (B/KB/MB/GB, no decimals at MB+). Hidden until first sample arrives (both fields non-null).
+
+Why this approach wins over a per-pid `ps`-per-descendant loop: one spawn per tick is O(1) cost regardless of subprocess tree depth, avoids `pgrep -P` recursion, and the `ps -A` output is already small (<10 KB on a typical dev box).
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-225 | component | Owns spawn lifecycle + registry upserts; new interval poller wired here | Verify Boundary + Interface rows still describe the registry contract; add the sampler adapter to Files |
+| c3-102 | component | ptyInstancesStore shape evolves with new PtyInstanceState fields; no code change needed but contract widens | Confirm component goal still covers "fan delta into selectors"; no Parent Delta expected |
+| c3-1 | container | PtyInstancesIndicator (under chat-ui) renders new memory cell | Parent Delta: container responsibilities unchanged; only chat-ui component grows |
+| c3-2 | container | New adapter file added under claude-pty/; container responsibilities unchanged | Parent Delta: no-delta evidence — adapter count grows, but boundary identical |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-side-effect-adapter | ps invocation is node:child_process IO; must live in *.adapter.ts to pass side-effect lint | comply |
+| ref-strong-typing | New rssBytes / rssPeakBytes fields cross WS envelope and JSONL boundary | comply |
+| ref-zustand-store | ptyInstancesStore is a Zustand store consuming the widened PtyInstanceState | comply (no shape change in store itself; selectors auto-pick fields) |
+| ref-colocated-bun-test | Sampler parser + adapter need colocated *.test.ts siblings | comply |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-strong-typing | New fields ship in the shared protocol union; no any allowed | comply |
+| rule-zustand-store | ptyInstancesStore selectors widen with new fields; must stay one-concern, colocated test | comply |
+| rule-colocated-bun-test | New adapter + parser + driver wiring tests must sit next to source under bun test | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| shared types | Add rssBytes + rssPeakBytes to PtyInstanceState; bump baseline in registry | src/shared/pty-instance.ts, src/server/claude-pty/pty-instance-registry.ts |
+| sampler adapter | New pty-memory-sampler.adapter.ts exporting parser + tree-RSS function | src/server/claude-pty/pty-memory-sampler.adapter.ts |
+| sampler tests | Pure parser + tree-collect test against fixtures; adapter smoke-test (skip if ps unavailable) | src/server/claude-pty/pty-memory-sampler.adapter.test.ts |
+| driver wiring | Start setInterval(2000) after pid known; clear on exit; upsert rss + peak | src/server/claude-pty/driver.ts |
+| client UI | New mem cell in PtyInstanceRow; add formatBytes helper | src/client/components/chat-ui/PtyInstancesIndicator.tsx |
+| client tests | Snapshot/render test verifies cell renders when fields present, hides when null | src/client/components/chat-ui/PtyInstancesIndicator.test.tsx (extend if exists, else add) |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| c3-225 codemap | Add pty-memory-sampler.adapter.ts to component files list via c3x set c3-225 codemap … if codemap pattern misses it | c3x lookup src/server/claude-pty/pty-memory-sampler.adapter.ts resolves to c3-225 after change |
+| c3-225 Interface section | Document new registry fields (rssBytes, rssPeakBytes) in component contract via c3x write c3-225 --section Interface | c3x read c3-225 --section Interface shows new fields |
+| c3-102 Interface section | Note widened PtyInstanceState shape selectors pick from | c3x read c3-102 --section Interface |
+| c3-check | c3x check returns no errors after edits | c3x check exit 0 |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| eslint side-effect seal | Blocks any new node:child_process import outside adapter glob; sampler MUST be .adapter.ts | bun run lint fails if sampler placed in non-adapter file |
+| bun test | Parser unit tests + driver-interval test catch sampler regressions | bun test src/server/claude-pty/pty-memory-sampler.adapter.test.ts |
+| c3x check | Catches doc drift after Interface section edits | c3x check |
+| Manual smoke | Spawn real PTY, open status panel, observe mem cell ticking every 2 s, peak monotonically non-decreasing | screenshot in PR |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Track only process.memoryUsage() of the kanna server | Measures kanna itself, not the spawned claude child — useless for the user's stated need |
+| pgrep -P recursive descent per tick | N spawns per descendant per tick; sampler cost scales with subprocess depth instead of O(1) — ps -A once is cheaper |
+| Add env-tunable poll interval (KANNA_PTY_MEM_POLL_MS) | User explicitly picked fixed 2 s; extra env var adds surface area without current need |
+| Render sparkline or bar in panel | User picked text-only display; sparkline adds renderer complexity and sample-history state for no current ask |
+| Read /proc//status directly (Linux fast path) | Project supports macOS + Linux; macOS has no /proc. Single ps invocation works on both, keeps adapter platform-uniform |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| ps spawn cost N instances × every 2 s overwhelms host | One ps -A per instance per tick is ~1 ms on modern macOS/Linux; if needed, can share a single tick across all instances in a follow-up | Manual time ps -A -o pid=,ppid=,rss= on dev machine; observe kanna CPU after 10-min session with 3 PTYs |
+| Sampler throws on unexpected ps output and crashes driver | Parser returns null on any parse failure; driver tolerates null rss without upsert | Unit test feeds malformed lines and asserts null return |
+| Interval leaks if exit handler never fires (orphaned PTY) | Driver clears interval on both pty.exited event AND registry remove(chatId); existing exit paths already cover orphans | Driver test asserts clearInterval called on exit |
+| Race: pid recycled by OS during measurement window | Window is 2 s, PIDs do not recycle in <2 s on practical kernels; worst case shows one stale sample then null on next tick | Documented as acceptable; no extra guard |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun test src/server/claude-pty/pty-memory-sampler.adapter.test.ts | green; parser handles valid + malformed fixtures |
+| bun test src/server/claude-pty/driver.test.ts | green; existing driver suite passes (sampler wiring covered by new targeted test) |
+| bun test src/client/components/chat-ui/PtyInstancesIndicator.test.tsx | green; mem cell render + hidden-when-null branches |
+| bun run lint | warning count unchanged or lower; side-effect seal passes |
+| bun run tsc --noEmit | green; new fields typed end-to-end |
+| c3x check | exit 0; component bodies match new interface |
+| Manual: launch dev kanna, open PTY chat, expand status panel | "mem" cell appears within first 2 s of pid being assigned; value updates each tick; peak monotonic non-decreasing |
diff --git a/.c3/adr/adr-20260524-session-share.md b/.c3/adr/adr-20260524-session-share.md
new file mode 100644
index 000000000..619943ff1
--- /dev/null
+++ b/.c3/adr/adr-20260524-session-share.md
@@ -0,0 +1,128 @@
+---
+id: adr-20260524-session-share
+c3-seal: aaee8ed4065044c56de52cb47dc9e221c981423e85d8bf785c8d46be8b20b9de
+title: session-share
+type: adr
+goal: Introduce a read-only session-share capability (c3-228) that lets owners mint a time-limited token URL for a finished Kanna chat, enabling teammates to view the full transcript without a Kanna login or write access.
+status: implemented
+date: "2026-05-24"
+---
+
+## Goal
+
+Introduce a read-only session-share capability (c3-228) that lets owners mint a time-limited token URL for a finished Kanna chat, enabling teammates to view the full transcript without a Kanna login or write access.
+
+## Context
+
+Owners need to show finished Kanna chat sessions to teammates without giving them write access or a Kanna login. Today the only sharing mechanism is the whole-Kanna Cloudflare tunnel (c3-218), which requires recipients to authenticate against the host's password.
+
+## Decision
+
+Introduce c3-228 session-share. Owner clicks Share in the chat header; server builds a frozen JSON snapshot from the event log via existing read-models, persists it under ~/.kanna/shares/.json (mode 0600), appends a share.token_minted event to a new shares event log, and returns /share/. The path is exempt from auth (c3-203 path-prefix bypass); the 256-bit token is the credential. Snapshot only — no live updates. TTL default lives in settings (shareDefaultTtlHours).
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-2 | container | Gains a new public route prefix /share/:token served without auth | ref-local-first-data, ref-side-effect-adapter |
+| c3-203 | component | Gains a path-prefix exemption rule for /share/:token | ref-local-first-data |
+| c3-205 | component | Gains two new event-kind union definitions: share.token_minted and share.token_revoked added to the events-schema discriminated union | ref-event-sourcing, ref-strong-typing |
+| c3-206 | component | Gains the new on-disk shares JSONL log file (shares.jsonl) and the appendShareEvent / getShareEvents methods that append to and read from it | ref-event-sourcing, ref-local-first-data |
+| c3-207 | component | Gains a new read-model projection for the shares log (share projection) | ref-cqrs-read-models |
+| c3-115 | component | Gains a ShareButton + SharePopover in the chat header (chat-ui-chrome); emits share.mint via WebSocket | ref-ws-subscription |
+| c3-204 | component | Shares directory ~/.kanna/shares resolves through c3-204; no new symbol added — c3-228 consumes the existing kannaDir accessor. | ref-local-first-data |
+| c3-306 | component | Gains ShareSnapshot, ShareToken shared types | ref-strong-typing |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-local-first-data | Snapshot files must live under ~/.kanna/shares/ (mode 0600); no remote upload | comply |
+| ref-event-sourcing | share.token_minted and share.token_expired must be appended to a JSONL shares log before any mutation | comply |
+| ref-cqrs-read-models | Share list / lookup must read from the shares projection, not directly from disk | comply |
+| ref-side-effect-adapter | All fs operations for snapshot persistence must live in a *.adapter.ts file | comply |
+| ref-strong-typing | ShareSnapshot, ShareToken, and all event payloads must use concrete TypeScript types — no any | comply |
+| ref-colocated-bun-test | Integration test for share-route.ts must sit next to the route file per project test convention | comply |
+| ref-ws-subscription | The share.mint request and share.minted response flow through the single typed WebSocket handled by c3-208; c3-115 client emits via that socket | comply |
+| ref-zustand-store | Session-share does not add or modify Zustand stores in c3-115 (chat-ui-chrome); the ShareButton is a new UI surface but does not own persistent store state | N.A - no zustand store changes in c3-115 |
+| ref-provider-adapter | Session-share does not touch provider normalization or agent driver paths; affected components (c3-210, c3-211, c3-212, c3-213, c3-225) are cited only because they use the ref generally, not because this ADR changes them | N.A - no provider adapter code touched |
+| ref-tool-hydration | Session-share does not involve tool-call hydration; snapshot is built from event-store read-models only; affected components (c3-210, c3-215, c3-226) are cited generally, not changed by this ADR | N.A - no tool hydration code touched |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-strong-typing | Share event payloads and snapshot shape must be fully typed at every boundary | comply |
+| rule-colocated-bun-test | HTTP route integration test must sit next to the route file | comply |
+| rule-zustand-store | Session-share does not add or modify any Zustand store in c3-115; the ShareButton is a pure UI component that calls a WebSocket command, no store ownership | N.A - no zustand store changes |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| c3-228 component scaffold | Add c3-228 session-share to c3-2 via c3x; wire 5 refs | .c3/ commit |
+| ADR | Add adr-20260524-session-share; set status accepted | .c3/ commit |
+| Shared types | Add ShareSnapshot, ShareToken, ShareEventKind to src/shared/types/share.ts | c3-228 Contract |
+| Protocol | Add share.mint WsEnvelope and share.minted response to src/shared/protocol.ts | c3-302 |
+| Token generator | Implement 256-bit random token in src/server/session-share/token.ts | c3-228 |
+| Snapshot-store adapter | Implement fs read/write in src/server/session-share/snapshot-store.adapter.ts | ref-side-effect-adapter |
+| Snapshot builder | Assemble frozen JSON from event-store read-models in src/server/session-share/snapshot-builder.ts | c3-207 |
+| Share projection | Project share events into in-memory map in src/server/session-share/share-projection.ts | ref-cqrs-read-models |
+| SessionShareService | Orchestrate mint, persist, sweep in src/server/session-share/session-share-service.ts | c3-228 |
+| HTTP route | Add GET /share/:token route; exempt from auth in src/server/session-share/share-route.ts | c3-203 |
+| Snapshot sweep | TTL cron in src/server/session-share/snapshot-sweep.ts appending share.token_expired | c3-228 |
+| Settings TTL row | Add shareDefaultTtlHours to app-settings.ts and settings UI | c3-116 |
+| ws-router envelopes | Handle share.mint in ws-router.ts and emit share.minted | c3-208 |
+| Client share-store | Zustand store for share state in src/client/stores/share-store.ts | c3-102 |
+| ShareButton + SharePopover | UI components in src/client/components/ShareButton.tsx | c3-112 |
+| ShareViewPage | Read-only transcript page at /share/:token in src/client/pages/ShareViewPage.tsx | c3-1 |
+| Integration test | HTTP test for /share/:token route in src/server/session-share/share-route.test.ts | rule-colocated-bun-test |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| c3-228 component | New component added via c3x add component session-share --container c3-2 | c3x check reports clean |
+| adr-20260524-session-share | New ADR added via c3x add adr session-share; set status accepted | c3x check --include-adr reports clean |
+| Refs wired | 5 refs wired to c3-228: ref-local-first-data, ref-event-sourcing, ref-cqrs-read-models, ref-side-effect-adapter, ref-strong-typing | c3x check reports clean |
+| N.A - schema/validator | No c3x schema or validator changes required by this ADR | N.A - no underlay schema modified |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| bun run lint | ESLint side-effect adapter seal catches any direct fs calls outside *.adapter.ts in server production code | CLAUDE.md side-effect-lint section |
+| bun test src/server/session-share/share-route.test.ts | Integration test verifies /share/:token returns 200 with snapshot, unknown tokens return 404, expired tokens return 410 | Task 17 |
+| c3x check | Validates c3-228 sections and wired refs remain consistent | c3x check output |
+| TypeScript strict mode | tsc catches any untyped shapes in share event payloads and snapshot boundary | bun run build |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Live ws subscription with viewer scope | Heavier auth surface across the entire event-store path; does not meet the "no login required" requirement |
+| Static HTML export hosted externally | Loses the chat-page look/feel and conflicts with the "full chat page read-only" requirement |
+| Hosted snapshot upload service | Out of scope; no Kanna backend service and violates local-first-data ref |
+| Whole-Kanna tunnel with password | Recipients must create a Kanna login; does not provide per-session granularity |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Token guessing attack | 256-bit random token; ~3.4×10^77 space makes brute-force infeasible | Token length check in token.ts unit test |
+| Snapshot disk exhaustion | TTL default + sweep cron append share.token_expired and delete file | Sweep integration test asserts file deleted after TTL |
+| Auth bypass regression | /share/:token path-prefix exemption must be narrow; any other prefix remains gated | bun test share-route.test.ts asserts non-share paths still return 401 |
+| Stale snapshot served post-TTL | Sweep checks expiry on startup replay; expired tokens return 410 | Integration test with expired snapshot fixture |
+| NO_TUNNEL mint refused | Mint endpoint checks tunnel active before minting; returns NO_TUNNEL error if none | Integration test with no tunnel fixture |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| c3x check --include-adr | Clean — no errors |
+| bun run lint | Zero warnings/errors on all new files |
+| bun test src/server/session-share/share-route.test.ts | All assertions pass |
+| GET /share/ returns snapshot JSON | 200 with frozen transcript JSON |
+| GET /share/ returns 410 | 410 Gone |
+| GET /share/ returns 404 | 404 Not Found |
+| GET /api/anything without cookie still returns 401 | Auth bypass is scoped to /share/ prefix only |
diff --git a/.c3/adr/adr-20260525-remove-standalone-export-legacy.md b/.c3/adr/adr-20260525-remove-standalone-export-legacy.md
new file mode 100644
index 000000000..85eb696ed
--- /dev/null
+++ b/.c3/adr/adr-20260525-remove-standalone-export-legacy.md
@@ -0,0 +1,114 @@
+---
+id: adr-20260525-remove-standalone-export-legacy
+c3-seal: 27e98c3e130b233cdd553951e982b438394161d8ed79108eec25e2d558667db4
+title: remove-standalone-export-legacy
+type: adr
+goal: 'Remove the legacy `chat.exportStandalone` code path — the upstream-`kanna.sh`-upload "Share chat" flow inherited from `jakemor/kanna` — from server, shared protocol, and client. The path is broken on this fork (returns `"No release viewer assets were found for 0.76.0."` because upstream only publishes viewer assets for `0.40.0` against `jakemor/kanna` releases, not against `cuongtranba/kanna`) and is fully superseded by the working session-share feature owned by `c3-228 session-share`. Outcome: a single share UX in the chat navbar (the `ShareButton` popover from `c3-228`), no orphaned upstream-upload code, no dead `StandaloneShareDialog`, no dead WS RPC, no dead types in `src/shared/types.ts`.'
+status: implemented
+date: "2026-05-25"
+---
+
+## Goal
+
+Remove the legacy `chat.exportStandalone` code path — the upstream-`kanna.sh`-upload "Share chat" flow inherited from `jakemor/kanna` — from server, shared protocol, and client. The path is broken on this fork (returns `"No release viewer assets were found for 0.76.0."` because upstream only publishes viewer assets for `0.40.0` against `jakemor/kanna` releases, not against `cuongtranba/kanna`) and is fully superseded by the working session-share feature owned by `c3-228 session-share`. Outcome: a single share UX in the chat navbar (the `ShareButton` popover from `c3-228`), no orphaned upstream-upload code, no dead `StandaloneShareDialog`, no dead WS RPC, no dead types in `src/shared/types.ts`.
+
+## Context
+
+The legacy adapter `src/server/standalone-export.adapter.ts` PUTs a serialized transcript bundle to `https://kanna.sh/api/share//transcript.json` and expects the upstream Cloudflare Worker to find matching `export-viewer__*` GitHub Release assets on the **upstream** `jakemor/kanna` repo for the bundle's `viewerVersion` (= local `package.json` version, currently `0.76.0`). Upstream only publishes those assets for `0.40.0`, so every share on this fork fails. The path was kept after the upstream merge because no one had a replacement. PR #318 (commit `c7a7245`, released as `0.76.0`) added the read-only session-share feature documented in `c3-228 session-share` with ADR `adr-20260524-session-share`: mint a token via WS, persist a frozen snapshot under `~/.kanna/shares/.json`, serve it at `GET /share/:token` over the existing Cloudflare tunnel (`c3-218 share`), no third party involved. The new path is wired through the `ShareButton` / `SharePopover` components in the navbar (`src/client/components/share/`). The two paths now coexist: the legacy `UserRoundPlus` button in `ChatNavbar.tsx` triggers the broken upstream upload, while the adjacent `ShareButton` triggers the working session-share. Affected topology: `c3-2 server` (legacy WS handler + adapter live here, uncharted), `c3-3 shared` (legacy protocol cases + types), `c3-115 chat-ui-chrome` (legacy dialog + navbar button + state in `useKannaState`). The legacy code is uncharted in C3 — `c3x lookup src/server/standalone-export.adapter.ts` returns no match. Constraint: don't touch any code owned by `c3-228 session-share`, `c3-218 share`, `c3-306 share-shared`, or `c3-115 chat-ui-chrome` beyond pruning the prop/state surface that fed the dead button.
+
+## Decision
+
+Delete the entire legacy code path in this PR. Server side: remove `src/server/standalone-export.adapter.ts`, its colocated `*.test.ts`, the import and `case "chat.exportStandalone":` handler in `src/server/ws-router.ts`. Shared side: remove the `chat.exportStandalone` arm of the `WsCommand` union in `src/shared/protocol.ts`, the `StandaloneTranscript*` re-imports, the `StandaloneTranscriptExportResult` member of the `ack` `result` union, and the `StandaloneTranscript*` type families in `src/shared/types.ts`. Client side: remove `src/client/components/chat-ui/StandaloneShareDialog.tsx`, the `StandaloneShareDialog` import + mount in `src/client/app/App.tsx`, the `handleShareChat` destructure passed to `useKeyboardShortcuts` in `App.tsx`, all six handlers in `useKannaState.ts` (`handleExportStandalone`, `handleShareChat`, `handleCloseStandaloneShareDialog`, `handleOpenStandaloneShareLink`, `handleCopyStandaloneShareLink`), their state fields (`isExportingStandalone`, `standaloneShareUrl`, `standaloneShareComplete`), the unused `downloadTextFile` helper and `StandaloneTranscriptExportCommandResult` import, the `onExportTranscript` / `canExportTranscript` / `isExportingTranscript` / `exportTranscriptComplete` prop chain through `ChatNavbar.tsx` (both the compact dropdown variant and the main toolbar variant) and the wiring in `ChatPage/index.tsx`. Reasoning: option (a) "keep both, document one as broken" leaves two buttons that look identical to users and one will keep firing tickets. Option (b) "rewrite the legacy adapter to use the new snapshot" duplicates `c3-228`'s contract surface for zero user benefit. Option (c) "leave it for a future release" loses the cleanup window while context is hot. Deletion is the only outcome that produces one share UX and zero dead code. Removal is safe because the new path is shipped and the keyboard shortcut binding to `handleShareChat` is the only non-button caller — it is removed alongside.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-2 | container | Hosts the legacy WS RPC + adapter file being deleted | Confirm c3-228 remains the sole share component under c3-2 |
+| c3-3 | container | Hosts the legacy chat.exportStandalone protocol case + StandaloneTranscript* types being deleted | Confirm c3-306 continues to own all live share types |
+| c3-115 | component | Owns StandaloneShareDialog.tsx (file deleted), ChatNavbar.tsx (legacy button + prop chain pruned), and the legacy useKannaState slice | Confirm ChatNavbar.tsx keeps the new ShareButton/SharePopover slot from c3-228 untouched; remove dead props from component contract narrative if any |
+| c3-228 | component | Not modified — it is the surviving share path; documented here as the reason legacy removal is safe | No-op: confirm no contract surface change |
+| c3-218 | component | Not modified — Cloudflare tunnel that the surviving session-share depends on; documented here as still required | No-op |
+| c3-306 | component | Not modified — owns live share types; documented here as the home for any share types remaining after the prune | No-op |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-side-effect-adapter | Legacy standalone-export.adapter.ts is correctly a .adapter.ts leaf; its deletion does not introduce new IO in non-adapter modules. After deletion, all share IO lives in session-share/snapshot-store.adapter.ts already governed by this ref. | comply |
+| ref-strong-typing | Removing the StandaloneTranscript* type families must not introduce any in the union types that still mention them (the ack.result union, WsCommand). | comply |
+| ref-local-first-data | Removing the only remote-upload code path strengthens compliance — no fork instance ever sends transcript data to a third party again. The surviving session-share already complies via ~/.kanna/shares/. | comply |
+| ref-event-sourcing | Legacy path does not emit events, so deletion does not change the event schema. The surviving session-share's share.token_minted / share.token_expired events are unaffected. | N.A - legacy path bypasses event store |
+| ref-cqrs-read-models | Same: legacy path reads store.getMessages directly and serializes inline; no read-model contract changes. | N.A - legacy path bypasses read models |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-zustand-store | Dead state being removed (isExportingStandalone, standaloneShareUrl, standaloneShareComplete) lives in useKannaState (server-derived hook), not a Zustand store, so the rule was not violated by the legacy code and the deletion does not relocate any client UI-local state. | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| Server adapter delete | Remove src/server/standalone-export.adapter.ts and src/server/standalone-export.adapter.test.ts | git rm src/server/standalone-export.adapter.ts src/server/standalone-export.adapter.test.ts |
+| WS router prune | Remove import { writeStandaloneTranscriptExport } at ws-router.ts:22 and case "chat.exportStandalone": block at ws-router.ts:1850-1862 | src/server/ws-router.ts |
+| Shared protocol prune | Remove the chat.exportStandalone member of WsCommand union (protocol.ts:238-242), the StandaloneTranscript* re-imports at top (protocol.ts:20-21), and StandaloneTranscriptExportResult from the ack result union (protocol.ts:321) | src/shared/protocol.ts |
+| Shared types prune | Remove StandaloneTranscriptAttachmentMode, StandaloneTranscriptTheme, StandaloneTranscriptBundle, StandaloneTranscriptExportResult, StandaloneTranscriptExportFailureResult, StandaloneTranscriptExportCommandResult from src/shared/types.ts (lines 17-118) | src/shared/types.ts |
+| Client dialog delete | Remove src/client/components/chat-ui/StandaloneShareDialog.tsx | git rm |
+| App mount prune | Remove StandaloneShareDialog import + JSX mount in src/client/app/App.tsx (lines 5, 458-468), drop handleShareChat from destructure passed to useKeyboardShortcuts (lines 230, 252-253) | src/client/app/App.tsx |
+| useKannaState prune | Remove handleExportStandalone, handleShareChat, handleCloseStandaloneShareDialog, handleCopyStandaloneShareLink, handleOpenStandaloneShareLink, the three setIsExportingStandalone/setStandaloneShareUrl/setStandaloneShareComplete state fields, the StandaloneTranscriptExportCommandResult import, the downloadTextFile helper, and all corresponding entries in the returned snapshot object | src/client/app/useKannaState.ts |
+| ChatNavbar prune | Remove the onExportTranscript / canExportTranscript / isExportingTranscript / exportTranscriptComplete prop quartet from both the compact dropdown variant (lines 37-49, 79-95) and the main toolbar variant (lines 114-117, 153-156, 286-308, 331-352) of src/client/components/chat-ui/ChatNavbar.tsx | src/client/components/chat-ui/ChatNavbar.tsx |
+| ChatPage prop prune | Remove the onExportTranscript/canExportTranscript/isExportingTranscript/exportTranscriptComplete quartet passed at src/client/app/ChatPage/index.tsx:957-960 | src/client/app/ChatPage/index.tsx |
+| Verification | bun run lint + bun test src/server/ws-router + bun test src/server/session-share + bun test src/client/components/share + git grep -E 'exportStandalone | StandaloneShare |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| c3-2 component inventory | N.A - legacy standalone-export.adapter.ts is uncharted in c3-2 (verified via c3x lookup); deletion removes an uncharted file, no c3-2 doc edit required | c3x lookup src/server/standalone-export.adapter.ts returns matches: |
+| c3-3 component inventory | N.A - the StandaloneTranscript* types in src/shared/types.ts are not enumerated in any c3-3 component contract; removal does not break a documented surface | c3x list shows c3-301..c3-307 with no entry referencing these types |
+| c3-115 chat-ui-chrome contract | N.A - StandaloneShareDialog.tsx is not enumerated in c3-115's Derived Materials or Contract sections; the file is uncharted within the component | c3x read c3-115 --full body does not mention StandaloneShareDialog |
+| ADR registry | This ADR adr-20260525-remove-standalone-export-legacy created at proposed; on completion transitioned to implemented | c3x list --include-adr |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| bun run lint | TypeScript noUnusedLocals + ESLint catch any dangling reference to removed types/handlers/props | CI workflow .github/workflows/test.yml runs bun run lint with --max-warnings=0 |
+| bun test | Existing ws-router.test.ts, session-share suite, and share-store.test.ts must still pass after WS handler removal | CI runs bun test on every push to main |
+| git grep final sweep | Manual check that no exportStandalone / StandaloneShare / standalone-export / onExportTranscript / handleShareChat / handleExportStandalone / isExportingStandalone token remains in src/ | git grep -E '' src/ returns empty |
+| Side-effect lint seal | ESLint no-restricted-imports already prevents new fetch/IO in non-adapter modules; deletion removes one of the legitimate .adapter.ts callers, no rule edit needed | eslint.config.js no-restricted-imports block |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Keep legacy path, document the broken state in README | Two share buttons with identical icons; users will keep hitting the broken one and filing tickets. Documentation cannot stop a button from being clicked. |
+| Rewrite legacy adapter to mint a session-share token internally and return its URL | Duplicates c3-228 contract surface (mintShare) behind a second protocol case; doubles the WS surface forever for zero user benefit; the existing ShareButton already calls share.mint directly. |
+| Move legacy code to a sibling fork and ship a Cloudflare Worker for cuongtranba/kanna releases (the original brainstorm from this session) | Owner explicitly redirected to "no host" → session-share already delivers that; reviving the Worker plan now is a regression. |
+| Defer deletion to a future "cleanup" release | Loses the cleanup window while the context is hot in one head; the dead UI keeps shipping; same outcome but later and harder. |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Hidden caller of chat.exportStandalone outside useKannaState (e.g., a keyboard shortcut, a CLI script, a test fixture) breaks silently | Final git grep sweep for every removed identifier across src/, scripts/, tests/, and CLAUDE.md surfaces | git grep -E 'exportStandalone |
+| Removing downloadTextFile breaks a non-share consumer | Pre-deletion grep confirms downloadTextFile has exactly one caller (the deleted code path) | grep -rn 'downloadTextFile' src/ returns only the to-be-deleted call site |
+| WsCommand ack.result union narrowing breaks a runtime branch | bun run lint flags any narrow on the removed type; bun test covers the ack path | bun run lint && bun test both pass |
+| User press of keyboard shortcut previously bound to handleShareChat does nothing | Audit useKeyboardShortcuts callers in App.tsx; either remap to ShareButton trigger or drop the binding | grep -n 'handleShareChat |
+| Stale doc references to "Share chat" upload flow in wiki/ or docs/ | Sweep wiki/** and docs/** for legacy phrasing | grep -rn 'kanna.sh |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun run lint from repo root | exit 0, zero warnings |
+| bun test src/server/ws-router.test.ts | all pass |
+| bun test src/server/session-share/ | all pass (no regression to surviving share path) |
+| bun test src/client/components/share/ | all pass |
+| git grep -E 'exportStandalone | StandaloneShare |
+| Manual: open running Kanna, navigate to a chat, confirm exactly one share affordance (the ShareButton popover) is visible in the navbar | one button only |
+| Manual: click ShareButton, mint a token, open the public URL in a private window, confirm read-only transcript renders | snapshot served at /share/ |
+| c3x check | PASS |
+| c3x set adr-20260525-remove-standalone-export-legacy status implemented after merge | terminal state recorded |
diff --git a/.c3/adr/adr-20260525-share-decouple-tunnel.md b/.c3/adr/adr-20260525-share-decouple-tunnel.md
new file mode 100644
index 000000000..8701a4299
--- /dev/null
+++ b/.c3/adr/adr-20260525-share-decouple-tunnel.md
@@ -0,0 +1,131 @@
+---
+id: adr-20260525-share-decouple-tunnel
+c3-seal: 6e0272277e62dc766b146d0dec63df8161defeb6aaab52ff585880bd76747dab
+title: share-decouple-tunnel
+type: adr
+goal: Decouple `c3-228 session-share` mint from the per-chat Cloudflare tunnel state. Share URLs are derived from the origin of the WebSocket upgrade request. Mint never refuses for missing tunnel; the `NO_TUNNEL` error path is removed. Public reachability of the resulting URL is a deployment concern.
+status: implemented
+date: "2026-05-25"
+---
+
+## Goal
+
+Decouple `c3-228 session-share` mint from the per-chat Cloudflare tunnel state. Share URLs are derived from the origin of the WebSocket upgrade request. Mint never refuses for missing tunnel; the `NO_TUNNEL` error path is removed. Public reachability of the resulting URL is a deployment concern.
+
+## Context
+
+`c3-228 session-share` (adr-20260524-session-share) required an active Cloudflare tunnel for `c3-218 share` before mint would succeed: the service called `getTunnelBaseUrl()`, and when it returned `null` the mint returned `{ kind: "no_tunnel" }`. The `ShareButton` UI was gated on `tunnelUp` and rendered disabled with a "Start a Cloudflare tunnel to enable public sharing" tooltip when no tunnel record existed.
+
+This produced two problems:
+
+1. Users hitting Kanna over their own configured hostname (e.g. `https://kanna.lowbit.link` already wired via a separate always-on cloudflared) saw the button disabled because no _per-chat_ tunnel record existed in the snapshot — even though their instance was already publicly reachable.
+2. Dev/local users who genuinely could not be reached publicly had no way to mint a URL for testing or for in-network sharing.
+
+Tunnel state is a deployment property: whoever runs the server already chose whether to expose it. The mint layer should not second-guess that choice. The simplest model is "build the URL from whatever origin the owner used to reach the server" — same hostname they're already typing into their browser, same scheme.
+
+## Decision
+
+Remove the tunnel-base coupling from `c3-228 session-share`:
+
+1. `SessionShareDeps.getTunnelBaseUrl` is removed. `mintToken` and `listSharesForChat` accept a `baseUrl: string` argument supplied by the caller.
+2. `ShareError.kind === "no_tunnel"` is removed from the shared error union. The `no_tunnel` branch in `mintToken` is deleted.
+3. The WS router captures the request origin (scheme + host) at WebSocket upgrade time and stores it in `ClientState.originHost`. Mint and list calls pass `ws.data.originHost` as `baseUrl`. URLs are formed as `${originHost}/share/`.
+4. Client-side: `ShareButton` and `SharePopover` drop the `tunnelUp` prop. The button is always enabled when a chat is selected. The "tunnel down" tooltip and disabled-state path are removed.
+5. `ChatPage` drops the `shareTunnelUp` derivation from the chat snapshot.
+
+Public reachability is a deployment concern — solved by the operator running cloudflared, exposing a port, deploying to a VPS, or any other means. The mint layer does not enforce it.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-228 | component | Mint contract change: drop getTunnelBaseUrl dep, drop no_tunnel failure mode, accept baseUrl per call. Foundational-flow Precondition row drops the tunnel requirement. Business-flow Alternate — NO_TUNNEL row is removed. | ref-strong-typing |
+| c3-208 | component | ws-router share.mint / share.list handlers pass ws.data.originHost into the service. ClientState gains originHost?: string. Upgrade handler captures the request origin. | ref-ws-subscription, ref-strong-typing |
+| c3-202 | component | HTTP server serverInstance.upgrade data payload gains originHost derived from req.headers.host + protocol. | ref-strong-typing |
+| c3-115 | component | ShareButton and SharePopover drop tunnelUp prop; ChatNavbar drops shareTunnelUp plumbing; ChatPage drops liveTunnelRecord derivation for share. | ref-zustand-store (N.A) |
+| c3-306 | component | ShareError discriminated union loses the no_tunnel variant. | ref-strong-typing |
+| c3-218 | component | No longer referenced by c3-228. c3-218 share (cloudflared tunnel) remains for its own purpose — public exposure of the host — but is no longer a precondition for mint. | N.A — only reference removed |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-strong-typing | ShareError union narrows; MintRequest / listSharesForChat signatures change; ClientState.originHost added — all must be concretely typed, no any | comply |
+| ref-ws-subscription | Mint and list still flow through the typed WebSocket; only the payload threading changes | comply |
+| ref-colocated-bun-test | Existing session-share.test.ts and share-projection.test.ts updated in place alongside their subjects | comply |
+| ref-local-first-data | Snapshot persistence unchanged — still ~/.kanna/shares/.json (mode 0600); only URL formation changes | comply |
+| ref-event-sourcing | No event schema change; share.token_minted and share.token_revoked payloads untouched | comply |
+| ref-cqrs-read-models | Share projection unchanged | comply |
+| ref-side-effect-adapter | No new fs / network calls; reads req.headers.host at upgrade time, which is already part of the HTTP boundary in c3-202 | comply |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-strong-typing | Union narrowing and new originHost field must be precisely typed | comply |
+| rule-colocated-bun-test | Updated tests sit next to their subjects | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| Shared types | Remove no_tunnel variant from ShareError in src/shared/session-share/types.ts | src/shared/session-share/types.ts |
+| SessionShareService | Drop getTunnelBaseUrl from SessionShareDeps; remove no_tunnel branch in mintToken; accept baseUrl: string in mintToken and listSharesForChat | src/server/session-share/index.ts |
+| Server bootstrap | Stop passing getTunnelBaseUrl into SessionShareService deps | src/server/server.ts |
+| WS upgrade | Capture request origin into ClientState.originHost at upgrade time | src/server/server.ts, src/server/ws-router.ts |
+| WS router | Pass ws.data.originHost ?? "" to mintToken and listSharesForChat | src/server/ws-router.ts |
+| Client UI | Remove tunnelUp prop from ShareButton, SharePopover; always-enabled state; drop tunnel-down tooltip path | src/client/components/share/ShareButton.tsx, src/client/components/share/SharePopover.tsx |
+| ChatNavbar | Drop shareTunnelUp prop + plumbing | src/client/components/chat-ui/ChatNavbar.tsx |
+| ChatPage | Drop liveTunnelRecord / shareTunnelUp derivation | src/client/app/ChatPage/index.tsx |
+| Tests | Update session-share.test.ts to use baseUrl arg; remove no_tunnel assertions; update ShareButton.test.tsx / SharePopover.test.tsx to drop tunnelUp | colocated *.test.ts(x) |
+| C3 doc | Update c3-228-session-share.md: drop Precondition tunnel row, drop Alternate — NO_TUNNEL row, drop the dependency on c3-218 from the description | .c3/c3-2-server/c3-228-session-share.md |
+| Wiki | Update wiki/src/content/docs/sharing/session-share.mdx to remove tunnel-required language | wiki/src/content/docs/sharing/session-share.mdx |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| c3-228 component | Edit c3-2-server/c3-228-session-share.md Foundational Flow + Business Flow + Change Safety to drop tunnel precondition and NO_TUNNEL rows | c3x check reports clean |
+| ADR | This ADR — adr-20260525-share-decouple-tunnel — added; status implemented after code merged | c3x check --include-adr reports clean |
+| Refs wired | No ref additions/removals on c3-228; existing 5 refs unchanged | c3x check reports clean |
+| N.A - schema/validator | No c3x schema or validator changes required by this ADR | N.A - no underlay schema modified |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| bun run lint | ESLint catches any leftover tunnelUp / getTunnelBaseUrl / no_tunnel references | bun run lint output |
+| bun run build | tsc rejects any remaining no_tunnel matcher / missing originHost field | bun run build output |
+| bun test src/server/session-share/ | All existing tests pass after signature update | bun test output |
+| bun test src/client/components/share/ | ShareButton + SharePopover render + interaction tests pass without tunnelUp | bun test output |
+| c3x check --include-adr | Validates updated c3-228 and new ADR remain consistent | c3x check output |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| KANNA_PUBLIC_BASE_URL env var | Adds deployment config surface; "make it simple" — owner's current origin is already the right answer 99% of the time |
+| Keep getTunnelBaseUrl but fall back to request host when null | Two URL-source code paths; harder to reason about; doesn't solve the "tunnel record absent but instance is reachable" case described in Context |
+| Keep tunnelUp gate, just fix the snapshot derivation | Treats symptom, not cause; mint layer should not own deployment-reachability concerns |
+| Server-side warn when URL looks local | Out of scope for this ADR; can be added later as a banner without revisiting the mint contract |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Mint succeeds, URL not reachable from recipient | Out of scope by design — deployment concern. Owners running on bare localhost get a http://localhost:3210/share/ URL they can paste in-network or self-test | Wiki updated to document the new "URL = your current origin" contract |
+| Empty originHost (e.g. unit test wiring forgets to set it) | URL falls back to /share/ (relative). Existing test covers the empty case to keep behaviour explicit | session-share.test.ts |
+| Host header spoofing rewrites the URL to attacker-chosen domain | Threat exists upstream of c3-228 (any reverse-proxy hop already trusts Host). Owner is the only consumer of the minted URL — they can copy/paste verify before sending | N.A — accepted risk |
+| Scheme guessed wrong (http vs https) behind a TLS-terminating tunnel | Use the request URL's protocol when available; default to https when running behind a proxy header x-forwarded-proto: https | session-share.test.ts |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| c3x check --include-adr | Clean — no errors |
+| bun run lint | Zero warnings/errors |
+| bun run build | tsc clean |
+| bun test src/server/session-share/ | All assertions pass |
+| bun test src/client/components/share/ | All assertions pass |
+| Manual: open http://localhost:5174/, click Share on any chat | Mint succeeds; popover shows http://localhost:5174/share/ URL |
+| Manual: open https://kanna.lowbit.link/, click Share on any chat | Mint succeeds; popover shows https://kanna.lowbit.link/share/ URL |
diff --git a/.c3/adr/adr-20260529-pty-oneshot-channel-push-prompt-delivery.md b/.c3/adr/adr-20260529-pty-oneshot-channel-push-prompt-delivery.md
new file mode 100644
index 000000000..700574d72
--- /dev/null
+++ b/.c3/adr/adr-20260529-pty-oneshot-channel-push-prompt-delivery.md
@@ -0,0 +1,103 @@
+---
+id: adr-20260529-pty-oneshot-channel-push-prompt-delivery
+c3-seal: aabdb0a280eeeb464415679ac2e90aa913a1bc0544a9b3127c655ab6e7c969f2
+title: pty-oneshot-channel-push-prompt-delivery
+type: adr
+goal: For one-shot PTY Claude spawns (subagent delegations), deliver the initial prompt via a Claude Code `notifications/claude/channel` MCP push from the kanna-mcp loopback HTTP server instead of typing it into the TUI as a bracketed paste. Add this as a new IN contract surface on `c3-225-claude-pty-driver` ("Channel prompt push") covering the kanna-mcp capability declaration, the dev-channels CLI flag, the dev-channels TUI dialog dismissal, the channel-client-ready signal, and the fail-fast behavior when the channel is unavailable.
+status: proposed
+date: "2026-05-29"
+---
+
+## Goal
+
+For one-shot PTY Claude spawns (subagent delegations), deliver the initial prompt via a Claude Code `notifications/claude/channel` MCP push from the kanna-mcp loopback HTTP server instead of typing it into the TUI as a bracketed paste. Add this as a new IN contract surface on `c3-225-claude-pty-driver` ("Channel prompt push") covering the kanna-mcp capability declaration, the dev-channels CLI flag, the dev-channels TUI dialog dismissal, the channel-client-ready signal, and the fail-fast behavior when the channel is unavailable.
+
+## Context
+
+Subagent delegations under `KANNA_CLAUDE_DRIVER=pty` use a one-shot claude spawn. Prompts were delivered through `tui-control.sendUserPrompt` which wraps the text in bracketed paste (`\x1b[200~…\x1b[201~`) and submits with `\r`. The claude TUI silently collapses multi-line pastes into a `[Pasted text #N +K lines]` placeholder; the placeholder is what gets submitted. Subagent run `fb83a848` (session `d6d265a4`) received only **6 input tokens** of a 2743-char/22-line prompt and emitted "please make a follow-up question to ask the user" instead of investigating. A Phase 0 spike proved that a single `notifications/claude/channel` notification on a freshly spawned idle claude wakes a turn and delivers the full payload reliably, provided the channel capability is declared, the CLI flag `--dangerously-load-development-channels server:kanna` is set, the DevChannelsDialog is dismissed, and the channel client is ready before push. Spike code lives in `spike/channel-mcp.ts` + `spike/run.ts`, kept untracked.
+
+## Decision
+
+Use channel push for one-shot subagent prompt delivery, gated by `KANNA_PTY_CHANNEL_DELIVERY=enabled` (default). Driver waits for `channelClientReady` (with `KANNA_PTY_CHANNEL_READY_TIMEOUT_MS=15000` default) then calls `pushChannelPrompt(content)` exactly once. No silent paste fallback — if the channel client is not ready within the timeout, the spawn fails fast with a clear error and a closed transcript stream. Main-chat (interactive) PTY sessions and the SDK driver are unchanged.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-225 | component | Adds new IN contract surface (Channel prompt push) and new Change Safety risk row (channel-ready timeout) | Add Channel prompt push surface; add fail-fast risk row; update Foundational Flow with kanna-mcp channel notification path |
+| c3-226 | component | kanna-mcp now declares claude/channel + claude/channel/permission experimental capabilities and exposes pushChannelPrompt + channelClientReady | Add channel capability surface and push handle |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-provider-adapter | Channel push is part of the PTY provider transport contract | comply |
+| ref-side-effect-adapter | New side effects (PTY input, MCP notification) stay inside existing adapters | comply |
+| ref-strong-typing | pushChannelPrompt(content: string) and channelClientReady use typed shapes | comply |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-strong-typing | Channel payload, capability declaration, env flags are strongly typed | comply |
+| rule-colocated-bun-test | New tests sit beside their sources (tui-control.test.ts, pty-cli-args.test.ts, kanna-mcp-http.test.ts, driver.test.ts) | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| Channel payload helper | buildChannelNotification(content) pure builder + test | src/server/claude-pty/channel-notification.ts |
+| Capability + push handle | McpServer declares experimental['claude/channel'+'/permission']; pushChannelPrompt + channelClientReady promise on handle | src/server/kanna-mcp-http.ts |
+| Dev-channels dialog dismissal | waitForTuiReadyDismissingDialogs dismisses trust + dev-channels dialogs, gated by postDismissOffset | src/server/claude-pty/tui-control.ts |
+| CLI args | buildPtyCliArgs channelServerName appends --dangerously-load-development-channels server:kanna for one-shot | src/server/claude-pty/pty-cli-args.ts |
+| Driver wiring | One-shot: wait for channelClientReady (timeout from env), push once with framed system-prompt append; fail-fast cleanup closes transcript stream | src/server/claude-pty/driver.ts |
+| Env docs | KANNA_PTY_CHANNEL_DELIVERY, KANNA_PTY_CHANNEL_READY_TIMEOUT_MS documented in CLAUDE.md | CLAUDE.md |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| c3-225 Contract | Add row "Channel prompt push — IN — One-shot subagent prompt delivered via notifications/claude/channel MCP push from kanna-mcp; bracketed paste retained for interactive sessions" | c3x read c3-225 shows new row |
+| c3-225 Change Safety | Add row "Channel client not ready before timeout — driver throws and closes transcript stream" with grep + test detection | c3x read c3-225 shows new row |
+| c3-225 Foundational Flow | Add row "Prompt — channel push" referencing kanna-mcp (c3-226) | c3x read c3-225 shows row |
+| c3-226 Contract | Add row noting channel capability declaration + pushChannelPrompt handle | c3x read c3-226 shows row |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| bun test src/server/claude-pty/driver.test.ts | Channel-push wiring + fail-fast timeout covered | bun test passes |
+| bun test src/server/claude-pty/channel-notification.test.ts | Payload builder shape verified | bun test passes |
+| bun test src/server/claude-pty/tui-control.test.ts | Trust + dev-channels dismissal sequence covered, no premature ready | bun test passes |
+| bun test src/server/claude-pty/pty-cli-args.test.ts | Channel CLI flag appended only for one-shot | bun test passes |
+| bun run lint | No new violations, no eslint-disable | lint clean |
+| CLAUDE.md | Env vars KANNA_PTY_CHANNEL_DELIVERY, KANNA_PTY_CHANNEL_READY_TIMEOUT_MS documented | grep CLAUDE.md |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| File handoff — write prompt to runtimeDir, paste a one-line "Read this file" instruction | Adds a tool round-trip per delegation, leaves prompt on disk, still relies on TUI input for the trigger; channel push delivers the full payload directly with no extra IO |
+| Repair bracketed paste — detect [Pasted text #N] placeholder and expand before submit | Fights claude TUI internals, breaks on any Anthropic UI change, originating source of this exact bug |
+| Use claude --print / -p first-message mode | Banned by project policy (PTY parity + subscription billing assumptions) |
+| Channel push with silent paste fallback | Hides channel readiness regressions behind the very paste bug we are eliminating; fail-fast surfaces breakage immediately |
+| Apply channel push to interactive main-chat sessions too | Scope creep; interactive paste path is not the failure mode and behavioral framing differs |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Anthropic GrowthBook tengu_harbor flag turns off → channel handler never registers → push silently dropped | Fail-fast on channelClientReady timeout instead of silent paste fallback; env flag KANNA_PTY_CHANNEL_DELIVERY=disabled reverts to paste path while staying loud about it | driver.test.ts asserts throw + transcript stream close on timeout |
+| DevChannelsDialog text changes between claude versions | waitForTuiReadyDismissingDialogs uses NBSP-tolerant marker + postDismissOffset reference guard so trust+dev dismissals do not collide | tui-control.test.ts asserts no premature ready signal |
+| Channel push interpreted as injection / spam by model | Subagent system prompt appends framing that claims the channel message as authoritative task; push is sent exactly once | driver.test.ts asserts single push; spike re-PASS-COMPLIED |
+| Fail-fast path leaks transcript watcher | Driver closes transcriptStream in fail-fast cleanup block | driver.test.ts covers the failure cleanup |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun test | 2197 pass / 2 skip / 0 fail |
+| bun run lint | clean |
+| tsc --noEmit on changed files | clean |
+| spike bun run spike/run.ts against built code | PASS-COMPLIED — full prompt delivered, model complied, zero TUI typing |
+| grep -rn "sendUserPrompt(initialPrompt" src/server/claude-pty/driver.ts | matches only the non-oneShot branch |
diff --git a/.c3/adr/adr-20260529-pty-transcript-watch-event-source.md b/.c3/adr/adr-20260529-pty-transcript-watch-event-source.md
new file mode 100644
index 000000000..f11ea2bc0
--- /dev/null
+++ b/.c3/adr/adr-20260529-pty-transcript-watch-event-source.md
@@ -0,0 +1,96 @@
+---
+id: adr-20260529-pty-transcript-watch-event-source
+c3-seal: 8a13043789995c20a4a952bd83ae64d56f9bfb07ccfa47616056956e63bd899c
+title: pty-transcript-watch-event-source
+type: adr
+goal: Authoritatively record that the PTY Claude driver's runtime event source is the **on-disk transcript JSONL** (`~/.claude/projects//.jsonl`) tailed via `fs.watch` (or polling), not the subprocess stdout stream. Supersede `adr-20260519-pty-driver-stdout-event-source` and update `c3-225-claude-pty-driver` so its Purpose, Foundational Flow, Contract, and Change Safety match production code. Record `jsonl-path.ts` (`computeJsonlPath`/`encodeCwd`) as **live** code with multiple production callers, removing the prior "dead code" claim.
+status: proposed
+date: "2026-05-29"
+---
+
+## Goal
+
+Authoritatively record that the PTY Claude driver's runtime event source is the **on-disk transcript JSONL** (`~/.claude/projects//.jsonl`) tailed via `fs.watch` (or polling), not the subprocess stdout stream. Supersede `adr-20260519-pty-driver-stdout-event-source` and update `c3-225-claude-pty-driver` so its Purpose, Foundational Flow, Contract, and Change Safety match production code. Record `jsonl-path.ts` (`computeJsonlPath`/`encodeCwd`) as **live** code with multiple production callers, removing the prior "dead code" claim.
+
+## Context
+
+The 2026-05-19 ADR charted the PTY driver around a `pumpStdout` reader: stdout JSONL was the sole event source, `jsonl-path.ts` was deferred dead code. Between then and 2026-05-29 the driver was refactored: stdout pump removed, event source flipped to `tui-source.adapter.ts:startTranscriptStream` which watches `~/.claude/projects/...` via `fs.watch` and feeds `createJsonlEventParser`. `computeJsonlPath`/`encodeCwd` became live callees (driver.ts, tui-source.adapter, smoke-test). CLAUDE.md was updated; `c3-225` was not. `c3x check` reports drift; debugging readers hit contradictory docs.
+
+## Decision
+
+Adopt the transcript-watch architecture as the authoritative PTY event source in C3:
+
+1. Rewrite `c3-225-claude-pty-driver.md` so Purpose, Foundational Flow rows ("Input — CLI stdout" → "Input — transcript JSONL"), Contract ("HarnessEvent stream", "stdin prompt channel"), Change Safety risks, and Derived Materials reflect transcript-watch reality.
+2. Mark `adr-20260519-pty-driver-stdout-event-source` as superseded by this ADR.
+3. Drop the "dead code" claim for `jsonl-path.ts`; its callers are now production.
+This codifies the de-facto state, so `c3x check` passes and future edits do not regress to stdout-pump assumptions.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-225 | component | All four contract surfaces and Change Safety rows describe stdout pump; current code uses transcript watch | Rewrite Purpose, Foundational Flow, Contract, Change Safety, Derived Materials |
+| adr-20260519-pty-driver-stdout-event-source | adr | Codifies the now-replaced stdout-pump charter | Mark superseded; reference this ADR |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-provider-adapter | The HarnessEvent contract still mediates provider transports; only the event source layer changes | comply |
+| ref-event-sourcing | Driver still emits events with log-before-broadcast invariant ordering | comply |
+| ref-colocated-bun-test | Tests stay under src/server/claude-pty/ | comply |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-strong-typing | Transcript line → HarnessEvent parser keeps typed shapes | comply |
+| rule-colocated-bun-test | tui-source.adapter.test.ts, driver.test.ts stay colocated | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| c3-225 component doc | Rewrite to transcript-watch event source | .c3/c3-2-server/c3-225-claude-pty-driver.md |
+| Originating ADR | Add superseded-by note pointing to this ADR | .c3/adr/adr-20260519-pty-driver-stdout-event-source.md |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| c3-225 frontmatter goal | Replace "parse its stdout JSONL stream" with "tail on-disk transcript JSONL" | c3x read c3-225 shows new goal |
+| c3-225 Foundational Flow | Replace "Input — CLI stdout pumpStdout" row with transcript-watch row citing tui-source.adapter.ts | c3x read c3-225 shows new row |
+| c3-225 Contract | Update "HarnessEvent stream" Evidence to tui-source.adapter.ts / driver.ts:617; remove "stdout is sole event source" claim | c3x read c3-225 |
+| c3-225 Change Safety | Replace "drift to on-disk transcript" risk with "drift back to stdout pump"; remove jsonl-path "dead code" risk | c3x read c3-225 |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| c3x check | Validates seal + structure after the rewrite | c3x check exits clean |
+| grep -rn "pumpStdout | proc.stdout" src/server/claude-pty | Returns zero matches in production |
+| bun test src/server/claude-pty/driver.test.ts | Driver test still green under transcript-watch model | bun test run |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Leave c3-225 stale, rely on CLAUDE.md | C3 is the architectural source of truth; stale c3 misleads agents and humans, c3x check keeps flagging drift |
+| Restore stdout-pump in code to match the ADR | Transcript-watch is the chosen runtime; reversing real, working code to match a stale doc inverts the source of truth |
+| Inline patch only the contradicting rows | Multiple rows contradict reality; a partial patch produces internally inconsistent doc and still fails Change Safety review |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| New ADR contradicts CLAUDE.md again | Cross-check against current CLAUDE.md "Architecture note" before writing | grep CLAUDE.md for "on-disk transcript" — present |
+| Future PR re-adds stdout pump without ADR | Change Safety row "drift back to stdout pump" lists grep + test detection | grep -rn "pumpStdout" src/server zero, driver.test.ts green |
+| Originating ADR remains active alongside this one | Add explicit superseded-by line referencing this ADR id | c3x read adr-20260519-pty-driver-stdout-event-source shows superseded note |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| c3x check | exits clean, no BROKEN_SEAL / drift |
+| grep -rn "pumpStdout | proc.stdout" src/server/claude-pty |
+| c3x read c3-225 | shows transcript-watch event source, no stdout pump |
diff --git a/.c3/adr/adr-20260530-keepalive-multiturn-subagent.md b/.c3/adr/adr-20260530-keepalive-multiturn-subagent.md
new file mode 100644
index 000000000..c4e63947f
--- /dev/null
+++ b/.c3/adr/adr-20260530-keepalive-multiturn-subagent.md
@@ -0,0 +1,158 @@
+---
+id: adr-20260530-keepalive-multiturn-subagent
+c3-seal: f0f320d8bc4347ceef92a9f5eb6799e22e3c240608dffbcf51c708aef70c8cc5
+title: keepalive-multiturn-subagent
+type: adr
+goal: |-
+ Let the main agent hold a Claude-PTY subagent session warm across multiple
+ turns. `delegate_subagent({ keep_alive: true })` runs turn 1 and leaves the
+ PTY REPL open instead of sending `/exit`; the main agent then drives further
+ turns into the SAME warm process via `send_subagent_message({ run_id,
+ prompt })` and tears it down with `close_subagent({ run_id })`. This changes
+ the subagent run lifecycle from strictly single-turn (spawn → drain → exit)
+ to optionally multi-turn (spawn → drain → register live session → N follow-up
+ turns → close), while preserving the existing one-shot path as the default.
+status: implemented
+date: "2026-05-30"
+---
+
+## Goal
+
+Let the main agent hold a Claude-PTY subagent session warm across multiple
+turns. `delegate_subagent({ keep_alive: true })` runs turn 1 and leaves the
+PTY REPL open instead of sending `/exit`; the main agent then drives further
+turns into the SAME warm process via `send_subagent_message({ run_id,
+prompt })` and tears it down with `close_subagent({ run_id })`. This changes
+the subagent run lifecycle from strictly single-turn (spawn → drain → exit)
+to optionally multi-turn (spawn → drain → register live session → N follow-up
+turns → close), while preserving the existing one-shot path as the default.
+
+## Context
+
+Today every subagent run is one-shot: `runClaudeSubagent` spawns the PTY,
+drains one turn, and the driver sends `/exit` on the first `result`. A new
+turn means a full re-spawn — trust dialog, smoke gate, MCP reconnect, TUI
+boot — which is prohibitively expensive for multi-turn orchestration (a
+5-turn conversation pays that cold-start cost 5×). PR #333 shipped channel
+prompt delivery (`pushChannelPrompt`, an MCP `notifications/claude/channel`
+push) which proved a prompt can be delivered into a live session without TUI
+paste collapse. The R-multi spike then proved a SECOND channel push into an
+already-used idle REPL starts a fresh turn, and that interactive TUI claude
+emits `system/turn_duration` (not `type:"result"`) per turn, which
+`normalizeClaudeStreamMessage` already synthesizes into one `kind:"result"`
+HarnessEvent per turn — so a per-turn drain returns once per turn over a
+persistent iterator. Affected topology: c3-225 (claude-pty-driver) owns the
+spawn + channel push, c3-210 (agent-coordinator) owns the orchestrator run
+lifecycle, c3-226 (kanna-mcp-host) owns the delegate tool surface. Constraint:
+keep-alive is Claude-PTY only (codex out of scope); the side-effect seal bars
+`process.env` inside the orchestrator; strong-typing rule bars `any` at the
+new boundaries.
+
+## Decision
+
+Add an opt-in `keepAlive` flag threaded from the MCP tool down through the
+orchestrator and provider run into the PTY driver. The driver suppresses
+`oneShotClose()` on the first result when `keepAlive` and exposes
+`pushChannelPrompt` on the handle. The provider run (`runClaudeSubagent`)
+drains turn 1 over a persistent async iterator, then returns a
+`LiveTurnSource` (`runTurn` pushes a channel prompt + drains the next turn;
+`close` shuts the REPL) instead of closing. The orchestrator keeps a
+`liveSessions` registry keyed by `runId`: turn 1 runs through the existing
+`spawnRun` plumbing (permit, RunState, timeout, abort, events) but on
+completion registers a `LiveSession` rather than cleaning up; `sendToLiveRun`
+drives follow-up turns (acquiring a permit only for the turn's drain);
+`closeLiveRun` tears down. Two orthogonal limits: a permit bounds concurrent
+ACTIVE turns (idle live sessions hold no permit), and `KANNA_SUBAGENT_MAX_LIVE`
+bounds live PROCESSES per chat (over cap → `CAP_EXCEEDED`). Idle sessions are
+auto-closed after `KANNA_SUBAGENT_IDLE_TIMEOUT_MS`. This fits the repo because
+it reuses the proven channel transport and the existing per-turn `result`
+synthesis, keeps the star topology (main agent always calls the tools), and
+leaves the default one-shot path byte-identical (default `keep_alive:false`).
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-210 | component | New public API on SubagentOrchestrator: sendToLiveRun, closeLiveRun, findSubagent, LiveTurnSource type, keepAlive on delegateRun/spawnRun, liveSessions registry, CAP_EXCEEDED/NO_LIVE_SESSION error codes, env wiring at AgentCoordinator construction | Code References + Goal must reflect multi-turn lifecycle; confirm event-sourcing + colocated-test refs still hold |
+| c3-225 | component | StartClaudeSessionPtyArgs.keepAlive, suppressed /exit on result, pushChannelPrompt on handle, buildChannelPromptFraming(keepAlive) plural framing | Contract surface (prompt-delivery) gains keep-alive multi-turn push; confirm transcript-as-sole-event-source unchanged |
+| c3-226 | component | New MCP tools send_subagent_message + close_subagent, keep_alive param on delegate_subagent, non-claude rejection | Tool roster + delegation contract update; strong-typing on new tool inputs |
+| c3-3 | container | New SubagentErrorCode members CAP_EXCEEDED/NO_LIVE_SESSION | Named-type boundary; no untyped shapes |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-event-sourcing | Follow-up turns persist subagent_message_delta + subagent_entry_appended events identically to turn 1; live-session state is derived from the same event log | comply |
+| ref-provider-adapter | Keep-alive must stay within the normalized HarnessEvent model — per-turn result is synthesized by the adapter, not special-cased in the orchestrator | comply |
+| ref-tool-hydration | New MCP tools return the same unified transcript/tool-result content shape; no provider branching introduced | comply |
+| ref-colocated-bun-test | New tests sit next to each changed file (*.test.ts) | comply |
+| ref-strong-typing | New boundary types (LiveTurnSource, keepAlive, tool inputs, error codes) are named TS types, no any | comply |
+| ref-local-first-data | No new persistent surface or network exposure; live sessions are in-process only | review — confirmed N.A for new IO |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-colocated-bun-test | Every changed module gained colocated *.test.ts coverage (driver, provider-run, orchestrator, kanna-mcp) | comply |
+| rule-strong-typing | LiveTurnSource, keepAlive, sendToLiveRun/closeLiveRun signatures, CAP_EXCEEDED/NO_LIVE_SESSION, and MCP tool zod inputs are all named/typed with no any at boundaries | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| Driver | keepAlive arg; suppress oneShotClose on result; expose pushChannelPrompt; buildChannelPromptFraming(keepAlive) | commit e3b55d9, 971f7e1, 1a541eb; src/server/claude-pty/driver.ts |
+| Provider run | drainOneTurn extraction; keep-alive runClaudeSubagent returns LiveTurnSource; widen ProviderRunStart.start | commit 78f50b6, c87e56a, cc5d2d7; src/server/subagent-provider-run.ts |
+| Orchestrator | liveSessions registry; keepAlive delegateRun + CAP_EXCEEDED; sendToLiveRun + closeLiveRun; cancel cascade; findSubagent | commit ba64cb0, b9a01d0, 4dfba28, 37e28d3; src/server/subagent-orchestrator.ts |
+| MCP host | keep_alive param + send_subagent_message + close_subagent; non-claude rejection | commit 37e28d3; src/server/kanna-mcp.ts, kanna-mcp-tools/delegate-subagent.ts |
+| Shared types | CAP_EXCEEDED + NO_LIVE_SESSION error codes | commit ba64cb0, b9a01d0; src/shared/types.ts |
+| Env wiring | KANNA_SUBAGENT_MAX_LIVE / KANNA_SUBAGENT_IDLE_TIMEOUT_MS into orchestrator deps at AgentCoordinator | commit 37e28d3; src/server/agent.ts |
+| Docs | CLAUDE.md keep-alive multi-turn subsection + env vars | this PR; CLAUDE.md |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| c3-210 component doc | Update Goal/Code References to add multi-turn lifecycle + new public API surfaces | c3x read c3-210 --full after c3x write; c3x check clear |
+| c3-225 component doc | Add keep-alive multi-turn channel push + plural framing to prompt-delivery contract | c3x read c3-225 --full; c3x check clear |
+| c3-226 component doc | Add send_subagent_message + close_subagent + keep_alive to tool roster | c3x read c3-226 --full; c3x check clear |
+| ADR record | This ADR adr-20260530-keepalive-multiturn-subagent authored to schema, transitioned proposed→accepted→implemented | c3x check --include-adr clear |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| bun test src/server/subagent-orchestrator.test.ts | Asserts keep_alive registers live session, CAP_EXCEEDED, sendToLiveRun, closeLiveRun, cancel cascade | 73 pass in suite |
+| bun test src/server/subagent-provider-run.test.ts | Asserts keep-alive run returns LiveTurnSource driving turn 2; drainOneTurn leaves iterator open | pass |
+| bun test src/server/claude-pty/driver.test.ts | Asserts no /exit on keepAlive result + pushChannelPrompt exposed; multiturn framing | pass |
+| bun test src/server/kanna-mcp.test.ts | Asserts the three tools registered + keep_alive routed | pass |
+| bun run lint | Side-effect seal: orchestrator reads no process.env; strong-typing enforced | clean (0 warnings) |
+| c3x check | Docs match code | 90 docs clear |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Resume-from-transcript per turn (claude --resume) | Re-spawn cost per turn (trust/smoke/MCP/TUI boot) defeats the purpose for back-and-forth orchestration; only wins for turns minutes/hours apart |
+| Mesh topology (subagent A pushes channel directly into B) | Breaks the "main agent always in the loop" invariant that makes Kanna debuggable/auditable/cancellable; Claude Code itself uses star + SendMessage, not mesh |
+| LRU eviction when over MAX_LIVE | An LRU live session may be mid-conversation; silently killing it corrupts the orchestration — fail-fast CAP_EXCEEDED is safer and explicit |
+| Idle session holds a parallel permit | Would let idle subagents starve fresh one-shot delegations; permit is for active turns only |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Leaked PTY processes if main forgets to close | Idle timeout (KANNA_SUBAGENT_IDLE_TIMEOUT_MS, default 300s) auto-closes; cancelChat/cancelRun cascade-close | orchestrator test "cancelChat closes live sessions"; idle-timer arm/reset code |
+| Process bomb via unbounded live sessions | KANNA_SUBAGENT_MAX_LIVE (default 5) per chat, fail-fast CAP_EXCEEDED | orchestrator test "keep_alive past cap fails CAP_EXCEEDED" |
+| Keep-alive silently degrades to dead one-turn session if no channel | Fail closed: runClaudeSubagent throws when pushChannelPrompt missing | provider-run keep-alive test path |
+| Turn 2+ treated as suspicious interrupt by model | Plural channel framing via buildChannelPromptFraming(true) | driver framing test asserts "multiple" language |
+| One-shot regression from shared drain refactor | drainOneTurn stops at first result; one-shot driver closes stream right after result anyway | full subagent suites green; default path byte-identical |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun test src/server/subagent-orchestrator.test.ts src/server/subagent-provider-run.test.ts src/server/claude-pty/driver.test.ts src/server/kanna-mcp.test.ts | all pass (0 fail) |
+| bunx tsc --noEmit | exit 0 |
+| bun run lint | clean, 0 warnings |
+| c3x check | 90 docs clear |
+| bun test (full) | 2235 pass / 1 pre-existing flake (auth.test.ts HTTP-bind timeout under load; passes in isolation) |
diff --git a/.c3/adr/adr-20260601-tool-callback-broadcast-and-no-rotation-cancel.md b/.c3/adr/adr-20260601-tool-callback-broadcast-and-no-rotation-cancel.md
new file mode 100644
index 000000000..7b508f6a5
--- /dev/null
+++ b/.c3/adr/adr-20260601-tool-callback-broadcast-and-no-rotation-cancel.md
@@ -0,0 +1,172 @@
+---
+id: adr-20260601-tool-callback-broadcast-and-no-rotation-cancel
+c3-seal: d874c0c73b807742063023d0b5b083371964a1abd6f813dc9cc1265b149ff87d
+title: tool-callback-broadcast-and-no-rotation-cancel
+type: adr
+goal: |-
+ Fix three converging causes of "the kanna question tool dropped / timed
+ out" UX bugs in c3-226 (kanna-mcp-host): (1) `createToolCallbackService`
+ never fires a chat-state broadcast after `putToolRequest` /
+ `resolveToolRequest`, so the UI never sees the new pending until an
+ unrelated event flushes the read model; (2) `makeClaudeSessionHandle.close`
+ calls `cancelAllForSession("session_closed")` on every PTY/SDK teardown,
+ including transparent token-rotation and idle-sweep respawns where the
+ model's turn is still live, denying mid-prompt asks for no real reason;
+ (3) the 600s wall-clock ask-pending timeout (ticked every 5s from
+ `server.ts`) silently masks (1) — pendings the UI never showed deny after
+ 10 min instead of waiting for an answer. The selected change adds an
+ `onStateChange(chatId)` callback wired to `router.scheduleChatStateBroadcast`,
+ removes the session-close cancel cascade in favour of an explicit
+ `cancelAllForChat` from the `chat.cancel` ws-router handler, and removes
+ the wall-clock timeout (matching upstream Claude Code's AskUserQuestion
+ built-in which has no timeout).
+status: proposed
+date: "2026-06-01"
+---
+
+# Tool-callback live broadcast + drop-cancel-on-rotation + remove ask-timeout
+
+## Goal
+
+Fix three converging causes of "the kanna question tool dropped / timed
+out" UX bugs in c3-226 (kanna-mcp-host): (1) `createToolCallbackService`
+never fires a chat-state broadcast after `putToolRequest` /
+`resolveToolRequest`, so the UI never sees the new pending until an
+unrelated event flushes the read model; (2) `makeClaudeSessionHandle.close`
+calls `cancelAllForSession("session_closed")` on every PTY/SDK teardown,
+including transparent token-rotation and idle-sweep respawns where the
+model's turn is still live, denying mid-prompt asks for no real reason;
+(3) the 600s wall-clock ask-pending timeout (ticked every 5s from
+`server.ts`) silently masks (1) — pendings the UI never showed deny after
+10 min instead of waiting for an answer. The selected change adds an
+`onStateChange(chatId)` callback wired to `router.scheduleChatStateBroadcast`,
+removes the session-close cancel cascade in favour of an explicit
+`cancelAllForChat` from the `chat.cancel` ws-router handler, and removes
+the wall-clock timeout (matching upstream Claude Code's AskUserQuestion
+built-in which has no timeout).
+
+## Context
+
+Today every interactive tool prompt (ask_user_question, exit_plan_mode,
+delegate_subagent) registers a `ToolRequest` in `tool-callback.ts`. The
+read model surfaces it as a `pending_tool_request` transcript entry via
+`getRecentChatHistory`. That snapshot only ships to the client when
+something else triggers `broadcastChatAndSidebar` (a stream chunk, a
+reconnect, a sidebar refresh). Between the model emitting `tool_use` and
+the next transcript line under PTY mode (transcript JSONL watcher idle
+while model awaits tool_result), nothing fires — so the user sees no
+prompt at all. The 600s `tickTimeouts` driver then resolves it
+`{kind:"deny", reason:"timeout"}` and the model gets a silent deny.
+Separately, `makeClaudeSessionHandle.close()` (agent.ts:1071) calls
+`cancelAllForSession(sessionToken, "session_closed")` on every teardown.
+Token rotation under PTY tears down and respawns the same chat
+constantly; each respawn denies any in-flight pending even though the
+turn is conceptually unbroken. Upstream Claude Code's built-in
+AskUserQuestion uses a synchronous React useState pending + a single
+`onAllow` resolve, with NO wall-clock timeout, NO cross-process state,
+and a TUI lock that fires the equivalent of a broadcast synchronously
+because state + view share one process. Kanna's server/client split
+makes the broadcast mandatory — there is no equivalent of the TUI lock.
+
+## Decision
+
+Inject an optional `onStateChange?: (chatId: string) => void` into
+`ToolCallbackServiceArgs`. Fire it after every persisted state change
+(submit's `persistPut`, answer/cancel/cancelAllForChat's `persistResolve`).
+Wire it from `server.ts` through a deferred holder
+(`let broadcastChatState: ...`) populated immediately after
+`createWsRouter` returns — `recoverOnStartup` runs before the router
+exists and its broadcasts no-op, which is correct (no connected client).
+Remove `cancelAllForSession` from `ToolCallbackService` entirely and drop
+its call site in `makeClaudeSessionHandle.close()`. Pending records now
+survive transparent respawns (rotation, idle sweep) and are reaped only
+by the three explicit paths that already exist: `cancelAllForChat` from
+`chat.delete`, a new `cancelAllForChat` call added to the `chat.cancel`
+handler in `ws-router.ts`, and `recoverOnStartup`'s fail-closed pass on
+server boot. Remove the `timeoutMs` field, the `tickTimeouts` method,
+and the 5s `setInterval` in `server.ts` that drives it. Existing
+persisted records still carry `expiresAt` — fill it with
+`Number.MAX_SAFE_INTEGER` (NEVER_EXPIRES) so the schema stays
+compatible while nothing enforces it.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-226 | component | Contract row "Durable approval protocol" changes shape (new onStateChange, no timeout, no cancelAllForSession). Failure modes table needs the "pending timeout" row removed and a "live broadcast missing" row added. | Update c3-226 Contract + Change Safety + Foundational/Business Flow rows in the same PR. |
+| c3-210 | component | Owns makeClaudeSessionHandle; close() no longer cascades cancel. | No contract surface change — remove the implicit "close also denies pendings" assumption from the Foundational Flow narrative if present. |
+| c3-2 | container | server.ts wires the broadcast holder and drops the tickTimeouts setInterval. | Container Responsibilities row "host the in-process MCP server + tool-callback service" stays correct; no Parent Delta required. |
+| c3-206 | component | No code change. putToolRequest/resolveToolRequest contract unchanged. | N.A - read-only consumer of the same EventStore API. |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-strong-typing | New onStateChange?: (chatId: string) => void is a typed boundary at MCP-host edge. | comply |
+| ref-event-sourcing | Broadcast still fires AFTER opts.store.putToolRequest / resolveToolRequest resolves — log remains source of truth, no broadcast-before-log inversion. | comply |
+| ref-tool-hydration | UI still hydrates pending_tool_request through src/shared/tools.ts; broadcast just makes the snapshot reach the client sooner. | comply |
+| ref-local-first-data | Persistence unchanged — same JSONL under ~/.kanna/data. | comply |
+| ref-provider-adapter | close() change applies to both SDK and PTY Claude drivers; both share makeClaudeSessionHandle. | comply |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-strong-typing | onStateChange and broadcastChatState are named function types at boundaries — no any. | comply |
+| rule-colocated-bun-test | New broadcast test sits in src/server/tool-callback.test.ts next to source; updated stub shape in src/server/agent.test.ts, kanna-mcp.test.ts, kanna-mcp-tools/grep.test.ts. | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| tool-callback.ts | Drop timeoutMs field; add onStateChange field; drop cancelAllForSession+tickTimeouts methods; add notify(chatId) helper called from persistPut and persistResolve; set expiresAt = NEVER_EXPIRES for ask-verdict records. | src/server/tool-callback.ts |
+| server.ts | Add let broadcastChatState: ...; pass onStateChange: (chatId) => broadcastChatState?.(chatId) to initToolCallbackOnBoot; populate broadcastChatState after createWsRouter; remove the toolCallbackTickInterval setInterval and its clearInterval in shutdown. | src/server/server.ts |
+| agent.ts | Drop the args.toolCallback.cancelAllForSession(...) call from makeClaudeSessionHandle.close(). | src/server/agent.ts |
+| ws-router.ts | In case "chat.cancel" handler, call agent.toolCallbackService.cancelAllForChat(chatId, "chat_cancelled") after agent.cancel(chatId). | src/server/ws-router.ts |
+| tool-callback.test.ts | Rewrite the two timeout tests as a NEVER_EXPIRES regression (24h jump, still pending). Add onStateChange fires on submit/answer/cancel/cancelAllForChat test. Add auto-allow/auto-deny does NOT fire onStateChange test. | src/server/tool-callback.test.ts |
+| Other stubs | Strip cancelAllForSession + tickTimeouts + timeoutMs from stubs in agent.test.ts, kanna-mcp.test.ts, kanna-mcp-tools/grep.test.ts. | grep no longer finds those identifiers under src/. |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| c3-226 Contract row "Durable approval protocol" | Rephrase the IN/OUT contract to mention onStateChange broadcast + no built-in timeout; remove cancelAllForSession from the surface. | c3x write c3-226 --section Contract |
+| c3-226 Foundational Flow "Failure — pending timeout" row | Replace with "Failure — chat cancelled / chat deleted" describing the explicit cancelAllForChat paths from ws-router. | c3x write c3-226 --section "Foundational Flow" |
+| c3-226 Change Safety table | Add row: "Live-broadcast missing — onStateChange omitted from createToolCallbackService args — Detection: tool-callback.test.ts asserts events fire — Verification: bun test src/server/tool-callback.test.ts". Add row covering session-close no longer cancelling pendings. | c3x write c3-226 --section "Change Safety" |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| bun test src/server/tool-callback.test.ts | Asserts onStateChange fires on submit/answer/cancel/cancelAllForChat (chat-1, 6 events). Asserts NEVER_EXPIRES (24h jump leaves pending open). Asserts auto-allow/auto-deny does NOT broadcast. | tool-callback.test.ts |
+| bun test src/server/agent.test.ts + bun test src/server/kanna-mcp.test.ts + bun test src/server/boot.test.ts | Stub shape compile-locks the new ToolCallbackService interface (no cancelAllForSession, no tickTimeouts). | TypeScript build / bun test |
+| bun run lint | Drops dead-code references; --max-warnings=0 catches accidental any. | bun run lint |
+| ws-router.ts case "chat.cancel" | Runtime path that now reaps pendings on user-initiated cancel; replaces the removed close()-side cascade. | src/server/ws-router.ts:chat.cancel handler |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Keep timeoutMs but lower it to 120s and surface expiresAt to the client | Still hides the broadcast bug as a "drop" — 120s of invisible prompt is the same UX failure as 600s, just shorter. Upstream Claude Code proves no timeout is needed. |
+| Keep cancelAllForSession but add a reason: "rotation" flag that skips cancel | Two-state flag on every close() site is a fragile contract — easy to forget to pass "rotation" at one of the four call sites. Removing the cascade entirely and routing through cancelAllForChat from ws-router is mechanically harder to break. |
+| Have the read-model push pending broadcasts from inside event-store.ts | Inverts the broadcast direction (event-store would need a router reference), breaks the existing one-way data flow from agent-coordinator → event-store → broadcast. onStateChange is a thin callback at the host boundary and keeps the seam. |
+| Use a Bun timer on each waiter instead of a global tickTimeouts | Same drop UX, plus 1 timer per pending — wasteful when the right fix is no timer at all. |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Pending records leak forever in inMemory when the model crashes mid-tool_use and no one cancels | recoverOnStartup fail-closes every pending on server boot; chat.delete / chat.cancel paths clear them mid-process. Worst case: stale record sits until next restart. | bun test src/server/tool-callback.test.ts (server-restart resolves persisted pending as session_closed) |
+| Removed cancelAllForSession leaves orphan waiters when a chat's session is rotated and a new pending is opened with a different sessionId (different hmacId) | The old waiter resolves on the next explicit cancel/answer/server-restart; the new pending resolves independently because hmacId differs. The model on resume will re-emit ask_user_question against the new sessionId → fresh pending with broadcast → user prompted. | bun test src/server/agent.test.ts oauth-rotation suite passes (no regression in token-rotation tests). |
+| Broadcast holder still null when an early submit fires (recoverOnStartup) | recoverOnStartup only touches records that survived from a previous boot; even if it broadcast, no client is connected yet. The null-coalesce no-op is the correct behaviour. | Manual: server boot replays a pending → resolves to session_closed → no broadcast → client connects → snapshot reflects resolved state. |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun test src/server/tool-callback.test.ts | 11 pass / 0 fail (new onStateChange + NEVER_EXPIRES tests included) |
+| bun test src/server/agent.test.ts | 86 pass / 0 fail |
+| bun test src/server/kanna-mcp.test.ts src/server/boot.test.ts src/server/kanna-mcp-tools/grep.test.ts | 29 pass / 0 fail |
+| bun test src/server/ws-router.test.ts | 51 pass / 0 fail |
+| bun run lint | exit 0, --max-warnings=0 |
+| bun test full suite | 2262 pass / 2 skip / 1 pre-existing flaky (paths-route > returns 404 for unknown project — 31s spawn-server-bundle timeout, unrelated to tool-callback; passes in isolation) |
diff --git a/.c3/adr/adr-20260603-agent-self-scheduled-wake.md b/.c3/adr/adr-20260603-agent-self-scheduled-wake.md
new file mode 100644
index 000000000..c7cb68e76
--- /dev/null
+++ b/.c3/adr/adr-20260603-agent-self-scheduled-wake.md
@@ -0,0 +1,154 @@
+---
+id: adr-20260603-agent-self-scheduled-wake
+c3-seal: ced766d7be6068e9a57435790a84add359ec20ac668deeefcfd939b116203ffa
+title: agent-self-scheduled-wake
+type: adr
+goal: |-
+ Make Kanna re-enter an idle chat turn on two agent-driven signals that the
+ spawned `claude` CLI emits but Kanna currently never acts on: (1) the model
+ calling `ScheduleWakeup({delaySeconds, prompt})` to resume later, and (2) a
+ turn ending while a background `Workflow` is still running
+ (`pendingWorkflowCount > 0`). Kanna will OWN the wake timer by routing both
+ signals into the existing `auto-continue` `ScheduleManager` (event-sourced,
+ restart-survivable), rather than relying on the CLI's in-process cron
+ scheduler — which dead-letters our spawn and whose fires Kanna's auto-wake
+ filter deliberately drops.
+status: accepted
+date: "2026-06-03"
+---
+
+# Agent self-scheduled wake + background-workflow harvest
+
+## Goal
+
+Make Kanna re-enter an idle chat turn on two agent-driven signals that the
+spawned `claude` CLI emits but Kanna currently never acts on: (1) the model
+calling `ScheduleWakeup({delaySeconds, prompt})` to resume later, and (2) a
+turn ending while a background `Workflow` is still running
+(`pendingWorkflowCount > 0`). Kanna will OWN the wake timer by routing both
+signals into the existing `auto-continue` `ScheduleManager` (event-sourced,
+restart-survivable), rather than relying on the CLI's in-process cron
+scheduler — which dead-letters our spawn and whose fires Kanna's auto-wake
+filter deliberately drops.
+
+## Context
+
+A PTY chat (`de4c6a76-919a-4f2e-8004-ec1328f5820c`) running a Workflow called
+`ScheduleWakeup` +1515s, ended its turn cleanly (`turn_finished`,
+`pendingWorkflowCount: 1`), and then sat idle forever — no `turn_started` ever
+followed. Spike findings against the claude-code source proved two independent
+blockers: (a) the only native re-fire engine is the cron tick in
+`useScheduledTasks.ts:40-127` armed by `/loop`→`CronCreate`; `ScheduleWakeup`
+is unbacked under Kanna's spawn (no Kanna-registered tool, native fire is
+in-memory and dies on restart); (b) even a legit cron/`/loop` fire enters the
+on-disk transcript as an `isMeta:true` user line (`useScheduledTasks.ts:71-82`),
+which `src/server/claude-pty/jsonl-to-event.ts:106` intentionally drops as a
+background auto-wake. `` bg-completion wakes share that same
+`isMeta:true` queue. Affected topology: c3-227 (auto-continue scheduler),
+c3-210 (agent-coordinator turn lifecycle + fire path), c3-226 (kanna-mcp shim
+registration), c3-225 (claude-pty driver disallow-tools + filter). Constraint:
+fix must survive server restart (matches c3-227's existing rate-limit resume
+guarantee) and must not reintroduce the noise wakes the filter was added to
+suppress.
+
+## Decision
+
+Kanna owns the wake. Intercept the native `ScheduleWakeup` (add it to the PTY
+`--disallowedTools` list and force-register a `mcp__kanna__schedule_wakeup`
+shim, exactly mirroring the `AskUserQuestion`/`ExitPlanMode` interception from
+issue #215). The shim emits an `auto_continue_accepted` event carrying
+`reason: "agent_wakeup"` + the agent-supplied `prompt` + `scheduledAt`. The
+existing `ScheduleManager` arms/persists/rehydrates it and `fireAutoContinue`
+re-enters the chat replaying that prompt. For background-workflow harvest, when
+a turn-end `result` carries `pendingWorkflowCount > 0`, the coordinator arms a
+short Kanna-owned poll-wake (`reason: "pending_workflow"`) so the agent
+re-enters to collect results — the `jsonl-to-event` filter stays unchanged
+(noise stays dropped). This wins over un-filtering native wakes because the
+event-sourced scheduler already gives restart survival, cancel-cascade, and a
+UI read-model for free; the native cron path gives none of those under our
+tail-the-transcript spawn model.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-227 | component | Owns the schedule/timer; gains two new schedule reasons (agent_wakeup, pending_workflow) beyond rate-limit/auth-error; goal statement widens | Parent Delta on c3-2; update goal + Foundational/Business Flow + Contract rows for the new reasons |
+| c3-210 | component | fireAutoContinue branches on reason to replay the agent prompt vs queued user prompt; turn-end path arms a pending_workflow schedule when pendingWorkflowCount > 0 | Review Business Flow turn-finalize; confirm event-before-broadcast ordering preserved |
+| c3-226 | component | Registers the new mcp__kanna__schedule_wakeup shim under the same spawn-context guard as other forced shims | Review Contract surface list; add the shim row |
+| c3-225 | component | Adds ScheduleWakeup to --disallowedTools; force-registers the shim; the auto-wake filter at jsonl-to-event.ts:106 is documented as intentionally retained | Review prompt-delivery + disallowed-tools section; no filter behavior change |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-event-sourcing | Every schedule mutation (the new wake reasons) must land as a JSONL event first, so the wake survives restart like rate-limit resume | comply |
+| ref-cqrs-read-models | The new schedule reasons must project through the existing schedule read-model the UI consumes, not a side channel | comply |
+| ref-strong-typing | The new event payload (reason, prompt, scheduledAt) and the shim input cross JSONL + MCP boundaries — must be named types, no any/untyped literals | comply |
+| ref-provider-adapter | The re-entered turn must use the same provider-agnostic turn shape; agent-wake replay must not branch on provider in the coordinator | comply |
+| ref-colocated-bun-test | Cited by c3-210 + c3-225; every touched module keeps its colocated *.test.ts with new wake-reason cases | comply |
+| ref-tool-hydration | Cited by c3-210 + c3-226; the schedule_wakeup MCP call must hydrate into the unified transcript via src/shared/tools.ts like every other tool, not a bespoke entry | comply |
+| ref-local-first-data | Cited by c3-226; the wake schedule persists only in the local ~/.kanna/data event log, opening no new network surface | comply |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-strong-typing | Detector/schedule envelopes and the new shim input are boundary types; no any/unknown without narrowing on the wake event or tool input | comply |
+| rule-colocated-bun-test | Each touched module (schedule-manager, events, read-model, kanna-mcp, jsonl-to-event, agent) must keep its *.test.ts sibling and add cases for the new reasons | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| Event type | Add reason discriminant (agent_wakeup \| pending_workflow alongside existing) + optional prompt to the accepted-schedule event; named type | src/server/auto-continue/events.ts |
+| Schedule fire | Branch fireAutoContinue on reason: replay agent prompt for agent_wakeup, re-poll for pending_workflow, existing path for failures; cap consecutive agent-wakes per chat | src/server/agent.ts (fireAutoContinue ~3024-3322) |
+| Turn-end arm | On result with pendingWorkflowCount > 0, emit a pending_workflow accepted-schedule with short delay | src/server/agent.ts (turn finalize) |
+| MCP shim | Register mcp__kanna__schedule_wakeup under the spawn-context guard; emit the event; return confirmation text | src/server/kanna-mcp.ts |
+| PTY disallow + shim | Add ScheduleWakeup to --disallowedTools; force-register shim like ask_user_question | src/server/claude-pty/driver.ts |
+| Env caps | KANNA_MAX_AGENT_WAKES (default ~25), reuse existing idle/cancel cascade | src/server/agent.ts deps |
+| Docs | Update CLAUDE.md (new env var + wake mechanism), c3-227/210/226/225 bodies | CLAUDE.md, .c3 via c3x write |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| N.A - no C3 CLI/validator/schema change | This ADR changes product code + component doc bodies only; the c3x tooling, schemas, validators, and help text are untouched | c3x check passes post-change with no schema/validator edits in the diff |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| schedule-manager.test.ts | Asserts an agent_wakeup/pending_workflow schedule arms, fires fireAutoContinue, and cancel clears the timer | src/server/auto-continue/schedule-manager.test.ts |
+| read-model.test.ts | Asserts the new reasons replay from JSONL on rehydrate (restart survival) | src/server/auto-continue/read-model.test.ts |
+| auto-continue/e2e.test.ts | End-to-end: shim emit → schedule → wake → new turn with the agent prompt | src/server/auto-continue/e2e.test.ts |
+| kanna-mcp test | Asserts schedule_wakeup registered only under spawn-context guard and emits the typed event | src/server/kanna-mcp*.test.ts |
+| jsonl-to-event.test.ts | Regression: genuine isMeta:true auto-wakes STILL dropped (filter unchanged) | src/server/claude-pty/jsonl-to-event.test.ts |
+| Cap guard | Consecutive agent-wakes beyond KANNA_MAX_AGENT_WAKES stop arming | src/server/agent.ts + test |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Un-filter native wakes in jsonl-to-event.ts and rely on the CLI cron timer | Native ScheduleWakeup is unbacked in our spawn (dead-letters); in-memory crons die on Kanna/CLI restart; and un-filtering reintroduces the noise wakes the filter (commit 216392b) was added to suppress |
+| Implement a brand-new scheduler component instead of reusing c3-227 | c3-227 already provides event-sourced persist + rehydrate + cancel-cascade + UI read-model; a parallel scheduler duplicates restart-survival logic and splits the wake surface across two components |
+| Use claude-code /loop+CronCreate durable cron (.claude/scheduled_tasks.json) | Lives outside Kanna's event log, invisible to Kanna's cancel/archive lifecycle and UI; fires land as filtered isMeta:true lines anyway |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Runaway self-wake loop burns OAuth quota | KANNA_MAX_AGENT_WAKES cap per chat + existing cancel cascade clears timers | schedule-manager.test.ts asserts arming stops past the cap |
+| Wake fires on an archived/cancelled chat | Reuse existing cancel/archive → auto_continue_cancelled clear; fire guards chat liveness | e2e.test.ts asserts cancelled schedule does not start a turn |
+| pending_workflow poll never resolves (workflow hangs) | Bounded re-poll count + idle timeout, same backoff infra as auth-error | schedule-manager.test.ts asserts bounded re-arm |
+| Native wake noise leaks back as a real turn | Filter at jsonl-to-event.ts:106 left intact; harvest goes through Kanna schedule only | jsonl-to-event.test.ts regression keeps dropping non-kanna isMeta wakes |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun test src/server/auto-continue/ | all pass incl. new agent_wakeup/pending_workflow cases |
+| bun test src/server/claude-pty/jsonl-to-event.test.ts | filter regression green (noise still dropped) |
+| bun test src/server/kanna-mcp*.test.ts | schedule_wakeup shim registration + emit pass |
+| bun run lint | 0 errors, warning cap not exceeded |
+| c3x check | passes; c3-227/210/226/225 bodies match code |
+| Live PTY smoke | a chat that calls ScheduleWakeup re-enters its turn after the delay (manual, real OAuth) |
diff --git a/.c3/adr/adr-20260603-workflow-aware-idle-reaper.md b/.c3/adr/adr-20260603-workflow-aware-idle-reaper.md
new file mode 100644
index 000000000..d8c328d2c
--- /dev/null
+++ b/.c3/adr/adr-20260603-workflow-aware-idle-reaper.md
@@ -0,0 +1,103 @@
+---
+id: adr-20260603-workflow-aware-idle-reaper
+c3-seal: 540f2d23ebafb5590fa5af08f8180771bdd095373d8dde8c5ab59a27dea7a9b8
+title: workflow-aware-idle-reaper
+type: adr
+goal: 'Make the Claude PTY session idle reaper and the resident-session budget enforcer in `AgentCoordinator` workflow-aware: a chat whose on-disk workflow registry reports a run with `status: "running"` must NOT have its warm PTY session torn down by `sweepIdleClaudeSessions` (idle ≥ `idleTimeoutMs`) nor evicted by `enforceClaudeSessionBudget` (resident > `maxConcurrent`). This stops Kanna from killing the host process out from under an in-flight background Workflow.'
+status: implemented
+date: "2026-06-03"
+---
+
+## Goal
+
+Make the Claude PTY session idle reaper and the resident-session budget enforcer in `AgentCoordinator` workflow-aware: a chat whose on-disk workflow registry reports a run with `status: "running"` must NOT have its warm PTY session torn down by `sweepIdleClaudeSessions` (idle ≥ `idleTimeoutMs`) nor evicted by `enforceClaudeSessionBudget` (resident > `maxConcurrent`). This stops Kanna from killing the host process out from under an in-flight background Workflow.
+
+## Context
+
+A background Claude Code Workflow runs inside the warm PTY claude process Kanna keeps per chat. When the main turn ends, the chat registers no `activeTurn`, adds no `pendingPromptSeqs`, and never bumps `lastUsedAt` (only `result`/`interrupted` events do). So `isClaudeSessionIdle` (`agent.ts:1345`) judges the chat idle after `idleTimeoutMs` (default 600_000) and `closeClaudeSession` → `session.session.close()` kills the PTY process tree, aborting the workflow.
+
+Observed in session `de4c6a76` (run `wf_5350e128-922`): last turn ended 13:24:33Z, idle reaper fired at +600s (~13:34:33Z — matches the closing file-history-snapshot), PTY killed, the on-disk sidecar `wf_5350e128-922.json` flipped to `status:"killed"` ("Workflow aborted"), losing in-flight agents. The #357 self-scheduled-wake mitigation does not cover this: its protective 120s `pending_workflow` wake is suppressed by the `if (live !== null) return` guard whenever the model has set its own longer `agent_wakeup` (the harvest prompt explicitly tells the model to "wait longer", so it sets 1200s > 600s idle), and a 1200s wake fires after the 600s reaper already killed the process.
+
+Topology: `c3-210 agent-coordinator` owns the reaper/budget logic and already holds `this.workflowRegistry` (wired in `server.ts`). `c3-229 workflow-status` owns `WorkflowRegistry.snapshot(chatId): WorkflowRunSummary[]` whose `status` field is the authoritative liveness signal (verified terminal on abort: a killed run reads `status:"killed"`, not stale `"running"`).
+
+## Decision
+
+Add a private `hasLiveWorkflow(chatId)` to `AgentCoordinator` that returns `true` iff `this.workflowRegistry?.snapshot(chatId)` contains a run with `status === "running"`. Add this as an early-return `false` guard inside `isClaudeSessionIdle` (alongside the existing `activeTurns` / `pendingPromptSeqs` guards) and as an extra predicate in the `enforceClaudeSessionBudget` candidate filter so a workflow-hosting session is neither swept nor evicted while a run is live.
+
+This consumes the existing #358 read-model rather than inventing new lifecycle state — the sidecar `status` is already watched live and is written terminal on process death, so it cannot strand a session in a false `"running"` state on a clean kill. It is the smallest correct change: no new event types, no driver changes, no clamp on wake delays. Wake-delay clamping (option 2) is deferred — the registry guard removes the root cause directly; clamping is a redundant belt that can land later if a hung-but-`running` workflow ever proves to strand a session.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-210 | component | isClaudeSessionIdle + enforceClaudeSessionBudget + new hasLiveWorkflow helper change session-teardown decisions | Comply with event-sourcing/provider-adapter refs + strong-typing + colocated-bun-test rules; add unit tests in agent.test.ts |
+| c3-229 | component | Consumed read-only via WorkflowRegistry.snapshot(chatId).status; no contract change, but the consumer relationship is new | Confirm no Contract change to c3-229; record consumer wiring; no Parent Delta to its surface |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-cqrs-read-models | The fix reads the derived WorkflowRegistry snapshot (read path) to drive a coordinator decision; must consume the read-model, not replay the log | comply |
+| ref-strong-typing | hasLiveWorkflow crosses the coordinator↔read-model boundary; must use named WorkflowRunSummary / WorkflowStatus types, no any | comply |
+| ref-event-sourcing | Reaper decision only reads a snapshot and emits no new event; must not mutate or depend on un-derived state | comply |
+| ref-colocated-bun-test | New agent.ts behavior needs colocated agent.test.ts cases (cited by c3-210) | comply |
+| ref-provider-adapter | Cited by c3-210/c3-229; this change touches session-teardown timing, not provider transcript normalization | N.A - no provider adapter surface touched |
+| ref-side-effect-adapter | Cited by c3-229; the guard reads an in-memory snapshot, adds no node:fs/spawn/IO | N.A - no new side effect introduced |
+| ref-tool-hydration | Cited by c3-210/c3-229; no Workflow tool_use hydration path changes | N.A - tool hydration unchanged |
+| ref-ws-subscription | Cited by c3-229; no WebSocket envelope or topic surface changes | N.A - no WS surface touched |
+| ref-zustand-store | Cited by c3-229; no client UI store changes | N.A - server-only change |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-colocated-bun-test | New behavior in agent.ts needs colocated tests in agent.test.ts covering idle-guard and budget-guard with a fake workflow registry | comply |
+| rule-strong-typing | The snapshot predicate types against WorkflowRunSummary["status"]; no untyped literals at the coordinator↔registry boundary | comply |
+| rule-zustand-store | Cited by c3-229; no client Zustand store added or changed | N.A - server-only change |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| Helper | Add private hasLiveWorkflow(chatId: string): boolean reading this.workflowRegistry?.snapshot(chatId).some(r => r.status === "running") ?? false | src/server/agent.ts |
+| Idle guard | In isClaudeSessionIdle, add if (this.hasLiveWorkflow(chatId)) return false after the pendingPromptSeqs guard | src/server/agent.ts:1345 |
+| Budget guard | In enforceClaudeSessionBudget candidate filter, add && !this.hasLiveWorkflow(chatId) | src/server/agent.ts:1383 |
+| Tests | TDD: idle-not-reaped when a run is running; reaped when completed/killed/none; budget keeps a live-workflow session resident; fake WorkflowRegistry injected via existing args.workflowRegistry | src/server/agent.test.ts |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| N.A - no C3 CLI underlay touched | This ADR changes runtime coordinator logic only; no c3x command, validator, schema, hint, or template is modified | c3x check passes unchanged after ADR + code |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| agent.test.ts idle-guard test | Fails if a session with a running workflow is reaped by the sweep | bun test src/server/agent.test.ts |
+| agent.test.ts budget-guard test | Fails if enforceClaudeSessionBudget evicts a session hosting a running workflow | bun test src/server/agent.test.ts |
+| bun run lint | Fails on any/untyped boundary or side-effect-seal violation in the new helper | CI lint gate |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Clamp schedule_wakeup delay to < idleTimeoutMs (option 2) | Indirect — only narrows the race window; a workflow longer than the clamp still gets killed across multiple wake cycles, and it fights the #357 "wait longer" prompt. Registry guard removes the root cause. Deferred as optional belt. |
+| Keep the 120s protective wake armed even when a longer schedule is live | Burns the maxAgentWakes (25) cap every 2 min and re-enters the chat needlessly while the workflow is healthy; treats the symptom, not the reaper. |
+| Bump lastUsedAt from the workflow watcher | Couples the read-model into the write-path heartbeat, fragile to watch latency, and still leaves budget eviction unguarded. |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| A genuinely hung workflow stuck at status:"running" strands the session forever (no idle reap, no budget evict) | Claude writes terminal status (killed/failed/completed) on abort/exit (verified on wf_5350e128-922.json); the workflow runtime has its own agent-count + timeout caps; cancelChat/killPtyInstance remain available as manual escape | Inspect sidecar status transitions; manual killPtyInstance path unaffected |
+| maxConcurrent budget exceeded when >N chats each host a live workflow | Intended: a soft resident cap must not abort live work; excess is bounded by real workflow concurrency, not unbounded idle sessions | Budget-guard test asserts live-workflow sessions are skipped, idle ones still evicted |
+| Registry returns empty (sidecar dir not yet registered / SDK driver) | hasLiveWorkflow returns false → falls back to existing behavior, no regression | Idle/budget tests with null registry assert unchanged legacy behavior |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun test src/server/agent.test.ts | All pass incl. new idle-guard + budget-guard cases |
+| bun run lint | 0 errors, no new warnings above cap |
+| c3x check | PASS (no drift) |
diff --git a/.c3/adr/adr-20260603-workflow-disk-watch-read-model.md b/.c3/adr/adr-20260603-workflow-disk-watch-read-model.md
new file mode 100644
index 000000000..b05ba2c3d
--- /dev/null
+++ b/.c3/adr/adr-20260603-workflow-disk-watch-read-model.md
@@ -0,0 +1,141 @@
+---
+id: adr-20260603-workflow-disk-watch-read-model
+c3-seal: 309cca6ee3d3413cc7055e5b4979c1ae3660b489907db7e633ef422ce1d61e7a
+title: workflow-disk-watch-read-model
+type: adr
+goal: Introduce a PTY-only, read-only "Workflow Status Panel" that surfaces Claude Code workflow runs (list, live progress, and drill-in detail) in the Kanna web UI. The system will watch `wf_.json` sidecar files written by Claude Code under `~/.claude/projects///workflows/` via a dedicated disk-watching adapter, feed them into a per-chat `WorkflowRegistry` read-model, and broadcast snapshots to subscribing clients over the `workflows` WebSocket topic. A `workflow` tool-call kind is also added to normalize the Workflow tool_use transcript entry into a hydrated inline card on launch.
+status: accepted
+date: "2026-06-03"
+---
+
+# Workflow Status: Disk-Watch Sidecar as Independent Read-Model
+
+## Goal
+
+Introduce a PTY-only, read-only "Workflow Status Panel" that surfaces Claude Code workflow runs (list, live progress, and drill-in detail) in the Kanna web UI. The system will watch `wf_.json` sidecar files written by Claude Code under `~/.claude/projects///workflows/` via a dedicated disk-watching adapter, feed them into a per-chat `WorkflowRegistry` read-model, and broadcast snapshots to subscribing clients over the `workflows` WebSocket topic. A `workflow` tool-call kind is also added to normalize the Workflow tool_use transcript entry into a hydrated inline card on launch.
+
+## Context
+
+The PTY transcript JSONL (component c3-225, sole event source) carries the `Workflow` tool_use invocation but does NOT contain `task_started`, `task_updated`, or `tool_progress` lifecycle events — those exist only in the on-disk `wf_.json` sidecar. Polling the transcript cannot surface live progress under PTY. The existing server read-model layer (c3-207) is event-sourced from Kanna's own JSONL event log (c3-206) and cannot ingest external filesystem events without violating the pure-projection contract. The WS subscription pattern (ref-ws-subscription) and CQRS read-model pattern (ref-cqrs-read-models) both apply, but the workflow read-model must be disk-fed rather than event-sourced — a deliberate, scoped override of ref-event-sourcing, documented here.
+
+## Decision
+
+Add `WorkflowRegistry` as an independent, disk-fed sibling read-model (not wired through c3-206) that:
+
+1. Registers and deregisters per-chat workflow directory paths via `claude-pty-driver` (c3-225) — the driver calls `registry.watch(chatId, dir)` when a PTY session starts and `registry.unwatch(chatId)` on close.
+2. Uses `workflow-watch-io.adapter.ts` (the sole IO adapter, compliant with ref-side-effect-adapter) to list, read, and watch sidecar files with debounce + parent directory re-arm.
+3. Maintains per-chat in-memory snapshots (`WorkflowsSnapshot`) and supports subscribe-by-chatId for WS push.
+4. Exposes a `workflows` topic in `src/shared/protocol.ts` with a `WorkflowsSnapshot` push type and a `workflows.getRun` command for detail drill-in.
+5. Adds `workflow` to the `ToolKind` union in `src/shared/tools.ts` and wires `taskId` hydration in `src/shared/types.ts`.
+6. Delivers the panel (`WorkflowsSection.tsx`) and inline transcript card (`WorkflowMessage.tsx`) on the client, backed by `workflowsStore.ts` (a Zustand store, compliant with ref-zustand-store).
+
+This design does NOT route workflow state through the Kanna event log (c3-206) or through the main transcript/turn pipeline — it is a purely additive sibling read-model, so the c3-225 "transcript is the sole event source" invariant is preserved for turn/transcript concerns.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-225 | component | Must call registry.watch/unwatch on PTY spawn/close; registers the chat's workflows directory path | Review ref-event-sourcing override scope: disk-watch is additive, not a transcript substitute |
+| c3-210 | component | Constructs WorkflowRegistry and threads it through server startup; passes it to the PTY driver via spawn args | Review ref-provider-adapter: registry is PTY-only; SDK driver must not receive it |
+| c3-208 | component | Must serve the new workflows topic: snapshot on subscribe, push on registry update, handle workflows.getRun command | Review ref-ws-subscription compliance: topic shape must follow shared envelope contract |
+| c3-302 | component | New workflows topic, WorkflowsSnapshot type, and workflows.getRun command must be added to the shared wire protocol | Review rule-strong-typing: all boundary types must be named exports |
+| c3-303 | component | New workflow ToolKind must be added and hydration logic implemented | Review ref-tool-hydration: workflow tool_use normalizes to a hydrated card |
+| c3-301 | component | taskId field must be added to the relevant transcript entry type for workflow hydration | Review rule-strong-typing |
+| c3-113 | component | Dispatch WorkflowMessage card for workflow tool kind in KannaTranscript.tsx | Review ref-tool-hydration: rendering dispatches on kind |
+| c3-114 | component | Add WorkflowMessage.tsx renderer; update ToolCallMessage.tsx dispatch | Review ref-tool-hydration compliance |
+| c3-112 | component | Thread chatId through ChatTranscriptViewport.tsx for the workflows panel | Review ref-ws-subscription: panel subscribes on mount |
+| N.A - new component workflow-status to be added | N.A - | New component for disk-watch read-model does not yet have a c3 id; wired after add | ref-cqrs-read-models, ref-side-effect-adapter, ref-ws-subscription, ref-event-sourcing override |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-cqrs-read-models | WorkflowRegistry is a read-model; must separate write path (disk sidecar) from read path (in-memory snapshot + WS push) | comply |
+| ref-ws-subscription | New workflows topic follows the single-socket subscribe/command/push envelope defined in protocol.ts | comply |
+| ref-event-sourcing | Governs all server state derivation; this ADR explicitly overrides it for WorkflowRegistry: state is derived from disk sidecars, not from the Kanna event log. Override scope is limited to WorkflowRegistry only. | update-ref (add scoped override note referencing this ADR) |
+| ref-side-effect-adapter | workflow-watch-io.adapter.ts is the sole IO file for all fs.watch/read/list calls; domain modules stay pure | comply |
+| ref-provider-adapter | WorkflowRegistry is PTY-only; SDK driver must not wire it; provider-agnostic agent-coordinator must conditionally thread it | comply |
+| ref-tool-hydration | workflow ToolKind added to tools.ts normalizes the Workflow tool_use into a hydrated inline card | comply |
+| ref-strong-typing | All new shared boundary types (WorkflowsSnapshot, WorkflowRunSummary, WorkflowRunFile) are named exports in shared/ | comply |
+| ref-zustand-store | workflowsStore.ts stores client-local WS snapshot state per the Zustand store pattern | comply |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-strong-typing | New boundary types across WS topic and protocol must be named typed exports; no any at boundaries | comply |
+| rule-colocated-bun-test | workflow-types.test.ts, workflow-watch-io.adapter.test.ts, and workflow-registry.test.ts must sit next to their implementation files | comply |
+| rule-zustand-store | workflowsStore.ts must follow one-concern-per-store and never cache server-derived truth independently of WS subscription | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| src/shared/workflow-types.ts | Pure types: WorkflowRunFile, WorkflowRunSummary, WorkflowsSnapshot; parseWorkflowRunFile; toRunSummary | bun test src/shared/workflow-types.test.ts |
+| src/shared/workflow-types.test.ts | Unit tests for parseWorkflowRunFile and toRunSummary | bun test src/shared/workflow-types.test.ts |
+| src/server/workflow-watch-io.adapter.ts | Only IO file: fs list/read/watch with debounce + parent dir re-arm; suffix .adapter.ts for ESLint seal | bun test src/server/workflow-watch-io.adapter.test.ts |
+| src/server/workflow-watch-io.adapter.test.ts | Tests for debounce, re-arm, error handling | bun test src/server/workflow-watch-io.adapter.test.ts |
+| src/server/workflow-registry.ts | Per-chat watch + snapshot/getRun/subscribe; mirrors PtyInstanceRegistry pattern | bun test src/server/workflow-registry.test.ts |
+| src/server/workflow-registry.test.ts | Tests for watch/unwatch, snapshot delivery, subscribe/push | bun test src/server/workflow-registry.test.ts |
+| src/shared/protocol.ts | Add workflows topic, WorkflowsSnapshot push type, workflows.getRun command | bunx tsc --noEmit |
+| src/server/ws-router.ts | Serve + push the workflows topic; handle getRun command | bunx tsc --noEmit |
+| src/server/claude-pty/driver.ts | Register/unregister chat's workflows dir on spawn/close | bunx tsc --noEmit |
+| src/server/agent.ts | Construct WorkflowRegistry; pass to PTY driver | bunx tsc --noEmit |
+| src/server/server.ts | Thread WorkflowRegistry through server startup | bunx tsc --noEmit |
+| src/shared/tools.ts | Add workflow ToolKind | bun run lint |
+| src/shared/types.ts | Add taskId field to hydrated tool entry | bunx tsc --noEmit |
+| src/client/stores/workflowsStore.ts | Zustand store: subscribe to workflows topic, hold WorkflowsSnapshot | bun run lint |
+| src/client/app/WorkflowsSection.tsx | Panel: list + live progress + drill-in UI | bun run lint |
+| src/client/components/messages/WorkflowMessage.tsx | Inline transcript card on Workflow tool launch | bun run lint |
+| src/client/components/messages/ToolCallMessage.tsx | Dispatch to WorkflowMessage for workflow kind | bun run lint |
+| src/client/app/KannaTranscript.tsx | Dispatch WorkflowMessage card for workflow tool kind | bun run lint |
+| src/client/app/ChatPage/ChatTranscriptViewport.tsx | Thread chatId for workflows panel subscription | bun run lint |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| New component: workflow-status | c3x add component workflow-status --container c3-2 with codemap patterns covering workflow-types.ts, workflow-watch-io.adapter.ts, workflow-registry.ts, workflowsStore.ts, WorkflowsSection.tsx, WorkflowMessage.tsx | c3x check passes 0 errors |
+| ADR record | This ADR (workflow-disk-watch-read-model) created under c3x add adr | c3x read returns all required sections |
+| Wiring: workflow-status refs | c3x wire ref-cqrs-read-models ref-ws-subscription ref-side-effect-adapter ref-provider-adapter ref-tool-hydration ref-strong-typing ref-zustand-store rule-strong-typing rule-colocated-bun-test rule-zustand-store | c3x check passes 0 errors |
+| Affected components: c3-225, c3-210, c3-208 | No body edits required at ADR creation; Parent Delta recorded as no-delta (bodies unchanged, behavior additions are additive) | c3x check --only c3-225; c3x check --only c3-210 |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| ESLint side-effect seal | workflow-watch-io.adapter.ts is the only file allowed to call fs.watch/readFile; any non-.adapter.ts file calling node:fs in src/server/ fails lint | bun run lint exits 0 |
+| TypeScript strict | All WorkflowsSnapshot, WorkflowRunSummary, WorkflowRunFile boundary types are named exports; bunx tsc --noEmit must exit 0 | bunx tsc --noEmit |
+| Bun test suite | workflow-types.test.ts, workflow-watch-io.adapter.test.ts, workflow-registry.test.ts all pass | bun test src/shared/workflow-types.test.ts && bun test src/server/workflow-watch-io.adapter.test.ts && bun test src/server/workflow-registry.test.ts |
+| c3x check | 0 errors after wiring all components and refs for this ADR | c3x check |
+| WS protocol compliance | WorkflowsSnapshot pushed as typed envelope; getRun returns typed response; no untyped payloads | bunx tsc --noEmit |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Parse task_started/task_updated/tool_progress lifecycle events from the PTY transcript JSONL | These events are NOT present in the on-disk transcript JSONL that c3-225 tails. Verified: Claude Code writes workflow progress only to the wf_.json sidecar, not to the session JSONL. Sourcing from the transcript is structurally impossible under PTY. |
+| Read workflow state from the SDK event stream | Out of scope: the Workflow Status Panel is PTY-only because the wf_.json sidecar is only written when the Claude CLI runs interactively under a PTY. The SDK driver does not produce these files. Adding SDK-driver support would require Anthropic build-gated access to task lifecycle events, which is not available. |
+| Route workflow updates through the Kanna event log (c3-206) | Would require emitting synthetic Kanna events for external disk state changes, polluting the event log with non-Kanna-originated mutations and violating the append-only JSONL model semantics. The disk sidecar is the authoritative source; Kanna should not duplicate it. |
+| Extend the existing c3-207 read-models component | c3-207 projects from the Kanna event log (pure derivation, no IO). Extending it to perform disk IO would break the pure-projection contract, violate ref-side-effect-adapter, and couple the event-sourced pipeline to an external filesystem source with different lifecycle and error semantics. A dedicated sibling read-model keeps each concern isolated. |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Violating c3-225 sole-event-source invariant | WorkflowRegistry is a SIBLING read-model feeding UI only; it does not affect the HarnessEvent stream or turn lifecycle. The PTY transcript JSONL remains the sole event source for turn/transcript concerns. | bun test src/server/workflow-registry.test.ts confirms no HarnessEvent coupling; c3x check confirms c3-225 body unchanged |
+| fs.watch missing file creation events (race on dir creation) | workflow-watch-io.adapter.ts arms a parent-directory watcher before the workflows/ subdir exists; on subdir creation, re-arms the file-level watcher. Debounce (50 ms) prevents event storms on rapid writes. | bun test src/server/workflow-watch-io.adapter.test.ts covers race and debounce scenarios |
+| Memory leak: stale per-chat watchers | WorkflowRegistry.unwatch(chatId) tears down all fs.watch handles for the chat; called by c3-225 on PTY session close. | bun test src/server/workflow-registry.test.ts includes unwatch/cleanup assertions |
+| Strong-typing drift at WS boundary | All types are named exports in src/shared/; bunx tsc --noEmit is a required check before done | bunx tsc --noEmit |
+| ESLint side-effect seal regression | Only workflow-watch-io.adapter.ts (matching *.adapter.ts glob) may import node:fs; any accidental IO in workflow-registry.ts fails lint | bun run lint exits 0 |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun test src/shared/workflow-types.test.ts | All tests pass |
+| bun test src/server/workflow-watch-io.adapter.test.ts | All tests pass |
+| bun test src/server/workflow-registry.test.ts | All tests pass |
+| bunx tsc --noEmit | Exit 0 — no type errors |
+| bun run lint | Exit 0 — 0 warnings, 0 errors |
+| c3x check | 0 errors |
diff --git a/.c3/adr/adr-20260603-workflow-liveness-live-rundir.md b/.c3/adr/adr-20260603-workflow-liveness-live-rundir.md
new file mode 100644
index 000000000..117ec76fb
--- /dev/null
+++ b/.c3/adr/adr-20260603-workflow-liveness-live-rundir.md
@@ -0,0 +1,106 @@
+---
+id: adr-20260603-workflow-liveness-live-rundir
+c3-seal: c17fc13875dd251e5460a055b73052f1d581f5fd5598fc8d0a24cbcc0198ca33
+title: workflow-liveness-live-rundir
+type: adr
+goal: 'Correct the workflow-liveness signal that `AgentCoordinator.hasLiveWorkflow` relies on. The prior ADR (`adr-20260603-workflow-aware-idle-reaper`) guarded the idle reaper on `WorkflowRegistry.snapshot(chatId)` finding a run with `status: "running"`. Empirically that signal is blind during the run: Claude writes the `workflows/wf_.json` sidecar only at/near termination, so for the entire live window the snapshot has no running run and the guard never fires — the PTY is still reaped mid-run. Switch liveness to the live transcript dir `subagents/workflows/wf_/` (written from second one) and add a wake-delay clamp so a re-entry always beats the reaper even if the file probe misses.'
+status: implemented
+date: "2026-06-03"
+---
+
+## Goal
+
+Correct the workflow-liveness signal that `AgentCoordinator.hasLiveWorkflow` relies on. The prior ADR (`adr-20260603-workflow-aware-idle-reaper`) guarded the idle reaper on `WorkflowRegistry.snapshot(chatId)` finding a run with `status: "running"`. Empirically that signal is blind during the run: Claude writes the `workflows/wf_.json` sidecar only at/near termination, so for the entire live window the snapshot has no running run and the guard never fires — the PTY is still reaped mid-run. Switch liveness to the live transcript dir `subagents/workflows/wf_/` (written from second one) and add a wake-delay clamp so a re-entry always beats the reaper even if the file probe misses.
+
+## Context
+
+Re-verified on session `de4c6a76`, run `wf_9b307764` (deployed v0.82.1 with the prior fix): launched 21:49:25, agent `*.jsonl` files written live every ~30s, but `workflows/wf_9b307764.json` stayed absent until the PTY was killed at ~22:00:13 (turn end + ~600s idle), at which point the sidecar appeared with `status:"killed"`. All 7 panel runs were terminal because the registry only ever sees terminal sidecars. So both the #358 panel and the prior `hasLiveWorkflow` are blind for the run's whole life.
+
+On disk Claude maintains TWO artifacts per run: the terminal summary `/workflows/wf_.json` (flushed late) and the live `/subagents/workflows/wf_/` dir (`journal.jsonl` + `agent-*.jsonl`, appended continuously). The registry (`c3-229`) already watches the former. The reaper/budget enforcer (`c3-210`) need a signal valid DURING the run — the latter.
+
+Compounding: the #357 harvest prompt tells the model to "call schedule_wakeup to wait longer", so it sets ~1200s wakes > the 600s idle window, and #357's `if (live !== null) return` guard suppresses the protective 120s `pending_workflow` wake — so even the wake path cannot save the run.
+
+## Decision
+
+Liveness = the live run dir, not the terminal sidecar. Add adapter `listWorkflowRunDirs(workflowsDir)` returning `{runId, newestMtimeMs}` for each `subagents/workflows/wf_*` dir, and `WorkflowRegistry.hasActiveRun(chatId, freshnessMs, now)`: a run is live when its dir saw activity within `freshnessMs` AND has no terminal sidecar yet (absent, or status still `running`). The terminal sidecar is Claude's authoritative death signal; the freshness window is the belt for a hard crash that never wrote one. `hasLiveWorkflow` calls `hasActiveRun(chatId, idleMs, now)`; the idle/budget guards from the prior ADR are unchanged. Additionally clamp `scheduleAgentWakeup` delay to `idleMs - 60s` (floor 30s) so a wake always re-enters before the reaper — defense-in-depth if the file probe is ever stale.
+
+This keeps the read-model/CQRS shape (lazy read, no new event), reuses the registered workflows dir to locate its `subagents/workflows` sibling, and adds no new IO outside the existing `.adapter.ts` leaf.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-229 | component | Gains WorkflowRegistry.hasActiveRun + adapter listWorkflowRunDirs (live-run-dir probe) as new public surface | Update Contract rows; comply with cqrs/side-effect-adapter/strong-typing refs + colocated-bun-test |
+| c3-210 | component | hasLiveWorkflow rewired to hasActiveRun; scheduleAgentWakeup clamps delay to the idle window | Comply with event-sourcing + strong-typing + colocated-bun-test; tests in agent.test.ts |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-cqrs-read-models | hasActiveRun is a derived read over the registry's run dirs + sidecars; stays on the read path | comply |
+| ref-side-effect-adapter | New node:fs calls (statSync/readdirSync) live in workflow-watch-io.adapter.ts, the exempt leaf | comply |
+| ref-strong-typing | New WorkflowRunDirInfo type + hasActiveRun signature are named types across the coordinator↔registry boundary | comply |
+| ref-event-sourcing | Guard only reads; the clamp adjusts an existing auto_continue_accepted field, emits no new event kind | comply |
+| ref-colocated-bun-test | New behavior in c3-210/c3-229 gets colocated *.test.ts next to each file under test | comply |
+| ref-provider-adapter | No provider transcript normalization change | N.A - not touched |
+| ref-tool-hydration | No tool_use hydration change | N.A - not touched |
+| ref-ws-subscription | No WS envelope/topic change (panel surface unchanged this ADR) | N.A - not touched |
+| ref-zustand-store | No client store change | N.A - server-only |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-colocated-bun-test | New behavior gets colocated tests: workflow-watch-io.adapter.test.ts, workflow-registry.test.ts, agent.test.ts | comply |
+| rule-strong-typing | WorkflowRunDirInfo + hasActiveRun typed; no untyped boundary literals | comply |
+| rule-zustand-store | No client Zustand store touched | N.A - server-only |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| Adapter | listWorkflowRunDirs(workflowsDir) → WorkflowRunDirInfo[] from ../subagents/workflows/wf_* with max-mtime | src/server/workflow-watch-io.adapter.ts |
+| Registry | dep listRunDirs? + hasActiveRun(chatId, freshnessMs, now) (fresh dir AND no terminal sidecar) | src/server/workflow-registry.ts |
+| Coordinator | hasLiveWorkflow → hasActiveRun(chatId, idleMs, now); clamp scheduleAgentWakeup delay to idleMs - WAKE_GUARD_BUFFER_MS (min 30s) | src/server/agent.ts |
+| Wiring | createWorkflowRegistry({ listRunDirs: listWorkflowRunDirs }) | src/server/server.ts |
+| Tests | adapter dir-list, registry hasActiveRun matrix (fresh/terminal/running/stale/legacy), agent idle+budget guards, wake clamp | *.test.ts |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| N.A - no C3 CLI underlay touched | Runtime + read-model logic only; no c3x command/validator/schema/template change | c3x check passes |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| workflow-registry.test.ts hasActiveRun matrix | Fails if liveness misclassifies fresh-no-sidecar / terminal / running / stale / legacy | bun test |
+| agent.test.ts idle+budget guard | Fails if a live-workflow session is reaped or evicted | bun test |
+| agent.test.ts wake clamp | Fails if a >idle delay is not clamped below the idle window | bun test |
+| bun run lint | Fails on side-effect-seal / any violations | CI |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Keep the sidecar status:"running" signal (prior ADR) | Empirically blind: sidecar is written only at/near termination, so the guard never fires during the run — the bug the prior fix was meant to solve still reproduced (run killed at 10-min idle). |
+| Parse journal.jsonl started/result delta for in-flight count | Heavier per-sweep parse; dir mtime + terminal-sidecar absence is cheaper and the terminal sidecar already gives a precise death edge. |
+| Clamp only, no live-dir signal | Clamp alone keeps the kill-resume churn (each kill loses in-flight agents); the live-dir guard prevents the kill outright. Clamp is the belt, not the primary fix. |
+| Bump lastUsedAt from the watcher | Couples the read-model into the write-path heartbeat; fragile to watch latency and leaves budget eviction unguarded. |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Hard crash leaves a run dir with no terminal sidecar | The freshnessMs window (= idleMs) reaps it once activity stops for one idle window | registry stale-window test |
+| A long single-agent phase writes no files for > idleMs and is misjudged dead | mtime is taken over ALL run-dir files incl. the agent's own appended jsonl; agents stream tool output continuously, so a full idle window of silence is itself an idle-worthy state | registry fresh/stale tests; live monitor showed ~30s write cadence |
+| Clamp shortens the model's chosen poll, using more wakes against the cap | Acceptable: correctness over token economy; cap resets on a human turn; primary defense (live-dir guard) means most wakes are not the only thing keeping the PTY alive | wake clamp test |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun test src/server/workflow-watch-io.adapter.test.ts src/server/workflow-registry.test.ts src/server/agent.test.ts | 113 pass / 0 fail |
+| bun run lint (changed files) | 0 errors |
+| c3x check --include-adr | PASS |
diff --git a/.c3/adr/adr-20260603-workflow-panel-show-running.md b/.c3/adr/adr-20260603-workflow-panel-show-running.md
new file mode 100644
index 000000000..cd0d236bc
--- /dev/null
+++ b/.c3/adr/adr-20260603-workflow-panel-show-running.md
@@ -0,0 +1,94 @@
+---
+id: adr-20260603-workflow-panel-show-running
+c3-seal: 50516dd9e402b9bb230279f707b60bbedd5810e2eb907ee48d302c11f8e17821
+title: workflow-panel-show-running
+type: adr
+goal: Make the workflow status panel show an in-flight run as `running`. Today `WorkflowRegistry.snapshot(chatId)` returns only parsed `workflows/wf_.json` sidecars, which Claude flushes at/near termination — so the panel only ever lists terminal runs (killed/completed/failed) and never a live one. Surface a synthetic `running` row from the live run dir, and watch that dir so the row appears promptly at launch.
+status: proposed
+date: "2026-06-03"
+---
+
+## Goal
+
+Make the workflow status panel show an in-flight run as `running`. Today `WorkflowRegistry.snapshot(chatId)` returns only parsed `workflows/wf_.json` sidecars, which Claude flushes at/near termination — so the panel only ever lists terminal runs (killed/completed/failed) and never a live one. Surface a synthetic `running` row from the live run dir, and watch that dir so the row appears promptly at launch.
+
+## Context
+
+`c3-229` watches `/workflows` for terminal sidecars. The live run dirs `/subagents/workflows/wf_*` (journal + agent jsonl) are written from second one. The idle-reaper fix (`adr-20260603-workflow-liveness-live-rundir`) already added `listWorkflowRunDirs` + `hasActiveRun` reading those dirs. This ADR reuses that same live signal for the panel read-model. Without a watch on the live dir root, a launched run (no sidecar yet) would not push a snapshot until it terminated, so the row would never appear while it matters.
+
+## Decision
+
+`snapshot(chatId)` merges sidecar runs with synthetic `running` rows: for each live run dir with no sidecar entry and activity within a 10-minute window, add `{status:"running"}` (sidecars always win — they carry the real terminal status + counts). Add adapter `watchWorkflowRunDirs(workflowsDir, cb)` (wraps `watchWorkflowDir` on the `liveRunRoot` sibling) and a registry `watchRunDirs?` dep; `register` arms both watches so a launch pushes a snapshot. Stale dirs past the window (crash with no sidecar) are dropped rather than shown forever-running. No client change — `WorkflowsSection` already renders the `running` status and guards missing counts.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-229 | component | snapshot now surfaces in-flight runs; adds watchWorkflowRunDirs adapter + watchRunDirs dep | Update Contract; comply cqrs/side-effect-adapter/strong-typing/ws-subscription + colocated-bun-test |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-cqrs-read-models | snapshot is the derived read pushed to the panel; synthetic running rows stay on the read path | comply |
+| ref-ws-subscription | the live-dir watch drives the same workflows topic push as the sidecar watch | comply |
+| ref-side-effect-adapter | the new watch is the existing .adapter.ts leaf (watchWorkflowRunDirs) | comply |
+| ref-strong-typing | synthetic run uses the named WorkflowRun/WorkflowStatus types, no untyped literal | comply |
+| ref-event-sourcing | read-model only; emits no event | N.A - read-model, not event path |
+| ref-provider-adapter | no provider transcript change | N.A - not touched |
+| ref-tool-hydration | no tool_use hydration change | N.A - not touched |
+| ref-zustand-store | client unchanged (renders running already) | N.A - no client change |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-colocated-bun-test | snapshot-running + watch-trigger tests colocated in workflow-registry.test.ts | comply |
+| rule-strong-typing | typed synthetic run + adapter signatures | comply |
+| rule-zustand-store | no client store touched | N.A - server-only |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| Adapter | liveRunRoot(workflowsDir) + watchWorkflowRunDirs(workflowsDir, cb) wrapping watchWorkflowDir on the sibling | src/server/workflow-watch-io.adapter.ts |
+| Registry | snapshot merges synthetic running rows (fresh live dir, no sidecar, sidecar wins); watchRunDirs? dep armed in register | src/server/workflow-registry.ts |
+| Wiring | createWorkflowRegistry({ watchRunDirs: watchWorkflowRunDirs }) | src/server/server.ts |
+| Tests | snapshot synth running / sidecar-wins / stale-drop / watch-notify | src/server/workflow-registry.test.ts |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| N.A - no C3 CLI underlay touched | Read-model + adapter only | c3x check passes |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| workflow-registry.test.ts snapshot tests | Fail if a fresh live run is not surfaced, a sidecar is overridden, or a stale dir lingers | bun test |
+| bun run lint | Fails on side-effect-seal / any | CI |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Parse journal.jsonl for live agent counts | Heavier; the goal is only to SHOW the run is running — counts fill in from the sidecar at termination. |
+| Poll instead of watch the live dir | A watch (already the registry's model) pushes promptly with no interval lag. |
+| Show every live dir as running with no freshness | A crashed run with no sidecar would linger as forever-running in the panel. |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Synthetic running row has no agentCount/tokens | Client guards missing counts (!= null); row shows status only until the sidecar lands | WorkflowsSection count guards; snapshot test |
+| Crash with no sidecar lingers as running | 10-min freshness window drops stale live dirs | stale-drop test |
+| Extra watch handle per chat | Disposed alongside the sidecar watch in unregister/re-register | register/dispose composition |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun test src/server/workflow-registry.test.ts | 13 pass / 0 fail |
+| bun run lint (changed files) | 0 errors |
+| c3x check | structural PASS |
diff --git a/.c3/adr/adr-20260604-claude-import-custom-title-backfill.md b/.c3/adr/adr-20260604-claude-import-custom-title-backfill.md
new file mode 100644
index 000000000..66f168356
--- /dev/null
+++ b/.c3/adr/adr-20260604-claude-import-custom-title-backfill.md
@@ -0,0 +1,86 @@
+---
+id: adr-20260604-claude-import-custom-title-backfill
+c3-seal: 53db8d7638f049183d03ef90bf90cb48c955dd43b8bf9f8194db93676d20ab1f
+title: claude-import-custom-title-backfill
+type: adr
+goal: Extend Claude session import naming so Kanna derives imported chat titles from Claude `custom-title` records and backfills already-imported Claude chats when their stored title still matches Kanna's legacy importer-derived title.
+status: implemented
+date: "2026-06-04"
+---
+
+## Goal
+
+Extend Claude session import naming so Kanna derives imported chat titles from Claude `custom-title` records and backfills already-imported Claude chats when their stored title still matches Kanna's legacy importer-derived title.
+
+## Context
+
+A real imported chat (`a1ff98cc-1856-4a63-bf1d-ce7ad190c15a`) shows the first user prompt in the sidebar even though the source Claude JSONL has repeated `type: "custom-title"` records with `customTitle: "pvs-no-change"`. The previous title derivation change only handled `summary` records, but this source file has zero `summary` records. Existing imports are also skipped before any rename happens when the source hash matches, so restarting or re-importing cannot update old titles. The importer adapter remains a C3 codemap gap, but its tests and neighboring Claude session types/mapper/scanner files are owned by c3-214 discovery. Parent Delta: c3-2 needs no container responsibility change because local history import and persistence are already server responsibilities.
+
+## Decision
+
+Add a typed Claude `custom-title` record shape and make `deriveTitle()` prefer the latest non-empty `customTitle`, then latest non-empty `summary`, then the first user text, then `"Imported session"`. For existing imported chats, compute both the new title and the legacy title that would have been produced without `custom-title`. Rename an existing chat only when the new title differs and the current title looks importer-owned: equal to the legacy title, `"Imported session"`, or `"New Chat"`. Run this title backfill before the unchanged-hash skip so a title-only update counts as `updated`; preserve normal message delta handling for changed hashes.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-214 | component | Claude session import and session record typing are local-history discovery/import behavior. | Comply with local-first data handling and keep projection shape unchanged. |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-local-first-data | The new title source is read from local Claude JSONL files and persisted to the local Kanna event log only. | comply |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-colocated-bun-test | The import behavior is enforced by src/server/claude-session-importer.test.ts beside the importer. | comply |
+| rule-strong-typing | custom-title is a boundary shape from Claude JSONL into the importer and must be named/narrowed. | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| src/server/claude-session-types.ts | Add a named ClaudeSessionCustomTitleRecord to the ClaudeSessionRecord union. | Type diff and importer test compilation. |
+| src/server/claude-session-importer.adapter.ts | Prefer latest non-empty customTitle, keep summary/user fallbacks, and backfill importer-owned existing titles before source-hash skip. | Targeted Bun tests pass. |
+| src/server/claude-session-importer.test.ts | Add tests for custom-title precedence and unchanged-hash title-only backfill. | bun test src/server/claude-session-importer.test.ts. |
+| c3-2 parent delta | No parent doc update needed; server already owns local discovery/import, persistence, and read-model broadcast. | c3-2 Responsibilities already cover local project discovery and event/read-model ownership. |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| N.A - no C3 CLI underlay changed | The change affects Kanna runtime import behavior only, not C3 commands, validators, schemas, hints, templates, or tests. | c3 check --include-adr --only adr-20260604-claude-import-custom-title-backfill remains clean. |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| Importer unit tests | Fail if customTitle does not beat summary/user fallback or if unchanged-hash existing imports cannot backfill importer-owned titles. | bun test src/server/claude-session-importer.test.ts. |
+| C3 structural check | Confirms ADR compliance and parent-delta evidence. | c3 check --include-adr --only adr-20260604-claude-import-custom-title-backfill. |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Rename every existing imported chat whenever Claude has a custom title | This could overwrite a user’s manual Kanna rename; importer-owned-title detection gives the requested backfill without broad clobbering. |
+| Only apply custom-title to new imports | It would not fix the actual reported chat because unchanged-hash existing imports are skipped. |
+| Put customTitle below summary in priority | The reported source uses custom-title as the explicit session name and has no summary; when both exist, explicit custom title should win over generated summary text. |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Backfill could overwrite a manually renamed Kanna chat. | Rename only if current title equals the legacy importer title, Imported session, or New Chat. | Backfill test exercises title-only rename; existing manual rename behavior is preserved by predicate. |
+| custom-title records have no timestamp and may repeat. | Iterate records from newest to oldest and accept the latest non-empty customTitle; repeated identical titles are idempotent. | Custom-title precedence test includes multiple title sources. |
+| Title-only updates might be invisible in import result counts. | Count a title-only rename as updated so UI feedback reports work done. | Unchanged-hash backfill test expects updated and not skipped. |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun test src/server/claude-session-importer.test.ts | Passed: 10 tests, 0 failed. |
+| bunx tsc --noEmit | Passed. |
+| c3 check --include-adr --only adr-20260604-claude-import-custom-title-backfill | Passed: 103 total, 0 issues. |
diff --git a/.c3/adr/adr-20260604-claude-import-summary-title.md b/.c3/adr/adr-20260604-claude-import-summary-title.md
new file mode 100644
index 000000000..863fc4b59
--- /dev/null
+++ b/.c3/adr/adr-20260604-claude-import-summary-title.md
@@ -0,0 +1,84 @@
+---
+id: adr-20260604-claude-import-summary-title
+c3-seal: 727d0945bd9767f90361018846f3edf07fad24e563c860311ef851dfef6d8f7e
+title: claude-import-summary-title
+type: adr
+goal: Change Claude session import title derivation so imported Kanna chats prefer a Claude JSONL summary record when present, while preserving the existing fallback to the first user prompt and finally "Imported session" when no useful text exists.
+status: implemented
+date: "2026-06-04"
+---
+
+## Goal
+
+Change Claude session import title derivation so imported Kanna chats prefer a Claude JSONL summary record when present, while preserving the existing fallback to the first user prompt and finally "Imported session" when no useful text exists.
+
+## Context
+
+The sidebar already renders the persisted chat title from the server read model, and import already renames new chats after creating them. The current `deriveTitle()` in `src/server/claude-session-importer.adapter.ts` ignores `summary` records even though `ClaudeSessionSummaryRecord` is modeled, so imported sessions can show a prompt snippet instead of Claude's session name/summary. The changed adapter file is currently a C3 codemap gap, but its colocated test and neighboring Claude session parser/mapper/scanner files are owned by c3-214 discovery. Parent Delta: c3-2 needs no container responsibility change because this stays inside the existing local-history import responsibility.
+
+## Decision
+
+Update `deriveTitle()` to scan Claude records for non-empty `summary` text before inspecting user messages. Use the latest non-empty summary by iterating records from newest to oldest, because a later summary is the most current session name after conversation evolution. If no summary is available, keep the existing first-user-text fallback and then the literal "Imported session" fallback. This keeps the title at the import boundary where chat persistence already happens and avoids adding sidebar-only derived naming logic.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-214 | component | Claude session import belongs with discovery/local history ingestion even though the adapter file is currently uncharted; tests and neighboring Claude session modules map here. | Comply with discovery's local-history scanning purpose and local-first data ref. |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-local-first-data | The title is derived only from local Claude JSONL history already on disk and persisted to Kanna's local event store. | comply |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-colocated-bun-test | The behavior is covered by a colocated Bun test beside the importer. | comply |
+| rule-strong-typing | The importer consumes typed Claude session records and must not introduce untyped boundary shapes. | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| src/server/claude-session-importer.adapter.ts | Add summary extraction and make deriveTitle() prefer the latest non-empty summary before user text. | Targeted diff and passing importer test. |
+| src/server/claude-session-importer.test.ts | Add coverage proving summary title wins over first user prompt and blank summaries are ignored. | bun test src/server/claude-session-importer.test.ts. |
+| c3-2 parent delta | No parent doc update needed because the server already owns local discovery/import, persistence, and read-model broadcast. | c3-2 Responsibilities already include discovering local projects and owning derived read models. |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| N.A - no C3 CLI underlay changed | The change affects Kanna runtime import behavior only, not C3 commands, schemas, validators, hints, or templates. | c3 check --include-adr --only adr-20260604-claude-import-summary-title remains clean. |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| Importer unit test | Fails if imported chat title does not prefer a non-empty summary over first user text. | bun test src/server/claude-session-importer.test.ts. |
+| C3 structural check | Confirms documentation structure remains valid after the ADR is added. | c3 check --include-adr --only adr-20260604-claude-import-summary-title. |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Add sidebar fallback logic for imported sessions | The sidebar already renders chat.title; duplicating title derivation in the client would violate the existing server-derived truth flow. |
+| Keep first user prompt as the title | This ignores available Claude summary records and does not satisfy the requested session-name behavior. |
+| Prefer the first summary record | Older summary records can be stale after later compaction/renaming; latest non-empty summary better represents current session state. |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| A blank or whitespace-only summary would hide a useful user prompt title. | Trim summary text and only accept non-empty values. | Unit test includes an empty summary before the real one. |
+| Existing imported sessions with unchanged source hashes do not get backfilled. | Scope this ADR to new import title derivation only; backfill can be a separate explicit change if needed. | Existing skip/update behavior remains untouched by targeted tests. |
+| Summary text could be longer than the existing prompt-derived title. | Apply the same 60-character cap to summary-derived titles. | Existing truncation path is applied to summary and user prompt titles. |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun test src/server/claude-session-importer.test.ts | Passed: 7 tests, 0 failed. |
+| c3 check --include-adr --only adr-20260604-claude-import-summary-title | Passed: 102 total, 0 issues. |
diff --git a/.c3/adr/adr-20260604-pty-instance-leak-respawn-clobber.md b/.c3/adr/adr-20260604-pty-instance-leak-respawn-clobber.md
new file mode 100644
index 000000000..543128c5d
--- /dev/null
+++ b/.c3/adr/adr-20260604-pty-instance-leak-respawn-clobber.md
@@ -0,0 +1,104 @@
+---
+id: adr-20260604-pty-instance-leak-respawn-clobber
+c3-seal: 754218fd9463aa15da3a90f90b923227503dcaa05d61aed016c5552de2bd53c3
+title: pty-instance-leak-respawn-clobber
+type: adr
+goal: 'Stop the claude-pty driver from leaking a live PTY child that has become invisible to both the in-memory `PtyInstanceRegistry` (UI "PTY instances" panel) and the on-disk `ClaudePtyRegistry` (crash-reap list). Two concrete defects are fixed: (1) when a chat re-spawns its claude session (each `--resume ` turn/wake/rotation reuses the same `chatId` and `sessionId`), the OLD handle''s deferred `cleanupResources()` clobbers the NEW handle''s registry entries because both registries key on the shared `chatId`/`sessionId`; (2) `killPgroup(pid)` reaps via `process.kill(-pid)`, which silently no-ops when the PTY child is not its own process-group leader (under PM2 the child inherits the server''s pgid) — and would SIGKILL the entire kanna app if the pgid ever matched. After this change a stale handle never overwrites a live handle''s registry state, and reap kills the actual process subtree by pid.'
+status: implemented
+date: "2026-06-04"
+---
+
+## Goal
+
+Stop the claude-pty driver from leaking a live PTY child that has become invisible to both the in-memory `PtyInstanceRegistry` (UI "PTY instances" panel) and the on-disk `ClaudePtyRegistry` (crash-reap list). Two concrete defects are fixed: (1) when a chat re-spawns its claude session (each `--resume ` turn/wake/rotation reuses the same `chatId` and `sessionId`), the OLD handle's deferred `cleanupResources()` clobbers the NEW handle's registry entries because both registries key on the shared `chatId`/`sessionId`; (2) `killPgroup(pid)` reaps via `process.kill(-pid)`, which silently no-ops when the PTY child is not its own process-group leader (under PM2 the child inherits the server's pgid) — and would SIGKILL the entire kanna app if the pgid ever matched. After this change a stale handle never overwrites a live handle's registry state, and reap kills the actual process subtree by pid.
+
+## Context
+
+Observed in session `5f78aa43-3e2e-416a-8e75-608d4e41c30c` (a Workflow run with 19 `schedule_wakeup` re-entries + OAuth rotations). The chat re-spawned its claude PTY six times for the same session `1f75b42a` (pids 30405→34743→36078→37288→38830→41506). Server log proves the race: `pty spawned pid 41506` (94715, registers) precedes `pty.exited resolved pid 38830 → drainTerminate → cleanupResources` (94745) for the OLD handle of the SAME sessionId. The old handle's `upsert(chatId,{phase:"exited"})` (in-memory, keyed by chatId) marked the live chat exited → pruned after the 60 s exited-TTL → UI showed 0; its `unregister(sessionId)` (on-disk, keyed by sessionId) deleted the live pid's reap entry → orphan invisible. `41506` itself never closed (blocked mid-turn on never-returning `until … sleep 30` Bash loops, so the idle reaper never fired). Separately, `ps` confirmed `pid 41506` had `PGID 51937` (the shared PM2/server group, 11 members) — NOT its own leader — so process group `41506` was empty and `kill(-41506)` was a no-op. `closeClaudeSession` (c3-210, agent.ts) tears the old session down fire-and-forget and immediately spawns the replacement, which is what opens the overlap window. Affected topology is entirely within c3-225 (`driver.ts`, `pty-instance-registry.ts`, `pid-registry.adapter.ts`, `pty-process.adapter.ts`); the orphan-reap and live-status surfaces are owned here.
+
+## Decision
+
+Make registry teardown identity-scoped to the handle that owns the entry, and make reap kill by process subtree rather than by group:
+
+1. **On-disk `ClaudePtyRegistry` keyed by `pid`, not `sessionId`.** `register` dedupes on `pid`; `unregister(pid)` removes only the matching pid. `--resume` makes `sessionId` non-unique across concurrent re-spawns, so pid is the only stable identity. The driver passes `pty.pid` to `unregister`.
+2. **In-memory `PtyInstanceRegistry` guarded by pid on teardown.** Add `markExitedIfCurrent(chatId, pid, patch)`: only apply the `phase:"exited"` patch when the live entry's `pid` still equals the closing handle's pid. A stale handle whose pid was already overwritten by the replacement spawn is a no-op.
+3. **Reap by subtree, never by group.** Replace `killPgroup(pid)`'s blind `process.kill(-pid)` with a descendant-walk kill (collect the pid + all descendants via `ps`, SIGKILL leaves-first). This reaps the claude child AND its detached `nohup` shell loops, never no-ops on non-leader children, and can never signal the server's own group.
+
+This is the right fit: it keeps the registries as thin read-models (no lifecycle redesign), fixes the exact clobber and the exact mis-targeted kill, and the subtree walk reuses the same `ps` primitive `pty-memory-sampler.adapter.ts` already shells, so it stays inside the `.adapter.ts` IO seal.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-225 | component | Owns the PTY transport, both registries, and the reap path being changed | Review Contract "Live-status registry upserts" surface + Change Safety; add reap/teardown identity guard |
+| c3-210 | component | closeClaudeSession re-spawn-without-await opens the overlap window; consumes registry state | Review-only: no signature change required, confirm fire-and-forget close stays compatible |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-colocated-bun-test | New behavior (pid-keyed registry, guarded teardown, subtree reap) needs tests beside each changed source | comply |
+| ref-provider-adapter | Changes are internal to the Claude PTY adapter; HarnessEvent stream + prompt surfaces unchanged | comply |
+| ref-event-sourcing | Registries are derived read-models, not the event log; no change to log-before-broadcast | review |
+| ref-tool-hydration | Cited by c3-210, listed in Affected Topology as review-only. This leak fix touches neither tool hydration nor the canUseTool/MCP-tool surface it governs | N.A - tool hydration untouched; c3-210 is review-only, no edits to its tool surfaces |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-colocated-bun-test | Each touched file (pid-registry.adapter.ts, pty-instance-registry.ts, driver.ts, pty-process.adapter.ts) has a colocated *.test.ts that must cover the new paths | comply |
+| rule-strong-typing | New API (unregister(pid:number), markExitedIfCurrent, descendant-kill helper) must use named types, no any at the boundary | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| on-disk registry | ClaudePtyRegistry: dedupe register by pid; change unregister(sessionId) → unregister(pid:number); reap unchanged | src/server/claude-pty/pid-registry.adapter.ts |
+| reap kill | Replace killPgroup body with subtree-collect-and-SIGKILL (pid + descendants via ps); export killProcessTree; reapStale + agent.ts killPtyInstance call it | src/server/claude-pty/pid-registry.adapter.ts, src/server/agent.ts |
+| in-memory registry | Add markExitedIfCurrent(chatId, pid, patch) guarded upsert | src/server/claude-pty/pty-instance-registry.ts |
+| driver teardown | cleanupResources uses markExitedIfCurrent(chatId, pty.pid, …) and unregister(pty.pid); guard when pty unassigned (early spawn failure) | src/server/claude-pty/driver.ts |
+| tests | pid-keyed register/unregister + clobber-race; markExitedIfCurrent stale-pid no-op; subtree kill collects descendants; driver teardown does not clobber a newer pid | src/server/claude-pty/pid-registry.test.ts, pty-instance-registry.test.ts, driver.test.ts |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| c3-225 Contract | Update "Live-status registry upserts" row to note teardown is pid-guarded (stale handle never clobbers live entry) | c3x write c3-225 --section Contract |
+| c3-225 Change Safety | Add row: "Stale re-spawn handle clobbers live registry entry / reap no-ops on non-leader pid" with grep + bun test detection | c3x write c3-225 --section "Change Safety" |
+| N.A - no CLI underlay | This ADR changes runtime code only, not c3x commands/validators/schema | N.A - runtime-only change |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| pid-registry.test.ts | Asserts unregister(stale pid) keeps the live pid entry; register dedupes by pid | src/server/claude-pty/pid-registry.test.ts |
+| pty-instance-registry.test.ts | Asserts markExitedIfCurrent(chatId, stalePid) is a no-op when live pid differs | src/server/claude-pty/pty-instance-registry.test.ts |
+| driver.test.ts | Asserts an old handle's cleanupResources does not flip a chat to exited after a newer pid registered | src/server/claude-pty/driver.test.ts |
+| pid-registry.test.ts (kill) | Asserts killProcessTree collects + signals pid + descendants, never -pid | src/server/claude-pty/pid-registry.test.ts |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| setsid the PTY child at spawn so pid==pgid and kill(-pid) works | Bun.Terminal/Bun.spawn does not expose a session-leader option in the deployed Bun; observed children inherit the server pgid. Relying on an unenforced setsid is exactly the false assumption that caused the leak. |
+| Serialize close-then-spawn in closeClaudeSession (await old teardown before re-spawn) | Fixes the timing but not the root identity bug; an await on the 2 s+3 s SIGKILL escalation would stall every wake/rotation turn for seconds, regressing latency, and any future async gap reopens the race. |
+| Keep sessionId key, add a generation counter | Adds a parallel identity to the pid that already uniquely identifies the OS process; pid is the natural key and is what reap needs anyway. |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Subtree walk misses a reparented descendant (orphaned after parent death) | Collect descendants BEFORE signalling the parent, then SIGKILL the whole set leaves-first | pid-registry.test.ts: tree fixture asserts all collected pids signalled |
+| unregister(pid) signature change breaks a caller | Only caller is driver.ts; grep confirms; type change is compile-checked | bun run check-all / tsc; grep -rn "\.unregister(" src/server |
+| Guarded teardown skips a legitimate exit (entry pid already cleared) | markExitedIfCurrent treats "no entry" as nothing-to-do (already removed) and only skips when a DIFFERENT pid owns the entry | pty-instance-registry.test.ts no-op + still-exits cases |
+| ps invocation differs across macOS/Linux | Reuse the same ps -A -o pid=,ppid= form the memory sampler adapter already ships and tests | pty-memory-sampler.adapter.test.ts parity; new kill test |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun test src/server/claude-pty/pid-registry.test.ts | pass (pid-key register/unregister + subtree kill) |
+| bun test src/server/claude-pty/pty-instance-registry.test.ts | pass (markExitedIfCurrent guard) |
+| bun test src/server/claude-pty/driver.test.ts | pass (no stale clobber) |
+| bun run lint | 0 errors, warnings ≤ cap |
+| grep -rn "process.kill(-" src/server/claude-pty | only inside killProcessTree guard, or absent |
diff --git a/.c3/adr/adr-20260604-workflow-rerun-masking.md b/.c3/adr/adr-20260604-workflow-rerun-masking.md
new file mode 100644
index 000000000..d72423ea8
--- /dev/null
+++ b/.c3/adr/adr-20260604-workflow-rerun-masking.md
@@ -0,0 +1,173 @@
+---
+id: adr-20260604-workflow-rerun-masking
+c3-seal: 06938fa4bff45257107c9e2f71815b4157fa82c460420c75c1f4c26de2518fe5
+title: workflow-rerun-masking
+type: adr
+goal: |-
+ Stop the WorkflowRegistry read-model from showing a stale terminal `failed`
+ sidecar when a later workflow launch has reused the same `runId` and is
+ actively running. When Claude Code relaunches a workflow via `scriptPath`
+ (the persisted script filename embeds the `runId`), the re-run reuses that
+ `runId` but mints a new `taskId`, pours its agents into the same
+ `subagents/workflows/wf_/` live dir, and does NOT rewrite the prior
+ sidecar until it terminates. The registry currently lets the prior
+ `failed` sidecar permanently mask the live re-run, so the panel shows a
+ finished run while 40+ agents are actually running. The decision: in
+ `snapshot()` and `getRun()`, a terminal sidecar that is the no-op crash
+ shape (`status=failed`, `agentCount=0`, empty `agents`) must NOT mask a
+ fresh, non-empty live journal for the same `runId` — surface a synthetic
+ `running` row enriched from the journal instead.
+status: implemented
+date: "2026-06-04"
+uses:
+ - c3-229
+---
+
+# workflow-rerun-masking
+
+## Goal
+
+Stop the WorkflowRegistry read-model from showing a stale terminal `failed`
+sidecar when a later workflow launch has reused the same `runId` and is
+actively running. When Claude Code relaunches a workflow via `scriptPath`
+(the persisted script filename embeds the `runId`), the re-run reuses that
+`runId` but mints a new `taskId`, pours its agents into the same
+`subagents/workflows/wf_/` live dir, and does NOT rewrite the prior
+sidecar until it terminates. The registry currently lets the prior
+`failed` sidecar permanently mask the live re-run, so the panel shows a
+finished run while 40+ agents are actually running. The decision: in
+`snapshot()` and `getRun()`, a terminal sidecar that is the no-op crash
+shape (`status=failed`, `agentCount=0`, empty `agents`) must NOT mask a
+fresh, non-empty live journal for the same `runId` — surface a synthetic
+`running` row enriched from the journal instead.
+
+## Context
+
+Reproduced in chat `5f78aa43` (cwd `pvs-core-i-full`). Launch 1
+(`Workflow({name})`, taskId `wdd2dyoww`) crashed at script eval
+(`TypeError: 'safeDirs.length'`) with 0 agents and wrote a terminal
+`failed` sidecar `wf_ca5a4465-d00.json`. The model fixed the script and
+relaunched via `Workflow({scriptPath: ".../sonar-sweep-remaining-wf_ca5a4465-d00.js"})`
+(taskId `w9mwas7qa`), which reused `runId` `wf_ca5a4465-d00` and ran 40+
+real agents (11:34→11:43, 74 distinct agentIds in `journal.jsonl`). The
+sidecar was never refreshed; the panel showed `FAILED` and never the
+running re-run. Constraint: WorkflowRegistry is bound by the c3-225
+sole-event-source invariant — it must NOT read the HarnessEvent transcript,
+so the strong identity (`taskId`, unique per launch) is unavailable
+server-side; only disk artifacts (sidecar + live journal) may be consulted.
+mtime ordering is too weak (fs granularity, buffered flush races,
+shared-dir interleave); a naive "any live agentId beyond the sidecar →
+running" signal false-positives on genuinely `completed` runs (validated:
+several completed runs have 2–4 journal agentIds absent from
+`workflowProgress`). Affected topology: c3-229 (workflow-status) only.
+
+## Decision
+
+Add a content-based predicate `isStaleCrashSidecar(run)` =
+`status === "failed" && (agentCount ?? 0) === 0 && agents.length === 0`.
+In `snapshot()` and `getRun()`, a sidecar passing this predicate is treated
+as overridable: when the same `runId`'s live run dir is fresh (within the
+existing `SNAPSHOT_LIVE_WINDOW_MS`) AND its `journal.jsonl` yields ≥1 agent,
+emit a synthetic `running` row enriched from the journal instead of the
+stale `failed` sidecar. A non-crash terminal sidecar
+(`completed`/`killed`/`failed-with-agents`) still wins unconditionally, and
+a crash sidecar with no fresh/non-empty journal still shows `failed`
+(truthful). This wins over mtime comparison because `agentCount===0` means
+the sidecar's run did literally nothing, so any journal agent can only
+belong to a later run reusing the dir — a monotonic, clock-independent fact
+that is safe under concurrency. Validated across 31 real runs: the
+predicate flags exactly the one re-run (`wf_ca5a4465-d00`) and zero of the
+12 completed / 16 killed / 2 true-crash runs. Coverage is intentionally
+narrow (re-run over a crashed-at-launch run — the only path that reuses a
+`runId` in practice, since the `runId` is embedded in the relaunched script
+filename); re-run over a completed/killed run is out of scope and noted.
+Client polish: `ToolCallMessage` joins a launch card to a run by exact
+`taskId`; lock that the card for a launch whose `taskId` has no matching
+run row renders the "started…" pill (never a stale failed run) — already
+the behavior; add a regression test since the synthetic running row carries
+no `taskId` (live dir has none) and cannot be bound to the card within
+c3-225.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-229 | component | Owns WorkflowRegistry snapshot/getRun semantics and the workflow ToolKind hydration consumed by ToolCallMessage; the masking rule and the contract text for snapshot/getRun change here | Update Contract rows for snapshot + getRun; verify c3-225 sole-event-source invariant still holds (no HarnessEvent read) |
+| c3-2 | container | Parent container (server) hosting the read-model; no structural/boundary delta — surface signatures unchanged | Parent Delta: no-delta, contract semantics refined within existing API |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-cqrs-read-models | Registry stays a pure read-model deriving views from disk; the override reads only the existing disk journal, never writes | comply |
+| ref-side-effect-adapter | All new disk reads (journal for the override) go through workflow-watch-io.adapter.ts via the already-wired readRunJournal/listRunDirs deps; no fs in workflow-registry.ts | comply |
+| ref-strong-typing | isStaleCrashSidecar takes/returns named types (WorkflowRun→boolean); no any, no new untyped boundary shape | comply |
+| ref-event-sourcing | SCOPED OVERRIDE (adr-20260603-workflow-disk-watch-read-model): state is disk-derived; this change stays inside that override and the c3-225 invariant (no HarnessEvent / no taskId from transcript) | comply |
+| ref-provider-adapter | Registry remains PTY-only; the override adds no SDK-path coupling and no provider branching | review |
+| ref-tool-hydration | Workflow ToolKind hydration is unchanged; the client polish only refines the taskId join, not hydration | review |
+| ref-ws-subscription | WorkflowsSnapshot envelope shape is unchanged; the override emits the same WorkflowRunSummary row type over the same topic | comply |
+| ref-zustand-store | workflowsStore stays WS-fed; client polish reads the snapshot via props, never caches server truth | comply |
+| ref-colocated-bun-test | New cases land in colocated workflow-registry.test.ts / ToolCallMessage.test.tsx | comply |
+| ref-local-first-data | Not cited by c3-229 and not touched here: workflow state is an external Claude Code disk sidecar, no Kanna local-first persisted store is read or written | N.A - ref not used by c3-229 |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-strong-typing | New predicate + altered snapshot/getRun branches cross the registry boundary; must stay named-typed, no any | comply |
+| rule-colocated-bun-test | New cases must land in the colocated workflow-registry.test.ts; client case in ToolCallMessage.test.tsx | comply |
+| rule-zustand-store | Client polish must not cache server truth in a store; workflowsStore stays WS-fed and the card reads the snapshot via existing props | comply |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| Server predicate | Add isStaleCrashSidecar(run: WorkflowRun): boolean = failed && agentCount 0 && agents empty | src/server/workflow-registry.ts |
+| Server snapshot() | In the listRunDirs loop, allow override of a merged entry when it isStaleCrashSidecar and the live journal (read only in that case) is non-empty + fresh; emit enriched synthetic running row | src/server/workflow-registry.ts |
+| Server getRun() | Return sidecar only when !isStaleCrashSidecar; otherwise fall through to live-dir synthesis, but fall back to the failed sidecar when the live journal is empty / dir not fresh | src/server/workflow-registry.ts |
+| Server tests | Cases: stale-crash sidecar + fresh non-empty journal → running; completed/killed sidecar → unchanged; crash sidecar + empty journal → failed; no-sidecar + live → running (existing) | src/server/workflow-registry.test.ts |
+| Client polish | Lock taskId-exact join: card whose taskId has no run row renders StartedPill, never a mismatched run | src/client/components/messages/ToolCallMessage.tsx, ToolCallMessage.test.tsx |
+| Doc sync | Update c3-229 Contract rows (snapshot/getRun) to state crash-sidecar override; CLAUDE.md Workflow Status Panel note | .c3 via c3x write; CLAUDE.md |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| N.A - no C3 CLI/validator/schema/template/help change | This ADR changes product code under c3-229 only; it does not touch the c3x CLI, its validators, schemas, hints, or templates | c3x check passes unchanged after the c3-229 Contract-row doc update |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| bun test src/server/workflow-registry.test.ts | Fails if a stale-crash sidecar masks a fresh live re-run, or if a completed/killed sidecar is wrongly overridden | src/server/workflow-registry.test.ts |
+| bun test src/client/components/messages/ToolCallMessage.test.tsx | Fails if a launch card binds to a run whose taskId differs | src/client/components/messages/ToolCallMessage.test.tsx |
+| bunx tsc --noEmit | Fails on any untyped boundary in the new predicate / branches | tsconfig |
+| bun run lint | Fails if any fs call leaks into workflow-registry.ts (side-effect seal) | eslint.config.js |
+| c3x check | Fails if c3-229 Contract drifts from the new snapshot/getRun semantics | .c3/c3-2-/c3-229-.md |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| Compare sidecar mtime vs live-dir newestMtimeMs (live newer → running) | Clock-based: fs mtime granularity + buffered/fsync ordering + shared-dir interleave make it racy; a genuinely-finished run with a trailing agent flush would ghost as running. User flagged it as too weak. |
+| Naive agentId set-difference (any live agentId not in sidecar → running) | Validated false-positive: several completed runs have 2–4 journal agentIds absent from workflowProgress → would ghost finished runs as running. |
+| Read taskId from the transcript to disambiguate launches server-side | Violates the c3-225 sole-event-source invariant — WorkflowRegistry must not couple to the HarnessEvent stream. |
+| Resolve staleness entirely on the client using transcript taskId ordering | Client never receives a row for the masked re-run (server suppresses it), so there is nothing to re-render; the fix must originate server-side. |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Override ghosts a real failed-at-launch run that never re-ran | Predicate also requires a non-empty live journal + freshness; a true crash has an empty journal (validated: wf_13d6d464, wf_a727ef61 → journal 0, not flagged) | bun test src/server/workflow-registry.test.ts (crash+empty-journal → failed) |
+| Re-run over a completed/killed run not surfaced as running | Out of scope by design; documented; such reuse is rare (runId reuse comes from relaunching the crashed run's persisted script). log()/doc note only | Doc note in c3-229 + CLAUDE.md; no false claim of full coverage |
+| Per-run journal read in snapshot adds IO cost | Journal read is gated behind isStaleCrashSidecar(existing) — only the rare crash-sidecar case reads it; the common no-sidecar synthetic path stays journal-free | Code review of snapshot() branch ordering |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun test src/server/workflow-registry.test.ts | pass (new crash-override + non-regression cases green) |
+| bun test src/client/components/messages/ToolCallMessage.test.tsx | pass (taskId-exact join locked) |
+| bunx tsc --noEmit | pass (no type errors) |
+| bun run lint | pass (no side-effect seal breach, 0 warnings) |
+| c3x check | pass (c3-229 Contract matches code) |
diff --git a/.c3/adr/adr-20260604-workflow-running-realtime-detail.md b/.c3/adr/adr-20260604-workflow-running-realtime-detail.md
new file mode 100644
index 000000000..39a02e508
--- /dev/null
+++ b/.c3/adr/adr-20260604-workflow-running-realtime-detail.md
@@ -0,0 +1,104 @@
+---
+id: adr-20260604-workflow-running-realtime-detail
+c3-seal: 056a6033604d64c095593addbdfefd45f3dea6c9b36eabc390ce3cd6b1c0cda8
+title: workflow-running-realtime-detail
+type: adr
+goal: Make the workflow drill-in dialog show live per-agent state for a still-running workflow by parsing the small `subagents/workflows//journal.jsonl` server-side in `WorkflowRegistry.getRun`, and have the client re-fetch on each `workflows` snapshot push without a loading flash.
+status: implemented
+date: "2026-06-04"
+---
+
+## Goal
+
+Make the workflow drill-in dialog show live per-agent state for a still-running workflow by parsing the small `subagents/workflows//journal.jsonl` server-side in `WorkflowRegistry.getRun`, and have the client re-fetch on each `workflows` snapshot push without a loading flash.
+
+## Context
+
+`getRun` already synthesizes a running `WorkflowRun` when no sidecar exists (PR #365) but with `agents:[]` and `agentCount` undefined, so the dialog body is blank. Claude writes per-agent events live to `journal.jsonl` (started + result lines, ~2KB at 10–20 agents); the heavy `agent-*.jsonl` files and the terminal sidecar carry token/toolcall counts and arrive only at termination. The existing `watchRunDirs` from PR #363 already pushes a `workflows` snapshot on each journal/agent write (debounced 250 ms), so a client effect is enough to keep the dialog live.
+
+## Decision
+
+Server: a new adapter `readWorkflowRunJournal(workflowsDir, runId)` returns parsed `WorkflowJournalEntry[]` (defensive: skips blank/unparseable lines, returns `[]` for missing file). `WorkflowRegistry` gains an optional `readRunJournal?` dep; when `getRun` falls into the synthetic-running path it uses the journal to derive `agents` + `agentCount`. Sidecar runs pass through unchanged.
+
+Client: `WorkflowsSectionWithDetail` adds a `useEffect` keyed on the selected `runId` + `runs` prop. When the dialog is open and the matching run in `runs` is `status:"running"`, it calls `getRunDetail` and swaps the result into `selectedRun` WITHOUT setting `"loading"` first. Stop condition is implicit: when the sidecar lands the run flips to a terminal status and the predicate is false.
+
+No new WS topic, no new store. Reuses the existing snapshot push and `workflows.getRun` command.
+
+## Affected Topology
+
+| Entity | Type | Why affected | Governance review |
+| --- | --- | --- | --- |
+| c3-229 | component | New adapter export + getRun running enrich + Contract rows | Comply with side-effect-adapter, strong-typing, ws-subscription, colocated-bun-test |
+
+## Compliance Refs
+
+| Ref | Why required | Action |
+| --- | --- | --- |
+| ref-side-effect-adapter | new node:fs read lives in workflow-watch-io.adapter.ts, the exempt leaf | comply |
+| ref-strong-typing | WorkflowJournalEntry is a named type at the adapter↔registry boundary | comply |
+| ref-cqrs-read-models | getRun enrich stays on the read path; no event emitted | comply |
+| ref-ws-subscription | reuses existing workflows topic push, no new envelope | comply |
+| ref-colocated-bun-test | adapter + registry + client tests colocated next to the file under test | comply |
+| ref-provider-adapter | no provider transcript change | N.A - not touched |
+| ref-tool-hydration | no tool_use hydration change | N.A - not touched |
+| ref-event-sourcing | read-model only, no event path | N.A - read-model |
+| ref-zustand-store | no client store change (effect is local to the component) | N.A - no store |
+
+## Compliance Rules
+
+| Rule | Why required | Action |
+| --- | --- | --- |
+| rule-colocated-bun-test | new behavior in c3-229 gets colocated tests next to each file under test | comply |
+| rule-strong-typing | typed adapter signature + journal entry shape | comply |
+| rule-zustand-store | no client Zustand store touched | N.A - server-only data + local component effect |
+
+## Work Breakdown
+
+| Area | Detail | Evidence |
+| --- | --- | --- |
+| Adapter | WorkflowJournalEntry type + readWorkflowRunJournal(workflowsDir, runId) | src/server/workflow-watch-io.adapter.ts |
+| Registry | optional readRunJournal? dep; getRun enriches the synthetic running run with agents[] + agentCount derived from the journal | src/server/workflow-registry.ts |
+| Wiring | createWorkflowRegistry({ readRunJournal: readWorkflowRunJournal, ... }) | src/server/server.ts |
+| Client | WorkflowsSectionWithDetail useEffect re-fetches getRunDetail on runs change while selected run is running; no "loading" swap | src/client/app/WorkflowsSection.tsx |
+| Tests | adapter parse/skip/empty; registry getRun running-enrich + sidecar-wins + legacy fallback; client re-fetch + no-flash + stop-at-terminal + render-loop check | adapter.test, registry.test, WorkflowsSection.test |
+
+## Underlay C3 Changes
+
+| Underlay area | Exact C3 change | Verification evidence |
+| --- | --- | --- |
+| N.A - no C3 CLI underlay touched | runtime + read-model + client effect only | c3x check passes |
+
+## Enforcement Surfaces
+
+| Surface | Behavior | Evidence |
+| --- | --- | --- |
+| workflow-watch-io.adapter.test.ts | Fails if journal parse mishandles started/result/blank/unparseable lines | bun test |
+| workflow-registry.test.ts | Fails if getRun does not enrich running, or sidecar does not win, or dep absent regresses | bun test |
+| WorkflowsSection.test.tsx | Fails if re-fetch does not fire on snapshot push, or sets "loading" mid-run, or keeps fetching past terminal | bun test |
+| bun run lint | Fails on side-effect-seal or any-type violations | CI |
+
+## Alternatives Considered
+
+| Alternative | Rejected because |
+| --- | --- |
+| New WS sub-topic pushing only the selected run's detail | More moving parts (envelope, store, subscription lifecycle) for the same effect the existing workflows push already triggers. |
+| Parse agent-*.jsonl for live token/toolcall counts | Heavy (MB per agent, 10–40 agents per run); UI guards != null and the sidecar fills these at termination — out of scope here. |
+| Server-side stream of journal events | Couples the read-model to a write-path stream; the watchRunDirs push + lazy parse on getRun is enough. |
+| Client polling on a timer | Burns bandwidth and lags vs the existing 250 ms debounced push. |
+
+## Risks
+
+| Risk | Mitigation | Verification |
+| --- | --- | --- |
+| Re-fetch loop (snapshot push triggers re-fetch triggers …) | getRun is a read command and does not fire the watcher; pushes are bounded by Claude's per-agent file-write cadence | client test asserts bounded fetch count per push |
+| Out-of-order re-fetch responses | useEffect cleanup discards stale promise resolutions | client test races two responses |
+| Partial-write tail in journal.jsonl | Adapter skips unparseable lines; next write re-fires the watch | adapter test covers blank/unparseable rows |
+| Token/toolcall still missing live | Out of scope; UI already guards missing fields, sidecar fills them on terminate | n/a |
+
+## Verification
+
+| Check | Result |
+| --- | --- |
+| bun test src/server/workflow-watch-io.adapter.test.ts src/server/workflow-registry.test.ts src/client/app/WorkflowsSection.test.tsx | all pass |
+| bun run lint | 0 errors |
+| c3x check | structural PASS |
diff --git a/.c3/c3-1-client/README.md b/.c3/c3-1-client/README.md
new file mode 100644
index 000000000..471cb620c
--- /dev/null
+++ b/.c3/c3-1-client/README.md
@@ -0,0 +1,41 @@
+---
+id: c3-1
+c3-version: 4
+c3-seal: 7bd272039f9d8c8d53f6e80a9466c5ffdbd6823217219f3ad39335cc0eaa8a10
+title: Client
+type: container
+boundary: app
+parent: c3-0
+goal: 'Render the chat experience: hydrate transcripts, accept input, drive sidebar/settings, and stay synchronized with server state via WebSocket subscriptions.'
+---
+
+# client
+
+## Goal
+
+Render the chat experience: hydrate transcripts, accept input, drive sidebar/settings, and stay synchronized with server state via WebSocket subscriptions.
+
+## Responsibilities
+
+- Own the browser-side state surface (Zustand stores, React context, URL routing).
+- Subscribe to server snapshots over WebSocket and diff them into the local view model.
+- Render hydrated transcripts including provider-agnostic tool calls, plan-mode prompts, and diffs.
+- Accept user input: chat composer, provider/model switches, settings, drag-to-reorder projects, terminal keystrokes.
+- Degrade gracefully when the socket drops or auth is required.
+
+## Components
+
+| ID | Name | Category | Status | Goal Contribution |
+| --- | --- | --- | --- | --- |
+| c3-101 | socket-client | foundation | implemented | Single WS transport + typed envelope dispatch |
+| c3-102 | state-stores | foundation | implemented | UI-local state via per-concern Zustand stores |
+| c3-103 | ui-primitives | foundation | implemented | Radix + shadcn primitives used by every feature |
+| c3-110 | app-shell | feature | implemented | Router, central state hook, socket wiring |
+| c3-111 | sidebar | feature | implemented | Project-first nav with drag-order + status dots |
+| c3-112 | chat-page | feature | implemented | Chat route shell composing transcript + input + terminal |
+| c3-113 | transcript | feature | implemented | Virtualized hydrated transcript list |
+| c3-114 | messages-renderer | feature | implemented | Per-kind renderers for transcript entries |
+| c3-115 | chat-ui-chrome | feature | implemented | Composer + provider/model/effort pickers |
+| c3-116 | settings-page | feature | implemented | Preferences, keybindings, data location |
+| c3-117 | local-projects-page | feature | implemented | List + open locally discovered projects |
+| c3-118 | terminal-workspace | feature | implemented | Embedded xterm panel with layout persistence |
diff --git a/.c3/c3-1-client/c3-101-socket-client.md b/.c3/c3-1-client/c3-101-socket-client.md
new file mode 100644
index 000000000..e310df95e
--- /dev/null
+++ b/.c3/c3-1-client/c3-101-socket-client.md
@@ -0,0 +1,86 @@
+---
+id: c3-101
+c3-version: 4
+c3-seal: d77222bf967fcaef5c9e8e6b38f9ca652dfd94faf9e6f9ee5c2b3d8531c12854
+title: socket-client
+type: component
+category: foundation
+parent: c3-1
+goal: Maintain the single WebSocket to the backend, decode typed envelopes, and dispatch commands + subscription push messages.
+uses:
+ - ref-strong-typing
+ - ref-ws-subscription
+ - rule-strong-typing
+---
+
+# socket-client
+
+## Goal
+
+Maintain the single WebSocket to the backend, decode typed envelopes, and dispatch commands + subscription push messages.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-1 (client) |
+| Parent Goal Slice | "stay synchronized with server state via WebSocket subscriptions" |
+| Category | foundation |
+| Lifecycle | Singleton — one socket lives for the lifetime of the page session |
+| Replaceability | Replaceable provided new transport satisfies Contract; consumers depend only on the typed envelope shape |
+
+## Purpose
+
+Owns the browser-side WebSocket: opens it, reconnects with backoff, decodes inbound `ServerEnvelope` payloads, and exposes a typed dispatch surface to the rest of the client. Non-goals: rendering, persistence, cross-tab coordination, or business decisions about when to re-subscribe.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Tab loaded with auth cookie present (or socket open will be rejected) | c3-203 |
+| Input — protocol envelopes | Typed ClientEnvelope / ServerEnvelope discriminated unions | c3-302 |
+| Input — port + dev-port helpers | Resolve target ws:// URL during dev/prod | c3-304 |
+| Internal state | Pending subscription map, command id sequence, reconnect timer | c3-101 |
+| Initialization | Called once from app-shell during mount | c3-110 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Client always sees fresh server snapshots and can issue commands without per-feature transport code | c3-110 |
+| Primary path | Open WS → send subscribe → receive snapshot push → forward to listener | ref-ws-subscription |
+| Alternate — command | command envelopes round-trip with correlation id; result pushed as commandResult | c3-302 |
+| Failure — drop | Reconnect with exponential backoff; pending commands rejected with transport.disconnected | c3-101 |
+| Failure — auth | 401 close → emit auth.required event so app-shell can show login | c3-203 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-ws-subscription | ref | Single-WS, typed envelope, snapshot-push pattern | must follow | Pattern is the contract for this component |
+| ref-strong-typing | ref | No any on decoded envelopes | must follow | Decode through typed parser, not JSON.parse as any |
+| rule-strong-typing | rule | Compliance target added by c3x wire; refine what must be reviewed or complied with before handoff. | wired compliance target beats uncited local prose | Added by c3x wire for explicit compliance review. |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| connect(url) | IN | Caller provides WS URL; transport opens and retries | c3-110 | src/client/app/socket.ts |
+| subscribe(topic, listener) | OUT | Listener receives typed snapshot pushes until unsubscribed | c3-110 | src/client/app/socket.ts |
+| command(envelope) | OUT | Returns Promise keyed by correlation id | c3-110 | src/client/app/socket.ts |
+| auth.required event | OUT | Fires when server rejects with 401 | c3-110 | src/client/app/socket.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Snapshot loss on reconnect | Backoff logic regression | Stale UI after intermittent disconnect | Manual reconnect test + bun run test src/client/app/socket.test.ts |
+| Type drift between envelope and server | c3-302 protocol bump without client update | tsc fails or runtime decode error | bun run check and replay socket.test.ts fixtures |
+| Memory leak from listeners | Subscription map not pruned | Heap snapshot growth over session | Long-session smoke + listener-count assertion in socket.test.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/client/app/socket.ts | c3-101 Contract | Implementation detail (timer values, decode helpers) | src/client/app/socket.ts |
+| src/client/app/socket.test.ts | c3-101 Contract | Test cases per Contract surface | src/client/app/socket.test.ts |
diff --git a/.c3/c3-1-client/c3-102-state-stores.md b/.c3/c3-1-client/c3-102-state-stores.md
new file mode 100644
index 000000000..26413bfb0
--- /dev/null
+++ b/.c3/c3-1-client/c3-102-state-stores.md
@@ -0,0 +1,88 @@
+---
+id: c3-102
+c3-version: 4
+c3-seal: 5e661d21719b3fb4d50032fa771998f3882bade8f6849b8a034d616c25f91dc8
+title: state-stores
+type: component
+category: foundation
+parent: c3-1
+goal: Hold UI-local state (chat input, terminal layout, sidebar, preferences) in small Zustand stores, persisting only what must survive reload.
+uses:
+ - ref-colocated-bun-test
+ - ref-strong-typing
+ - ref-zustand-store
+ - rule-colocated-bun-test
+ - rule-strong-typing
+ - rule-zustand-store
+---
+
+# state-stores
+
+## Goal
+
+Hold UI-local state (chat input, terminal layout, sidebar, preferences) in small Zustand stores, persisting only what must survive reload.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-1 (client) |
+| Parent Goal Slice | "Own the browser-side state surface (Zustand stores, React context, URL routing)" |
+| Category | foundation |
+| Lifecycle | Module-singleton stores instantiated at app boot |
+| Replaceability | Stores can be swapped per-concern as long as typed selector contract holds |
+
+## Purpose
+
+Owns the browser-side ephemeral state split into per-concern Zustand stores (chat input, sidebar order, terminal layout, preferences) with selective `persist` middleware so reloads only restore what users expect. Non-goals: server state, transcript content, route state — those live elsewhere.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Browser has localStorage available for persisted slices | c3-102 |
+| Input — types | Domain types and ports for selector typing | c3-301 |
+| Internal state | Per-store slices kept in memory; subset persisted via zustand persist | c3-102 |
+| Initialization | Store factories invoked on first hook call | c3-110 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Features read/write UI state without prop-drilling or context refactors | c3-110 |
+| Primary path | Component calls hook → selector returns slice → setter mutates store | ref-zustand-store |
+| Alternate — persistence | Persisted slices rehydrate on next load via zustand persist | ref-zustand-store |
+| Failure — corruption | Persisted JSON parse failure resets to initial state | c3-102 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-zustand-store | ref | Per-concern store pattern, persist usage | must follow | Each store is one concern |
+| ref-strong-typing | ref | Typed selectors and setters | must follow | No any in slice types |
+| ref-colocated-bun-test | ref | *.test.ts next to source | must follow | Store tests live alongside |
+| rule-strong-typing | rule | All boundary state must be named-type, never any | rule wins on conflict | Enforces ref-strong-typing for store slices |
+| rule-colocated-bun-test | rule | Each store file must have a colocated .test.ts | rule wins on conflict | Enforces ref-colocated-bun-test for store tests |
+| rule-zustand-store | rule | All stores must use create() + zustand/middleware persist, never custom localStorage | rule wins on conflict | Enforces ref-zustand-store at store-file shape |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| useChatInputStore | OUT | Pending text + send actions | c3-115 | src/client/stores |
+| useSidebarStore | OUT | Project order, drag state, persistence | c3-111 | src/client/stores |
+| useTerminalStore | OUT | Layout sizes, visibility, persistence | c3-118 | src/client/stores |
+| usePreferencesStore | OUT | Theme, notifications, provider keys | c3-116 | src/client/stores |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Persisted-shape break on schema change | Slice field renamed without migration | Users see reset to defaults after upgrade | Add version/migrate to persist in src/client/stores/; bun run check |
+| Store coupling drift | Component imports from another store directly | grep cross-imports | bun run check + audit src/client/stores/ |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/client/stores/**/*.ts | c3-102 Contract | One store per concern; setters/selectors typed | src/client/stores |
diff --git a/.c3/c3-1-client/c3-103-ui-primitives.md b/.c3/c3-1-client/c3-103-ui-primitives.md
new file mode 100644
index 000000000..18dfb03e5
--- /dev/null
+++ b/.c3/c3-1-client/c3-103-ui-primitives.md
@@ -0,0 +1,79 @@
+---
+id: c3-103
+c3-version: 4
+c3-seal: 39d6a085b03d37a2f90e672090880b6a0102462b59d075ca23fb69d79f822553
+title: ui-primitives
+type: component
+category: foundation
+parent: c3-1
+goal: 'Ship the low-level, brand-aligned UI primitives (Radix + shadcn derivatives: button, dialog, popover, scroll-area, tooltip, select, kbd, ...).'
+uses:
+ - ref-strong-typing
+ - rule-strong-typing
+---
+
+# ui-primitives
+
+## Goal
+
+Ship the low-level, brand-aligned UI primitives (Radix + shadcn derivatives: button, dialog, popover, scroll-area, tooltip, select, kbd, ...).
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-1 (client) |
+| Parent Goal Slice | "Render the chat experience" — primitives keep interaction quality consistent across surfaces |
+| Category | foundation |
+| Lifecycle | Stateless React components, instantiated by features as needed |
+| Replaceability | Replaceable per-primitive provided shadcn/Radix prop contract is preserved |
+
+## Purpose
+
+Hosts every shared UI primitive consumed by feature components: buttons, dialogs, popovers, tooltips, selects, scroll areas, kbd. Pure presentational components forwarding typed props to Radix. Non-goals: feature logic, data fetching, app-level state.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Tailwind theme + shadcn tokens loaded | c3-103 |
+| Input — Radix slot APIs | Underlying behavior comes from Radix UI | c3-103 |
+| Internal state | Stateless; controlled or uncontrolled per Radix conventions | c3-103 |
+| Initialization | Tree-shaken; imports happen lazily per consumer | c3-103 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Visual + interaction consistency across chat, sidebar, settings, terminal | c3-1 |
+| Primary path | Feature imports primitive → composes with feature-specific markup | c3-103 |
+| Alternate — class merge | cn() helper merges Tailwind classes deterministically | c3-103 |
+| Failure — accessibility regression | aria-* attributes lost during refactor | c3-103 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-strong-typing | ref | Typed forwardRef + Props discriminated unions | must follow | No any for HTML attribute spreading |
+| rule-strong-typing | rule | Compliance target added by c3x wire; refine what must be reviewed or complied with before handoff. | wired compliance target beats uncited local prose | Added by c3x wire for explicit compliance review. |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| and friends | OUT | Typed forwardRef components with shadcn variants | c3-110 | src/client/components/ui |
+| cn(...classes) helper | OUT | Tailwind class merger | c3-110 | src/client/components/ui |
+| Variant props | OUT | Discriminated variant + size unions | c3-115 | src/client/components/ui |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Accessibility regression | Slot/asChild rewiring | aXe audit fails or focus traps break | Keyboard nav smoke + bun run check on src/client/components/ui/ |
+| Theme drift | Tailwind token rename without sweep | Visual diff in Storybook-style smoke | bun run check + screenshot diff against src/client/components/ui/ |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/client/components/ui/**/*.tsx | c3-103 Contract | New primitives may be added; existing surface stable | src/client/components/ui |
diff --git a/.c3/c3-1-client/c3-110-app-shell.md b/.c3/c3-1-client/c3-110-app-shell.md
new file mode 100644
index 000000000..0dcc45374
--- /dev/null
+++ b/.c3/c3-1-client/c3-110-app-shell.md
@@ -0,0 +1,84 @@
+---
+id: c3-110
+c3-version: 4
+c3-seal: 94e1e94d1fec5ece2405ba51a402e28297fd2598709f26ca3a44f7a905d36c86
+title: app-shell
+type: component
+category: feature
+parent: c3-1
+goal: 'Own the top-level React shell: routing, Kanna state hook (useKannaState), socket wiring, global keybindings, and layout chrome.'
+uses:
+ - ref-cqrs-read-models
+ - ref-ws-subscription
+---
+
+# app-shell
+
+## Goal
+
+Own the top-level React shell: routing, Kanna state hook (useKannaState), socket wiring, global keybindings, and layout chrome.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-1 (client) |
+| Parent Goal Slice | "Subscribe to server snapshots… and stay synchronized with server state via WebSocket subscriptions" |
+| Category | feature |
+| Lifecycle | Mounts once at boot; unmounts only on full reload |
+| Replaceability | Hard to replace — defines container linkages for every feature page |
+
+## Purpose
+
+Composes the React tree at boot: react-router, the central `useKannaState` hook, socket bring-up, global keybinding listeners, and persistent layout chrome (sidebar + page outlet). Non-goals: feature-specific rendering, transcript composition, or business logic.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Browser DOM ready and auth cookie or anonymous mode resolved | c3-203 |
+| Input — socket | Single socketClient instance opened at mount | c3-101 |
+| Input — stores | Preference + layout stores hydrated from localStorage | c3-102 |
+| Input — primitives | UI primitives composed throughout chrome | c3-103 |
+| Internal state | useKannaState hook holds projections from snapshot pushes | c3-110 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Every feature page sees a hydrated, route-aware app shell | c3-1 |
+| Primary path | Boot → connect socket → subscribe → render | ref-ws-subscription |
+| Alternate — auth required | Render login overlay until cookie present | c3-203 |
+| Alternate — disconnected | Show degraded banner; route still mounts | c3-101 |
+| Failure — snapshot decode error | Log and surface error toast; keep last good state | ref-cqrs-read-models |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-ws-subscription | ref | How shell opens + threads snapshots | must follow | Single socket per session |
+| ref-cqrs-read-models | ref | Consume derived projections, never raw events | must follow | No event-log access on client |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| Mounted | OUT | Provides routes for chat-page, settings, projects | c3-112, c3-116, c3-117 | src/client/app/App.tsx |
+| useKannaState() hook | OUT | Returns snapshot-derived view models | c3-112, c3-115 | src/client/app/useKannaState.ts |
+| Global keybinding handlers | OUT | Dispatches commands like number-jump, toggle terminal | c3-111, c3-118 | src/client/hooks |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Snapshot/projection desync | Hook subscribing to wrong topic | Stale page after server change | bun run test src/client/app/useKannaState.test.ts and manual chat smoke |
+| Route regression | Router config edit drops route | 404 on previously-working URL | Manual nav across /chat, /settings, /projects |
+| Global keybinding leak | Event listener not cleaned up | Listener fires after unmount | Component unmount test in chatFocusPolicy.test.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/client/app/App.tsx | c3-110 Contract | Layout chrome detail | src/client/app/App.tsx |
+| src/client/app/useKannaState.ts | c3-110 Contract | Memoization detail | src/client/app/useKannaState.ts |
+| src/client/hooks/** | c3-110 Contract | Hook composition allowed | src/client/hooks |
diff --git a/.c3/c3-1-client/c3-111-sidebar.md b/.c3/c3-1-client/c3-111-sidebar.md
new file mode 100644
index 000000000..904d1dbae
--- /dev/null
+++ b/.c3/c3-1-client/c3-111-sidebar.md
@@ -0,0 +1,84 @@
+---
+id: c3-111
+c3-version: 4
+c3-seal: 5f07faa569c29efd4f95a08d7866162fffde5f0c611325d5c255d2cbdde1b35d
+title: sidebar
+type: component
+category: feature
+parent: c3-1
+goal: 'Render the project-first sidebar: grouped chats, live status dots, drag-to-reorder project groups, number-key jumps.'
+uses:
+ - ref-cqrs-read-models
+ - ref-zustand-store
+ - rule-zustand-store
+---
+
+# sidebar
+
+## Goal
+
+Render the project-first sidebar: grouped chats, live status dots, drag-to-reorder project groups, number-key jumps.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-1 (client) |
+| Parent Goal Slice | "Render hydrated transcripts… Accept user input… drag-to-reorder projects" |
+| Category | feature |
+| Lifecycle | Mounts inside app-shell; persists across route changes |
+| Replaceability | Replaceable provided sidebar projection contract preserved |
+
+## Purpose
+
+Renders the project-first navigation: project groups with their chats, live agent status dots, drag-to-reorder, number-key shortcuts to jump chats. Non-goals: chat content, project creation flow, agent control.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | App-shell mounted and socket subscribed | c3-110 |
+| Input — sidebar projection | Server sidebarView snapshot streamed over WS | c3-207 |
+| Input — sidebar store | Local order + collapsed-group state | c3-102 |
+| Input — primitives | Buttons, popover, scroll-area | c3-103 |
+| Internal state | Drag in-progress flag, last-jumped index | c3-111 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | User can navigate or reorder projects without leaving the sidebar | c3-1 |
+| Primary path | Render projection → click chat → route push | ref-cqrs-read-models |
+| Alternate — drag reorder | dnd-kit reorder → persist via store → emit project.reorder command | ref-zustand-store |
+| Alternate — number jump | Number key handler maps to nth chat, focuses route | c3-111 |
+| Failure — empty projection | Show "no projects" placeholder; still allow add | c3-117 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-cqrs-read-models | ref | Read sidebarView projection, never raw events | must follow | Server owns derivation |
+| ref-zustand-store | ref | Persist drag-order locally with persist middleware | must follow | One sidebar store |
+| rule-zustand-store | rule | Compliance target added by c3x wire; refine what must be reviewed or complied with before handoff. | wired compliance target beats uncited local prose | Added by c3x wire for explicit compliance review. |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| Mounted sidebar tree | OUT | Renders projects + chats; click navigates | c3-110 | src/client/app/KannaSidebar.tsx |
+| Drag-end command | OUT | project.reorder envelope | c3-208 | src/client/app/sidebarNumberJump.ts |
+| Number-jump callback | IN/OUT | App-shell wires global key listeners | c3-110 | src/client/app/sidebarNumberJump.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Reorder desync | Local persisted order differs from server | Sidebar items appear out of order on cold load | bun run test src/client/app/sidebarNumberJump.test.ts plus manual reorder smoke |
+| Drag breaking accessibility | dnd-kit upgrade | Keyboard reorder fails | bun run check + keyboard nav smoke on src/client/app/sidebarNumberJump.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/client/app/KannaSidebar.tsx | c3-111 Contract | Layout/skin detail | src/client/app/KannaSidebar.tsx |
+| src/client/app/sidebarNumberJump.ts | c3-111 Contract | Key map detail | src/client/app/sidebarNumberJump.ts |
diff --git a/.c3/c3-1-client/c3-112-chat-page.md b/.c3/c3-1-client/c3-112-chat-page.md
new file mode 100644
index 000000000..da613a337
--- /dev/null
+++ b/.c3/c3-1-client/c3-112-chat-page.md
@@ -0,0 +1,82 @@
+---
+id: c3-112
+c3-version: 4
+c3-seal: 6052faabc1d845ad96dc573fedf403c6a4f7f110f3d37fedf70cbaa1ba979207
+title: chat-page
+type: component
+category: feature
+parent: c3-1
+goal: 'Compose the chat route: transcript viewport, input dock, terminal workspace, focus policy, and sidebar actions.'
+uses:
+ - ref-cqrs-read-models
+ - ref-ws-subscription
+---
+
+# chat-page
+
+## Goal
+
+Compose the chat route: transcript viewport, input dock, terminal workspace, focus policy, and sidebar actions.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-1 (client) |
+| Parent Goal Slice | "Render hydrated transcripts… Accept user input: chat composer" |
+| Category | feature |
+| Lifecycle | Mounts per /chat/:sessionId route; remounts on session change |
+| Replaceability | Composition can be reshaped; sub-components remain stable |
+
+## Purpose
+
+Composes the chat route: transcript viewport, input dock, embedded terminal panel, focus/scroll policy, sidebar action wiring. Non-goals: rendering individual entries, owning input state, terminal PTY logic.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | App-shell mounted and useKannaState returns chat snapshot for sessionId | c3-110 |
+| Input — transcript renderer | Receives entries, dispatches per-kind | c3-113 |
+| Input — chat UI chrome | Composer, pickers, attachments | c3-115 |
+| Input — terminal workspace | Embedded PTY panel | c3-118 |
+| Internal state | Focus policy state, panel sizes, scroll anchor | c3-102 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Single workspace where user reads agent output and replies | c3-1 |
+| Primary path | Subscribe chatView → render transcript + composer → send command | ref-ws-subscription |
+| Alternate — terminal toggle | Cmd-key opens terminal panel; layout animates | c3-118 |
+| Alternate — sticky focus | Focus policy keeps last-read entry visible during streaming | c3-112 |
+| Failure — session not found | Display banner; allow back-to-projects | c3-117 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-ws-subscription | ref | Subscribe to per-session chatView | must follow | One subscription per chat-page mount |
+| ref-cqrs-read-models | ref | Render only snapshot projections | must follow | No event-log reads |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| route component | OUT | Mounts at /chat/:sessionId, owns layout | c3-110 | src/client/app/ChatPage |
+| Layout slot order | OUT | Sidebar → transcript → composer → terminal | c3-110 | src/client/app/ChatPage |
+| Focus policy callback | IN | Hooks consumed for sticky scroll | c3-112 | src/client/app/useStickyChatFocus.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Sticky focus regression | Scroll-anchor logic edit | User loses place during streaming | bun run test src/client/app/ChatPage.test.ts + manual streaming smoke |
+| Layout animation jank | Toggle animation timing edit | Visible flash on terminal toggle | bun run test src/client/app/useTerminalToggleAnimation.ts adjacent tests |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/client/app/ChatPage/**/*.tsx | c3-112 Contract | Internal layout shape | src/client/app/ChatPage |
+| src/client/app/useStickyChatFocus.ts | c3-112 Contract | Hook detail | src/client/app/useStickyChatFocus.ts |
diff --git a/.c3/c3-1-client/c3-113-transcript.md b/.c3/c3-1-client/c3-113-transcript.md
new file mode 100644
index 000000000..3c40a65c6
--- /dev/null
+++ b/.c3/c3-1-client/c3-113-transcript.md
@@ -0,0 +1,81 @@
+---
+id: c3-113
+c3-version: 4
+c3-seal: a7295207954690cc4cca0c902e8c387e94e3ca00035d147ef83af0c50f7584b5
+title: transcript
+type: component
+category: feature
+parent: c3-1
+goal: Render a hydrated list of transcript entries (text, tool calls, plan dialogs, diffs) with virtualized scrolling and sticky focus.
+uses:
+ - ref-provider-adapter
+ - ref-tool-hydration
+---
+
+# transcript
+
+## Goal
+
+Render a hydrated list of transcript entries (text, tool calls, plan dialogs, diffs) with virtualized scrolling and sticky focus.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-1 (client) |
+| Parent Goal Slice | "Render hydrated transcripts including provider-agnostic tool calls" |
+| Category | feature |
+| Lifecycle | Mounts inside chat-page; remounts on session swap |
+| Replaceability | Replaceable provided per-kind dispatch contract preserved |
+
+## Purpose
+
+Renders the virtualized list of hydrated transcript entries inside the chat page, dispatching each entry to the correct per-kind renderer and managing scroll position. Non-goals: per-kind rendering, hydration of raw events, server-side projection.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Chat page provides hydrated entries from chatView snapshot | c3-112 |
+| Input — renderer map | Per-kind components from messages-renderer | c3-114 |
+| Input — primitives | Scroll-area, dividers, status indicators | c3-103 |
+| Input — tool normalization | Hydrated tool kinds from shared/tools | c3-303 |
+| Internal state | Virtualization window indices, last-rendered length | c3-113 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Long transcripts stay performant; live updates keep place | c3-1 |
+| Primary path | Subscribe to entries → render visible window → dispatch per-kind | ref-tool-hydration |
+| Alternate — provider-agnostic | Same render path for Claude + Codex entries | ref-provider-adapter |
+| Alternate — autoscroll | Pin to bottom while user is at bottom; release on manual scroll | c3-112 |
+| Failure — unknown kind | Render raw envelope as fallback diagnostic block | c3-114 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-tool-hydration | ref | Dispatch by hydrated kind only | must follow | Never branch on provider |
+| ref-provider-adapter | ref | Provider-agnostic render path | must follow | Hydration normalizes upstream |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| | OUT | Receives entries; renders virtualized list | c3-112 | src/client/app/KannaTranscript.tsx |
+| Renderer dispatch | IN | Pulls per-kind component from c3-114 map | c3-114 | src/client/app/KannaTranscript.tsx |
+| Scroll-anchor callback | OUT | Reports bottom-pin state to parent | c3-112 | src/client/app/KannaTranscript.tsx |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Virtualization breakage | Item-size estimator edit | Items overlap or list jitters | bun run test src/client/app/KannaTranscript.test.tsx + manual streaming smoke |
+| Autoscroll regression | Scroll-anchor heuristic edit | User loses pin to bottom unexpectedly | bun run test src/client/app/KannaTranscript.test.tsx + manual scroll smoke |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/client/app/KannaTranscript.tsx | c3-113 Contract | Virtualization library detail | src/client/app/KannaTranscript.tsx |
diff --git a/.c3/c3-1-client/c3-114-messages-renderer.md b/.c3/c3-1-client/c3-114-messages-renderer.md
new file mode 100644
index 000000000..6ef7b0acb
--- /dev/null
+++ b/.c3/c3-1-client/c3-114-messages-renderer.md
@@ -0,0 +1,83 @@
+---
+id: c3-114
+c3-version: 4
+c3-seal: 73bcbf17ee76135ddef6e7f74e2ac26d9828d367bfbe46c5823ae5c7dfd557a2
+title: messages-renderer
+type: component
+category: feature
+parent: c3-1
+goal: Render each transcript entry kind (text, tool call, write_file, delete_file, plan, diff, ...) consistently, with collapse/expand and status.
+uses:
+ - ref-strong-typing
+ - ref-tool-hydration
+ - rule-strong-typing
+---
+
+# messages-renderer
+
+## Goal
+
+Render each transcript entry kind (text, tool call, write_file, delete_file, plan, diff, ...) consistently, with collapse/expand and status.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-1 (client) |
+| Parent Goal Slice | "Render hydrated transcripts including provider-agnostic tool calls, plan-mode prompts, and diffs" |
+| Category | feature |
+| Lifecycle | Components mounted by transcript per visible item |
+| Replaceability | New kinds added by extending the dispatch map |
+
+## Purpose
+
+Owns the per-kind UI for transcript entries — text, tool_use, tool_result, plan, diff, file ops — with consistent collapse/expand, status badges, and provider-agnostic styling. Non-goals: hydration logic, virtualization, scroll behavior.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Entry already hydrated to a normalized kind | c3-303 |
+| Input — primitives | Buttons, code blocks, dialogs | c3-103 |
+| Input — shared tools | Tool kind definitions and helpers | c3-303 |
+| Internal state | Per-item collapsed flag, copy-to-clipboard state | c3-114 |
+| Initialization | Pulled lazily via dispatch map keyed by kind | c3-113 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Consistent reading experience across all entry kinds | c3-1 |
+| Primary path | Receive entry → switch on kind → render component | ref-tool-hydration |
+| Alternate — collapse | Long blocks collapse by default with "Show more" | c3-114 |
+| Alternate — exhaustive switch | TypeScript exhaustiveness ensures coverage | ref-strong-typing |
+| Failure — unknown kind | Fallback diagnostic renderer with raw payload | c3-114 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-tool-hydration | ref | Branch by kind only, not provider | must follow | Provider-agnostic UI |
+| ref-strong-typing | ref | Exhaustive switch on entry union | must follow | Compile-time coverage |
+| rule-strong-typing | rule | Compliance target added by c3x wire; refine what must be reviewed or complied with before handoff. | wired compliance target beats uncited local prose | Added by c3x wire for explicit compliance review. |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| Renderer map | OUT | Record> | c3-113 | src/client/components/messages |
+| Per-kind component | OUT | Pure component receiving typed entry | c3-113 | src/client/components/messages |
+| Collapse callback | IN/OUT | Caller may control expanded state | c3-113 | src/client/components/messages |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Missing kind handler | New entry kind added without renderer | Fallback shows in UI | bun run check against src/client/components/messages/ |
+| Collapsed default regression | Default expand-state change | User overwhelmed by long blocks | bun run check + manual review of src/client/components/messages/ |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/client/components/messages/**/*.tsx | c3-114 Contract | Per-kind layout detail | src/client/components/messages |
diff --git a/.c3/c3-1-client/c3-115-chat-ui-chrome.md b/.c3/c3-1-client/c3-115-chat-ui-chrome.md
new file mode 100644
index 000000000..e88f8eae7
--- /dev/null
+++ b/.c3/c3-1-client/c3-115-chat-ui-chrome.md
@@ -0,0 +1,84 @@
+---
+id: c3-115
+c3-version: 4
+c3-seal: d7e82da963e2a764aebfb3ff17eb76c2110cc197b1a5b8eb628c1e6c01de8291
+title: chat-ui-chrome
+type: component
+category: feature
+parent: c3-1
+goal: 'Provide the composer and chat chrome: input dock, provider/model/effort pickers, attachment controls, queued message alignment.'
+uses:
+ - ref-provider-adapter
+ - ref-zustand-store
+ - rule-zustand-store
+---
+
+# chat-ui-chrome
+
+## Goal
+
+Provide the composer and chat chrome: input dock, provider/model/effort pickers, attachment controls, queued message alignment.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-1 (client) |
+| Parent Goal Slice | "Accept user input: chat composer, provider/model switches" |
+| Category | feature |
+| Lifecycle | Mounts inside chat-page for active session |
+| Replaceability | Replaceable provided composer command contract preserved |
+
+## Purpose
+
+Owns the composer and surrounding chrome: textarea input, provider/model/effort pickers, attachment controls, queued message indicator, send action. Non-goals: transcript rendering, server command execution, chat history.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Chat-page provides session context | c3-112 |
+| Input — chat input store | Pending text, attachments | c3-102 |
+| Input — preferences | Theme, provider/model defaults | c3-102 |
+| Input — primitives | Textarea, popover, select, tooltip | c3-103 |
+| Input — provider catalog types | Provider/model/effort options | c3-301 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | User sends a message to the agent with chosen provider/model | c3-1 |
+| Primary path | Type → choose model → click Send → emit chat.send command | c3-208 |
+| Alternate — drag-attach | Drop file → upload → reference inserted in payload | c3-217 |
+| Alternate — provider switch | Picker writes to preferences store; persists across sessions | ref-zustand-store |
+| Failure — send rejected | Show inline banner; retain text in store | c3-115 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-provider-adapter | ref | Use normalized catalog, not per-provider forms | must follow | One UI for all providers |
+| ref-zustand-store | ref | Persist pending input + preferences | must follow | Survives reload |
+| rule-zustand-store | rule | Compliance target added by c3x wire; refine what must be reviewed or complied with before handoff. | wired compliance target beats uncited local prose | Added by c3x wire for explicit compliance review. |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| Composer component | OUT | Renders input + pickers; emits send | c3-112 | src/client/components/chat-ui |
+| Send callback | OUT | Calls socket command chat.send with provider/model | c3-101 | src/client/components/chat-ui |
+| Attachment controls | OUT | Opens file picker; pushes to upload pipeline | c3-217 | src/client/components/chat-ui |
+| Public link button | OUT | Renders "Public link" button in chat header toolbar, next to existing Share button; triggers share.mint command | c3-228 | src/client/components/chat-ui |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Provider/model mismatch | Catalog type change | Picker shows wrong options | bun run check against src/client/components/chat-ui |
+| Lost draft on reload | Persistence regression | Pending text disappears | bun run check + manual reload smoke against src/client/stores/ |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/client/components/chat-ui/**/*.tsx | c3-115 Contract | Layout/skin detail | src/client/components/chat-ui |
diff --git a/.c3/c3-1-client/c3-116-settings-page.md b/.c3/c3-1-client/c3-116-settings-page.md
new file mode 100644
index 000000000..d7de07f31
--- /dev/null
+++ b/.c3/c3-1-client/c3-116-settings-page.md
@@ -0,0 +1,84 @@
+---
+id: c3-116
+c3-version: 4
+c3-seal: 72ccfdb8495323653e32e38da789836066c397a86c1bf67cfe22bae1a790b6a7
+title: settings-page
+type: component
+category: feature
+parent: c3-1
+goal: 'Expose user settings: provider keys, theme, keybindings, chat preferences, notifications, data location.'
+uses:
+ - ref-local-first-data
+ - ref-zustand-store
+ - rule-zustand-store
+---
+
+# settings-page
+
+## Goal
+
+Expose user settings: provider keys, theme, keybindings, chat preferences, notifications, data location.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-1 (client) |
+| Parent Goal Slice | "Accept user input: … settings" |
+| Category | feature |
+| Lifecycle | Mounts on /settings route |
+| Replaceability | Section composition replaceable; settings keys remain stable |
+
+## Purpose
+
+Surfaces user-facing configuration: provider API keys, theme, custom keybindings, chat preferences, notification toggles, data directory, cloudflare tunnel toggles. Non-goals: server-side preference enforcement, secret storage policy, multi-user identity.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | App-shell mounted; preferences hydrated | c3-110 |
+| Input — preferences store | Theme, notifications, model defaults | c3-102 |
+| Input — primitives | Switches, dialogs, sliders | c3-103 |
+| Input — server keybinding projection | Persisted bindings from server | c3-222 |
+| Input — cloudflare tunnel settings | Toggles + setter | c3-223 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | User configures Kanna without leaving the app | c3-1 |
+| Primary path | Edit field → store/setter → optimistic update + server command | c3-208 |
+| Alternate — provider keys | Saved to local config (via server) only | ref-local-first-data |
+| Alternate — keybinding edit | Capture new chord → emit keybindings.set | c3-222 |
+| Failure — save reject | Revert optimistic change; show banner | c3-116 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-zustand-store | ref | Store-backed preferences with persist | must follow | One preferences store |
+| ref-local-first-data | ref | Local-only paths and keys | must follow | No cloud sync |
+| rule-zustand-store | rule | Compliance target added by c3x wire; refine what must be reviewed or complied with before handoff. | wired compliance target beats uncited local prose | Added by c3x wire for explicit compliance review. |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| route | OUT | Mounts at /settings; sections per concern | c3-110 | src/client/app/SettingsPage.tsx |
+| Setting setters | OUT | Emit typed commands (keybindings.set, tunnel.set, ...) | c3-208 | src/client/app/SettingsPage.tsx |
+| Provider key form | IN/OUT | Reads/writes provider config via server | c3-203 | src/client/app/SettingsPage.tsx |
+| Share expiry row | IN/OUT | "Default share link expiry (hours)" input wired through settings.writeAppSettingsPatch | c3-228 | src/client/app/SettingsPage.tsx |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Lost preferences on schema bump | Persist field rename | Settings reset after upgrade | Add migrate in src/client/stores/ + bun run check |
+| Secret leakage | Provider key shown in DOM | Manual inspect of input element | bun run check + grep src/client/app/SettingsPage.tsx for plain logs |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/client/app/SettingsPage.tsx | c3-116 Contract | Section ordering | src/client/app/SettingsPage.tsx |
diff --git a/.c3/c3-1-client/c3-117-local-projects-page.md b/.c3/c3-1-client/c3-117-local-projects-page.md
new file mode 100644
index 000000000..0cddf0476
--- /dev/null
+++ b/.c3/c3-1-client/c3-117-local-projects-page.md
@@ -0,0 +1,82 @@
+---
+id: c3-117
+c3-version: 4
+c3-seal: 84cd4964e2bb596beefd5612d2473cfdbd4bffdecb8b0f84f7deffd0ea99dc6c
+title: local-projects-page
+type: component
+category: feature
+parent: c3-1
+goal: List projects auto-discovered from local Claude and Codex history so users can open them into Kanna.
+uses:
+ - ref-local-first-data
+ - ref-ws-subscription
+---
+
+# local-projects-page
+
+## Goal
+
+List projects auto-discovered from local Claude and Codex history so users can open them into Kanna.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-1 (client) |
+| Parent Goal Slice | "drag-to-reorder projects" — onboarding surface that bootstraps the project list |
+| Category | feature |
+| Lifecycle | Mounts on /projects route |
+| Replaceability | Layout replaceable; discovery feed contract preserved |
+
+## Purpose
+
+Lists projects auto-discovered from local Claude and Codex history; lets the user open a project into Kanna or create a new one. Non-goals: discovery itself (server), project removal, agent history mutation.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | App-shell mounted; socket subscribed | c3-110 |
+| Input — discovery feed | discoveryView snapshot from server | c3-214 |
+| Input — primitives | Cards, dialogs, buttons | c3-103 |
+| Internal state | Filter text, last-open list | c3-117 |
+| Initialization | Subscribe on mount; unsubscribe on unmount | ref-ws-subscription |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | User opens an existing project as a Kanna chat with one click | c3-1 |
+| Primary path | Render discovered list → click → emit project.open | c3-208 |
+| Alternate — create project | Modal collects path → project.create command | c3-117 |
+| Alternate — empty state | "No projects discovered" with onboarding tip | c3-117 |
+| Failure — open reject | Show banner; retain list state | c3-117 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-ws-subscription | ref | Subscribe to discovery projection | must follow | One subscription per mount |
+| ref-local-first-data | ref | Read only local discovery data | must follow | No cloud lookup |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| route | OUT | Mounts at /projects | c3-110 | src/client/app/LocalProjectsPage.tsx |
+| project.open command | OUT | Emits via socket with project path | c3-208 | src/client/app/LocalProjectsPage.tsx |
+| | OUT | Drives project.create command | c3-208 | src/client/components/NewProjectModal.tsx |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Stale list after rescan | Subscription drop unhandled | List doesn't refresh after directory change | bun run check + smoke src/client/app/LocalProjectsPage.tsx |
+| Create regression | Modal validation regression | User cannot create new project | bun run check + smoke src/client/components/NewProjectModal.tsx |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/client/app/LocalProjectsPage.tsx | c3-117 Contract | Layout detail | src/client/app/LocalProjectsPage.tsx |
+| src/client/components/NewProjectModal.tsx | c3-117 Contract | Form layout detail | src/client/components/NewProjectModal.tsx |
diff --git a/.c3/c3-1-client/c3-118-terminal-workspace.md b/.c3/c3-1-client/c3-118-terminal-workspace.md
new file mode 100644
index 000000000..a15203bd9
--- /dev/null
+++ b/.c3/c3-1-client/c3-118-terminal-workspace.md
@@ -0,0 +1,85 @@
+---
+id: c3-118
+c3-version: 4
+c3-seal: ae80c9943ef2f3236c67075f626fe562f51944e9360398c8378cd2bbced01400
+title: terminal-workspace
+type: component
+category: feature
+parent: c3-1
+goal: Host the embedded xterm terminal panel with layout animation + resize + preference persistence.
+uses:
+ - ref-ws-subscription
+ - ref-zustand-store
+ - rule-zustand-store
+---
+
+# terminal-workspace
+
+## Goal
+
+Host the embedded xterm terminal panel with layout animation + resize + preference persistence.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-1 (client) |
+| Parent Goal Slice | "Accept user input: … terminal keystrokes" |
+| Category | feature |
+| Lifecycle | Mounts inside chat-page when terminal panel is enabled |
+| Replaceability | Replaceable provided PTY stream contract preserved |
+
+## Purpose
+
+Hosts the embedded xterm.js panel inside chat-page: bidirectional PTY streaming, layout animation, resizable splitter, preference persistence. Non-goals: server-side PTY allocation, agent integration, scrollback persistence.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Chat-page mounted; user toggles terminal on | c3-112 |
+| Input — terminal layout store | Sizes, last-open state | c3-102 |
+| Input — primitives | Splitter, kbd | c3-103 |
+| Input — server terminal manager | PTY stream over WS | c3-216 |
+| Internal state | xterm instance, resize observer, animation state | c3-118 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | User runs a shell next to the agent without leaving the chat page | c3-1 |
+| Primary path | Toggle on → request PTY → stream stdin/stdout via WS | ref-ws-subscription |
+| Alternate — resize | User drags splitter → resize PTY rows/cols | c3-216 |
+| Alternate — persist layout | Layout sizes persisted via store | ref-zustand-store |
+| Failure — PTY drop | Show "terminal disconnected"; offer retry | c3-216 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-ws-subscription | ref | Stream PTY over single WS | must follow | No separate connection |
+| ref-zustand-store | ref | Persist layout via store | must follow | One terminal store |
+| rule-zustand-store | rule | Compliance target added by c3x wire; refine what must be reviewed or complied with before handoff. | wired compliance target beats uncited local prose | Added by c3x wire for explicit compliance review. |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| | OUT | Renders xterm + splitter | c3-112 | src/client/app/ChatPage/TerminalWorkspaceShell.tsx |
+| Resize callback | OUT | Reports rows/cols to server | c3-216 | src/client/app/terminalLayoutResize.ts |
+| Toggle animation | IN/OUT | Driven by chat-page hook | c3-112 | src/client/app/terminalToggleAnimation.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Resize drift | Resize observer + xterm fit mismatch | Wrapping or ghost cursor | bun run test src/client/app/terminalLayoutResize.test.ts |
+| Animation jank on toggle | Timing edit | Visible flash | bun run test src/client/app/terminalToggleAnimation.test.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/client/app/ChatPage/TerminalWorkspaceShell.tsx | c3-118 Contract | Layout detail | src/client/app/ChatPage/TerminalWorkspaceShell.tsx |
+| src/client/app/terminalLayoutResize.ts | c3-118 Contract | Resize math detail | src/client/app/terminalLayoutResize.ts |
+| src/client/app/terminalToggleAnimation.ts | c3-118 Contract | Animation timing detail | src/client/app/terminalToggleAnimation.ts |
diff --git a/.c3/c3-2-server/README.md b/.c3/c3-2-server/README.md
new file mode 100644
index 000000000..ffb674d20
--- /dev/null
+++ b/.c3/c3-2-server/README.md
@@ -0,0 +1,60 @@
+---
+id: c3-2
+c3-version: 4
+c3-seal: ce52f697e8fc91213a7525615acb4ff3cce2bc9dfff381e7bae3bf8b48680ff4
+title: Server
+type: container
+boundary: service
+parent: c3-0
+goal: 'Run the local Bun backend: serve HTTP+WebSocket on localhost, coordinate Claude + Codex agent turns, persist events, and broadcast derived read models.'
+---
+
+# server
+
+## Goal
+
+Run the local Bun backend: serve HTTP+WebSocket on localhost, coordinate Claude + Codex agent turns, persist events, and broadcast derived read models.
+
+## Responsibilities
+
+- Own the authoritative event log and derived read models; every state mutation lands as a JSONL event first.
+- Accept WebSocket subscriptions and commands; push fresh snapshots on every change.
+- Drive multi-provider agent turns (Claude Agent SDK, Claude CLI under PTY, Codex App Server) through a single coordinator.
+- Host the in-process loopback MCP server for `mcp__kanna__*` shims and route interactive tool requests through a durable approval protocol that survives restart.
+- Detect rate-limit / auth-error turn endings and auto-resume the chat at the right wake-up moment without user intervention.
+- Discover local projects, manage terminals and uploads, operate share tunnels.
+- Gate network access (auth), supervise its own CLI lifecycle, and refuse to leave localhost unless explicitly asked.
+
+## Components
+
+| ID | Name | Category | Status | Goal Contribution |
+| --- | --- | --- | --- | --- |
+| c3-201 | cli-entry | foundation | implemented | CLI parsing, supervisor, browser launcher |
+| c3-202 | http-ws-server | foundation | implemented | HTTP + WS + static serving |
+| c3-203 | auth | foundation | implemented | Password + session cookie gating |
+| c3-204 | paths-config | foundation | implemented | Central data-path resolution |
+| c3-205 | events-schema | foundation | implemented | Typed event unions for the log |
+| c3-206 | event-store | foundation | implemented | Append-only JSONL + replay + snapshot compaction |
+| c3-207 | read-models | foundation | implemented | Derived views from event state |
+| c3-208 | ws-router | foundation | implemented | WS subscribe/command multiplexer |
+| c3-209 | process-utils | foundation | implemented | Shared process lifecycle helpers |
+| c3-210 | agent-coordinator | feature | implemented | Multi-provider turn orchestration |
+| c3-211 | codex-app-server | feature | implemented | Codex App Server JSON-RPC adapter |
+| c3-212 | provider-catalog | feature | implemented | Provider/model/effort normalization |
+| c3-213 | quick-response | feature | implemented | Structured Haiku queries with Codex fallback |
+| c3-214 | discovery | feature | implemented | Auto-discover local Claude + Codex projects |
+| c3-215 | diff-store | feature | implemented | Per-chat diff state for file-change UI |
+| c3-216 | terminal-manager | feature | implemented | PTY sessions for embedded terminal |
+| c3-217 | uploads | feature | implemented | File upload handling |
+| c3-218 | share | feature | implemented | Cloudflare quick + named tunnels + QR |
+| c3-219 | update-manager | feature | implemented | npm version checking |
+| c3-220 | restart | feature | implemented | In-place server relaunch |
+| c3-221 | external-open | feature | implemented | Open URLs/files in external apps |
+| c3-222 | keybindings | feature | implemented | Persist user keybindings |
+| c3-223 | cloudflare-tunnel | feature | implemented | Detect dev-server ports and expose via cloudflared quick tunnels |
+| c3-224 | oauth-token-pool | feature | implemented | Multi-account OAuth token pool: per-chat reservation, rate-limit/auth-error rotation, refusal classifier |
+| c3-225 | claude-pty-driver | feature | implemented | Claude CLI PTY transport: parse subprocess stdout JSONL into normalized events, preserve subscription billing |
+| c3-226 | kanna-mcp-host | feature | implemented | Loopback MCP server + built-in shims + durable approval protocol + path-deny |
+| c3-227 | auto-continue | feature | implemented | Detect rate-limit / auth-error endings, schedule retries, replay queued prompts |
+| c3-228 | session-share | feature | planned | Mint read-only share tokens for finished chats; serve frozen snapshots at /share/:token without auth |
+| c3-229 | workflow-status | feature | planned | Disk-watch sidecar read-model for PTY workflow runs; WorkflowRegistry + WorkflowsSnapshot WS topic |
diff --git a/.c3/c3-2-server/c3-201-cli-entry.md b/.c3/c3-2-server/c3-201-cli-entry.md
new file mode 100644
index 000000000..576ed897d
--- /dev/null
+++ b/.c3/c3-2-server/c3-201-cli-entry.md
@@ -0,0 +1,79 @@
+---
+id: c3-201
+c3-version: 4
+c3-seal: a4286636bc6eb380d95060bd342c3b6d3262fadafea1da8e1968d990ca6df387
+title: cli-entry
+type: component
+category: foundation
+parent: c3-2
+goal: Parse CLI flags, supervise the Bun server process, pick dev/prod runtime mode, and open the browser.
+uses:
+ - ref-local-first-data
+---
+
+# cli-entry
+
+## Goal
+
+Parse CLI flags, supervise the Bun server process, pick dev/prod runtime mode, and open the browser.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Boot the local Bun server, supervise children, pick dev/prod mode" |
+| Category | foundation |
+| Lifecycle | Process entry — runs once per server boot |
+| Replaceability | Replaceable provided same flag surface and supervisor contract preserved |
+
+## Purpose
+
+Parses argv, resolves runtime mode (dev/prod), supervises the Bun server child, opens the default browser, and wires `--share` into the tunnel manager. Non-goals: HTTP routing, agent execution, persistent state — those live in c3-202 and downstream.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Bun runtime available on PATH | c3-201 |
+| Input — paths | Resolved via shared paths-config | c3-204 |
+| Input — port defaults | Shared port constants | c3-304 |
+| Input — process helpers | Spawn/signal helpers | c3-209 |
+| Initialization | Invoked by bun run kanna entry | c3-201 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | User runs one command and gets a working app + browser tab | c3-2 |
+| Primary path | Parse flags → spawn server child → open browser | c3-202 |
+| Alternate — share | --share triggers tunnel manager | c3-218 |
+| Alternate — restart | Exit code 76 triggers self-relaunch | c3-220 |
+| Failure — port in use | Surface error and exit non-zero | c3-304 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-local-first-data | ref | Defaults to localhost; --remote/--share are explicit opt-ins | must follow | No wider bind without flag |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| kanna CLI | IN | Accepts documented flags (--port, --share, --password, ...) | c3-2 | src/server/cli.ts |
+| Spawned server child | OUT | Inherits stdio; restarts on exit code 76 | c3-202 | src/server/cli.ts |
+| Tunnel hookup | OUT | Forwards public URL to share manager | c3-218 | src/server/cli.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Flag parsing regression | New flag added without parser update | Wrong defaults at boot | bun run check + manual kanna --help smoke against src/server/cli.ts |
+| Restart loop | Bad exit-code handling | Process restarts forever | bun run check + manual restart smoke against src/server/cli.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/cli.ts | c3-201 Contract | Flag impl details | src/server/cli.ts |
diff --git a/.c3/c3-2-server/c3-202-http-ws-server.md b/.c3/c3-2-server/c3-202-http-ws-server.md
new file mode 100644
index 000000000..5b6d29140
--- /dev/null
+++ b/.c3/c3-2-server/c3-202-http-ws-server.md
@@ -0,0 +1,85 @@
+---
+id: c3-202
+c3-version: 4
+c3-seal: f58866c80e38d426fb062c197cd5da0d5523016aed3a1e393a6b1a7a46e168c5
+title: http-ws-server
+type: component
+category: foundation
+parent: c3-2
+goal: Serve HTTP (static + API) and upgrade to WebSocket; attach auth gating; expose `/health`.
+uses:
+ - c3-228
+ - ref-local-first-data
+ - ref-ws-subscription
+---
+
+# http-ws-server
+
+## Goal
+
+Serve HTTP (static + API) and upgrade to WebSocket; attach auth gating; expose `/health`.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Expose HTTP + WebSocket endpoints to the local browser" |
+| Category | foundation |
+| Lifecycle | Singleton listener per server process |
+| Replaceability | Replaceable provided HTTP+WS contract and auth hookup preserved |
+
+## Purpose
+
+Hosts the Bun-side HTTP server, serves built client assets, exposes API + upgrade endpoints, gates connections via the auth middleware, and routes upgraded sockets to the WS router. Non-goals: business logic, persistence, projection state.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | CLI parsed and port resolved | c3-201 |
+| Input — auth gate | Cookie-based middleware | c3-203 |
+| Input — WS router | Receives upgraded sockets | c3-208 |
+| Input — port defaults | Shared port constants | c3-304 |
+| Initialization | Invoked from CLI after flag parse | c3-201 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Client connects, authenticates, opens single WS | c3-101 |
+| Primary path | HTTP serves assets → upgrade → ws-router | c3-208 |
+| Alternate — health | /health returns 200 for liveness checks | c3-202 |
+| Alternate — API | /api/* routes serve JSON endpoints (uploads, etc.) | c3-217 |
+| Failure — auth reject | 401 close on missing/invalid cookie | c3-203 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-ws-subscription | ref | Single-WS upgrade pattern | must follow | Hand off to ws-router |
+| ref-local-first-data | ref | Default bind 127.0.0.1 | must follow | Wider bind requires explicit flag |
+| c3-228 | ref | /share/:token and /assets/share-view/* routes are dispatched before the auth gate | must follow | Wired for session-share coupling |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| HTTP listener | IN | Serves static assets + API + upgrade | c3-101 | src/server/http.ts |
+| WS upgrade hookup | OUT | Hands socket to ws-router | c3-208 | src/server/http.ts |
+| /health | OUT | Liveness probe | c3-2 | src/server/http.ts |
+| /share/:token | OUT | Public read-only snapshot endpoint dispatched BEFORE the auth gate; serves frozen chat snapshot JSON | c3-228 | src/server/http.ts |
+| /assets/share-view/* | OUT | Reserved static path for the share viewer bundle, also pre-auth | c3-228 | src/server/http.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Auth bypass | Middleware order regression | Unauthenticated requests succeed | bun run check + smoke src/server/http.ts with --password |
+| Static asset 404 | Build path drift | UI fails to load | bun run check against src/server/http.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/http.ts | c3-202 Contract | Listener detail | src/server/http.ts |
diff --git a/.c3/c3-2-server/c3-203-auth.md b/.c3/c3-2-server/c3-203-auth.md
new file mode 100644
index 000000000..9c786c4e3
--- /dev/null
+++ b/.c3/c3-2-server/c3-203-auth.md
@@ -0,0 +1,78 @@
+---
+id: c3-203
+c3-version: 4
+c3-seal: 1e8aba899cef58a6197200c340a2b8ddb5a97ad954c783c31f314096d1ada971
+title: auth
+type: component
+category: foundation
+parent: c3-2
+goal: Gate HTTP, WebSocket, and API routes behind a launch-password session cookie when `--password` is set.
+uses:
+ - ref-local-first-data
+---
+
+# auth
+
+## Goal
+
+Gate HTTP, WebSocket, and API routes behind a launch-password session cookie when `--password` is set.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Protect remote/shared servers behind launch-password" |
+| Category | foundation |
+| Lifecycle | Middleware module bound at HTTP server boot |
+| Replaceability | Replaceable provided cookie + 401 contract preserved |
+
+## Purpose
+
+Issues and validates a launch-password session cookie, blocks unauthenticated HTTP/WS/API requests, and surfaces a login form when `--password` is set. Non-goals: per-user accounts, OAuth, multi-tenant auth — single launch password only.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | CLI flag --password provided when sharing | c3-201 |
+| Input — paths | Reads cookie secret from data dir | c3-204 |
+| Internal state | Session cookie hashed in memory | c3-203 |
+| Initialization | Bound by http-ws-server before route registration | c3-202 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Public/shared servers stay safe behind a password | c3-218 |
+| Primary path | POST /login → set-cookie → cookie attached to subsequent reqs | c3-202 |
+| Alternate — local-only | No password set: middleware passthrough | c3-203 |
+| Failure — 401 | Closes WS upgrade with auth-required signal | c3-101 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-local-first-data | ref | Wider surfaces require password | must follow | Local-only bind is unauthenticated |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| attachAuth(server) | OUT | Wraps HTTP server, gates routes | c3-202 | src/server/auth.ts |
+| Login endpoint | IN | Accepts password form, sets session cookie | c3-202 | src/server/auth.ts |
+| WS auth check | OUT | Rejects upgrade without valid cookie | c3-208 | src/server/auth.ts |
+| isPublicSharePath(url) | OUT | Exempts /share/* and /assets/share-view/* from owner auth; called at the top of the middleware before any cookie check | c3-228 | src/server/auth.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Cookie leakage | Wrong cookie attributes | Token shows up in non-secure context | bun run check + smoke src/server/auth.ts with --password |
+| Bypass on WS upgrade | Middleware skipped for upgrade | Unauthorized clients connect | bun run check + manual upgrade smoke against src/server/auth.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/auth.ts | c3-203 Contract | Middleware detail | src/server/auth.ts |
diff --git a/.c3/c3-2-server/c3-204-paths-config.md b/.c3/c3-2-server/c3-204-paths-config.md
new file mode 100644
index 000000000..e325b0178
--- /dev/null
+++ b/.c3/c3-2-server/c3-204-paths-config.md
@@ -0,0 +1,76 @@
+---
+id: c3-204
+c3-version: 4
+c3-seal: 973197301d01c80d833fed35bbf2e94abc02ba22ba3065e5215cb93db9a33fcb
+title: paths-config
+type: component
+category: foundation
+parent: c3-2
+goal: Resolve all filesystem paths (data dir, JSONL logs, snapshots) and machine identity helpers for the server.
+uses:
+ - ref-local-first-data
+---
+
+# paths-config
+
+## Goal
+
+Resolve all filesystem paths (data dir, JSONL logs, snapshots) and machine identity helpers for the server.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Centralize filesystem layout under ~/.kanna/data" |
+| Category | foundation |
+| Lifecycle | Pure module, evaluated on first import |
+| Replaceability | Replaceable provided path-resolver function names preserved |
+
+## Purpose
+
+Owns the canonical mapping of data dir, JSONL event logs, snapshot files, settings file, and machine identity helpers; everything else asks paths-config rather than hard-coding strings. Non-goals: I/O, persistence, schema decisions.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | OS user home resolvable | c3-204 |
+| Input — branding constants | App name + data dir prefix | c3-305 |
+| Internal state | None — pure functions | c3-204 |
+| Initialization | Imported lazily by consumers | c3-204 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Server reads/writes consistent paths everywhere | c3-2 |
+| Primary path | Consumer calls paths.eventsLog(projectId) | c3-206 |
+| Alternate — settings file | paths.settings() resolves shared settings json | c3-222 |
+| Alternate — uploads dir | paths.uploadsDir() for attachments | c3-217 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-local-first-data | ref | All paths under ~/.kanna/data | must follow | No remote storage roots |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| paths.* helpers | OUT | Returns absolute paths within data dir | c3-206 | src/server/paths.ts |
+| Machine identity helper | OUT | Stable per-machine id from data dir | c3-218 | src/server/paths.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Path drift | New consumer hard-codes string instead of helper | grep finds raw ~/.kanna/data literals | bun run check + audit src/server/ |
+| Layout break on rename | Folder rename without migration | Existing data inaccessible | bun run check against src/server/paths.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/paths.ts | c3-204 Contract | Path detail | src/server/paths.ts |
diff --git a/.c3/c3-2-server/c3-205-events-schema.md b/.c3/c3-2-server/c3-205-events-schema.md
new file mode 100644
index 000000000..b4f3beb53
--- /dev/null
+++ b/.c3/c3-2-server/c3-205-events-schema.md
@@ -0,0 +1,82 @@
+---
+id: c3-205
+c3-version: 4
+c3-seal: db3dfa4b3eb72c604aa38aa59400c31112150e89eb4d57d63669097cfb003e38
+title: events-schema
+type: component
+category: foundation
+parent: c3-2
+goal: Define the typed event union (project/chat/message/turn) appended to JSONL logs.
+uses:
+ - ref-event-sourcing
+ - ref-strong-typing
+ - rule-strong-typing
+---
+
+# events-schema
+
+## Goal
+
+Define the typed event union (project/chat/message/turn) appended to JSONL logs.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Define the canonical event vocabulary for the event log" |
+| Category | foundation |
+| Lifecycle | Type module, no runtime instances |
+| Replaceability | Replaceable provided discriminated union shape preserved |
+
+## Purpose
+
+Owns the discriminated union of every event written to the JSONL log: project events, chat events, message events, turn events, tunnel events. Non-goals: I/O, replay, persistence — those live in c3-206.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Bun + TypeScript strict mode | c3-2 |
+| Input — shared types | Domain types reused for payloads | c3-301 |
+| Internal state | Pure type module | c3-205 |
+| Initialization | Imported by writers and read-models | c3-206 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Writers and read-models share one source of truth | c3-2 |
+| Primary path | Writer constructs typed event → store appends | c3-206 |
+| Alternate — projection | Read-models switch on event kind | c3-207 |
+| Alternate — coordinator | Coordinator emits turn events | c3-210 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-event-sourcing | ref | Defines event vocabulary | must follow | One union per log line |
+| ref-strong-typing | ref | Discriminated unions per kind | must follow | No any in event payloads |
+| rule-strong-typing | rule | Compliance target added by c3x wire; refine what must be reviewed or complied with before handoff. | wired compliance target beats uncited local prose | Added by c3x wire for explicit compliance review. |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| Event union | OUT | Discriminated union of every persisted kind | c3-206 | src/server/events.ts |
+| Event constructors | OUT | Helpers returning typed events with timestamps | c3-210 | src/server/events.ts |
+| share.token_minted event | OUT | { tokenId, chatId, expiresAt, createdAt, createdBy } — appended to shares.jsonl (owned by c3-206) when a share link is created | c3-228 | src/server/events.ts |
+| share.token_revoked event | OUT | { tokenId, revokedAt } — appended to shares.jsonl when a link is revoked or expires | c3-228 | src/server/events.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Schema drift | New kind added without read-model handler | Replay errors or missing data on UI | bun run check against src/server/events.ts |
+| Untyped payload | Writer escapes to any | tsc fails or runtime decode error | bun run check plus grep src/server/ for as any regressions |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/events.ts | c3-205 Contract | Type detail | src/server/events.ts |
diff --git a/.c3/c3-2-server/c3-206-event-store.md b/.c3/c3-2-server/c3-206-event-store.md
new file mode 100644
index 000000000..a60869ee9
--- /dev/null
+++ b/.c3/c3-2-server/c3-206-event-store.md
@@ -0,0 +1,87 @@
+---
+id: c3-206
+c3-version: 4
+c3-seal: 53b5fc2b9ef2492a08a4e5d13f15d0feae8a86d0383b99a08062f992e43ca7e4
+title: event-store
+type: component
+category: foundation
+parent: c3-2
+goal: Append events to JSONL, replay on boot, compact to snapshot.json when the log exceeds 2 MB.
+uses:
+ - ref-colocated-bun-test
+ - ref-event-sourcing
+ - ref-local-first-data
+ - rule-colocated-bun-test
+---
+
+# event-store
+
+## Goal
+
+Append events to JSONL, replay on boot, compact to snapshot.json when the log exceeds 2 MB.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Persist agent + chat events durably and replay them on boot" |
+| Category | foundation |
+| Lifecycle | Singleton per server process |
+| Replaceability | Replaceable provided append/replay/compact contract preserved |
+
+## Purpose
+
+Owns the JSONL event log: append-only writes, in-order replay on boot, snapshot compaction once the log exceeds 2 MB. Non-goals: projection logic, command handling, network — those live elsewhere.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Data dir created and writable | c3-204 |
+| Input — events schema | Typed event union | c3-205 |
+| Input — paths | Log + snapshot file paths | c3-204 |
+| Internal state | In-memory log mirror + write queue | c3-206 |
+| Initialization | Replays JSONL → snapshot before serving | c3-206 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Authoritative state survives restarts and compaction | c3-2 |
+| Primary path | Append → fsync → notify subscribers | c3-207 |
+| Override — subagent ephemeral | subagent_* events apply in-memory synchronously then enqueue a disk-only append (no second applyEvent in the chain callback); disk failure caught and logged, in-memory state remains advanced. Durable/structural events keep strict Append→fsync→notify. See adr-20260519-subagent-live-progress-decouple. | c3-206 |
+| Alternate — replay | Boot replay rebuilds state from log + snapshot | c3-206 |
+| Alternate — compact | Snapshot taken when log > 2 MB | c3-206 |
+| Failure — write error | Surface to caller; log not advanced (structural events). Subagent ephemeral events: disk failure logged via .catch; in-memory already advanced. | c3-205 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-event-sourcing | ref | Append-only log + snapshot strategy | must follow | One log per project |
+| ref-local-first-data | ref | Files under ~/.kanna/data | must follow | No remote replication |
+| ref-colocated-bun-test | ref | Tests live next to source | must follow | event-store.test.ts |
+| rule-colocated-bun-test | rule | Compliance target added by c3x wire; refine what must be reviewed or complied with before handoff. | wired compliance target beats uncited local prose | Added by c3x wire for explicit compliance review. |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| append(event) | IN | Typed append, returns ack | c3-210 | src/server/event-store.ts |
+| replay() | OUT | Yields events in order | c3-207 | src/server/event-store.ts |
+| compact() | OUT | Writes snapshot.json + truncates JSONL | c3-206 | src/server/event-store.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Lost events on crash | Write order regression | Replay yields incomplete state | bun run test src/server/event-store.test.ts |
+| Snapshot/log divergence | Compact bug | Boot replays stale state | bun run check plus replay smoke against src/server/event-store.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/event-store.ts | c3-206 Contract | Storage detail | src/server/event-store.ts |
+| src/server/event-store.test.ts | c3-206 Contract | Test cases per surface | src/server/event-store.test.ts |
diff --git a/.c3/c3-2-server/c3-207-read-models.md b/.c3/c3-2-server/c3-207-read-models.md
new file mode 100644
index 000000000..749893740
--- /dev/null
+++ b/.c3/c3-2-server/c3-207-read-models.md
@@ -0,0 +1,82 @@
+---
+id: c3-207
+c3-version: 4
+c3-seal: 83a047ba307bc972cde8c504cf431b335c717a2fc4676ffbcc705dfbb7f462af
+title: read-models
+type: component
+category: foundation
+parent: c3-2
+goal: Project events into derived views (sidebar, chat, projects, discovery) that ws-router broadcasts to clients.
+uses:
+ - ref-cqrs-read-models
+ - ref-strong-typing
+ - rule-strong-typing
+---
+
+# read-models
+
+## Goal
+
+Project events into derived views (sidebar, chat, projects, discovery) that ws-router broadcasts to clients.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Turn raw events into UI-shaped projections pushed via WS" |
+| Category | foundation |
+| Lifecycle | In-memory derived state, rebuilt from event log |
+| Replaceability | Replaceable provided projection shape preserved |
+
+## Purpose
+
+Subscribes to event-store appends, derives per-feature views (sidebar list, chat snapshot, project list, discovery feed, tunnel state), and exposes them to ws-router for snapshot push. Non-goals: persistence, command handling, raw event shape decisions.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | event-store ready with replayed log | c3-206 |
+| Input — events schema | Typed events | c3-205 |
+| Input — discovery feed | Discovery emits its own projection | c3-214 |
+| Input — keybindings | Keybinding store emits projection | c3-222 |
+| Initialization | Subscribes to event-store on boot | c3-207 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | UI gets shape-stable snapshots without replay | c3-101 |
+| Primary path | Event appended → projection updated → WS broadcast | c3-208 |
+| Alternate — initial sub | New client gets latest snapshot on subscribe | c3-208 |
+| Alternate — diff | Diff snapshots projected from diff-store | c3-215 |
+| Failure — projection mismatch | Type drift triggers tsc failure | c3-205 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-cqrs-read-models | ref | Project once, broadcast many | must follow | No cross-feature joins |
+| ref-strong-typing | ref | Typed view models | must follow | Discriminated by topic |
+| rule-strong-typing | rule | Compliance target added by c3x wire; refine what must be reviewed or complied with before handoff. | wired compliance target beats uncited local prose | Added by c3x wire for explicit compliance review. |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| subscribe(topic) | OUT | Returns latest snapshot + push stream | c3-208 | src/server/read-models.ts |
+| Projection map | IN | Event-store appends drive projection update | c3-206 | src/server/read-models.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Stale snapshot on edit | Update path skips projection | UI shows old data | bun run check against src/server/read-models.ts |
+| Type drift between projection + client | Shape change without protocol update | tsc fails or runtime decode error | bun run check plus replay protocol fixtures from src/shared/protocol.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/read-models.ts | c3-207 Contract | Projection detail | src/server/read-models.ts |
diff --git a/.c3/c3-2-server/c3-208-ws-router.md b/.c3/c3-2-server/c3-208-ws-router.md
new file mode 100644
index 000000000..a1580c17d
--- /dev/null
+++ b/.c3/c3-2-server/c3-208-ws-router.md
@@ -0,0 +1,88 @@
+---
+id: c3-208
+c3-version: 4
+c3-seal: 84bb93824011b523ed71e02324eb0473fbc9c77f838625000cf793816cf866a9
+title: ws-router
+type: component
+category: foundation
+parent: c3-2
+goal: 'Multiplex WS traffic: route subscribe/unsubscribe/command envelopes, push projections on every state change.'
+uses:
+ - c3-228
+ - ref-colocated-bun-test
+ - ref-cqrs-read-models
+ - ref-ws-subscription
+ - rule-colocated-bun-test
+---
+
+# ws-router
+
+## Goal
+
+Multiplex WS traffic: route subscribe/unsubscribe/command envelopes, push projections on every state change.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Multiplex WS subscriptions and commands across the single socket" |
+| Category | foundation |
+| Lifecycle | One router instance per server, one connection per client |
+| Replaceability | Replaceable provided envelope contract preserved |
+
+## Purpose
+
+Accepts upgraded WS sockets, decodes typed `ClientEnvelope` payloads, dispatches subscribe/unsubscribe/command, forwards push payloads from read-models, and relays command results. Non-goals: HTTP routing, persistence, business decisions.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | HTTP server upgraded the socket and auth check passed | c3-202 |
+| Input — read-models | Snapshots and push streams | c3-207 |
+| Input — agent-coordinator | Command handlers for chat/turn ops | c3-210 |
+| Input — protocol envelopes | Shared discriminated unions | c3-302 |
+| Initialization | Bound by http-ws-server on upgrade | c3-202 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Every connected client mirrors the latest server state | c3-101 |
+| Primary path | subscribe → snapshot push → diff stream | c3-207 |
+| Alternate — command | command envelope → handler → commandResult push | c3-210 |
+| Alternate — terminal | PTY bytes piped over the same socket | c3-216 |
+| Failure — envelope decode | Reject with typed error envelope | c3-302 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-ws-subscription | ref | Single-WS, typed envelopes | must follow | No additional sockets |
+| ref-cqrs-read-models | ref | Only projections cross the wire | must follow | Raw events stay server-side |
+| ref-colocated-bun-test | ref | Tests next to router | must follow | ws-router.test.ts |
+| rule-colocated-bun-test | rule | Compliance target added by c3x wire; refine what must be reviewed or complied with before handoff. | wired compliance target beats uncited local prose | Added by c3x wire for explicit compliance review. |
+| c3-228 | ref | Session-share envelopes (share.mint, share.revoke, share.list) dispatched through ws-router | must follow | Wired for session-share coupling |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| Envelope dispatch | IN | Decodes ClientEnvelope → handler | c3-101 | src/server/ws-router.ts |
+| Snapshot push | OUT | Pushes typed ServerEnvelope to subscribers | c3-101 | src/server/ws-router.ts |
+| Command result | OUT | Correlates result envelope to client command | c3-101 | src/server/ws-router.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Envelope drift | Protocol bump without router update | Decode errors at runtime | bun run test src/server/ws-router.test.ts |
+| Subscription leak | Listener not pruned on disconnect | Memory growth across long sessions | Long-session smoke + listener count assertion in src/server/ws-router.test.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/ws-router.ts | c3-208 Contract | Routing detail | src/server/ws-router.ts |
+| src/server/ws-router.test.ts | c3-208 Contract | Test cases per surface | src/server/ws-router.test.ts |
diff --git a/.c3/c3-2-server/c3-209-process-utils.md b/.c3/c3-2-server/c3-209-process-utils.md
new file mode 100644
index 000000000..8cff31803
--- /dev/null
+++ b/.c3/c3-2-server/c3-209-process-utils.md
@@ -0,0 +1,78 @@
+---
+id: c3-209
+c3-version: 4
+c3-seal: c80638fcbd96f3c35dc04fbb04a7124db1809b9d11ad1a3ce9b1399410443539
+title: process-utils
+type: component
+category: foundation
+parent: c3-2
+goal: Provide helpers for spawning, signaling, and tearing down child processes (agents, terminals, tunnels).
+uses:
+ - ref-strong-typing
+ - rule-strong-typing
+---
+
+# process-utils
+
+## Goal
+
+Provide helpers for spawning, signaling, and tearing down child processes (agents, terminals, tunnels).
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Manage child-process lifecycles uniformly across features" |
+| Category | foundation |
+| Lifecycle | Pure helper module |
+| Replaceability | Replaceable provided spawn/signal helper signatures preserved |
+
+## Purpose
+
+Wraps Bun's child-process APIs into typed helpers (spawn, signal, kill, drain stdio) that features call rather than reinventing process lifecycle logic. Non-goals: domain-specific process orchestration — that belongs to feature components.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Bun runtime spawn API available | c3-2 |
+| Input — typed handles | Reused across spawners | c3-301 |
+| Internal state | None — helpers manage state per call | c3-209 |
+| Initialization | Imported lazily by feature modules | c3-210 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Features dispose child processes cleanly | c3-2 |
+| Primary path | Caller spawns → drains stdio → awaits exit | c3-216 |
+| Alternate — signal | Caller signals SIGTERM → grace timeout → SIGKILL | c3-220 |
+| Alternate — supervisor | CLI uses helpers to relaunch server | c3-201 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-strong-typing | ref | Typed child-process handles | must follow | No any for spawn options |
+| rule-strong-typing | rule | Compliance target added by c3x wire; refine what must be reviewed or complied with before handoff. | wired compliance target beats uncited local prose | Added by c3x wire for explicit compliance review. |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| spawn(opts) | OUT | Returns typed child handle | c3-216 | src/server/process-utils.ts |
+| signalAndWait(handle) | OUT | Sends SIGTERM, escalates after timeout | c3-220 | src/server/process-utils.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Zombie process leak | Helper skips wait on exit | Process count grows over session | bun run check against src/server/process-utils.ts |
+| Signal escalation regression | Timeout edit | Stuck on shutdown | Manual SIGTERM smoke + grep src/server/ for stuck process patterns |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/process-utils.ts | c3-209 Contract | Helper detail | src/server/process-utils.ts |
diff --git a/.c3/c3-2-server/c3-210-agent-coordinator.md b/.c3/c3-2-server/c3-210-agent-coordinator.md
new file mode 100644
index 000000000..aaf98333d
--- /dev/null
+++ b/.c3/c3-2-server/c3-210-agent-coordinator.md
@@ -0,0 +1,97 @@
+---
+id: c3-210
+c3-version: 4
+c3-seal: a596727ab20c5c0c03592aead2ee06f349aef950cf04de1f8ff3fe2bb729d6f4
+title: agent-coordinator
+type: component
+category: feature
+parent: c3-2
+goal: 'Drive turn lifecycle across providers: start/cancel/resume Claude + Codex sessions, emit normalized transcript events.'
+uses:
+ - c3-229
+ - ref-colocated-bun-test
+ - ref-event-sourcing
+ - ref-provider-adapter
+ - ref-tool-hydration
+ - rule-colocated-bun-test
+---
+
+# agent-coordinator
+
+## Goal
+
+Drive turn lifecycle across providers: start/cancel/resume Claude + Codex sessions, emit normalized transcript events.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Orchestrate provider-agnostic agent turns and persist transcript events" |
+| Category | feature |
+| Lifecycle | Singleton orchestrator with per-chat session state |
+| Replaceability | Replaceable provided turn command + transcript event contract preserved |
+
+## Purpose
+
+Owns the agent turn lifecycle: receives `chat.send` commands, picks the provider via the catalog, drives the Codex/Claude adapter, normalizes streamed events into transcript events, and writes them to the event store. Non-goals: provider transport details, command routing — those live in c3-211 and c3-208.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Provider catalog loaded and event store ready | c3-212 |
+| Input — Codex adapter | Routes Codex turns over JSON-RPC | c3-211 |
+| Input — event store | Appends transcript events | c3-206 |
+| Input — tool hydration | Normalizes tool entries before persistence | c3-303 |
+| Input — process utils | Spawns/cancels child processes | c3-209 |
+| Input — oauth token pool | Picks per-chat Claude OAuth token; rotates on rate-limit/auth-error; supplies refusal classifier | c3-224 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | UI streams a coherent turn from any supported provider | c3-101 |
+| Primary path | chat.send → start session → stream events → finalize turn | c3-208 |
+| Subagent live progress | onEntry fires onRunProgress directly (not chained on write chain) so UI updates synchronously with in-memory state; onChunk fires trailing-edge throttled (~100ms) onRunProgress for streaming text visibility. See adr-20260519-subagent-live-progress-decouple. | c3-207 |
+| Alternate — cancel | chat.cancel propagates to provider | c3-211 |
+| Alternate — resume | Resume reuses live session if available | c3-211 |
+| Failure — provider error | Emits typed failure event; surfaces to client | c3-205 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-provider-adapter | ref | Provider-agnostic turn shape | must follow | All providers via adapter |
+| ref-event-sourcing | ref | Events written before broadcast | must follow | Log is source of truth |
+| ref-tool-hydration | ref | Tool calls normalized before persistence | must follow | Single hydration path |
+| ref-colocated-bun-test | ref | Tests live next to coordinator | must follow | agent-coordinator.test.ts |
+| rule-colocated-bun-test | rule | Coordinator test suites enforce colocated-bun-test rule | must follow | agent.*.test.ts colocated with agent.ts |
+| c3-229 | ref | Compliance target added by c3x wire; refine what must be reviewed or complied with before handoff. | wired compliance target beats uncited local prose | Added by c3x wire for explicit compliance review. |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| runTurn(command) | IN | Drives a single turn from chat.send | c3-208 | src/server/agent-coordinator.ts |
+| Transcript events | OUT | Append-only typed events | c3-206 | src/server/agent-coordinator.ts |
+| Cancel callback | IN | Propagates cancel to provider | c3-211 | src/server/agent-coordinator.ts |
+| delegateRun({keepAlive?}) | IN | Runs subagent turn 1; on keepAlive completion registers a live session (no /exit) and returns runId; over KANNA_SUBAGENT_MAX_LIVE per chat fails CAP_EXCEEDED | c3-226 | src/server/subagent-orchestrator.ts |
+| sendToLiveRun(runId, prompt) | IN | Drives a follow-up turn into a warm keep-alive session via channel push; acquires a permit for the turn only; NO_LIVE_SESSION if unknown | c3-226 | src/server/subagent-orchestrator.ts |
+| closeLiveRun(chatId, runId, reason) | IN | Tears down a live session (close REPL, cleanup RunState, onRunTerminal); also driven by idle timeout + cancel cascade | c3-226 | src/server/subagent-orchestrator.ts |
+| LiveTurnSource | OUT | Provider-run handle returned after keep-alive turn 1 — runTurn (push + drain one turn) + close; keeps the persistent HarnessEvent iterator open | c3-225 | src/server/subagent-provider-run.ts |
+| findSubagent(id) | IN | Snapshot lookup used by the MCP host to reject keep_alive for non-claude subagents | c3-226 | src/server/subagent-orchestrator.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Lost turn on crash | Event written after broadcast | Replay missing turn | bun run test src/server/agent-coordinator.test.ts |
+| Provider drift | Provider event shape change | Tool entries malformed | bun run check against src/server/agent-coordinator.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/agent-coordinator.ts | c3-210 Contract | Orchestration detail | src/server/agent-coordinator.ts |
+| src/server/agent-coordinator.test.ts | c3-210 Contract | Test cases per surface | src/server/agent-coordinator.test.ts |
diff --git a/.c3/c3-2-server/c3-211-codex-app-server.md b/.c3/c3-2-server/c3-211-codex-app-server.md
new file mode 100644
index 000000000..4c087bc26
--- /dev/null
+++ b/.c3/c3-2-server/c3-211-codex-app-server.md
@@ -0,0 +1,81 @@
+---
+id: c3-211
+c3-version: 4
+c3-seal: 0cb981682d6bb47a7da1a70c35c92c84bc9852286e5e7fb187cfd4713111aec8
+title: codex-app-server
+type: component
+category: feature
+parent: c3-2
+goal: 'Drive the Codex App Server over JSON-RPC: boot, run turns, translate Codex events into coordinator-friendly shapes.'
+uses:
+ - ref-provider-adapter
+ - ref-strong-typing
+ - rule-strong-typing
+---
+
+# codex-app-server
+
+## Goal
+
+Drive the Codex App Server over JSON-RPC: boot, run turns, translate Codex events into coordinator-friendly shapes.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Provide a Codex provider implementation behind the adapter" |
+| Category | feature |
+| Lifecycle | Child process spawned on first Codex turn, reused per chat |
+| Replaceability | Replaceable provided JSON-RPC + adapter contract preserved |
+
+## Purpose
+
+Spawns the Codex App Server child process, speaks JSON-RPC, maps its event stream onto the provider-adapter shape consumed by the coordinator. Non-goals: turn orchestration, transcript persistence — those live in c3-210.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Codex CLI installed and resolvable | c3-2 |
+| Input — process utils | Spawn + signal helpers | c3-209 |
+| Input — protocol | Typed JSON-RPC envelopes | c3-301 |
+| Initialization | Lazy spawn on first Codex turn | c3-211 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Codex turns flow through the same coordinator path as Claude | c3-210 |
+| Primary path | RPC runTurn → stream events → translate to adapter | c3-210 |
+| Alternate — fallback | Quick-response uses Codex when Claude Haiku fails | c3-213 |
+| Alternate — cancel | RPC cancel propagates to running turn | c3-210 |
+| Failure — child crash | Restart child; surface error event | c3-209 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-provider-adapter | ref | Adapter sits behind coordinator | must follow | No direct UI imports |
+| ref-strong-typing | ref | Typed JSON-RPC envelopes | must follow | No any in protocol |
+| rule-strong-typing | rule | Compliance target added by c3x wire; refine what must be reviewed or complied with before handoff. | wired compliance target beats uncited local prose | Added by c3x wire for explicit compliance review. |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| runTurn(spec) | OUT | Streams adapter-shaped events | c3-210 | src/server/codex-app-server.ts |
+| cancel(turnId) | OUT | Aborts running turn | c3-210 | src/server/codex-app-server.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| RPC envelope drift | Codex CLI upgrade | Decode errors at runtime | bun run check against src/server/codex-app-server.ts |
+| Child leak | Cancel skips kill path | Codex children accumulate | Long-session smoke + child-count assertion against src/server/codex-app-server.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/codex-app-server.ts | c3-211 Contract | RPC detail | src/server/codex-app-server.ts |
diff --git a/.c3/c3-2-server/c3-212-provider-catalog.md b/.c3/c3-2-server/c3-212-provider-catalog.md
new file mode 100644
index 000000000..076b2c396
--- /dev/null
+++ b/.c3/c3-2-server/c3-212-provider-catalog.md
@@ -0,0 +1,76 @@
+---
+id: c3-212
+c3-version: 4
+c3-seal: d03adc55365c80a728f70050664d85ab40701eceb874119fdadd8981b4f87cb8
+title: provider-catalog
+type: component
+category: feature
+parent: c3-2
+goal: Normalize providers, models, reasoning effort levels, and Codex fast-mode flags into a single catalog.
+uses:
+ - ref-provider-adapter
+---
+
+# provider-catalog
+
+## Goal
+
+Normalize providers, models, reasoning effort levels, and Codex fast-mode flags into a single catalog.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Expose a single normalized provider/model catalog server-side" |
+| Category | feature |
+| Lifecycle | Static module, evaluated on import |
+| Replaceability | Replaceable provided typed catalog shape preserved |
+
+## Purpose
+
+Holds the typed catalog of providers, models, reasoning effort levels, and provider-specific flags. Downstream code (coordinator, quick-response, client picker) reads from one place. Non-goals: actual model invocation, transport.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Shared types defined | c3-301 |
+| Input — adapter conventions | Catalog shape derives from adapter | c3-210 |
+| Internal state | Static export | c3-212 |
+| Initialization | Imported at first lookup | c3-210 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Coordinator and UI agree on provider/model identity | c3-115 |
+| Primary path | Lookup by id → returns typed entry | c3-210 |
+| Alternate — quick-response | Reads catalog to select Haiku/Codex fallback | c3-213 |
+| Alternate — UI picker | Re-exported catalog types feed picker | c3-301 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-provider-adapter | ref | Catalog is adapter vocabulary | must follow | All providers conform |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| catalog export | OUT | Typed list of providers + models | c3-210 | src/server/provider-catalog.ts |
+| Catalog types | OUT | Re-exported to client picker | c3-301 | src/server/provider-catalog.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Picker desync | Catalog entry added without client release | UI shows wrong options | bun run check against src/server/provider-catalog.ts |
+| Type drift | Effort enum widened without consumers | tsc fails downstream | bun run check plus grep src/client/ for stale enum branches |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/provider-catalog.ts | c3-212 Contract | Catalog detail | src/server/provider-catalog.ts |
diff --git a/.c3/c3-2-server/c3-213-quick-response.md b/.c3/c3-2-server/c3-213-quick-response.md
new file mode 100644
index 000000000..fc394dd49
--- /dev/null
+++ b/.c3/c3-2-server/c3-213-quick-response.md
@@ -0,0 +1,76 @@
+---
+id: c3-213
+c3-version: 4
+c3-seal: 53f98bb17164654664f4e35f347ebd9a90d61bd3a16f0e3a9fb3dc7feb451f37
+title: quick-response
+type: component
+category: feature
+parent: c3-2
+goal: Execute lightweight structured queries (titles, commit messages) via Claude Haiku with Codex fallback.
+uses:
+ - ref-provider-adapter
+---
+
+# quick-response
+
+## Goal
+
+Execute lightweight structured queries (titles, commit messages) via Claude Haiku with Codex fallback.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Run cheap helper queries (titles, commit messages) without involving the full coordinator" |
+| Category | feature |
+| Lifecycle | Stateless module invoked per helper request |
+| Replaceability | Replaceable provided JSON-shape input/output contract preserved |
+
+## Purpose
+
+Performs short structured-output LLM calls (chat title generation, commit message synthesis) using Claude Haiku, falling back to Codex when Claude fails. Non-goals: agent turn lifecycle, persistence, transcript events.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Provider catalog loaded | c3-212 |
+| Input — Codex fallback | Codex App Server reused | c3-211 |
+| Input — diff store | Reads diff snapshot for commit synthesis | c3-215 |
+| Internal state | Stateless | c3-213 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | UI gets fast titles/commits without spinning a full turn | c3-208 |
+| Primary path | Helper request → Haiku call → typed JSON | c3-208 |
+| Alternate — fallback | Haiku error → Codex with the same schema | c3-211 |
+| Failure — both fail | Surface typed error to caller | c3-208 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-provider-adapter | ref | Fallback honors adapter contract | must follow | No bespoke per-provider shapes |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| generateTitle(input) | OUT | Returns typed title string | c3-208 | src/server/quick-response.ts |
+| generateCommit(diffRef) | OUT | Returns typed commit message + summary | c3-215 | src/server/quick-response.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Fallback regression | Codex path skipped on Haiku error | UI shows unhandled failures | bun run check against src/server/quick-response.ts |
+| Schema drift | JSON shape change without consumer update | Decode errors at runtime | bun run check plus replay JSON fixtures from src/server/quick-response.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/quick-response.ts | c3-213 Contract | Helper detail | src/server/quick-response.ts |
diff --git a/.c3/c3-2-server/c3-214-discovery.md b/.c3/c3-2-server/c3-214-discovery.md
new file mode 100644
index 000000000..564a402cc
--- /dev/null
+++ b/.c3/c3-2-server/c3-214-discovery.md
@@ -0,0 +1,76 @@
+---
+id: c3-214
+c3-version: 4
+c3-seal: e84e90f6dafe2c96272ee276d60b8dbbced7b9ffd79f219cc524b0ada2a78864
+title: discovery
+type: component
+category: feature
+parent: c3-2
+goal: Scan Claude Code and Codex local history directories to surface candidate projects for the local-projects page.
+uses:
+ - ref-local-first-data
+---
+
+# discovery
+
+## Goal
+
+Scan Claude Code and Codex local history directories to surface candidate projects for the local-projects page.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Surface existing local Claude/Codex projects with zero config" |
+| Category | feature |
+| Lifecycle | Background scanner started at server boot |
+| Replaceability | Replaceable provided projection shape preserved |
+
+## Purpose
+
+Walks Claude Code and Codex history directories on disk, identifies candidate projects, and emits a typed projection for the local-projects page. Non-goals: cloud lookup, repo cloning, persistent project state.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Data dir + history paths resolvable | c3-204 |
+| Input — paths | Per-tool history locations | c3-204 |
+| Internal state | Cached scan results with mtime | c3-214 |
+| Initialization | Initial scan on server boot | c3-214 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Users see existing work without manual setup | c3-117 |
+| Primary path | Scan → derive projection → push via read-models | c3-207 |
+| Alternate — rescan | Filesystem watch triggers incremental rescan | c3-214 |
+| Alternate — open | project.open command consumes projection rows | c3-208 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-local-first-data | ref | Reads only local history paths | must follow | No network calls |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| Discovery projection | OUT | Typed list of discovered projects | c3-207 | src/server/discovery.ts |
+| Rescan trigger | IN | Filesystem watch invokes rescan | c3-214 | src/server/discovery.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Scan stalls | Walker error not surfaced | Discovery list empty | bun run check against src/server/discovery.ts |
+| Stale entries | Watch handler skipped | UI lists deleted projects | Manual rescan smoke + grep src/server/discovery.ts for watch hookup |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/discovery.ts | c3-214 Contract | Scan detail | src/server/discovery.ts |
diff --git a/.c3/c3-2-server/c3-215-diff-store.md b/.c3/c3-2-server/c3-215-diff-store.md
new file mode 100644
index 000000000..f447e902b
--- /dev/null
+++ b/.c3/c3-2-server/c3-215-diff-store.md
@@ -0,0 +1,76 @@
+---
+id: c3-215
+c3-version: 4
+c3-seal: 5fc35c53fbad01f497a90133bbcd4620e19b7d5c82a2c7e121554e3862d48fcc
+title: diff-store
+type: component
+category: feature
+parent: c3-2
+goal: Maintain per-chat diff state for hydrated write_file/delete_file tool rendering and commit scaffolding.
+uses:
+ - ref-tool-hydration
+---
+
+# diff-store
+
+## Goal
+
+Maintain per-chat diff state for hydrated write_file/delete_file tool rendering and commit scaffolding.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Track per-chat diffs for hydrated tool rendering and commit synthesis" |
+| Category | feature |
+| Lifecycle | Per-chat diff state, rebuilt from event log on boot |
+| Replaceability | Replaceable provided diff snapshot contract preserved |
+
+## Purpose
+
+Maintains a per-chat map of file paths → cumulative diff state, hydrated from write_file/delete_file tool entries. Powers diff rendering and commit message synthesis. Non-goals: tool dispatch, transcript persistence — those live in c3-210.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Tool hydration available | c3-303 |
+| Input — paths | Resolves file paths within data dir | c3-204 |
+| Input — tool events | Reads write_file/delete_file entries | c3-205 |
+| Internal state | Per-chat diff map | c3-215 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | UI renders full file diffs without replaying tool history | c3-114 |
+| Primary path | Tool hydrated → diff updated → projection pushed | c3-207 |
+| Alternate — commit | Quick-response reads diff snapshot to draft commit | c3-213 |
+| Alternate — boot replay | Rebuilds diffs from event log | c3-206 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-tool-hydration | ref | Diff updates flow through hydration | must follow | One pipeline for tool entries |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| diffsForChat(chatId) | OUT | Returns typed diff snapshot | c3-207 | src/server/diff-store.ts |
+| Hydrate hook | IN | Tool hydration writes into store | c3-303 | src/server/diff-store.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Stale diff after delete | Delete handler skipped | UI shows file that no longer exists | bun run check against src/server/diff-store.ts |
+| Boot replay drift | Replay path differs from runtime path | Diffs differ after restart | Replay smoke from src/server/event-store.ts + boot snapshot diff |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/diff-store.ts | c3-215 Contract | Diff impl detail | src/server/diff-store.ts |
diff --git a/.c3/c3-2-server/c3-216-terminal-manager.md b/.c3/c3-2-server/c3-216-terminal-manager.md
new file mode 100644
index 000000000..1ad442e67
--- /dev/null
+++ b/.c3/c3-2-server/c3-216-terminal-manager.md
@@ -0,0 +1,78 @@
+---
+id: c3-216
+c3-version: 4
+c3-seal: d3905261b855926416d9ff4d67f3fbefa4954daee0fff24140dd4918cd100740
+title: terminal-manager
+type: component
+category: feature
+parent: c3-2
+goal: Spawn and manage PTY sessions for the embedded xterm terminal; stream I/O over WebSocket.
+uses:
+ - ref-ws-subscription
+---
+
+# terminal-manager
+
+## Goal
+
+Spawn and manage PTY sessions for the embedded xterm terminal; stream I/O over WebSocket.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Back the embedded xterm panel with managed PTY sessions" |
+| Category | feature |
+| Lifecycle | One PTY per active terminal panel; cleaned on disconnect |
+| Replaceability | Replaceable provided PTY stream contract preserved |
+
+## Purpose
+
+Spawns PTY child processes via process-utils, streams stdin/stdout over the WS socket, accepts resize/cancel commands, and tears down on disconnect. Non-goals: client-side rendering, scrollback persistence.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | OS PTY support available | c3-2 |
+| Input — process utils | Spawn + signal helpers | c3-209 |
+| Input — ws-router | Routes terminal envelopes | c3-208 |
+| Internal state | Map of session id → PTY handle | c3-216 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | User runs a shell next to the agent without leaving Kanna | c3-118 |
+| Primary path | Open → spawn PTY → stream bytes | c3-208 |
+| Alternate — resize | Resize envelope updates rows/cols | c3-118 |
+| Alternate — cancel | Disconnect triggers PTY kill | c3-209 |
+| Failure — spawn fail | Surface typed error envelope | c3-208 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-ws-subscription | ref | Terminal flows over single WS | must follow | No separate connection |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| openPty(opts) | OUT | Returns session id + I/O streams | c3-208 | src/server/terminal-manager.ts |
+| Resize handler | IN | Adjusts PTY rows/cols | c3-118 | src/server/terminal-manager.ts |
+| Close handler | IN | Kills PTY on disconnect | c3-209 | src/server/terminal-manager.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| PTY leak on disconnect | Cleanup hook skipped | Process count grows | bun run check against src/server/terminal-manager.ts |
+| Resize drift | Wrong rows/cols on update | Wrapping artifacts in client | Manual resize smoke pairing client src/client/app/terminalLayoutResize.ts and server src/server/terminal-manager.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/terminal-manager.ts | c3-216 Contract | PTY detail | src/server/terminal-manager.ts |
diff --git a/.c3/c3-2-server/c3-217-uploads.md b/.c3/c3-2-server/c3-217-uploads.md
new file mode 100644
index 000000000..af650c442
--- /dev/null
+++ b/.c3/c3-2-server/c3-217-uploads.md
@@ -0,0 +1,76 @@
+---
+id: c3-217
+c3-version: 4
+c3-seal: d320255e10f2473f36c54335b44810a1423cb085e5f10995f373688552730b61
+title: uploads
+type: component
+category: feature
+parent: c3-2
+goal: Accept file uploads (drag-drop attachments), store under data dir, emit events referencing the stored assets.
+uses:
+ - ref-local-first-data
+---
+
+# uploads
+
+## Goal
+
+Accept file uploads (drag-drop attachments), store under data dir, emit events referencing the stored assets.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Accept attachment uploads and persist them under the local data dir" |
+| Category | feature |
+| Lifecycle | HTTP route bound at server boot |
+| Replaceability | Replaceable provided upload endpoint + event contract preserved |
+
+## Purpose
+
+Hosts the upload HTTP endpoint, persists files under the data dir, emits typed upload events that downstream chat features reference. Non-goals: chat composition, agent-side file consumption — those happen elsewhere.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | HTTP server bound | c3-202 |
+| Input — paths | Uploads dir under data dir | c3-204 |
+| Input — event store | Writes upload events | c3-206 |
+| Initialization | Bound to /api/uploads on boot | c3-202 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | User attaches file → chat references stored asset | c3-115 |
+| Primary path | POST /api/uploads → write file → emit event | c3-206 |
+| Alternate — large file | Streamed write; event emitted on complete | c3-217 |
+| Failure — disk error | Surface 500; no event emitted | c3-202 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-local-first-data | ref | All uploads under ~/.kanna/data | must follow | No remote upload service |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| POST /api/uploads | IN | Accepts multipart form data | c3-202 | src/server/uploads.ts |
+| Upload event | OUT | Typed event referencing stored path | c3-206 | src/server/uploads.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Path traversal | Filename not sanitized | Files written outside data dir | bun run check against src/server/uploads.ts |
+| Orphaned files | Event write fails after disk write | Files exist without events | bun run check plus orphan-scan smoke against src/server/event-store.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/uploads.ts | c3-217 Contract | Upload detail | src/server/uploads.ts |
diff --git a/.c3/c3-2-server/c3-218-share.md b/.c3/c3-2-server/c3-218-share.md
new file mode 100644
index 000000000..fe4477289
--- /dev/null
+++ b/.c3/c3-2-server/c3-218-share.md
@@ -0,0 +1,77 @@
+---
+id: c3-218
+c3-version: 4
+c3-seal: 2aafc9716cc4797e44fc41ce68e55c3d4f547f40e04f33aac60a1799841d5c9d
+title: share
+type: component
+category: feature
+parent: c3-2
+goal: Create public trycloudflare URLs or named Cloudflare tunnels and emit terminal QR output.
+uses:
+ - ref-local-first-data
+---
+
+# share
+
+## Goal
+
+Create public trycloudflare URLs or named Cloudflare tunnels and emit terminal QR output.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Provide opt-in --share remote access via Cloudflare tunnels" |
+| Category | feature |
+| Lifecycle | Spawned only when --share is set; cleaned on shutdown |
+| Replaceability | Replaceable provided tunnel URL + QR output contract preserved |
+
+## Purpose
+
+Spawns `cloudflared` to obtain a public URL, prints the QR code in the terminal, and exposes the URL to the CLI banner. Non-goals: in-chat tunnel detection (lives in c3-223), per-port tunnels.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | --share flag set; cloudflared installed | c3-201 |
+| Input — process utils | Spawn + signal cloudflared | c3-209 |
+| Input — share types | Public URL + QR payload shape | c3-306 |
+| Internal state | One tunnel handle per server | c3-218 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Remote users open the local Kanna over a tunnel | c3-2 |
+| Primary path | Spawn cloudflared → parse URL → print QR | c3-201 |
+| Alternate — named tunnel | Reads named-tunnel config from settings | c3-222 |
+| Alternate — stop | Server shutdown signals tunnel kill | c3-209 |
+| Failure — cloudflared missing | Surface helpful error and continue local-only | c3-201 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-local-first-data | ref | Only runs on explicit opt-in | must follow | No tunnel without --share |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| startTunnel(opts) | OUT | Returns public URL + QR payload | c3-201 | src/server/share.ts |
+| Stop hook | IN | Tears down cloudflared on shutdown | c3-209 | src/server/share.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Tunnel leak on shutdown | Stop hook skipped | cloudflared lingers after exit | bun run check against src/server/share.ts |
+| URL parse regression | cloudflared output format change | Empty URL surfaced to UI | Manual cloudflared smoke + grep src/server/share.ts for URL parser |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/share.ts | c3-218 Contract | Tunnel impl detail | src/server/share.ts |
diff --git a/.c3/c3-2-server/c3-219-update-manager.md b/.c3/c3-2-server/c3-219-update-manager.md
new file mode 100644
index 000000000..875bbe775
--- /dev/null
+++ b/.c3/c3-2-server/c3-219-update-manager.md
@@ -0,0 +1,85 @@
+---
+id: c3-219
+c3-version: 4
+c3-seal: 8c5c0a5231ff26e90e11e8f72f16c0a9e9df827ee6db9454c08a48ef9e2f5e17
+title: update-manager
+type: component
+category: feature
+parent: c3-2
+goal: Detect newer kanna-code versions, expose update state to the UI, and reload the app via a swappable strategy.
+uses:
+ - ref-cqrs-read-models
+ - ref-strong-typing
+ - rule-strong-typing
+---
+
+# update-manager
+
+## Goal
+
+Detect newer kanna-code versions, expose update state to the UI, and reload the app via a swappable strategy.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Detect new versions and reload via swappable strategies (npm/pm2/git)" |
+| Category | feature |
+| Lifecycle | Singleton manager with timer-driven checks |
+| Replaceability | Replaceable provided checker/reloader interface contract preserved |
+
+## Purpose
+
+Hosts the version-check loop, exposes typed update state via a projection, and triggers reloads through a swappable `UpdateChecker` + `UpdateReloader` pair (npm/supervisor default, git/pm2 opt-in). Non-goals: HTTP serving, persistence, restart mechanics — those live in c3-220.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Strategy chosen via KANNA_RELOADER env | c3-219 |
+| Input — strategy module | Checker + reloader factories | c3-219 |
+| Input — read-models | Surfaces update projection | c3-207 |
+| Internal state | Timer + last-known version | c3-219 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Users see update banner and can trigger reload | c3-101 |
+| Primary path | Timer → checker → projection update | c3-207 |
+| Alternate — apply | User confirms → reloader installs + relaunches | c3-220 |
+| Alternate — pm2 strategy | git pull → build → pm2 reload | c3-219 |
+| Failure — install error | Surfaces structured UpdateInstallError | c3-208 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-cqrs-read-models | ref | Update state projected over WS | must follow | Push, never pull |
+| ref-strong-typing | ref | Typed checker/reloader interfaces | must follow | No any, no globals |
+| rule-strong-typing | rule | Compliance target added by c3x wire; refine what must be reviewed or complied with before handoff. | wired compliance target beats uncited local prose | Added by c3x wire for explicit compliance review. |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| Update projection | OUT | Typed update state for UI | c3-207 | src/server/update-manager.ts |
+| applyUpdate() | IN | Triggers checker → reloader chain | c3-220 | src/server/update-manager.ts |
+| Strategy factory | IN/OUT | Returns checker + reloader pair | c3-219 | src/server/update-strategy.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Hardcoded strategy regression | Manager imports specific reloader | Tests fail without env | bun run test src/server/update-manager.test.ts |
+| Reloader misfire | pm2 strategy edit | Service stuck after reload | bun run test src/server/update-strategy.test.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/update-manager.ts | c3-219 Contract | State machine detail | src/server/update-manager.ts |
+| src/server/update-strategy.ts | c3-219 Contract | Strategy implementations | src/server/update-strategy.ts |
+| src/server/update-manager.test.ts | c3-219 Contract | Manager test cases | src/server/update-manager.test.ts |
+| src/server/update-strategy.test.ts | c3-219 Contract | Strategy test cases | src/server/update-strategy.test.ts |
diff --git a/.c3/c3-2-server/c3-220-restart.md b/.c3/c3-2-server/c3-220-restart.md
new file mode 100644
index 000000000..5d49856b2
--- /dev/null
+++ b/.c3/c3-2-server/c3-220-restart.md
@@ -0,0 +1,76 @@
+---
+id: c3-220
+c3-version: 4
+c3-seal: d1566c622e6d0b144c2f49e67f3607768517da195f87ab208ef4e0739ca3c0bf
+title: restart
+type: component
+category: feature
+parent: c3-2
+goal: Implement in-place server restart (self-relaunch) after version updates or CLI flag changes.
+uses:
+ - ref-ws-subscription
+---
+
+# restart
+
+## Goal
+
+Implement in-place server restart (self-relaunch) after version updates or CLI flag changes.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Self-relaunch the server cleanly after upgrades and flag edits" |
+| Category | feature |
+| Lifecycle | Stateless command handler |
+| Replaceability | Replaceable provided restart command + exit-code contract preserved |
+
+## Purpose
+
+Coordinates the server-side relaunch: drains in-flight work, emits a restart-pending event, exits with the supervisor-recognized exit code so the CLI can spawn a fresh process. Non-goals: update detection, version selection — those live in c3-219.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | CLI is supervising the server | c3-201 |
+| Input — process utils | Drain + signal helpers | c3-209 |
+| Input — read-models | Emits restart-pending projection | c3-207 |
+| Initialization | Bound to ws-router on boot | c3-208 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Users upgrade without manual kill/restart | c3-2 |
+| Primary path | Command → drain → emit projection → exit 76 | c3-201 |
+| Alternate — flag change | Settings edit triggers restart | c3-222 |
+| Failure — drain timeout | Force exit after grace period | c3-209 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-ws-subscription | ref | Restart state pushed via WS | must follow | Clients observe state |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| restart command handler | IN | Triggers drain + exit-76 | c3-208 | src/server/restart.ts |
+| Restart-pending projection | OUT | Surfaces state to clients | c3-207 | src/server/restart.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Drain skipped | Timeout edit | In-flight requests dropped | bun run check against src/server/restart.ts |
+| Exit code drift | Code changed without CLI update | CLI fails to relaunch | Manual restart smoke pairing src/server/restart.ts and src/server/cli.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/restart.ts | c3-220 Contract | Drain detail | src/server/restart.ts |
diff --git a/.c3/c3-2-server/c3-221-external-open.md b/.c3/c3-2-server/c3-221-external-open.md
new file mode 100644
index 000000000..f559f102f
--- /dev/null
+++ b/.c3/c3-2-server/c3-221-external-open.md
@@ -0,0 +1,76 @@
+---
+id: c3-221
+c3-version: 4
+c3-seal: 1c6f78bcd3646c0041d0d4dc9257c0a71dd2679704aa5b287dbd75fab18fc390
+title: external-open
+type: component
+category: feature
+parent: c3-2
+goal: Open URLs, files, and VS Code / editor links in the user's external apps.
+uses:
+ - ref-local-first-data
+---
+
+# external-open
+
+## Goal
+
+Open URLs, files, and VS Code / editor links in the user's external apps.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Bridge in-app links to the host OS without leaking remote endpoints" |
+| Category | feature |
+| Lifecycle | Stateless command handler bound at boot |
+| Replaceability | Replaceable provided open command + URL allowlist contract preserved |
+
+## Purpose
+
+Routes UI requests to open URLs/files/VS Code links to the host OS via platform-specific helpers. Non-goals: file content access, remote URL forwarding.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Local-only execution context | c3-2 |
+| Input — paths | Validates file paths against data dir | c3-204 |
+| Internal state | Stateless | c3-221 |
+| Initialization | Registered on ws-router boot | c3-208 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Users follow links/files without leaving Kanna | c3-101 |
+| Primary path | UI command → spawn open helper | c3-208 |
+| Alternate — VS Code | Detects VS Code link → invokes code CLI | c3-221 |
+| Failure — disallowed scheme | Reject with typed error | c3-208 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-local-first-data | ref | Dispatches to local host only | must follow | No remote forwarding |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| open command handler | IN | Accepts typed URL/file payload | c3-208 | src/server/external-open.ts |
+| OS dispatcher | OUT | Spawns platform open binary | c3-209 | src/server/external-open.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Open injection | Payload validation skipped | Arbitrary command runs on host | bun run check against src/server/external-open.ts |
+| Platform fallback drift | New OS path not handled | Open fails silently | Manual macOS+Linux smoke against src/server/external-open.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/external-open.ts | c3-221 Contract | Helper detail | src/server/external-open.ts |
diff --git a/.c3/c3-2-server/c3-222-keybindings.md b/.c3/c3-2-server/c3-222-keybindings.md
new file mode 100644
index 000000000..0cafb8583
--- /dev/null
+++ b/.c3/c3-2-server/c3-222-keybindings.md
@@ -0,0 +1,76 @@
+---
+id: c3-222
+c3-version: 4
+c3-seal: 42302f9ff7f5b7f18334b430c4bea8cc86fb5953e5de3e7eb6c956536866dfc5
+title: keybindings
+type: component
+category: feature
+parent: c3-2
+goal: Persist per-user keybindings to the local data dir and sync them with the client.
+uses:
+ - ref-local-first-data
+---
+
+# keybindings
+
+## Goal
+
+Persist per-user keybindings to the local data dir and sync them with the client.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Persist user keybindings under the data dir and broadcast them" |
+| Category | feature |
+| Lifecycle | Singleton store with on-disk persistence |
+| Replaceability | Replaceable provided projection + setter contract preserved |
+
+## Purpose
+
+Stores keybinding overrides on disk, exposes a typed projection, and accepts `keybindings.set` commands from the client. Non-goals: rendering shortcuts, conflict resolution UI.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Data dir + paths resolved | c3-204 |
+| Input — paths | Settings file path | c3-204 |
+| Input — read-models | Pushes projection over WS | c3-207 |
+| Initialization | Loads keybindings on boot | c3-222 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Shortcut prefs survive restart and sync across tabs | c3-116 |
+| Primary path | UI sets binding → store writes → projection push | c3-208 |
+| Alternate — boot | Replays persisted file into projection | c3-222 |
+| Failure — write error | Surfaces typed error envelope | c3-208 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-local-first-data | ref | Persisted under ~/.kanna/data | must follow | No remote sync |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| Keybinding projection | OUT | Typed map of action → key combo | c3-207 | src/server/keybindings.ts |
+| keybindings.set handler | IN | Persists override and broadcasts | c3-208 | src/server/keybindings.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Lost prefs on schema bump | Field rename without migration | Settings reset after upgrade | bun run check against src/server/keybindings.ts |
+| Push regression | Setter skips broadcast | Other tabs out of sync | Manual two-tab smoke + grep src/server/ws-router.ts for keybinding push |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/keybindings.ts | c3-222 Contract | Persistence detail | src/server/keybindings.ts |
diff --git a/.c3/c3-2-server/c3-223-cloudflare-tunnel.md b/.c3/c3-2-server/c3-223-cloudflare-tunnel.md
new file mode 100644
index 000000000..4677dc802
--- /dev/null
+++ b/.c3/c3-2-server/c3-223-cloudflare-tunnel.md
@@ -0,0 +1,91 @@
+---
+id: c3-223
+c3-seal: bd7bf4e9e01fb47581b4e618625ba8a2c00fb7a0fdddee117a46b4c4476e83ba
+title: cloudflare-tunnel
+type: component
+category: feature
+parent: c3-2
+goal: 'Let the agent proactively propose Cloudflare quick tunnels for local ports via the Kanna `expose_port` MCP tool. Each call is gated by the Cloudflare Tunnel setting: `enabled` toggles the tool on/off, and `mode` (`always-ask` | `auto-expose`) decides whether the user confirms each proposal or it spawns automatically.'
+uses:
+ - ref-cqrs-read-models
+ - ref-strong-typing
+ - ref-ws-subscription
+ - rule-strong-typing
+---
+
+# cloudflare-tunnel
+
+## Goal
+
+Let the agent proactively propose Cloudflare quick tunnels for local ports via the Kanna `expose_port` MCP tool. Each call is gated by the Cloudflare Tunnel setting: `enabled` toggles the tool on/off, and `mode` (`always-ask` | `auto-expose`) decides whether the user confirms each proposal or it spawns automatically.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Expose agent-started local services through opt-in cloudflared quick tunnels via an agent-callable tool" |
+| Category | feature |
+| Lifecycle | Per-chat lifecycle; tunnels disposed on chat close or source exit |
+| Replaceability | Replaceable provided event union + WS command surface preserved |
+
+## Purpose
+
+Exposes a Kanna MCP tool (`mcp__kanna__expose_port`) that the agent calls proactively after starting a local server. Each call records a `tunnel_proposed` event the UI renders as an accept/dismiss card. Acceptance spawns `cloudflared --url` and projects state over WS. Non-goals: named tunnels, port allowlists, auto-installing cloudflared, bash-output port detection, automatic acceptance.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | cloudflareTunnel.enabled set true in settings | c3-222 |
+| Input — agent MCP tool | expose_port calls into TunnelGateway | c3-210 |
+| Input — process utils | Spawn cloudflared | c3-209 |
+| Input — settings store | Reads enabled + mode + cloudflaredPath | c3-222 |
+| Internal state | In-memory tunnel records (port, URL, lifecycle) | c3-223 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Users expose agent-started services without leaving Kanna | c3-2 |
+| Primary path | Agent calls expose_port → propose → user accepts → spawn tunnel | c3-208 |
+| Alternate — auto-expose | mode=auto-expose: propose + accept (source: "auto_setting") + spawn in one step; tool returns auto_exposed | c3-222 |
+| Alternate — already live | Duplicate proposal for same port returns already_live | c3-223 |
+| Alternate — disabled | Settings disabled returns disabled, no event | c3-222 |
+| Alternate — stop | User stop, source exit, chat close, server shutdown | c3-216 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-cqrs-read-models | ref | Tunnel state projected over WS | must follow | Push, never pull |
+| ref-ws-subscription | ref | Reuses single-WS broadcast pipeline | must follow | No new push channel |
+| ref-strong-typing | ref | Typed event union + injected interfaces | must follow | No any in gateway or spawner |
+| rule-strong-typing | rule | Compliance target added by c3x wire; refine what must be reviewed or complied with before handoff. | wired compliance target beats uncited local prose | Added by c3x wire for explicit compliance review. |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| Tunnel projection | OUT | Adds tunnels + liveTunnelId to chat snapshot | c3-207 | src/server/cloudflare-tunnel/read-model.ts |
+| tunnel.accept/tunnel.stop/tunnel.retry | IN | Typed WS commands | c3-208 | src/server/cloudflare-tunnel/gateway.ts |
+| expose_port MCP tool | IN | Agent-callable tool that calls TunnelGateway.proposeFromTool | c3-210 | src/server/kanna-mcp.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Always-on regression | Default flips to enabled | Tunnels spawn without user consent | bun test src/server/cloudflare-tunnel/e2e.test.ts |
+| Tunnel leak after chat close | Lifecycle hook skipped | cloudflared lingers post-session | Manual chat-close smoke + grep src/server/cloudflare-tunnel/lifecycle.ts for cleanup |
+| Silent auto-accept regression | accept triggered without user action | tunnel_accepted with non-"user" source | grep source: "user" in src/server/cloudflare-tunnel/gateway.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/cloudflare-tunnel/events.ts | c3-223 Contract | Event payload detail | src/server/cloudflare-tunnel/events.ts |
+| src/server/cloudflare-tunnel/read-model.ts | c3-223 Contract | Projection detail | src/server/cloudflare-tunnel/read-model.ts |
+| src/server/cloudflare-tunnel/tunnel-manager.ts | c3-223 Contract | Spawner detail | src/server/cloudflare-tunnel/tunnel-manager.ts |
+| src/server/cloudflare-tunnel/gateway.ts | c3-223 Contract | WS command + propose API | src/server/cloudflare-tunnel/gateway.ts |
+| src/server/kanna-mcp.ts | c3-223 Contract | expose_port MCP tool wiring | src/server/kanna-mcp.ts |
+| src/server/cloudflare-tunnel/e2e.test.ts | c3-223 Contract | Integration test | src/server/cloudflare-tunnel/e2e.test.ts |
diff --git a/.c3/c3-2-server/c3-224-oauth-token-pool.md b/.c3/c3-2-server/c3-224-oauth-token-pool.md
new file mode 100644
index 000000000..0ff582889
--- /dev/null
+++ b/.c3/c3-2-server/c3-224-oauth-token-pool.md
@@ -0,0 +1,98 @@
+---
+id: c3-224
+c3-seal: e4fa6708633805800ee0f94fe0a5b76f0520ff50a1ef935a26af876b22ada94b
+title: oauth-token-pool
+type: component
+category: feature
+parent: c3-2
+goal: 'Own the multi-token Anthropic OAuth pool: pick the right token per chat turn, prevent two chats from sharing one token, mark tokens limited/errored on detection, and surface a structured refusal when no token is usable.'
+uses:
+ - ref-local-first-data
+ - ref-strong-typing
+ - rule-colocated-bun-test
+ - rule-strong-typing
+---
+
+# oauth-token-pool
+
+## Goal
+
+Own the multi-token Anthropic OAuth pool: pick the right token per chat turn, prevent two chats from sharing one token, mark tokens limited/errored on detection, and surface a structured refusal when no token is usable.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Drive multi-provider agent turns through a single coordinator" — sub-system covers Claude OAuth quota across multiple subscription accounts |
+| Category | feature |
+| Lifecycle | Single instance constructed at server boot, injected into AgentCoordinator and quick-response |
+| Replaceability | Replaceable provided pickActive/pickEphemeral/markLimited/markError/describeUnavailability contract preserved |
+
+## Purpose
+
+Maintains an in-memory refcounted reservation index (Map>) plus the token state machine over the OAuth tokens persisted in app settings under `claudeAuth.tokens`. Selects an eligible token for each spawn via `pickActive(chatId)` with cap-aware spread-load semantics: per-token `maxConcurrent` (1–5) admits up to N concurrent chats on the same token, defaulting to `ClaudeAuthSettings.concurrencyDefault` when omitted. Owners are returned to the rotation layer in `agent.ts` via `takeStaleOwners(id)` before `markLimited` / `markError` so the layer can drive a deduped, staggered respawn for every shared owner (per `adr-20260522-oauth-token-share-cap`). Classifies why each token is unusable via `describeUnavailability(chatId)` for the refusal UI, naming every chat in the multi-owner case. Non-goals: the OAuth login flow itself (handled in settings UI), the launch-password gate (c3-203), persistent token storage (delegated to app-settings under c3-204 / c3-206 boundary), event sourcing — pool state is settings-backed by design because tokens are user secrets, not derivable from the event log.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | At least one OAuth token configured via settings UI; tokens shape OAuthTokenEntry | c3-116 |
+| Input — paths | Reads/writes claudeAuth.tokens via app-settings under ~/.kanna/data | c3-204 |
+| Internal state | reservedBy Map in-memory only; resets on restart | c3-2 |
+| Initialization | Constructed once at server boot with readTokens + writeStatus closures over app-settings | c3-202 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Claude turns run on the right subscription account; rate-limit on one token rotates to the next without user intervention | c3-210 |
+| Primary path | pickActive(chatId) → markUsed → spawn subprocess with CLAUDE_CODE_OAUTH_TOKEN | c3-210 |
+| Alternate — rotation | Rate-limit/auth-error detected → markLimited/markError drops reservation → pickActive picks next → token_rotation auto_continue event | c3-210 |
+| Failure — refusal | No usable token + pool non-empty → OAuthPoolUnavailableError is caught in startTurnForChat and persisted to the chat transcript as a kind:"result", subtype:"error" entry whose result body is the describeUnavailability output (chat references rendered as /chat/ markdown links). Replaces the prior throw → commandError banner path, which flickered when the next snapshot tick wiped commandError. | c3-114 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-local-first-data | ref | Pool reads/writes token secrets via app-settings under ~/.kanna/data only | must follow | Tokens never sent to any non-Anthropic surface |
+| ref-strong-typing | ref | OAuthTokenEntry, TokenStatusPatch, TokenUnavailability, EphemeralLease must stay precisely typed at the chat/agent boundary | must follow | No any in pool API |
+| rule-strong-typing | rule | Boundary types enforced lint-level | must follow | Patch payload is Partial>, not Record |
+| rule-colocated-bun-test | rule | oauth-token-pool.test.ts sits next to oauth-token-pool.ts | must follow | Existing test covers state machine + reservation + refusal |
+| adr-20260519-split-oauth-pool-from-auth | adr | Decision to extract this component from c3-203 | must follow | Establishes parent fit and code-map ownership |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| pickActive(reservedFor?) | OUT | Returns the LRU-eligible token for caller, binds reservation under refcounted Set. A token admits up to tokenCap(token) distinct chats (per-token maxConcurrent or ClaudeAuthSettings.concurrencyDefault, clamped to [1,5]). Re-entrant pickActive returns the caller's already-owned token; otherwise spreads load by owner-count ASC then LRU. Revives expired-limited tokens. Null when none eligible. | c3-210 | src/server/oauth-pool/oauth-token-pool.ts |
+| pickEphemeral() | OUT | Returns EphemeralLease under synthetic key so concurrent ephemeral callers (quick-response, subagent oneShot) do not collide. Counts against the picked token's cap; release() frees the slot. | c3-213 | src/server/oauth-pool/oauth-token-pool.ts |
+| markLimited(id, resetAt) | IN | Marks token limited until resetAt; clears the local owner set. Caller MUST invoke takeStaleOwners(id) BEFORE markLimited to drive coordinated rotation for all shared owners. | c3-210 | src/server/oauth-pool/oauth-token-pool.ts |
+| markError(id, message) | IN | Marks token errored (401); clears the local owner set. Same takeStaleOwners precondition as markLimited. | c3-210 | src/server/oauth-pool/oauth-token-pool.ts |
+| markUsed(id) / markDisabled / markEnabled | IN | Update lastUsedAt / status transitions. markDisabled clears the owner set. | c3-116 | src/server/oauth-pool/oauth-token-pool.ts |
+| takeStaleOwners(id) | OUT | Returns the current owner list for a token and clears it. Called by the rotation layer immediately before mark{Limited,Error} so it learns every chat sharing the now-dead token and can stagger their respawns. | c3-210 | src/server/oauth-pool/oauth-token-pool.ts |
+| release(reservedFor) | IN | Drops the caller from every token's owner set; deletes a set entry when empty. Refcounted across cap-shared tokens. | c3-210 | src/server/oauth-pool/oauth-token-pool.ts |
+| describeUnavailability(reservedFor?) | OUT | Returns per-token TokenUnavailability reasons. When at cap, reason "reserved" carries byChatIds: string[] (the full owner list) and ownedBySelf for self-aware UI. | c3-210 | src/server/oauth-pool/oauth-token-pool.ts |
+| hasAnyToken / hasUsable / allLimited / earliestUnlimit | OUT | Read-only probes for spawn-gate, schedule, and refusal logic. hasUsable honors the same cap-aware eligibility predicate as pickActive (TOCTOU closed). | c3-210 | src/server/oauth-pool/oauth-token-pool.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Cap exceeded by concurrent picks | Edits to isEligible / pickActive admit more than tokenCap(token) chats | New chat returns a token already at cap | bun test src/server/oauth-pool/oauth-token-pool.test.ts (cap-admit + cap-reject cases) |
+| TOCTOU between hasUsable preflight and pickActive | Eligibility predicate diverges between read-only and mutating paths under cap-aware logic | Refusal banner appears but pickActive would succeed (or vice versa) | bun test src/server/oauth-pool/oauth-token-pool.test.ts — hasUsable/pickActive parity tests |
+| Expired-limited token never revived | Revive logic skipped post-sort | Token remains limited past limitedUntil and never picked again | bun test src/server/oauth-pool/oauth-token-pool.test.ts — revive test |
+| Refcount leak — release frees a slot still in use by another chat | release(chatId) clobbers entire Set instead of removing the single chat | A shared token reports fewer owners than reality; cap admits over the limit | bun test src/server/oauth-pool/oauth-token-pool.test.ts — release refcount case |
+| Rotation herd when N owners simultaneously detect limit/401 on shared token | acquireRotationSlot in agent.ts does not dedupe within TOKEN_ROTATION_DEDUPE_WINDOW_MS or skips stagger application | All N respawns fire at once; PTY cold-boot stampede; second pickActive on same chatId double-claims | Existing bun test src/server/agent.oauth-rotation.test.ts + manual smoke (cap=2 on one token, force 401, observe staggered respawn) |
+| PTY smoke-probe race on cold cache | smoke-test.ts singleflight removed or keyed wrong | Two concurrent probes hit Anthropic on the same OAuth token at boot — 429 cascade | bun test src/server/claude-pty/smoke-test.test.ts — singleflight collapse case |
+| Refusal transcript entry loses chat reference | describeUnavailability output format changes, agent.ts buildPoolUnavailableMessage drift, or renderChatLinks regex drift | ResultMessage error body missing /chat/ links for the multi-owner case | bun test src/server/oauth-pool/ + src/client/components/messages/ResultMessage.test.tsx |
+| Reservation pinned across restart | reservedBy persisted (it must not be) | Restart cannot pick any token until manual fix | reservedBy lives in memory only — confirmed by private readonly reservedBy = new Map(...) in oauth-token-pool.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/oauth-pool/oauth-token-pool.ts | c3-224 Contract | Internal data structures may evolve as long as Contract surfaces hold | src/server/oauth-pool/oauth-token-pool.ts |
+| src/server/oauth-pool/oauth-token-pool.test.ts | c3-224 Change Safety | Test names may evolve; coverage of state machine + reservation + describeUnavailability must remain | src/server/oauth-pool/oauth-token-pool.test.ts |
+| agent.ts buildPoolUnavailableMessage + OAuthPoolUnavailableError | c3-224 Contract (describeUnavailability surface) | Wording may evolve; markdown chat-link format title is fixed (UI parser); error class identity is used by startTurnForChat catch to switch on refusal vs other failures | src/server/agent.ts buildPoolUnavailableMessage, OAuthPoolUnavailableError |
+| renderChatLinks helper + ResultMessage error body | c3-224 Contract (describeUnavailability surface) | Regex may evolve; must keep accepting /chat/ link form | src/client/components/messages/renderChatLinks.tsx, src/client/components/messages/ResultMessage.tsx, src/client/app/ChatPage/ChatTranscriptViewport.tsx |
diff --git a/.c3/c3-2-server/c3-225-claude-pty-driver.md b/.c3/c3-2-server/c3-225-claude-pty-driver.md
new file mode 100644
index 000000000..f38555b27
--- /dev/null
+++ b/.c3/c3-2-server/c3-225-claude-pty-driver.md
@@ -0,0 +1,112 @@
+---
+id: c3-225
+c3-seal: 2b67d72bd3d830ab385c32cec968efeb9bda5609562e0f99a38cf1b5c8e85db9
+title: claude-pty-driver
+type: component
+category: feature
+parent: c3-2
+goal: Run the `claude` CLI under a pseudo-terminal, tail the on-disk transcript JSONL it writes under `~/.claude/projects//.jsonl` as the SOLE event source, and deliver prompts via TUI input for interactive sessions or via `notifications/claude/channel` MCP push for one-shot subagent sessions. Preserves Pro/Max subscription billing.
+uses:
+ - ref-colocated-bun-test
+ - ref-event-sourcing
+ - ref-provider-adapter
+ - rule-colocated-bun-test
+ - rule-strong-typing
+---
+
+# claude-pty-driver
+
+## Goal
+
+Run the `claude` CLI under a pseudo-terminal, tail the on-disk transcript JSONL it writes under `~/.claude/projects//.jsonl` as the SOLE event source, and deliver prompts via TUI input for interactive sessions or via `notifications/claude/channel` MCP push for one-shot subagent sessions. Preserves Pro/Max subscription billing.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | Orchestrate provider-agnostic agent turns — supplies the Claude PTY transport the orchestrator drives |
+| Category | feature |
+| Lifecycle | Per-spawn subprocess holder; one PTY child + one transcript-watch parser per session |
+| Replaceability | Replaceable while the HarnessEvent stream contract, the prompt-delivery surfaces (TUI input + channel push), and the kanna-mcp channel capability declaration are preserved |
+
+## Purpose
+
+Owns the Claude CLI PTY transport: spawns the `claude` subprocess (after the smoke-test gate), watches the on-disk transcript JSONL the CLI writes at `~/.claude/projects//.jsonl` via `tui-source.adapter.ts:startTranscriptStream` (`fs.watch` by default, polling under `KANNA_PTY_TRANSCRIPT_WATCH=poll`), feeds each line to `createJsonlEventParser` → normalized HarnessEvents. Prompt delivery has two surfaces: interactive sessions receive prompts via `tui-control.sendUserPrompt` bracketed paste; one-shot subagent sessions receive the initial prompt via a single `notifications/claude/channel` MCP push from the kanna-mcp loopback HTTP server (c3-226), gated by `KANNA_PTY_CHANNEL_DELIVERY` and a `channelClientReady` signal. Non-goals: turn orchestration and provider selection (c3-210), provider/model metadata normalization (c3-212), Codex transport (c3-211). Stdout is read into a bounded 256 KB ring buffer only for trust/dev-channels dialog detection and silent-exit failure synthesis; it is NEVER the event source. `jsonl-path.ts` (`computeJsonlPath`/`encodeCwd`) is LIVE production code with multiple callers (driver, tui-source.adapter, smoke-test).
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | OAuth-pool token injected as CLAUDE_CODE_OAUTH_TOKEN; ANTHROPIC_API_KEY stripped from child env; smoke-test gate passes | c3-210 |
+| Input — transcript JSONL | tui-source.adapter.ts startTranscriptStream watches ~/.claude/projects// for the session file, then tails it via fs.watch or polling; each line fed to createJsonlEventParser → HarnessEvents | N.A - internal transcript-watch module within this component (tui-source.adapter.ts, jsonl-to-event.ts) |
+| Input — output ring | 256 KB bounded ring buffer captures PTY stdout for trust/dev-channels dialog detection and silent-exit failure synthesis ONLY; not the event source | N.A - internal output-ring buffer (output-ring.ts) |
+| Prompt — interactive | tui-control.sendUserPrompt sends bracketed paste then \r to the PTY for normal chat sessions | N.A - internal tui-control module |
+| Prompt — channel push | One-shot subagent spawns: kanna-mcp pushes notifications/claude/channel with the full prompt content after channelClientReady resolves; NO TUI typing | c3-226 |
+| Shared dep — Kanna MCP | In-process loopback HTTP MCP server attached per spawn via --mcp-config; declares experimental['claude/channel'+'/permission'] and provides pushChannelPrompt + channelClientReady | c3-226 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | A subscription-billed Claude turn streams to the UI with SDK-equivalent event sequencing | c3-210 |
+| Primary path | spawn PTY → dismiss trust + dev-channels dialogs → deliver prompt (paste for chat, channel push for one-shot) → tail transcript JSONL → emit HarnessEvents upstream | c3-209 |
+| Alternate — oneShot | Subagent one-turn sessions: prompt via channel push, REPL closed after first result line via /exit + SIGTERM/SIGKILL escalation | c3-210 |
+| Alternate — silent crash | Exit with no result: synthesize {kind:result,subtype:error} from the output ring tail | N.A - internal failure synthesis within this component |
+| Failure — OAuth/auth error | Synthesized error result drives the same rotation/retry path as the SDK driver | c3-210 |
+| Failure — channel not ready | One-shot with KANNA_PTY_CHANNEL_DELIVERY=enabled: if channelClientReady does not resolve within KANNA_PTY_CHANNEL_READY_TIMEOUT_MS the spawn throws fail-fast and closes the transcript stream — no silent paste fallback | c3-226 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-provider-adapter | ref | Provider-agnostic turn/event shape and prompt-delivery surfaces | must follow | Claude PTY transport adapter, parallel to c3-211 |
+| ref-event-sourcing | ref | Driver emits events; log-before-broadcast invariant | must follow | Ordering owned upstream by c3-210/c3-206 |
+| ref-colocated-bun-test | ref | Tests sit beside sources under src/server/claude-pty/ | must follow | driver.test.ts, jsonl-to-event.test.ts, tui-control.test.ts, pty-cli-args.test.ts |
+| rule-colocated-bun-test | rule | Test colocation enforced for this subtree | wired compliance target beats uncited local prose | Added by c3x wire |
+| rule-strong-typing | rule | No internal untyped shapes; only documented external Bun boundary cast allowed | wired compliance target | as unknown as ReadableStream at subprocess boundary only |
+| adr-20260519-pty-driver-stdout-event-source | adr | Originating charter; superseded by the transcript-watch ADR below | superseded | status=superseded — kept for history |
+| adr-20260529-pty-transcript-watch-event-source | adr | Authoritative current event-source contract: on-disk transcript JSONL via tui-source.adapter.ts | governs this component | Implemented |
+| adr-20260529-pty-oneshot-channel-push-prompt-delivery | adr | Authorizes channel-push prompt delivery for one-shot subagent spawns + the dev-channels CLI flag + dialog dismissal + fail-fast on channel timeout | governs this component | Implemented |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| Start PTY session | IN | Spawn claude child for a chat/subagent turn; sets per-spawn runtimeDir, mcp-config, smoke-test gate | c3-210 | src/server/claude-pty/driver.ts |
+| HarnessEvent stream | OUT | Normalized events parsed from the on-disk transcript JSONL — the SOLE event source; stdout is never the event source | c3-210 | src/server/claude-pty/tui-source.adapter.ts, src/server/claude-pty/driver.ts |
+| TUI prompt input | IN | Interactive sessions: prompt written to PTY via tui-control.sendUserPrompt bracketed paste + \r; REPL closed on oneShot/close | c3-210 | src/server/claude-pty/tui-control.ts, src/server/claude-pty/driver.ts |
+| Channel prompt push | IN | One-shot subagent sessions: full prompt delivered via kanna-mcp notifications/claude/channel push after channelClientReady resolves; bracketed paste path is bypassed for one-shot. Gated by KANNA_PTY_CHANNEL_DELIVERY (default enabled); KANNA_PTY_CHANNEL_READY_TIMEOUT_MS bounds readiness wait; fail-fast (no paste fallback) on timeout | c3-226 | src/server/claude-pty/driver.ts, src/server/kanna-mcp-http.ts, src/server/claude-pty/channel-notification.ts |
+| Keep-alive multi-turn | IN/OUT | When StartClaudeSessionPtyArgs.keepAlive is set, the first result does NOT trigger oneShotClose so the REPL stays open; the handle exposes pushChannelPrompt(text) to deliver subsequent turns via the same channel push (after a short REPL idle beat). buildChannelPromptFraming(keepAlive) appends plural channel framing so the model expects multiple channel messages over the session. Drives c3-210 LiveTurnSource turns | c3-210 | src/server/claude-pty/driver.ts |
+| Dev-channels CLI flag | OUT | One-shot spawns append --dangerously-load-development-channels server:kanna so the channel handler registers in the spawned claude | c3-226 | src/server/claude-pty/pty-cli-args.ts |
+| Live-status registry upserts | OUT | Driver upserts PtyInstanceState (phase, pid, model, account, rssBytes, rssPeakBytes, cpuPercent, cpuPeakPercent) into PtyInstanceRegistry; ws-router fans deltas to subscribed clients. Resource sampler ticks every 2 s (configurable via memorySamplerIntervalMs) using sampleProcessTreeUsage which shells one ps -A -o pid=,ppid=,rss=,pcpu= per tick and sums RSS + CPU% across child + descendants; interval cleared on cleanupResources. Teardown is pid-scoped: cleanupResources captures the handle's own pid and uses markExitedIfCurrent(chatId, pid, …) + on-disk ptyRegistry.unregister(pid) so a stale re-spawn handle (same chatId+sessionId via --resume, older pid) cannot clobber the live entry. Orphan reap kills by process SUBTREE (killProcessTree), never by process group — the PTY child is not guaranteed to be its own pgid leader under a supervisor like PM2 | c3-102 | src/server/claude-pty/pty-instance-registry.ts, src/server/claude-pty/pid-registry.adapter.ts, src/server/claude-pty/pty-memory-sampler.adapter.ts, src/server/claude-pty/driver.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Event source drifts back to stdout pump | Edit re-adds pumpStdout or reads proc.stdout for events | grep -rn 'pumpStdout\|proc.stdout' src/server/claude-pty non-test count above zero | bun test src/server/claude-pty/driver.test.ts |
+| One-shot prompt typed instead of channel-pushed | Edit calls sendUserPrompt on the oneShot path when KANNA_PTY_CHANNEL_DELIVERY is enabled | grep for sendUserPrompt inside the oneShot branch of driver.ts | bun test src/server/claude-pty/driver.test.ts |
+| Channel push fires more than once per one-shot spawn | Edit removes single-push guard or retries on apparent stall | grep for repeated calls or loop around pushChannelPrompt | bun test src/server/claude-pty/driver.test.ts (single-push assertion) |
+| Channel-ready timeout silently falls back to paste | Edit re-introduces paste fallback after channelClientReady timeout | grep for sendUserPrompt in the fail-fast cleanup block of driver.ts | bun test src/server/claude-pty/driver.test.ts (fail-fast throw assertion) |
+| Dev-channels dialog dismissal regresses or signals premature ready | Edit removes postDismissOffset reference guard from waitForTuiReadyDismissingDialogs | grep for postDismissOffset usage in tui-control.ts | bun test src/server/claude-pty/tui-control.test.ts |
+| Subscription-billing invariant broken | ANTHROPIC_API_KEY not stripped from child env | buildPtyEnv auth test fails | bun test src/server/claude-pty/auth.test.ts |
+| Stale re-spawn handle clobbers the live PTY registry entry | Teardown calls unconditional upsert(chatId,exited)/unregister(sessionId) instead of the pid-scoped guards — a chat re-spawns via --resume so old+new handles share chatId+sessionId | grep for markExitedIfCurrent in cleanupResources and unregister(ownPid in driver.ts; absence = regression | bun test src/server/claude-pty/pty-instance-registry.test.ts src/server/claude-pty/pid-registry.test.ts |
+| Reap no-ops on a non-leader pid or signals the whole app group | Edit reintroduces process.kill(-pid)/killPgroup instead of killProcessTree (PTY child inherits the server pgid under PM2) | grep -rn 'process.kill(-' src/server/claude-pty must be absent outside killProcessTree | bun test src/server/claude-pty/pid-registry.test.ts (non-leader subtree kill) |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/claude-pty/driver.ts | Contract | Spawn/dismiss/oneShot/channel-push wiring detail | src/server/claude-pty/driver.ts |
+| src/server/claude-pty/tui-source.adapter.ts | Contract (HarnessEvent stream) | fs.watch vs polling, file-discovery loop | src/server/claude-pty/tui-source.adapter.ts |
+| src/server/claude-pty/jsonl-to-event.ts | Contract | Parser state-machine detail | src/server/claude-pty/jsonl-to-event.ts |
+| src/server/claude-pty/jsonl-path.ts | Contract | encodeCwd + computeJsonlPath path derivation | src/server/claude-pty/jsonl-path.ts |
+| src/server/claude-pty/channel-notification.ts | Contract (Channel prompt push) | Payload shape; pure builder | src/server/claude-pty/channel-notification.ts |
+| src/server/claude-pty/tui-control.ts | Contract (TUI prompt input + dialog dismissal) | NBSP marker matching, postDismissOffset reference guard | src/server/claude-pty/tui-control.ts |
+| src/server/claude-pty/pty-cli-args.ts | Contract (Dev-channels CLI flag) | Flag assembly | src/server/claude-pty/pty-cli-args.ts |
+| src/server/claude-pty/pty-memory-sampler.adapter.ts | Contract | ps invocation + parse + tree-RSS sum; ports-and-adapters seal exemption | src/server/claude-pty/pty-memory-sampler.adapter.ts |
+| src/server/claude-pty/driver.test.ts | Change Safety | Test cases per surface | src/server/claude-pty/driver.test.ts |
+| src/server/claude-pty/tui-control.test.ts | Change Safety | Dialog dismissal + reference-guard coverage | src/server/claude-pty/tui-control.test.ts |
+| src/server/claude-pty/pty-cli-args.test.ts | Change Safety | Channel flag presence/absence per session kind | src/server/claude-pty/pty-cli-args.test.ts |
+| src/server/claude-pty/pty-memory-sampler.adapter.test.ts | Change Safety | Parser + tree-collect + integration coverage for sampler | src/server/claude-pty/pty-memory-sampler.adapter.test.ts |
diff --git a/.c3/c3-2-server/c3-226-kanna-mcp-host.md b/.c3/c3-2-server/c3-226-kanna-mcp-host.md
new file mode 100644
index 000000000..56f69fda3
--- /dev/null
+++ b/.c3/c3-2-server/c3-226-kanna-mcp-host.md
@@ -0,0 +1,123 @@
+---
+id: c3-226
+c3-seal: d7870fc861e8bb32b6a3419e83ab7589feabb155cc50386e131eb0b45b732c2d
+title: kanna-mcp-host
+type: component
+category: feature
+parent: c3-2
+goal: |-
+ Host the in-process loopback MCP server that the Claude driver attaches
+ via `--mcp-config`, expose Kanna-side built-in shims that route through
+ the durable approval protocol, and enforce read/write path-deny rules
+ before any tool side-effect runs.
+uses:
+ - ref-local-first-data
+ - ref-strong-typing
+ - ref-tool-hydration
+ - rule-colocated-bun-test
+ - rule-strong-typing
+---
+
+# kanna-mcp-host
+
+## Goal
+
+Host the in-process loopback MCP server that the Claude driver attaches
+via `--mcp-config`, expose Kanna-side built-in shims that route through
+the durable approval protocol, and enforce read/write path-deny rules
+before any tool side-effect runs.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Drive multi-provider agent turns through a single coordinator" — supplies the MCP host every Claude session attaches to |
+| Category | feature |
+| Lifecycle | One MCP server bound per server process; per-spawn --mcp-config injected by the agent coordinator |
+| Replaceability | Replaceable while the tool-call envelope, durable approval protocol, and mcp__kanna__* tool surface are preserved |
+
+## Purpose
+
+Owns the Kanna MCP host runtime: builds the in-process HTTP MCP server
+that publishes `mcp__kanna__*` tools, registers the durable approval
+protocol used by `ask_user_question`, `exit_plan_mode`, and
+`delegate_subagent`, and enforces read/write path-deny on the eight
+built-in shims (`read`, `glob`, `grep`, `bash`, `edit`, `write`,
+`webfetch`, `websearch`) gated by `KANNA_MCP_TOOL_CALLBACKS`. Non-goals:
+turn orchestration (c3-210), Claude PTY transport (c3-225), Codex App
+Server (c3-211), provider/model normalization (c3-212). The host never
+performs the actual filesystem or network side-effect itself; each shim
+delegates to the same node primitives the native tools would call after
+the approval protocol clears.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Spawn-time --mcp-config written to point Claude at the loopback HTTP MCP server; auth/session token gated by c3-203 | c3-210 |
+| Input — tool call | Claude (or Codex) issues an mcp__kanna__* tool call through MCP transport | c3-210 |
+| State — pending request | Each interactive call (ask/exit-plan/delegate) registers a durable pending record in tool-callback.ts; survives restart and replays on reconnect as pending_tool_request | c3-205 |
+| Live broadcast | createToolCallbackService fires onStateChange(chatId) after every persisted state change; server.ts wires this to router.scheduleChatStateBroadcast so the UI sees the new pending the moment the model emits the call (no longer waiting for an unrelated event to flush the read model) | c3-208 |
+| Shared dep — event store | Pending and resolved tool requests append events to the JSONL log | c3-206 |
+| Shared dep — paths-config | readPathDeny / writePathDeny resolved against ~/.kanna/data and project roots | c3-204 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Kanna-owned tool implementations run with the same approval UX whether the model used SDK canUseTool or the native built-in shims | c3-210 |
+| Primary path | Tool call → shim → path-deny check → durable approval (if interactive) → execute → return MCP result | c3-205 |
+| Alternate — feature flag off | Default KANNA_MCP_TOOL_CALLBACKS=0: native built-ins handle reads/writes; only ask_user_question, exit_plan_mode, delegate_subagent shims stay active under PTY (issue #215) | N.A - documented in CLAUDE.md "Tool Callback Feature Flag" |
+| Alternate — websearch | Stub: always returns isError: true — external web search integration out of scope | N.A - documented stub in CLAUDE.md |
+| Failure — chat cancelled / chat deleted | ws-router's chat.cancel and chat.delete handlers call cancelAllForChat(chatId, reason), resolving every open ask-style record as {kind:"deny", canceled:}. Replaces the prior session-close cascade which mis-fired on transparent rotation/sweep respawns. | c3-208 |
+| Failure — server restart | recoverOnStartup() fail-closes every still-pending record as session_closed so no MCP turn hangs forever across reboots; no wall-clock timeout fires while the server is running | c3-206 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-tool-hydration | ref | MCP tool envelopes still normalize through src/shared/tools.ts before the UI renders them | must follow | shims share the same hydration path as native tool calls |
+| ref-local-first-data | ref | Pending records persist under ~/.kanna/data; HTTP MCP only binds localhost | must follow | path-deny defaults block leaving project root |
+| ref-strong-typing | ref | Every shim arg/result has a named type at the MCP boundary | must follow | rule-strong-typing applies |
+| rule-strong-typing | rule | No any/unknown at the MCP envelope or path-deny surface | wired compliance target | enforces typed inputs across the host |
+| rule-colocated-bun-test | rule | Every shim has a colocated .test.ts next to its source | wired compliance target | applies to all of kanna-mcp-tools/** |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| mcp__kanna__* tool surface | OUT | Set of MCP tools published to Claude/Codex; envelope matches MCP spec; KANNA_MCP_TOOL_CALLBACKS flag selects which shims register | c3-210 | src/server/kanna-mcp.ts |
+| Loopback HTTP MCP server | IN | HTTP endpoint Claude PTY/SDK attaches via --mcp-config; bound to 127.0.0.1 only | c3-202 | src/server/kanna-mcp-http.ts |
+| Durable approval protocol | IN/OUT | Register pending request, push to UI, await resolution; pendings survive process restart and replay as pending_tool_request entries. Surface methods: submit, answer, cancel, cancelAllForChat, recoverOnStartup. createToolCallbackService accepts an onStateChange(chatId) hook fired after every persisted state change inside submit's persistPut and answer/cancel/cancelAllForChat's persistResolve; server.ts wires this to router.scheduleChatStateBroadcast so the UI receives pending_tool_request the same tick the model emits the tool_use. Pendings resolve through three explicit paths: user answer, ws-router cancelAllForChat triggered by chat.cancel and chat.delete, or recoverOnStartup fail-close on server boot. | c3-208 | src/server/tool-callback.ts |
+| Path deny enforcement | IN | readPathDeny + writePathDeny reject paths outside allowed roots before shim execution | c3-204 | src/server/permission-gate.ts |
+| Channel notification push | OUT | McpServer declares experimental capabilities claude/channel + claude/channel/permission; exposes pushChannelPrompt(content) which sends a single notifications/claude/channel notification, and channelClientReady which resolves when the spawned claude has acknowledged channel registration. Used by one-shot subagent PTY spawns (c3-225) to deliver the initial prompt without typing it into the TUI | c3-225 | src/server/kanna-mcp-http.ts, src/server/claude-pty/channel-notification.ts |
+| delegate_subagent keep_alive param | OUT | keep_alive boolean on delegate_subagent. When true and the target is a Claude subagent, the run stays live and the reply text carries the live run_id; non-claude targets return isError. Routes to c3-210 delegateRun with keepAlive | c3-210 | src/server/kanna-mcp.ts, src/server/kanna-mcp-tools/delegate-subagent.ts |
+| send_subagent_message tool | OUT | Takes run_id plus prompt, drives one follow-up turn into a live keep-alive session, blocks until that turn finishes, returns the subagent reply text or isError NO_LIVE_SESSION. Routes to c3-210 sendToLiveRun | c3-210 | src/server/kanna-mcp.ts |
+| close_subagent tool | OUT | Takes run_id, closes a live keep-alive session and frees its process. Routes to c3-210 closeLiveRun | c3-210 | src/server/kanna-mcp.ts |
+| schedule_wakeup tool | OUT | Takes delay_seconds plus prompt, arms a Kanna-owned agent_wakeup schedule via c3-210 scheduleAgentWakeup, returns the schedule_id or isError when the per-chat runaway cap is reached. Registered only when a scheduleWakeup callback is supplied, mirroring the delegate_subagent guard. Replaces the native ScheduleWakeup the PTY driver disallows | c3-227 | src/server/kanna-mcp.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Path-deny bypass | Edit removes the gate from a shim path | Add a deny-rule test; grep for direct fs writes inside shims | bun test src/server/permission-gate.test.ts |
+| Durable approval drift | Edit forgets to persist a new interactive tool kind | tool-callback.test.ts asserts every interactive shim registers | bun test src/server/tool-callback.test.ts |
+| Loopback bind escapes | Code change opens the MCP HTTP server beyond 127.0.0.1 | http-ws-server test asserts bind host | bun test src/server/kanna-mcp-http.test.ts |
+| Native built-in re-enabled under PTY for AskUserQuestion/ExitPlanMode | --disallowedTools list misses entries | grep for AskUserQuestion in PTY spawn args | bun test src/server/claude-pty/driver.test.ts |
+| Channel capability declaration dropped | Edit removes experimental['claude/channel'] from McpServer options | grep for claude/channel in kanna-mcp-http.ts | bun test src/server/kanna-mcp-http.test.ts |
+| pushChannelPrompt called more than once per one-shot spawn | Driver wiring re-pushes on apparent stall | grep for pushChannelPrompt callers; single-call assertion in driver.test.ts | bun test src/server/claude-pty/driver.test.ts |
+| Live broadcast missing on new pending — UI never shows the prompt | createToolCallbackService called without onStateChange, or persistPut/persistResolve refactored to skip the notify(chatId) hook | tool-callback.test.ts asserts 6 events for submit/answer/cancel/cancelAllForChat sequence, and zero events for auto-allow/auto-deny | bun test src/server/tool-callback.test.ts |
+| Session-close cancel cascade re-introduced — denies asks mid-rotation | A future edit re-adds args.toolCallback.cancelAllForSession in makeClaudeSessionHandle.close, or any equivalent close()-side cancel call | grep for cancelAllForSession in src/ returns hits, or oauth-rotation tests show pendings denied mid-turn | bun test src/server/agent.test.ts (oauth-rotation suite); grep -r cancelAllForSession src/ |
+| Pending leak — model crashes mid-tool_use with no cancel path | recoverOnStartup not run on boot (initToolCallbackOnBoot replaced with createToolCallbackService) | boot.test.ts asserts recoverOnStartup is called before service is returned | bun test src/server/boot.test.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/kanna-mcp.ts | Contract (mcp__kanna__* tool surface) | Tool registration order | src/server/kanna-mcp.ts |
+| src/server/kanna-mcp-http.ts | Contract (loopback HTTP MCP server) | HTTP framing detail | src/server/kanna-mcp-http.ts |
+| src/server/kanna-mcp-tools/**/*.ts | Contract (each shim implements one MCP tool) | Per-tool argument shape | src/server/kanna-mcp-tools/ |
+| src/server/tool-callback.ts | Contract (durable approval protocol) | Persistence backend detail | src/server/tool-callback.ts |
+| src/server/permission-gate.ts | Contract (path deny enforcement) | Allow-list detail | src/server/permission-gate.ts |
+| src/server/claude-pty/channel-notification.ts | Contract (channel notification push) | Payload builder shape | src/server/claude-pty/channel-notification.ts |
diff --git a/.c3/c3-2-server/c3-227-auto-continue.md b/.c3/c3-2-server/c3-227-auto-continue.md
new file mode 100644
index 000000000..c55bb3b3a
--- /dev/null
+++ b/.c3/c3-2-server/c3-227-auto-continue.md
@@ -0,0 +1,112 @@
+---
+id: c3-227
+c3-seal: cf8bdf5d73b8a9a93354021d117d7e9596e2b522db9e55e89f1c0eac0829397e
+title: auto-continue
+type: component
+category: feature
+parent: c3-2
+goal: |-
+ Detect provider rate-limit and auth-error endings on a Kanna chat,
+ schedule a retry at the right wake-up moment, replay the queued user
+ prompt automatically, and expose the current schedule as a derived view
+ the UI can render.
+uses:
+ - ref-cqrs-read-models
+ - ref-event-sourcing
+ - ref-strong-typing
+ - rule-colocated-bun-test
+ - rule-strong-typing
+---
+
+# auto-continue
+
+## Goal
+
+Detect provider rate-limit and auth-error endings on a Kanna chat,
+schedule a retry at the right wake-up moment, replay the queued user
+prompt automatically, and expose the current schedule as a derived view
+the UI can render.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Drive multi-provider agent turns" — adds the unattended retry layer above the agent coordinator |
+| Category | feature |
+| Lifecycle | Long-lived background scheduler holding pending wake-up timers per chat |
+| Replaceability | Replaceable while the auto_continue_* event shapes and the schedule read-model contract are preserved |
+
+## Purpose
+
+Owns the Kanna auto-continue feature: classifies a turn-ending `result`
+event as `rate-limited` or `auth-error`, picks a retry time (provider
+hint when present, fallback backoff otherwise), records an
+`auto_continue_scheduled` event, sleeps until the wake-up, then replays
+the queued user prompt by triggering a new turn on the same chat.
+Non-goals: turn orchestration itself (c3-210), OAuth token rotation
+(c3-224), Claude/Codex transport (c3-225/c3-211). The scheduler never
+mutates account state — token rotation stays in c3-224 — and never
+writes a UI envelope directly; it pushes events that read-models
+subscribe to and the WS router fans out.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | A Claude or Codex turn emits a result event with subtype: error and a recognised error body | c3-205 |
+| Input — limit detection | limit-detector.ts parses Anthropic rate-limit signatures + retry-after hints | N.A - internal classifier within this component |
+| Input — auth-error detection | auth-error-detector.ts matches "Please run /login", 401, OAuth refusal payloads | N.A - internal classifier within this component |
+| State — schedule | schedule-manager.ts keeps an in-memory map of chatId → wakeAt; persistence is via the event log | c3-206 |
+| Shared dep — events | Records auto_continue_scheduled / auto_continue_triggered / auto_continue_cancelled events | c3-205 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | A chat that hit a soft failure resumes itself when the provider's rate window reopens, without user intervention | c3-210 |
+| Primary path | result(error) → classifier → schedule → wake → start new turn with the queued prompt | c3-210 |
+| Alternate — auth-error | Trigger OAuth-pool rotation through c3-224 and reschedule once a healthy token exists | c3-224 |
+| Alternate — user cancels | UI emits auto_continue_cancel; scheduler appends auto_continue_cancelled and clears the timer | c3-208 |
+| Failure — unknown error shape | Classifier returns null; no schedule recorded; original result propagates unchanged | N.A - internal fallback path |
+| Failure — server restart mid-wait | Event-store replay re-creates the pending schedule on boot | c3-206 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-event-sourcing | ref | All schedule mutations land as events first | must follow | the schedule is replayable from JSONL |
+| ref-cqrs-read-models | ref | UI consumes the derived schedule view, never the event log directly | must follow | read-model.ts projects the events |
+| ref-strong-typing | ref | Detector outputs and schedule records are named types crossing WS + JSONL boundaries | must follow | wired rule below enforces |
+| rule-strong-typing | rule | No any/unknown on detector returns or schedule envelopes | wired compliance target | typed at module boundary |
+| rule-colocated-bun-test | rule | Every detector and schedule module sits next to its .test.ts | wired compliance target | enforced for auto-continue/** |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| Auto-continue events | OUT | auto_continue_scheduled, auto_continue_triggered, auto_continue_cancelled typed events on the JSONL log | c3-205 | src/server/auto-continue/events.ts |
+| Schedule read-model | OUT | {chatId, wakeAt, reason} snapshots projected from the event log | c3-207 | src/server/auto-continue/read-model.ts |
+| Trigger new turn | OUT | On wake, call the agent coordinator's "start turn"; replay the schedule prompt for agent wakes or the queued user prompt for provider-failure resume | c3-210 | src/server/auto-continue/schedule-manager.ts |
+| Arm agent wake | IN | AgentCoordinator.scheduleAgentWakeup arms an agent_wakeup or pending_workflow schedule and returns null past the per-chat runaway cap | c3-210 | src/server/agent.ts |
+| Cancel signal | IN | UI cancels via auto_continue_cancel command on the WS router | c3-208 | src/server/auto-continue/schedule-manager.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Wrong classifier verdict triggers a busy loop | New error string introduced upstream | Detector unit test must include every recognised pattern | bun test src/server/auto-continue/limit-detector.test.ts |
+| Schedule lost across restart | Event recording moved out of the event-store path | Read-model replay test fails | bun test src/server/auto-continue/read-model.test.ts |
+| Auth-error retry hammers the same broken token | OAuth-pool rotation skipped on auth-error trigger | Detector test asserts rotation hook called | bun test src/server/auto-continue/auth-error-detector.test.ts |
+| Cancel does not stop a pending timer | Timer reference held outside schedule-manager | Schedule-manager test asserts cancel clears the timer | bun test src/server/auto-continue/schedule-manager.test.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/auto-continue/events.ts | Contract (auto-continue events) | Event payload field names | src/server/auto-continue/events.ts |
+| src/server/auto-continue/limit-detector.ts | Foundational Flow (rate-limit classifier) | Pattern detail | src/server/auto-continue/limit-detector.ts |
+| src/server/auto-continue/auth-error-detector.ts | Foundational Flow (auth-error classifier) | Pattern detail | src/server/auto-continue/auth-error-detector.ts |
+| src/server/auto-continue/schedule-manager.ts | Contract (schedule + trigger surface) | Timer backend | src/server/auto-continue/schedule-manager.ts |
+| src/server/auto-continue/read-model.ts | Contract (schedule read-model) | Projection detail | src/server/auto-continue/read-model.ts |
+| src/server/auto-continue/e2e.test.ts | Business Flow primary path | Test framing | src/server/auto-continue/e2e.test.ts |
diff --git a/.c3/c3-2-server/c3-228-session-share.md b/.c3/c3-2-server/c3-228-session-share.md
new file mode 100644
index 000000000..de67d3b9f
--- /dev/null
+++ b/.c3/c3-2-server/c3-228-session-share.md
@@ -0,0 +1,99 @@
+---
+id: c3-228
+c3-seal: 85f21fe26e141d712e5b5cdee8e13f0b953d932de7bd8ccac2af945c424a1bdb
+title: session-share
+type: component
+category: feature
+parent: c3-2
+goal: Mint time-limited read-only share tokens for finished Kanna chat sessions, persist frozen snapshots under ~/.kanna/shares/, serve them at /share/:token without auth, and sweep expired tokens via TTL.
+uses:
+ - ref-cqrs-read-models
+ - ref-event-sourcing
+ - ref-local-first-data
+ - ref-side-effect-adapter
+ - ref-strong-typing
+---
+
+## Goal
+
+Mint time-limited read-only share tokens for finished Kanna chat sessions, persist frozen snapshots under ~/.kanna/shares/, serve them at /share/:token without auth, and sweep expired tokens via TTL.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | "Provide opt-in session sharing without requiring recipient auth" |
+| Category | feature |
+| Lifecycle | Service started at boot; route registered before HTTP server binds; sweep timer fires on interval |
+| Replaceability | Replaceable provided token mint, snapshot GET, and sweep contract preserved |
+
+## Purpose
+
+Owns the complete lifecycle of a read-only session share: receive mint request from ws-router (c3-208), build a frozen JSON snapshot from event-store read-models (c3-207), persist it under ~/.kanna/shares/.json (mode 0600) via snapshot-store adapter, append share.token_minted to the shares JSONL log (c3-206), return the public URL. Serves the snapshot at GET /share/:token exempt from auth (c3-203 path-prefix bypass). Runs a TTL sweep that appends share.token_expired and deletes expired files. Non-goals include live transcript streaming to viewers, per-viewer access logs, multi-tenant user accounts, and hosting snapshots outside ~/.kanna/.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Server running; chat has at least one event. Public reachability is a deployment concern, not a runtime gate. | c3-206 |
+| Input — ws-router | share.mint WsEnvelope carrying chatId and requestedTtlHours; mint receives originHost captured at WS upgrade and uses it as the base URL | c3-208 |
+| Input — event-store | Replayed event log for the target chat | c3-206 |
+| Input — read-models | Chat title, transcript entries, metadata from projection | c3-207 |
+| Input — paths-config | ~/.kanna/shares/ directory resolved at boot | c3-204 |
+| Internal state | In-memory share projection (token → ShareRecord) rebuilt from shares JSONL on startup | c3-228 |
+| Initialization | SessionShareService registered in server bootstrap; HTTP route added to c3-202 | c3-202 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Owner receives a URL they can paste to any browser; recipient sees a frozen read-only transcript | c3-2 |
+| Primary path | ws share.mint → build snapshot → write file (mode 0600) → append share.token_minted → return ${originHost}/share/ | c3-208 |
+| Alternate — sweep expiry | TTL cron fires → load share projection → for each expired token: delete file + append share.token_expired | c3-228 |
+| Alternate — startup replay | On boot, replay shares JSONL; any token past TTL is expired immediately (fail-closed) | c3-206 |
+| Failure — snapshot read error | File missing or corrupt on GET: return 404 | c3-228 |
+| Failure — expired token on GET | Token past TTL: return 410 Gone | c3-228 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-local-first-data | ref | Snapshots must live under ~/.kanna/shares/ (mode 0600) | must follow | No remote upload |
+| ref-event-sourcing | ref | share.token_minted and share.token_expired appended before any mutation | must follow | Shares log is append-only JSONL |
+| ref-cqrs-read-models | ref | Share lookup reads from in-memory projection rebuilt from shares log | must follow | No direct disk scan for token lookup |
+| ref-side-effect-adapter | ref | All fs reads/writes in snapshot-store.adapter.ts only | must follow | No direct fs calls in service or route |
+| ref-strong-typing | ref | ShareSnapshot, ShareToken, share event payloads — no any | must follow | tsc strict enforced |
+| adr-20260524-session-share | adr | Original decision record. Tunnel precondition row superseded by adr-20260525-share-decouple-tunnel. | governs this component | Accepted |
+| adr-20260525-share-decouple-tunnel | adr | Removes the cloudflared-tunnel precondition: mint accepts a baseUrl argument supplied by the ws-router from the WS upgrade request origin. | governs this component | Implemented |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| mintShare(chatId, ttlHours, baseUrl) | IN | Builds snapshot, persists file, appends event, returns ${baseUrl}/share/. Caller (ws-router) passes the request origin captured at WS upgrade. | c3-208 | src/server/session-share/session-share-service.ts |
+| GET /share/:token | IN | Returns frozen ShareSnapshot JSON if valid; 404 if unknown; 410 if expired | c3-202 | src/server/session-share/share-route.ts |
+| sweepExpired() | IN | Appends share.token_expired and deletes file for each token past TTL | internal timer | src/server/session-share/snapshot-sweep.ts |
+| snapshot-store adapter | IN/OUT | readSnapshot(token), writeSnapshot(token, data), deleteSnapshot(token) | c3-204 | src/server/session-share/snapshot-store.adapter.ts |
+| share projection | IN | Projects share events into Map; rebuilt on startup replay | c3-206 | src/server/session-share/share-projection.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Auth bypass widened | /share/ prefix extended or middleware ordering changed | Unauthenticated requests reach protected routes | bun test share-route.test.ts: non-share paths still return 401 |
+| Snapshot disk leak | sweep timer stopped or share.token_expired not appended on expiry | ~/.kanna/shares/ grows unbounded | bun test src/server/session-share/snapshot-sweep.test.ts: asserts file deleted after TTL |
+| Stale snapshot served | GET route reads file without checking projection expiry | Expired token returns 200 instead of 410 | bun test src/server/session-share/share-route.test.ts: expired fixture returns 410 |
+| Event schema drift | New share event kind added without projection handler | Replay corrupts in-memory map | bun test share-projection.test.ts covers all event kinds |
+| Token collision | PRNG weakness produces duplicate 256-bit token | Two chats share same file | Token uniqueness assertion in token.ts unit test |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/server/session-share/session-share-service.ts | c3-228 Contract: mintShare, sweepExpired | Orchestration detail | src/server/session-share/session-share-service.ts |
+| src/server/session-share/share-route.ts | c3-228 Contract: GET /share/:token | HTTP framework detail | src/server/session-share/share-route.ts |
+| src/server/session-share/snapshot-store.adapter.ts | c3-228 Contract: snapshot-store adapter | fs implementation detail | src/server/session-share/snapshot-store.adapter.ts |
+| src/server/session-share/share-projection.ts | c3-228 Contract: share projection | Projection implementation | src/server/session-share/share-projection.ts |
+| src/server/session-share/snapshot-sweep.ts | c3-228 Contract: sweepExpired | Cron wiring detail | src/server/session-share/snapshot-sweep.ts |
+| src/server/session-share/share-route.test.ts | c3-228 Contract: GET /share/:token | Test fixture detail | src/server/session-share/share-route.test.ts |
diff --git a/.c3/c3-2-server/c3-229-workflow-status.md b/.c3/c3-2-server/c3-229-workflow-status.md
new file mode 100644
index 000000000..8e769820b
--- /dev/null
+++ b/.c3/c3-2-server/c3-229-workflow-status.md
@@ -0,0 +1,120 @@
+---
+id: c3-229
+c3-seal: 09651e0a37ee0f0896f38c1ec1fc790b027e747da94d6145e262a887a0e37df2
+title: workflow-status
+type: component
+category: feature
+parent: c3-2
+goal: Watch Claude Code `wf_.json` sidecar files from disk, maintain a per-chat in-memory WorkflowRegistry read-model, and broadcast WorkflowsSnapshot updates to subscribing clients over the `workflows` WebSocket topic.
+uses:
+ - ref-cqrs-read-models
+ - ref-event-sourcing
+ - ref-provider-adapter
+ - ref-side-effect-adapter
+ - ref-strong-typing
+ - ref-tool-hydration
+ - ref-ws-subscription
+ - ref-zustand-store
+ - rule-colocated-bun-test
+ - rule-strong-typing
+ - rule-zustand-store
+---
+
+# workflow-status
+
+## Goal
+
+Watch Claude Code `wf_.json` sidecar files from disk, maintain a per-chat in-memory WorkflowRegistry read-model, and broadcast WorkflowsSnapshot updates to subscribing clients over the `workflows` WebSocket topic.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-2 (server) |
+| Parent Goal Slice | Broadcast derived read models — supplies the workflow projection the ws-router broadcasts |
+| Category | feature |
+| Lifecycle | Singleton registry per server; per-chat disk-watchers created on PTY spawn, torn down on session close |
+| Replaceability | Replaceable while WorkflowsSnapshot shape and watch/unwatch API are preserved |
+
+## Purpose
+
+Owns the workflow sidecar read-model lifecycle: receives `watch(chatId, dir)` / `unwatch(chatId)` calls from `claude-pty-driver` (c3-225), delegates all IO to `workflow-watch-io.adapter.ts` (the sole adapter), maintains per-chat WorkflowsSnapshot in memory, and notifies subscribers on every disk change. Also owns the `workflow` ToolKind normalization in `src/shared/tools.ts` that converts the `Workflow` tool_use transcript entry into a hydrated inline card for the UI. Non-goals: emitting Kanna JSONL events for workflow state, driving turn lifecycle, writing to disk.
+
+**Override of ref-event-sourcing (scoped):** This read-model is disk-fed, not event-sourced from the Kanna event log (c3-206). The Claude Code `wf_.json` sidecars are external filesystem artifacts; duplicating them into the Kanna event log would pollute the append-only log with non-Kanna mutations. This exception is limited to WorkflowRegistry only and is documented in ADR `adr-20260603-workflow-disk-watch-read-model`.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | PTY session started; workflows directory path known | c3-225 |
+| Input — disk adapter | workflow-watch-io.adapter.ts lists, reads, and watches wf_*.json files; emits debounced change events | ref-side-effect-adapter |
+| Input — agent-coordinator | Constructs WorkflowRegistry singleton and passes it to PTY driver at spawn | c3-210 |
+| Input — ws-router | Calls registry.subscribe(chatId, cb) to receive snapshot pushes for WS broadcast | c3-208 |
+| Initialization | registry.watch(chatId, dir) called by c3-225 on PTY spawn; registry.unwatch(chatId) on close | c3-225 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | UI clients see live workflow run progress without polling | c3-101 |
+| Primary path | PTY writes wf_.json → adapter detects change → registry updates snapshot → WS push to subscriber | c3-208 |
+| Alternate — initial subscribe | Client subscribes → ws-router calls registry.getSnapshot(chatId) → full snapshot pushed immediately | c3-208 |
+| Alternate — getRun command | Client sends workflows.getRun → ws-router calls registry.getRun(chatId, runId) → typed response | c3-302 |
+| Failure — dir not found | adapter returns empty snapshot; re-arms parent watcher; recovers when dir is created | ref-side-effect-adapter |
+| Failure — unwatch missing | unwatch(chatId) is idempotent; no-op if chatId not registered | c3-225 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-cqrs-read-models | ref | WorkflowRegistry is a read-model: read path (snapshot + WS push) separated from write path (disk sidecar) | primary | Registry never writes to disk |
+| ref-ws-subscription | ref | workflows topic follows the single-socket subscribe/command/push envelope contract in protocol.ts | primary | WorkflowsSnapshot and getRun use shared typed envelopes |
+| ref-side-effect-adapter | ref | All fs.watch/readFile/readdir calls live in workflow-watch-io.adapter.ts only; domain files stay pure | primary | ESLint seal enforces mechanically |
+| ref-provider-adapter | ref | WorkflowRegistry is PTY-only; SDK driver must not wire it; wiring is conditional on driver type | primary | registry is undefined when SDK driver is active |
+| ref-tool-hydration | ref | workflow ToolKind added to tools.ts; WorkflowMessage.tsx dispatches on kind | primary | Hydration normalizes Workflow tool_use into inline card |
+| ref-strong-typing | ref | WorkflowsSnapshot, WorkflowRunSummary, WorkflowRunFile are named exports; no any at WS boundary | primary | Types declared in src/shared/workflow-types.ts |
+| ref-zustand-store | ref | workflowsStore.ts is a scoped Zustand store holding server-pushed workflow state | primary | Never independently cache truth; re-populate from WS subscription |
+| ref-event-sourcing | ref | SCOPED OVERRIDE: workflow state is derived from disk sidecars, not from the Kanna event log. Override documented in adr-20260603-workflow-disk-watch-read-model | override | Exception limited to WorkflowRegistry only |
+| rule-strong-typing | rule | All boundary types crossing WS or module boundaries must be named TypeScript exports | primary | bunx tsc --noEmit enforces |
+| rule-colocated-bun-test | rule | workflow-types.test.ts, workflow-watch-io.adapter.test.ts, workflow-registry.test.ts colocated next to impl | primary | bun test enforces by path convention |
+| rule-zustand-store | rule | workflowsStore.ts follows one-concern-per-store and subscribes via WS, not direct server import | primary | Store subscribes to socket topic on mount |
+| adr-20260603-workflow-disk-watch-read-model | adr | Work order authorizing this component and its disk-watch design | primary | Must be accepted before implementation |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| WorkflowRegistry.watch(chatId, dir) | IN | Register a per-chat workflows directory for disk-watching; idempotent on re-call with same dir | c3-225 | src/server/workflow-registry.ts |
+| WorkflowRegistry.unwatch(chatId) | IN | Tear down all fs.watch handles for the chat; idempotent if chatId not registered | c3-225 | src/server/workflow-registry.ts |
+| WorkflowRegistry.snapshot(chatId) | OUT | Return WorkflowRunSummary[] = terminal sidecar runs MERGED with synthetic running rows from live run dirs (no sidecar yet, fresh within 10m); real terminal sidecars win, EXCEPT a no-op crash sidecar (failed + agentCount 0 + no agents) is overridden by a fresh, non-empty live journal — a re-run reused the runId (carries the crash sidecar's taskId/workflowName) | c3-208 | src/server/workflow-registry.ts |
+| WorkflowRegistry.getRun(chatId, runId) | OUT | Return single WorkflowRun or null; mirrors snapshot — a running run with no sidecar yet (or a crash sidecar overridden by a fresh non-empty journal) is synthesized from its live dir and (when readRunJournal is wired) enriched with agents[] + agentCount from journal.jsonl; a crash sidecar with no live agents falls back to the failed sidecar | c3-208 | src/server/workflow-registry.ts |
+| WorkflowRegistry.hasActiveRun(chatId, freshnessMs, now) | OUT | True when a live run dir (subagents/workflows/wf_*) saw activity within freshnessMs AND has no terminal sidecar yet; the in-run liveness signal for the idle reaper / budget enforcer | c3-210 | src/server/workflow-registry.ts |
+| listWorkflowRunDirs(workflowsDir) | OUT | Adapter: list live run dirs subagents/workflows/wf_* with newest file mtime; the live signal Claude writes from second one (unlike the terminal sidecar) | c3-210 | src/server/workflow-watch-io.adapter.ts |
+| watchWorkflowRunDirs(workflowsDir, cb) | IN | Adapter: watch the live run-dir root so a launch (no sidecar yet) pushes a snapshot promptly | c3-302 | src/server/workflow-watch-io.adapter.ts |
+| readWorkflowRunJournal(workflowsDir, runId) | OUT | Adapter: parse subagents/workflows//journal.jsonl into WorkflowJournalEntry[] (defensive; [] when missing/unreadable); the live per-agent signal getRun uses to enrich a running run | c3-208 | src/server/workflow-watch-io.adapter.ts |
+| WorkflowRegistry.subscribe(chatId, cb) | IN/OUT | Register callback invoked on every snapshot change; returns unsubscribe fn | c3-208 | src/server/workflow-registry.ts |
+| WorkflowsSnapshot WS push | OUT | Typed envelope for WS push on the workflows topic | c3-302 | src/shared/workflow-types.ts |
+| workflow ToolKind | OUT | Normalized hydrated transcript entry for Workflow tool_use dispatched to WorkflowMessage.tsx | c3-303 | src/shared/tools.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Violating c3-225 sole-event-source invariant | Any code in workflow-status imports from or writes to the HarnessEvent stream | bun test src/server/workflow-registry.test.ts fails if HarnessEvent coupling introduced | bun test src/server/workflow-registry.test.ts |
+| fs.watch handle leak | unwatch(chatId) not called on PTY session close | Registry holds stale watchers; memory grows per chat | bun test src/server/workflow-registry.test.ts |
+| ESLint side-effect seal breach | fs.watch/readFile called outside *.adapter.ts file | bun run lint fails with no-restricted-imports error | bun test src/server/workflow-watch-io.adapter.test.ts |
+| WorkflowsSnapshot shape drift | Type changed in shared/ without updating ws-router push or client store | bunx tsc --noEmit reports type errors at boundary | bunx tsc --noEmit |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/shared/workflow-types.ts | Contract | Field additions are non-breaking; removals require ADR update | src/shared/workflow-types.ts |
+| src/server/workflow-watch-io.adapter.ts | Contract | May add new fs primitives; must stay a leaf module with no domain logic | src/server/workflow-watch-io.adapter.ts |
+| src/server/workflow-registry.ts | Contract | Internal data structure may change; public API is the contract | src/server/workflow-registry.ts |
+| src/client/stores/workflowsStore.ts | Contract | May add UI-local state fields; must not independently derive server truth | src/client/stores/workflowsStore.ts |
+| src/client/app/WorkflowsSection.tsx | Contract | UI layout may change; must not bypass the Zustand store | src/client/app/WorkflowsSection.tsx |
+| src/client/components/messages/WorkflowMessage.tsx | Contract | Presentation may change; must dispatch only on kind === workflow | src/client/components/messages/WorkflowMessage.tsx |
+| src/server/workflow-registry.test.ts | Change Safety | Test cases per surface | src/server/workflow-registry.test.ts |
+| src/server/workflow-watch-io.adapter.test.ts | Change Safety | Debounce, re-arm, error handling coverage | src/server/workflow-watch-io.adapter.test.ts |
+| src/shared/workflow-types.test.ts | Change Safety | parseWorkflowRunFile and toRunSummary coverage | src/shared/workflow-types.test.ts |
diff --git a/.c3/c3-3-shared/README.md b/.c3/c3-3-shared/README.md
new file mode 100644
index 000000000..d5658623d
--- /dev/null
+++ b/.c3/c3-3-shared/README.md
@@ -0,0 +1,34 @@
+---
+id: c3-3
+c3-version: 4
+c3-seal: efae8370de0491f8fa0542b874f6c96f8bb91dc344a37e12f26bf16479465812
+title: Shared
+type: container
+boundary: library
+parent: c3-0
+goal: Publish the wire protocol, core domain types, tool-call normalization, port and branding config that both client and server import — a thin seam that keeps the two containers honest.
+---
+
+# shared
+
+## Goal
+
+Publish the wire protocol, core domain types, tool-call normalization, port and branding config that both client and server import — a thin seam that keeps the two containers honest.
+
+## Responsibilities
+
+- Define domain types (projects, chats, turns, transcript entries, provider catalog).
+- Define the WebSocket protocol envelope shared by client + server.
+- Normalize tool-call shapes so Claude and Codex render through one pipeline.
+- Publish port helpers and branding constants.
+
+## Components
+
+| ID | Name | Category | Status | Goal Contribution |
+| --- | --- | --- | --- | --- |
+| c3-301 | types | foundation | implemented | Core domain types shared by client + server |
+| c3-302 | protocol | foundation | implemented | WS envelope definitions |
+| c3-303 | tools | foundation | implemented | Tool-call hydration pipeline |
+| c3-304 | ports | foundation | implemented | Port constants + dev-port helpers |
+| c3-305 | branding | foundation | implemented | Product name + data dir constants |
+| c3-306 | share-shared | foundation | implemented | Share DTOs shared with client |
diff --git a/.c3/c3-3-shared/c3-301-types.md b/.c3/c3-3-shared/c3-301-types.md
new file mode 100644
index 000000000..97e63331c
--- /dev/null
+++ b/.c3/c3-3-shared/c3-301-types.md
@@ -0,0 +1,78 @@
+---
+id: c3-301
+c3-version: 4
+c3-seal: c02dd38e8e96c14e6117e0b89e5df36e34b50106f60009bcf584d0e4a05106ff
+title: types
+type: component
+category: foundation
+parent: c3-3
+goal: Declare core domain types (projects, chats, turns, transcript entries, provider catalog shape) shared by client and server.
+uses:
+ - ref-strong-typing
+ - rule-strong-typing
+---
+
+# types
+
+## Goal
+
+Declare core domain types (projects, chats, turns, transcript entries, provider catalog shape) shared by client and server.
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-3 (shared) |
+| Parent Goal Slice | "Define the typed surface shared between client and server" |
+| Category | foundation |
+| Lifecycle | Pure type module |
+| Replaceability | Replaceable provided exported type names + shapes preserved |
+
+## Purpose
+
+Defines the discriminated unions and structural types that cross the wire: project records, chat snapshots, transcript entries, provider catalog entries. Non-goals: I/O, validators, runtime helpers.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | TypeScript strict mode | c3-3 |
+| Input — provider catalog | Re-exports catalog types | c3-212 |
+| Internal state | None — pure types | c3-301 |
+| Initialization | Imported by both containers on demand | c3-301 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Client and server agree on shape of every entity | c3-101 |
+| Primary path | Server emits typed projection → client decodes typed | c3-208 |
+| Alternate — picker | Client uses re-exported catalog types in pickers | c3-115 |
+| Alternate — write | Server constructs typed events using these types | c3-205 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-strong-typing | ref | All shared types are explicit | must follow | No any/unknown exports |
+| rule-strong-typing | rule | Compliance target added by c3x wire; refine what must be reviewed or complied with before handoff. | wired compliance target beats uncited local prose | Added by c3x wire for explicit compliance review. |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| Domain type exports | OUT | Project/chat/turn/transcript types | c3-1 | src/shared/types.ts |
+| Catalog re-exports | OUT | Provider catalog types via shared module | c3-115 | src/shared/types.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Cross-wire drift | Type renamed only on one side | tsc fails on consumer | bun run check against src/shared/types.ts |
+| Re-export break | Catalog re-export missing | tsc fails on UI picker | bun run check plus grep src/client/ for missing catalog imports |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/shared/types.ts | c3-301 Contract | Type detail | src/shared/types.ts |
diff --git a/.c3/c3-3-shared/c3-302-protocol.md b/.c3/c3-3-shared/c3-302-protocol.md
new file mode 100644
index 000000000..758db9cb0
--- /dev/null
+++ b/.c3/c3-3-shared/c3-302-protocol.md
@@ -0,0 +1,80 @@
+---
+id: c3-302
+c3-version: 4
+c3-seal: aa952cd5f176df00d11ddf8885ec33a65f29f19b54c3040cc8bb509232423331
+title: protocol
+type: component
+category: foundation
+parent: c3-3
+goal: Define WebSocket wire envelopes (WsInbound, WsOutbound, subscribe/command kinds, correlation ids).
+uses:
+ - ref-strong-typing
+ - ref-ws-subscription
+ - rule-strong-typing
+---
+
+# protocol
+
+## Goal
+
+Define WebSocket wire envelopes (WsInbound, WsOutbound, subscribe/command kinds, correlation ids).
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-3 (shared) |
+| Parent Goal Slice | "Define the WebSocket envelope vocabulary shared by client and server" |
+| Category | foundation |
+| Lifecycle | Pure type module |
+| Replaceability | Replaceable provided discriminated envelope contract preserved |
+
+## Purpose
+
+Holds the WS envelope discriminated unions: subscribe/unsubscribe/command kinds, correlation ids, and snapshot/diff payload wrappers. Non-goals: transport itself, business handlers.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | TypeScript strict mode | c3-3 |
+| Input — shared types | Domain types embedded in envelopes | c3-301 |
+| Internal state | None | c3-302 |
+| Initialization | Imported by socket and ws-router | c3-101 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Both sides decode and emit envelopes safely | c3-101 |
+| Primary path | Client encodes → server decodes → reply encoded | c3-208 |
+| Alternate — push | Server pushes typed snapshot envelope without correlation | c3-208 |
+| Alternate — error | Both sides handle typed error envelope | c3-101 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-ws-subscription | ref | Protocol is the contract for WS pattern | must follow | One vocabulary, both sides |
+| ref-strong-typing | ref | Discriminated unions over envelope kinds | must follow | No any in payload type |
+| rule-strong-typing | rule | Compliance target added by c3x wire; refine what must be reviewed or complied with before handoff. | wired compliance target beats uncited local prose | Added by c3x wire for explicit compliance review. |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| WsInbound union | OUT | Client-to-server envelope kinds | c3-208 | src/shared/protocol.ts |
+| WsOutbound union | OUT | Server-to-client envelope kinds | c3-101 | src/shared/protocol.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Backward-incompat bump | New kind added without bump version | Old client breaks at runtime | bun run check against src/shared/protocol.ts |
+| Type drift | Payload shape change without consumer update | tsc fails on either side | bun run check plus replay envelope fixtures from src/client/app/socket.test.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/shared/protocol.ts | c3-302 Contract | Envelope detail | src/shared/protocol.ts |
diff --git a/.c3/c3-3-shared/c3-303-tools.md b/.c3/c3-3-shared/c3-303-tools.md
new file mode 100644
index 000000000..68e1bd997
--- /dev/null
+++ b/.c3/c3-3-shared/c3-303-tools.md
@@ -0,0 +1,85 @@
+---
+id: c3-303
+c3-version: 4
+c3-seal: d365be9211ad9391fdaffb59e520a70e1cabf14ffae486e6a29cfbd96e506f14
+title: tools
+type: component
+category: foundation
+parent: c3-3
+goal: Normalize tool-call inputs from Claude and Codex into unified transcript tool entries (read, edit, write_file, delete_file, bash, plan, diff, ...).
+uses:
+ - ref-colocated-bun-test
+ - ref-strong-typing
+ - ref-tool-hydration
+ - rule-colocated-bun-test
+ - rule-strong-typing
+---
+
+# tools
+
+## Goal
+
+Normalize tool-call inputs from Claude and Codex into unified transcript tool entries (read, edit, write_file, delete_file, bash, plan, diff, ...).
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-3 (shared) |
+| Parent Goal Slice | "Provide the canonical tool-entry hydration pipeline used everywhere" |
+| Category | foundation |
+| Lifecycle | Pure functions imported by client and server |
+| Replaceability | Replaceable provided hydrated entry shape preserved |
+
+## Purpose
+
+Hosts the hydration pipeline that turns raw provider tool-call inputs into a single typed `ToolEntry` discriminated union consumed by renderer and coordinator. Non-goals: rendering, persistence, transport.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | Provider raw shapes typed | c3-301 |
+| Input — shared types | Domain types embedded in entries | c3-301 |
+| Internal state | None — pure functions | c3-303 |
+| Initialization | Imported by hydrators per-entry | c3-114 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Renderer and coordinator share one tool vocabulary | c3-114 |
+| Primary path | Raw tool input → hydrate → typed entry | c3-210 |
+| Alternate — diff | Diff-store consumes hydrated write/delete entries | c3-215 |
+| Alternate — renderer | Per-kind renderer reads hydrated entry | c3-113 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-tool-hydration | ref | This module is the hydration pipeline | must follow | One pipeline, one source |
+| ref-strong-typing | ref | Discriminated tool-entry union | must follow | No any in handlers |
+| ref-colocated-bun-test | ref | Tests next to source | must follow | tools.test.ts |
+| rule-strong-typing | rule | All tool-entry boundary types must be named exports | rule wins on conflict | Enforces ref-strong-typing for the tools union |
+| rule-colocated-bun-test | rule | tools.test.ts must remain in src/shared next to tools.ts | rule wins on conflict | Enforces ref-colocated-bun-test for shared tests |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| hydrateToolInput(raw) | OUT | Returns typed ToolEntry | c3-114 | src/shared/tools.ts |
+| ToolEntry union | OUT | Consumed by renderer + coordinator | c3-210 | src/shared/tools.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Missing kind handler | New tool added without hydrator | Fallback shows in renderer | bun run test src/shared/tools.test.ts |
+| Hydration drift | Provider shape change without update | Decode errors at runtime | bun run check plus replay tool fixtures from src/shared/tools.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/shared/tools.ts | c3-303 Contract | Hydration detail | src/shared/tools.ts |
+| src/shared/tools.test.ts | c3-303 Contract | Test cases per kind | src/shared/tools.test.ts |
diff --git a/.c3/c3-3-shared/c3-304-ports.md b/.c3/c3-3-shared/c3-304-ports.md
new file mode 100644
index 000000000..5f3542889
--- /dev/null
+++ b/.c3/c3-3-shared/c3-304-ports.md
@@ -0,0 +1,78 @@
+---
+id: c3-304
+c3-version: 4
+c3-seal: 867cf0e9907bec39192aeeaa7cecb62eca2c4a1148adb04b12a3616127febdcd
+title: ports
+type: component
+category: foundation
+parent: c3-3
+goal: Centralize default ports and dev-mode port offsets (Vite client + Bun backend).
+uses:
+ - ref-strong-typing
+ - rule-strong-typing
+---
+
+# ports
+
+## Goal
+
+Centralize default ports and dev-mode port offsets (Vite client + Bun backend).
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-3 (shared) |
+| Parent Goal Slice | "Centralize default ports and dev-mode offsets used by client + server" |
+| Category | foundation |
+| Lifecycle | Static constants module |
+| Replaceability | Replaceable provided constant names preserved |
+
+## Purpose
+
+Exports the canonical default ports and dev-mode offsets used by the CLI, Bun server, and Vite client. Non-goals: socket transport, runtime port discovery.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | TypeScript strict mode | c3-3 |
+| Input — branding constants | App name used in env var keys | c3-305 |
+| Internal state | None | c3-304 |
+| Initialization | Imported by CLI/server/client on demand | c3-201 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | CLI, server, client agree on port defaults | c3-2 |
+| Primary path | Consumer reads DEFAULT_PORT/DEV_OFFSET | c3-201 |
+| Alternate — env override | Consumer respects KANNA_PORT env when set | c3-202 |
+| Alternate — dev | Vite reads dev offset for hot reload | c3-101 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-strong-typing | ref | Typed numeric constants | must follow | No magic literals |
+| rule-strong-typing | rule | Compliance target added by c3x wire; refine what must be reviewed or complied with before handoff. | wired compliance target beats uncited local prose | Added by c3x wire for explicit compliance review. |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| Port constants | OUT | Default port + dev offset exports | c3-202 | src/shared/ports.ts |
+| Dev URL helper | OUT | Returns ws/http URL for dev consumers | c3-101 | src/shared/ports.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Drift between client + server | Constant edit on one side only | dev hot-reload fails | bun run check against src/shared/ports.ts |
+| Dev offset collision | Offset changed without doc update | Two services compete for port | Manual bun run dev smoke + grep vite.config.ts for port wiring |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/shared/ports.ts | c3-304 Contract | Port detail | src/shared/ports.ts |
diff --git a/.c3/c3-3-shared/c3-305-branding.md b/.c3/c3-3-shared/c3-305-branding.md
new file mode 100644
index 000000000..fcb74fdf1
--- /dev/null
+++ b/.c3/c3-3-shared/c3-305-branding.md
@@ -0,0 +1,76 @@
+---
+id: c3-305
+c3-version: 4
+c3-seal: 6a2fde93f6ad55c885826dd8a4821fdfbbad295839823d2f16d7364b8bf21e14
+title: branding
+type: component
+category: foundation
+parent: c3-3
+goal: Publish the product name and data-dir constants (kanna, ~/.kanna/data/...).
+uses:
+ - ref-local-first-data
+---
+
+# branding
+
+## Goal
+
+Publish the product name and data-dir constants (kanna, ~/.kanna/data/...).
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-3 (shared) |
+| Parent Goal Slice | "Publish app name + data-dir constants used by both containers" |
+| Category | foundation |
+| Lifecycle | Static constants module |
+| Replaceability | Replaceable provided constant names preserved |
+
+## Purpose
+
+Exposes the product name and the data-dir prefix as typed constants imported wherever app-name or `~/.kanna/data` would otherwise be hard-coded. Non-goals: filesystem I/O, theming.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | TypeScript strict mode | c3-3 |
+| Input — none | Module is self-contained | c3-305 |
+| Internal state | None | c3-305 |
+| Initialization | Imported by paths, app-shell, branding strings | c3-204 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Renaming the app touches one file | c3-3 |
+| Primary path | Consumer imports APP_NAME/DATA_DIR | c3-204 |
+| Alternate — UI title | Client reads constant for window title | c3-110 |
+| Alternate — env var prefix | CLI uses constant for env-var keys | c3-201 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-local-first-data | ref | Anchors the local data path layout | must follow | Single canonical prefix |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| APP_NAME constant | OUT | String used in titles + env keys | c3-110 | src/shared/branding.ts |
+| DATA_DIR constant | OUT | Path prefix for paths-config | c3-204 | src/shared/branding.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Hard-coded literal regression | New consumer hard-codes string | grep finds raw kanna literals | bun run check against src/shared/branding.ts |
+| Path migration miss | Constant changed without paths update | Existing data inaccessible | Manual upgrade smoke pairing src/shared/branding.ts and src/server/paths.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/shared/branding.ts | c3-305 Contract | Constant detail | src/shared/branding.ts |
diff --git a/.c3/c3-3-shared/c3-306-share-shared.md b/.c3/c3-3-shared/c3-306-share-shared.md
new file mode 100644
index 000000000..2c199f916
--- /dev/null
+++ b/.c3/c3-3-shared/c3-306-share-shared.md
@@ -0,0 +1,78 @@
+---
+id: c3-306
+c3-version: 4
+c3-seal: 6746ce8a78464cb978f7abcf0bfbea4de7757412733f762adb7ba38d3871efb2
+title: share-shared
+type: component
+category: foundation
+parent: c3-3
+goal: Expose share/tunnel types used on both client and server (QR payload, public URL shape).
+uses:
+ - ref-strong-typing
+ - rule-strong-typing
+---
+
+# share-shared
+
+## Goal
+
+Expose share/tunnel types used on both client and server (QR payload, public URL shape).
+
+## Parent Fit
+
+| Field | Value |
+| --- | --- |
+| Container | c3-3 (shared) |
+| Parent Goal Slice | "Define share/tunnel DTOs shared between client and server" |
+| Category | foundation |
+| Lifecycle | Pure type module |
+| Replaceability | Replaceable provided exported type shapes preserved |
+
+## Purpose
+
+Holds the typed DTOs for the `--share` feature: public URL payload, QR-code payload, tunnel state. Now also covers read-only session-share types under `src/shared/session-share/` (`ChatSnapshot`, `ShareError`, `ShareSummary`, etc.) imported by both client and server for the session-share feature (c3-228). Non-goals: tunnel runtime, classifier logic.
+
+## Foundational Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Precondition | TypeScript strict mode | c3-3 |
+| Input — shared types | Reuses common URL primitives | c3-301 |
+| Internal state | None | c3-306 |
+| Initialization | Imported by server share + client banner | c3-218 |
+
+## Business Flow
+
+| Aspect | Detail | Reference |
+| --- | --- | --- |
+| Outcome | Share UI and server use one canonical DTO | c3-218 |
+| Primary path | Server emits DTO → client renders banner/QR | c3-110 |
+| Alternate — settings | Settings page reads tunnel mode DTO | c3-116 |
+| Alternate — none | No alternate transports — single shape across consumers | c3-306 |
+
+## Governance
+
+| Reference | Type | Governs | Precedence | Notes |
+| --- | --- | --- | --- | --- |
+| ref-strong-typing | ref | Typed share DTOs | must follow | No any in payloads |
+| rule-strong-typing | rule | Compliance target added by c3x wire; refine what must be reviewed or complied with before handoff. | wired compliance target beats uncited local prose | Added by c3x wire for explicit compliance review. |
+
+## Contract
+
+| Surface | Direction | Contract | Boundary | Evidence |
+| --- | --- | --- | --- | --- |
+| Share DTO exports | OUT | Public URL + QR payload types | c3-218 | src/shared/share.ts |
+| Tunnel DTO exports | OUT | Tunnel state shape used in projection | c3-110 | src/shared/share.ts |
+
+## Change Safety
+
+| Risk | Trigger | Detection | Required Verification |
+| --- | --- | --- | --- |
+| Cross-wire drift | Type renamed only on one side | tsc fails on consumer | bun run check against src/shared/share.ts |
+| Field marked required without migration | DTO required-flag flipped | Old client breaks at runtime | Manual mixed-version smoke pairing src/server/share.ts and src/client/app/socket.ts |
+
+## Derived Materials
+
+| Material | Must derive from | Allowed variance | Evidence |
+| --- | --- | --- | --- |
+| src/shared/share.ts | c3-306 Contract | DTO detail | src/shared/share.ts |
diff --git a/.c3/code-map.yaml b/.c3/code-map.yaml
new file mode 100644
index 000000000..0a7762df2
--- /dev/null
+++ b/.c3/code-map.yaml
@@ -0,0 +1,219 @@
+c3-101:
+ - src/client/app/socket.test.ts
+ - src/client/app/socket.ts
+c3-102:
+ - src/client/stores/**/*.ts
+c3-103:
+ - _exclude:src/client/lib/testing/**
+ - src/client/components/editor-icons.tsx
+ - src/client/components/ui/**/*.tsx
+c3-110:
+ - src/client/app/App.test.tsx
+ - src/client/app/App.tsx
+ - src/client/app/AppBootstrap.tsx
+ - src/client/app/PageHeader.tsx
+ - src/client/app/chatFocusPolicy.test.ts
+ - src/client/app/chatFocusPolicy.ts
+ - src/client/app/chatNotifications.ts
+ - src/client/app/derived.ts
+ - src/client/app/useKannaState.test.ts
+ - src/client/app/useKannaState.ts
+ - src/client/components/LocalDev.tsx
+ - src/client/hooks/**/*.ts
+ - src/client/hooks/**/*.tsx
+ - src/client/lib/**/*.ts
+ - src/main.tsx
+c3-111:
+ - src/client/app/KannaSidebar.tsx
+ - src/client/app/sidebarNumberJump.test.ts
+ - src/client/app/sidebarNumberJump.ts
+c3-112:
+ - src/client/app/ChatPage.test.ts
+ - src/client/app/ChatPage/**/*.ts
+ - src/client/app/ChatPage/**/*.tsx
+ - src/client/app/useRightSidebarToggleAnimation.ts
+ - src/client/app/useStickyChatFocus.ts
+ - src/client/app/useTerminalToggleAnimation.ts
+c3-113:
+ - src/client/app/KannaTranscript.test.tsx
+ - src/client/app/KannaTranscript.tsx
+ - src/client/app/subagent-run-placement.test.ts
+ - src/client/app/subagent-run-placement.ts
+c3-114:
+ - src/client/components/messages/**/*.ts
+ - src/client/components/messages/**/*.tsx
+c3-115:
+ - src/client/components/chat-ui/**/*.ts
+ - src/client/components/chat-ui/**/*.tsx
+ - src/client/components/open-external-menu.tsx
+c3-116:
+ - src/client/app/SettingsPage.test.tsx
+ - src/client/app/SettingsPage.tsx
+ - src/client/app/SubagentsSection.test.tsx
+ - src/client/app/SubagentsSection.tsx
+ - src/client/components/settings/**/*.tsx
+c3-117:
+ - src/client/app/LocalProjectsPage.tsx
+ - src/client/components/NewProjectModal.tsx
+c3-118:
+ - src/client/app/ChatPage/TerminalWorkspaceShell.tsx
+ - src/client/app/terminalLayoutResize.test.ts
+ - src/client/app/terminalLayoutResize.ts
+ - src/client/app/terminalToggleAnimation.test.ts
+ - src/client/app/terminalToggleAnimation.ts
+c3-201:
+ - src/server/cli-runtime.test.ts
+ - src/server/cli-runtime.ts
+ - src/server/cli-supervisor.ts
+ - src/server/cli.ts
+c3-202:
+ - src/server/app-settings.ts
+ - src/server/server.ts
+c3-203:
+ - src/server/auth.test.ts
+ - src/server/auth.ts
+c3-204:
+ - src/server/machine-name.ts
+ - src/server/paths.ts
+ - src/server/project-paths.test.ts
+ - src/server/project-paths.ts
+c3-205:
+ - src/server/events.ts
+ - src/server/harness-types.ts
+c3-206:
+ - src/server/event-store.test.ts
+ - src/server/event-store.ts
+c3-207:
+ - src/server/read-models.test.ts
+ - src/server/read-models.ts
+c3-208:
+ - src/server/ws-router.test.ts
+ - src/server/ws-router.ts
+c3-209:
+ - src/server/process-utils.test.ts
+ - src/server/process-utils.ts
+c3-210:
+ - src/server/agent.test.ts
+ - src/server/agent.ts
+ - src/server/history-primer.test.ts
+ - src/server/history-primer.ts
+ - src/server/mention-parser.test.ts
+ - src/server/mention-parser.ts
+ - src/server/subagent-entry-cap.test.ts
+ - src/server/subagent-entry-cap.ts
+ - src/server/subagent-orchestrator.test.ts
+ - src/server/subagent-orchestrator.ts
+ - src/server/subagent-provider-run.test.ts
+ - src/server/subagent-provider-run.ts
+c3-211:
+ - src/server/codex-app-server-protocol.ts
+ - src/server/codex-app-server.test.ts
+ - src/server/codex-app-server.ts
+c3-212:
+ - src/server/provider-catalog.test.ts
+ - src/server/provider-catalog.ts
+c3-213:
+ - src/server/generate-commit-message.test.ts
+ - src/server/generate-commit-message.ts
+ - src/server/generate-title.ts
+ - src/server/llm-provider.test.ts
+ - src/server/llm-provider.ts
+ - src/server/quick-response.test.ts
+ - src/server/quick-response.ts
+ - src/server/title-generation.live.test.ts
+c3-214:
+ - src/server/claude-session-importer.test.ts
+ - src/server/claude-session-importer.ts
+ - src/server/claude-session-mapper.test.ts
+ - src/server/claude-session-mapper.ts
+ - src/server/claude-session-parser.test.ts
+ - src/server/claude-session-parser.ts
+ - src/server/claude-session-scanner.test.ts
+ - src/server/claude-session-scanner.ts
+ - src/server/claude-session-types.ts
+ - src/server/discovery.test.ts
+ - src/server/discovery.ts
+c3-215:
+ - src/server/diff-store.test.ts
+ - src/server/diff-store.ts
+c3-216:
+ - src/server/terminal-manager.test.ts
+ - src/server/terminal-manager.ts
+ - src/server/terminal-pid-registry.test.ts
+ - src/server/terminal-pid-registry.ts
+c3-217:
+ - src/server/uploads.test.ts
+ - src/server/uploads.ts
+c3-218:
+ - src/server/share.test.ts
+ - src/server/share.ts
+c3-219:
+ - src/server/update-manager.test.ts
+ - src/server/update-manager.ts
+ - src/server/update-strategy.test.ts
+ - src/server/update-strategy.ts
+c3-220:
+ - src/server/restart.test.ts
+ - src/server/restart.ts
+c3-221:
+ - src/server/external-open.test.ts
+ - src/server/external-open.ts
+c3-222:
+ - src/server/keybindings.test.ts
+ - src/server/keybindings.ts
+c3-223:
+ - src/server/cloudflare-tunnel/**/*.ts
+c3-224:
+ - src/server/oauth-pool/**/*.ts
+c3-225:
+ - src/server/claude-pty/**
+c3-226:
+ - src/server/kanna-mcp-http.test.ts
+ - src/server/kanna-mcp-http.ts
+ - src/server/kanna-mcp-tools/**/*.ts
+ - src/server/kanna-mcp.test.ts
+ - src/server/kanna-mcp.ts
+ - src/server/permission-gate.test.ts
+ - src/server/permission-gate.ts
+ - src/server/tool-callback.ts
+c3-227:
+ - src/server/auto-continue/**/*.ts
+c3-229:
+ - src/client/app/WorkflowsSection.tsx
+ - src/client/components/messages/WorkflowMessage.tsx
+ - src/client/stores/workflowsStore.ts
+ - src/server/workflow-registry.test.ts
+ - src/server/workflow-registry.ts
+ - src/server/workflow-watch-io.adapter.test.ts
+ - src/server/workflow-watch-io.adapter.ts
+ - src/shared/workflow-types.test.ts
+ - src/shared/workflow-types.ts
+c3-301:
+ - src/shared/analytics.ts
+ - src/shared/kanna-system-prompt.test.ts
+ - src/shared/kanna-system-prompt.ts
+ - src/shared/mask-oauth-key.test.ts
+ - src/shared/mask-oauth-key.ts
+ - src/shared/mention-pattern.ts
+ - src/shared/permission-policy.test.ts
+ - src/shared/permission-policy.ts
+ - src/shared/projectFileRelocation.test.ts
+ - src/shared/projectFileRelocation.ts
+ - src/shared/projectFileUrl.test.ts
+ - src/shared/projectFileUrl.ts
+ - src/shared/types.test.ts
+ - src/shared/types.ts
+c3-302:
+ - src/shared/protocol.ts
+c3-303:
+ - src/shared/tools.test.ts
+ - src/shared/tools.ts
+c3-304:
+ - src/shared/dev-ports.test.ts
+ - src/shared/dev-ports.ts
+ - src/shared/ports.ts
+c3-305:
+ - src/shared/branding.test.ts
+ - src/shared/branding.ts
+c3-306:
+ - src/shared/share.ts
diff --git a/.c3/config.yaml b/.c3/config.yaml
new file mode 100644
index 000000000..c8a80eb3b
--- /dev/null
+++ b/.c3/config.yaml
@@ -0,0 +1 @@
+# C3 configuration
diff --git a/.c3/refs/ref-colocated-bun-test.md b/.c3/refs/ref-colocated-bun-test.md
new file mode 100644
index 000000000..abfb04514
--- /dev/null
+++ b/.c3/refs/ref-colocated-bun-test.md
@@ -0,0 +1,58 @@
+---
+id: ref-colocated-bun-test
+c3-version: 4
+c3-seal: 4eec5e6a4b94eae9351f8803dae01591274400628f449b0cd97edad0e79a8e5e
+title: Colocated Bun Test
+type: ref
+goal: Tests sit next to the file under test, named *.test.ts(x), and run under bun test — no separate test directory, no framework churn.
+---
+
+# colocated-bun-test
+
+## Goal
+
+Tests sit next to the file under test, named *.test.ts(x), and run under bun test — no separate test directory, no framework churn.
+
+## Choice
+
+bun test as the single test runner. Test file naming: .test.ts or .test.tsx. Live integration tests end in .live.test.ts and are gated by env.
+
+## Why
+
+Keeps tests visible and close to behavior. Bun's fast startup eliminates the cost of running narrow test subsets while iterating.
+
+## How
+
+| Guideline | Example |
+| --- | --- |
+| Test lives next to impl | src/server/auth.ts + auth.test.ts |
+| Live APIs gated by .live.test.ts | title-generation.live.test.ts |
+| Use bun test to scope runs | bun test src/server/agent.test.ts |
+
+## Not This
+
+| Alternative | Rejected Because |
+| --- | --- |
+| ... | ... |
+
+## Scope
+
+**Applies to:**
+
+-
+
+**Does NOT apply to:**
+
+-
+
+## Override
+
+To override this ref:
+
+1. Document justification in an ADR under "Pattern Overrides"
+2. Cite this ref and explain why the override is necessary
+3. Specify the scope of the override (which components deviate)
+
+## Cited By
+
+- c3-{N}{NN} ({component name})
diff --git a/.c3/refs/ref-cqrs-read-models.md b/.c3/refs/ref-cqrs-read-models.md
new file mode 100644
index 000000000..6b5fb9c4c
--- /dev/null
+++ b/.c3/refs/ref-cqrs-read-models.md
@@ -0,0 +1,58 @@
+---
+id: ref-cqrs-read-models
+c3-version: 4
+c3-seal: 66e35556e038b69c2420a105b1b007f7779dccbceb5f14443e6abba157ae02c0
+title: CQRS Read Models
+type: ref
+goal: Separate write path (event log) from read path (derived views) so subscribers consume fast snapshots without replaying the log.
+---
+
+# cqrs-read-models
+
+## Goal
+
+Separate write path (event log) from read path (derived views) so subscribers consume fast snapshots without replaying the log.
+
+## Choice
+
+read-models.ts projects events into sidebar / chat / project views; ws-router broadcasts those views to subscribers on every state change.
+
+## Why
+
+Keeps UI render paths off the log; allows per-view memoization; lets derived shapes evolve without touching event schema.
+
+## How
+
+| Guideline | Example |
+| --- | --- |
+| One read model per UI concern | sidebarView, chatView, projectsView |
+| Pure projections — no I/O from derivation | read-models.ts functions are deterministic |
+| Broadcast diffs on change, not on request | ws-router pushes on event append |
+
+## Not This
+
+| Alternative | Rejected Because |
+| --- | --- |
+| ... | ... |
+
+## Scope
+
+**Applies to:**
+
+-
+
+**Does NOT apply to:**
+
+-
+
+## Override
+
+To override this ref:
+
+1. Document justification in an ADR under "Pattern Overrides"
+2. Cite this ref and explain why the override is necessary
+3. Specify the scope of the override (which components deviate)
+
+## Cited By
+
+- c3-{N}{NN} ({component name})
diff --git a/.c3/refs/ref-event-sourcing.md b/.c3/refs/ref-event-sourcing.md
new file mode 100644
index 000000000..8b8a0e441
--- /dev/null
+++ b/.c3/refs/ref-event-sourcing.md
@@ -0,0 +1,58 @@
+---
+id: ref-event-sourcing
+c3-version: 4
+c3-seal: bf0a97b00954a0b89c37cab5c3281924bf943ddcb1fed7259e079ebd88dd6e5a
+title: Event Sourcing
+type: ref
+goal: Every state mutation is first captured as an immutable event appended to a JSONL log; system state is derived by replay + periodic snapshot compaction.
+---
+
+# event-sourcing
+
+## Goal
+
+Every state mutation is first captured as an immutable event appended to a JSONL log; system state is derived by replay + periodic snapshot compaction.
+
+## Choice
+
+Append-only JSONL event logs (projects, chats, messages, turns) plus a compacted snapshot.json — no database. Implemented by src/server/event-store.ts and src/server/events.ts.
+
+## Why
+
+Zero-infra (no DB), crash-safe, human-inspectable, replayable for bug triage. Snapshots keep cold-start fast; replay tail handles recent events. Natural fit for a local-first single-user tool.
+
+## How
+
+| Guideline | Example |
+| --- | --- |
+| Mutations always emit an event first, derivations follow | agent-coordinator appends turn events; read-models react |
+| Events are append-only; never rewrite history | use new events for corrections, never edit log |
+| Compact when log exceeds 2 MB | snapshot.json generated on startup |
+
+## Not This
+
+| Alternative | Rejected Because |
+| --- | --- |
+| ... | ... |
+
+## Scope
+
+**Applies to:**
+
+-
+
+**Does NOT apply to:**
+
+-
+
+## Override
+
+To override this ref:
+
+1. Document justification in an ADR under "Pattern Overrides"
+2. Cite this ref and explain why the override is necessary
+3. Specify the scope of the override (which components deviate)
+
+## Cited By
+
+- c3-{N}{NN} ({component name})
diff --git a/.c3/refs/ref-local-first-data.md b/.c3/refs/ref-local-first-data.md
new file mode 100644
index 000000000..1fb2472f7
--- /dev/null
+++ b/.c3/refs/ref-local-first-data.md
@@ -0,0 +1,58 @@
+---
+id: ref-local-first-data
+c3-version: 4
+c3-seal: 6e3466e18f6f49b68ab8464d46a5e0f577a05f4c01131f4aaef6c690a52ffb1e
+title: Local-First Data
+type: ref
+goal: All persistent state sits under ~/.kanna/data; the server binds to 127.0.0.1 by default and only exposes wider surfaces (LAN, tunnel) when the user opts in.
+---
+
+# local-first-data
+
+## Goal
+
+All persistent state sits under ~/.kanna/data; the server binds to 127.0.0.1 by default and only exposes wider surfaces (LAN, tunnel) when the user opts in.
+
+## Choice
+
+paths.ts centralizes data paths; cli.ts defaults to localhost; --host / --remote / --share are explicit opt-ins; --password gates all surfaces when set.
+
+## Why
+
+Zero cloud lock-in, zero hosting cost, user owns data on their disk, safe default for a developer tool.
+
+## How
+
+| Guideline | Example |
+| --- | --- |
+| All file paths flow through paths.ts | projects.jsonl, snapshot.json |
+| Bind only what user asked for | default 127.0.0.1, --remote for 0.0.0.0 |
+| Authenticated surfaces == all surfaces when --password set | API, /health, /ws |
+
+## Not This
+
+| Alternative | Rejected Because |
+| --- | --- |
+| ... | ... |
+
+## Scope
+
+**Applies to:**
+
+-
+
+**Does NOT apply to:**
+
+-
+
+## Override
+
+To override this ref:
+
+1. Document justification in an ADR under "Pattern Overrides"
+2. Cite this ref and explain why the override is necessary
+3. Specify the scope of the override (which components deviate)
+
+## Cited By
+
+- c3-{N}{NN} ({component name})
diff --git a/.c3/refs/ref-provider-adapter.md b/.c3/refs/ref-provider-adapter.md
new file mode 100644
index 000000000..f4f738951
--- /dev/null
+++ b/.c3/refs/ref-provider-adapter.md
@@ -0,0 +1,58 @@
+---
+id: ref-provider-adapter
+c3-version: 4
+c3-seal: f181407eeec0163db5fa3f5a8407d2685ed270d58ea190c3893cc8b35f3168db
+title: Provider Adapter
+type: ref
+goal: Normalize Claude Agent SDK and Codex App Server into one transcript + tool-call model so the UI never branches on provider.
+---
+
+# provider-adapter
+
+## Goal
+
+Normalize Claude Agent SDK and Codex App Server into one transcript + tool-call model so the UI never branches on provider.
+
+## Choice
+
+agent-coordinator owns turn lifecycle. provider-catalog normalizes model/effort/fast-mode per provider. codex-app-server adapts Codex JSON-RPC. quick-response falls back Claude Haiku → Codex when needed.
+
+## Why
+
+Users switch providers mid-chat; transcript must stay unified. Isolating adapters keeps the rest of the server provider-agnostic.
+
+## How
+
+| Guideline | Example |
+| --- | --- |
+| Transcript types live in shared/types.ts, not per provider | TranscriptEntry is one union |
+| Tool calls route through shared/tools.ts hydration | unified icon/label regardless of provider |
+| Provider-specific quirks stay inside its adapter file | codex-app-server-protocol.ts |
+
+## Not This
+
+| Alternative | Rejected Because |
+| --- | --- |
+| ... | ... |
+
+## Scope
+
+**Applies to:**
+
+-
+
+**Does NOT apply to:**
+
+-
+
+## Override
+
+To override this ref:
+
+1. Document justification in an ADR under "Pattern Overrides"
+2. Cite this ref and explain why the override is necessary
+3. Specify the scope of the override (which components deviate)
+
+## Cited By
+
+- c3-{N}{NN} ({component name})
diff --git a/.c3/refs/ref-side-effect-adapter.md b/.c3/refs/ref-side-effect-adapter.md
new file mode 100644
index 000000000..40def77c9
--- /dev/null
+++ b/.c3/refs/ref-side-effect-adapter.md
@@ -0,0 +1,92 @@
+---
+id: ref-side-effect-adapter
+c3-seal: c57070a9b08ba9967ec758f5e83f2e9df4027761a591359c47cb72226caa46ae
+title: side-effect-adapter
+type: ref
+goal: Keep every `node:fs`, `node:child_process`, `node:http`/`https`, `bun:sqlite`/`better-sqlite3`/`pg`, and `Bun.spawn`/`Bun.$`/`Bun.file`/`Bun.serve`/`Bun.Terminal` call site in a single, named, leaf-level wrapper file so the rest of `src/server/**` can stay pure and the seal is mechanically enforceable by ESLint without per-file allow-lists.
+---
+
+## Goal
+
+Keep every `node:fs`, `node:child_process`, `node:http`/`https`, `bun:sqlite`/`better-sqlite3`/`pg`, and `Bun.spawn`/`Bun.$`/`Bun.file`/`Bun.serve`/`Bun.Terminal` call site in a single, named, leaf-level wrapper file so the rest of `src/server/**` can stay pure and the seal is mechanically enforceable by ESLint without per-file allow-lists.
+
+## Choice
+
+Two-shape adapter convention, both colocated next to the module that owns the port:
+
+1. **Leaf-IO module** — a file whose only responsibility is the side effect itself. Suffix: `.adapter.ts`. Examples on main: `src/server/storage/fs-storage.adapter.ts`, `src/server/claude-pty/pty-process.adapter.ts`, `src/server/machine-name.adapter.ts`, `src/server/orphan-persistence.adapter.ts`.
+2. **Mixed-concern module** — domain logic stays in `.ts`; the IO it needs is extracted into a sibling `-io.adapter.ts` and re-imported. Examples on main: `src/server/diff-store.ts` + `src/server/diff-store-io.adapter.ts`, `src/server/server.ts` + `src/server/server-io.adapter.ts`, `src/server/app-settings.ts` + `src/server/app-settings-io.adapter.ts`.
+
+Files matching `src/server/**/*.adapter.ts` (or the legacy `src/server/adapters/**` directory) are the only exempt globs in the `no-restricted-imports` + `no-restricted-globals` override in `eslint.config.js`. Tests, `__fixtures__`, and `test-helpers` are also exempt.
+
+## Why
+
+A pure rename-the-file convention was picked over alternatives because:
+
+- **Per-component allow-lists were tried and rejected** during the 90 → 0 ratchet burndown (PRs #283-#302). Each new component added meant another ESLint override block; the config grew unbounded.
+- **Dependency injection alone is not sufficient** — leaf modules must eventually call the real `fs.readFile`, and forcing every leaf through a port interface just to satisfy lint pushed boilerplate into every consumer without adding test seams (consumers already mock through the leaf).
+- **Filename is greppable, AST-checkable, and survives moves**. `*.adapter.ts` shows up in IDE search, in `c3x lookup`, in CODEOWNERS, and in PR diffs — no need to remember which globs are exempt.
+- **The `-io` infix tells the next reader** that the parent module is a mixed-concern domain file, not a leaf wrapper, and that the IO calls were intentionally extracted (not yet refactored into a port). Without the infix, reviewers cannot distinguish "this file is allowed to call `fs` because it's an adapter" from "this file is allowed to call `fs` because someone forgot to extract".
+
+This boundary is verified by `bun run lint` failing on any new side-effect import in a non-adapter file (final flip landed in PR #303; ratchet tooling deleted in the same PR).
+
+## How
+
+Two file templates, taken verbatim from main.
+
+**Shape 1 — leaf-IO module.** Source: `src/server/machine-name.adapter.ts`. The whole file is the wrapper. No domain logic.
+
+```ts
+// src/server/machine-name.adapter.ts
+import { hostname } from "node:os"
+
+export function getMachineDisplayName(): string {
+ return hostname()
+}
+```
+
+**Shape 2 — mixed-concern split.** Source: `src/server/server-io.adapter.ts` paired with `src/server/server.ts`.
+
+```ts
+// src/server/server-io.adapter.ts
+import { stat } from "node:fs/promises"
+import type { Stats } from "node:fs"
+import type { BunFile, Server } from "bun"
+
+export function getServerFile(p: string): BunFile {
+ return Bun.file(p)
+}
+export function statFile(p: string): Promise {
+ return stat(p)
+}
+export function serveHttp(opts: unknown): Server {
+ return Bun.serve(opts as never) as unknown as Server
+}
+```
+
+```ts
+// src/server/server.ts — consumer
+import { getServerFile, serveHttp, statFile } from "./server-io.adapter"
+
+const info = await statFile(filePath)
+const file = getServerFile(filePath)
+const server = serveHttp({ port, hostname, fetch: handler })
+```
+
+REQUIRED:
+
+- Filename suffix `.adapter.ts` for both shapes.
+- Adapter wraps **one primitive surface** (fs, spawn, http, Bun global, …); do not stack unrelated IO into a single adapter just to satisfy lint.
+- Adapter has no domain decisions: it normalizes shape (e.g. `statOrNull`) but does not interpret state.
+- For Shape 2, the parent file imports through the sibling adapter only — no direct `node:fs` import survives.
+
+OPTIONAL:
+
+- Adapters MAY export typed helpers (`SpawnResult`, `DirEntry`) so consumers do not import `node:*` types either.
+- Adapters MAY re-export node types (`export type { Stats } from "node:fs"`) to keep consumers off the restricted import list.
+
+NOT THIS:
+
+- Do not add `// eslint-disable-next-line no-restricted-imports` to a non-adapter file. The seal has no escape valve; adding a disable was rejected during the burndown as it defeats the audit value.
+- Do not rename a mixed-concern file to `.adapter.ts` just to silence the rule — that hides the fact that domain logic is still co-located with IO. Use the `-io.adapter.ts` sibling instead.
+- Do not put adapters under arbitrary paths like `src/server/lib/io/`. The colocation rule (sibling-next-to-consumer) is what makes ownership obvious in PR diffs.
diff --git a/.c3/refs/ref-strong-typing.md b/.c3/refs/ref-strong-typing.md
new file mode 100644
index 000000000..2f9982676
--- /dev/null
+++ b/.c3/refs/ref-strong-typing.md
@@ -0,0 +1,58 @@
+---
+id: ref-strong-typing
+c3-version: 4
+c3-seal: 59a714df85f6e12a0975f6b69f001467c0f9f5736c9c84a7383176f4e1df0cf9
+title: Strong Typing Policy
+type: ref
+goal: No any / untyped shapes at boundaries — everything that crosses client↔server, provider↔coordinator, or log↔read-model is a named type in src/shared or the owning module.
+---
+
+# strong-typing
+
+## Goal
+
+No any / untyped shapes at boundaries — everything that crosses client↔server, provider↔coordinator, or log↔read-model is a named type in src/shared or the owning module.
+
+## Choice
+
+TypeScript strict mode; shared types in src/shared/types.ts; protocol envelopes in src/shared/protocol.ts; events in src/server/events.ts.
+
+## Why
+
+Refactors stay safe, tool hydration logic can exhaustively switch on kinds, and client/server drift is caught at build time (bun run check).
+
+## How
+
+| Guideline | Example |
+| --- | --- |
+| Discriminated unions over flags | TranscriptEntry kinds |
+| Shared types win over local duplicates | import from shared/types.ts |
+| bun run check must stay green | tsc --noEmit + vite build |
+
+## Not This
+
+| Alternative | Rejected Because |
+| --- | --- |
+| ... | ... |
+
+## Scope
+
+**Applies to:**
+
+-
+
+**Does NOT apply to:**
+
+-
+
+## Override
+
+To override this ref:
+
+1. Document justification in an ADR under "Pattern Overrides"
+2. Cite this ref and explain why the override is necessary
+3. Specify the scope of the override (which components deviate)
+
+## Cited By
+
+- c3-{N}{NN} ({component name})
diff --git a/.c3/refs/ref-tool-hydration.md b/.c3/refs/ref-tool-hydration.md
new file mode 100644
index 000000000..947e93b76
--- /dev/null
+++ b/.c3/refs/ref-tool-hydration.md
@@ -0,0 +1,58 @@
+---
+id: ref-tool-hydration
+c3-version: 4
+c3-seal: d55e81af5f34870fd9065aaca26c98695602b83763729a8f111b95efc72a577c
+title: Tool Call Hydration
+type: ref
+goal: Provider tool calls (Read, Edit, Bash, plan, diff, ...) are normalized into unified transcript entries by src/shared/tools.ts before rendering.
+---
+
+# tool-hydration
+
+## Goal
+
+Provider tool calls (Read, Edit, Bash, plan, diff, ...) are normalized into unified transcript entries by src/shared/tools.ts before rendering.
+
+## Choice
+
+One hydration function per tool kind in shared/tools.ts; messages-renderer selects renderer by kind; agent-coordinator emits normalized entries before persisting.
+
+## Why
+
+Renderers stay simple and exhaustive; adding a tool is one shared normalization + one UI renderer; provider-agnostic by construction.
+
+## How
+
+| Guideline | Example |
+| --- | --- |
+| Hydration never throws — unknown tools map to generic entry | fallback branch in tools.ts |
+| No provider branching in renderers | messages-renderer dispatches on kind only |
+| Icons/labels live with hydration, not renderer | keeps hydration self-contained |
+
+## Not This
+
+| Alternative | Rejected Because |
+| --- | --- |
+| ... | ... |
+
+## Scope
+
+**Applies to:**
+
+-
+
+**Does NOT apply to:**
+
+-
+
+## Override
+
+To override this ref:
+
+1. Document justification in an ADR under "Pattern Overrides"
+2. Cite this ref and explain why the override is necessary
+3. Specify the scope of the override (which components deviate)
+
+## Cited By
+
+- c3-{N}{NN} ({component name})
diff --git a/.c3/refs/ref-ws-subscription.md b/.c3/refs/ref-ws-subscription.md
new file mode 100644
index 000000000..9ddf3fb3d
--- /dev/null
+++ b/.c3/refs/ref-ws-subscription.md
@@ -0,0 +1,58 @@
+---
+id: ref-ws-subscription
+c3-version: 4
+c3-seal: 5134ee6411de502e9a8ea47f69eaee09bd06ce28a7fc89a5684b7ea2371a4d06
+title: WebSocket Subscription
+type: ref
+goal: A single typed WebSocket handles both subscriptions (push) and commands (pull), with a shared envelope defined in src/shared/protocol.ts.
+---
+
+# ws-subscription
+
+## Goal
+
+A single typed WebSocket handles both subscriptions (push) and commands (pull), with a shared envelope defined in src/shared/protocol.ts.
+
+## Choice
+
+One WS per client. Server-side ws-router multiplexes subscribe/unsubscribe/command. Client-side socket.ts maintains the connection and dispatches typed envelopes.
+
+## Why
+
+Keeps the wire count flat, reuses the auth cookie, pairs naturally with the reactive read-model broadcast. Avoids REST polling, still supports one-shot commands.
+
+## How
+
+| Guideline | Example |
+| --- | --- |
+| All message shapes live in src/shared/protocol.ts | WsInbound / WsOutbound unions |
+| Commands return correlation IDs | request/response still works over the same socket |
+| Subscriptions receive full snapshots, not diffs | simpler reconciliation |
+
+## Not This
+
+| Alternative | Rejected Because |
+| --- | --- |
+| ... | ... |
+
+## Scope
+
+**Applies to:**
+
+-
+
+**Does NOT apply to:**
+
+-
+
+## Override
+
+To override this ref:
+
+1. Document justification in an ADR under "Pattern Overrides"
+2. Cite this ref and explain why the override is necessary
+3. Specify the scope of the override (which components deviate)
+
+## Cited By
+
+- c3-{N}{NN} ({component name})
diff --git a/.c3/refs/ref-zustand-store.md b/.c3/refs/ref-zustand-store.md
new file mode 100644
index 000000000..b8c3aa6b8
--- /dev/null
+++ b/.c3/refs/ref-zustand-store.md
@@ -0,0 +1,58 @@
+---
+id: ref-zustand-store
+c3-version: 4
+c3-seal: fcb22fc444cf64ccc44318aabef415b2ff7e1178f9bb489e25aa3c72b9783006
+title: Zustand Store Pattern
+type: ref
+goal: Client UI state lives in small Zustand stores scoped by concern (chat input, preferences, sidebar, terminal), persisted selectively via localStorage.
+---
+
+# zustand-store
+
+## Goal
+
+Client UI state lives in small Zustand stores scoped by concern (chat input, preferences, sidebar, terminal), persisted selectively via localStorage.
+
+## Choice
+
+One store per concern under src/client/stores/. Prefer selectors + shallow equality. Persist via zustand/middleware when state must survive reloads.
+
+## Why
+
+Lightweight, no Provider tree, easy to test. Aligns with server-pushed snapshots (stores only hold UI-local state, server state comes via socket).
+
+## How
+
+| Guideline | Example |
+| --- | --- |
+| One concern per store file | chatInputStore, rightSidebarStore |
+| Colocate a *.test.ts | chatInputStore.test.ts |
+| Never store server-derived truth | server snapshots live in useKannaState hook, not a store |
+
+## Not This
+
+| Alternative | Rejected Because |
+| --- | --- |
+| ... | ... |
+
+## Scope
+
+**Applies to:**
+
+-
+
+**Does NOT apply to:**
+
+-
+
+## Override
+
+To override this ref:
+
+1. Document justification in an ADR under "Pattern Overrides"
+2. Cite this ref and explain why the override is necessary
+3. Specify the scope of the override (which components deviate)
+
+## Cited By
+
+- c3-{N}{NN} ({component name})
diff --git a/.c3/rules/rule-colocated-bun-test.md b/.c3/rules/rule-colocated-bun-test.md
new file mode 100644
index 000000000..3c51a78dd
--- /dev/null
+++ b/.c3/rules/rule-colocated-bun-test.md
@@ -0,0 +1,82 @@
+---
+id: rule-colocated-bun-test
+c3-seal: 656866c97ce4b026e5395b9c402ca3adb5ead39d1f83d2c9aed860ee2f80d8f8
+title: colocated-bun-test
+type: rule
+goal: Every Kanna test must sit next to the file under test, share its basename, and run under `bun test`. No `__tests__/` directories, no separate test packages, no second runner. Live-API tests use `.live.test.ts` and are gated by environment.
+---
+
+# colocated-bun-test
+
+## Goal
+
+Every Kanna test must sit next to the file under test, share its basename, and run under `bun test`. No `__tests__/` directories, no separate test packages, no second runner. Live-API tests use `.live.test.ts` and are gated by environment.
+
+## Rule
+
+All test files must live in the same directory as the file under test and be named `.test.ts` or `.test.tsx`; live integration tests must be named `.live.test.ts` so CI can opt out.
+
+## Golden Example
+
+```ts
+// src/server/auth.test.ts
+import { afterEach, describe, expect, test } from "bun:test" // REQUIRED: bun:test imports
+import { mkdtemp, rm, writeFile } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import path from "node:path"
+import { persistProjectUpload } from "./uploads" // REQUIRED: relative import — test sits beside impl
+import { startKannaServer } from "./server"
+
+const tempDirs: string[] = []
+
+afterEach(async () => {
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
+})
+
+async function startPasswordServer(options: { // OPTIONAL: local helper hoisted above describe
+ trustProxy?: boolean
+ port?: number
+ dataDir?: string
+} = {}) {
+ // ...
+}
+
+describe("auth", () => { // REQUIRED: describe block scoping
+ test("rejects request without cookie", async () => { // REQUIRED: test() not it()
+ // ...
+ })
+})
+```
+
+File: `src/server/auth.test.ts` lives next to `src/server/auth.ts`.
+
+## Not This
+
+| Anti-Pattern | Correct | Why Wrong Here |
+| --- | --- | --- |
+| tests/auth.test.ts (separate root) | src/server/auth.test.ts (next to impl) | Breaks bun test src/server/auth.test.ts fast-iteration glob; reviewers cannot jump from impl to test |
+| auth.spec.ts | auth.test.ts | bun test glob requires .test.ts(x) — .spec.ts is silently skipped |
+| import { describe, test } from "vitest" | import { describe, test } from "bun:test" | Two runners cause framework churn; CI runs only bun test |
+| import "jest" in any test | bun:test only | Same as above |
+| auth.test.ts calls a live HTTP API unconditionally | rename to auth.live.test.ts | Live tests must be gated by .live.test.ts so CI runners skip them |
+
+## Scope
+
+**Applies to:**
+
+- Every `.ts` or `.tsx` file under `src/` that ships behavior — must have a colocated `.test.ts(x)` unless explicitly excluded
+- Server, client, and shared packages alike
+
+**Does NOT apply to:**
+
+- Pure declaration files (`*.d.ts`)
+- Generated code under `dist/` and assets under `public/`
+- Files listed under `_exclude` in `.c3/code-map.yaml`
+
+## Override
+
+To deviate:
+
+1. Add the path to `_exclude` in `.c3/code-map.yaml` with a comment naming the reason
+2. Document in an ADR `Compliance Rules` row with action `override`
+3. Cite rule-colocated-bun-test and the exact path skipped
diff --git a/.c3/rules/rule-mcp-name-reserved.md b/.c3/rules/rule-mcp-name-reserved.md
new file mode 100644
index 000000000..1491b0c7d
--- /dev/null
+++ b/.c3/rules/rule-mcp-name-reserved.md
@@ -0,0 +1,81 @@
+---
+id: rule-mcp-name-reserved
+c3-seal: b2f0e61c6e33a09512d44846f04e15ae5339e248f3cf6528192e986c971ca015
+title: mcp-name-reserved
+type: rule
+goal: |-
+ User MCP server names registered in `customMcpServers` must never equal
+ `KANNA_MCP_SERVER_NAME` ("kanna"). Enforced at storage, SDK driver, and PTY
+ driver so the Kanna-internal MCP tool surface is never shadowed or overwritten
+ by a user-supplied server.
+---
+
+# mcp-name-reserved
+
+## Goal
+
+User MCP server names registered in `customMcpServers` must never equal
+`KANNA_MCP_SERVER_NAME` ("kanna"). Enforced at storage, SDK driver, and PTY
+driver so the Kanna-internal MCP tool surface is never shadowed or overwritten
+by a user-supplied server.
+
+## Rule
+
+User MCP server names registered in `customMcpServers` must never equal
+`KANNA_MCP_SERVER_NAME` ("kanna"). Enforced at storage (`validateMcpShape`),
+SDK driver (`buildUserMcpServers`), and PTY driver (`buildMcpConfigJson`
+filter).
+
+## Golden Example
+
+```ts
+// src/shared/app-settings.ts
+const KANNA_MCP_SERVER_NAME = "kanna"
+
+export function validateMcpShape(entry: unknown): McpServerConfig {
+ const parsed = McpServerConfigSchema.parse(entry)
+ if (parsed.name === KANNA_MCP_SERVER_NAME) {
+ throw new Error(`MCP server name "${KANNA_MCP_SERVER_NAME}" is reserved`)
+ }
+ return parsed
+}
+```
+
+```ts
+// src/server/agent.ts
+export function buildUserMcpServers(servers: McpServerConfig[]): McpServersMap {
+ return Object.fromEntries(
+ servers
+ .filter((s) => s.enabled && s.name !== KANNA_MCP_SERVER_NAME) // belt-and-suspenders
+ .map((s) => [s.name, toSdkTransportConfig(s)]),
+ )
+}
+```
+
+## Not This
+
+| Anti-Pattern | Correct | Why Wrong Here |
+| --- | --- | --- |
+| Skip name check in buildUserMcpServers because validateMcpShape already rejects it | Keep the filter in all three sites | Defense-in-depth: storage validation can be bypassed by direct DB writes or migration gaps |
+| Allow kanna name and rely on merge-order to win | Reject at each boundary | If user server wins the merge, mcp__kanna__* shims disappear from Claude's tool list |
+| Only enforce at the API route level | Enforce at storage + both driver build functions | Driver functions receive deserialized AppSettingsSnapshot; they must not trust that storage already validated |
+
+## Scope
+
+**Applies to:**
+
+- `src/shared/app-settings.ts` — `validateMcpShape` storage guard
+- `src/server/agent.ts` — `buildUserMcpServers` SDK driver filter
+- `src/server/kanna-mcp-http.ts` — `buildMcpConfigJson` PTY driver filter
+
+**Does NOT apply to:**
+
+- The internal `kanna` server entry itself, which is always constructed by `buildMcpConfigJson` / the SDK driver, never from user input
+
+## Override
+
+To deviate:
+
+1. Document in an ADR `Compliance Rules` row with action `override` and a repo-specific reason
+2. Cite rule-mcp-name-reserved
+3. Name the exact call site and provide an alternative guard that prevents the `kanna` name from being injected into either driver's server map
diff --git a/.c3/rules/rule-strong-typing.md b/.c3/rules/rule-strong-typing.md
new file mode 100644
index 000000000..e21313ed4
--- /dev/null
+++ b/.c3/rules/rule-strong-typing.md
@@ -0,0 +1,75 @@
+---
+id: rule-strong-typing
+c3-seal: e7e2a6050dc3dd2cef2272c7201fbb8acba0ad9629fa5fcf8f04d525b5b360c1
+title: strong-typing
+type: rule
+goal: All values crossing a Kanna boundary (client↔server WebSocket envelopes, JSONL events↔read-models, provider adapter↔agent coordinator, shared module exports) must have a named TypeScript type. No `any`, no `unknown` without narrowing, no untyped object literals at boundaries. This is a project-wide standard for every package in `src/`.
+---
+
+# strong-typing
+
+## Goal
+
+All values crossing a Kanna boundary (client↔server WebSocket envelopes, JSONL events↔read-models, provider adapter↔agent coordinator, shared module exports) must have a named TypeScript type. No `any`, no `unknown` without narrowing, no untyped object literals at boundaries. This is a project-wide standard for every package in `src/`.
+
+## Rule
+
+All boundary types must be named exports (interface or discriminated union) declared in `src/shared/**` or the owning module — never `any`, never an untyped inline object, never a `Record` left unnarrowed.
+
+## Golden Example
+
+```ts
+// src/shared/types.ts
+export type AgentProvider = "claude" | "codex" // REQUIRED: discriminated union literal
+export type AppThemePreference = "light" | "dark" | "system" // REQUIRED: enumerate every variant
+export type AttachmentKind = "image" | "file" | "mention"
+
+export interface SkillSearchResult { // REQUIRED: named interface, exported
+ id: string // REQUIRED: every field typed
+ skillId: string
+ name: string
+ installs: number
+ source: string
+}
+
+export interface SkillSearchSnapshot { // REQUIRED: named interface for boundary value
+ query: string
+ searchType: string
+ skills: SkillSearchResult[] // REQUIRED: nested type by name, not inline
+ count: number
+ duration_ms: number // OPTIONAL: snake_case field allowed at protocol boundary
+}
+```
+
+File: `src/shared/types.ts`
+
+## Not This
+
+| Anti-Pattern | Correct | Why Wrong Here |
+| --- | --- | --- |
+| function handle(payload: any) { ... } | function handle(payload: WsEnvelope) { ... } | Loses discriminated-union narrowing; tool-hydration switch cannot exhaustively check kinds |
+| const event = JSON.parse(line) as Record | Parse into a named KannaEvent union via runtime guard | Read-model projection compiles but crashes when shape drifts |
+| interface Foo { data: object } | interface Foo { data: SkillSearchResult } | object accepts anything; refactors do not catch field renames |
+| const result: { ok: boolean; data?: any } = ... | Declare interface Result { ok: boolean; data?: SkillSearchResult } and export | Inline object types do not survive cross-file refactors |
+
+## Scope
+
+**Applies to:**
+
+- `src/shared/**/*.ts` — wire protocol, shared domain types
+- `src/server/events.ts`, `src/server/read-models.ts`, `src/server/agent.ts`, `src/server/codex-app-server.ts`, `src/server/provider-catalog.ts`, `src/server/process-utils.ts`, `src/server/update-manager.ts`, `src/server/cloudflare-tunnel/**/*.ts`
+- `src/client/app/socket.ts`, `src/client/components/messages/**/*.tsx`, `src/client/components/ui/**/*.tsx`, `src/client/stores/**/*.ts`
+
+**Does NOT apply to:**
+
+- Test files using `as unknown as ` for fixture narrowing (allowed only inside `*.test.ts(x)` and only at the assertion site)
+- Third-party untyped JSON crossing the boundary once — must be narrowed into a named type before any internal consumer sees it
+
+## Override
+
+To deviate:
+
+1. Document in an ADR `Compliance Rules` row with action `override` and a repo-specific reason
+2. Cite rule-strong-typing
+3. Name the exact symbol or file scope of the deviation
+4. Add a runtime narrowing guard at the boundary so downstream code still sees a named type
diff --git a/.c3/rules/rule-zustand-store.md b/.c3/rules/rule-zustand-store.md
new file mode 100644
index 000000000..ce7dce770
--- /dev/null
+++ b/.c3/rules/rule-zustand-store.md
@@ -0,0 +1,90 @@
+---
+id: rule-zustand-store
+c3-seal: 211b6f01a30b45b1c9a6d95fbcb70a1f4ce6fba9ef3b4e801b839960e2c3cf10
+title: zustand-store
+type: rule
+goal: All client UI-local state in Kanna lives in small Zustand stores under `src/client/stores/Store.ts`, one concern per file, with a colocated `Store.test.ts`. Server-derived truth must NOT live in a Zustand store — it lives in the WebSocket-backed `useKannaState` hook.
+---
+
+# zustand-store
+
+## Goal
+
+All client UI-local state in Kanna lives in small Zustand stores under `src/client/stores/Store.ts`, one concern per file, with a colocated `Store.test.ts`. Server-derived truth must NOT live in a Zustand store — it lives in the WebSocket-backed `useKannaState` hook.
+
+## Rule
+
+All client UI-state stores must use `create()` from `zustand`, live at `src/client/stores/(Store)?.ts`, expose a single hook (`useStore`), and persist only via `zustand/middleware`'s `persist` — never via custom `localStorage` writes.
+
+## Golden Example
+
+```ts
+// src/client/stores/preferences.ts
+import { create } from "zustand" // REQUIRED: zustand create import
+import { persist } from "zustand/middleware" // REQUIRED for persisted stores; OPTIONAL otherwise
+
+interface PreferencesState { // REQUIRED: named state interface
+ autoResumeOnRateLimit: boolean
+ setAutoResumeOnRateLimit: (value: boolean) => void // REQUIRED: setters live in the state shape
+}
+
+interface PersistedPreferencesState { // REQUIRED when persist() is used: separate shape for migrate()
+ autoResumeOnRateLimit?: boolean
+}
+
+function migratePreferencesState( // REQUIRED when version > 0
+ persistedState: Partial | undefined,
+): Pick {
+ return {
+ autoResumeOnRateLimit: Boolean(persistedState?.autoResumeOnRateLimit),
+ }
+}
+
+export const usePreferencesStore = create()( // REQUIRED: single exported hook named useStore
+ persist(
+ (set) => ({
+ autoResumeOnRateLimit: false,
+ setAutoResumeOnRateLimit: (value) => set({ autoResumeOnRateLimit: value }),
+ }),
+ {
+ name: "kanna-preferences", // REQUIRED: stable storage key
+ version: 1,
+ migrate: (persistedState) => migratePreferencesState(
+ persistedState as Partial | undefined,
+ ),
+ },
+ ),
+)
+```
+
+File: `src/client/stores/preferences.ts` (colocated test: `src/client/stores/preferences.test.ts`).
+
+## Not This
+
+| Anti-Pattern | Correct | Why Wrong Here |
+| --- | --- | --- |
+| React.createContext + provider for UI state | create() Zustand store | Adds provider tree, breaks selector ergonomics, makes testing harder |
+| useState lifted into App for cross-route state | Zustand store in src/client/stores/ | Re-renders entire subtree; routes lose isolation |
+| Store holds server snapshot (chats: ChatSnapshot[]) | Server state stays in useKannaState hook (WS-backed) | Two sources of truth diverge; socket reconnect overwrites store mid-edit |
+| localStorage.setItem("foo", ...) directly in store | persist middleware with name: key | Custom writes bypass schema versioning + migrate; reload corrupts state |
+| Store file at src/client/app/myStore.ts | src/client/stores/myStore.ts | Breaks the single-directory contract; lookup + audit cannot find it |
+
+## Scope
+
+**Applies to:**
+
+- All UI-local state for the client app: chat input, preferences, sidebar collapse, terminal layout, sound prefs, slash-command picker state, etc.
+- Both persisted (`persist`) and ephemeral (no middleware) stores
+
+**Does NOT apply to:**
+
+- Server snapshots (chats, projects, messages, status) — these arrive over WebSocket and live in the `useKannaState` hook, not a store
+- Component-local state that never crosses a single component boundary — `useState` is correct there
+
+## Override
+
+To deviate:
+
+1. Document in an ADR `Compliance Rules` row with action `override` and a repo-specific reason
+2. Cite rule-zustand-store
+3. Name the exact concern and why a non-Zustand container is needed
diff --git a/.claude/skills/kanna-debug/SKILL.md b/.claude/skills/kanna-debug/SKILL.md
new file mode 100644
index 000000000..c515cb66d
--- /dev/null
+++ b/.claude/skills/kanna-debug/SKILL.md
@@ -0,0 +1,96 @@
+---
+name: kanna-debug
+description: Pull rich context from a Kanna chat session transcript when debugging or troubleshooting a Kanna issue. Use whenever the user pastes a Kanna session/chat id (UUID like `ab06e5ab-6f15-42ab-b630-fbb7abfe7640`), says things like "debug this session", "what happened in chat X", "the chat got stuck", "this session crashed", "investigate session Y", "explain why the tool failed", or otherwise references a Kanna chat that needs analysis. Also use when the user is debugging Kanna server behavior (event-store, agent loop, tool callbacks, PTY driver) and mentions a session id — the transcript shows exactly which tool calls fired, what the model said, and where errors surfaced. Do not use for stack traces or logs that are not Kanna chat transcripts.
+user-invocable: false
+---
+
+# Kanna debug — read the chat transcript
+
+Kanna persists every chat to a per-chat JSONL transcript on disk. When the user gives you a session/chat id and asks why something happened, that file is the source of truth: it records every user message, every assistant response, every tool call and its result, in order. Read it before you guess.
+
+## Where transcripts live
+
+Kanna stores transcripts under its data dir:
+
+- Production runtime: `~/.kanna/data/transcripts/.jsonl`
+- Dev runtime (`KANNA_BRANDING_OVERRIDE=dev` or running `bun dev`): `~/.kanna-dev/data/transcripts/.jsonl`
+
+The `` is the UUID the user pastes. Try the prod path first; fall back to dev. If both miss, list the directory and grep — chat ids can collide with old / archived sessions and the user may have copied a partial id.
+
+```bash
+# Resolve path, prefer prod
+TRANSCRIPT="$HOME/.kanna/data/transcripts/.jsonl"
+[ -f "$TRANSCRIPT" ] || TRANSCRIPT="$HOME/.kanna-dev/data/transcripts/.jsonl"
+[ -f "$TRANSCRIPT" ] || ls ~/.kanna/data/transcripts/ ~/.kanna-dev/data/transcripts/ 2>/dev/null | grep
+```
+
+## Step 1 — summarize first, then drill in
+
+Transcripts get big fast (hundreds of tool calls = tens of MB). Reading the raw file blindly burns context. Run the bundled summarizer first; it produces a compact timeline of every entry with tool name, status, and a short preview. Only after you know which entry is interesting should you `jq` the original line for full detail.
+
+```bash
+python3 scripts/summarize_transcript.py "$TRANSCRIPT"
+```
+
+Flags (all optional):
+
+- `--kinds tool_call,tool_result,user_prompt` — filter to specific entry kinds
+- `--tool Bash,Edit` — filter tool calls to specific tools
+- `--errors-only` — show only failed tool results
+- `--last N` — show only the final N entries (useful for "what crashed at the end")
+- `--around <_id>` — show 5 entries before/after a specific entry id
+
+## Step 2 — pull the full payload for entries that matter
+
+The summarizer prints each entry's `_id`. Use `jq` to retrieve the full JSON, which carries the raw SDK payload in `debugRaw`:
+
+```bash
+jq -c 'select(._id == "")' "$TRANSCRIPT"
+```
+
+For a tool call, the interesting fields are `tool.toolName`, `tool.input`, and the matching `tool_result.content` / `isError`. For an assistant message, `text` is what the model said. The `debugRaw` field is the unparsed JSONL frame the SDK or PTY driver wrote — useful when you suspect the parser dropped data.
+
+## Entry shapes
+
+Each line is one JSON object. Common fields: `_id` (uuid), `createdAt` (epoch ms), `kind`, plus kind-specific fields.
+
+| kind | key fields | meaning |
+|-----------------|------------------------------------------------------------------------|------------------------------------------|
+| `system_init` | `provider`, `model`, `tools[]`, `mcpServers[]`, `debugRaw` | session start — confirms model + tools |
+| `account_info` | `accountInfo.tokenSource`, `accountInfo.apiProvider` | which OAuth token / billing path |
+| `user_prompt` | `content`, `attachments[]` | what the human typed |
+| `assistant_text`| `text` | model's visible reply |
+| `tool_call` | `tool.toolName`, `tool.toolId`, `tool.input` | model invoked a tool |
+| `tool_result` | `toolId`, `content`, `isError` | tool returned (`isError: true` = failed) |
+
+Pair `tool_call.tool.toolId` with `tool_result.toolId` to match a call to its result.
+
+## What to look for, by symptom
+
+- **"Session got stuck / hung"** → check the last `tool_call` without a matching `tool_result`. The agent is waiting on something that never returned. For `AskUserQuestion` / `ExitPlanMode` under `KANNA_MCP_TOOL_CALLBACKS=1`, cross-check `tool-requests.jsonl` for a pending durable approval.
+- **"Tool failed"** → `--errors-only` lists every `isError: true`. The `content` field has the error string the SDK surfaced.
+- **"Model did the wrong thing"** → read the `user_prompt` then the next 1-2 `assistant_text` and `tool_call` entries. Often the prompt was ambiguous or an attachment was missing.
+- **"Permission denied / approval loop"** → search for `tool` names matching `mcp__kanna__*` and look at the result content; the durable approval protocol writes a deny reason there.
+- **"Billing went to API not subscription"** → check the `system_init.debugRaw.apiKeySource` and the `account_info.tokenSource`. PTY driver requires `apiKeySource: "none"` and a CLAUDE_CODE_OAUTH_TOKEN source.
+- **"Wrong model / unexpected model switch"** → `system_init.model` shows the start model; the SDK writes a new `system_init` on model switch, so multiple `system_init` lines = mid-session switch.
+
+## Step 3 — connect to the server-side event log if needed
+
+The transcript is the model-facing view. Server-side events (chat lifecycle, tool-request decisions, push notifications) live in sibling files:
+
+- `~/.kanna/data/turns.jsonl` — turn events per chat
+- `~/.kanna/data/tool-requests.jsonl` — durable approval requests
+- `~/.kanna/data/chats.jsonl` — chat create/rename/archive
+- `~/.kanna/data/snapshot.json` — periodic full state
+
+Filter any of these by `chatId`:
+
+```bash
+jq -c 'select(.chatId == "")' ~/.kanna/data/turns.jsonl
+```
+
+Cross-referencing a tool_call's `createdAt` with the matching `tool-requests.jsonl` entry tells you whether the user approved, denied, or the request timed out.
+
+## Why this matters
+
+Without the transcript you are guessing. With it you can say exactly: "at 11:03:42 the model called Bash with `rm -rf …`, the tool callback returned deny:timeout 600s later, then assistant_text said 'I cannot proceed' and the chat went idle." That precision is what makes Kanna bug reports actionable instead of "it didn't work".
diff --git a/.claude/skills/kanna-debug/scripts/summarize_transcript.py b/.claude/skills/kanna-debug/scripts/summarize_transcript.py
new file mode 100755
index 000000000..b1bf9f6cd
--- /dev/null
+++ b/.claude/skills/kanna-debug/scripts/summarize_transcript.py
@@ -0,0 +1,217 @@
+#!/usr/bin/env python3
+"""Compact summary of a Kanna chat transcript JSONL.
+
+Reads ~/.kanna/data/transcripts/.jsonl and prints a one-line-per-entry
+timeline so Claude can scan a huge session without pulling the whole file into
+context. Each line shows: index, timestamp, kind, and a kind-specific preview.
+
+Usage:
+ python3 summarize_transcript.py [flags]
+
+Flags:
+ --kinds K1,K2 comma list of kinds to keep
+ --tool T1,T2 filter tool_call/tool_result to these tool names
+ --errors-only show only tool_result with isError=true
+ --last N only the last N entries (post-filter)
+ --around ENTRY_ID 5 entries before/after the entry whose _id matches
+ --json emit JSON lines instead of human format
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from typing import Any
+
+
+@dataclass
+class Entry:
+ index: int
+ raw: dict[str, Any]
+
+ @property
+ def kind(self) -> str:
+ return self.raw.get("kind", "?")
+
+ @property
+ def created_at(self) -> int:
+ return int(self.raw.get("createdAt", 0))
+
+ @property
+ def entry_id(self) -> str:
+ return self.raw.get("_id", "")
+
+ @property
+ def tool_name(self) -> str | None:
+ if self.kind == "tool_call":
+ return self.raw.get("tool", {}).get("toolName")
+ return None
+
+ @property
+ def tool_id(self) -> str | None:
+ if self.kind == "tool_call":
+ return self.raw.get("tool", {}).get("toolId")
+ if self.kind == "tool_result":
+ return self.raw.get("toolId")
+ return None
+
+ @property
+ def is_error(self) -> bool:
+ return self.kind == "tool_result" and bool(self.raw.get("isError"))
+
+
+def load(path: str) -> list[Entry]:
+ out: list[Entry] = []
+ with open(path, "r", encoding="utf-8") as fh:
+ for i, line in enumerate(fh):
+ line = line.strip()
+ if not line:
+ continue
+ try:
+ out.append(Entry(i, json.loads(line)))
+ except json.JSONDecodeError as exc:
+ print(f"warning: skipping malformed line {i}: {exc}", file=sys.stderr)
+ return out
+
+
+def fmt_ts(ms: int) -> str:
+ if ms <= 0:
+ return "?"
+ return datetime.fromtimestamp(ms / 1000, tz=timezone.utc).strftime("%H:%M:%S")
+
+
+def preview(entry: Entry, width: int = 100) -> str:
+ raw = entry.raw
+ kind = entry.kind
+ if kind == "user_prompt":
+ text = raw.get("content", "").replace("\n", " ")
+ attachments = raw.get("attachments") or []
+ suffix = f" [+{len(attachments)} attachments]" if attachments else ""
+ return _trim(text, width) + suffix
+ if kind == "assistant_text":
+ return _trim(raw.get("text", "").replace("\n", " "), width)
+ if kind == "tool_call":
+ tool = raw.get("tool", {}) or {}
+ name = tool.get("toolName", "?")
+ tool_id = tool.get("toolId", "")
+ input_blob = json.dumps(tool.get("input", {}), ensure_ascii=False)
+ return f"{name}({_trim(input_blob, width)}) id={_short(tool_id)}"
+ if kind == "tool_result":
+ tool_id = raw.get("toolId", "")
+ err = "ERROR " if raw.get("isError") else ""
+ content = raw.get("content", "")
+ if isinstance(content, list):
+ content = json.dumps(content, ensure_ascii=False)
+ if not isinstance(content, str):
+ content = str(content)
+ return f"{err}id={_short(tool_id)} {_trim(content.replace(chr(10), ' '), width)}"
+ if kind == "system_init":
+ return f"model={raw.get('model','?')} provider={raw.get('provider','?')} tools={len(raw.get('tools') or [])}"
+ if kind == "account_info":
+ info = raw.get("accountInfo", {}) or {}
+ return f"tokenSource={info.get('tokenSource','?')} apiProvider={info.get('apiProvider','?')}"
+ return _trim(json.dumps({k: v for k, v in raw.items() if k != "debugRaw"}, ensure_ascii=False), width)
+
+
+def _trim(s: str, n: int) -> str:
+ if len(s) <= n:
+ return s
+ return s[: n - 1] + "…"
+
+
+def _short(tool_id: str) -> str:
+ return tool_id[-8:] if tool_id else "?"
+
+
+def apply_filters(entries: list[Entry], args: argparse.Namespace) -> list[Entry]:
+ kinds = set(args.kinds.split(",")) if args.kinds else None
+ tools = set(args.tool.split(",")) if args.tool else None
+
+ keep: list[Entry] = []
+ for e in entries:
+ if kinds and e.kind not in kinds:
+ continue
+ if tools and e.tool_name and e.tool_name not in tools:
+ continue
+ if tools and e.kind == "tool_result":
+ # keep tool_results whose paired tool_call passes the filter
+ keep.append(e)
+ continue
+ if args.errors_only and not e.is_error:
+ continue
+ keep.append(e)
+
+ if args.around:
+ anchor_idx = next((i for i, e in enumerate(keep) if e.entry_id == args.around), None)
+ if anchor_idx is None:
+ print(f"--around: no entry with _id={args.around} after filters", file=sys.stderr)
+ return []
+ lo = max(0, anchor_idx - 5)
+ hi = min(len(keep), anchor_idx + 6)
+ keep = keep[lo:hi]
+
+ if args.last:
+ keep = keep[-args.last :]
+ return keep
+
+
+def print_human(entries: list[Entry], total: int) -> None:
+ print(f"# Kanna transcript summary — {len(entries)}/{total} entries")
+ print()
+ print(f"{'idx':>4} {'time':<8} {'kind':<14} preview")
+ print("-" * 100)
+ for e in entries:
+ print(f"{e.index:>4} {fmt_ts(e.created_at):<8} {e.kind:<14} {preview(e)}")
+ if entries:
+ print()
+ print("# next steps")
+ print('# - full detail: jq -c \'select(._id == "")\' ')
+ print('# - context around one entry: --around ')
+
+
+def print_json(entries: list[Entry]) -> None:
+ for e in entries:
+ out = {
+ "index": e.index,
+ "_id": e.entry_id,
+ "createdAt": e.created_at,
+ "kind": e.kind,
+ "preview": preview(e, width=200),
+ }
+ if e.tool_name:
+ out["toolName"] = e.tool_name
+ if e.tool_id:
+ out["toolId"] = e.tool_id
+ if e.is_error:
+ out["isError"] = True
+ print(json.dumps(out, ensure_ascii=False))
+
+
+def main() -> int:
+ p = argparse.ArgumentParser(description="Summarize a Kanna chat transcript JSONL.")
+ p.add_argument("path")
+ p.add_argument("--kinds", help="comma list of kinds to keep")
+ p.add_argument("--tool", help="comma list of tool names to keep")
+ p.add_argument("--errors-only", action="store_true")
+ p.add_argument("--last", type=int)
+ p.add_argument("--around", help="entry _id to center on (5 before / 5 after)")
+ p.add_argument("--json", action="store_true")
+ args = p.parse_args()
+
+ entries = load(args.path)
+ if not entries:
+ print("empty transcript", file=sys.stderr)
+ return 1
+ filtered = apply_filters(entries, args)
+ if args.json:
+ print_json(filtered)
+ else:
+ print_human(filtered, total=len(entries))
+ return 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/.claude/skills/kanna-react-style/SKILL.md b/.claude/skills/kanna-react-style/SKILL.md
new file mode 100644
index 000000000..89fdcda06
--- /dev/null
+++ b/.claude/skills/kanna-react-style/SKILL.md
@@ -0,0 +1,265 @@
+---
+name: kanna-react-style
+description: React + TypeScript coding style for Kanna's client (src/client/**). Apply when creating or editing any .tsx/.ts file under src/client, src/shared, or src/server that ships UI behavior. Covers component shape, props typing, state-aware helpers, format helpers, snapshot-stable rendering, mobile/desktop variants, tabular numerics, project-Tooltip-over-native-title, centralized abstractions, co-located tests, and TDD commit cadence. Trigger on phrases like "add a component", "render X in the navbar", "format duration", "show state", "fix this UI bug", "extract a shared component", "write a test for this", or whenever editing existing components in src/client/components, src/client/app, or src/client/lib.
+user-invocable: false
+---
+
+# Kanna React style
+
+Patterns the Kanna client follows. Match them when adding or editing UI code so the codebase stays coherent.
+
+## File layout
+
+```
+src/shared/types.ts # cross-boundary types (server <-> client)
+src/server/... # event-sourced server, ports, read-models
+src/client/lib/.ts # pure helpers, lowest-level
+src/client/lib/.test.ts # co-located test
+src/client/components//.tsx
+src/client/components//.test.tsx
+src/client/app/.tsx # page-level composition
+```
+
+Prefer co-located tests (`Foo.tsx` next to `Foo.test.tsx`). Helpers separate from components — components import helpers, never the inverse.
+
+## Strong typing — no `any`, no `unknown`
+
+The repo's TS strictness is non-negotiable. Concrete types or interfaces, never `any`. `unknown` is acceptable only when narrowed within the same scope. Test files may use `as any` to reach private members or fixture mocks; production code may not.
+
+If a type doesn't exist yet, add it. Co-locate single-use types with the component; lift to `shared/types.ts` only when crossing the WS boundary.
+
+```ts
+// Yes
+interface Props {
+ message: ProcessedResultMessage
+}
+
+// No
+function ResultMessage({ message }: { message: any }) { ... }
+```
+
+## Pure helpers in `src/client/lib/`
+
+Format, label, and tone logic belongs in `src/client/lib/*.ts`, not inline in components. Helpers must be pure (no DOM, no `Date.now()`, no globals — take inputs as args).
+
+Examples from this codebase:
+- `formatDuration.ts` — `formatCompactDuration(ms)`, `formatLiveDuration(ms)`
+- `statusLabel.ts` — `statusLabel(status)`, `statusTone(status)`, `statusToneClass(tone)`
+
+Why pure: helpers are deterministic given args, so tests are trivial and rerenders are stable.
+
+```ts
+// Yes — pure, args supply state
+export function formatCompactDuration(ms: number): string { ... }
+
+// No — helper reads ambient time, callers can't snapshot
+export function formatAge(): string { return new Date().toString() }
+```
+
+## Snapshot-stable rendering
+
+When the server pushes timing/state via WS snapshots, format using the **server's** timestamp baked into the snapshot (e.g. `derivedAtMs`), not `Date.now()` at render. This keeps numbers stable across React rerenders that fire between events.
+
+```tsx
+// Yes
+{formatCompactDuration(timings.derivedAtMs - timings.stateEnteredAt)}
+
+// No — drifts on every rerender even with no new event
+{formatCompactDuration(Date.now() - timings.stateEnteredAt)}
+```
+
+Where Date.now is unavoidable (e.g. sidebar rows that don't carry derivedAtMs), accept it but use `tabular-nums` to mask jitter.
+
+## State-aware label + tone helpers
+
+When a single enum drives both human-readable text and visual tone, write three thin helpers next to each other:
+
+```ts
+// statusLabel.ts
+export function statusLabel(status: KannaStatus): string { /* enum -> "Idle"/"Running" */ }
+export function statusTone(status: KannaStatus): StatusTone { /* enum -> tone enum */ }
+export function statusToneClass(tone: StatusTone): string { /* tone -> Tailwind class */ }
+```
+
+Components compose: `statusToneClass(statusTone(status))`. Adding a new status touches one file. Never ship raw enum identifiers to UI (`waiting_for_user` should never render as text).
+
+## Centralize repeated markup
+
+Three lines is fine. Two near-identical JSX blocks across two render paths is duplication — extract to a component. Pattern from this codebase: `TurnDurationFooter` covers both success and failure branches of `ResultMessage`, parameterized by a `prefix` prop (`"Worked for"` vs `"Failed after"`).
+
+Signs you should extract:
+- Same wrapper markup in two `if/else` arms of one component
+- Same markup repeated across sibling components
+- Format helper invoked from JSX in three places
+
+```tsx
+// Yes — one source of truth, branches differ only by prop
+{success ? : }
+
+// No — copy-paste markup with different label string
+{success
+ ? Worked for {fmt(d)}
+ : Failed after {fmt(d)} }
+```
+
+## Check existing primitives before creating new ones
+
+Before writing a fresh component, scan the relevant `shared.tsx` (or `ui/`) for primitives that already cover the pattern. Two primitive registries to check:
+
+- **Generic UI**: `src/client/components/ui/` — `card.tsx`, `button.tsx`, `tooltip.tsx`, `dialog.tsx`, `popover.tsx`, `kbd.tsx`, `select.tsx`, `scroll-area.tsx`, `segmented-control.tsx`, `input.tsx`, `textarea.tsx`, `context-menu.tsx`, `app-dialog.tsx`, `animated-shiny-text.tsx`, `resizable.tsx`, `settings-header-button.tsx`.
+- **Message rendering**: `src/client/components/messages/shared.tsx` — `MetaRow`, `MetaContent`, `MetaSeparator`, `MetaLabel`, `MetaText`, `RuledLabel`, `ExpandableRow`, `MetaCodeBlock`, `VerticalLineContainer`, `getToolIcon`.
+
+Two-tier rule:
+1. **If a primitive already fits**, use it directly. No new file.
+2. **If you find yourself reaching for new ad-hoc markup that resembles a generic shape** (rule lines + label, dot + text + duration, icon + collapsible block, etc.), the right move is to add the **primitive** to the appropriate `shared.tsx` and have your specific component compose it. Don't bake the generic shape into a domain-specific file — it'll get re-invented next time.
+
+Real example from this codebase: `TurnDurationFooter` first inlined the `X ` sandwich. That sandwich is generic ("centered label flanked by horizontal rules"), so it was hoisted to `messages/shared.tsx` as `RuledLabel`. `TurnDurationFooter` shrank to a one-liner that composes `RuledLabel`. Future ruled footers (e.g. compact-summary boundaries, divider headers) can now use `RuledLabel` without copying the divider markup.
+
+```tsx
+// Yes — domain component composes shared primitive
+export function TurnDurationFooter({ durationMs, prefix = "Worked for" }: Props) {
+ if (durationMs <= 0) return null
+ return {prefix} {formatTurnDuration(durationMs)}
+}
+
+// No — generic rule-line shape baked into one component
+export function TurnDurationFooter(...) {
+ return (
+
+
+ {prefix} {fmt(durationMs)}
+
+
+ )
+}
+```
+
+When deciding whether a new shape is "generic enough" to belong in `shared.tsx`: ask whether two unrelated callers would plausibly want it. If yes, hoist it. If only one caller will ever want it, keep it co-located with that caller.
+
+## Defensive guard, not optional chaining the JSX
+
+When required props can be absent, render `null` (or a stable fallback wrapper) early. Don't sprinkle `?.` deep inside the JSX tree — readers can't tell what's optional.
+
+```tsx
+// Yes
+{timings && status ? (
+
+ {statusLabel(status)} {formatLiveDuration(timings.derivedAtMs - timings.stateEnteredAt)}
+
+) : (
+
// preserve layout
+)}
+
+// No
+
+ {status ? statusLabel(status) : ""}
+ {timings?.derivedAtMs ? formatLiveDuration(timings.derivedAtMs - timings.stateEnteredAt) : null}
+
+```
+
+The fallback `
` matters: without it, the sibling layout collapses when the data isn't ready.
+
+## Mobile vs desktop: CSS variants, not JS branches
+
+Use Tailwind's `hidden md:flex` / `flex md:hidden` to render two markups one of which the browser shows. Don't compute the breakpoint in React.
+
+```tsx
+// Yes — both render, CSS picks one
+<>
+ {full}
+ {compact}
+>
+
+// No — JS reads window, causes hydration mismatches and SSR drift
+{useIsMobile() ? : }
+```
+
+## `tabular-nums` for live numerics
+
+Any element where digits change in place (timers, counters, durations, monospace stamps) gets `tabular-nums`. Without it the row jitters horizontally as glyph widths change.
+
+```tsx
+{formatLiveDuration(elapsed)}
+```
+
+## Project Tooltip over native `title`
+
+Native `title=""` is laggy (~700ms hover delay), unstyled, and renders as one line joined by `·` or `\n`. Use the project's `Tooltip` / `TooltipTrigger` / `TooltipContent` from `src/client/components/ui/tooltip` for any breakdown longer than a few words.
+
+```tsx
+// Yes
+
+ {stateLabel}
+
+ Chat created {ago}
+ Idle {idle}
+ Running {running}
+
+
+
+// No
+{stateLabel}
+```
+
+## TDD commit cadence
+
+Bug fixes and small additions can ship as a single commit. New behavior is two commits:
+
+1. `test(scope): add tests (failing)` — the failing test file alone
+2. `feat(scope): implement ` — minimal code to pass
+
+Why split: the first commit alone proves the test is meaningful (it can fail). A green test added alongside the implementation can be a tautology. The repo's history under `feature/chat-session-timings` follows this pattern.
+
+## Conventional commit prefixes
+
+Match the repo's existing convention:
+- `feat(scope): ...` — new behavior
+- `fix(scope): ...` — bug fix
+- `test(scope): ...` — test-only changes
+- `chore(scope): ...` — refactor, dependency, infra
+- `docs(scope): ...` — markdown only
+- `ux(scope): ...` — visual / interaction polish that isn't a bug
+
+Scope is the most specific subdir or component name (`chat-navbar`, `read-models`, `event-store`, `result-message`). Subject ≤ 50 chars, present tense, no period.
+
+## Don't over-comment
+
+Default: zero comments. Only annotate **why** when:
+- A subtle invariant exists that the code can't express
+- A workaround for a specific bug or constraint
+- An intentional `Math.max(0, ...)` guarding against clock skew
+
+Don't restate the code, don't reference the PR, don't add `// added for issue #28` — those rot. Prefer renaming the variable or extracting a helper over writing a comment.
+
+## Live state vs idle state styling
+
+When a row or pill represents a live process, give the live variant a **different visual weight** than the idle one:
+- Idle: `text-muted-foreground`, default opacity
+- Live (running/waiting/failed): full opacity, `font-medium`, tone-colored dot
+- Use `tabular-nums` on the live duration so it ticks without re-laying-out the row
+
+The sidebar `ChatRow` widens its trailing slot (`w-6 → w-20`) when the chat is live so a longer `Running 0:12` label fits without colliding with the hover-only action buttons. Apply the same trick anywhere a live label needs more horizontal space than its idle counterpart.
+
+## Server boundary contract
+
+Anything that crosses the WS boundary lives in `src/shared/types.ts`. `ChatRuntime`, `SidebarChatRow`, `ChatStateTimings` — all defined once and imported on both sides. Never duplicate a shape on the client. Never let a server-only type leak into the client (server types live in `src/server/events.ts` etc.).
+
+When extending a shared type, keep new fields:
+- Required if every snapshot will populate them (e.g. `timings: ChatStateTimings` on `ChatRuntime`)
+- Optional if only some rows carry them (e.g. `stateEnteredAt?: number` on `SidebarChatRow`)
+
+## Resource safety when running tests
+
+Tests run via `bun test `. Only run tests for files you change:
+
+```bash
+bun test src/server/event-store src/server/read-models
+bun test src/client/lib/formatDuration src/client/components/messages
+bunx tsc --noEmit
+```
+
+Never run the full suite from a subagent — parallel full builds exhaust the host. Targeted runs scoped to changed files + a typecheck cover the same ground for the work you actually did.
+
+## When in doubt
+
+Look at recent commits on `main` (or a feature branch shipped recently — e.g. `feature/chat-session-timings`) for the most current example of a pattern. The code on disk is the spec; this skill is a quick reference for the shapes that recur.
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
deleted file mode 100644
index b6f1a23db..000000000
--- a/.github/workflows/publish.yml
+++ /dev/null
@@ -1,27 +0,0 @@
-name: Publish to npm
-
-on:
- release:
- types: [published]
-
-jobs:
- publish:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- id-token: write
- steps:
- - uses: actions/checkout@v4
-
- - uses: oven-sh/setup-bun@v2
-
- - run: bun install
-
- - run: bun run build
-
- - uses: actions/setup-node@v4
- with:
- node-version: "24"
- registry-url: "https://registry.npmjs.org"
-
- - run: npm publish --provenance --access public
diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml
new file mode 100644
index 000000000..1298926cb
--- /dev/null
+++ b/.github/workflows/release-please.yml
@@ -0,0 +1,55 @@
+name: Release Please
+
+on:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: write
+ pull-requests: write
+
+jobs:
+ release-please:
+ runs-on: ubuntu-latest
+ outputs:
+ release_created: ${{ steps.release.outputs.release_created }}
+ tag_name: ${{ steps.release.outputs.tag_name }}
+ steps:
+ - uses: googleapis/release-please-action@v4
+ id: release
+ with:
+ config-file: release-please-config.json
+ manifest-file: .release-please-manifest.json
+
+ publish:
+ needs: release-please
+ if: needs.release-please.outputs.release_created == 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ id-token: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: oven-sh/setup-bun@v2
+
+ - run: bun install --frozen-lockfile
+
+ - run: bun run build
+
+ - name: Run tests
+ run: timeout --foreground --signal=SIGTERM --kill-after=10 420 bash scripts/ci-test-with-hang-diagnostics.sh
+ env:
+ GIT_TERMINAL_PROMPT: "0"
+ HANG_AFTER: "170"
+
+ - uses: actions/setup-node@v4
+ with:
+ node-version: "24"
+ registry-url: "https://registry.npmjs.org"
+ scope: "@cuongtran001"
+
+ - run: npm publish --provenance --access public
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
new file mode 100644
index 000000000..fed4ce467
--- /dev/null
+++ b/.github/workflows/test.yml
@@ -0,0 +1,35 @@
+name: Test
+
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ branches:
+ - main
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: oven-sh/setup-bun@v2
+
+ - run: bun install --frozen-lockfile
+
+ - run: bun run lint
+
+ - name: Type check
+ run: bunx tsc --noEmit
+
+ - run: bun run build
+
+ - name: Run tests
+ run: timeout --foreground --signal=SIGTERM --kill-after=10 420 bash scripts/ci-test-with-hang-diagnostics.sh
+ env:
+ GIT_TERMINAL_PROMPT: "0"
+ HANG_AFTER: "170"
diff --git a/.github/workflows/wiki-deploy.yml b/.github/workflows/wiki-deploy.yml
new file mode 100644
index 000000000..4938b4be1
--- /dev/null
+++ b/.github/workflows/wiki-deploy.yml
@@ -0,0 +1,41 @@
+name: Deploy Wiki
+
+on:
+ push:
+ branches: [main]
+ paths: ['wiki/**', '.github/workflows/wiki-deploy.yml']
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ pages: write
+ id-token: write
+
+concurrency:
+ group: pages
+ cancel-in-progress: false
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: oven-sh/setup-bun@v2
+ - run: bun install
+ working-directory: wiki
+ - run: bun run build
+ working-directory: wiki
+ - uses: actions/configure-pages@v5
+ - uses: actions/upload-pages-artifact@v3
+ with:
+ path: wiki/dist
+
+ deploy:
+ needs: build
+ runs-on: ubuntu-latest
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ steps:
+ - id: deployment
+ uses: actions/deploy-pages@v4
diff --git a/.gitignore b/.gitignore
index d23336174..3db9671b2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,19 @@ Thumbs.db
# vite
vite.config.ts.timestamp-*
/.kanna
+
+# git worktrees
+.worktrees/
+
+# claude code session artifacts
+.claude/scheduled_tasks.lock
+
+# playwright MCP scratch
+.playwright-mcp/
+
+# pm2 rendered config (generated from scripts/pm2.config.cjs.tmpl)
+scripts/pm2.config.cjs
+
+# pm2 local secrets (cloudflared token, password) — sourced by deploy.sh
+scripts/pm2.env
+.c3/c3.db
diff --git a/.impeccable/design.json b/.impeccable/design.json
new file mode 100644
index 000000000..6eba5ec28
--- /dev/null
+++ b/.impeccable/design.json
@@ -0,0 +1,190 @@
+{
+ "schemaVersion": 2,
+ "generatedAt": "2026-05-07T00:00:00Z",
+ "title": "Design System: Kanna",
+ "extensions": {
+ "colorMeta": {
+ "destructive-text": {
+ "role": "primary",
+ "displayName": "Destructive Text (Light)",
+ "canonical": "oklch(56% 0.18 13)",
+ "note": "AA-compliant darker coral for text/icon-only destructive foreground in light mode (5.04:1 on Warm Paper). In dark mode aliases to --destructive (6.35:1 on Inkstone). Do NOT use as button-fill background."
+ },
+ "kanna-coral": {
+ "role": "primary",
+ "displayName": "Kanna Coral",
+ "canonical": "oklch(71.2% 0.194 13.428)",
+ "tonalRamp": [
+ "oklch(15% 0.05 13)",
+ "oklch(28% 0.10 13)",
+ "oklch(40% 0.14 13)",
+ "oklch(52% 0.17 13)",
+ "oklch(63% 0.19 13)",
+ "oklch(71.2% 0.194 13.428)",
+ "oklch(82% 0.13 13)",
+ "oklch(94% 0.05 13)"
+ ]
+ },
+ "paper": {
+ "role": "neutral",
+ "displayName": "Warm Paper",
+ "canonical": "oklch(99.5% 0.003 13)"
+ },
+ "inkstone": {
+ "role": "neutral",
+ "displayName": "Inkstone",
+ "canonical": "oklch(20% 0.01 13)"
+ },
+ "espresso-ink": {
+ "role": "neutral",
+ "displayName": "Espresso Ink",
+ "canonical": "oklch(16% 0.01 13)"
+ },
+ "margin-gray-light": {
+ "role": "neutral",
+ "displayName": "Margin Gray (light)",
+ "canonical": "oklch(55% 0.013 13)"
+ },
+ "soft-edge-light": {
+ "role": "neutral",
+ "displayName": "Soft Edge (light)",
+ "canonical": "oklch(91% 0.008 13)"
+ },
+ "verified-sage": {
+ "role": "secondary",
+ "displayName": "Verified Sage",
+ "canonical": "oklch(68% 0.15 155)"
+ },
+ "editor-amber": {
+ "role": "tertiary",
+ "displayName": "Editor Amber",
+ "canonical": "oklch(76% 0.14 78)"
+ },
+ "reference-blue": {
+ "role": "tertiary",
+ "displayName": "Reference Blue",
+ "canonical": "oklch(66% 0.13 235)"
+ }
+ },
+ "typographyMeta": {
+ "display": { "displayName": "Display", "purpose": "Kanna wordmark only." },
+ "headline": { "displayName": "Headline", "purpose": "Page and dialog titles." },
+ "title": { "displayName": "Title", "purpose": "List row primaries, sidebar group labels." },
+ "body": { "displayName": "Body", "purpose": "Chat content, prose, descriptive metadata." },
+ "label": { "displayName": "Label", "purpose": "Metadata pairs, timestamps, type tags." },
+ "mono": { "displayName": "Mono", "purpose": "Commands, durations, ages, pids, tabular numerics." }
+ },
+ "shadows": [
+ { "name": "focus-ring", "value": "0 0 0 2px var(--ring)", "purpose": "Keyboard focus indicator on every interactive element." }
+ ],
+ "motion": [
+ { "name": "ease-out-quart", "value": "cubic-bezier(0.22, 1, 0.36, 1)", "purpose": "Default easing for state transitions, sheet open, sidebar reveal." },
+ { "name": "duration-fast", "value": "160ms", "purpose": "Dialog open, popover open." },
+ { "name": "duration-base", "value": "180ms", "purpose": "Row enter, confirm-stop slide." },
+ { "name": "duration-toggle", "value": "280ms", "purpose": "Terminal pane and right-sidebar reveal." }
+ ],
+ "breakpoints": [
+ { "name": "sm", "value": "640px" },
+ { "name": "md", "value": "768px" },
+ { "name": "lg", "value": "1024px" },
+ { "name": "xl", "value": "1280px" },
+ { "name": "3xl", "value": "1920px" }
+ ]
+ },
+ "components": [
+ {
+ "name": "Primary Button",
+ "kind": "button",
+ "refersTo": "button-primary",
+ "description": "Default primary action. Espresso-ink fill, pale text, rounded-md.",
+ "html": "Save changes ",
+ "css": ".ds-btn-primary { background: oklch(16% 0.01 13); color: oklch(98% 0.005 13); padding: 8px 14px; border: none; border-radius: 6px; font-family: 'Body', system-ui, sans-serif; font-weight: 500; font-size: 14px; line-height: 1.3; cursor: pointer; transition: background 150ms cubic-bezier(0.22,1,0.36,1); } .ds-btn-primary:hover { background: oklch(22% 0.012 13); } .ds-btn-primary:focus-visible { outline: 2px solid oklch(18% 0.01 13); outline-offset: 2px; }"
+ },
+ {
+ "name": "Destructive Button",
+ "kind": "button",
+ "refersTo": "button-destructive",
+ "description": "Stop, delete, force-kill. Kanna Coral fill. Pairs with inline confirm flow.",
+ "html": "Stop ",
+ "css": ".ds-btn-destructive { background: oklch(71.2% 0.194 13.428); color: oklch(98% 0.005 13); padding: 8px 14px; border: none; border-radius: 6px; font-family: 'Body', system-ui, sans-serif; font-weight: 500; font-size: 14px; line-height: 1.3; cursor: pointer; transition: background 150ms cubic-bezier(0.22,1,0.36,1); } .ds-btn-destructive:hover { background: oklch(66% 0.20 13); } .ds-btn-destructive:focus-visible { outline: 2px solid oklch(71.2% 0.194 13.428); outline-offset: 2px; }"
+ },
+ {
+ "name": "Ghost Button",
+ "kind": "button",
+ "refersTo": "button-ghost",
+ "description": "Used inside dense lists where another fill would be noise.",
+ "html": "Cancel ",
+ "css": ".ds-btn-ghost { background: transparent; color: oklch(16% 0.01 13); padding: 8px 14px; border: none; border-radius: 6px; font-family: 'Body', system-ui, sans-serif; font-weight: 500; font-size: 14px; line-height: 1.3; cursor: pointer; transition: background 150ms cubic-bezier(0.22,1,0.36,1); } .ds-btn-ghost:hover { background: oklch(96% 0.005 13); } .ds-btn-ghost:focus-visible { outline: 2px solid oklch(18% 0.01 13); outline-offset: 2px; }"
+ },
+ {
+ "name": "Input Field",
+ "kind": "input",
+ "refersTo": "input-field",
+ "description": "Text input. Soft-Edge border, paper background, rounded-md. iOS-safe 16px on mobile.",
+ "html": "Project name ",
+ "css": ".ds-input-wrap { display: flex; flex-direction: column; gap: 4px; font-family: 'Body', system-ui, sans-serif; } .ds-input-label { font-size: 12px; font-weight: 500; color: oklch(55% 0.013 13); } .ds-input { background: oklch(99.5% 0.003 13); color: oklch(16% 0.01 13); padding: 8px 12px; border: 1px solid oklch(91% 0.008 13); border-radius: 6px; font-size: 14px; line-height: 1.5; transition: border-color 150ms cubic-bezier(0.22,1,0.36,1); } .ds-input:focus { outline: none; border-color: oklch(18% 0.01 13); box-shadow: 0 0 0 1px oklch(18% 0.01 13); } @media (max-width: 640px) { .ds-input { font-size: 16px; } }"
+ },
+ {
+ "name": "Status Dot",
+ "kind": "chip",
+ "refersTo": "card-surface",
+ "description": "Status indicator. Static, no pulse. Amber = running, sage = idle, coral = failed.",
+ "html": "running 2m 14s ",
+ "css": ".ds-status-row { display: inline-flex; align-items: center; gap: 8px; font-family: 'Body', system-ui, sans-serif; font-size: 13px; color: oklch(16% 0.01 13); } .ds-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; } .ds-dot-running { background: oklch(76% 0.14 78); } .ds-dot-idle { background: oklch(68% 0.15 155); } .ds-dot-failed { background: oklch(71.2% 0.194 13.428); } .ds-status-label { font-weight: 500; } .ds-status-meta { font-family: 'Roboto Mono', ui-monospace, monospace; font-variant-numeric: tabular-nums; color: oklch(55% 0.013 13); font-size: 13px; }"
+ },
+ {
+ "name": "Background Task Row",
+ "kind": "card",
+ "refersTo": "card-surface",
+ "description": "Two-line row inside the Background Tasks dialog. Mono command + tabular age, sans meta.",
+ "html": "",
+ "css": ".ds-bgrow { display: flex; flex-direction: column; gap: 4px; padding: 12px 16px; border-radius: 6px; transition: background 150ms cubic-bezier(0.22,1,0.36,1); } .ds-bgrow:hover { background: oklch(96% 0.005 13); } .ds-bgrow-line1 { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } .ds-bgrow-cmd { font-family: 'Roboto Mono', ui-monospace, monospace; font-size: 14px; font-weight: 600; color: oklch(16% 0.01 13); } .ds-bgrow-age { font-family: 'Roboto Mono', ui-monospace, monospace; font-size: 13px; font-weight: 500; font-variant-numeric: tabular-nums; color: oklch(16% 0.01 13); } .ds-bgrow-line2 { display: flex; align-items: center; gap: 6px; font-family: 'Body', system-ui, sans-serif; font-size: 12px; color: oklch(55% 0.013 13); } .ds-bgrow-tag { text-transform: lowercase; } .ds-bgrow-sep { opacity: 0.6; } .ds-bgrow-chat { color: oklch(55% 0.013 13); text-decoration: none; border-bottom: 1px dotted oklch(91% 0.008 13); } .ds-bgrow-chat:hover { color: oklch(16% 0.01 13); border-bottom-color: oklch(16% 0.01 13); } .ds-bgrow-stop { margin-left: auto; background: transparent; color: oklch(71.2% 0.194 13.428); border: none; padding: 4px 8px; border-radius: 4px; font-family: 'Body', system-ui, sans-serif; font-size: 12px; font-weight: 500; cursor: pointer; } .ds-bgrow-stop:hover { background: oklch(96% 0.005 13); } .ds-bgrow-stop:focus-visible { outline: 2px solid oklch(71.2% 0.194 13.428); outline-offset: 2px; }"
+ }
+ ],
+ "narrative": {
+ "northStar": "The Editorial Workspace",
+ "overview": "Kanna reads like a well-edited document, not a dashboard. The system stays warm-tinted and quiet so that long agent sessions remain legible at 11pm on a 27-inch monitor without wearing the user down. Density is paid for in rhythm, not in chrome: hierarchy emerges from typographic weight and generous spacing, never from gradients, glow, or decorative borders. Color is restrained by default. One brand accent (Kanna Coral) carries identity and destructive intent both, used on under 10% of any screen.",
+ "keyCharacteristics": [
+ "Warm-tinted neutrals (chroma 0.003–0.013, hue ~13°) across both themes.",
+ "One brand accent, used rarely and on purpose.",
+ "Editorial type pairing: Body for prose, Bricolage Grotesque for the logo only, Roboto Mono for code and tabular data.",
+ "Flat by default. Depth comes from contrast and spacing, not shadows.",
+ "Tabular numerics on every duration, count, age, or pid."
+ ],
+ "rules": [
+ { "name": "The Tint-Everything Rule", "body": "No #000 or #fff. Every neutral carries chroma 0.003–0.013 toward hue 13°. Pure black or pure white in this codebase is a bug.", "section": "colors" },
+ { "name": "The One-Voice Rule", "body": "Kanna Coral is the only brand color and is used on ≤10% of any given screen. Its rarity is the point. Decorative use prohibited.", "section": "colors" },
+ { "name": "The Color-Plus Rule", "body": "Color alone never carries meaning. Status, errors, and live states always pair color with shape (icon), text, or weight.", "section": "colors" },
+ { "name": "The No-All-Caps Rule", "body": "Headers and labels are sentence case. ALL CAPS is reserved for emergencies the system does not have.", "section": "typography" },
+ { "name": "The Tabular-Nums Rule", "body": "Any duration, count, age, pid, or time-to-x ticker uses font-variant-numeric: tabular-nums. Reflow under live tickers is a regression.", "section": "typography" },
+ { "name": "The Mobile-Input-16 Rule", "body": "Inputs, textareas, and selects use font-size: 16px minimum on mobile to prevent iOS zoom-on-focus.", "section": "typography" },
+ { "name": "The Flat-By-Default Rule", "body": "Surfaces are flat at rest. Depth is a state response (focus, overlay), not an idle aesthetic.", "section": "elevation" },
+ { "name": "The No-Glassmorphism Rule", "body": "backdrop-filter blur on a translucent panel is prohibited as a default. Use it only when the underlying content must stay partially visible for a functional reason.", "section": "elevation" }
+ ],
+ "dos": [
+ "Tint every neutral toward hue 13° at chroma 0.003–0.013.",
+ "Carry the One-Voice Rule: Kanna Coral on ≤10% of any screen.",
+ "Pair color with shape, label, or weight on every state indicator.",
+ "Use Roboto Mono with tabular-nums for every duration, age, count, pid.",
+ "Keep dialogs flat: scale-and-fade entry, no backdrop blur, no nested modals.",
+ "Write keyboard shortcuts on every action; every keyboard action also has a clear mouse target.",
+ "Respect prefers-reduced-motion.",
+ "Use the project Tooltip component; native title attributes are prohibited.",
+ "Target body text contrast ≥ 7:1 (AAA) where the design allows."
+ ],
+ "donts": [
+ "Don't use #000, #fff, or any zero-chroma neutral.",
+ "Don't use purple-blue gradients, glassmorphism cards, or glow accents.",
+ "Don't ship marketing-cream backgrounds, oversized illustrations, or hero-feature-card grids.",
+ "Don't put saturated green or cyan on a black background.",
+ "Don't stack panels at Datadog/Grafana density.",
+ "Don't use border-left greater than 1px as a colored stripe.",
+ "Don't clip text inside a gradient.",
+ "Don't open a modal on top of a modal.",
+ "Don't animate layout properties (width, height, top, left, padding).",
+ "Don't pulse status dots.",
+ "Don't use outline: none on focusable elements without a clear replacement.",
+ "Don't rely on color alone for status."
+ ]
+ }
+}
diff --git a/.release-please-manifest.json b/.release-please-manifest.json
new file mode 100644
index 000000000..6aca5c6be
--- /dev/null
+++ b/.release-please-manifest.json
@@ -0,0 +1,3 @@
+{
+ ".": "0.85.1"
+}
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 000000000..dc6ef20ba
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,1071 @@
+# Changelog
+
+## [0.85.1](https://github.com/cuongtranba/kanna/compare/v0.85.0...v0.85.1) (2026-06-04)
+
+
+### Bug Fixes
+
+* **pty:** stop re-spawn from leaking an invisible PTY child ([#375](https://github.com/cuongtranba/kanna/issues/375)) ([a6fdbb0](https://github.com/cuongtranba/kanna/commit/a6fdbb0d54809ea21efd7d33c98b26bcc178e293))
+
+## [0.85.0](https://github.com/cuongtranba/kanna/compare/v0.84.1...v0.85.0) (2026-06-04)
+
+
+### Features
+
+* **workflow:** richer per-agent journal detail in drill-in ([#372](https://github.com/cuongtranba/kanna/issues/372)) ([8d27627](https://github.com/cuongtranba/kanna/commit/8d27627e64ef02839accc8cb5781ced6a70e20c3))
+
+
+### Bug Fixes
+
+* **transcript:** stop rendering benign synthetic turn-end markers as API errors ([#374](https://github.com/cuongtranba/kanna/issues/374)) ([6206239](https://github.com/cuongtranba/kanna/commit/62062395f4d12da847b0a9481a70dc465171e728))
+
+## [0.84.1](https://github.com/cuongtranba/kanna/compare/v0.84.0...v0.84.1) (2026-06-04)
+
+
+### Bug Fixes
+
+* **workflow:** surface a live re-run that reused a crashed run's runId ([#370](https://github.com/cuongtranba/kanna/issues/370)) ([bef730e](https://github.com/cuongtranba/kanna/commit/bef730e864bc03e6e029822e76d1e6f2d036c27e))
+
+## [0.84.0](https://github.com/cuongtranba/kanna/compare/v0.83.1...v0.84.0) (2026-06-04)
+
+
+### Features
+
+* **workflow:** live per-agent detail for running runs (journal.jsonl) ([#367](https://github.com/cuongtranba/kanna/issues/367)) ([5ac6975](https://github.com/cuongtranba/kanna/commit/5ac6975bb347287e86a078658bc55357b218b0e7))
+
+
+### Bug Fixes
+
+* **sidebar:** make collapse-all chip a real affordance with semantic icon ([#369](https://github.com/cuongtranba/kanna/issues/369)) ([92cc071](https://github.com/cuongtranba/kanna/commit/92cc07127464e1e68edf5e3cdfa3d4e9a29a0ffa))
+
+## [0.83.1](https://github.com/cuongtranba/kanna/compare/v0.83.0...v0.83.1) (2026-06-03)
+
+
+### Bug Fixes
+
+* **workflow:** getRun returns synthetic running run (drill-in no longer flickers) ([#365](https://github.com/cuongtranba/kanna/issues/365)) ([243f8fd](https://github.com/cuongtranba/kanna/commit/243f8fd55bfe17efeec8389de7f5677f0c27b7ee))
+
+## [0.83.0](https://github.com/cuongtranba/kanna/compare/v0.82.2...v0.83.0) (2026-06-03)
+
+
+### Features
+
+* **workflow:** show in-flight runs as running in the status panel ([#363](https://github.com/cuongtranba/kanna/issues/363)) ([be3933d](https://github.com/cuongtranba/kanna/commit/be3933d879fa8a67cc7f660e3f1402d5d29a27f8))
+
+## [0.82.2](https://github.com/cuongtranba/kanna/compare/v0.82.1...v0.82.2) (2026-06-03)
+
+
+### Bug Fixes
+
+* **agent:** real workflow liveness via live run dir (corrects [#359](https://github.com/cuongtranba/kanna/issues/359) no-op) ([#361](https://github.com/cuongtranba/kanna/issues/361)) ([9707062](https://github.com/cuongtranba/kanna/commit/970706234ce75915d16e8f6f992449535391ae07))
+
+## [0.82.1](https://github.com/cuongtranba/kanna/compare/v0.82.0...v0.82.1) (2026-06-03)
+
+
+### Bug Fixes
+
+* **agent:** keep PTY session alive while a background workflow is running ([#359](https://github.com/cuongtranba/kanna/issues/359)) ([8e7af80](https://github.com/cuongtranba/kanna/commit/8e7af80ee3b1fff0249bd7f4226d659e7ccaa281))
+
+## [0.82.0](https://github.com/cuongtranba/kanna/compare/v0.81.3...v0.82.0) (2026-06-03)
+
+
+### Features
+
+* Kanna-owned agent self-scheduled wake (ScheduleWakeup + pending-workflow harvest) ([#357](https://github.com/cuongtranba/kanna/issues/357)) ([51fd6fa](https://github.com/cuongtranba/kanna/commit/51fd6fafcf9bdf0c66e5fa823545e3d715c5d60d))
+* workflow status panel (PTY disk-watch) ([#358](https://github.com/cuongtranba/kanna/issues/358)) ([1ab36a2](https://github.com/cuongtranba/kanna/commit/1ab36a2c3fcde805c8369baf882d3b7cc3611038))
+
+
+### Bug Fixes
+
+* **chat-ui:** prevent composer toolbar / token readout overlap ([#354](https://github.com/cuongtranba/kanna/issues/354)) ([1e429b6](https://github.com/cuongtranba/kanna/commit/1e429b69ce122418dc364337fcb9c48dc00f7a7e))
+* **sidebar:** pin collapse-all toggle above scroll list ([#356](https://github.com/cuongtranba/kanna/issues/356)) ([abdb32b](https://github.com/cuongtranba/kanna/commit/abdb32b2c3195ba0146da0c66e0db6a85208a0d7))
+
+## [0.81.3](https://github.com/cuongtranba/kanna/compare/v0.81.2...v0.81.3) (2026-06-03)
+
+
+### Bug Fixes
+
+* **chat-ui:** align session token readout with flat toolbar ([#349](https://github.com/cuongtranba/kanna/issues/349)) ([53a8e98](https://github.com/cuongtranba/kanna/commit/53a8e986207b2c30d74373634de4a3a4d0767174))
+* **mcp:** forward customMcpServers through agent settings view ([#353](https://github.com/cuongtranba/kanna/issues/353)) ([7efa965](https://github.com/cuongtranba/kanna/commit/7efa965b8f889cad7537a4aa51c66f751dbf1292))
+* **mcp:** keep loopback MCP transport alive across idle gaps ([#351](https://github.com/cuongtranba/kanna/issues/351)) ([22f8bec](https://github.com/cuongtranba/kanna/commit/22f8bec92e6dafdbaeeaaa58ff7f200d3f95900c))
+* **pty:** cannot fork PTY-created conversations (session id collision) ([#352](https://github.com/cuongtranba/kanna/issues/352)) ([4b3852e](https://github.com/cuongtranba/kanna/commit/4b3852e22b7f10074ab6998ad4d981748e9020e9))
+
+## [0.81.2](https://github.com/cuongtranba/kanna/compare/v0.81.1...v0.81.2) (2026-06-02)
+
+
+### Bug Fixes
+
+* **share:** make share page scroll on overflow (mobile-safe) ([#347](https://github.com/cuongtranba/kanna/issues/347)) ([539b8e8](https://github.com/cuongtranba/kanna/commit/539b8e84fc9b83154f18a429bbc2a5f8e266d6c8))
+
+## [0.81.1](https://github.com/cuongtranba/kanna/compare/v0.81.0...v0.81.1) (2026-06-02)
+
+
+### Bug Fixes
+
+* **chat-ui:** keep session token pill visible on mobile ([#345](https://github.com/cuongtranba/kanna/issues/345)) ([3f5b30e](https://github.com/cuongtranba/kanna/commit/3f5b30e906096c5311088c70bf0e661e92def738))
+* **pty:** read assistant usage from nested message.usage ([#344](https://github.com/cuongtranba/kanna/issues/344)) ([c387112](https://github.com/cuongtranba/kanna/commit/c387112ec7b715daebf9f34245b2dc584ba08a9c))
+
+## [0.81.0](https://github.com/cuongtranba/kanna/compare/v0.80.0...v0.81.0) (2026-06-01)
+
+
+### Features
+
+* **chat-ui:** show session token total pill in composer ([#341](https://github.com/cuongtranba/kanna/issues/341)) ([23872d9](https://github.com/cuongtranba/kanna/commit/23872d99ee48539340f51ada184b8cd679690997))
+
+
+### Bug Fixes
+
+* **tool-callback:** live broadcast + stop cancel-on-rotation + drop ask timeout ([#343](https://github.com/cuongtranba/kanna/issues/343)) ([0af2ff8](https://github.com/cuongtranba/kanna/commit/0af2ff837b4e0a575af57d18d281898a5872205e))
+
+## [0.80.0](https://github.com/cuongtranba/kanna/compare/v0.79.0...v0.80.0) (2026-05-31)
+
+
+### Features
+
+* **transcript:** anchor subagent runs under their delegate_subagent call ([#339](https://github.com/cuongtranba/kanna/issues/339)) ([8e5e445](https://github.com/cuongtranba/kanna/commit/8e5e4451c31f7bf625e2c4200791c03d2002ea66))
+
+## [0.79.0](https://github.com/cuongtranba/kanna/compare/v0.78.0...v0.79.0) (2026-05-30)
+
+
+### Features
+
+* **subagent:** keep-alive multi-turn PTY sessions ([#338](https://github.com/cuongtranba/kanna/issues/338)) ([deb412b](https://github.com/cuongtranba/kanna/commit/deb412b2c321899375e235a1c4e6adff90235ac2))
+
+
+### Bug Fixes
+
+* **pty:** deliver subagent prompt via MCP channel push (fail-fast) ([#333](https://github.com/cuongtranba/kanna/issues/333)) ([c93afc3](https://github.com/cuongtranba/kanna/commit/c93afc35a14d710b7bd2c8814e03323901e6c879))
+
+## [0.78.0](https://github.com/cuongtranba/kanna/compare/v0.77.3...v0.78.0) (2026-05-29)
+
+
+### Features
+
+* **models:** add claude-opus-4-8 to provider catalog ([#335](https://github.com/cuongtranba/kanna/issues/335)) ([8d36fdb](https://github.com/cuongtranba/kanna/commit/8d36fdb392561bfcd91534a168aaa0bd4e16cd34))
+
+## [0.77.3](https://github.com/cuongtranba/kanna/compare/v0.77.2...v0.77.3) (2026-05-28)
+
+
+### Bug Fixes
+
+* **file-preview:** bound scroll region inside dialog for long content ([#330](https://github.com/cuongtranba/kanna/issues/330)) ([5d12c76](https://github.com/cuongtranba/kanna/commit/5d12c76d83d0c19384ec8bacafda6ffe8099a36e))
+* **pty:** ignore sidechain + background auto-wake lines in transcript parser ([#332](https://github.com/cuongtranba/kanna/issues/332)) ([216392b](https://github.com/cuongtranba/kanna/commit/216392b5ae8175682ed8ae11a083d4fb4cf51a75))
+* **share:** style share-view with Tailwind + shared markdown components ([#327](https://github.com/cuongtranba/kanna/issues/327)) ([3305bb3](https://github.com/cuongtranba/kanna/commit/3305bb3291a02676c54b88c49eda65c3aec5d44a))
+* **ui:** surface question header + chosen option description in ask-user-question card ([#329](https://github.com/cuongtranba/kanna/issues/329)) ([fd9acb4](https://github.com/cuongtranba/kanna/commit/fd9acb4d265a2f3c071e7b2e91feb056e8033645))
+
+## [0.77.2](https://github.com/cuongtranba/kanna/compare/v0.77.1...v0.77.2) (2026-05-25)
+
+
+### Bug Fixes
+
+* **share:** popover trigger + share-view rendering ([#325](https://github.com/cuongtranba/kanna/issues/325)) ([fd896ff](https://github.com/cuongtranba/kanna/commit/fd896ffcc574f649c1815b40e635e0bddcc89ec3))
+
+## [0.77.1](https://github.com/cuongtranba/kanna/compare/v0.77.0...v0.77.1) (2026-05-25)
+
+
+### Bug Fixes
+
+* **share:** include kind discriminant in share.* ws responses ([#323](https://github.com/cuongtranba/kanna/issues/323)) ([a115854](https://github.com/cuongtranba/kanna/commit/a1158541b42813fc675543179442230d63710a67))
+
+## [0.77.0](https://github.com/cuongtranba/kanna/compare/v0.76.0...v0.77.0) (2026-05-25)
+
+
+### Features
+
+* **share:** derive share URL from request origin, drop tunnel gate ([#321](https://github.com/cuongtranba/kanna/issues/321)) ([24599e9](https://github.com/cuongtranba/kanna/commit/24599e9b12118c623c6730ba65244f5017ea18cd))
+
+## [0.76.0](https://github.com/cuongtranba/kanna/compare/v0.75.0...v0.76.0) (2026-05-24)
+
+
+### Features
+
+* **share:** read-only public session share ([#318](https://github.com/cuongtranba/kanna/issues/318)) ([c7a7245](https://github.com/cuongtranba/kanna/commit/c7a7245fcd21cc869c352c8a9a7d88b5a8749784))
+
+## [0.75.0](https://github.com/cuongtranba/kanna/compare/v0.74.0...v0.75.0) (2026-05-24)
+
+
+### Features
+
+* **pty:** realtime memory tracking in live status panel ([#316](https://github.com/cuongtranba/kanna/issues/316)) ([8148302](https://github.com/cuongtranba/kanna/commit/814830259868c93183418de32af5ed9b031c2b2d))
+
+## [0.74.0](https://github.com/cuongtranba/kanna/compare/v0.73.1...v0.74.0) (2026-05-23)
+
+
+### Features
+
+* **pty:** hide exited instances from status panel + TTL prune ([#313](https://github.com/cuongtranba/kanna/issues/313)) ([2efb78e](https://github.com/cuongtranba/kanna/commit/2efb78e54012b6ecd055a1f2570b704024dfaab2))
+* remove background tasks panel and related code ([#315](https://github.com/cuongtranba/kanna/issues/315)) ([a59079c](https://github.com/cuongtranba/kanna/commit/a59079c937c72e1e39c8d16b5b11dda0032cd5dd))
+
+## [0.73.1](https://github.com/cuongtranba/kanna/compare/v0.73.0...v0.73.1) (2026-05-23)
+
+
+### Bug Fixes
+
+* **pty:** bound transcript poll + quiet-period TUI ready gate ([#311](https://github.com/cuongtranba/kanna/issues/311)) ([e4b3bed](https://github.com/cuongtranba/kanna/commit/e4b3bed6ccafb8ecdacdac4bbd852b852b3caf0e))
+
+## [0.73.0](https://github.com/cuongtranba/kanna/compare/v0.72.0...v0.73.0) (2026-05-23)
+
+
+### Features
+
+* **pty:** live status panel + cancel/kill actions ([#309](https://github.com/cuongtranba/kanna/issues/309)) ([e077d7a](https://github.com/cuongtranba/kanna/commit/e077d7a86639a8f9d60183f3ac26421757e465ec))
+
+## [0.72.0](https://github.com/cuongtranba/kanna/compare/v0.71.0...v0.72.0) (2026-05-23)
+
+
+### Features
+
+* **mobile:** swipe to open/close sidebar ([#306](https://github.com/cuongtranba/kanna/issues/306)) ([3000d58](https://github.com/cuongtranba/kanna/commit/3000d589f4aadb9071f868be4af4abd65cd76f83))
+
+## [0.71.0](https://github.com/cuongtranba/kanna/compare/v0.70.0...v0.71.0) (2026-05-23)
+
+
+### Features
+
+* custom MCP servers in settings (SDK + PTY) ([#282](https://github.com/cuongtranba/kanna/issues/282)) ([996b732](https://github.com/cuongtranba/kanna/commit/996b732d6fffdaf42e07afe7ee513d7995813300))
+* **lint:** ban side-effect imports in src/shared and src/client ([#283](https://github.com/cuongtranba/kanna/issues/283)) ([c5d6934](https://github.com/cuongtranba/kanna/commit/c5d69342fe6e96dec05a829c93a424743792ad48))
+* **lint:** catch DB construction, process.exit, process.env in pure layers ([#286](https://github.com/cuongtranba/kanna/issues/286)) ([8977d83](https://github.com/cuongtranba/kanna/commit/8977d83caa1139394b933d2e6b726ec0ad257905))
+* **lint:** ratchet side-effect call sites in src/server (warn + lower-only baseline) ([#287](https://github.com/cuongtranba/kanna/issues/287)) ([9ec4c7e](https://github.com/cuongtranba/kanna/commit/9ec4c7e528f200b69336bb21721d7067ca8fbe44))
+
+
+### Bug Fixes
+
+* **file-preview:** restore scroll inside @-triggered file sheet ([#305](https://github.com/cuongtranba/kanna/issues/305)) ([c388e5a](https://github.com/cuongtranba/kanna/commit/c388e5a06976f71d2b6579a5eebf8529f5f00f64))
+* **oauth-pool:** keep "In use" badge on single line ([#278](https://github.com/cuongtranba/kanna/issues/278)) ([4aa2aa8](https://github.com/cuongtranba/kanna/commit/4aa2aa8d2a288ba588c665fa277989ac129ffcda))
+* point the dynamic import at `./terminal-pid-registry.adapter`. ([54270c6](https://github.com/cuongtranba/kanna/commit/54270c63ebab9d1aa550818d3cddc65784d6362a))
+* **settings:** forward globalPromptAppend to agent spawn ([#281](https://github.com/cuongtranba/kanna/issues/281)) ([37e9fbd](https://github.com/cuongtranba/kanna/commit/37e9fbdb56766604bf7496f43eca0b7fb9569fba))
+* **test:** update dynamic import after terminal-pid-registry rename ([#291](https://github.com/cuongtranba/kanna/issues/291)) ([54270c6](https://github.com/cuongtranba/kanna/commit/54270c63ebab9d1aa550818d3cddc65784d6362a))
+
+## [0.70.0](https://github.com/cuongtranba/kanna/compare/v0.69.0...v0.70.0) (2026-05-22)
+
+
+### Features
+
+* **transcript:** syntax-highlight fenced code blocks in chat messages ([#276](https://github.com/cuongtranba/kanna/issues/276)) ([f966b56](https://github.com/cuongtranba/kanna/commit/f966b560dc4a5081d3c54fcd7019a6476c1a523c))
+
+## [0.69.0](https://github.com/cuongtranba/kanna/compare/v0.68.1...v0.69.0) (2026-05-22)
+
+
+### Features
+
+* **oauth-pool:** per-token concurrency cap (share OAuth across chats) ([#275](https://github.com/cuongtranba/kanna/issues/275)) ([9fdbfdd](https://github.com/cuongtranba/kanna/commit/9fdbfdd142130aa032c4a0b842420e3cbc9772af))
+* **transcript:** render Claude CLI synthetic API errors as dedicated entry kind ([#273](https://github.com/cuongtranba/kanna/issues/273)) ([b2b1585](https://github.com/cuongtranba/kanna/commit/b2b158517f03c0be2f0993c2070db90745442e49))
+
+## [0.68.1](https://github.com/cuongtranba/kanna/compare/v0.68.0...v0.68.1) (2026-05-21)
+
+
+### Bug Fixes
+
+* **claude-pty:** PID registry JSONL discovery + cross-talk hardening ([#271](https://github.com/cuongtranba/kanna/issues/271)) ([9b5bbf8](https://github.com/cuongtranba/kanna/commit/9b5bbf87ff672be45ebd533c4119f5bc787c3f50))
+* **cli-supervisor:** skip self-update after UI-triggered restart so rollback sticks ([#269](https://github.com/cuongtranba/kanna/issues/269)) ([91d1415](https://github.com/cuongtranba/kanna/commit/91d141510917add042c13a9995b9cd17674ff57c))
+
+## [0.68.0](https://github.com/cuongtranba/kanna/compare/v0.67.0...v0.68.0) (2026-05-21)
+
+
+### ⚠ BREAKING CHANGES
+
+* **claude-pty:** Shannon-style TUI transport — drop --print, tail transcript JSONL ([#261](https://github.com/cuongtranba/kanna/issues/261))
+
+### Features
+
+* **claude-pty:** on-disk pid registry to reap crash orphans on next boot ([#267](https://github.com/cuongtranba/kanna/issues/267)) ([1817cde](https://github.com/cuongtranba/kanna/commit/1817cde883b2a5ad992d359a22be682ba134850c))
+* **claude-pty:** plan-mode exit via Shift+Tab (F1) + getSupportedCommands live list (F2) ([#262](https://github.com/cuongtranba/kanna/issues/262)) ([5d941a5](https://github.com/cuongtranba/kanna/commit/5d941a574f8686701ad87554ece7bbe9167ada1b))
+* **claude-pty:** Shannon-style TUI transport — drop --print, tail transcript JSONL ([#261](https://github.com/cuongtranba/kanna/issues/261)) ([273386c](https://github.com/cuongtranba/kanna/commit/273386cdb8d63803bc863f0ebfcf26b208e84ed9))
+* **messages:** mask OAuth key as primary AccountInfo identifier ([#257](https://github.com/cuongtranba/kanna/issues/257)) ([d91f880](https://github.com/cuongtranba/kanna/commit/d91f880747ccad444cbc04c8bf970f412d773a40))
+* **notice-banner:** extract reusable shell notice primitive ([#256](https://github.com/cuongtranba/kanna/issues/256)) ([1d1539e](https://github.com/cuongtranba/kanna/commit/1d1539e300a094b98b7e71a805759ae35f37d216))
+* **settings:** add global prompt append for Claude + Codex turns ([#260](https://github.com/cuongtranba/kanna/issues/260)) ([f700d08](https://github.com/cuongtranba/kanna/commit/f700d085cd1d60249d582411a449ed25e14288f5))
+
+
+### Bug Fixes
+
+* **claude-pty, subagent:** adaptive paste-commit wait + clear stale cancel on new turn ([#265](https://github.com/cuongtranba/kanna/issues/265)) ([0782da4](https://github.com/cuongtranba/kanna/commit/0782da4bac0a30b03f2e4b1d7565c8d71204a3bd))
+* **claude-pty:** fail-close hung turns on stream-end + add lifecycle trace logs ([#268](https://github.com/cuongtranba/kanna/issues/268)) ([b321973](https://github.com/cuongtranba/kanna/commit/b3219739b0c81afa864c4f006fc6b4e5dda94889))
+* **claude-pty:** multi-line paste submit + mtime-floor JSONL discovery ([#264](https://github.com/cuongtranba/kanna/issues/264)) ([d9d9052](https://github.com/cuongtranba/kanna/commit/d9d905207929351df42c33d512f586337895a952))
+* **claude-pty:** plug PTY resource leaks + harden graceful shutdown ([#266](https://github.com/cuongtranba/kanna/issues/266)) ([2dd5a16](https://github.com/cuongtranba/kanna/commit/2dd5a1625157896a4fb60ec67049b3a59969aded))
+* **claude-pty:** TUI prompt submission, turn-end marker, deterministic JSONL path ([#263](https://github.com/cuongtranba/kanna/issues/263)) ([57aa777](https://github.com/cuongtranba/kanna/commit/57aa77703f31ae9940f3c655e4d7bee7d1c76460))
+
+## [0.67.0](https://github.com/cuongtranba/kanna/compare/v0.66.1...v0.67.0) (2026-05-20)
+
+
+### Features
+
+* **messages:** surface OAuth key in chat AccountInfoMessage ([#254](https://github.com/cuongtranba/kanna/issues/254)) ([e24ec3e](https://github.com/cuongtranba/kanna/commit/e24ec3e6c2ad96bfd65d12d420b25e26f30042d8))
+
+## [0.66.1](https://github.com/cuongtranba/kanna/compare/v0.66.0...v0.66.1) (2026-05-20)
+
+
+### Bug Fixes
+
+* **wiki:** editorial home page, WCAG AA gray ramp, Starlight cascade ([#252](https://github.com/cuongtranba/kanna/issues/252)) ([ed2acf3](https://github.com/cuongtranba/kanna/commit/ed2acf32b78ffb417178455e49552553251eaa27))
+
+## [0.66.0](https://github.com/cuongtranba/kanna/compare/v0.65.1...v0.66.0) (2026-05-20)
+
+
+### Features
+
+* **client:** render <thinking> blocks as collapsible disclosure ([#250](https://github.com/cuongtranba/kanna/issues/250)) ([f91722d](https://github.com/cuongtranba/kanna/commit/f91722d64e640b74f800a6f5f52a5ec5be36926d))
+* **wiki:** Kanna documentation site at kanna-wiki.lowbit.link ([#249](https://github.com/cuongtranba/kanna/issues/249)) ([01a86a2](https://github.com/cuongtranba/kanna/commit/01a86a24c33e2af66ada7443373693180a06d040))
+
+## [0.65.1](https://github.com/cuongtranba/kanna/compare/v0.65.0...v0.65.1) (2026-05-19)
+
+
+### Bug Fixes
+
+* **client:** include subagentRuns in chat-snapshot dedup compare ([#245](https://github.com/cuongtranba/kanna/issues/245)) ([76d7b45](https://github.com/cuongtranba/kanna/commit/76d7b4586d5705234983339996d9f77f52b2e463))
+* **oauth-pool:** persist refusal as transcript result entry ([#248](https://github.com/cuongtranba/kanna/issues/248)) ([adbf02d](https://github.com/cuongtranba/kanna/commit/adbf02d8a5f5f5d4ed7c7338117050c0fcf2aad2))
+
+## [0.65.0](https://github.com/cuongtranba/kanna/compare/v0.64.0...v0.65.0) (2026-05-19)
+
+
+### Features
+
+* **messages:** render mermaid diagrams in transcript markdown ([#242](https://github.com/cuongtranba/kanna/issues/242)) ([c606355](https://github.com/cuongtranba/kanna/commit/c606355c6330175f6ccf170afdc228a90aeea943))
+
+
+### Bug Fixes
+
+* **event-store:** decouple subagent live progress from global writeChain ([#244](https://github.com/cuongtranba/kanna/issues/244)) ([21ea6e9](https://github.com/cuongtranba/kanna/commit/21ea6e9aefe497fcd66984bbbdbdf1346145faae))
+
+## [0.64.0](https://github.com/cuongtranba/kanna/compare/v0.63.0...v0.64.0) (2026-05-19)
+
+
+### Features
+
+* **oauth-pool:** name contested chat in token-unavailable refusal ([#235](https://github.com/cuongtranba/kanna/issues/235)) ([eef731b](https://github.com/cuongtranba/kanna/commit/eef731bccd2301aad12bcc6dfa8a32f113a723a8))
+* **subagent:** live UI broadcast + pending tool loading state ([#237](https://github.com/cuongtranba/kanna/issues/237)) ([65969ed](https://github.com/cuongtranba/kanna/commit/65969eda3382ae480d1b8e2bf968fdeb26c0d2e5))
+
+
+### Bug Fixes
+
+* **ui:** align PTY driver banner with floating sidebar chrome ([#239](https://github.com/cuongtranba/kanna/issues/239)) ([855b80d](https://github.com/cuongtranba/kanna/commit/855b80d5221bd0572a1e78ad18ab92c83b62077a))
+
+## [0.63.0](https://github.com/cuongtranba/kanna/compare/v0.62.0...v0.63.0) (2026-05-19)
+
+
+### Features
+
+* **subagent:** reactive activity label from latest entries ([#231](https://github.com/cuongtranba/kanna/issues/231)) ([08a41a5](https://github.com/cuongtranba/kanna/commit/08a41a58e23642a55b34b3786a1339219a6fe3f8))
+* **subagent:** rich activity labels + MCP progress notifications ([#234](https://github.com/cuongtranba/kanna/issues/234)) ([493ef87](https://github.com/cuongtranba/kanna/commit/493ef87e809d09594c210e2f2f52475bef510f82))
+
+## [0.62.0](https://github.com/cuongtranba/kanna/compare/v0.61.5...v0.62.0) (2026-05-19)
+
+
+### Features
+
+* **ui:** unify AskUserQuestion slide UI across native + pending paths ([#229](https://github.com/cuongtranba/kanna/issues/229)) ([a565506](https://github.com/cuongtranba/kanna/commit/a5655068e415ead2389da36b40c1759f8b0635db))
+
+
+### Bug Fixes
+
+* **oauth-pool:** stop turn-end release from leaking the rotation pin; OAuth-only PTY auth ([#227](https://github.com/cuongtranba/kanna/issues/227)) ([024e09b](https://github.com/cuongtranba/kanna/commit/024e09be2862fe5c2f7a8ccff1b4a76237626340))
+
+## [0.61.5](https://github.com/cuongtranba/kanna/compare/v0.61.4...v0.61.5) (2026-05-19)
+
+
+### Bug Fixes
+
+* **tools:** peel MCP CallToolResult envelope when hydrating ask_user_question ([#225](https://github.com/cuongtranba/kanna/issues/225)) ([fc106c1](https://github.com/cuongtranba/kanna/commit/fc106c1f0c4ca369b18493dfc4b56ae3bc1fcc0a))
+
+## [0.61.4](https://github.com/cuongtranba/kanna/compare/v0.61.3...v0.61.4) (2026-05-18)
+
+
+### Bug Fixes
+
+* **ui:** normalize mcp__kanna__ask_user_question text→question in pending card ([#223](https://github.com/cuongtranba/kanna/issues/223)) ([3610f9b](https://github.com/cuongtranba/kanna/commit/3610f9b2cbf46510d8db0b3910d8a1cd87e07d0b))
+
+## [0.61.3](https://github.com/cuongtranba/kanna/compare/v0.61.2...v0.61.3) (2026-05-18)
+
+
+### Bug Fixes
+
+* **claude-pty:** SIGINT on stop, drain queue after cancel ([#220](https://github.com/cuongtranba/kanna/issues/220)) ([f5a76ff](https://github.com/cuongtranba/kanna/commit/f5a76ff1d40e956e95a26d818172c19e2b6d436a))
+* **tools:** normalize mcp__kanna__ask_user_question text→question field ([#222](https://github.com/cuongtranba/kanna/issues/222)) ([b11741d](https://github.com/cuongtranba/kanna/commit/b11741dbd1ec604adf4f41d8d05a540db04e7747))
+
+## [0.61.2](https://github.com/cuongtranba/kanna/compare/v0.61.1...v0.61.2) (2026-05-18)
+
+
+### Bug Fixes
+
+* **permission-gate:** force ask for mcp__kanna__ask_user_question / exit_plan_mode ([#217](https://github.com/cuongtranba/kanna/issues/217)) ([941f92f](https://github.com/cuongtranba/kanna/commit/941f92f19f159fba83c07e94abf62d85adb4a438)), closes [#215](https://github.com/cuongtranba/kanna/issues/215)
+
+## [0.61.1](https://github.com/cuongtranba/kanna/compare/v0.61.0...v0.61.1) (2026-05-18)
+
+
+### Bug Fixes
+
+* **claude-pty:** route AskUserQuestion/ExitPlanMode to UI under PTY ([#216](https://github.com/cuongtranba/kanna/issues/216)) ([2316725](https://github.com/cuongtranba/kanna/commit/2316725845263948761e24d897d5eba5b03bcebb)), closes [#215](https://github.com/cuongtranba/kanna/issues/215)
+* **update:** instant overlay + per-button loading for install/rollback/redeploy ([#213](https://github.com/cuongtranba/kanna/issues/213)) ([e2f0801](https://github.com/cuongtranba/kanna/commit/e2f0801810ae12ee704e67d2eb375e8c5f387a24))
+
+## [0.61.0](https://github.com/cuongtranba/kanna/compare/v0.60.0...v0.61.0) (2026-05-18)
+
+
+### Features
+
+* **codex:** auto-relocate ImageGeneration outputs into project ([#210](https://github.com/cuongtranba/kanna/issues/210)) ([d1fb494](https://github.com/cuongtranba/kanna/commit/d1fb494b664882ec58b9ab39773ab7469f77ed05))
+
+
+### Bug Fixes
+
+* **settings/subagents:** remove duplicate copy in empty state and list ([#212](https://github.com/cuongtranba/kanna/issues/212)) ([55510cb](https://github.com/cuongtranba/kanna/commit/55510cbc8ef50bf3e62f03e641098d3e0e051450))
+
+## [0.60.0](https://github.com/cuongtranba/kanna/compare/v0.59.0...v0.60.0) (2026-05-18)
+
+
+### Features
+
+* **ui:** full-app loading overlay during redeploy/update restart ([#207](https://github.com/cuongtranba/kanna/issues/207)) ([c967cf2](https://github.com/cuongtranba/kanna/commit/c967cf21e0b733f06ea2d34f982f8e80ecb96a67))
+* **update:** install any release from changelog UI ([#208](https://github.com/cuongtranba/kanna/issues/208)) ([8fd44e9](https://github.com/cuongtranba/kanna/commit/8fd44e9cdf91fe21b8686081b3dbfb38a549ff6b))
+
+## [0.59.0](https://github.com/cuongtranba/kanna/compare/v0.58.0...v0.59.0) (2026-05-18)
+
+
+### Features
+
+* **subagent:** main agent delegates via mcp__kanna__delegate_subagent ([#205](https://github.com/cuongtranba/kanna/issues/205)) ([47466dc](https://github.com/cuongtranba/kanna/commit/47466dc7aff848baf0fc22d89a14149ee1c30148))
+* **ui:** centralize app bootstrap loading state ([#206](https://github.com/cuongtranba/kanna/issues/206)) ([b4ada0e](https://github.com/cuongtranba/kanna/commit/b4ada0ef1504fad5c53471ceecdf016b2127a97b))
+
+
+### Bug Fixes
+
+* **pty:** close mcp/tmp/tool-callbacks on every exit path ([#201](https://github.com/cuongtranba/kanna/issues/201)) ([26a13b8](https://github.com/cuongtranba/kanna/commit/26a13b8004b93bcafe2803f6ed442cd7e8fc61de))
+* **subagent:** inherit parent chat's OAuth-pool reservation ([#204](https://github.com/cuongtranba/kanna/issues/204)) ([007ece2](https://github.com/cuongtranba/kanna/commit/007ece27d2dcf4dc78ede815fd2bd9c0b2d9b79a))
+
+## [0.58.0](https://github.com/cuongtranba/kanna/compare/v0.57.5...v0.58.0) (2026-05-18)
+
+
+### Features
+
+* **pty:** switch to --print stream-json + trust claude as source of truth ([#200](https://github.com/cuongtranba/kanna/issues/200)) ([ca62112](https://github.com/cuongtranba/kanna/commit/ca621122f39b22609d89782287dbcb8548ff164d))
+
+
+### Bug Fixes
+
+* **subagent:** close 5 P1 concurrency / routing bugs (B1–B5) ([#199](https://github.com/cuongtranba/kanna/issues/199)) ([0775d69](https://github.com/cuongtranba/kanna/commit/0775d6948b63fc9c8629d97b059381fcf53c805b))
+* **subagent:** forward user instruction + scan main reply for mentions ([#196](https://github.com/cuongtranba/kanna/issues/196)) ([0745f78](https://github.com/cuongtranba/kanna/commit/0745f78ac0dd19c153056c1cbec6ee9935e83e1b))
+
+## [0.57.5](https://github.com/cuongtranba/kanna/compare/v0.57.4...v0.57.5) (2026-05-18)
+
+
+### Bug Fixes
+
+* **server:** allow HEAD on /api/projects/:id/{files,uploads}/*/content ([#194](https://github.com/cuongtranba/kanna/issues/194)) ([330f33a](https://github.com/cuongtranba/kanna/commit/330f33a3adfa00e66889f263c1fa992ef95ddd71))
+
+## [0.57.4](https://github.com/cuongtranba/kanna/compare/v0.57.3...v0.57.4) (2026-05-17)
+
+
+### Bug Fixes
+
+* **chat-input:** prevent iOS Safari page-jump when tapping file picker ([#192](https://github.com/cuongtranba/kanna/issues/192)) ([e139eb8](https://github.com/cuongtranba/kanna/commit/e139eb83f5044fdc15fa711fd8afa4c4b46f61e4))
+
+## [0.57.3](https://github.com/cuongtranba/kanna/compare/v0.57.2...v0.57.3) (2026-05-17)
+
+
+### Miscellaneous Chores
+
+* release 0.57.3 to publish reverted baseline to npm ([#190](https://github.com/cuongtranba/kanna/issues/190)) ([5dd8b88](https://github.com/cuongtranba/kanna/commit/5dd8b884921079df6115eef74c2f4f2b1a37f3e7))
+
+## [0.57.2](https://github.com/cuongtranba/kanna/compare/v0.57.1...v0.57.2) (2026-05-17)
+
+
+### Chores
+
+* bump to 0.57.2 to bypass tag clash with the prior v0.57.1 release (v0.57.1 was reverted in #186 but the git tag still points at the old release commit)
+
+## [0.57.1](https://github.com/cuongtranba/kanna/compare/v0.57.0...v0.57.1) (2026-05-17)
+
+
+### Bug Fixes
+
+* **chat-input:** prevent iOS Safari page-jump when tapping file picker ([#182](https://github.com/cuongtranba/kanna/issues/182)) ([d8cd8cd](https://github.com/cuongtranba/kanna/commit/d8cd8cdc30de476fdb3e6f3373f3a217c0784708))
+* **chat-ui:** clamp Selection back into textarea on iOS keyboard-trackpad drift ([#183](https://github.com/cuongtranba/kanna/issues/183)) ([2b55798](https://github.com/cuongtranba/kanna/commit/2b557987c9d23fcf60b152f125a30f8d77c1be98))
+
+
+### Reverts
+
+* restore chat input + version to 0.57.0 state ([#186](https://github.com/cuongtranba/kanna/issues/186)) ([cb0495a](https://github.com/cuongtranba/kanna/commit/cb0495aaf94d974a1fdb16689ab8edf89c98d5c0))
+
+## [0.57.0](https://github.com/cuongtranba/kanna/compare/v0.56.4...v0.57.0) (2026-05-17)
+
+
+### Features
+
+* **pty:** D4 partial — runtime /plan enter via slash command ([#174](https://github.com/cuongtranba/kanna/issues/174)) ([f9ab062](https://github.com/cuongtranba/kanna/commit/f9ab062837d9135e97b31bc584d4d11591ba5bfc))
+* **pty:** phase 1 parity wiring (B2 + B5) ([#164](https://github.com/cuongtranba/kanna/issues/164)) ([3781119](https://github.com/cuongtranba/kanna/commit/3781119ae70cf3b754da6f013ef9ac5e8207cc7e))
+* **pty:** phase 2 — register kanna MCP server in PTY (B3 + B6) ([#168](https://github.com/cuongtranba/kanna/issues/168)) ([aa37c86](https://github.com/cuongtranba/kanna/commit/aa37c86717cd3d5bb8bd4ea3bd4f798470c7919e))
+* **pty:** phase 3 — JSONL event parity (D1 + D2 + D3 + D4) ([#169](https://github.com/cuongtranba/kanna/issues/169)) ([f90384d](https://github.com/cuongtranba/kanna/commit/f90384dee08d457770d00c1505cdb412586a1195))
+* **pty:** phase 4 — failure handling parity (B4 + D5 + D7) ([#170](https://github.com/cuongtranba/kanna/issues/170)) ([85a685d](https://github.com/cuongtranba/kanna/commit/85a685d7138609af9a576663a76cd8843e05b31f))
+* **pty:** phase 5 — subagent routing + shared prompt + account (D6 + D8 + C1) ([#171](https://github.com/cuongtranba/kanna/issues/171)) ([0fa777d](https://github.com/cuongtranba/kanna/commit/0fa777d7b8f988ed6514f5b47c9211c335e1b3c8))
+* **pty:** phase 6 — SDK ↔ PTY equivalence matrix + doc sweep ([#172](https://github.com/cuongtranba/kanna/issues/172)) ([043d82c](https://github.com/cuongtranba/kanna/commit/043d82cf6516752ae707e6272801df2aeb460434))
+* **settings:** subagent CRUD UI ([#166](https://github.com/cuongtranba/kanna/issues/166)) ([0f094ab](https://github.com/cuongtranba/kanna/commit/0f094ab7870fb311a84ec17b080923045923fe3a))
+* **skills:** add kanna-debug skill for transcript-driven debugging ([f6df21a](https://github.com/cuongtranba/kanna/commit/f6df21afbb27a5c4e41c7ac9b6ae9c7b946a00e6))
+
+
+### Bug Fixes
+
+* **agent:** preserve rotation reservation in closeClaudeSession ([#179](https://github.com/cuongtranba/kanna/issues/179)) ([102270c](https://github.com/cuongtranba/kanna/commit/102270c7f8e7b934e0ce2a40588a7f9529987224))
+* **chat-ui:** prevent iOS cursor-jump during hold-space cursor drag ([#180](https://github.com/cuongtranba/kanna/issues/180)) ([cf28ff0](https://github.com/cuongtranba/kanna/commit/cf28ff0ebf2730d54e306bf1927b1a61848b3b7a))
+* **codex:** serve absolute-path generated images via /api/local-file ([#167](https://github.com/cuongtranba/kanna/issues/167)) ([61aa1de](https://github.com/cuongtranba/kanna/commit/61aa1de077404a2009ace01a46043c3d06452eb1))
+* **oauth-pool:** TOCTOU-safe hasUsable, ephemeral lease, pure read loop ([#177](https://github.com/cuongtranba/kanna/issues/177)) ([561e074](https://github.com/cuongtranba/kanna/commit/561e074c4a1b313d29036009bc4847d013c72792))
+* **pty/preflight:** fail-closed on throw, real invalidateAll, contract-versioned cache, poll vs sleep ([#176](https://github.com/cuongtranba/kanna/issues/176)) ([575011e](https://github.com/cuongtranba/kanna/commit/575011eee6c5ddd957808e489a184d8232a77b5e))
+* **pty/preflight:** narrow TOCTOU window by re-verifying binary sha256 before spawn ([#178](https://github.com/cuongtranba/kanna/issues/178)) ([0404680](https://github.com/cuongtranba/kanna/commit/0404680e9a4c148874c075af6ddb697d5bd2c7dc))
+* **pty/sandbox:** symlink resolution, glob surfacing, injection + signal ([#175](https://github.com/cuongtranba/kanna/issues/175)) ([378797f](https://github.com/cuongtranba/kanna/commit/378797f5578456410a002b0afc300918df416940))
+* **pty:** drop credentials.json requirement when OAuth-pool token supplied ([#173](https://github.com/cuongtranba/kanna/issues/173)) ([6dc8f37](https://github.com/cuongtranba/kanna/commit/6dc8f37e8c3327f77f1a6bc09584b0c4954115b3))
+
+## [0.56.4](https://github.com/cuongtranba/kanna/compare/v0.56.3...v0.56.4) (2026-05-16)
+
+
+### Bug Fixes
+
+* **chat:** transcript not scrollable on mobile for long conversations ([#159](https://github.com/cuongtranba/kanna/issues/159)) ([22b273b](https://github.com/cuongtranba/kanna/commit/22b273b90301bef85df8c7b02b693c34bea2e4f1))
+
+## [0.56.3](https://github.com/cuongtranba/kanna/compare/v0.56.2...v0.56.3) (2026-05-16)
+
+
+### Performance Improvements
+
+* **transcript:** stabilize markdown props + memoize message components ([#157](https://github.com/cuongtranba/kanna/issues/157)) ([6ed1531](https://github.com/cuongtranba/kanna/commit/6ed153168686afc6d05fc2f858adcdadbec4209f))
+
+## [0.56.2](https://github.com/cuongtranba/kanna/compare/v0.56.1...v0.56.2) (2026-05-16)
+
+
+### Bug Fixes
+
+* **chat-preferences:** persist composer state + use providerDefaults for new chat ([#155](https://github.com/cuongtranba/kanna/issues/155)) ([54aa3e0](https://github.com/cuongtranba/kanna/commit/54aa3e0562158d965c80d4426ca90ab6489d2d10))
+
+## [0.56.1](https://github.com/cuongtranba/kanna/compare/v0.56.0...v0.56.1) (2026-05-16)
+
+
+### Bug Fixes
+
+* **chat-preferences:** refresh new-chat composer when settings change ([#151](https://github.com/cuongtranba/kanna/issues/151)) ([ad7c3ac](https://github.com/cuongtranba/kanna/commit/ad7c3acd91efd437607f4c2617d5969d34d2a4bf))
+* **compact:** stop cumulative result.usage leaking into usedTokens ([#152](https://github.com/cuongtranba/kanna/issues/152)) ([3007810](https://github.com/cuongtranba/kanna/commit/30078108852aed9b147479b73cbba04e00271613))
+
+## [0.56.0](https://github.com/cuongtranba/kanna/compare/v0.55.3...v0.56.0) (2026-05-16)
+
+
+### Features
+
+* **file-preview:** mobile-first universal file preview sheet ([#143](https://github.com/cuongtranba/kanna/issues/143)) ([181e60a](https://github.com/cuongtranba/kanna/commit/181e60aca9877815da7fb95b84a9183889a593cd))
+
+
+### Bug Fixes
+
+* **agent:** recreate activeTurn on late canUseTool from SDK self-resume ([#148](https://github.com/cuongtranba/kanna/issues/148)) ([4114fc7](https://github.com/cuongtranba/kanna/commit/4114fc7c99944ee0e0f11a4dc8b5e4140d3c7a88))
+
+## [0.55.3](https://github.com/cuongtranba/kanna/compare/v0.55.2...v0.55.3) (2026-05-16)
+
+
+### Bug Fixes
+
+* **server:** dispose fs.watch managers before fallible shutdown awaits ([#146](https://github.com/cuongtranba/kanna/issues/146)) ([9460481](https://github.com/cuongtranba/kanna/commit/9460481145898b469605d4fd687b05dc6f242121))
+
+## [0.55.2](https://github.com/cuongtranba/kanna/compare/v0.55.1...v0.55.2) (2026-05-16)
+
+
+### Bug Fixes
+
+* **test:** dispose AppSettingsManager FSWatchers via centralized afterEach ([#144](https://github.com/cuongtranba/kanna/issues/144)) ([9b7c0be](https://github.com/cuongtranba/kanna/commit/9b7c0be4717167b1c5208db63c8a2c172fe6f91f))
+
+## [0.55.1](https://github.com/cuongtranba/kanna/compare/v0.55.0...v0.55.1) (2026-05-16)
+
+
+### Bug Fixes
+
+* **ci:diag:** capture stuck-process stack when bun test hangs ([#141](https://github.com/cuongtranba/kanna/issues/141)) ([4d83e9c](https://github.com/cuongtranba/kanna/commit/4d83e9cc25cd519aef38e522ca353f9287ad858b))
+
+## [0.55.0](https://github.com/cuongtranba/kanna/compare/v0.54.0...v0.55.0) (2026-05-16)
+
+
+### Features
+
+* **claude-pty:** P7 — driver toggle, lifecycle, sidebar badges, per-chat permissions ([#135](https://github.com/cuongtranba/kanna/issues/135)) ([1742ea7](https://github.com/cuongtranba/kanna/commit/1742ea775e419adfb43f01514557e6fc57241529))
+
+
+### Bug Fixes
+
+* **chat:** seed composer provider from server snapshot on session reload ([#137](https://github.com/cuongtranba/kanna/issues/137)) ([9019c50](https://github.com/cuongtranba/kanna/commit/9019c509786b13153680dbd2342c39db46b17d06))
+* **chat:** server-authoritative routing kills duplicate queued bubble ([#136](https://github.com/cuongtranba/kanna/issues/136)) ([5354454](https://github.com/cuongtranba/kanna/commit/535445437a7d08dde652c2b39b9a91bf71755bd8))
+* **codex:** render ImageGeneration inline with project URL and populated prompt ([#132](https://github.com/cuongtranba/kanna/issues/132)) ([a9d4c39](https://github.com/cuongtranba/kanna/commit/a9d4c3911729984201b498acce32eead1f5263d2))
+* **compact:** persist proactive-compact circuit breaker + harden audit gaps ([#139](https://github.com/cuongtranba/kanna/issues/139)) ([81ed65b](https://github.com/cuongtranba/kanna/commit/81ed65b3db05a96134d4335ad2b32a56f48cb051))
+* **compact:** protect queued message from accidental dequeue mid-compact ([#134](https://github.com/cuongtranba/kanna/issues/134)) ([e1c0c73](https://github.com/cuongtranba/kanna/commit/e1c0c73b79f770483fbdd509ae64d13646650959))
+* **compact:** seed maxTokens from [1m] model id to stop premature compact ([#131](https://github.com/cuongtranba/kanna/issues/131)) ([1f7bc42](https://github.com/cuongtranba/kanna/commit/1f7bc42a483c5d8b65a5eb074c14c25422e4c0b4))
+* **image-gen:** tighten types, fix silent error, dedupe URL builder ([#138](https://github.com/cuongtranba/kanna/issues/138)) ([890ad71](https://github.com/cuongtranba/kanna/commit/890ad716ccf15b3484c3ad6192ae0b8feeb7b3d2))
+* **local-file-link:** treat extension-less paths as editor links ([#129](https://github.com/cuongtranba/kanna/issues/129)) ([8a0c867](https://github.com/cuongtranba/kanna/commit/8a0c867d857f50374d9836988870e2decddebb59))
+* **useKannaState:** drop optimistic user_prompt when chat.send acks queued ([#133](https://github.com/cuongtranba/kanna/issues/133)) ([554b492](https://github.com/cuongtranba/kanna/commit/554b492bcee57f70a41fcf5f6573052ffc345b4e))
+
+## [0.54.0](https://github.com/cuongtranba/kanna/compare/v0.53.0...v0.54.0) (2026-05-15)
+
+
+### Features
+
+* **claude-pty:** session lifecycle + prompt-too-long recovery (P6) ([#122](https://github.com/cuongtranba/kanna/issues/122)) ([9239751](https://github.com/cuongtranba/kanna/commit/9239751d5af721c7807572e454c9e40228f25605))
+
+
+### Bug Fixes
+
+* **codex:** surface image generation + unknown ThreadItems, suppress empty agent messages ([#125](https://github.com/cuongtranba/kanna/issues/125)) ([4130ba9](https://github.com/cuongtranba/kanna/commit/4130ba93d49d98138241a68a66a8798bf73f6af8))
+* **oauth-pool:** release token reservation on turn end so idle chats stop blocking ([#128](https://github.com/cuongtranba/kanna/issues/128)) ([086d60d](https://github.com/cuongtranba/kanna/commit/086d60da07199f8307071839fb946278729d6f24))
+* **tests:** force NODE_ENV=test via bunfig preload to load React dev bundle ([#127](https://github.com/cuongtranba/kanna/issues/127)) ([b38d32f](https://github.com/cuongtranba/kanna/commit/b38d32f036ecd0d502b10311990c2db18276fafc))
+
+## [0.53.0](https://github.com/cuongtranba/kanna/compare/v0.52.0...v0.53.0) (2026-05-15)
+
+
+### Features
+
+* **oauth-pool:** add disabled token status to exclude accounts from pool ([#117](https://github.com/cuongtranba/kanna/issues/117)) ([1fb43ae](https://github.com/cuongtranba/kanna/commit/1fb43ae04b2e7e76282f83864fbdacf7e734cf86))
+* **update:** host-agnostic install with detection + KANNA_UPDATE_COMMAND override ([#119](https://github.com/cuongtranba/kanna/issues/119)) ([e9e66b2](https://github.com/cuongtranba/kanna/commit/e9e66b2d62b34751efacdc2b818db6733c986964))
+
+
+### Bug Fixes
+
+* **oauth-pool:** refuse spawn + rotate on 401 to stop keychain-fallback 401 loop ([#123](https://github.com/cuongtranba/kanna/issues/123)) ([99662fc](https://github.com/cuongtranba/kanna/commit/99662fca8cac12e53eaa8fc8019472ea73e5800c))
+
+## [0.52.0](https://github.com/cuongtranba/kanna/compare/v0.51.0...v0.52.0) (2026-05-15)
+
+
+### Features
+
+* **agent:** proactive /compact injection before context overflows ([#116](https://github.com/cuongtranba/kanna/issues/116)) ([1169e3e](https://github.com/cuongtranba/kanna/commit/1169e3e120946e8c0cfce5a76da6527e6b228356))
+* cancel individual subagent run ([#96](https://github.com/cuongtranba/kanna/issues/96)) ([b171ddf](https://github.com/cuongtranba/kanna/commit/b171ddf7cbf1b566b6df4aa0c82684364a29f704))
+* **claude-pty:** allowlist preflight + --tools flag (P3b) ([#110](https://github.com/cuongtranba/kanna/issues/110)) ([ba6b440](https://github.com/cuongtranba/kanna/commit/ba6b440ae53a6f47cd459d8e5d10750de04e246d))
+* **claude-pty:** Linux bwrap sandbox parity (P4.1) ([#112](https://github.com/cuongtranba/kanna/issues/112)) ([713c1da](https://github.com/cuongtranba/kanna/commit/713c1da25cbbd9c933434920994e6aabf67d4023))
+* **claude-pty:** macOS sandbox-exec wrapper (P4) ([#111](https://github.com/cuongtranba/kanna/issues/111)) ([b3a9e12](https://github.com/cuongtranba/kanna/commit/b3a9e1258c30057dce89f4aa6a68598948643f99))
+* **claude-pty:** OAuth pool rotation via CLAUDE_CODE_OAUTH_TOKEN (P5) ([#114](https://github.com/cuongtranba/kanna/issues/114)) ([65c1542](https://github.com/cuongtranba/kanna/commit/65c1542e4e371a5109c2565679a45ad8dd9c945a))
+* **claude-pty:** PTY core driver (P2 — flag off by default) ([#106](https://github.com/cuongtranba/kanna/issues/106)) ([0ece0ba](https://github.com/cuongtranba/kanna/commit/0ece0ba128c5fc16fd758e675a878f63f8b69095))
+* **kanna-mcp:** built-in tool shims (P3a — flag off by default) ([#107](https://github.com/cuongtranba/kanna/issues/107)) ([bbaed17](https://github.com/cuongtranba/kanna/commit/bbaed17c014bbe874b255aa871b1af5db1c2172b))
+* **mcp-tool-refactor:** durable approval protocol + permission-gate (P1 — flag off by default) ([#105](https://github.com/cuongtranba/kanna/issues/105)) ([d2b2cce](https://github.com/cuongtranba/kanna/commit/d2b2cce003191f5989520adfabeaea6a3de2a1eb))
+
+
+### Bug Fixes
+
+* **agent:** gate runClaudeSession finally activeTurn cleanup on isCurrentSession ([#115](https://github.com/cuongtranba/kanna/issues/115)) ([fad644a](https://github.com/cuongtranba/kanna/commit/fad644a87a2be63ebc7842cedea16621d7f39b0a))
+* **event-store:** dedupe appendMessage by messageId (JSONL replay safety) ([#109](https://github.com/cuongtranba/kanna/issues/109)) ([b6d5c01](https://github.com/cuongtranba/kanna/commit/b6d5c01e3e733d3b3e4a9bad2413b55099edff56))
+* **subagent:** cancel rejects pending resolvers even with no main turn ([#94](https://github.com/cuongtranba/kanna/issues/94)) ([9aac71d](https://github.com/cuongtranba/kanna/commit/9aac71dc226a62703568790bafd45974771c0167))
+* **tool-callback test:** flush background persists before tmpdir cleanup ([#113](https://github.com/cuongtranba/kanna/issues/113)) ([dd0387a](https://github.com/cuongtranba/kanna/commit/dd0387a06b16f2df7bae471aa81a0c9db2b7c951))
+
+## [0.51.0](https://github.com/cuongtranba/kanna/compare/v0.50.0...v0.51.0) (2026-05-14)
+
+
+### Features
+
+* phase 3 subagent orchestration + UI ([#83](https://github.com/cuongtranba/kanna/issues/83)) ([bca45b9](https://github.com/cuongtranba/kanna/commit/bca45b9098b292373b54dcfd1e2bda5f05a3efe9))
+* phase 4 real provider integration for subagents ([#86](https://github.com/cuongtranba/kanna/issues/86)) ([52d22ce](https://github.com/cuongtranba/kanna/commit/52d22ce50335059cc52b3c8705e1608b573d8a70))
+* **sidebar:** asterism separator between stacks ([#85](https://github.com/cuongtranba/kanna/issues/85)) ([002f39e](https://github.com/cuongtranba/kanna/commit/002f39ecb73173ee1b0fbcfe5bd1a34eb264d8ca))
+
+
+### Bug Fixes
+
+* **event-store:** forkChat preserves stack membership ([#87](https://github.com/cuongtranba/kanna/issues/87)) ([7f76ac9](https://github.com/cuongtranba/kanna/commit/7f76ac94bdb1d3f7558b8cfc92ad8deed91d2c26))
+* **oauth-pool:** reserve token per chat to prevent concurrent rotation race ([#89](https://github.com/cuongtranba/kanna/issues/89)) ([686c6b8](https://github.com/cuongtranba/kanna/commit/686c6b8a7de31d02f31f85d52c1c00a6df1581c9))
+* **subagent:** clear pendingTool on terminal events + use /api/local-file ([#88](https://github.com/cuongtranba/kanna/issues/88)) ([e32db6f](https://github.com/cuongtranba/kanna/commit/e32db6fa264f5b5947bd524a3834fdce1890daa3))
+* **subagent:** resolver leaks, full restart recovery, harden cap ([#93](https://github.com/cuongtranba/kanna/issues/93)) ([7bb3d92](https://github.com/cuongtranba/kanna/commit/7bb3d923c84e012a2716aa428d624ec70c519c3a))
+* **ws-router:** strip timings from chat snapshot dedup signature ([#90](https://github.com/cuongtranba/kanna/issues/90)) ([ee3548a](https://github.com/cuongtranba/kanna/commit/ee3548a9ece5c4785aeaaed5e4d9de465fb00668))
+
+## [0.50.0](https://github.com/cuongtranba/kanna/compare/v0.49.0...v0.50.0) (2026-05-14)
+
+
+### Features
+
+* model-independent chat phase 2 (subagent CRUD + [@agent](https://github.com/agent) mentions) ([#81](https://github.com/cuongtranba/kanna/issues/81)) ([07955a8](https://github.com/cuongtranba/kanna/commit/07955a81ad07f16a24bbf69f0c325a7f21999337))
+
+## [0.49.0](https://github.com/cuongtranba/kanna/compare/v0.48.0...v0.49.0) (2026-05-13)
+
+
+### Features
+
+* model-independent chat phase 1 (provider-switching) ([#77](https://github.com/cuongtranba/kanna/issues/77)) ([075000b](https://github.com/cuongtranba/kanna/commit/075000be0201cc59194a76415213784cec0f6db1))
+* **sidebar:** add stack delete via dropdown + context menu ([#79](https://github.com/cuongtranba/kanna/issues/79)) ([f4843a1](https://github.com/cuongtranba/kanna/commit/f4843a1fc987cc05986fdfcb7fc276bb2c4a4702))
+
+## [0.48.0](https://github.com/cuongtranba/kanna/compare/v0.47.2...v0.48.0) (2026-05-13)
+
+
+### Features
+
+* **chat-navbar:** show worktree dir in branch label ([#69](https://github.com/cuongtranba/kanna/issues/69)) ([6dca7cc](https://github.com/cuongtranba/kanna/commit/6dca7cc70e3a950bf88713fe95add172ce00644e))
+* star projects in sidebar ([#74](https://github.com/cuongtranba/kanna/issues/74)) ([65c1b33](https://github.com/cuongtranba/kanna/commit/65c1b330b88c3c67157b8514b5fc3ae0e59efe60))
+* **tunnel:** replace bash-detector with agent-callable expose_port tool ([#70](https://github.com/cuongtranba/kanna/issues/70)) ([24c6233](https://github.com/cuongtranba/kanna/commit/24c6233f3e0594c8ab0543485a312b62661a936b))
+
+
+### Bug Fixes
+
+* **downloads:** render local-file markdown links as download cards ([#75](https://github.com/cuongtranba/kanna/issues/75)) ([67fb665](https://github.com/cuongtranba/kanna/commit/67fb6651788c5718bae2403e777c5db28d9e1667))
+* **oauth-pool:** tear down session on token rotation ([#72](https://github.com/cuongtranba/kanna/issues/72)) ([9f28a71](https://github.com/cuongtranba/kanna/commit/9f28a713bf78657cce14fbbc43cd22db806fb4f0))
+* **server:** serve arbitrary local files via /api/local-file ([#66](https://github.com/cuongtranba/kanna/issues/66)) ([dffbf01](https://github.com/cuongtranba/kanna/commit/dffbf0126b0faa49510dcda0a57eb7e7a1683e05))
+* **stacks:** render stack chats inside expanded stack section ([#71](https://github.com/cuongtranba/kanna/issues/71)) ([d00f6a5](https://github.com/cuongtranba/kanna/commit/d00f6a555a7e51f03e979c3cb235a3014869e93b))
+
+## [0.47.2](https://github.com/cuongtranba/kanna/compare/v0.47.1...v0.47.2) (2026-05-13)
+
+
+### Bug Fixes
+
+* **app-settings:** atomic writes prevent OAuth token loss ([#60](https://github.com/cuongtranba/kanna/issues/60)) ([7619fb8](https://github.com/cuongtranba/kanna/commit/7619fb8e7c2d3ec30a1084704decb2db3dad9077))
+
+## [0.47.1](https://github.com/cuongtranba/kanna/compare/v0.47.0...v0.47.1) (2026-05-13)
+
+
+### Bug Fixes
+
+* **stacks:** stack chat create row layout on narrow widths ([#57](https://github.com/cuongtranba/kanna/issues/57)) ([95d83be](https://github.com/cuongtranba/kanna/commit/95d83bebfb6fbe82a464efe7ce80d68c33dd8888))
+
+## [0.47.0](https://github.com/cuongtranba/kanna/compare/v0.46.1...v0.47.0) (2026-05-13)
+
+
+### Features
+
+* **stacks:** Phase 3 — sidebar UI, chat creation, peer strip ([#55](https://github.com/cuongtranba/kanna/issues/55)) ([0a680c1](https://github.com/cuongtranba/kanna/commit/0a680c119688a9c069e747c5087df96ebe461645))
+
+## [0.46.1](https://github.com/cuongtranba/kanna/compare/v0.46.0...v0.46.1) (2026-05-12)
+
+
+### Bug Fixes
+
+* **oauth-pool:** detect SDK-wrapped rate-limit and rotate tokens ([c0a30a9](https://github.com/cuongtranba/kanna/commit/c0a30a90122db3c15fd5c98a0c00d3e44b62f887))
+
+## [0.46.0](https://github.com/cuongtranba/kanna/compare/v0.45.0...v0.46.0) (2026-05-11)
+
+
+### Features
+
+* OAuth token pool with automatic rotation on rate-limit ([#52](https://github.com/cuongtranba/kanna/issues/52)) ([219ecef](https://github.com/cuongtranba/kanna/commit/219ecefe4fb453525c6e4314413c976235e7806c))
+* **stacks:** Phase 1 — server, events, store, ws-router ([#48](https://github.com/cuongtranba/kanna/issues/48)) ([7abeff1](https://github.com/cuongtranba/kanna/commit/7abeff13a6a7293959d712a36b0480b5ea1e6787))
+* **stacks:** Phase 2 — chat bindings + agent spawn wiring ([#50](https://github.com/cuongtranba/kanna/issues/50)) ([2295fc8](https://github.com/cuongtranba/kanna/commit/2295fc80f2a24815e9263040ab731d91efce8cab))
+* **stacks:** Phase 3 — UI plan (draft, plan-only) ([#51](https://github.com/cuongtranba/kanna/issues/51)) ([4f52dac](https://github.com/cuongtranba/kanna/commit/4f52dace8ddc06f26c879b40a9b0151c0693031a))
+
+
+### Bug Fixes
+
+* **bg-tasks:** remove duplicate "Background tasks" header ([#53](https://github.com/cuongtranba/kanna/issues/53)) ([029c957](https://github.com/cuongtranba/kanna/commit/029c957f44208df6aa4e85ef7ea4e1a611a4c776))
+* **uploads:** raise Bun maxRequestBodySize to upload max ([#45](https://github.com/cuongtranba/kanna/issues/45)) ([68752f4](https://github.com/cuongtranba/kanna/commit/68752f4344c6ecf0dd6d760ef8aa238f4b2bfbf6))
+
+## [0.45.0](https://github.com/cuongtranba/kanna/compare/v0.44.0...v0.45.0) (2026-05-10)
+
+
+### Features
+
+* **agent:** inline file downloads via offer_download SDK MCP tool ([#42](https://github.com/cuongtranba/kanna/issues/42)) ([20b2d99](https://github.com/cuongtranba/kanna/commit/20b2d998e532860551b22bd7dcd4b30ff1e436ef))
+* **bg-tasks:** visibility and stop control for background tasks ([#38](https://github.com/cuongtranba/kanna/issues/38)) ([416bab5](https://github.com/cuongtranba/kanna/commit/416bab580b0cede033f6a16e2bce29026d472e10))
+* **worktrees:** server git wrapper (phase 1) ([#44](https://github.com/cuongtranba/kanna/issues/44)) ([8c1553c](https://github.com/cuongtranba/kanna/commit/8c1553c8c8e0b0bb3d64b70b4b23eae4acfb6299))
+
+
+### Bug Fixes
+
+* **push:** skip push when chat is currently open ([#41](https://github.com/cuongtranba/kanna/issues/41)) ([f6c6bf2](https://github.com/cuongtranba/kanna/commit/f6c6bf23b4ccb658a6ea81c048947bdc3a035050))
+
+## [0.44.0](https://github.com/cuongtranba/kanna/compare/v0.43.2...v0.44.0) (2026-05-08)
+
+
+### Features
+
+* **uploads:** configurable max file size + upload progress UI ([#37](https://github.com/cuongtranba/kanna/issues/37)) ([220d590](https://github.com/cuongtranba/kanna/commit/220d590f541d7e13bce1499484380f5d9be0c87b))
+
+
+### Bug Fixes
+
+* **agent:** clear stuck Running state after cancel-then-steer ([#39](https://github.com/cuongtranba/kanna/issues/39)) ([c951f1c](https://github.com/cuongtranba/kanna/commit/c951f1c8e941b300f488bda7db31189a2a36895a))
+* **chat-input:** show attach button on desktop ([#35](https://github.com/cuongtranba/kanna/issues/35)) ([40c8c8e](https://github.com/cuongtranba/kanna/commit/40c8c8eb50ba95381a5279f0319b76b5d5c68643))
+
+## [0.43.2](https://github.com/cuongtranba/kanna/compare/v0.43.1...v0.43.2) (2026-05-06)
+
+
+### Bug Fixes
+
+* **terminals:** stop dev process leaks on project remove, shell exit, SIGHUP, and crash ([#33](https://github.com/cuongtranba/kanna/issues/33)) ([7d872c1](https://github.com/cuongtranba/kanna/commit/7d872c1dbfa967baae5ccae8f390adb23c6753eb))
+
+## [0.43.1](https://github.com/cuongtranba/kanna/compare/v0.43.0...v0.43.1) (2026-05-06)
+
+
+### Bug Fixes
+
+* **diff-store:** harden git spawns and add CI test workflow ([#31](https://github.com/cuongtranba/kanna/issues/31)) ([fe874fb](https://github.com/cuongtranba/kanna/commit/fe874fbfdaa5c670d2c083c4e044b5984bd21028))
+
+## [0.43.0](https://github.com/cuongtranba/kanna/compare/v0.42.6...v0.43.0) (2026-05-06)
+
+
+### Features
+
+* **timings:** chat session timings UI ([#28](https://github.com/cuongtranba/kanna/issues/28)) ([2f50b22](https://github.com/cuongtranba/kanna/commit/2f50b22d1f21b1b2760cb02f5af5c5d1a7e885cf))
+
+
+### Bug Fixes
+
+* **agent:** set claude_code preset with trust context to stop spurious malware refusals ([a38ec31](https://github.com/cuongtranba/kanna/commit/a38ec3113391c4aef22530a0595d195ecc26ef19))
+
+## [0.42.6](https://github.com/cuongtranba/kanna/compare/v0.42.5...v0.42.6) (2026-05-05)
+
+
+### Bug Fixes
+
+* **quick-response:** unblock Haiku title gen in nested CC sessions ([fff7fa4](https://github.com/cuongtranba/kanna/commit/fff7fa4e21aef17263cddd3506b1776e8a6682a2))
+
+## [0.42.5](https://github.com/cuongtranba/kanna/compare/v0.42.4...v0.42.5) (2026-05-05)
+
+
+### Bug Fixes
+
+* **push:** use /chat singular route in notification payload ([#24](https://github.com/cuongtranba/kanna/issues/24)) ([f7ee018](https://github.com/cuongtranba/kanna/commit/f7ee01838df257cf6c650f8e96c8c3b2feca1d74))
+
+## [0.42.4](https://github.com/cuongtranba/kanna/compare/v0.42.3...v0.42.4) (2026-05-05)
+
+
+### Bug Fixes
+
+* **push:** include diagnostic delivery logging in release ([fb549a9](https://github.com/cuongtranba/kanna/commit/fb549a9c6fb2a9ee91c603a797ddcb7dfe31f5b0))
+
+## [0.42.3](https://github.com/cuongtranba/kanna/compare/v0.42.2...v0.42.3) (2026-05-05)
+
+
+### Bug Fixes
+
+* **test:** make pushClient tests robust to readonly globalThis.window ([#20](https://github.com/cuongtranba/kanna/issues/20)) ([18451f0](https://github.com/cuongtranba/kanna/commit/18451f08d90296c79192300f4dbcd3c68d692cf7))
+
+## [0.42.2](https://github.com/cuongtranba/kanna/compare/v0.42.1...v0.42.2) (2026-05-05)
+
+
+### Bug Fixes
+
+* **push:** use real mailto for VAPID subject ([#18](https://github.com/cuongtranba/kanna/issues/18)) ([df5fd48](https://github.com/cuongtranba/kanna/commit/df5fd48878368cf4f71219a1d03d2cea11f1f057))
+
+## [0.42.1](https://github.com/cuongtranba/kanna/compare/v0.42.0...v0.42.1) (2026-05-05)
+
+
+### Bug Fixes
+
+* **settings:** repair push notifications UI overflow ([#16](https://github.com/cuongtranba/kanna/issues/16)) ([ac39fcd](https://github.com/cuongtranba/kanna/commit/ac39fcdc27497e81aa8b36c1d9f95eaf6e1401ec))
+
+## [0.42.0](https://github.com/cuongtranba/kanna/compare/v0.41.0...v0.42.0) (2026-05-04)
+
+
+### Features
+
+* **agent:** emit session_commands_loaded on Claude session start ([ada47a3](https://github.com/cuongtranba/kanna/commit/ada47a32d962c05b5e1fad141942b7a09915c3f1))
+* **agent:** expose getSupportedCommands on Claude harness ([5416847](https://github.com/cuongtranba/kanna/commit/541684778152845408f548a4b184e9fb76d0e6ae))
+* always-on sidebar RELOAD button + design polish ([b341e37](https://github.com/cuongtranba/kanna/commit/b341e3783c59ec79bd312c3e209beaf8a28fbcc6))
+* **auth:** persist sessions across restart and browser close ([#10](https://github.com/cuongtranba/kanna/issues/10)) ([2734f51](https://github.com/cuongtranba/kanna/commit/2734f51a582ebf2d5895a2f7e8021e8274a99d4e))
+* **auto-continue:** auto-resume chats on rate-limit reset ([#2](https://github.com/cuongtranba/kanna/issues/2)) ([bd67cd8](https://github.com/cuongtranba/kanna/commit/bd67cd8f485a7f505f9d99a5c07f2a0c88c4ee87))
+* **chat-ui:** @ mention file picker ([7f23523](https://github.com/cuongtranba/kanna/commit/7f23523b4b820f8f57dde45b7b5552b55a2c1832))
+* **chat-ui:** add SlashCommandPicker component ([492a61a](https://github.com/cuongtranba/kanna/commit/492a61a6b3fb53fa6083157262bb93e027a4f92c))
+* **chat-ui:** skeleton rows while slash commands load ([b3a4fba](https://github.com/cuongtranba/kanna/commit/b3a4fbab56463255be00e195d707f8ae1c78f52f))
+* **chat-ui:** wire slash command picker into ChatInput ([41d1d22](https://github.com/cuongtranba/kanna/commit/41d1d22ba68b76ff1a94ba57277e02da51fbe16e))
+* **client:** add slash command filter and picker-open utils ([5ebb58c](https://github.com/cuongtranba/kanna/commit/5ebb58c3fc577b72e86a9f731ce78b5a3290c6dc))
+* **client:** add slash commands store ([e7af522](https://github.com/cuongtranba/kanna/commit/e7af5220fae38fb21a42e4b05eb1611c4f3d38d1))
+* **client:** add useSlashCommands hook ([fc213ed](https://github.com/cuongtranba/kanna/commit/fc213ede672168c702e76c8649816e40efc04f68))
+* **client:** populate slash commands store from chat snapshot ([65c2510](https://github.com/cuongtranba/kanna/commit/65c2510ed50d7e36e2729e2bb68f26dd0615b790))
+* **event-store:** record session_commands_loaded events ([4415aab](https://github.com/cuongtranba/kanna/commit/4415aab1eff13a92ba895c87f9f41e07c8b593d5))
+* **events:** add session_commands_loaded turn event ([374e550](https://github.com/cuongtranba/kanna/commit/374e5506b63125921b0d81a27a7809c8854a5674))
+* **import:** add Claude Code session record types ([f5e1f64](https://github.com/cuongtranba/kanna/commit/f5e1f64efccd605572813e0aef93b801c1b79eba))
+* **import:** add Import button to sidebar header ([0759563](https://github.com/cuongtranba/kanna/commit/075956393c9d0a3345c7dc4e8f357007f0633d7b))
+* **import:** add importClaudeSessions state hook ([5e7e491](https://github.com/cuongtranba/kanna/commit/5e7e4916b1132d49ba4cb14a06c51f98e48a7b1e))
+* **import:** add sessions.importClaude WS command ([83219b1](https://github.com/cuongtranba/kanna/commit/83219b168908af49b3b22e3a75fab6f25ad71865))
+* **import:** append new messages when source JSONL changes ([f9fe383](https://github.com/cuongtranba/kanna/commit/f9fe383f246e00576a03b3f1b2759c40cb4279be))
+* **import:** handle sessions.importClaude over WebSocket ([52487bc](https://github.com/cuongtranba/kanna/commit/52487bcc8c522dd2fa35d5e5afb7d6ef86d39b15))
+* **import:** map Claude session records to Kanna transcript entries ([00706a0](https://github.com/cuongtranba/kanna/commit/00706a0a557bd48708531fd1255eb467b986697e))
+* **import:** orchestrate import with dedup and event emission ([f131f69](https://github.com/cuongtranba/kanna/commit/f131f69333870f7c18fd3e248b654f7b490032a3))
+* **import:** parse Claude Code session JSONL files ([46b96bb](https://github.com/cuongtranba/kanna/commit/46b96bb94b9114628d2d88785678d586016abba4))
+* **import:** scan ~/.claude/projects for session files ([c6e369f](https://github.com/cuongtranba/kanna/commit/c6e369f5ac88e744bf9147b1ebd64236d2a0d119))
+* **import:** surface updated count in import result alert ([2529569](https://github.com/cuongtranba/kanna/commit/252956994b353786ffb80d708793936c294d79e6))
+* **import:** track source file md5 on chats for change detection ([02ad85d](https://github.com/cuongtranba/kanna/commit/02ad85d48ac0bbfd95da0072f510e65c7acbb962))
+* pm2 update reloader + swappable update strategy ([4a36d0b](https://github.com/cuongtranba/kanna/commit/4a36d0befb71bd07cb4fe86fed2a941003a5d02f))
+* **pm2:** forward cloudflared token + password via scripts/pm2.env ([3c7a250](https://github.com/cuongtranba/kanna/commit/3c7a2506d394487f5666a07e42120ba2957fe569))
+* **push:** web push notifications for chat state changes ([#11](https://github.com/cuongtranba/kanna/issues/11)) ([8ecb9d1](https://github.com/cuongtranba/kanna/commit/8ecb9d1b76674a22482b086af033c6e2196bec1c))
+* **read-models:** expose slashCommands on ChatSnapshot ([2846ffb](https://github.com/cuongtranba/kanna/commit/2846ffb4c109f784b5e6727bff37ff3215dec218))
+* support serving kanna from a subpath ([72ead70](https://github.com/cuongtranba/kanna/commit/72ead70599bfc99e7b1f4e5a4f9369eed570dd94))
+* **tunnel:** cloudflare quick-tunnel auto-expose ([#3](https://github.com/cuongtranba/kanna/issues/3)) ([7a3d365](https://github.com/cuongtranba/kanna/commit/7a3d3653230a98131e30b7d765b3b3c73bd18348))
+* **types:** add SlashCommand type and ChatSnapshot.slashCommands ([e432971](https://github.com/cuongtranba/kanna/commit/e4329711c371360bff5c29a29cb50498baa3a2f4))
+* **user-message:** render steer icon left of bubble for mid-turn messages ([e251047](https://github.com/cuongtranba/kanna/commit/e251047ba5a1cb8541436c9865173b79cdf40e3e))
+
+
+### Bug Fixes
+
+* add chat auto-scroll setting ([d314796](https://github.com/cuongtranba/kanna/commit/d3147969201af2b6b5b323f9cfc3b21b670e6587))
+* **agent:** pre-warm slash commands on chat subscribe ([4c4ee81](https://github.com/cuongtranba/kanna/commit/4c4ee81d007c9a1b87e3ba085c5bbca3b45b9637))
+* **auto-continue:** detect rate-limit from stream result text ([29ae73c](https://github.com/cuongtranba/kanna/commit/29ae73cd35da5018d2d0e4af3a9a1c1ebbd7327a))
+* **auto-continue:** parse minutes in rate-limit reset text ([bf0f33e](https://github.com/cuongtranba/kanna/commit/bf0f33e97ea9319343374a0f9ec336e6e9161377))
+* avoid autofocus for existing chat history ([8a98fd5](https://github.com/cuongtranba/kanna/commit/8a98fd59c0590d489d7f0c9754578e66659fc763))
+* **chat-ui:** align slash picker columns, prevent wrap ([0da17a1](https://github.com/cuongtranba/kanna/commit/0da17a15ceb1ee0a343033a983f0379a65430856))
+* **chat-ui:** dismiss picker after accepting a command ([321823a](https://github.com/cuongtranba/kanna/commit/321823a66cd4a0eaa5c1eb6f0617fead2afd98ec))
+* **chat-ui:** show full slash command name, responsive picker ([31f2aa5](https://github.com/cuongtranba/kanna/commit/31f2aa5fad120be039de2acadb19e72702b58a51))
+* **chat:** surface tool and action card errors in UI ([8533147](https://github.com/cuongtranba/kanna/commit/85331479c26018a5c07871ed0b3ffcf1fffc204a))
+* close mobile sidebar after chat selection ([b4b5c6f](https://github.com/cuongtranba/kanna/commit/b4b5c6fe10e3f7f4737369bbd52bf84924d6b418))
+* **diff-store:** use main as default branch and support Git < 2.38 ([c22f2a7](https://github.com/cuongtranba/kanna/commit/c22f2a796fd8253bf3a24652e6430c4198a44232))
+* **import:** extract title from array-form user content ([026ac34](https://github.com/cuongtranba/kanna/commit/026ac34c2150dede9fabd30efc4be6e4214232bb))
+* **import:** harden parser against stat errors and use symmetric timestamp sentinels ([18cd8d0](https://github.com/cuongtranba/kanna/commit/18cd8d0674f7b49fdd5f017cab12b2b8b853d7e9))
+* keep chat switches pinned to latest message ([ad73460](https://github.com/cuongtranba/kanna/commit/ad73460990d3b0db932ea2f4c8fd16227ca05b2b))
+* **npm:** rename package scope to [@cuongtran001](https://github.com/cuongtran001) to match npm account ([bd2c0d0](https://github.com/cuongtranba/kanna/commit/bd2c0d0e3d6df02712017a0facd023a463412b87))
+* **pm2:** use ./bin/kanna shebang to bypass pm2 require-based fork wrapper ([13a6e0c](https://github.com/cuongtranba/kanna/commit/13a6e0c690f664ac23c2320de8f0e4362fca5d85))
+* restore chat title fallback generation ([40bc694](https://github.com/cuongtranba/kanna/commit/40bc69461418462710b96b7a4e38582e9d2320c7))
+* restore kanna client bundle build ([38dc79b](https://github.com/cuongtranba/kanna/commit/38dc79b5d3f7049c9d814ae2adc6793ce607a022))
+* **server:** fall back to bundled cloudflared binary ([d539bae](https://github.com/cuongtranba/kanna/commit/d539bae7d87ccb3c7e8490dc1ac03d4b12e7dd07))
+* **sidebar:** allow touch scroll past project headers ([ecb97d8](https://github.com/cuongtranba/kanna/commit/ecb97d80ba4f1a637adecd3c33533032f0d3e8dd))
+* stop forcing transcript autoscroll ([cc39984](https://github.com/cuongtranba/kanna/commit/cc39984f4b6ca6281b566bcfe6d7aa4ca48886a3))
+* **terminal-manager:** prevent zsh-newuser-install dialog in tests ([ac22810](https://github.com/cuongtranba/kanna/commit/ac22810cc57f70124189f16c34a807c3f2d9a9ff))
+* **tests:** use Object.defineProperty to override read-only globalThis props ([aea7eba](https://github.com/cuongtranba/kanna/commit/aea7eba77461bfc3225dd1f7cd99e8c7a5cf3520))
+* **tunnel:** hide card when dismissing a proposed tunnel ([097cc23](https://github.com/cuongtranba/kanna/commit/097cc2323e6cdea8bf2ec4ebebbd2513141d209b))
+* **update:** drop pm2 IPC reload to avoid "Reload in progress" error ([0629f04](https://github.com/cuongtranba/kanna/commit/0629f04f7b02615297dac67fb530c64c3843a394))
+* **update:** re-deploy installs current version when latest is stale ([7deece0](https://github.com/cuongtranba/kanna/commit/7deece0e12556ce4f252d3e16acd6a3963a43980))
+
+## [0.41.0](https://github.com/cuongtranba/kanna/compare/v0.40.1...v0.41.0) (2026-05-04)
+
+
+### Features
+
+* **push:** web push notifications for chat state changes ([#11](https://github.com/cuongtranba/kanna/issues/11)) ([8ecb9d1](https://github.com/cuongtranba/kanna/commit/8ecb9d1b76674a22482b086af033c6e2196bec1c))
+
+## [0.40.1](https://github.com/cuongtranba/kanna/compare/v0.40.0...v0.40.1) (2026-04-30)
+
+
+### Bug Fixes
+
+* **tunnel:** hide card when dismissing a proposed tunnel ([097cc23](https://github.com/cuongtranba/kanna/commit/097cc2323e6cdea8bf2ec4ebebbd2513141d209b))
+
+## [0.40.0](https://github.com/cuongtranba/kanna/compare/v0.39.2...v0.40.0) (2026-04-29)
+
+
+### Features
+
+* **auth:** persist sessions across restart and browser close ([#10](https://github.com/cuongtranba/kanna/issues/10)) ([2734f51](https://github.com/cuongtranba/kanna/commit/2734f51a582ebf2d5895a2f7e8021e8274a99d4e))
+
+
+### Bug Fixes
+
+* **chat:** surface tool and action card errors in UI ([8533147](https://github.com/cuongtranba/kanna/commit/85331479c26018a5c07871ed0b3ffcf1fffc204a))
+* **server:** fall back to bundled cloudflared binary ([d539bae](https://github.com/cuongtranba/kanna/commit/d539bae7d87ccb3c7e8490dc1ac03d4b12e7dd07))
+
+## [0.39.2](https://github.com/cuongtranba/kanna/compare/v0.39.1...v0.39.2) (2026-04-29)
+
+
+### Bug Fixes
+
+* **npm:** rename package scope to [@cuongtran001](https://github.com/cuongtran001) to match npm account ([bd2c0d0](https://github.com/cuongtranba/kanna/commit/bd2c0d0e3d6df02712017a0facd023a463412b87))
+
+## [0.39.1](https://github.com/cuongtranba/kanna/compare/v0.39.0...v0.39.1) (2026-04-29)
+
+
+### Bug Fixes
+
+* **update:** re-deploy installs current version when latest is stale ([7deece0](https://github.com/cuongtranba/kanna/commit/7deece0e12556ce4f252d3e16acd6a3963a43980))
+
+## [0.39.0](https://github.com/cuongtranba/kanna/compare/v0.38.0...v0.39.0) (2026-04-29)
+
+
+### Features
+
+* **agent:** emit session_commands_loaded on Claude session start ([ada47a3](https://github.com/cuongtranba/kanna/commit/ada47a32d962c05b5e1fad141942b7a09915c3f1))
+* **agent:** expose getSupportedCommands on Claude harness ([5416847](https://github.com/cuongtranba/kanna/commit/541684778152845408f548a4b184e9fb76d0e6ae))
+* always-on sidebar RELOAD button + design polish ([b341e37](https://github.com/cuongtranba/kanna/commit/b341e3783c59ec79bd312c3e209beaf8a28fbcc6))
+* **auto-continue:** auto-resume chats on rate-limit reset ([#2](https://github.com/cuongtranba/kanna/issues/2)) ([bd67cd8](https://github.com/cuongtranba/kanna/commit/bd67cd8f485a7f505f9d99a5c07f2a0c88c4ee87))
+* **chat-ui:** @ mention file picker ([7f23523](https://github.com/cuongtranba/kanna/commit/7f23523b4b820f8f57dde45b7b5552b55a2c1832))
+* **chat-ui:** add SlashCommandPicker component ([492a61a](https://github.com/cuongtranba/kanna/commit/492a61a6b3fb53fa6083157262bb93e027a4f92c))
+* **chat-ui:** skeleton rows while slash commands load ([b3a4fba](https://github.com/cuongtranba/kanna/commit/b3a4fbab56463255be00e195d707f8ae1c78f52f))
+* **chat-ui:** wire slash command picker into ChatInput ([41d1d22](https://github.com/cuongtranba/kanna/commit/41d1d22ba68b76ff1a94ba57277e02da51fbe16e))
+* **client:** add slash command filter and picker-open utils ([5ebb58c](https://github.com/cuongtranba/kanna/commit/5ebb58c3fc577b72e86a9f731ce78b5a3290c6dc))
+* **client:** add slash commands store ([e7af522](https://github.com/cuongtranba/kanna/commit/e7af5220fae38fb21a42e4b05eb1611c4f3d38d1))
+* **client:** add useSlashCommands hook ([fc213ed](https://github.com/cuongtranba/kanna/commit/fc213ede672168c702e76c8649816e40efc04f68))
+* **client:** populate slash commands store from chat snapshot ([65c2510](https://github.com/cuongtranba/kanna/commit/65c2510ed50d7e36e2729e2bb68f26dd0615b790))
+* **event-store:** record session_commands_loaded events ([4415aab](https://github.com/cuongtranba/kanna/commit/4415aab1eff13a92ba895c87f9f41e07c8b593d5))
+* **events:** add session_commands_loaded turn event ([374e550](https://github.com/cuongtranba/kanna/commit/374e5506b63125921b0d81a27a7809c8854a5674))
+* **import:** add Claude Code session record types ([f5e1f64](https://github.com/cuongtranba/kanna/commit/f5e1f64efccd605572813e0aef93b801c1b79eba))
+* **import:** add Import button to sidebar header ([0759563](https://github.com/cuongtranba/kanna/commit/075956393c9d0a3345c7dc4e8f357007f0633d7b))
+* **import:** add importClaudeSessions state hook ([5e7e491](https://github.com/cuongtranba/kanna/commit/5e7e4916b1132d49ba4cb14a06c51f98e48a7b1e))
+* **import:** add sessions.importClaude WS command ([83219b1](https://github.com/cuongtranba/kanna/commit/83219b168908af49b3b22e3a75fab6f25ad71865))
+* **import:** append new messages when source JSONL changes ([f9fe383](https://github.com/cuongtranba/kanna/commit/f9fe383f246e00576a03b3f1b2759c40cb4279be))
+* **import:** handle sessions.importClaude over WebSocket ([52487bc](https://github.com/cuongtranba/kanna/commit/52487bcc8c522dd2fa35d5e5afb7d6ef86d39b15))
+* **import:** map Claude session records to Kanna transcript entries ([00706a0](https://github.com/cuongtranba/kanna/commit/00706a0a557bd48708531fd1255eb467b986697e))
+* **import:** orchestrate import with dedup and event emission ([f131f69](https://github.com/cuongtranba/kanna/commit/f131f69333870f7c18fd3e248b654f7b490032a3))
+* **import:** parse Claude Code session JSONL files ([46b96bb](https://github.com/cuongtranba/kanna/commit/46b96bb94b9114628d2d88785678d586016abba4))
+* **import:** scan ~/.claude/projects for session files ([c6e369f](https://github.com/cuongtranba/kanna/commit/c6e369f5ac88e744bf9147b1ebd64236d2a0d119))
+* **import:** surface updated count in import result alert ([2529569](https://github.com/cuongtranba/kanna/commit/252956994b353786ffb80d708793936c294d79e6))
+* **import:** track source file md5 on chats for change detection ([02ad85d](https://github.com/cuongtranba/kanna/commit/02ad85d48ac0bbfd95da0072f510e65c7acbb962))
+* pm2 update reloader + swappable update strategy ([4a36d0b](https://github.com/cuongtranba/kanna/commit/4a36d0befb71bd07cb4fe86fed2a941003a5d02f))
+* **pm2:** forward cloudflared token + password via scripts/pm2.env ([3c7a250](https://github.com/cuongtranba/kanna/commit/3c7a2506d394487f5666a07e42120ba2957fe569))
+* **read-models:** expose slashCommands on ChatSnapshot ([2846ffb](https://github.com/cuongtranba/kanna/commit/2846ffb4c109f784b5e6727bff37ff3215dec218))
+* support serving kanna from a subpath ([72ead70](https://github.com/cuongtranba/kanna/commit/72ead70599bfc99e7b1f4e5a4f9369eed570dd94))
+* **tunnel:** cloudflare quick-tunnel auto-expose ([#3](https://github.com/cuongtranba/kanna/issues/3)) ([7a3d365](https://github.com/cuongtranba/kanna/commit/7a3d3653230a98131e30b7d765b3b3c73bd18348))
+* **types:** add SlashCommand type and ChatSnapshot.slashCommands ([e432971](https://github.com/cuongtranba/kanna/commit/e4329711c371360bff5c29a29cb50498baa3a2f4))
+* **user-message:** render steer icon left of bubble for mid-turn messages ([e251047](https://github.com/cuongtranba/kanna/commit/e251047ba5a1cb8541436c9865173b79cdf40e3e))
+
+
+### Bug Fixes
+
+* add chat auto-scroll setting ([d314796](https://github.com/cuongtranba/kanna/commit/d3147969201af2b6b5b323f9cfc3b21b670e6587))
+* **agent:** pre-warm slash commands on chat subscribe ([4c4ee81](https://github.com/cuongtranba/kanna/commit/4c4ee81d007c9a1b87e3ba085c5bbca3b45b9637))
+* **auto-continue:** detect rate-limit from stream result text ([29ae73c](https://github.com/cuongtranba/kanna/commit/29ae73cd35da5018d2d0e4af3a9a1c1ebbd7327a))
+* **auto-continue:** parse minutes in rate-limit reset text ([bf0f33e](https://github.com/cuongtranba/kanna/commit/bf0f33e97ea9319343374a0f9ec336e6e9161377))
+* avoid autofocus for existing chat history ([8a98fd5](https://github.com/cuongtranba/kanna/commit/8a98fd59c0590d489d7f0c9754578e66659fc763))
+* **chat-ui:** align slash picker columns, prevent wrap ([0da17a1](https://github.com/cuongtranba/kanna/commit/0da17a15ceb1ee0a343033a983f0379a65430856))
+* **chat-ui:** dismiss picker after accepting a command ([321823a](https://github.com/cuongtranba/kanna/commit/321823a66cd4a0eaa5c1eb6f0617fead2afd98ec))
+* **chat-ui:** show full slash command name, responsive picker ([31f2aa5](https://github.com/cuongtranba/kanna/commit/31f2aa5fad120be039de2acadb19e72702b58a51))
+* close mobile sidebar after chat selection ([b4b5c6f](https://github.com/cuongtranba/kanna/commit/b4b5c6fe10e3f7f4737369bbd52bf84924d6b418))
+* **diff-store:** use main as default branch and support Git < 2.38 ([c22f2a7](https://github.com/cuongtranba/kanna/commit/c22f2a796fd8253bf3a24652e6430c4198a44232))
+* **import:** extract title from array-form user content ([026ac34](https://github.com/cuongtranba/kanna/commit/026ac34c2150dede9fabd30efc4be6e4214232bb))
+* **import:** harden parser against stat errors and use symmetric timestamp sentinels ([18cd8d0](https://github.com/cuongtranba/kanna/commit/18cd8d0674f7b49fdd5f017cab12b2b8b853d7e9))
+* keep chat switches pinned to latest message ([ad73460](https://github.com/cuongtranba/kanna/commit/ad73460990d3b0db932ea2f4c8fd16227ca05b2b))
+* **pm2:** use ./bin/kanna shebang to bypass pm2 require-based fork wrapper ([13a6e0c](https://github.com/cuongtranba/kanna/commit/13a6e0c690f664ac23c2320de8f0e4362fca5d85))
+* restore chat title fallback generation ([40bc694](https://github.com/cuongtranba/kanna/commit/40bc69461418462710b96b7a4e38582e9d2320c7))
+* restore kanna client bundle build ([38dc79b](https://github.com/cuongtranba/kanna/commit/38dc79b5d3f7049c9d814ae2adc6793ce607a022))
+* **sidebar:** allow touch scroll past project headers ([ecb97d8](https://github.com/cuongtranba/kanna/commit/ecb97d80ba4f1a637adecd3c33533032f0d3e8dd))
+* stop forcing transcript autoscroll ([cc39984](https://github.com/cuongtranba/kanna/commit/cc39984f4b6ca6281b566bcfe6d7aa4ca48886a3))
+* **terminal-manager:** prevent zsh-newuser-install dialog in tests ([ac22810](https://github.com/cuongtranba/kanna/commit/ac22810cc57f70124189f16c34a807c3f2d9a9ff))
+* **tests:** use Object.defineProperty to override read-only globalThis props ([aea7eba](https://github.com/cuongtranba/kanna/commit/aea7eba77461bfc3225dd1f7cd99e8c7a5cf3520))
+
+## [0.35.0](https://github.com/cuongtranba/kanna/compare/v0.34.2...v0.35.0) (2026-04-28)
+
+
+### Features
+
+* **agent:** emit session_commands_loaded on Claude session start ([ada47a3](https://github.com/cuongtranba/kanna/commit/ada47a32d962c05b5e1fad141942b7a09915c3f1))
+* **agent:** expose getSupportedCommands on Claude harness ([5416847](https://github.com/cuongtranba/kanna/commit/541684778152845408f548a4b184e9fb76d0e6ae))
+* always-on sidebar RELOAD button + design polish ([b341e37](https://github.com/cuongtranba/kanna/commit/b341e3783c59ec79bd312c3e209beaf8a28fbcc6))
+* **auto-continue:** auto-resume chats on rate-limit reset ([#2](https://github.com/cuongtranba/kanna/issues/2)) ([bd67cd8](https://github.com/cuongtranba/kanna/commit/bd67cd8f485a7f505f9d99a5c07f2a0c88c4ee87))
+* **chat-ui:** @ mention file picker ([7f23523](https://github.com/cuongtranba/kanna/commit/7f23523b4b820f8f57dde45b7b5552b55a2c1832))
+* **chat-ui:** add SlashCommandPicker component ([492a61a](https://github.com/cuongtranba/kanna/commit/492a61a6b3fb53fa6083157262bb93e027a4f92c))
+* **chat-ui:** skeleton rows while slash commands load ([b3a4fba](https://github.com/cuongtranba/kanna/commit/b3a4fbab56463255be00e195d707f8ae1c78f52f))
+* **chat-ui:** wire slash command picker into ChatInput ([41d1d22](https://github.com/cuongtranba/kanna/commit/41d1d22ba68b76ff1a94ba57277e02da51fbe16e))
+* **client:** add slash command filter and picker-open utils ([5ebb58c](https://github.com/cuongtranba/kanna/commit/5ebb58c3fc577b72e86a9f731ce78b5a3290c6dc))
+* **client:** add slash commands store ([e7af522](https://github.com/cuongtranba/kanna/commit/e7af5220fae38fb21a42e4b05eb1611c4f3d38d1))
+* **client:** add useSlashCommands hook ([fc213ed](https://github.com/cuongtranba/kanna/commit/fc213ede672168c702e76c8649816e40efc04f68))
+* **client:** populate slash commands store from chat snapshot ([65c2510](https://github.com/cuongtranba/kanna/commit/65c2510ed50d7e36e2729e2bb68f26dd0615b790))
+* **event-store:** record session_commands_loaded events ([4415aab](https://github.com/cuongtranba/kanna/commit/4415aab1eff13a92ba895c87f9f41e07c8b593d5))
+* **events:** add session_commands_loaded turn event ([374e550](https://github.com/cuongtranba/kanna/commit/374e5506b63125921b0d81a27a7809c8854a5674))
+* **import:** add Claude Code session record types ([f5e1f64](https://github.com/cuongtranba/kanna/commit/f5e1f64efccd605572813e0aef93b801c1b79eba))
+* **import:** add Import button to sidebar header ([0759563](https://github.com/cuongtranba/kanna/commit/075956393c9d0a3345c7dc4e8f357007f0633d7b))
+* **import:** add importClaudeSessions state hook ([5e7e491](https://github.com/cuongtranba/kanna/commit/5e7e4916b1132d49ba4cb14a06c51f98e48a7b1e))
+* **import:** add sessions.importClaude WS command ([83219b1](https://github.com/cuongtranba/kanna/commit/83219b168908af49b3b22e3a75fab6f25ad71865))
+* **import:** append new messages when source JSONL changes ([f9fe383](https://github.com/cuongtranba/kanna/commit/f9fe383f246e00576a03b3f1b2759c40cb4279be))
+* **import:** handle sessions.importClaude over WebSocket ([52487bc](https://github.com/cuongtranba/kanna/commit/52487bcc8c522dd2fa35d5e5afb7d6ef86d39b15))
+* **import:** map Claude session records to Kanna transcript entries ([00706a0](https://github.com/cuongtranba/kanna/commit/00706a0a557bd48708531fd1255eb467b986697e))
+* **import:** orchestrate import with dedup and event emission ([f131f69](https://github.com/cuongtranba/kanna/commit/f131f69333870f7c18fd3e248b654f7b490032a3))
+* **import:** parse Claude Code session JSONL files ([46b96bb](https://github.com/cuongtranba/kanna/commit/46b96bb94b9114628d2d88785678d586016abba4))
+* **import:** scan ~/.claude/projects for session files ([c6e369f](https://github.com/cuongtranba/kanna/commit/c6e369f5ac88e744bf9147b1ebd64236d2a0d119))
+* **import:** surface updated count in import result alert ([2529569](https://github.com/cuongtranba/kanna/commit/252956994b353786ffb80d708793936c294d79e6))
+* **import:** track source file md5 on chats for change detection ([02ad85d](https://github.com/cuongtranba/kanna/commit/02ad85d48ac0bbfd95da0072f510e65c7acbb962))
+* pm2 update reloader + swappable update strategy ([4a36d0b](https://github.com/cuongtranba/kanna/commit/4a36d0befb71bd07cb4fe86fed2a941003a5d02f))
+* **pm2:** forward cloudflared token + password via scripts/pm2.env ([3c7a250](https://github.com/cuongtranba/kanna/commit/3c7a2506d394487f5666a07e42120ba2957fe569))
+* **read-models:** expose slashCommands on ChatSnapshot ([2846ffb](https://github.com/cuongtranba/kanna/commit/2846ffb4c109f784b5e6727bff37ff3215dec218))
+* support serving kanna from a subpath ([72ead70](https://github.com/cuongtranba/kanna/commit/72ead70599bfc99e7b1f4e5a4f9369eed570dd94))
+* **tunnel:** cloudflare quick-tunnel auto-expose ([#3](https://github.com/cuongtranba/kanna/issues/3)) ([7a3d365](https://github.com/cuongtranba/kanna/commit/7a3d3653230a98131e30b7d765b3b3c73bd18348))
+* **types:** add SlashCommand type and ChatSnapshot.slashCommands ([e432971](https://github.com/cuongtranba/kanna/commit/e4329711c371360bff5c29a29cb50498baa3a2f4))
+* **user-message:** render steer icon left of bubble for mid-turn messages ([e251047](https://github.com/cuongtranba/kanna/commit/e251047ba5a1cb8541436c9865173b79cdf40e3e))
+
+
+### Bug Fixes
+
+* add chat auto-scroll setting ([d314796](https://github.com/cuongtranba/kanna/commit/d3147969201af2b6b5b323f9cfc3b21b670e6587))
+* **agent:** pre-warm slash commands on chat subscribe ([4c4ee81](https://github.com/cuongtranba/kanna/commit/4c4ee81d007c9a1b87e3ba085c5bbca3b45b9637))
+* **auto-continue:** detect rate-limit from stream result text ([29ae73c](https://github.com/cuongtranba/kanna/commit/29ae73cd35da5018d2d0e4af3a9a1c1ebbd7327a))
+* **auto-continue:** parse minutes in rate-limit reset text ([bf0f33e](https://github.com/cuongtranba/kanna/commit/bf0f33e97ea9319343374a0f9ec336e6e9161377))
+* avoid autofocus for existing chat history ([8a98fd5](https://github.com/cuongtranba/kanna/commit/8a98fd59c0590d489d7f0c9754578e66659fc763))
+* **chat-ui:** align slash picker columns, prevent wrap ([0da17a1](https://github.com/cuongtranba/kanna/commit/0da17a15ceb1ee0a343033a983f0379a65430856))
+* **chat-ui:** dismiss picker after accepting a command ([321823a](https://github.com/cuongtranba/kanna/commit/321823a66cd4a0eaa5c1eb6f0617fead2afd98ec))
+* **chat-ui:** show full slash command name, responsive picker ([31f2aa5](https://github.com/cuongtranba/kanna/commit/31f2aa5fad120be039de2acadb19e72702b58a51))
+* close mobile sidebar after chat selection ([b4b5c6f](https://github.com/cuongtranba/kanna/commit/b4b5c6fe10e3f7f4737369bbd52bf84924d6b418))
+* **diff-store:** use main as default branch and support Git < 2.38 ([c22f2a7](https://github.com/cuongtranba/kanna/commit/c22f2a796fd8253bf3a24652e6430c4198a44232))
+* **import:** extract title from array-form user content ([026ac34](https://github.com/cuongtranba/kanna/commit/026ac34c2150dede9fabd30efc4be6e4214232bb))
+* **import:** harden parser against stat errors and use symmetric timestamp sentinels ([18cd8d0](https://github.com/cuongtranba/kanna/commit/18cd8d0674f7b49fdd5f017cab12b2b8b853d7e9))
+* keep chat switches pinned to latest message ([ad73460](https://github.com/cuongtranba/kanna/commit/ad73460990d3b0db932ea2f4c8fd16227ca05b2b))
+* **pm2:** use ./bin/kanna shebang to bypass pm2 require-based fork wrapper ([13a6e0c](https://github.com/cuongtranba/kanna/commit/13a6e0c690f664ac23c2320de8f0e4362fca5d85))
+* restore chat title fallback generation ([40bc694](https://github.com/cuongtranba/kanna/commit/40bc69461418462710b96b7a4e38582e9d2320c7))
+* restore kanna client bundle build ([38dc79b](https://github.com/cuongtranba/kanna/commit/38dc79b5d3f7049c9d814ae2adc6793ce607a022))
+* **sidebar:** allow touch scroll past project headers ([ecb97d8](https://github.com/cuongtranba/kanna/commit/ecb97d80ba4f1a637adecd3c33533032f0d3e8dd))
+* stop forcing transcript autoscroll ([cc39984](https://github.com/cuongtranba/kanna/commit/cc39984f4b6ca6281b566bcfe6d7aa4ca48886a3))
+* **terminal-manager:** prevent zsh-newuser-install dialog in tests ([ac22810](https://github.com/cuongtranba/kanna/commit/ac22810cc57f70124189f16c34a807c3f2d9a9ff))
+* **tests:** use Object.defineProperty to override read-only globalThis props ([aea7eba](https://github.com/cuongtranba/kanna/commit/aea7eba77461bfc3225dd1f7cd99e8c7a5cf3520))
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 000000000..43d10bb16
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,547 @@
+# Architecture
+
+This project uses C3 docs in `.c3/`.
+
+**MANDATORY for Claude Code AND Codex:**
+1. **Before coding** — run `/c3 query ` (or `c3x lookup `) to load
+ component context, refs, and rules. Do NOT skip even for "small" edits.
+ Skipping = stale assumptions = wrong patches.
+2. **After coding** — if change touches component boundaries, refs, public
+ contracts, or rules, run `/c3 change` (or `/c3 sweep` for audit) to update
+ `.c3/` docs in the SAME PR. Code-doc drift is a blocker.
+3. **Architecture questions, audits, file→component lookup** — always `/c3`.
+
+Operations: query, audit, change, ref, sweep.
+File lookup: `c3x lookup ` maps files/directories to components + refs.
+Skill: `c3-skill:c3` (auto-triggers on `/c3` or architecture phrases).
+
+# Pull Requests
+
+This is a fork. `origin` = `cuongtranba/kanna` (mine), `upstream` = `jakemor/kanna`.
+PRs MUST target `cuongtranba/kanna`, never `jakemor/kanna`.
+`gh repo set-default cuongtranba/kanna` is set; always pass `--repo cuongtranba/kanna`
+or `--base main --head ` to `gh pr create` to make the target explicit.
+
+# Lint
+
+`bun run lint` runs ESLint on `src/` with `--max-warnings=0`. CI runs it
+before tests; merges blocked on lint errors AND on any warning count above
+the cap. The cap is a ratchet: when warnings drop, lower the cap in the
+same PR so they cannot creep back up. Plugin `react-hooks` (set 7+) enforces
+React 19 rules: `rules-of-hooks`, `purity`, `globals` are errors;
+`set-state-in-effect`, `refs`, `immutability`, `preserve-manual-memoization`,
+`exhaustive-deps` are warnings.
+
+# Side-Effect Lint (ports-and-adapters seal)
+
+Side effects (`node:fs`, `chokidar`, `bun:sqlite`/`better-sqlite3`/`pg`,
+`node:child_process`, `node:http`/`https`, `Bun.spawn`/`Bun.$`/`Bun.file`,
+`new Database`, `process.exit`, `process.env`) are **sealed at `error`
+across both `src/shared/**` + `src/client/**` AND `src/server/**`
+production code**.
+
+`no-restricted-imports` + `no-restricted-globals` + `no-restricted-syntax`
+in `eslint.config.js` make every flagged import / global / call fail
+`bun run lint`. Browser-native `fetch` is intentionally allowed in
+shared/client. There is no escape valve; do not add `eslint-disable`
+comments.
+
+**Server layer exempt globs** (where direct IO is allowed):
+`src/server/**/*.test.ts(x)`, `src/server/__fixtures__/**`,
+`src/server/test-helpers/**`, `src/server/adapters/**`, and any file
+matching `src/server/**/*.adapter.ts`.
+
+**`.adapter.ts` filename convention.** Any file whose single
+responsibility is to perform the side effect on behalf of a port
+interface MUST be suffixed `.adapter.ts` and colocated next to its
+port. Mixed-concern modules (domain logic + IO) extract their IO into
+a sibling `*-io.adapter.ts` instead of renaming the parent.
+
+**Adding new IO.** New IO requires either (1) putting the call in a
+file matching one of the exempt globs above, or (2) injecting the
+operation through a typed parameter / port interface. Adapter files
+are leaf modules — they wrap one node/Bun primitive and have no
+domain logic, so they are safe to import from anywhere that needs
+the operation.
+
+Authored across PRs #283 (pure-layer seal), #285 (paths-config
+purify), #286 (call-site selectors), #287 (ratchet infrastructure),
+#288–#302 (burn-down 90 → 0), and the final flip (server override
+moved to `error` + ratchet tooling deleted).
+
+# Render-loop regression checks
+
+When introducing a new `use*Store` selector or any React hook that derives
+collections, the selector MUST return a stable reference. Inline `?? []` or
+`?? {}` produces fresh refs each call and triggers React error #185
+(`Maximum update depth exceeded`). Pattern to use:
+
+```ts
+const EMPTY: Subagent[] = []
+useStore((state) => state.list ?? EMPTY)
+// or
+useStore(useShallow((state) => state.list ?? []))
+```
+
+Tests can mount a component with effects and assert no loop warnings via
+`renderForLoopCheck` in `src/client/lib/testing/`.
+
+# Tool Callback Feature Flag (KANNA_MCP_TOOL_CALLBACKS)
+
+Setting `KANNA_MCP_TOOL_CALLBACKS=1` routes `AskUserQuestion` and
+`ExitPlanMode` through the durable approval protocol in
+`src/server/tool-callback.ts`. Pending requests survive server restart
+(resolved as `session_closed` fail-closed on boot) and are replayed to the
+client on reconnect as `pending_tool_request` transcript entries. Default is
+off; the SDK driver uses the legacy `canUseTool` → `onToolRequest` path.
+
+**PTY exception (issue #215):** under `KANNA_CLAUDE_DRIVER=pty` the
+`ask_user_question` / `exit_plan_mode` shims are **always registered**
+regardless of this flag — the PTY driver passes
+`forceInteractiveToolCallbacks: true` to `buildKannaMcpTools` because
+PTY has no `canUseTool` hook (the durable approval protocol is the only
+host path). The PTY CLI args also include
+`--disallowedTools AskUserQuestion ExitPlanMode` so the model cannot
+pick the native built-ins (which the CLI auto-rejects with
+`is_error: "Answer questions?"`, mis-read as a user cancel). The flag
+still **exclusively** gates the 8 built-in shims
+(`read/glob/grep/bash/edit/write/webfetch/websearch`) and the SDK
+driver's `canUseTool` routing — those are never force-enabled under PTY.
+
+Optional `KANNA_SERVER_SECRET` env var stabilises HMAC tool-request ids
+across the process lifetime. Cross-restart idempotency does not matter
+because `recoverOnStartup()` fail-closes all pending records on boot.
+
+Periodic `tickTimeouts` driver fires every 5s; default request timeout is
+600s. Pending requests time out as `{kind:"deny", reason:"timeout"}`.
+
+# Claude Driver Flag (KANNA_CLAUDE_DRIVER)
+
+Setting `KANNA_CLAUDE_DRIVER=pty` launches the `claude` CLI **interactively**
+under a Bun.Terminal pseudo-terminal (Shannon-style) and tails the on-disk
+transcript JSONL at `~/.claude/projects//.jsonl`
+as the sole event source. Input is sent as raw text + `\r` (no JSONL
+envelopes). PTY mode preserves Pro/Max subscription billing; SDK mode
+bills at API rates.
+
+Default is `sdk` (no behaviour change). Authentication requires an OAuth-pool
+token configured in Kanna settings; the token is injected via
+`CLAUDE_CODE_OAUTH_TOKEN`. The local `claude /login` keychain path is not
+supported in this deployment. PTY mode is OAuth-only and NEVER uses an API
+key: `buildPtyEnv` unconditionally strips `ANTHROPIC_API_KEY` from the
+spawned child env. `verifyPtyAuth` only requires the OAuth-pool token.
+
+Platform support: macOS / Linux only.
+
+**Encoded cwd path:** Claude resolves the cwd to its real path
+(`fs.realpathSync` — macOS `/var` → `/private/var`), then replaces both
+`/` and `.` with `-`. `src/server/claude-pty/jsonl-path.ts`
+(`encodeCwd`, `computeJsonlPath`, `computeProjectDir`) matches this
+behaviour exactly. Mismatch = transcript file never found.
+
+**Trust dialog:** TUI claude prompts "Quick safety check: Is this a project
+you created or one you trust?" on every previously-unseen cwd. The driver
+detects the marker in the PTY output ring buffer and sends `\r` to accept
+"Yes, I trust this folder" (the default-highlighted option). Trust persists
+across spawns in the same cwd, so the dismiss cost amortises. Set
+`KANNA_PTY_TRUST_DISMISS=disabled` to bypass detection (escape hatch if
+Anthropic changes the dialog wording).
+
+**TUI ready signal:** Driver polls the output ring for the input-box marker
+`❯ ` before sending the first prompt. Hard cap defaults to 3000 ms
+(`KANNA_PTY_TUI_BOOT_MS`).
+
+**Transcript watch:** `tui-source.ts` uses `fs.watch` by default; set
+`KANNA_PTY_TRANSCRIPT_WATCH=poll` to force 50 ms polling (for unreliable
+filesystems like NFS / CIFS).
+
+**oneShot subagent close:** After the first `result` transcript entry on a
+one-shot run (Claude subagent), the driver sends `/exit\r` to gracefully
+close the REPL, awaits `pty.exited` with 5 s grace, then escalates SIGTERM →
+SIGKILL on hang. Matches the SDK driver's prompt-queue close semantics.
+
+**Smoke test (replaces preflight P3b):** Every spawn passes through a
+single TUI probe that verifies `--disallowedTools Bash` is honored.
+Cached 24 h per (binarySha256, model) under
+`${HOME}/.kanna/cache/smoke-test/`. PASS unlocks spawn; FAIL refuses
+with a clear reason that surfaces through the existing spawn-error
+path. The 8-probe preflight gate is removed (`KANNA_PTY_PREFLIGHT_MODEL`
+no longer consulted).
+
+**AskUserQuestion / ExitPlanMode (issue #215 — CLOSED):** Driver disallows
+the native built-ins (`--disallowedTools AskUserQuestion ExitPlanMode`)
+and force-registers the `mcp__kanna__ask_user_question` /
+`mcp__kanna__exit_plan_mode` shims, which route through the durable
+approval protocol to the UI — active regardless of `KANNA_MCP_TOOL_CALLBACKS`.
+See the Tool Callback Feature Flag section for full wiring.
+
+**setPermissionMode:** Asymmetric.
+- ENTER plan (`planMode === true`) sends `/plan\r` and sets an internal
+ `localPlanModeActive = true` flag.
+- EXIT plan (`planMode === false`) sends `SHIFT_TAB_KEY` (`\x1b[Z`, one
+ Shift+Tab press) and clears the flag **when `localPlanModeActive` is
+ true** — covers the common case where the driver entered plan mode.
+ If the flag is false (plan mode toggled externally via Shift+Tab in the
+ UI), a warning is logged and no keypress is sent. Restart the session
+ to return to acceptEdits from an unknown state. Tracked:
+ anthropics/claude-code#59891.
+
+**setModel:** Sends `/model \r` via the slash command (no stream-json
+control_request envelope in TUI mode).
+
+**interrupt:** Sends `Ctrl+C` (0x03) via PTY stdin — TUI claude treats this
+as an interactive interrupt, cancelling the current turn.
+
+**getSupportedCommands():** Returns the live slash-command list from the
+spawned claude's `system_init` JSONL entry once a session is active.
+Falls back to a static four-command list (`model`, `exit`, `clear`, `help`)
+before first spawn (cold-start gap).
+
+**SDK ↔ PTY equivalence (Phase 6):** `src/server/claude-pty/parity-matrix.test.ts`
+drives both `createClaudeHarnessStream` (SDK) and `createJsonlEventParser`
+fed via `startTranscriptStream` (PTY) with the same SDK-message fixtures and
+asserts identical `HarnessEvent` sequences. Covers the original 7 cases
+unchanged.
+
+**Subagent + prompt + account parity (Phase 5):** unchanged from prior
+phases — `buildClaudeSubagentStarter` adapts the SDK-shaped starter to
+`StartClaudeSessionPtyArgs` with `oneShot: true`; both drivers append
+the shared `KANNA_SYSTEM_PROMPT_APPEND`; PTY derives `AccountInfo` from
+the picked OAuth-pool token label + masked key.
+
+**Failure handling:** Every PTY spawn captures terminal output into a 256 KB
+ring buffer (`OutputRing` in `output-ring.ts`). Failure synthesis on silent
+exit, auth detection (`401`, "Please run /login", "Not logged in"), and
+trust-dialog detection all read from this ring. Synthesised error events
+feed the same `detectFromResultText` / OAuth-pool rotation path in
+`agent.ts` the SDK driver uses.
+
+**Architecture note:** PTY mode parses the on-disk transcript JSONL file
+as the sole event source — `src/server/claude-pty/tui-source.ts`
+(`startTranscriptStream`) watches `~/.claude/projects//`
+for the file claude creates on first user prompt, then follows it via
+`fs.watch` (or polling under `KANNA_PTY_TRANSCRIPT_WATCH=poll`).
+`driver.ts` is a thin coordinator: spawn (via `pty-process.ts`
+`spawnPtyProcess` + Bun.Terminal) → trust dismiss → first-prompt send →
+pipe transcript lines into `createJsonlEventParser` → emit HarnessEvents.
+Nothing reads the PTY stdout for events; the output ring only powers
+trust detection + failure synth. Spawn-time `--mcp-config` still wires
+the kanna-mcp loopback HTTP server (Phase 2) unchanged.
+
+**OAuth pool rotation (P5):** PTY mode honors the same multi-token rotation
+the SDK driver uses. `AgentCoordinator` picks an active token from
+`OAuthTokenPool` per chat and the PTY driver injects it via the
+`CLAUDE_CODE_OAUTH_TOKEN` env var. Auth failures (401 detected in the
+output ring) synthesise an `oauth_invalid_token` result event that feeds
+the same rotation/retry path the SDK driver uses on thrown stream errors.
+
+**Env vars (PTY-specific):**
+- `KANNA_CLAUDE_DRIVER=sdk|pty` — driver selector (default `sdk`).
+- `KANNA_MCP_TOOL_CALLBACKS=1` — route built-in shims through durable approval.
+- `KANNA_PTY_TRUST_DISMISS=enabled|disabled` — trust-dialog dismiss (default `enabled`).
+- `KANNA_PTY_TUI_BOOT_MS=3000` — hard cap on TUI-ready wait (default `3000`).
+- `KANNA_PTY_TRANSCRIPT_WATCH=fs|poll` — transcript watch mode (default `fs`).
+- `CLAUDE_CODE_OAUTH_TOKEN` — set by driver from pool, NOT a user env var.
+- `KANNA_PTY_CHANNEL_DELIVERY=enabled|disabled` — for one-shot (subagent) PTY
+ spawns, deliver the prompt via a `notifications/claude/channel` push instead
+ of typing it into the TUI (default `enabled`). Avoids the multi-line
+ bracketed-paste collapse that silently truncated subagent prompts. Requires
+ the account's channel feature enabled. Fail-fast: if the channel client is
+ not ready within `KANNA_PTY_CHANNEL_READY_TIMEOUT_MS` the spawn fails with a
+ clear error — there is NO silent paste fallback. Set `disabled` to revert
+ subagent spawns to the legacy paste path. Adds
+ `--dangerously-load-development-channels server:kanna` to subagent spawns and
+ appends channel framing to the subagent system prompt.
+- `KANNA_PTY_CHANNEL_READY_TIMEOUT_MS=15000` — channel client-ready timeout
+ before a subagent spawn fails fast (default `15000`).
+
+Removed in this version (no longer consulted):
+- `KANNA_PTY_PREFLIGHT_MODEL` — preflight gone, replaced by smoke-test.
+- `KANNA_PTY_SANDBOX` — sandbox already removed in a prior change; flag now inert.
+
+# Kanna-MCP Built-in Shims
+
+When `KANNA_MCP_TOOL_CALLBACKS=1`, kanna-mcp registers 8 additional tools
+that mirror Claude's built-ins: `mcp__kanna__{read, glob, grep, bash, edit,
+write, webfetch, websearch}`. They route through the durable approval
+protocol with the same path-deny rules as the bash tool from P1 (readPathDeny
+for `read`/`glob`/`grep`, writePathDeny for `edit`/`write`).
+
+These shims are inert until the PTY driver applies `--tools "mcp__kanna__*"`
+(P3b — landing in a follow-up PR). With the SDK driver (default), the model
+still uses its native built-ins and these shims sit unused.
+
+`websearch` is a stub that always returns `isError: true` — real web search
+needs an external API integration which is out of scope for P3a.
+
+# Custom MCP Servers
+
+Users register MCP servers via Settings → "MCP servers". Entries persist
+in `settings.json` under `customMcpServers` (file mode 0600) and are
+merged into both Claude drivers at chat spawn time:
+
+- **SDK driver** (`agent.ts`): `buildUserMcpServers` maps each enabled
+ entry to the SDK's per-transport config and merges it into the
+ `mcpServers` map passed to `query()` alongside `mcp__kanna__*`.
+- **PTY driver** (`kanna-mcp-http.ts:buildMcpConfigJson` +
+ `claude-pty/driver.ts`): entries serialize into the same
+ `mcp-config.json` the driver hands to `--strict-mcp-config`. Kanna
+ settings remain the single source of truth; `~/.claude.json` stays
+ ignored.
+
+User MCP tool calls auto-allow (`canUseTool` already returns
+`{ behavior: "allow" }` for any tool that isn't `AskUserQuestion` /
+`ExitPlanMode`, which includes every `mcp____*` whose ``
+isn't `kanna`). Trust model: if the user installed it, they trust it.
+
+Supported transports: `stdio`, `http`, `sse`, `ws`. Reserved name:
+`kanna`. Names match `^[a-zA-Z][a-zA-Z0-9_-]{0,31}$` and form the tool
+prefix `mcp____`.
+
+**Connect-test:** on create/update, `ws-router.ts` fires a fire-and-
+forget `validateMcpServer` (`src/server/mcp-validator.ts`, 10s timeout,
+list-tools probe) and persists `lastTest` on the entry. The UI shows a
+per-row status pill plus a manual "Test" button that drives the
+explicit `settings.testMcpServer` RPC.
+
+**Boundary rule:** user MCP server names MUST NOT equal
+`KANNA_MCP_SERVER_NAME`. Enforced by both `validateMcpShape`
+(`app-settings.ts`) and `buildUserMcpServers` / `buildMcpConfigJson`
+filters (belt-and-suspenders).
+
+# Subagent Delegation (Anthropic Task-tool pattern)
+
+The main agent is always in the loop. `@agent/` in chat input is a
+**hint**, not server-side routing — it no longer short-circuits the main
+turn. The main model decides whether to delegate and calls
+`mcp__kanna__delegate_subagent({ subagent_id, prompt })`. The tool blocks
+until the run finishes and returns the subagent's final reply as text;
+the main model then synthesizes it into its own response.
+
+- **Roster injection:** `buildKannaSystemPromptAppend(subagents)` in
+ `src/shared/kanna-system-prompt.ts` builds a dynamic system-prompt
+ suffix listing every configured subagent's `name`, `id`, and
+ `description`. Computed per-spawn in `agent.ts` and passed to both
+ drivers (SDK via `systemPrompt.append`, PTY via
+ `--append-system-prompt`). Truncated at 20 entries by `updatedAt`
+ descending; remainder surfaced as "(N more subagents omitted ...)".
+- **MCP tool:** registered in `kanna-mcp.ts` only when the spawn
+ supplies both `subagentOrchestrator` AND `delegationContext`. Main
+ spawns supply `depth: 0`, `ancestorSubagentIds: []`, `parentRunId:
+ null`. Subagent spawns (sub-spawn-sub) supply the caller's own
+ context so cycle / depth checks apply — `LOOP_DETECTED` when the
+ target appears in the ancestor chain, `DEPTH_EXCEEDED` when
+ `depth > maxChainDepth` (default 1, configurable on the orchestrator).
+- **`SubagentOrchestrator.delegateRun(args)`:** public async API that
+ awaits a single run and returns `DelegationOutcome` —
+ `{status:"completed", text}` or `{status:"failed", errorCode, errorMessage}`.
+ Used by the MCP tool; also exposed via
+ `AgentCoordinator.getSubagentOrchestrator()` for tests.
+- **Cancellation:** `cancelChat` / `cancelRun` cascade through delegated
+ runs as before. Each `delegateRun` registers a `RunState` and obeys
+ the same permit / timeout / abort wiring as the legacy
+ mention-triggered path.
+- **Backwards compat:** `parseMentions` still runs inside the normal
+ `appendUserPrompt` path so `subagentMentions` metadata stays on
+ `user_prompt` entries for UI badges and analytics. The assistant-text
+ mention scan and the `chat_send` / dequeue short-circuits are removed.
+
+## Keep-Alive Multi-Turn Subagents (claude-PTY only)
+
+`delegate_subagent({ subagent_id, prompt, keep_alive: true })` keeps the
+subagent's PTY claude REPL open after the first `result` instead of sending
+`/exit`. The main agent then drives further turns into the SAME warm
+process — no re-spawn, no re-trust, warm cache. Star topology preserved:
+the main agent is always the one calling these tools.
+
+- **Transport:** each turn is a kanna channel push (`pushChannelPrompt`, the
+ same MCP-notification transport shipped in PR #333) followed by draining
+ the persistent `HarnessEvent` stream until the next synthesized
+ `kind:"result"` event. Interactive TUI claude writes `system/turn_duration`
+ (not `type:"result"`) per turn; `normalizeClaudeStreamMessage`
+ (`agent.ts`) synthesizes one `kind:"result"` per `turn_duration`, so a
+ per-turn drain (`drainOneTurn` in `subagent-provider-run.ts`) returns once
+ per turn and leaves the iterator open.
+- **Auto-wake filter exemption (do NOT remove):** a channel push lands in the
+ transcript as a `user isMeta:true` line at a turn boundary, which the
+ `jsonl-to-event.ts` auto-wake filter (added in 216392b to drop CC's own
+ `` background wakes) would otherwise eat — dropping the
+ synthesized `result` and hanging `drainOneTurn` forever. The parser detects
+ the `` tag (`userMessageContainsKannaChannel`) and
+ treats those lines as real turns. Genuine `` wakes stay
+ filtered. Unit fakes emit `kind:"result"` directly and bypass this path, so
+ this invariant is only covered by the parser tests + the real-OAuth e2e.
+- **Driver:** `StartClaudeSessionPtyArgs.keepAlive` suppresses
+ `oneShotClose()` on the first result and exposes
+ `pushChannelPrompt` on the handle (`claude-pty/driver.ts`). Keep-alive
+ REQUIRES channel delivery — a keep-alive run with no `pushChannelPrompt`
+ fails closed. The subagent system prompt gets the plural channel framing
+ (`buildChannelPromptFraming(true)`) so the model expects multiple channel
+ messages over the session and does not treat turn 2+ as a suspicious
+ interrupt.
+- **Provider run:** `runClaudeSubagent` drains turn 1, then returns a
+ `LiveTurnSource` (`runTurn(prompt, onChunk, onEntry)` + `close()`) via the
+ widened `ProviderRunStart.start(onChunk, onEntry, { keepAlive })`. Codex is
+ out of scope — keep-alive is claude-PTY only; the MCP layer rejects
+ `keep_alive` for non-claude subagents.
+- **Orchestrator:** a `liveSessions` registry (keyed by `runId`) holds each
+ warm session. Turn 1 runs through the normal `spawnRun` plumbing (permit,
+ RunState, timeout, abort, events) but on completion registers a
+ `LiveSession` instead of cleaning up; the RunState stays registered so
+ cancel can reach it. Follow-up turns: `sendToLiveRun(runId, prompt)`.
+ Teardown: `closeLiveRun(chatId, runId, reason)`.
+- **Permit model:** an idle live session holds NO parallel permit. Each
+ active turn (`spawnRun` turn 1, and each `sendToLiveRun`) acquires a permit
+ for its drain and releases it after. Two orthogonal limits — permits =
+ concurrent active turns; `KANNA_SUBAGENT_MAX_LIVE` = live processes.
+- **Lifecycle bounds:** idle sessions are auto-closed after
+ `KANNA_SUBAGENT_IDLE_TIMEOUT_MS` (default 300000), reset on each turn. Live
+ process count is capped per chat by `KANNA_SUBAGENT_MAX_LIVE` (default 5) —
+ over cap, `delegate_subagent({keep_alive:true})` fails `CAP_EXCEEDED`
+ (no LRU eviction; an LRU session might be in use). `cancelChat` /
+ `cancelRun` cascade-close all live sessions for the chat/run.
+- **MCP tools** (registered under the same `subagentOrchestrator &&
+ delegationContext` guard as `delegate_subagent`):
+ - `delegate_subagent({ ..., keep_alive })` — turn 1; on completion appends
+ `[run_id: ...]` to the reply so the model learns the handle.
+ - `send_subagent_message({ run_id, prompt })` — drives a follow-up turn;
+ blocks until that turn finishes; `NO_LIVE_SESSION` if unknown.
+ - `close_subagent({ run_id })` — tears down + frees the process.
+- **Env vars:** `KANNA_SUBAGENT_MAX_LIVE` (default 5),
+ `KANNA_SUBAGENT_IDLE_TIMEOUT_MS` (default 300000) — both wired into the
+ orchestrator deps at `AgentCoordinator` construction (`agent.ts`); the
+ orchestrator itself reads only its deps (side-effect seal).
+
+# Agent Self-Scheduled Wake (KANNA_MAX_AGENT_WAKES, KANNA_PENDING_WORKFLOW_POLL_MS)
+
+Kanna owns the timer for agent-driven chat re-entry. The native claude-code
+`ScheduleWakeup` / `/loop` cron cannot drive a re-entry under Kanna's spawn
+model: a fire lands in the transcript as an `isMeta:true` user line, which
+`jsonl-to-event.ts` deliberately drops as a background auto-wake, and the
+CLI's in-memory cron dies on restart. So both agent-wake paths route through
+the existing event-sourced `auto-continue` `ScheduleManager` (survives restart
+via event replay, obeys the cancel cascade). See
+`adr-20260603-agent-self-scheduled-wake`.
+
+- **`ScheduleWakeup` interception (Part A).** The PTY driver disallows the
+ native tool (`PTY_DISALLOWED_NATIVE_TOOLS` now includes `ScheduleWakeup`,
+ same #215 pattern as AskUserQuestion/ExitPlanMode) and force-registers
+ `mcp__kanna__schedule_wakeup`, which calls
+ `AgentCoordinator.scheduleAgentWakeup({source:"agent_wakeup"})`. The shim is
+ registered only when a `scheduleWakeup` callback is supplied (main chats);
+ subagent spawns lose the no-op native tool by design. On fire,
+ `fireAutoContinue` replays the schedule's `prompt` instead of the literal
+ `"continue"` (the prompt rides on `auto_continue_accepted.prompt`).
+
+- **Pending-workflow harvest (Part B).** When a turn ends with a background
+ Workflow still running, claude-code's `turn_duration` frame carries
+ `pendingWorkflowCount`. `normalizeClaudeStreamMessage` surfaces it onto the
+ `result` entry; `maybeArmPendingWorkflowWake` arms a single
+ `source:"pending_workflow"` wake (no double-arm if a schedule is already
+ live). Kanna has no mid-flight completion signal, so the replayed prompt
+ asks the model to check its background work and call `schedule_wakeup` again
+ if it is still running.
+
+- **Runaway-loop cap.** `KANNA_MAX_AGENT_WAKES` (default 25) bounds consecutive
+ agent wakes per chat; the in-memory chain counter resets when a real
+ (non-auto-continue) user turn starts in `startTurnForChat`. Over cap,
+ `scheduleAgentWakeup` returns `null` and `schedule_wakeup` surfaces an
+ `isError` with guidance.
+
+- **Env vars:** `KANNA_MAX_AGENT_WAKES` (default 25),
+ `KANNA_PENDING_WORKFLOW_POLL_MS` (default 120000) — both parsed in
+ `server.ts` and passed to `AgentCoordinator`; the coordinator reads only its
+ args (side-effect seal).
+
+# Workflow Status Panel (PTY disk-watch, read-only)
+
+Surfaces Claude Code's native `Workflow` tool (dynamic multi-agent
+orchestration) in the UI: a per-chat panel listing every run with live status +
+drill-in progress, plus an inline transcript card on the launch. **PTY driver
+only, read-only.** Complementary to "Agent Self-Scheduled Wake" — that keeps the
+*agent* re-entering while a workflow runs; this *displays* the workflow.
+
+**Why disk-watch, not the event stream.** The PTY transcript JSONL (PTY's sole
+event source) carries the `Workflow` tool_use launch but **no**
+`task_started`/`task_updated`/`tool_progress` lifecycle lines — those flow only
+through the SDK live stream-json channel, which PTY never reads. Claude instead
+writes a complete, self-updating sidecar per run:
+`~/.claude/projects///workflows/wf_.json`
+(`runId`, `taskId`, `workflowName`, `status`, `agentCount`, `totalTokens`,
+`phases[]`, `workflowProgress[]` per-agent tree, `result`/`error`/`summary`).
+`taskId` joins a run to the transcript's `Task ID: X` launch text.
+
+**Independent read-model (does NOT violate c3-225).** The watcher feeds a sibling
+read-model, never the transcript/turn event pipeline (same spirit as reading
+subagent files). See `adr-20260603-workflow-disk-watch-read-model`.
+
+- **Adapter** `src/server/workflow-watch-io.adapter.ts` — the only IO; lists +
+ reads `wf_*.json`, `fs.watch` with ~250 ms debounce, and **re-arms via the
+ nearest existing ancestor** when `workflows/` doesn't exist yet (Claude
+ creates it lazily on the first Workflow call, after registration).
+- **Registry** `src/server/workflow-registry.ts` — per-chat watch + parse
+ (one defensive choke-point `parseWorkflowRunFile`) + `snapshot()` (light,
+ heavy fields stripped) + `getRun()` (full) + `subscribe()`. Mirrors
+ `PtyInstanceRegistry`. IO injected (side-effect seal). **Re-run masking
+ (adr-20260604-workflow-rerun-masking):** Claude embeds the `runId` in the
+ persisted workflow script filename, so a fix-and-relaunch via `scriptPath`
+ reuses the same `runId` (new `taskId`) and pours agents into the same live
+ dir WITHOUT rewriting the prior sidecar. A no-op **crash sidecar**
+ (`isStaleCrashSidecar`: `status=failed && agentCount===0 && agents:[]`) is
+ therefore the ONLY terminal status `snapshot()`/`getRun()` will override —
+ and only when the live `journal.jsonl` proves a re-run (≥1 agent), surfacing
+ a synthetic `running` row that carries the crash sidecar's `taskId`/
+ `workflowName` so the launch card binds. The discriminator is content-based
+ (agentCount 0 vs non-empty journal), NOT mtime ordering (clock-racy, fails
+ under concurrency). `completed`/`killed`/`failed-with-agents` sidecars win
+ unconditionally; a true crash (empty journal) stays `failed`. Re-run over a
+ completed/killed run is out of scope (the synthetic row has no `taskId` from
+ disk, and reading the transcript taskId would breach the c3-225 invariant).
+- **Driver** registers `//workflows` derived from the
+ resolved `transcriptStream.filePath` basename (Claude mints its OWN session
+ UUID and ignores `--session-id` on new sessions, so kanna's `sessionId` is
+ NOT the dir name). A `workflowRegistrationCancelled` flag prevents a late
+ `register()` after `cleanupResources` `unregister()` on fast-failing spawns.
+- **Transport** WS topic `{type:"workflows", chatId}` → `workflowRunsUpdated`
+ snapshot push (mirrors `pty-instances`); `workflows.getRun` command for the
+ heavy drill-in payload.
+- **Client** `workflowsStore` (stable `EMPTY` ref), `WorkflowsSection` panel
+ (mirrors `SubagentsSection`), `WorkflowMessage` transcript card (live pill
+ joined by `taskId` once `chatId` is threaded through the transcript rows).
+
+Out of scope: SDK driver, global cross-chat view, stop/relaunch.
+
+# Tests
+
+`bun test` MUST pass locally before any push or PR. CI (`.github/workflows/test.yml`)
+runs `bun test` on every push to `main` and every PR; merges are blocked on failure.
+Run `bun test src/server/.test.ts` for fast iteration on a single suite.
+When a test spawns `git` or other subprocesses, ensure the spawn sets
+`stdin: "ignore"` and `GIT_TERMINAL_PROMPT=0` so a hung credential prompt
+cannot exhaust the test timeout. Also give it an explicit timeout
+(`test(name, fn, 30_000)`) — the 5s Bun default is too tight for CI runners.
+
+# Wiki
+
+Public docs site lives in `wiki/` (Astro Starlight) and is deployed to
+https://kanna-wiki.lowbit.link on every push to `main` that touches `wiki/**`.
+
+Regenerate screenshots:
+
+```bash
+bash wiki/scripts/capture-all.sh
+```
+
+This spawns a seeded demo Kanna under a tmpdir `KANNA_HOME`, captures all
+~32 PNGs via Playwright, and writes them to `wiki/public/screenshots/`.
+Commit the PNGs.
+
+Regenerate env-var reference table:
+
+```bash
+cd wiki && bun run scripts/extract-env-vars.ts
+```
+
+Wiki is isolated from the main repo build — its own `package.json`, own
+`node_modules`. `bun run lint` and `bun test` at the repo root do NOT touch
+`wiki/`.
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 000000000..6c48a9ab5
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,290 @@
+---
+name: Kanna
+description: A calm, editorial web UI for the Claude Code & Codex CLIs.
+colors:
+ paper: "oklch(99.5% 0.003 13)"
+ inkstone: "oklch(20% 0.01 13)"
+ espresso-ink: "oklch(16% 0.01 13)"
+ pale-foreground: "oklch(98% 0.003 13)"
+ warm-card-light: "oklch(99.5% 0.003 13)"
+ warm-card-dark: "oklch(23% 0.01 13)"
+ surface-secondary-light: "oklch(96% 0.005 13)"
+ surface-secondary-dark: "oklch(26% 0.01 13)"
+ margin-gray-light: "oklch(55% 0.013 13)"
+ margin-gray-dark: "oklch(70% 0.012 13)"
+ soft-edge-light: "oklch(91% 0.008 13)"
+ soft-edge-dark: "oklch(29% 0.008 13)"
+ muted-icon-light: "oklch(82% 0.008 13)"
+ muted-icon-dark: "oklch(55% 0.01 13)"
+ kanna-coral: "oklch(71.2% 0.194 13.428)"
+ destructive-text: "oklch(56% 0.18 13)"
+ verified-sage: "oklch(68% 0.15 155)"
+ editor-amber: "oklch(76% 0.14 78)"
+ reference-blue: "oklch(66% 0.13 235)"
+typography:
+ display:
+ fontFamily: "Bricolage Grotesque Variable, Bricolage Grotesque, sans-serif"
+ fontSize: "clamp(1.75rem, 3.5vw, 2.5rem)"
+ fontWeight: 800
+ lineHeight: 1.05
+ letterSpacing: "-0.02em"
+ headline:
+ fontFamily: "Body, ui-sans-serif, system-ui, -apple-system, sans-serif"
+ fontSize: "1.125rem"
+ fontWeight: 500
+ lineHeight: 1.3
+ letterSpacing: "-0.01em"
+ title:
+ fontFamily: "Body, ui-sans-serif, system-ui, -apple-system, sans-serif"
+ fontSize: "0.9375rem"
+ fontWeight: 600
+ lineHeight: 1.35
+ letterSpacing: "normal"
+ body:
+ fontFamily: "Body, ui-sans-serif, system-ui, -apple-system, sans-serif"
+ fontSize: "0.875rem"
+ fontWeight: 400
+ lineHeight: 1.55
+ letterSpacing: "normal"
+ label:
+ fontFamily: "Body, ui-sans-serif, system-ui, -apple-system, sans-serif"
+ fontSize: "0.75rem"
+ fontWeight: 500
+ lineHeight: 1.3
+ letterSpacing: "0.005em"
+ mono:
+ fontFamily: "Roboto Mono, ui-monospace, SFMono-Regular, Menlo, monospace"
+ fontSize: "0.8125rem"
+ fontWeight: 400
+ lineHeight: 1.55
+ fontFeature: "tnum"
+rounded:
+ sm: "calc(0.5rem - 4px)"
+ md: "calc(0.5rem - 2px)"
+ lg: "0.5rem"
+spacing:
+ xs: "4px"
+ sm: "8px"
+ md: "12px"
+ lg: "16px"
+ xl: "24px"
+ "2xl": "32px"
+components:
+ button-primary:
+ backgroundColor: "{colors.espresso-ink}"
+ textColor: "{colors.pale-foreground}"
+ rounded: "{rounded.md}"
+ padding: "8px 14px"
+ button-secondary:
+ backgroundColor: "{colors.surface-secondary-light}"
+ textColor: "{colors.espresso-ink}"
+ rounded: "{rounded.md}"
+ padding: "8px 14px"
+ button-ghost:
+ backgroundColor: "transparent"
+ textColor: "{colors.espresso-ink}"
+ rounded: "{rounded.md}"
+ padding: "8px 14px"
+ button-destructive:
+ backgroundColor: "{colors.kanna-coral}"
+ textColor: "{colors.pale-foreground}"
+ rounded: "{rounded.md}"
+ padding: "8px 14px"
+ card-surface:
+ backgroundColor: "{colors.warm-card-light}"
+ textColor: "{colors.espresso-ink}"
+ rounded: "{rounded.lg}"
+ padding: "16px"
+ input-field:
+ backgroundColor: "{colors.paper}"
+ textColor: "{colors.espresso-ink}"
+ rounded: "{rounded.md}"
+ padding: "8px 12px"
+ dialog-surface:
+ backgroundColor: "{colors.warm-card-light}"
+ textColor: "{colors.espresso-ink}"
+ rounded: "{rounded.lg}"
+ padding: "24px"
+---
+
+# Design System: Kanna
+
+## 1. Overview
+
+**Creative North Star: "The Editorial Workspace"**
+
+Kanna reads like a well-edited document, not a dashboard. The system stays warm-tinted and quiet so that long agent sessions remain legible at 11pm on a 27-inch monitor without wearing the user down. Density is paid for in rhythm, not in chrome: hierarchy emerges from typographic weight and generous spacing, never from gradients, glow, or decorative borders. The palette tints every neutral toward a warm rose hue (~13°) so even greys feel like paper, not aluminium. The system explicitly rejects the four anti-references in PRODUCT.md: generic AI SaaS gradient chrome, marketing-heavy SaaS-cream landing pages, neon terminal cyberpunk, and cluttered dashboard density.
+
+Color is restrained by default. One brand accent (Kanna Coral) carries identity and destructive intent both, used on under 10% of any screen. Three semantic accents (sage, amber, blue) carry success/warning/info — never decorative. State is always paired with a label or icon shape; color alone never communicates.
+
+**Key Characteristics:**
+
+- Warm-tinted neutrals (chroma 0.003–0.013, hue ~13°) across both themes.
+- One brand accent, used rarely and on purpose.
+- Editorial type pairing: Body (a custom warm sans) for prose; Bricolage Grotesque for the logo only; Roboto Mono for code, IDs, and tabular data.
+- Flat by default. Depth comes from contrast and spacing, not shadows.
+- Tabular numerics on every duration, count, age, or pid. No reflow under live tickers.
+
+## 2. Colors: The Warm Editorial Palette
+
+The palette is one rose-tinted neutral family with a single saturated coral accent and three semantic markers. Every color is OKLCH; the doctrine is "tint everything, even white."
+
+### Primary
+
+- **Kanna Coral** (`oklch(71.2% 0.194 13.428)`): the brand mark and the destructive surface. Used as logo color, as the primary CTA in landing/auth contexts, and as `--destructive` for stop/delete affordances. Never used as a background fill or a decorative gradient stop.
+
+- **Destructive Text** (`oklch(56% 0.18 13)` light / `var(--destructive)` dark): AA-compliant coral variant for text and icon-only destructive contexts (e.g. "Confirm stop?", "Force kill" labels). The bright Kanna Coral (`oklch(71.2% 0.194 13.428)`) achieves only 2.81:1 on Warm Paper — below WCAG AA. This darker variant hits 5.04:1 on Warm Paper in light mode while preserving the editorial-light-text-button feel. In dark mode the token aliases back to `--destructive` (6.35:1 on Inkstone), so the bright coral is used in both contexts where it passes. Filled destructive buttons continue to use the bright coral as background with Pale Foreground text — this token is only for text or icon-only foreground use.
+
+### Neutral (warm rose family, hue ~13°)
+
+- **Warm Paper** (`oklch(99.5% 0.003 13)`): light-mode background. Tinted just enough to feel paper-like rather than clinical.
+- **Inkstone** (`oklch(20% 0.01 13)`): dark-mode background. Warm enough to read as ink rather than asphalt.
+- **Espresso Ink** (`oklch(16% 0.01 13)`): light-mode foreground; primary fill in dark-mode buttons.
+- **Pale Foreground** (`oklch(98% 0.003 13)`): dark-mode foreground; readable on Inkstone.
+- **Margin Gray** (`oklch(55% 0.013 13)` light / `oklch(70% 0.012 13)` dark): muted text — timestamps, secondary metadata, system messages.
+- **Soft Edge** (`oklch(91% 0.008 13)` light / `oklch(29% 0.008 13)` dark): borders and dividers. Always 1px, never wider; never colored.
+- **Muted Icon** (`oklch(82% 0.008 13)` light / `oklch(55% 0.01 13)` dark): icon-only fills when the icon is informational, not actionable.
+- **Surface Secondary** (`oklch(96% 0.005 13)` light / `oklch(26% 0.01 13)` dark): tonal layer for secondary buttons, hover states, muted panels.
+- **Warm Card** (`oklch(99.5% 0.003 13)` light / `oklch(23% 0.01 13)` dark): elevated surfaces (cards, dialogs, popovers). One step warmer than the page in dark mode to give tonal lift without a shadow.
+
+### Semantic
+
+- **Verified Sage** (`oklch(68% 0.15 155)`): success — completed tasks, applied diffs, healthy state. Pair with check shape.
+- **Editor Amber** (`oklch(76% 0.14 78)`): warning and *running* state. Used for live agent indicators and background-task running dots. Never alarms, never congratulates; states *attention available*. Pair with text or icon.
+- **Reference Blue** (`oklch(66% 0.13 235)`): informational — links, references, neutral notices. Pair with underline or icon.
+
+### Named Rules
+
+**The Tint-Everything Rule.** No `#000` or `#fff`. Every neutral carries chroma 0.003–0.013 toward hue 13°. Pure black or pure white in this codebase is a bug.
+
+**The One-Voice Rule.** Kanna Coral is the only brand color and is used on ≤10% of any given screen. Its rarity is the point. Decorative use prohibited.
+
+**The Color-Plus Rule.** Color alone never carries meaning. Status, errors, and live states always pair color with shape (icon), text, or weight, so the interface remains legible to users with reduced color vision and to anyone glancing past a screen.
+
+## 3. Typography
+
+**Display Font:** Bricolage Grotesque Variable (Bricolage Grotesque fallback, sans-serif). Used **only** for the Kanna wordmark. Not for headings.
+
+**Body Font:** Body — a self-hosted warm humanist sans served from `/fonts/body-*.woff2` at weights 400, 500, 600. Fallback stack: `ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif`. Body is the workhorse: chat content, sidebar, dialogs, settings, every label that is not code.
+
+**Label/Mono Font:** Roboto Mono. Used for code, command names, ids, durations, ages, pids, and any column that benefits from `font-variant-numeric: tabular-nums`.
+
+**Character:** Body reads warmer and less industrial than Inter or system-default. Roboto Mono is geometric without being playful. Together they sit close to a serious editorial publication that happens to render code, not a terminal that grew a UI.
+
+### Hierarchy
+
+- **Display** (Bricolage Grotesque, 800, `clamp(1.75rem, 3.5vw, 2.5rem)`, line-height 1.05, letter-spacing -0.02em): the Kanna wordmark only.
+- **Headline** (Body, 500, 1.125rem / 18px, line-height 1.3, letter-spacing -0.01em): page titles, dialog titles, section headers. Sentence case, no all-caps, no icon prefix on dialog titles.
+- **Title** (Body, 600, 0.9375rem / 15px, line-height 1.35): chat list rows, sidebar group labels, primary command names in lists.
+- **Body** (Body, 400, 0.875rem / 14px, line-height 1.55): chat content, prose, descriptive metadata. Cap line length at 65–75ch in long-form contexts.
+- **Label** (Body, 500, 0.75rem / 12px, line-height 1.3): metadata pairs, timestamps, type tags, secondary annotations.
+- **Mono** (Roboto Mono, 400, 0.8125rem / 13px, line-height 1.55, `tabular-nums`): commands, durations, ages, pids, anything monospaced or numeric.
+
+### Named Rules
+
+**The No-All-Caps Rule.** Headers and labels are sentence case. ALL CAPS is reserved for emergencies the system does not have.
+
+**The Tabular-Nums Rule.** Any duration, count, age, pid, or time-to-x ticker uses `font-variant-numeric: tabular-nums`. Reflow under live tickers is a regression.
+
+**The Mobile-Input-16 Rule.** Inputs, textareas, and selects use `font-size: 16px` minimum on mobile to prevent iOS zoom-on-focus. Carried at the global stylesheet level; do not override.
+
+## 4. Elevation
+
+Kanna is **flat by default with tonal layering for depth**. There is no global shadow vocabulary. In light mode, the page and elevated surfaces share the same lightness; depth comes from a 1px border and from the warm-card hue being identical. In dark mode, elevated surfaces (cards, popovers, dialogs) shift one step lighter than the background (Inkstone → Warm Card Dark) so they lift without a glow.
+
+Shadows appear only as a response to *state*: focus rings, dialog overlays, and the toaster. Even those are restrained — no halo, no spread larger than 4px.
+
+### Shadow Vocabulary
+
+- **Focus ring** (`outline: 2px solid var(--ring)` with 2px offset): keyboard focus only. Visible always; `outline: none` without a replacement is prohibited.
+- **Dialog backdrop** (default shadcn dialog overlay, no blur): a single dimming layer at ~50% black-tinted-warm. **No backdrop-filter blur.**
+- **Toaster** (default sonner shadow): the only floating element with a soft shadow. Bottom-right desktop, top-center mobile.
+
+### Named Rules
+
+**The Flat-By-Default Rule.** Surfaces are flat at rest. Depth is a state response (focus, overlay), not an idle aesthetic.
+
+**The No-Glassmorphism Rule.** `backdrop-filter: blur(...)` on a translucent panel is prohibited as a default. Use it only when the underlying content must stay partially visible for a functional reason (e.g. media overlay).
+
+## 5. Components
+
+### Buttons
+
+- **Shape:** rounded corners (`rounded-md`, ~6px). Never pill, never sharp.
+- **Primary:** Espresso Ink fill, Pale Foreground text, 8×14 padding. Hover steps to slightly lighter ink.
+- **Secondary:** Surface Secondary fill, Espresso Ink text. Used for non-destructive secondary actions.
+- **Ghost:** transparent fill, Espresso Ink text. Used inside dense lists where another fill would be noise.
+- **Destructive:** Kanna Coral fill, Pale Foreground text. Reserved for stop, delete, force-kill. Pairs with confirm-step inline; never opens a modal-on-modal.
+- **Hover / Focus:** color transitions in 150ms ease-out. Focus ring (2px solid Ring) on `:focus-visible`. Active state slightly compresses background luminance, no transform.
+
+### Inputs / Fields
+
+- **Style:** Paper background, Soft Edge 1px border, `rounded-md`, 8×12 padding.
+- **Focus:** border shifts to Ring color; subtle 1px focus ring outside the border, no glow.
+- **Error:** border shifts to Kanna Coral, helper text in Coral with icon prefix.
+- **Mobile:** font-size 16px enforced globally to prevent iOS zoom.
+
+### Cards / Surfaces
+
+- **Corner Style:** `rounded-lg` (8px).
+- **Background:** Warm Card (light or dark variant) — same hue as page in light, one step lighter in dark.
+- **Shadow Strategy:** none at rest; depth via background hue + 1px Soft Edge border in light mode.
+- **Border:** 1px Soft Edge in light mode; borderless in dark mode (tonal lift carries it).
+- **Internal Padding:** 16px default; 24px for dialog surfaces.
+
+### Dialogs / Popovers / Sheets
+
+- **Surface:** Warm Card with `rounded-lg`, 24px padding for dialogs, 12–16px for popovers.
+- **Title:** Headline scale, sentence case, no icon prefix.
+- **Backdrop:** dim layer, no blur.
+- **Open animation:** scale 0.98 → 1, opacity 0 → 1, 160ms ease-out-quart. Disabled under `prefers-reduced-motion`.
+- **Mobile:** dialogs become bottom sheets, full width, swipe-down to dismiss.
+
+### Navigation (Sidebar + ChatNavbar)
+
+- **Sidebar:** Surface Secondary background, Title-scale group labels, Body-scale chat rows, status indicator dot at start of row (sage / amber / coral / muted, pair with shape variation). Drag-and-drop project ordering via clear handle, never a hidden affordance.
+- **Navbar:** flat, 1px Soft Edge bottom border, Body-scale title centered, action icon group right-aligned. Tooltips use the project `Tooltip` component, **never** native `title`.
+- **Active state:** background shifts to Surface Secondary, label weight steps up to Title (600). No left-border stripe.
+
+### Lists (chat transcripts, sidebar, background tasks)
+
+- **Row anatomy:** two-line by default — Title-scale primary line + Label-scale meta line. Mono used for command names and timestamps; sans for descriptive labels.
+- **Hover:** background tints to Surface Secondary, no transform, no scale.
+- **Selected:** subtle Surface Secondary fill plus 1px-left visual is **prohibited** (anti-pattern). Use full-row tonal fill or a leading marker dot instead.
+
+### Status Indicators
+
+- **Dots:** 6–8px solid circle, paired with a label or context (chat title, list row). Amber = running, Sage = completed/idle, Coral = failed/needs attention, Muted = neutral. **Static; no pulse, no glow.** A pulsing dot reads as anxiety.
+
+### Terminal pane (signature component)
+
+`kanna-terminal` overrides xterm's default background to transparent, inheriting the page background. The PTY content sits in the same tonal field as the chat — the terminal is part of the document, not a separate window. Roboto Mono carries content; selection uses Surface Secondary; cursor blink is a single CSS animation, no canvas glow.
+
+## 6. Do's and Don'ts
+
+### Do:
+
+- **Do** tint every neutral toward hue 13° at chroma 0.003–0.013. White is `oklch(99.5% 0.003 13)`. Black is `oklch(20% 0.01 13)`. Pure `#fff` and `#000` are bugs.
+- **Do** carry the One-Voice Rule: Kanna Coral on ≤10% of any screen, used for brand mark and destructive intent only.
+- **Do** pair color with shape, label, or weight on every state indicator. Color alone never communicates.
+- **Do** use Roboto Mono with `tabular-nums` for every duration, age, count, pid, or live ticker. Reflow under a ticker is a regression.
+- **Do** keep dialogs flat: scale-and-fade entry, no backdrop blur, no nested modals; inline confirm flows for destructive actions.
+- **Do** write keyboard shortcuts on every action. Every keyboard action also has a clear mouse target. No dead-ends in either direction.
+- **Do** respect `prefers-reduced-motion`: disable all entry animations and translateY/translateX transitions.
+- **Do** use the project `Tooltip` component. Native `title` attributes are prohibited as a hover-explanation surface.
+- **Do** target body text contrast ≥ 7:1 (AAA) where the design allows; never below AA (4.5:1).
+
+### Don't:
+
+- **Don't** use `#000`, `#fff`, or any zero-chroma neutral. Tint everything toward hue 13°.
+- **Don't** use purple-blue gradients, glassmorphism cards, or glow accents. Quoting PRODUCT.md: avoid "**generic AI SaaS gradient** — purple-blue hero gradients, glassmorphism cards, glow accents, ChatGPT-clone chrome."
+- **Don't** ship marketing-cream backgrounds, oversized illustrations, or hero-feature-card grids. Quoting PRODUCT.md: avoid "**marketing-heavy SaaS-cream** — cream backgrounds, hero illustrations, 'feature card' grids, oversized CTA buttons."
+- **Don't** put saturated green or cyan on a black background. Quoting PRODUCT.md: avoid "**neon terminal cyberpunk** — black background plus saturated green/cyan accents; hacker-aesthetic chrome."
+- **Don't** stack panels at Datadog/Grafana density. Quoting PRODUCT.md: avoid "**cluttered devtool dashboards** — every pixel a panel, no breathing room, no hierarchy."
+- **Don't** use `border-left` greater than 1px as a colored stripe to indicate state. Use a leading dot, full-row tint, or weight change instead.
+- **Don't** clip text inside a gradient (`background-clip: text` with a gradient). Use a solid color; emphasis via weight or size.
+- **Don't** open a modal on top of a modal. Inline confirm or step the existing dialog.
+- **Don't** animate layout properties (`width`, `height`, `top`, `left`, `padding`). Animate `transform` and `opacity` only.
+- **Don't** pulse status dots. A pulsing dot reads as anxiety; the warm coral is alarming enough on its own when it appears.
+- **Don't** use `outline: none` on focusable elements without a clear replacement focus indicator.
+- **Don't** rely on color alone for status; pair with icon, label, or weight.
diff --git a/PRODUCT.md b/PRODUCT.md
new file mode 100644
index 000000000..88e3e57ac
--- /dev/null
+++ b/PRODUCT.md
@@ -0,0 +1,45 @@
+# Product
+
+## Register
+
+product
+
+## Users
+
+Solo developers running Claude Code or Codex CLIs on their own machine for focused, multi-hour sessions. They jump between many chats and projects, expect keyboard-first navigation with mouse fallbacks, and watch agents work for long stretches while occasionally steering. Context is a quiet desk on a real monitor, not a phone. They came to Kanna because the raw CLI made long sessions hard to track; they stay because the UI makes the work legible without getting in the way.
+
+## Product Purpose
+
+Kanna is a web UI for the Claude Code and Codex CLIs that makes long agent sessions tractable. It surfaces project structure, chat status, transcripts, tool calls, plan-mode prompts, and background work as a single calm, navigable workspace. Success looks like: a developer running three agents across two projects can tell at a glance what each is doing, jump in to steer any of them, never lose work to a forgotten background process, and trust what the transcript shows.
+
+## Brand Personality
+
+Editorial, thoughtful, warm. Voice: confident without swagger; explains state, never performs it. Closer to a well-edited document than a control panel. Quiet typography does the heavy lifting. Color is restrained and tinted toward warm neutrals, never the icy grays of generic devtools.
+
+## Anti-references
+
+- **Generic AI SaaS gradient** — purple-blue hero gradients, glassmorphism cards, glow accents, ChatGPT-clone chrome.
+- **Marketing-heavy SaaS-cream** — cream backgrounds, hero illustrations, "feature card" grids, oversized CTA buttons.
+- **Neon terminal cyberpunk** — black background plus saturated green/cyan accents; hacker-aesthetic chrome.
+- **Cluttered devtool dashboards** — Datadog/Grafana density: every pixel a panel, no breathing room, no hierarchy.
+
+Reference for the right feel: **Notion**. Warm neutrals, content-first, calm density, editorial type discipline.
+
+## Design Principles
+
+1. **Workflow over wow.** Design serves the developer's task; it never performs. If a flourish does not help someone steer an agent faster, cut it.
+2. **Calm density.** Show a lot of state at once, but with breathing room, weighted hierarchy, and warmth. Density without rhythm is clutter.
+3. **Editorial typography earns hierarchy.** Scale, weight, and spacing carry meaning. No decorative gradients, no glow, no chrome substituting for type.
+4. **Keyboard-first, mouse-friendly.** Every action reachable from the keyboard. Every keyboard action also reachable from a clear mouse target. No dead-ends in either direction.
+5. **Trust via legibility.** Agent output, tool calls, and background processes read like documents you can audit — not log dumps, not loading spinners. The user must always be able to verify what is happening.
+
+## Accessibility & Inclusion
+
+Target WCAG 2.1 AAA where feasible, AA as the floor. Specifically:
+
+- Contrast ≥ 7:1 for body text and ≥ 4.5:1 for large text where the design allows; never below AA.
+- Full keyboard navigation including all destructive actions (e.g. stopping background tasks).
+- Visible focus rings on every interactive element; never `outline: none` without a replacement.
+- Respect `prefers-reduced-motion`: disable non-essential transitions and any directional motion.
+- Color is never the only signal — pair with icon, label, or weight (status, errors, running/stopped states).
+- Tabular numerics (`font-variant-numeric: tabular-nums`) for any timing, count, or status duration.
diff --git a/README.md b/README.md
index 57fc5c8f3..9bac8d9a5 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,18 @@
-
+ Community fork of jakemor/kanna — kept in sync with upstream and extended with subscription-billing PTY mode, OAuth token pooling, multi-provider chat (Claude + Codex), subagent orchestration, durable tool-approval protocol, in-app self-update, and more.
+
+
+
+
+
+
+
+
+
+
+ 📖 Docs: kanna-wiki.lowbit.link
@@ -24,10 +35,27 @@
+## About this fork
+
+Kanna started life as [jakemor/kanna](https://github.com/jakemor/kanna) — a clean web UI for the Claude Code CLI. This fork (`@cuongtran001/kanna`) tracks upstream and layers on features needed for heavier day-to-day use, multi-account billing, and self-hosting.
+
+**Headline additions vs. upstream:**
+
+- **Subscription-billing PTY driver** (`KANNA_CLAUDE_DRIVER=pty`) — runs the `claude` CLI under a pseudo-terminal so Pro/Max plans are charged instead of API rates. Includes JSONL event parity with the SDK driver, macOS `sandbox-exec` / Linux `bwrap` sandboxing, allowlist preflight probes, and failure-mode parity.
+- **OAuth token pool** — register multiple Claude OAuth tokens; Kanna rotates across them per chat with automatic fallover on rate-limit and an explicit disabled state.
+- **Multi-provider chat** — switch between Claude and Codex (OpenAI) from the composer with per-provider model + reasoning-effort controls and Codex fast mode.
+- **Subagent orchestration** — first-class subagent CRUD, `@agent/` mentions, parallel runs, live activity labels, MCP progress notifications, and `mcp__kanna__delegate_subagent` so the main agent itself can delegate.
+- **Durable tool-approval protocol** (`KANNA_MCP_TOOL_CALLBACKS=1`) — pending `AskUserQuestion` / `ExitPlanMode` / built-in shims survive server restart and replay to the client on reconnect.
+- **Cloudflare `expose_port` MCP tool** — agent-callable port exposure with always-ask or auto-expose modes, replacing bash-output sniffing.
+- **In-app self-update** — one-click pull/rebuild/reload with a host-agnostic supervisor (works under pm2, systemd, docker, plain shell) or direct pm2 reload; install any prior release straight from the changelog UI.
+- **Git worktree isolation** per chat, **bulk import** of existing `~/.claude/projects/` sessions, **proactive context compaction**, **password gate** for HTTP/WS/API, **PWA / mobile layout**, **mermaid rendering** in transcripts, **standalone HTML transcript export**, and **customizable keybindings**.
+
+See the full inventory in [Features](#features) below.
+
## Quickstart
```bash
-bun install -g kanna-code
+bun install -g @cuongtran001/kanna
```
If Bun isn't installed, install it first:
@@ -46,41 +74,151 @@ That's it. Kanna opens in your browser at [`localhost:3210`](http://localhost:32
## Features
-- **Multi-provider support** — switch between Claude and Codex (OpenAI) from the chat input, with per-provider model selection, reasoning effort controls, and Codex fast mode
+**Providers & models**
+
+- **Multi-provider support** — switch between Claude and Codex (OpenAI) from the chat input, with per-provider model selection, reasoning-effort controls, and Codex fast mode
+- **OAuth token pool** — register multiple Claude OAuth tokens; Kanna rotates across them per chat
+- **Subscription-billing PTY driver** — optional `KANNA_CLAUDE_DRIVER=pty` runs the `claude` CLI under a pseudo-terminal so Pro/Max subscription billing is preserved instead of API rates
+
+**Chat & transcript**
+
+- **Rich transcript rendering** — hydrated tool calls, collapsible tool groups, plan-mode dialogs, and interactive prompts with full result display
+- **Inline diff viewer** — file and commit diffs rendered directly in the transcript
+- **Embedded terminal** — per-project xterm terminal in a resizable side panel (macOS/Linux)
+- **File & image uploads** — drag-and-drop attachments into the composer
+- **Slash commands & @-mentions** — in-composer pickers for slash commands, file mentions, and subagents
+- **Plan mode** — review and approve agent plans before execution
+- **Subagent orchestration** — run and track parallel subagents within a turn
+- **Background tasks** — long-running tasks tracked out-of-band with a status indicator
+- **Auto-continue** — optionally continue a turn automatically when the agent stops short
+- **Proactive compaction** — context-window meter with automatic transcript compaction before limits are hit
+
+**Projects & sessions**
+
- **Project-first sidebar** — chats grouped under projects, with live status indicators (idle, running, waiting, failed)
- **Drag-and-drop project ordering** — reorder project groups in the sidebar with persistent ordering
- **Local project discovery** — auto-discovers projects from both Claude and Codex local history
-- **Rich transcript rendering** — hydrated tool calls, collapsible tool groups, plan mode dialogs, and interactive prompts with full result display
-- **Quick responses** — lightweight structured queries (e.g. title generation) via Haiku with automatic Codex fallback
-- **Plan mode** — review and approve agent plans before execution
-- **Persistent local history** — refresh-safe routes backed by JSONL event logs and compacted snapshots
-- **Auto-generated titles** — chat titles generated in the background via Claude Haiku
+- **Bulk import Claude Code sessions** — one-click import of existing `~/.claude/projects/` sessions with full transcript and seamless resume via the Claude Agent SDK
+- **Git worktree isolation** — run a chat in an isolated worktree without disturbing your working tree
- **Session resumption** — resume agent sessions with full context preservation
+- **Auto-generated titles** — chat titles generated in the background via Claude Haiku
+- **Quick responses** — lightweight structured queries (e.g. title generation) via Haiku with automatic Codex fallback
+
+**Persistence & realtime**
+
+- **Persistent local history** — refresh-safe routes backed by append-only JSONL event logs and compacted snapshots
- **WebSocket-driven** — real-time subscription model with reactive state broadcasting
+- **Standalone transcript export** — export a chat as a self-contained HTML viewer
+
+**Access & notifications**
+
+- **Password protection** — optional launch password gating the app, WebSocket, and API routes
+- **Public share link** — `--share` creates a temporary `trycloudflare.com` URL with a terminal QR code
+- **Cloudflare tunnel via `expose_port` tool** — opt-in; the agent proactively calls the Kanna `expose_port` MCP tool with a port. In `always-ask` mode Kanna shows an inline "expose via Cloudflare" card for you to accept; in `auto-expose` mode `cloudflared tunnel --url` spawns immediately. Both modes are gated by the Cloudflare Tunnel setting
+- **Web push & sound notifications** — browser push and sound alerts when a chat needs attention
+- **Customizable keybindings** — user-editable keyboard shortcuts
+- **In-app self-update** — one-click update that pulls, rebuilds, and hot-reloads (host-agnostic supervisor or pm2)
+- **Mobile-friendly** — responsive layout, installable as a standalone PWA
## Architecture
+```mermaid
+flowchart LR
+ Browser["Browser React + Zustand"]
+
+ subgraph Server["Bun Server (src/server/**)"]
+ direction TB
+ WS["WSRouter subscriptions + commands"]
+ Auth["Auth gate"]
+ Agent["AgentCoordinator multi-provider turns"]
+ ES["EventStore append-only JSONL + snapshots"]
+ RM["ReadModels derived views"]
+ Diff["DiffStore"]
+ Term["TerminalManager"]
+ Up["Uploads"]
+ Disc["Discovery"]
+ Push["Push"]
+ Tun["Share / Tunnel"]
+ Upd["UpdateManager"]
+
+ subgraph Adapters["*.adapter.ts (IO seal exempt)"]
+ direction LR
+ FsA["fs / chokidar"]
+ DbA["bun:sqlite / pg"]
+ SpA["Bun.spawn / child_process"]
+ HtA["node:http / fetch"]
+ PtyA["Bun.Terminal (PTY)"]
+ end
+
+ WS --> Agent
+ WS --> ES
+ WS --> RM
+ Agent --> ES
+ Agent -.spawn.-> SpA
+ Agent -.spawn.-> PtyA
+ ES -.fs.-> FsA
+ Diff -.fs+spawn.-> SpA
+ Diff -.fs.-> FsA
+ Term -.pty.-> PtyA
+ Up -.fs.-> FsA
+ Disc -.fs.-> FsA
+ Tun -.spawn+http.-> SpA
+ Tun -.http.-> HtA
+ Upd -.spawn.-> SpA
+ end
+
+ subgraph Shared["src/shared/** (pure)"]
+ Proto["protocol types"]
+ Types["domain types"]
+ end
+
+ subgraph External["External processes"]
+ CC["Claude Agent SDK / claude CLI (PTY)"]
+ CX["Codex App Server"]
+ FS["Local FS ~/.kanna/data/, project dirs"]
+ end
+
+ Browser <-->|WebSocket| WS
+ Browser -.types.-> Shared
+ Server -.types.-> Shared
+
+ SpA --> CC
+ SpA --> CX
+ PtyA --> CC
+ FsA --> FS
```
-Browser (React + Zustand)
- ↕ WebSocket
-Bun Server (HTTP + WS)
- ├── WSRouter ─── subscription & command routing
- ├── AgentCoordinator ─── multi-provider turn management
- ├── ProviderCatalog ─── provider/model/effort normalization
- ├── QuickResponseAdapter ─── structured queries with provider fallback
- ├── EventStore ─── JSONL persistence + snapshot compaction
- └── ReadModels ─── derived views (sidebar, chat, projects)
- ↕ stdio
-Claude Agent SDK / Codex App Server (local processes)
- ↕
-Local File System (~/.kanna/data/, project dirs)
-```
+
+**Layer rules (lint-enforced, see [CLAUDE.md](./CLAUDE.md#side-effect-lint-ports-and-adapters-seal)):**
+
+- `src/shared/**` + `src/client/**` — pure. ESLint `no-restricted-imports` errors on `node:fs`, `bun:sqlite`, `node:child_process`, `node:http`, `Bun.spawn`, `Bun.file`, `Bun.serve`, …
+- `src/server/**` production — also sealed at `error`. Side-effect call sites only allowed inside files matching `**/*.adapter.ts` (or the legacy `src/server/adapters/**` dir).
+- Mixed-concern modules extract their IO into a sibling `*-io.adapter.ts` and import through it.
**Key patterns:** Event sourcing for all state mutations. CQRS with separate write (event log) and read (derived snapshots) paths. Reactive broadcasting — subscribers get pushed fresh snapshots on every state change. Multi-provider agent coordination with tool gating for user-approval flows. Provider-agnostic transcript hydration for unified rendering.
+### Workflow: adding code that touches IO
+
+```mermaid
+flowchart TD
+ Start(["You need fs / spawn / http / DB / Bun globals"]) --> Layer{"Which layer?"}
+ Layer -->|src/shared or src/client| Reject["ESLint errors at CI"]
+ Reject --> Move["Move the module to src/server/** or inject through a typed parameter"]
+ Move --> Server
+ Layer -->|src/server| Server{"File responsibility?"}
+ Server -->|leaf IO wrapper| RenameAdapter["Name it foo.adapter.ts (exempt from seal)"]
+ Server -->|mixed domain + IO| SiblingAdapter["Extract calls into foo-io.adapter.ts keep domain logic in foo.ts import helpers from the adapter"]
+ Server -->|domain only| Port["Take a typed port parameter provided by caller's adapter"]
+ RenameAdapter --> Lint["bun run lint"]
+ SiblingAdapter --> Lint
+ Port --> Lint
+ Lint --> CI(["CI: lint + tests + build"])
+```
+
+For the longer story (90 → 0 burndown, ratchet pipeline retired in PR #303) see the **Side-Effect Lint** section of `CLAUDE.md`.
+
## Requirements
-- [Bun](https://bun.sh) v1.3.5+
+- [Bun](https://bun.sh) v1.3.11+
- A working [Claude Code](https://docs.anthropic.com/en/docs/claude-code) environment
- _(Optional)_ [Codex CLI](https://github.com/openai/codex) for Codex provider support
@@ -91,7 +229,7 @@ Embedded terminal support uses Bun's native PTY APIs and currently works on macO
Install Kanna globally:
```bash
-bun install -g kanna-code
+bun install -g @cuongtran001/kanna
```
If Bun isn't installed, install it first:
@@ -103,7 +241,7 @@ curl -fsSL https://bun.sh/install | bash
Or clone and build from source:
```bash
-git clone https://github.com/jakemor/kanna.git
+git clone https://github.com/cuongtranba/kanna.git
cd kanna
bun install
bun run build
@@ -114,6 +252,7 @@ bun run build
```bash
kanna # start with defaults (localhost only)
kanna --port 4000 # custom port
+kanna --strict-port # fail instead of trying another port
kanna --no-open # don't open browser
kanna --password # require a password before loading the app
kanna --share # create a public quick tunnel + terminal QR
@@ -176,6 +315,20 @@ With `--cloudflared `, Kanna runs `cloudflared tunnel run --token
If Kanna can detect the public hostname from cloudflared output, it prints the same QR/public/local block.
If not, it keeps the tunnel running, warns that no public hostname was detected, and prints the local URL so you can use the hostname already configured for that tunnel in Cloudflare.
+### Auto-expose detected ports
+
+When the agent runs a Bash command in a chat (`bun run dev`, `go run`, `uvicorn`, etc.), Kanna can detect any listening port from the command's stdout and offer to expose it through a Cloudflare quick tunnel without leaving the chat.
+
+Enable from **Settings → Cloudflare Tunnel**:
+
+- **Toggle** — opt-in (off by default)
+- **Mode** — `Always ask` (one card per detected port; click Expose to spawn) or `Auto-expose` (spawn immediately on detection)
+- **`cloudflared` path** — defaults to `cloudflared` on `$PATH`
+
+Each detected port shows up inline in the transcript. Click **Expose**, watch the spinner until cloudflared returns the `*.trycloudflare.com` URL, then click **Stop** when done. Tunnels are also stopped automatically when the chat closes or the server restarts.
+
+Requires the `cloudflared` binary installed locally — `brew install cloudflared` on macOS, or see [Cloudflare's downloads](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/).
+
## Development
```bash
@@ -204,43 +357,63 @@ bun run dev:server # http://localhost:5175
## Scripts
-| Command | Description |
-| -------------------- | ---------------------------- |
-| `bun run build` | Build for production |
-| `bun run check` | Typecheck + build |
-| `bun run dev` | Run client + server together |
-| `bun run dev:client` | Vite dev server only |
-| `bun run dev:server` | Bun backend only |
-| `bun run start` | Start production server |
+| Command | Description |
+| -------------------- | ------------------------------------ |
+| `bun run build` | Build client + standalone export viewer |
+| `bun run check` | Typecheck, lint, and build |
+| `bun run lint` | ESLint over `src/` (zero-warning gate) |
+| `bun run dev` | Run client + server together |
+| `bun run dev:client` | Vite dev server only (`:5174`) |
+| `bun run dev:server` | Bun backend only (`:5175`) |
+| `bun run start` | Start production server |
+| `bun test` | Run the test suite |
## Project Structure
+Abridged — the actual tree has more modules, each with co-located `*.test.ts`:
+
```
src/
├── client/ React UI layer
│ ├── app/ App router, pages, central state hook, socket client
-│ ├── components/ Messages, chat chrome, dialogs, buttons, inputs
-│ ├── hooks/ Theme, standalone mode detection
-│ ├── stores/ Zustand stores (chat input, preferences, project order)
-│ └── lib/ Formatters, path utils, transcript parsing
+│ ├── components/ chat-ui, messages, settings, ui primitives, modals
+│ ├── hooks/ mobile/standalone detection, theme, mention/slash suggestions
+│ ├── stores/ Zustand stores (chat input, preferences, terminal, tasks…)
+│ └── lib/ formatters, path utils, transcript parsing, keybindings
├── server/ Bun backend
-│ ├── cli.ts CLI entry point & browser launcher
-│ ├── server.ts HTTP/WS server setup & static serving
-│ ├── agent.ts AgentCoordinator (multi-provider turn management)
-│ ├── codex-app-server.ts Codex App Server JSON-RPC client
-│ ├── provider-catalog.ts Provider/model/effort normalization
-│ ├── quick-response.ts Structured queries with provider fallback
-│ ├── ws-router.ts WebSocket message routing & subscriptions
-│ ├── event-store.ts JSONL persistence, replay & compaction
-│ ├── discovery.ts Auto-discover projects from Claude and Codex local state
-│ ├── read-models.ts Derive view models from event state
-│ └── events.ts Event type definitions
+│ ├── cli.ts · cli-runtime.ts CLI entry, flag parsing, supervisor
+│ ├── server.ts HTTP/WS server + static serving
+│ ├── auth.ts password gate for HTTP/WS/API
+│ ├── ws-router.ts WebSocket routing & subscriptions
+│ ├── agent.ts AgentCoordinator (multi-provider turns)
+│ ├── codex-app-server.ts Codex App Server JSON-RPC client
+│ ├── claude-pty/ PTY driver (subscription billing)
+│ ├── oauth-pool/ Claude OAuth token rotation
+│ ├── provider-catalog.ts provider/model/effort normalization
+│ ├── quick-response.ts structured queries w/ provider fallback
+│ ├── event-store.ts JSONL persistence, replay & compaction
+│ ├── read-models.ts derived view models
+│ ├── events.ts event type definitions
+│ ├── discovery.ts auto-discover Claude/Codex projects
+│ ├── claude-session-importer.ts bulk import existing sessions
+│ ├── diff-store.ts per-chat diff hydration
+│ ├── terminal-manager.ts embedded-terminal PTY sessions
+│ ├── uploads.ts attachment intake
+│ ├── subagent-orchestrator.ts parallel subagent runs
+│ ├── background-tasks.ts out-of-band task tracking
+│ ├── worktree-store.ts git worktree isolation
+│ ├── push/ web-push notifications
+│ ├── share.ts · cloudflare-tunnel/ trycloudflare / expose_port tunnels
+│ ├── update-manager.ts · update-strategy.ts self-update
+│ ├── kanna-mcp.ts Kanna MCP tools (built-in shims)
+│ └── keybindings.ts persisted keybindings
└── shared/ Shared between client & server
- ├── types.ts Core data types, provider catalog, transcript entries
- ├── tools.ts Tool call normalization and hydration
- ├── protocol.ts WebSocket message protocol
- ├── ports.ts Port configuration
- └── branding.ts App name, data directory paths
+ ├── types.ts core domain types, provider catalog, transcript entries
+ ├── tools.ts tool-call normalization & hydration
+ ├── protocol.ts WebSocket wire envelopes
+ ├── ports.ts default ports & dev-mode offsets
+ ├── share.ts share/tunnel shared types
+ └── branding.ts app name & data-directory paths
```
## Data Storage
@@ -257,13 +430,128 @@ All state is stored locally at `~/.kanna/data/`:
Event logs are append-only JSONL. On startup, Kanna replays the log tail after the last snapshot, then compacts if the logs exceed 2 MB.
+## Self-hosting on macOS (pm2 + Cloudflare tunnel)
+
+Run Kanna as a background service on macOS under [pm2](https://pm2.keymetrics.io/), exposed through a named Cloudflare tunnel. The in-app **Update** button then pulls the latest commit, rebuilds, and hot-reloads the pm2 process — no terminal round-trip needed.
+
+### 1. Link the repo as the global install
+
+`bun link` makes the global `kanna` binary resolve to your checkout:
+
+```bash
+cd ~/path/to/kanna
+bun install
+bun run build
+bun link # registers @cuongtran001/kanna → repo
+```
+
+After this, `~/.bun/install/global/node_modules/@cuongtran001/kanna` is a symlink to your repo.
+
+### 2. Create a named Cloudflare tunnel
+
+In the [Cloudflare Zero Trust dashboard](https://one.dash.cloudflare.com/) → **Networks → Tunnels → Create tunnel** (type: **Cloudflared**):
+
+1. Name the tunnel (e.g. `kanna`) and copy the **connector token** Cloudflare shows you. You will paste it as `KANNA_CLOUDFLARED_TOKEN` in the next step.
+2. Add a **public hostname** route: pick your subdomain (e.g. `kanna.example.com`) and point service to `HTTP` → `localhost:5174` (or whatever `--port` you plan to run). Kanna binds `127.0.0.1` automatically when `--cloudflared` is set, so the tunnel is the only ingress.
+3. Save. The hostname's TLS is terminated at Cloudflare's edge.
+
+### 3. Write `scripts/pm2.env` (untracked secrets)
+
+`scripts/deploy.sh` reads this file and passes the values to kanna as `--cloudflared --password `. Without it, deploy launches kanna with no token and no password — kanna will then run as plain HTTP on localhost, **`trustProxy` will not auto-enable**, and every `/auth/login` POST through the tunnel will return **403** because the CSRF origin check compares the browser's `https://` Origin against the server's `http://` `req.url`.
+
+Create `scripts/pm2.env` (gitignored) with at least:
+
+```env
+KANNA_CLOUDFLARED_TOKEN=
+KANNA_PASSWORD=
+# Optional: pass through to spawned Claude Code agents
+# CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...
+```
+
+Generate a strong password with `openssl rand -base64 24`.
+
+### 4. (Migrating from launchd) Unload the old agent
+
+If you previously ran Kanna under launchd, unload it once so pm2 can take over:
+
+```bash
+launchctl bootout gui/$(id -u)/io.silentium.kanna || true
+```
+
+### 5. First deploy
+
+`scripts/deploy.sh` installs pm2 if missing, renders `scripts/pm2.config.cjs` from the template (via `envsubst` from `brew install gettext`), and starts the pm2 process:
+
+```bash
+./scripts/deploy.sh
+pm2 list # kanna should be "online"
+pm2 logs kanna --lines 50
+```
+
+`pm2 save` persists the running process list. To resurrect after a reboot, run `pm2 startup` once (pm2 prints the exact command) and then `pm2 save` again.
+
+The pm2 config sets `KANNA_RELOADER=pm2` and `KANNA_REPO_DIR=` so the in-app Update button triggers the pm2 reload pipeline (see next section). Override the pm2 process name with `KANNA_PM2_PROCESS_NAME` before running `./scripts/deploy.sh` if you need to run multiple instances.
+
+### 6. Redeploy / update
+
+Two ways to ship a new build:
+
+**a. From the UI (fastest).** Click **Update** in the running app. The server runs `git pull --ff-only` → conditional `bun install` → `bun run build` → `pm2.reload` internally, and the UI reconnects to the fresh build. If any step fails, the UI shows a red banner with the stderr tail and the old build keeps serving.
+
+**b. From the terminal.** Useful for non-Kanna deploys (e.g., pm2 config edits) or when the UI is unreachable:
+
+```bash
+git pull
+./scripts/deploy.sh
+```
+
+### 7. Troubleshooting: 403 on login
+
+If the login screen rejects the correct password with **403** behind a Cloudflare (or any HTTPS-terminating) tunnel, the server is running without `trustProxy` enabled. The CSRF origin check then compares the browser's `https://kanna.example.com` `Origin` against the local `http://127.0.0.1:` `req.url` and rejects them as mismatched. Two ways to enable it:
+
+- **Recommended.** Pass `--cloudflared ` (or `--share`) on the kanna command line. Both flags auto-enable `trustProxy` and bind to `127.0.0.1`. With `scripts/pm2.env` populated, `scripts/deploy.sh` does this for you — verify with `pm2 logs kanna --lines 20` that the startup line includes `--cloudflared`.
+- **Running cloudflared separately?** Use `--cloudflared` on kanna anyway and let kanna spawn the tunnel; the standalone `cloudflared` daemon does not set `trustProxy` for you. (There is no standalone `--trust-proxy` CLI flag today.)
+
+Other things to check if the 403 persists:
+
+- Cloudflare tunnel **public hostname** points to `http://localhost:`, not `https://` — kanna terminates plain HTTP locally.
+- The public hostname's **TLS mode** is `Full` or `Flexible` (Cloudflare → Origin is HTTP), not `Full (strict)` against a self-signed origin.
+- No `Access` policy in front of the hostname is stripping or rewriting the `Origin` header.
+
+### 8. Update strategies
+
+The update mechanism is abstracted behind `UpdateChecker` + `UpdateReloader` interfaces in `src/server/update-strategy.ts`, selected at startup by `KANNA_RELOADER`:
+
+| `KANNA_RELOADER` | Check | Reload | Notes |
+|---|---|---|---|
+| unset / `supervisor` | npm registry for `@cuongtran001/kanna` | ` install -g @cuongtran001/kanna@latest`, exit 76, supervisor respawns | Default. End-user path. `` auto-detected: `bun`/`npm`/`pnpm`/`yarn`. Override via `KANNA_UPDATE_COMMAND`. |
+| `pm2` | `git fetch` + `HEAD` vs `origin/main` | `git pull --ff-only` → cond. `bun install` → `bun run build` → `pm2 reload` | Dev/self-host path. Requires `KANNA_REPO_DIR`. |
+
+**Host-agnostic supervisor mode.** When `KANNA_RELOADER` is unset (default), the in-app Update button works under any process host (pm2, systemd, docker, screen, plain shell) — the internal supervisor catches the child's exit-76 and respawns. The package manager used to install the new version is auto-detected from the running binary path:
+
+- `~/.bun/bin/kanna` → `bun install -g`
+- `~/.local/share/pnpm/kanna` (or any `pnpm/` path) → `pnpm add -g`
+- `~/.yarn/bin/kanna` (or any `.yarn/` path) → `yarn global add`
+- anything else (e.g. `/usr/local/bin/kanna`, `~/.npm-global/bin/kanna`) → `npm install -g`
+
+If the detected manager is not on `PATH`, kanna falls back through `bun → npm → pnpm → yarn`. To override the install command entirely — useful for custom installers, monorepo wrappers, docker pulls, ansible, etc. — set `KANNA_UPDATE_COMMAND`. Placeholders `{package}` and `{version}` are substituted; the result is executed via `sh -c`.
+
+```bash
+# Force npm regardless of detection
+KANNA_UPDATE_COMMAND="npm install -g {package}@{version}" pm2 start kanna
+# Custom: chain pre-install hook
+KANNA_UPDATE_COMMAND="my-deploy-hook && npm install -g {package}@{version}" kanna
+```
+
+To add another reload mechanism (e.g., docker, systemd) at the strategy layer, implement `UpdateChecker` + `UpdateReloader` and branch inside `createUpdateStrategy`; no changes to `UpdateManager`, `server.ts`, or any client code are needed.
+
## Star History
-
+
-
-
-
+
+
+
diff --git a/bin/kanna b/bin/kanna
index d8765c3a0..cfa6a92b5 100755
--- a/bin/kanna
+++ b/bin/kanna
@@ -5,5 +5,5 @@ import { CLI_CHILD_MODE, CLI_CHILD_MODE_ENV_VAR } from "../src/server/restart"
if (process.env[CLI_CHILD_MODE_ENV_VAR] === CLI_CHILD_MODE) {
await import("../src/server/cli.ts")
} else {
- await import("../src/server/cli-supervisor.ts")
+ await import("../src/server/cli-supervisor.adapter.ts")
}
diff --git a/bun.lock b/bun.lock
index 2a8d27fbd..f3d91641f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,8 +5,9 @@
"": {
"name": "kanna",
"dependencies": {
- "@anthropic-ai/claude-agent-sdk": "^0.2.39",
+ "@anthropic-ai/claude-agent-sdk": "^0.2.140",
"@legendapp/list": "3.0.0-beta.44",
+ "@modelcontextprotocol/sdk": "^1.29.0",
"@pierre/diffs": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-select": "^2.2.6",
@@ -18,14 +19,22 @@
"cloudflared": "^0.7.1",
"default-shell": "^2.2.0",
"file-type": "^22.0.0",
+ "mermaid": "^11.15.0",
+ "minimatch": "^10.2.5",
"openai": "^6.34.0",
"react-resizable-panels": "^4.7.3",
+ "shell-quote": "^1.8.3",
+ "sonner": "^2.0.7",
"uqr": "^0.1.3",
+ "web-push": "^3.6.7",
},
"devDependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
+ "@eslint/js": "^10.0.1",
+ "@fontsource-variable/bricolage-grotesque": "^5.2.10",
+ "@happy-dom/global-registrator": "^20.9.0",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-tooltip": "^1.2.8",
@@ -35,10 +44,16 @@
"@types/node": "^24.10.1",
"@types/react": "19.2.7",
"@types/react-dom": "19.2.3",
+ "@types/shell-quote": "^1.7.5",
+ "@types/web-push": "^3.6.4",
"@vitejs/plugin-react": "5.1.1",
"autoprefixer": "^10.4.23",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "eslint": "^10.3.0",
+ "eslint-plugin-react-hooks": "^7.1.1",
+ "globals": "^17.6.0",
+ "happy-dom": "^20.9.0",
"lucide-react": "^0.562.0",
"react": "19.2.1",
"react-dom": "19.2.1",
@@ -48,6 +63,7 @@
"tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18",
"typescript": "5.8.3",
+ "typescript-eslint": "^8.59.3",
"vite": "^6.0.0",
"zustand": "^5.0.10",
},
@@ -56,7 +72,27 @@
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
- "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.72", "", { "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-GR3QaLRCoWO5DkRknaaCH6zzmUNZ3E6VckEKNE7EO5R7qDBexQe9tDKag257pji2NenTrnBDMxznoZrhNCRTzA=="],
+ "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="],
+
+ "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.140", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.140", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.140", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.140", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.140", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.140", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.140", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.140", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.140" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-Zq2L7YCoTdbxTUi3/soN1axrTqbG7GoKuc6Im8EpkBRdwaY0D1W9+Ux3vAbV/cX8Qk31Vck7DQLZz1lGEArdoQ=="],
+
+ "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.140", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zEbDsDKeoDO4DzbyX6wBVlcPhLy/gYiCrKzKnxmkOhyNtJBeshgiOTdr+M7WX1xcuI/M/UhEY+B9U6oo884lAQ=="],
+
+ "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.140", "", { "os": "darwin", "cpu": "x64" }, "sha512-BFJGeZEksvERy7mMJ0mkNAWoMrZOgl6XN/mKPaunGnaC/i+1ykx7xih7e58bRhsrzKzo2mnUrwtjiFyF3MFNRQ=="],
+
+ "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.140", "", { "os": "linux", "cpu": "arm64" }, "sha512-FauGGg3zikxrjAUnu+Pso6zD9Qv4Z2+QBiTiZqc12U+x4uoikNsplymUnsJ7MYD9VaTGmLJuZ9pCch0IiKrseQ=="],
+
+ "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.140", "", { "os": "linux", "cpu": "arm64" }, "sha512-nG7xLL0nKb4ymFVnX0QhSGLoyhh9fuuDpBR+TYz5O4ZQc2RVUMSMqGusqcCNEIGxAKQSVKWCf0WgpCG/edAO9Q=="],
+
+ "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.140", "", { "os": "linux", "cpu": "x64" }, "sha512-7f627Tq2mIiwFoBYfCKTdEeZSP90r8UOWu/I5DezudTtwtoVl2zRaRCnJ8c4rW+Tzw+xWSfP/pHvR9bTQGXaOw=="],
+
+ "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.140", "", { "os": "linux", "cpu": "x64" }, "sha512-EZ7VzOGmvft/1ymh2rwts5v3yPnsGGlGrTJlY2Dqnr1ABF43JIhEm1NFYrLnXQWSN74s5Pj8tgkPbYS9x4BhFA=="],
+
+ "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.140", "", { "os": "win32", "cpu": "arm64" }, "sha512-9EOozRF+LTt3UedeJtjJXC8pj9VTAFtPBuB+/YUmcpmDAEH9qcWWknWhf7NDKTapKtBWkNP/387x+18L15MLqg=="],
+
+ "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.140", "", { "os": "win32", "cpu": "x64" }, "sha512-puQyWoYiqosjDEYULWAS/lBJse1vzib0NmQj/bYTirWCbtiUcu6ixKMd4NmLbE+Si/DKTB8XNz6hVZ/KckqeoQ=="],
+
+ "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="],
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
@@ -90,6 +126,8 @@
"@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="],
+ "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
+
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
@@ -98,6 +136,10 @@
"@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="],
+ "@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
+
+ "@chevrotain/types": ["@chevrotain/types@11.1.2", "", {}, "sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw=="],
+
"@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="],
"@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="],
@@ -158,45 +200,49 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
- "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
+ "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
- "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
+ "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
- "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
+ "@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
- "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
+ "@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="],
- "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
+ "@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
- "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
+ "@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
- "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
+ "@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
- "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
+ "@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
- "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
+ "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
- "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
+ "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
- "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
+ "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.8", "", { "dependencies": { "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A=="],
- "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
+ "@floating-ui/utils": ["@floating-ui/utils@0.2.11", "", {}, "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg=="],
+
+ "@fontsource-variable/bricolage-grotesque": ["@fontsource-variable/bricolage-grotesque@5.2.10", "", {}, "sha512-5EDsCqgGpKVcJWE4sg9ydli+t5WM97mISYw5lla/Ev4z71FwXh1oN0YUU8xjkRW9+wBCGD9R+ntAvI8G4bUFJg=="],
+
+ "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "happy-dom": "^20.9.0" } }, "sha512-lBW6/m5BIFl3pMuWPNN0lIOYw9LMCmPfix53ExS3FBi4E+NELEljQ3xH6aAV9IYiQRfn9YIIgzzMrD0vIcD7tw=="],
- "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
+ "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
- "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
+ "@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
- "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
+ "@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
- "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
+ "@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
- "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
+ "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
- "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
+ "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
- "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
+ "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
- "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
+ "@iconify/utils": ["@iconify/utils@3.1.3", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "import-meta-resolve": "^4.2.0" } }, "sha512-LPKOXPn/zV+zis1oOfGWogaXVpqUybF3ZS6SCZIsz8vg0ivVp9+fVqyYB7xq0aiST/VhUQYGO1qo6uoYSiEJqw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
@@ -210,6 +256,10 @@
"@legendapp/list": ["@legendapp/list@3.0.0-beta.44", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*" } }, "sha512-loGRve78NuZ5k8Z54ZSDNOtv3dVBM1SeBCRtm1EYtZiDIZ8SyMVcYpUGgFpGuNKk71+9/NuM9hvScrgf7+4E+A=="],
+ "@mermaid-js/parser": ["@mermaid-js/parser@1.1.1", "", { "dependencies": { "@chevrotain/types": "~11.1.1" } }, "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw=="],
+
+ "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
+
"@pierre/diffs": ["@pierre/diffs@1.1.12", "", { "dependencies": { "@pierre/theme": "0.0.28", "@shikijs/transformers": "^3.0.0", "diff": "8.0.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-InssHHM7f0nkazIRkuaiNCy6GkBLfwJlqc7LtTkMD/KSqsuc6bnL2V9sIQoG5PZu9jwinQiXUb/gT7itFa6U9A=="],
"@pierre/theme": ["@pierre/theme@0.0.28", "", {}, "sha512-1j/H/fECBuc9dEvntdWI+l435HZapw+RCJTlqCA6BboQ5TjlnE005j/ROWutXIs8aq5OAc82JI2Kwk4A1WWBgw=="],
@@ -394,14 +444,82 @@
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
+ "@types/d3": ["@types/d3@7.4.3", "", { "dependencies": { "@types/d3-array": "*", "@types/d3-axis": "*", "@types/d3-brush": "*", "@types/d3-chord": "*", "@types/d3-color": "*", "@types/d3-contour": "*", "@types/d3-delaunay": "*", "@types/d3-dispatch": "*", "@types/d3-drag": "*", "@types/d3-dsv": "*", "@types/d3-ease": "*", "@types/d3-fetch": "*", "@types/d3-force": "*", "@types/d3-format": "*", "@types/d3-geo": "*", "@types/d3-hierarchy": "*", "@types/d3-interpolate": "*", "@types/d3-path": "*", "@types/d3-polygon": "*", "@types/d3-quadtree": "*", "@types/d3-random": "*", "@types/d3-scale": "*", "@types/d3-scale-chromatic": "*", "@types/d3-selection": "*", "@types/d3-shape": "*", "@types/d3-time": "*", "@types/d3-time-format": "*", "@types/d3-timer": "*", "@types/d3-transition": "*", "@types/d3-zoom": "*" } }, "sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww=="],
+
+ "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
+
+ "@types/d3-axis": ["@types/d3-axis@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw=="],
+
+ "@types/d3-brush": ["@types/d3-brush@3.0.6", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A=="],
+
+ "@types/d3-chord": ["@types/d3-chord@3.0.6", "", {}, "sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg=="],
+
+ "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
+
+ "@types/d3-contour": ["@types/d3-contour@3.0.6", "", { "dependencies": { "@types/d3-array": "*", "@types/geojson": "*" } }, "sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg=="],
+
+ "@types/d3-delaunay": ["@types/d3-delaunay@6.0.4", "", {}, "sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw=="],
+
+ "@types/d3-dispatch": ["@types/d3-dispatch@3.0.7", "", {}, "sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA=="],
+
+ "@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="],
+
+ "@types/d3-dsv": ["@types/d3-dsv@3.0.7", "", {}, "sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g=="],
+
+ "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
+
+ "@types/d3-fetch": ["@types/d3-fetch@3.0.7", "", { "dependencies": { "@types/d3-dsv": "*" } }, "sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA=="],
+
+ "@types/d3-force": ["@types/d3-force@3.0.10", "", {}, "sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw=="],
+
+ "@types/d3-format": ["@types/d3-format@3.0.4", "", {}, "sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g=="],
+
+ "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="],
+
+ "@types/d3-hierarchy": ["@types/d3-hierarchy@3.1.7", "", {}, "sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg=="],
+
+ "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
+
+ "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="],
+
+ "@types/d3-polygon": ["@types/d3-polygon@3.0.2", "", {}, "sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA=="],
+
+ "@types/d3-quadtree": ["@types/d3-quadtree@3.0.6", "", {}, "sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg=="],
+
+ "@types/d3-random": ["@types/d3-random@3.0.3", "", {}, "sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ=="],
+
+ "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
+
+ "@types/d3-scale-chromatic": ["@types/d3-scale-chromatic@3.1.0", "", {}, "sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ=="],
+
+ "@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="],
+
+ "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
+
+ "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
+
+ "@types/d3-time-format": ["@types/d3-time-format@4.0.3", "", {}, "sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg=="],
+
+ "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
+
+ "@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="],
+
+ "@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="],
+
"@types/debug": ["@types/debug@4.1.12", "", { "dependencies": { "@types/ms": "*" } }, "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ=="],
+ "@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
+
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
+ "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
+
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
+ "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
+
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
@@ -412,10 +530,42 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
+ "@types/shell-quote": ["@types/shell-quote@1.7.5", "", {}, "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw=="],
+
+ "@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
+
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
+ "@types/web-push": ["@types/web-push@3.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ=="],
+
+ "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
+
+ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
+
+ "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.3", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/type-utils": "8.59.3", "@typescript-eslint/utils": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.3", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PwFvSKsXGShKGW6n5bZOhGHEcCZXM8HofLK9fNsEwZXzFRjoY+XT1Vsf1zgyXdwTr0ZYz1/2tkZ0DBTT9jZjhw=="],
+
+ "@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.3", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HPwA+hVkfcriajbNvTmZv4VRauibay+cWArYUYq7u7W7PmGShMxbPxLvrwDme55a6d5alG3nrYfhyJ/G28XlLg=="],
+
+ "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.3", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.3", "@typescript-eslint/types": "^8.59.3", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-ECiUWa/KYRGDFUqTNehaRgzDshnJfkTABJxVemHk4ko22gcr0ukloKjWvyQ64g8YCV/UI47kN1dbmjf/GaQYng=="],
+
+ "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3" } }, "sha512-t2LvZnoEfzKtnPjgeEu41xw5gxq9mQVfYy4OoZ4Vlt0sk3JwxmhCca/AR7DwOiHrjWgjAj6as4AhRLKSDfvZIA=="],
+
+ "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.3", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-PcIJHjmaREXLgIAIzLnSY9VucEzz8FKXsRgFa1DmdGCK/5tJpW03TKJF01Q6VZd1lLdz2sIKPWaDUZN9dp//dw=="],
+
+ "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-g71d8QD8UaiHGvrJwyIS1hCX5r63w6Jll+4VEYhEAHXTDIqX1JgxhTAbEHtKntL9kuc4jRo7/GWw5xfCepSccQ=="],
+
+ "@typescript-eslint/types": ["@typescript-eslint/types@8.59.3", "", {}, "sha512-ePFoH0g4ludssdRFqqDxQePCxU4WQyRa9+XVwjm7yLn0FKhMeoetC+qBEEI1Eyb1pGSDveTIT09Bvw2WhlGayg=="],
+
+ "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.3", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.3", "@typescript-eslint/tsconfig-utils": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/visitor-keys": "8.59.3", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-CbRjVRAf7Lr9Kr8RopKcbY45p2VfmmHrm0ygOCYFi7oU8q19m0Fs/6iHS7kNOmwpp+ob07ZVcAqlxUod9lYdmg=="],
+
+ "@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.3", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.3", "@typescript-eslint/types": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-JAvT14goBzRzzzZyqq3P9BLArIxTtQURUtFgQ/V7FO+eU+Gg6ES+5ymOPP1wRxXcxAYeivCk4uS3jCKWI1K8Zg=="],
+
+ "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.3", "", { "dependencies": { "@typescript-eslint/types": "8.59.3", "eslint-visitor-keys": "^5.0.0" } }, "sha512-f1UQF7ggd42YiwI5wGrRaPsa+P0CINBlrkLPmGfpq/u/I/oVtecoEIfFR9ag/oa1sLOsRNZ6xehf6qMZhQGBDg=="],
+
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
+ "@upsetjs/venn.js": ["@upsetjs/venn.js@2.0.0", "", { "optionalDependencies": { "d3-selection": "^3.0.0", "d3-transition": "^3.0.1" } }, "sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw=="],
+
"@vitejs/plugin-react": ["@vitejs/plugin-react@5.1.1", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.47", "@types/babel__core": "^7.20.5", "react-refresh": "^0.18.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-WQfkSw0QbQ5aJ2CHYw23ZGkqnRwqKHD/KYsMeTkZzPT4Jcf0DcBxBtwMJxnu6E7oxw5+JC6ZAiePgh28uJ1HBA=="],
"@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
@@ -428,18 +578,48 @@
"@xterm/xterm": ["@xterm/xterm@6.0.0", "", {}, "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg=="],
+ "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
+
+ "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
+
+ "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
+
+ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
+
+ "ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
+
+ "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
+
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
+ "asn1.js": ["asn1.js@5.4.1", "", { "dependencies": { "bn.js": "^4.0.0", "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0", "safer-buffer": "^2.1.0" } }, "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA=="],
+
"autoprefixer": ["autoprefixer@10.4.27", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001774", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
+ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
+
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="],
+ "bn.js": ["bn.js@4.12.3", "", {}, "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g=="],
+
+ "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
+
+ "brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
+
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
+ "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
+
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
+ "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
+
+ "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
+
+ "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
+
"caniuse-lite": ["caniuse-lite@1.0.30001777", "", {}, "sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
@@ -460,20 +640,114 @@
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
+ "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
+
+ "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
+
+ "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
+
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
+ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
+
+ "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
+
+ "cose-base": ["cose-base@1.0.3", "", { "dependencies": { "layout-base": "^1.0.0" } }, "sha512-s9whTXInMSgAp/NVXVNuVxVKzGH2qck3aQlVHxDCdAEPgtMKwc4Wq6/QKhgdEdgbLSi9rBTAcPoRa6JpiG4ksg=="],
+
+ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+ "cytoscape": ["cytoscape@3.33.4", "", {}, "sha512-HIN5Pmd9MrX9BkV7tDwnOcEJCSFvCpc8X97h3f508J6I5FsqAY65wKOCvgH2CuP42CaahWaz4tuh32SOOIH7ww=="],
+
+ "cytoscape-cose-bilkent": ["cytoscape-cose-bilkent@4.1.0", "", { "dependencies": { "cose-base": "^1.0.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ=="],
+
+ "cytoscape-fcose": ["cytoscape-fcose@2.2.0", "", { "dependencies": { "cose-base": "^2.2.0" }, "peerDependencies": { "cytoscape": "^3.2.0" } }, "sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ=="],
+
+ "d3": ["d3@7.9.0", "", { "dependencies": { "d3-array": "3", "d3-axis": "3", "d3-brush": "3", "d3-chord": "3", "d3-color": "3", "d3-contour": "4", "d3-delaunay": "6", "d3-dispatch": "3", "d3-drag": "3", "d3-dsv": "3", "d3-ease": "3", "d3-fetch": "3", "d3-force": "3", "d3-format": "3", "d3-geo": "3", "d3-hierarchy": "3", "d3-interpolate": "3", "d3-path": "3", "d3-polygon": "3", "d3-quadtree": "3", "d3-random": "3", "d3-scale": "4", "d3-scale-chromatic": "3", "d3-selection": "3", "d3-shape": "3", "d3-time": "3", "d3-time-format": "4", "d3-timer": "3", "d3-transition": "3", "d3-zoom": "3" } }, "sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA=="],
+
+ "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="],
+
+ "d3-axis": ["d3-axis@3.0.0", "", {}, "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw=="],
+
+ "d3-brush": ["d3-brush@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "3", "d3-transition": "3" } }, "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ=="],
+
+ "d3-chord": ["d3-chord@3.0.1", "", { "dependencies": { "d3-path": "1 - 3" } }, "sha512-VE5S6TNa+j8msksl7HwjxMHDM2yNK3XCkusIlpX5kwauBfXuyLAtNg9jCp/iHH61tgI4sb6R/EIMWCqEIdjT/g=="],
+
+ "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
+
+ "d3-contour": ["d3-contour@4.0.2", "", { "dependencies": { "d3-array": "^3.2.0" } }, "sha512-4EzFTRIikzs47RGmdxbeUvLWtGedDUNkTcmzoeyg4sP/dvCexO47AaQL7VKy/gul85TOxw+IBgA8US2xwbToNA=="],
+
+ "d3-delaunay": ["d3-delaunay@6.0.4", "", { "dependencies": { "delaunator": "5" } }, "sha512-mdjtIZ1XLAM8bm/hx3WwjfHt6Sggek7qH043O8KEjDXN40xi3vx/6pYSVTwLjEgiXQTbvaouWKynLBiUZ6SK6A=="],
+
+ "d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
+
+ "d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="],
+
+ "d3-dsv": ["d3-dsv@3.0.1", "", { "dependencies": { "commander": "7", "iconv-lite": "0.6", "rw": "1" }, "bin": { "csv2json": "bin/dsv2json.js", "csv2tsv": "bin/dsv2dsv.js", "dsv2dsv": "bin/dsv2dsv.js", "dsv2json": "bin/dsv2json.js", "json2csv": "bin/json2dsv.js", "json2dsv": "bin/json2dsv.js", "json2tsv": "bin/json2dsv.js", "tsv2csv": "bin/dsv2dsv.js", "tsv2json": "bin/dsv2json.js" } }, "sha512-UG6OvdI5afDIFP9w4G0mNq50dSOsXHJaRE8arAS5o9ApWnIElp8GZw1Dun8vP8OyHOZ/QJUKUJwxiiCCnUwm+Q=="],
+
+ "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
+
+ "d3-fetch": ["d3-fetch@3.0.1", "", { "dependencies": { "d3-dsv": "1 - 3" } }, "sha512-kpkQIM20n3oLVBKGg6oHrUchHM3xODkTzjMoj7aWQFq5QEM+R6E4WkzT5+tojDY7yjez8KgCBRoj4aEr99Fdqw=="],
+
+ "d3-force": ["d3-force@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-quadtree": "1 - 3", "d3-timer": "1 - 3" } }, "sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg=="],
+
+ "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
+
+ "d3-geo": ["d3-geo@3.1.1", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q=="],
+
+ "d3-hierarchy": ["d3-hierarchy@3.1.2", "", {}, "sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA=="],
+
+ "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="],
+
+ "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="],
+
+ "d3-polygon": ["d3-polygon@3.0.1", "", {}, "sha512-3vbA7vXYwfe1SYhED++fPUQlWSYTTGmFmQiany/gdbiWgU/iEyQzyymwL9SkJjFFuCS4902BSzewVGsHHmHtXg=="],
+
+ "d3-quadtree": ["d3-quadtree@3.0.1", "", {}, "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw=="],
+
+ "d3-random": ["d3-random@3.0.1", "", {}, "sha512-FXMe9GfxTxqd5D6jFsQ+DJ8BJS4E/fT5mqqdjovykEB2oFbTMDVdg1MGFxfQW+FBOGoB++k8swBrgwSHT1cUXQ=="],
+
+ "d3-sankey": ["d3-sankey@0.12.3", "", { "dependencies": { "d3-array": "1 - 2", "d3-shape": "^1.2.0" } }, "sha512-nQhsBRmM19Ax5xEIPLMY9ZmJ/cDvd1BG3UVvt5h3WRxKg5zGRbvnteTyWAbzeSvlh3tW7ZEmq4VwR5mB3tutmQ=="],
+
+ "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
+
+ "d3-scale-chromatic": ["d3-scale-chromatic@3.1.0", "", { "dependencies": { "d3-color": "1 - 3", "d3-interpolate": "1 - 3" } }, "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ=="],
+
+ "d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],
+
+ "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
+
+ "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
+
+ "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="],
+
+ "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
+
+ "d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="],
+
+ "d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
+
+ "dagre-d3-es": ["dagre-d3-es@7.0.14", "", { "dependencies": { "d3": "^7.9.0", "lodash-es": "^4.17.21" } }, "sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg=="],
+
+ "dayjs": ["dayjs@1.11.20", "", {}, "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ=="],
+
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
+ "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
+
"default-shell": ["default-shell@2.2.0", "", {}, "sha512-sPpMZcVhRQ0nEMDtuMJ+RtCxt7iHPAMBU+I4tAlo5dU1sjRpNax0crj6nR3qKpvVnckaQ9U38enXcwW9nZJeCw=="],
+ "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="],
+
+ "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
+
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -484,66 +758,220 @@
"diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="],
+ "dompurify": ["dompurify@3.4.5", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-OrwIBKsdNSVEeubdJ1HBv/wNENRM9ytAVCv7YXt//A3vPdVMNuACRqK9mXCGCBW2ln7BT/A4X0jXHo2Gu89miA=="],
+
+ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
+
+ "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
+
+ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
+
"electron-to-chromium": ["electron-to-chromium@1.5.307", "", {}, "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg=="],
+ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
+
"enhanced-resolve": ["enhanced-resolve@5.20.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ=="],
+ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
+
+ "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
+
+ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
+
+ "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
+
+ "es-toolkit": ["es-toolkit@1.46.1", "", {}, "sha512-5eNtXOs3tbfxXOj04tjjseeWkRWaoCjdEI+96DgwzZoe6c9juL49pXlzAFTI72aWC9Y8p7168g6XIKjh7k6pyQ=="],
+
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
- "escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
+ "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
+
+ "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
+
+ "eslint": ["eslint@10.3.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw=="],
+
+ "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.1.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" } }, "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g=="],
+
+ "eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
+
+ "eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
+
+ "espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
+
+ "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
+
+ "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
+
+ "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
+ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
+
+ "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
+
+ "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
+
+ "eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
+
+ "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
+
+ "express-rate-limit": ["express-rate-limit@8.5.0", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q=="],
+
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
+ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
+
+ "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
+
+ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
+
+ "fast-uri": ["fast-uri@3.1.2", "", {}, "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ=="],
+
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
+ "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
+
"file-type": ["file-type@22.0.0", "", { "dependencies": { "@tokenizer/inflate": "^0.4.1", "strtok3": "^10.3.5", "token-types": "^6.1.2", "uint8array-extras": "^1.5.0" } }, "sha512-cmBmnYo8Zymabm2+qAP7jTFbKF10bQpYmxoGfuZbRFRcq00BRddJdGNH/P7GA1EMpJy5yQbqa9B7yROb3z8Ziw=="],
+ "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
+
+ "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
+
+ "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
+
+ "flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
+
+ "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
+
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
+ "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
+
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
+ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
+ "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
+
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
+ "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
+
+ "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
+
+ "globals": ["globals@17.6.0", "", {}, "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA=="],
+
+ "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
+
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
+ "hachure-fill": ["hachure-fill@0.5.2", "", {}, "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="],
+
+ "happy-dom": ["happy-dom@20.9.0", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" } }, "sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ=="],
+
+ "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
+
+ "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="],
+
"hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
+ "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="],
+
+ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="],
+
+ "hono": ["hono@4.12.17", "", {}, "sha512-FbJJNb/XgX7YW0hX/V8w5oYLztKEsRLykCMZWt1WdLtsfjzMvmoqWBA4H4t5norinq8/rh20oiZYr+WSl4UzAQ=="],
+
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
+ "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
+
+ "http_ece": ["http_ece@1.2.0", "", {}, "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA=="],
+
+ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
+
+ "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
+
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
+ "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
+
+ "import-meta-resolve": ["import-meta-resolve@4.2.0", "", {}, "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg=="],
+
+ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
+
+ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
+
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
+ "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
+
+ "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
+
+ "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
+
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
+ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
+
+ "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
+
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
+ "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
+
+ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
+ "jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
+
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
+ "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
+
+ "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
+
+ "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
+
+ "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
+
+ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
+
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
+ "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
+
+ "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
+
+ "katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
+
+ "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
+
+ "khroma": ["khroma@2.1.0", "", {}, "sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw=="],
+
+ "layout-base": ["layout-base@1.0.2", "", {}, "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="],
+
+ "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
+
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
@@ -568,6 +996,10 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
+ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
+
+ "lodash-es": ["lodash-es@4.18.1", "", {}, "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A=="],
+
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
@@ -580,6 +1012,10 @@
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
+ "marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
+
+ "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
+
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
@@ -610,6 +1046,12 @@
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
+ "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
+
+ "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
+
+ "mermaid": ["mermaid@11.15.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "es-toolkit": "^1.45.1", "katex": "^0.16.25", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw=="],
+
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
@@ -666,32 +1108,90 @@
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
+ "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
+
+ "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
+
+ "minimalistic-assert": ["minimalistic-assert@1.0.1", "", {}, "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="],
+
+ "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
+
+ "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
+
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
+ "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
+
+ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
+
"node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="],
+ "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
+
+ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
+
+ "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
+
+ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
+
"oniguruma-parser": ["oniguruma-parser@0.12.1", "", {}, "sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w=="],
"oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="],
"openai": ["openai@6.34.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-yEr2jdGf4tVFYG6ohmr3pF6VJuveP0EA/sS8TBx+4Eq5NT10alu5zg2dmxMXMgqpihRDQlFGpRt2XwsGj+Fyxw=="],
+ "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
+
+ "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
+
+ "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
+
+ "package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
+
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
+ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
+
+ "path-data-parser": ["path-data-parser@0.1.0", "", {}, "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w=="],
+
+ "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
+
+ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
+
+ "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
+
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
+ "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
+
+ "points-on-curve": ["points-on-curve@0.2.0", "", {}, "sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A=="],
+
+ "points-on-path": ["points-on-path@0.2.1", "", { "dependencies": { "path-data-parser": "0.1.0", "points-on-curve": "0.2.0" } }, "sha512-25ClnWWuw7JbWZcgqY/gJ4FQWadKxGWk+3kR/7kD0tCaDtPPMj7oHu2ToLaVhfpnHrZzYby2w6tUA0eOIuUg8g=="],
+
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
"postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
+ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
+
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
+ "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
+
+ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
+
+ "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="],
+
+ "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
+
+ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
+
"react": ["react@19.2.1", "", {}, "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw=="],
"react-dom": ["react-dom@19.2.1", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.1" } }, "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg=="],
@@ -726,20 +1226,58 @@
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
+ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
+
+ "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="],
+
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
+ "roughjs": ["roughjs@4.6.6", "", { "dependencies": { "hachure-fill": "^0.5.2", "path-data-parser": "^0.1.0", "points-on-curve": "^0.2.0", "points-on-path": "^0.2.1" } }, "sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ=="],
+
+ "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
+
+ "rw": ["rw@1.3.3", "", {}, "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="],
+
+ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
+
+ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
+
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+ "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
+
+ "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
+
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
+ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
+
+ "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
+
+ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
+
+ "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
+
"shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],
+ "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
+
+ "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
+
+ "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
+
+ "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
+
+ "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
+
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
+ "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
+
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
@@ -748,24 +1286,42 @@
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
+ "stylis": ["stylis@4.4.0", "", {}, "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA=="],
+
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
"tailwindcss": ["tailwindcss@4.2.1", "", {}, "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw=="],
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
+ "tinyexec": ["tinyexec@1.1.2", "", {}, "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA=="],
+
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
+ "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
+
"token-types": ["token-types@6.1.2", "", { "dependencies": { "@borewit/text-codec": "^0.2.1", "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
+ "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
+
+ "ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
+
+ "ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="],
+
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+ "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
+
+ "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
+
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
+ "typescript-eslint": ["typescript-eslint@8.59.3", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.3", "@typescript-eslint/parser": "8.59.3", "@typescript-eslint/typescript-estree": "8.59.3", "@typescript-eslint/utils": "8.59.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-KgusgyDgG4LI8Ih/sWaCtZ06tckLAS5CvT5A4D1Q7bYVoAAyzwiZvE4BmwDHkhRVkvhRBepKeASoFzQetha7Fg=="],
+
"uint8array-extras": ["uint8array-extras@1.5.0", "", {}, "sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
@@ -782,10 +1338,14 @@
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
+ "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
+
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"uqr": ["uqr@0.1.3", "", {}, "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA=="],
+ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
+
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
@@ -794,20 +1354,46 @@
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
+ "uuid": ["uuid@14.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg=="],
+
+ "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
+
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
+ "web-push": ["web-push@3.6.7", "", { "dependencies": { "asn1.js": "^5.3.0", "http_ece": "1.2.0", "https-proxy-agent": "^7.0.0", "jws": "^4.0.0", "minimist": "^1.2.5" }, "bin": { "web-push": "src/cli.js" } }, "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A=="],
+
+ "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
+
+ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+
+ "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
+
+ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
+
+ "ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
+
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
+
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
+ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
+
+ "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="],
+
"zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
+ "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
+
+ "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
+
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
@@ -820,6 +1406,36 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+ "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
+
+ "@typescript-eslint/typescript-estree/semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
+
+ "ajv-formats/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
+
+ "cytoscape-fcose/cose-base": ["cose-base@2.2.0", "", { "dependencies": { "layout-base": "^2.0.0" } }, "sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g=="],
+
+ "d3-dsv/commander": ["commander@7.2.0", "", {}, "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw=="],
+
+ "d3-dsv/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
+
+ "d3-sankey/d3-array": ["d3-array@2.12.1", "", { "dependencies": { "internmap": "^1.0.0" } }, "sha512-B0ErZK/66mHtEsR1TkPEEkwdy+WDesimkM5gpZr5Dsg54BiTA5RXtYW5qTLIAcekaS9xfZrzBLF/OAkB3Qn1YQ=="],
+
+ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="],
+
+ "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
+
+ "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
+
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
+
+ "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
+
+ "ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
+
+ "cytoscape-fcose/cose-base/layout-base": ["layout-base@2.0.1", "", {}, "sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg=="],
+
+ "d3-sankey/d3-array/internmap": ["internmap@1.0.1", "", {}, "sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw=="],
+
+ "d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
}
}
diff --git a/bunfig.toml b/bunfig.toml
new file mode 100644
index 000000000..10e82fcb1
--- /dev/null
+++ b/bunfig.toml
@@ -0,0 +1,2 @@
+[test]
+preload = ["./scripts/test-preload.ts"]
diff --git a/docs/plans/2026-04-20-import-claude-code-sessions-design.md b/docs/plans/2026-04-20-import-claude-code-sessions-design.md
new file mode 100644
index 000000000..02b863432
--- /dev/null
+++ b/docs/plans/2026-04-20-import-claude-code-sessions-design.md
@@ -0,0 +1,135 @@
+# Import Claude Code Sessions — Design
+
+**Date:** 2026-04-20
+**Status:** Approved, ready for implementation
+
+## Goal
+
+Bulk-import existing Claude Code CLI sessions from `~/.claude/projects/` into Kanna as native chats, preserving full transcript history and enabling seamless resume through the Claude Agent SDK.
+
+## Scope
+
+**In:**
+- Sidebar "Import" button beside existing "Add Project" button
+- One-shot scan of all `~/.claude/projects/*/*.jsonl` session files
+- Full transcript preload into Kanna chat (not stub/lazy)
+- Auto-create Kanna project if session's cwd is not yet tracked
+- Deduplication by `claudeSessionId`
+- Resume via session ID on next user turn (no forking)
+
+**Out (YAGNI):**
+- Running-process detection (`ps` scan)
+- Live session tailing
+- Separate sidebar section for un-imported CLI sessions
+- Codex session import
+- Bulk undo/delete for imported chats (existing per-chat delete suffices)
+
+## UI
+
+**Entry point:** new Import icon-button in sidebar header, sibling of Add Project.
+
+**Flow:**
+1. Click → confirmation modal: "Scan `~/.claude/projects/` and import sessions into Kanna?"
+2. Progress toast: "Scanning X sessions..." → streams count updates via WS
+3. Final toast: "Imported Y new, skipped Z existing, failed W"
+4. Sidebar refreshes with new projects and chats appearing under their groups
+
+## Architecture
+
+### New server module
+
+`src/server/import-claude-sessions.ts` — orchestrates scan, parse, dedup, write.
+
+### Scan phase
+
+- Walk `~/.claude/projects/*/` directories
+- List `*.jsonl` files per subdir (exclude snapshots/compacted files)
+- Decode folder name → cwd path via existing `resolveEncodedClaudePath` (discovery.ts:22)
+- Skip if cwd no longer exists on disk
+
+### Parse phase (per session file)
+
+- Read JSONL line-by-line, JSON.parse each
+- Extract `sessionId` from first record
+- Skip if `sessionId` already present in Kanna `chats.jsonl` (dedup)
+- Map each record → Kanna message event:
+ - user prompt → `message_appended { role: "user", ... }`
+ - assistant text → `message_appended { role: "assistant", ... }`
+ - tool_use / tool_result → normalized via `src/shared/tools.ts`
+- Emit `turn_finished` at assistant-response boundaries
+- Skip empty sessions (0 messages)
+- On malformed line: log + skip line, continue file (don't abort)
+
+### Write phase
+
+- Append `chat_created` event to `chats.jsonl`:
+ - `provider: "claude"`
+ - `claudeSessionId: `
+ - `status: "idle"`
+ - `projectId: `
+- Append all `message_appended` + `turn_finished` events to `messages.jsonl` / `turns.jsonl`
+- Trigger async title generation (existing Haiku pipeline) for untitled chats
+
+### Auto-create project
+
+If session cwd doesn't map to any existing Kanna project, emit `project_opened` event using same flow as Add Project modal.
+
+### Transport
+
+New WS command: `importClaudeSessions`
+Response shape: `{ imported: number, skipped: number, failed: number, newProjects: number }`
+Progress events streamed: `{ type: "importProgress", scanned, imported }`
+
+## Resume behavior
+
+- Kanna chat stores `claudeSessionId`
+- Next user turn: `AgentCoordinator` passes `resume: ` option to Claude Agent SDK
+- SDK continues same session → appends to original `~/.claude/projects/*.jsonl`
+- No fork, no duplicate session ID
+
+## Edge cases
+
+| Case | Behavior |
+|---|---|
+| Malformed JSONL line | Log + skip line, continue file |
+| Empty session (0 messages) | Skip, no chat created |
+| Session file still being written (CLI active) | Import current snapshot; resume continues normally |
+| Project dir deleted on disk | Skip session, count as failed |
+| Re-import of existing session | Dedup by `claudeSessionId`, skip |
+| Very large session (>10k messages) | Stream events; single progress update per 100 entries |
+
+## Testing
+
+### Unit
+
+`src/server/import-claude-sessions.test.ts`:
+- Fixture valid session → produces correct chat + message events
+- Fixture malformed JSONL → skips bad lines, imports rest
+- Fixture empty session → skipped
+- Fixture with tool_use/tool_result → normalized via shared/tools
+- Dedup: re-import produces 0 new
+- Missing project dir → failed count
+- Auto-create project when cwd new
+
+### Integration
+
+Full pipeline: WS `importClaudeSessions` → event store → read models → sidebar snapshot.
+
+## Files to touch
+
+**New:**
+- `src/server/import-claude-sessions.ts`
+- `src/server/import-claude-sessions.test.ts`
+- `src/client/components/ImportSessionsButton.tsx` (or inline in sidebar header)
+
+**Modified:**
+- `src/server/ws-router.ts` — add `importClaudeSessions` command handler
+- `src/shared/protocol.ts` — add command + progress event types
+- `src/server/events.ts` — reuse existing events; no new types needed
+- `src/client/app/KannaSidebar.tsx` — render Import button next to Add Project
+- `src/client/app/useKannaState.ts` — wire WS command + toast feedback
+- `src/server/agent.ts` — verify `resume: claudeSessionId` is passed (likely already supported)
+
+## Open questions
+
+None blocking. Implementation can proceed.
diff --git a/docs/plans/2026-04-20-import-claude-code-sessions.md b/docs/plans/2026-04-20-import-claude-code-sessions.md
new file mode 100644
index 000000000..2f5237366
--- /dev/null
+++ b/docs/plans/2026-04-20-import-claude-code-sessions.md
@@ -0,0 +1,1198 @@
+# Import Claude Code Sessions Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Add an "Import" button to the Kanna sidebar that scans `~/.claude/projects/*/*.jsonl` and bulk-creates Kanna chats from each session with full transcript preloaded, deduped by Claude session ID.
+
+**Architecture:** New server module `claude-session-importer.ts` parses Claude Code session JSONL files, maps records to Kanna `TranscriptEntry` values, and emits events through the existing `EventStore` (`openProject` → `createChat` → `renameChat` → `setChatProvider` → `appendMessage` × N → `setSessionToken`). Dedup uses the `sessionToken` field already present on `ChatRecord` (agent.ts:620 passes it as `resume` to the Claude Agent SDK, so imported chats resume seamlessly). New WS command `sessions.importClaude` handles the request; client adds an icon button next to the existing Add Project button in the sidebar header.
+
+**Tech Stack:** TypeScript, Bun, React 19, Zustand, Vite, WebSocket (custom envelope protocol). Existing test framework: `bun test`.
+
+**Reference design:** `docs/plans/2026-04-20-import-claude-code-sessions-design.md`
+
+---
+
+## Preflight
+
+**Run before starting:** ensure clean `main`, install deps.
+
+```bash
+git status # expect clean
+bun install
+bun run check # typecheck + build baseline passes
+bun test # baseline green
+```
+
+Create a worktree (recommended):
+
+```bash
+git worktree add ../kanna-import-sessions -b feat/import-claude-sessions
+cd ../kanna-import-sessions
+```
+
+All paths below are relative to repo root.
+
+---
+
+## Task 1: Define Claude session record type
+
+**Files:**
+- Create: `src/server/claude-session-types.ts`
+
+**Purpose:** Narrow, self-contained TypeScript types for Claude Code JSONL records. Keep parsing strict — only fields we use.
+
+**Step 1: Create the types file.**
+
+```ts
+// src/server/claude-session-types.ts
+
+export interface ClaudeSessionRecordBase {
+ type: string
+ uuid?: string
+ parentUuid?: string | null
+ sessionId?: string
+ timestamp?: string
+ cwd?: string
+ version?: string
+}
+
+export interface ClaudeSessionUserRecord extends ClaudeSessionRecordBase {
+ type: "user"
+ message: {
+ role: "user"
+ content: string | Array<
+ | { type: "text"; text: string }
+ | { type: "tool_result"; tool_use_id: string; content?: unknown; is_error?: boolean }
+ >
+ }
+}
+
+export interface ClaudeSessionAssistantRecord extends ClaudeSessionRecordBase {
+ type: "assistant"
+ message: {
+ role: "assistant"
+ id?: string
+ content: Array<
+ | { type: "text"; text: string }
+ | { type: "tool_use"; id: string; name: string; input: Record }
+ >
+ }
+}
+
+export interface ClaudeSessionSummaryRecord extends ClaudeSessionRecordBase {
+ type: "summary"
+ summary?: string
+}
+
+export interface ClaudeSessionSystemRecord extends ClaudeSessionRecordBase {
+ type: "system"
+ content?: string
+}
+
+export type ClaudeSessionRecord =
+ | ClaudeSessionUserRecord
+ | ClaudeSessionAssistantRecord
+ | ClaudeSessionSummaryRecord
+ | ClaudeSessionSystemRecord
+ | ClaudeSessionRecordBase
+
+export interface ParsedClaudeSession {
+ sessionId: string
+ filePath: string
+ cwd: string
+ firstTimestamp: number
+ lastTimestamp: number
+ records: ClaudeSessionRecord[]
+}
+```
+
+**Step 2: Typecheck.**
+
+```bash
+bun run tsc --noEmit
+```
+
+Expected: no errors.
+
+**Step 3: Commit.**
+
+```bash
+git add src/server/claude-session-types.ts
+git commit -m "feat(import): add Claude Code session record types"
+```
+
+---
+
+## Task 2: JSONL parser — happy path test first
+
+**Files:**
+- Create: `src/server/claude-session-parser.ts`
+- Create: `src/server/claude-session-parser.test.ts`
+- Create: `src/server/__fixtures__/claude-session-valid.jsonl`
+
+**Step 1: Write the happy-path fixture.**
+
+`src/server/__fixtures__/claude-session-valid.jsonl`:
+
+```jsonl
+{"type":"user","uuid":"u1","sessionId":"sess-abc","cwd":"/tmp/kanna-test-proj","timestamp":"2026-04-20T10:00:00.000Z","message":{"role":"user","content":"hello"}}
+{"type":"assistant","uuid":"a1","parentUuid":"u1","sessionId":"sess-abc","timestamp":"2026-04-20T10:00:01.000Z","message":{"role":"assistant","id":"msg-1","content":[{"type":"text","text":"hi back"}]}}
+{"type":"user","uuid":"u2","parentUuid":"a1","sessionId":"sess-abc","timestamp":"2026-04-20T10:00:02.000Z","message":{"role":"user","content":"run ls"}}
+{"type":"assistant","uuid":"a2","parentUuid":"u2","sessionId":"sess-abc","timestamp":"2026-04-20T10:00:03.000Z","message":{"role":"assistant","id":"msg-2","content":[{"type":"tool_use","id":"tu-1","name":"Bash","input":{"command":"ls","description":"list files"}}]}}
+{"type":"user","uuid":"u3","parentUuid":"a2","sessionId":"sess-abc","timestamp":"2026-04-20T10:00:04.000Z","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"tu-1","content":"file1\nfile2"}]}}
+{"type":"assistant","uuid":"a3","parentUuid":"u3","sessionId":"sess-abc","timestamp":"2026-04-20T10:00:05.000Z","message":{"role":"assistant","id":"msg-3","content":[{"type":"text","text":"done"}]}}
+```
+
+**Step 2: Write failing test.**
+
+`src/server/claude-session-parser.test.ts`:
+
+```ts
+import { describe, expect, test } from "bun:test"
+import path from "node:path"
+import { parseClaudeSessionFile } from "./claude-session-parser"
+
+const FIXTURE_DIR = path.join(__dirname, "__fixtures__")
+
+describe("parseClaudeSessionFile", () => {
+ test("parses valid session with user, assistant, tool_use, tool_result", () => {
+ const parsed = parseClaudeSessionFile(path.join(FIXTURE_DIR, "claude-session-valid.jsonl"))
+ expect(parsed).not.toBeNull()
+ if (!parsed) return
+ expect(parsed.sessionId).toBe("sess-abc")
+ expect(parsed.cwd).toBe("/tmp/kanna-test-proj")
+ expect(parsed.records.length).toBe(6)
+ expect(parsed.firstTimestamp).toBeGreaterThan(0)
+ expect(parsed.lastTimestamp).toBeGreaterThanOrEqual(parsed.firstTimestamp)
+ })
+})
+```
+
+Run: `bun test src/server/claude-session-parser.test.ts`
+Expected: FAIL — module not found.
+
+**Step 3: Implement minimal parser.**
+
+`src/server/claude-session-parser.ts`:
+
+```ts
+import { readFileSync, statSync } from "node:fs"
+import type { ClaudeSessionRecord, ParsedClaudeSession } from "./claude-session-types"
+
+function tryParse(line: string): ClaudeSessionRecord | null {
+ try {
+ const parsed = JSON.parse(line)
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null
+ if (typeof (parsed as ClaudeSessionRecord).type !== "string") return null
+ return parsed as ClaudeSessionRecord
+ } catch {
+ return null
+ }
+}
+
+export function parseClaudeSessionFile(filePath: string): ParsedClaudeSession | null {
+ let raw: string
+ try {
+ raw = readFileSync(filePath, "utf8")
+ } catch {
+ return null
+ }
+
+ const records: ClaudeSessionRecord[] = []
+ let sessionId: string | null = null
+ let cwd: string | null = null
+ let first = Number.POSITIVE_INFINITY
+ let last = 0
+
+ for (const line of raw.split("\n")) {
+ const trimmed = line.trim()
+ if (!trimmed) continue
+ const record = tryParse(trimmed)
+ if (!record) continue
+
+ if (!sessionId && typeof record.sessionId === "string") sessionId = record.sessionId
+ if (!cwd && typeof record.cwd === "string") cwd = record.cwd
+
+ const ts = typeof record.timestamp === "string" ? Date.parse(record.timestamp) : Number.NaN
+ if (!Number.isNaN(ts)) {
+ if (ts < first) first = ts
+ if (ts > last) last = ts
+ }
+
+ records.push(record)
+ }
+
+ if (!sessionId) return null
+ if (records.length === 0) return null
+
+ const mtime = statSync(filePath).mtimeMs
+ return {
+ sessionId,
+ filePath,
+ cwd: cwd ?? "",
+ firstTimestamp: Number.isFinite(first) ? first : mtime,
+ lastTimestamp: last > 0 ? last : mtime,
+ records,
+ }
+}
+```
+
+**Step 4: Run test — expect PASS.**
+
+```bash
+bun test src/server/claude-session-parser.test.ts
+```
+
+**Step 5: Commit.**
+
+```bash
+git add src/server/claude-session-parser.ts src/server/claude-session-parser.test.ts src/server/__fixtures__/claude-session-valid.jsonl
+git commit -m "feat(import): parse Claude Code session JSONL files"
+```
+
+---
+
+## Task 3: Parser edge cases — malformed / empty
+
+**Files:**
+- Create: `src/server/__fixtures__/claude-session-malformed.jsonl`
+- Create: `src/server/__fixtures__/claude-session-empty.jsonl`
+- Modify: `src/server/claude-session-parser.test.ts`
+
+**Step 1: Add fixtures.**
+
+`claude-session-malformed.jsonl`:
+
+```jsonl
+{"type":"user","uuid":"u1","sessionId":"sess-bad","cwd":"/tmp/x","timestamp":"2026-04-20T10:00:00.000Z","message":{"role":"user","content":"ok"}}
+not valid json at all
+{"type":"assistant","uuid":"a1","sessionId":"sess-bad","timestamp":"2026-04-20T10:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"still works"}]}}
+```
+
+`claude-session-empty.jsonl`: create an empty file.
+
+```bash
+: > src/server/__fixtures__/claude-session-empty.jsonl
+```
+
+**Step 2: Add tests.**
+
+Append to `claude-session-parser.test.ts`:
+
+```ts
+ test("skips malformed lines, keeps valid ones", () => {
+ const parsed = parseClaudeSessionFile(path.join(FIXTURE_DIR, "claude-session-malformed.jsonl"))
+ expect(parsed).not.toBeNull()
+ if (!parsed) return
+ expect(parsed.records.length).toBe(2)
+ expect(parsed.sessionId).toBe("sess-bad")
+ })
+
+ test("returns null for empty file", () => {
+ const parsed = parseClaudeSessionFile(path.join(FIXTURE_DIR, "claude-session-empty.jsonl"))
+ expect(parsed).toBeNull()
+ })
+
+ test("returns null for missing file", () => {
+ const parsed = parseClaudeSessionFile(path.join(FIXTURE_DIR, "does-not-exist.jsonl"))
+ expect(parsed).toBeNull()
+ })
+```
+
+**Step 3: Run — expect PASS without code changes (parser already handles these).**
+
+```bash
+bun test src/server/claude-session-parser.test.ts
+```
+
+**Step 4: Commit.**
+
+```bash
+git add src/server/claude-session-parser.test.ts src/server/__fixtures__/claude-session-malformed.jsonl src/server/__fixtures__/claude-session-empty.jsonl
+git commit -m "test(import): cover malformed and empty Claude session files"
+```
+
+---
+
+## Task 4: Map Claude records → Kanna TranscriptEntry
+
+**Files:**
+- Create: `src/server/claude-session-mapper.ts`
+- Create: `src/server/claude-session-mapper.test.ts`
+
+**Step 1: Write failing test.**
+
+```ts
+import { describe, expect, test } from "bun:test"
+import { mapClaudeRecordsToEntries } from "./claude-session-mapper"
+import type { ClaudeSessionRecord } from "./claude-session-types"
+
+describe("mapClaudeRecordsToEntries", () => {
+ const baseTs = "2026-04-20T10:00:00.000Z"
+
+ test("user message → user_prompt entry", () => {
+ const records: ClaudeSessionRecord[] = [
+ { type: "user", uuid: "u1", timestamp: baseTs, message: { role: "user", content: "hello" } },
+ ]
+ const entries = mapClaudeRecordsToEntries(records)
+ expect(entries.length).toBe(1)
+ expect(entries[0].kind).toBe("user_prompt")
+ if (entries[0].kind === "user_prompt") {
+ expect(entries[0].content).toBe("hello")
+ }
+ })
+
+ test("assistant text → assistant_text entry", () => {
+ const records: ClaudeSessionRecord[] = [
+ {
+ type: "assistant",
+ uuid: "a1",
+ timestamp: baseTs,
+ message: { role: "assistant", id: "m1", content: [{ type: "text", text: "hi" }] },
+ },
+ ]
+ const entries = mapClaudeRecordsToEntries(records)
+ expect(entries.length).toBe(1)
+ expect(entries[0].kind).toBe("assistant_text")
+ if (entries[0].kind === "assistant_text") {
+ expect(entries[0].text).toBe("hi")
+ }
+ })
+
+ test("assistant tool_use → tool_call entry with normalized Bash tool", () => {
+ const records: ClaudeSessionRecord[] = [
+ {
+ type: "assistant",
+ uuid: "a2",
+ timestamp: baseTs,
+ message: {
+ role: "assistant",
+ content: [{ type: "tool_use", id: "tu-1", name: "Bash", input: { command: "ls" } }],
+ },
+ },
+ ]
+ const entries = mapClaudeRecordsToEntries(records)
+ expect(entries.length).toBe(1)
+ expect(entries[0].kind).toBe("tool_call")
+ if (entries[0].kind === "tool_call") {
+ expect(entries[0].tool.toolKind).toBe("bash")
+ expect(entries[0].tool.toolId).toBe("tu-1")
+ }
+ })
+
+ test("user tool_result → tool_result entry", () => {
+ const records: ClaudeSessionRecord[] = [
+ {
+ type: "user",
+ uuid: "u1",
+ timestamp: baseTs,
+ message: {
+ role: "user",
+ content: [{ type: "tool_result", tool_use_id: "tu-1", content: "file1\nfile2" }],
+ },
+ },
+ ]
+ const entries = mapClaudeRecordsToEntries(records)
+ expect(entries.length).toBe(1)
+ expect(entries[0].kind).toBe("tool_result")
+ if (entries[0].kind === "tool_result") {
+ expect(entries[0].toolId).toBe("tu-1")
+ expect(entries[0].content).toBe("file1\nfile2")
+ }
+ })
+
+ test("skips summary and system records", () => {
+ const records: ClaudeSessionRecord[] = [
+ { type: "summary", summary: "x" },
+ { type: "system", content: "y" },
+ { type: "user", uuid: "u1", timestamp: baseTs, message: { role: "user", content: "hi" } },
+ ]
+ const entries = mapClaudeRecordsToEntries(records)
+ expect(entries.length).toBe(1)
+ })
+})
+```
+
+Run: `bun test src/server/claude-session-mapper.test.ts` — expect FAIL.
+
+**Step 2: Implement mapper.**
+
+`src/server/claude-session-mapper.ts`:
+
+```ts
+import { normalizeToolCall } from "../shared/tools"
+import type {
+ AssistantTextEntry,
+ ToolCallEntry,
+ ToolResultEntry,
+ TranscriptEntry,
+ UserPromptEntry,
+} from "../shared/types"
+import type {
+ ClaudeSessionAssistantRecord,
+ ClaudeSessionRecord,
+ ClaudeSessionUserRecord,
+} from "./claude-session-types"
+
+function toMillis(value: string | undefined): number {
+ if (!value) return Date.now()
+ const parsed = Date.parse(value)
+ return Number.isFinite(parsed) ? parsed : Date.now()
+}
+
+function makeId(uuid: string | undefined, suffix: string): string {
+ if (uuid) return `${uuid}-${suffix}`
+ return `${crypto.randomUUID()}-${suffix}`
+}
+
+function mapUserRecord(record: ClaudeSessionUserRecord): TranscriptEntry[] {
+ const createdAt = toMillis(record.timestamp)
+ const content = record.message.content
+
+ if (typeof content === "string") {
+ const entry: UserPromptEntry = {
+ _id: makeId(record.uuid, "user"),
+ kind: "user_prompt",
+ createdAt,
+ content,
+ }
+ return [entry]
+ }
+
+ const entries: TranscriptEntry[] = []
+ for (let i = 0; i < content.length; i += 1) {
+ const block = content[i]
+ if (block.type === "tool_result") {
+ const resultEntry: ToolResultEntry = {
+ _id: makeId(record.uuid, `tool_result-${i}`),
+ kind: "tool_result",
+ createdAt,
+ toolId: block.tool_use_id,
+ content: typeof block.content === "string" ? block.content : block.content ?? null,
+ isError: block.is_error === true,
+ }
+ entries.push(resultEntry)
+ }
+ }
+ return entries
+}
+
+function mapAssistantRecord(record: ClaudeSessionAssistantRecord): TranscriptEntry[] {
+ const createdAt = toMillis(record.timestamp)
+ const messageId = record.message.id
+
+ const entries: TranscriptEntry[] = []
+ for (let i = 0; i < record.message.content.length; i += 1) {
+ const block = record.message.content[i]
+ if (block.type === "text") {
+ const entry: AssistantTextEntry = {
+ _id: makeId(record.uuid, `text-${i}`),
+ messageId,
+ kind: "assistant_text",
+ createdAt,
+ text: block.text,
+ }
+ entries.push(entry)
+ continue
+ }
+ if (block.type === "tool_use") {
+ const tool = normalizeToolCall({
+ toolName: block.name,
+ toolId: block.id,
+ input: block.input ?? {},
+ })
+ const entry: ToolCallEntry = {
+ _id: makeId(record.uuid, `tool_call-${i}`),
+ messageId,
+ kind: "tool_call",
+ createdAt,
+ tool,
+ }
+ entries.push(entry)
+ }
+ }
+ return entries
+}
+
+export function mapClaudeRecordsToEntries(records: ClaudeSessionRecord[]): TranscriptEntry[] {
+ const entries: TranscriptEntry[] = []
+ for (const record of records) {
+ if (record.type === "user") {
+ entries.push(...mapUserRecord(record as ClaudeSessionUserRecord))
+ } else if (record.type === "assistant") {
+ entries.push(...mapAssistantRecord(record as ClaudeSessionAssistantRecord))
+ }
+ // summary/system/other: skipped
+ }
+ return entries
+}
+```
+
+**Step 3: Run test — expect PASS.**
+
+```bash
+bun test src/server/claude-session-mapper.test.ts
+```
+
+**Step 4: Commit.**
+
+```bash
+git add src/server/claude-session-mapper.ts src/server/claude-session-mapper.test.ts
+git commit -m "feat(import): map Claude session records to Kanna transcript entries"
+```
+
+---
+
+## Task 5: Scanner — walk ~/.claude/projects/
+
+**Files:**
+- Create: `src/server/claude-session-scanner.ts`
+- Create: `src/server/claude-session-scanner.test.ts`
+
+**Step 1: Failing test using a temp dir.**
+
+```ts
+import { describe, expect, test } from "bun:test"
+import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"
+import { tmpdir } from "node:os"
+import path from "node:path"
+import { scanClaudeSessions } from "./claude-session-scanner"
+
+function makeTempClaudeHome(): { home: string; cleanup: () => void } {
+ const home = mkdtempSync(path.join(tmpdir(), "kanna-claude-home-"))
+ return { home, cleanup: () => rmSync(home, { recursive: true, force: true }) }
+}
+
+describe("scanClaudeSessions", () => {
+ test("returns empty list when ~/.claude/projects missing", () => {
+ const { home, cleanup } = makeTempClaudeHome()
+ try {
+ expect(scanClaudeSessions(home)).toEqual([])
+ } finally {
+ cleanup()
+ }
+ })
+
+ test("discovers session files inside project folders", () => {
+ const { home, cleanup } = makeTempClaudeHome()
+ try {
+ const realProj = mkdtempSync(path.join(tmpdir(), "kanna-proj-"))
+ const folderName = realProj.replace(/\//g, "-")
+ const projDir = path.join(home, ".claude", "projects", folderName)
+ mkdirSync(projDir, { recursive: true })
+ const sessionPath = path.join(projDir, "sess-abc.jsonl")
+ const line = JSON.stringify({
+ type: "user",
+ uuid: "u1",
+ sessionId: "sess-abc",
+ cwd: realProj,
+ timestamp: "2026-04-20T10:00:00.000Z",
+ message: { role: "user", content: "hi" },
+ })
+ writeFileSync(sessionPath, `${line}\n`, "utf8")
+
+ const sessions = scanClaudeSessions(home)
+ expect(sessions.length).toBe(1)
+ expect(sessions[0].sessionId).toBe("sess-abc")
+ expect(sessions[0].filePath).toBe(sessionPath)
+ rmSync(realProj, { recursive: true, force: true })
+ } finally {
+ cleanup()
+ }
+ })
+})
+```
+
+Run: expect FAIL.
+
+**Step 2: Implement scanner.**
+
+`src/server/claude-session-scanner.ts`:
+
+```ts
+import { existsSync, readdirSync } from "node:fs"
+import { homedir } from "node:os"
+import path from "node:path"
+import type { ParsedClaudeSession } from "./claude-session-types"
+import { parseClaudeSessionFile } from "./claude-session-parser"
+
+export function scanClaudeSessions(homeDir: string = homedir()): ParsedClaudeSession[] {
+ const projectsDir = path.join(homeDir, ".claude", "projects")
+ if (!existsSync(projectsDir)) return []
+
+ const sessions: ParsedClaudeSession[] = []
+ for (const entry of readdirSync(projectsDir, { withFileTypes: true })) {
+ if (!entry.isDirectory()) continue
+ const projDir = path.join(projectsDir, entry.name)
+
+ for (const file of readdirSync(projDir, { withFileTypes: true })) {
+ if (!file.isFile() || !file.name.endsWith(".jsonl")) continue
+ const parsed = parseClaudeSessionFile(path.join(projDir, file.name))
+ if (parsed) sessions.push(parsed)
+ }
+ }
+
+ return sessions
+}
+```
+
+**Step 3: Run — PASS.**
+
+```bash
+bun test src/server/claude-session-scanner.test.ts
+```
+
+**Step 4: Commit.**
+
+```bash
+git add src/server/claude-session-scanner.ts src/server/claude-session-scanner.test.ts
+git commit -m "feat(import): scan ~/.claude/projects for session files"
+```
+
+---
+
+## Task 6: Importer orchestrator — dedup + event emission
+
+**Files:**
+- Create: `src/server/claude-session-importer.ts`
+- Create: `src/server/claude-session-importer.test.ts`
+
+This module glues scan → parse → map → store. Dedup on `chat.sessionToken === sessionId`. Skip sessions whose `cwd` doesn't exist on disk.
+
+**Step 1: Failing test using real `EventStore` with temp data dir.**
+
+```ts
+import { describe, expect, test, beforeEach } from "bun:test"
+import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
+import { tmpdir } from "node:os"
+import path from "node:path"
+import { EventStore } from "./event-store"
+import { importClaudeSessions } from "./claude-session-importer"
+
+function fresh() {
+ const dataDir = mkdtempSync(path.join(tmpdir(), "kanna-data-"))
+ const homeDir = mkdtempSync(path.join(tmpdir(), "kanna-home-"))
+ const realProj = mkdtempSync(path.join(tmpdir(), "kanna-proj-"))
+ return { dataDir, homeDir, realProj, cleanup: () => {
+ rmSync(dataDir, { recursive: true, force: true })
+ rmSync(homeDir, { recursive: true, force: true })
+ rmSync(realProj, { recursive: true, force: true })
+ } }
+}
+
+function seedSession(homeDir: string, realProj: string, sessionId: string) {
+ const folderName = realProj.replace(/\//g, "-")
+ const projDir = path.join(homeDir, ".claude", "projects", folderName)
+ mkdirSync(projDir, { recursive: true })
+ const line1 = JSON.stringify({
+ type: "user", uuid: "u1", sessionId, cwd: realProj,
+ timestamp: "2026-04-20T10:00:00.000Z",
+ message: { role: "user", content: "hi" },
+ })
+ const line2 = JSON.stringify({
+ type: "assistant", uuid: "a1", sessionId, cwd: realProj,
+ timestamp: "2026-04-20T10:00:01.000Z",
+ message: { role: "assistant", id: "m1", content: [{ type: "text", text: "hello" }] },
+ })
+ writeFileSync(path.join(projDir, `${sessionId}.jsonl`), `${line1}\n${line2}\n`, "utf8")
+}
+
+describe("importClaudeSessions", () => {
+ test("imports a session, creating project + chat + messages", async () => {
+ const ctx = fresh()
+ try {
+ seedSession(ctx.homeDir, ctx.realProj, "sess-aaa")
+ const store = new EventStore({ dataDir: ctx.dataDir })
+ await store.initialize()
+
+ const result = await importClaudeSessions({ store, homeDir: ctx.homeDir })
+
+ expect(result.imported).toBe(1)
+ expect(result.skipped).toBe(0)
+ expect(result.failed).toBe(0)
+
+ const chats = [...store.state.chatsById.values()].filter((c) => !c.deletedAt)
+ expect(chats.length).toBe(1)
+ expect(chats[0].sessionToken).toBe("sess-aaa")
+ expect(chats[0].provider).toBe("claude")
+ expect(store.getMessages(chats[0].id).length).toBe(2)
+ } finally {
+ ctx.cleanup()
+ }
+ })
+
+ test("re-import is a no-op (dedup by sessionToken)", async () => {
+ const ctx = fresh()
+ try {
+ seedSession(ctx.homeDir, ctx.realProj, "sess-bbb")
+ const store = new EventStore({ dataDir: ctx.dataDir })
+ await store.initialize()
+
+ await importClaudeSessions({ store, homeDir: ctx.homeDir })
+ const second = await importClaudeSessions({ store, homeDir: ctx.homeDir })
+
+ expect(second.imported).toBe(0)
+ expect(second.skipped).toBe(1)
+ } finally {
+ ctx.cleanup()
+ }
+ })
+
+ test("skips session whose cwd no longer exists", async () => {
+ const ctx = fresh()
+ try {
+ seedSession(ctx.homeDir, ctx.realProj, "sess-ccc")
+ rmSync(ctx.realProj, { recursive: true, force: true })
+ const store = new EventStore({ dataDir: ctx.dataDir })
+ await store.initialize()
+
+ const result = await importClaudeSessions({ store, homeDir: ctx.homeDir })
+ expect(result.imported).toBe(0)
+ expect(result.failed).toBe(1)
+ } finally {
+ ctx.cleanup()
+ }
+ })
+})
+```
+
+Check `EventStore` constructor shape in `src/server/event-store.ts` (look for `constructor(...)` near line 120-180) — if it takes a different shape, adjust the test. If `initialize()` isn't the entry, use whatever the existing code calls on startup (see `src/server/server.ts`).
+
+Run: expect FAIL — module missing.
+
+**Step 2: Implement importer.**
+
+`src/server/claude-session-importer.ts`:
+
+```ts
+import { existsSync, statSync } from "node:fs"
+import { homedir } from "node:os"
+import type { EventStore } from "./event-store"
+import { mapClaudeRecordsToEntries } from "./claude-session-mapper"
+import { scanClaudeSessions } from "./claude-session-scanner"
+import type { ParsedClaudeSession } from "./claude-session-types"
+
+export interface ImportClaudeSessionsResult {
+ imported: number
+ skipped: number
+ failed: number
+ newProjects: number
+}
+
+export interface ImportClaudeSessionsArgs {
+ store: EventStore
+ homeDir?: string
+ onProgress?: (update: { scanned: number; imported: number }) => void
+}
+
+function cwdExists(cwd: string): boolean {
+ if (!cwd) return false
+ try {
+ return statSync(cwd).isDirectory()
+ } catch {
+ return false
+ }
+}
+
+function deriveTitle(session: ParsedClaudeSession): string {
+ for (const record of session.records) {
+ if (record.type !== "user") continue
+ const content = (record as { message?: { content?: unknown } }).message?.content
+ if (typeof content === "string") {
+ const trimmed = content.trim()
+ if (trimmed) return trimmed.slice(0, 60)
+ }
+ }
+ return "Imported session"
+}
+
+export async function importClaudeSessions(args: ImportClaudeSessionsArgs): Promise {
+ const { store, homeDir = homedir(), onProgress } = args
+ const sessions = scanClaudeSessions(homeDir)
+
+ let imported = 0
+ let skipped = 0
+ let failed = 0
+ let newProjects = 0
+
+ const existingSessionTokens = new Set()
+ for (const chat of store.state.chatsById.values()) {
+ if (chat.deletedAt) continue
+ if (chat.sessionToken) existingSessionTokens.add(chat.sessionToken)
+ }
+
+ let scanned = 0
+ for (const session of sessions) {
+ scanned += 1
+ if (onProgress) onProgress({ scanned, imported })
+
+ if (existingSessionTokens.has(session.sessionId)) {
+ skipped += 1
+ continue
+ }
+ if (!cwdExists(session.cwd)) {
+ failed += 1
+ continue
+ }
+
+ const entries = mapClaudeRecordsToEntries(session.records)
+ if (entries.length === 0) {
+ skipped += 1
+ continue
+ }
+
+ try {
+ const projectBefore = store.state.projectIdsByPath.get(session.cwd)
+ const project = await store.openProject(session.cwd)
+ if (!projectBefore) newProjects += 1
+
+ const chat = await store.createChat(project.id)
+ await store.setChatProvider(chat.id, "claude")
+ await store.renameChat(chat.id, deriveTitle(session))
+
+ for (const entry of entries) {
+ await store.appendMessage(chat.id, entry)
+ }
+
+ await store.setSessionToken(chat.id, session.sessionId)
+ existingSessionTokens.add(session.sessionId)
+ imported += 1
+ if (onProgress) onProgress({ scanned, imported })
+ } catch (error) {
+ console.error("[kanna/import] failed to import session", session.filePath, error)
+ failed += 1
+ }
+ }
+
+ return { imported, skipped, failed, newProjects }
+}
+```
+
+**Step 3: Run — PASS.**
+
+```bash
+bun test src/server/claude-session-importer.test.ts
+```
+
+If `EventStore` constructor signature differs, read `src/server/event-store.ts` around the constructor definition (search for `class EventStore`, then `constructor(`). Adjust the test setup to match (e.g. `new EventStore(dataDir)` vs `new EventStore({ dataDir })`).
+
+**Step 4: Commit.**
+
+```bash
+git add src/server/claude-session-importer.ts src/server/claude-session-importer.test.ts
+git commit -m "feat(import): orchestrate import with dedup and event emission"
+```
+
+---
+
+## Task 7: Add WS protocol command
+
+**Files:**
+- Modify: `src/shared/protocol.ts`
+
+**Step 1: Add the command and progress event to the union.**
+
+In `ClientCommand` union, add:
+
+```ts
+ | { type: "sessions.importClaude" }
+```
+
+Keep the rest untouched. Place the new variant near `project.create` for locality.
+
+**Step 2: Typecheck.**
+
+```bash
+bun run tsc --noEmit
+```
+
+Expected: no errors. If there are exhaustive switch statements over `ClientCommand` (search `ws-router.ts` for `switch (command.type)`), TypeScript will flag missing case — we handle that in Task 8, so a failure here is only acceptable in `ws-router.ts`.
+
+**Step 3: Commit.**
+
+```bash
+git add src/shared/protocol.ts
+git commit -m "feat(import): add sessions.importClaude WS command"
+```
+
+---
+
+## Task 8: Wire WS handler
+
+**Files:**
+- Modify: `src/server/ws-router.ts`
+
+**Step 1: Add import.**
+
+Near the top of `ws-router.ts`, add:
+
+```ts
+import { importClaudeSessions } from "./claude-session-importer"
+```
+
+**Step 2: Add the command case.**
+
+Find the big `switch (command.type)` (look for `case "chat.create"` around line 802). Add a new case near `project.create`:
+
+```ts
+ case "sessions.importClaude": {
+ const result = await importClaudeSessions({ store })
+ if (result.newProjects > 0) {
+ await refreshDiscovery()
+ }
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result })
+ await broadcastSidebarToAll()
+ break
+ }
+```
+
+If the existing file has a helper named `broadcastSidebarToAll` or similar, use it. Otherwise look for how `chat.create` or `project.create` broadcasts sidebar updates (`broadcastChatAndSidebar` or `broadcastSidebar`) and mirror it. Grep first:
+
+```bash
+grep -n "broadcastSidebar\|broadcastChatAndSidebar\|refreshDiscovery" src/server/ws-router.ts
+```
+
+Use whichever matches the existing pattern for sidebar invalidation.
+
+**Step 3: Typecheck + test.**
+
+```bash
+bun run tsc --noEmit
+bun test
+```
+
+Expected: all green (prior tests should still pass; no new server test added here).
+
+**Step 4: Commit.**
+
+```bash
+git add src/server/ws-router.ts
+git commit -m "feat(import): handle sessions.importClaude over WebSocket"
+```
+
+---
+
+## Task 9: Client state hook wiring
+
+**Files:**
+- Modify: `src/client/app/useKannaState.ts`
+
+The hook exposes WS command senders. Add `importClaudeSessions` that sends the new command and returns the ack result.
+
+**Step 1: Locate the existing command-sender pattern.**
+
+```bash
+grep -n "project.create\|chat.create" src/client/app/useKannaState.ts
+```
+
+Copy the style used by `project.create`.
+
+**Step 2: Add the sender.**
+
+Inside the hook, near the other command senders:
+
+```ts
+ const importClaudeSessions = useCallback(async () => {
+ const result = await sendCommand({ type: "sessions.importClaude" })
+ return result as { imported: number; skipped: number; failed: number; newProjects: number }
+ }, [sendCommand])
+```
+
+Return `importClaudeSessions` from the hook's return object (add it alongside `createProject`, `removeProject`, etc.).
+
+**Step 3: Typecheck.**
+
+```bash
+bun run tsc --noEmit
+```
+
+**Step 4: Commit.**
+
+```bash
+git add src/client/app/useKannaState.ts
+git commit -m "feat(import): add importClaudeSessions state hook"
+```
+
+---
+
+## Task 10: Sidebar Import button
+
+**Files:**
+- Modify: `src/client/app/KannaSidebar.tsx` (or wherever Add Project button lives — confirm first)
+- Modify: `src/client/app/App.tsx` if needed to pass the handler
+
+**Step 1: Locate Add Project button.**
+
+```bash
+grep -rn "onOpenAddProjectModal\|NewProjectModal" src/client
+```
+
+The sidebar renders the Add Project button (likely as an icon-only button in a header row). Add a sibling button.
+
+**Step 2: Add Import button.**
+
+Import a suitable icon from `lucide-react`:
+
+```ts
+import { Download } from "lucide-react"
+```
+
+Inside the sidebar header, next to the Add Project button, add:
+
+```tsx
+
+
+
+```
+
+Wire `handleImportClick`:
+
+```ts
+const [isImporting, setIsImporting] = useState(false)
+
+const handleImportClick = async () => {
+ if (isImporting) return
+ const confirmed = window.confirm(
+ "Scan ~/.claude/projects/ and import all sessions into Kanna? Already-imported sessions are skipped.",
+ )
+ if (!confirmed) return
+ setIsImporting(true)
+ try {
+ const result = await importClaudeSessions()
+ alert(
+ `Imported ${result.imported}, skipped ${result.skipped}, failed ${result.failed}.`
+ + (result.newProjects > 0 ? ` (${result.newProjects} new projects)` : ""),
+ )
+ } catch (error) {
+ console.error("[kanna/import] failed", error)
+ alert("Import failed. See console for details.")
+ } finally {
+ setIsImporting(false)
+ }
+}
+```
+
+`importClaudeSessions` arrives from `useKannaState` — pass it through props if the sidebar doesn't already consume the hook directly (mirror how Add Project is wired).
+
+**Step 3: Typecheck + build.**
+
+```bash
+bun run check
+```
+
+Expected: success.
+
+**Step 4: Commit.**
+
+```bash
+git add src/client/app/KannaSidebar.tsx src/client/app/App.tsx
+git commit -m "feat(import): add Import button to sidebar header"
+```
+
+> Note: `window.confirm` / `window.alert` are used for minimal friction. Swap to a proper modal/toast later if the rest of the app uses a toast system — confirm by searching for existing toast components before rewriting.
+
+---
+
+## Task 11: Manual verification
+
+**Files:** none.
+
+**Step 1: Build + run dev.**
+
+```bash
+bun run dev
+```
+
+Visit `http://localhost:5174`.
+
+**Step 2: Verify preconditions.**
+
+```bash
+ls ~/.claude/projects/ | head
+```
+
+Expect at least one project directory with `.jsonl` files. If empty, copy one of your own sessions or create a minimal fixture before testing.
+
+**Step 3: Click Import.**
+
+- Confirm dialog appears
+- After accept, alert shows `Imported N, skipped 0, failed 0`
+- Sidebar refreshes — imported chats appear grouped under their project (project auto-created if needed)
+- Open an imported chat — transcript preloads (user messages, assistant text, tool calls render correctly)
+
+**Step 4: Verify dedup.**
+
+- Click Import again
+- Expect `Imported 0, skipped N`
+
+**Step 5: Verify resume.**
+
+- Open an imported chat
+- Send a follow-up message
+- Inspect `~/.claude/projects//.jsonl` — new lines should be appended by the Agent SDK (no new JSONL file created)
+
+**Step 6: Verify edge case — missing project.**
+
+- Temporarily rename a project directory whose sessions you've not imported
+- Click Import again — that session should count toward `failed` without crashing
+
+No commit for this task.
+
+---
+
+## Task 12: Docs update
+
+**Files:**
+- Modify: `README.md`
+
+**Step 1: Add import to Features section.**
+
+Under `## Features`, insert a bullet:
+
+```markdown
+- **Bulk import Claude Code sessions** — one-click import of existing `~/.claude/projects/` sessions with full transcript and seamless resume via the Claude Agent SDK
+```
+
+**Step 2: Commit.**
+
+```bash
+git add README.md
+git commit -m "docs: mention Claude Code session import feature"
+```
+
+---
+
+## Task 13: Final check
+
+```bash
+bun run check # typecheck + build
+bun test # all unit tests
+git log --oneline # verify commit history is clean and linear
+```
+
+All green → feature is ready for PR.
+
+---
+
+## Deferred / explicitly out of scope
+
+- Process scan / live CLI session detection
+- Separate "CLI sessions" sidebar section before import
+- Codex session import (Codex uses a different format in `~/.codex/sessions/`)
+- Bulk undo / unimport (use per-chat delete)
+- Progress streaming via WS events (single ack is sufficient for v1)
+- Toast-based progress UI (using `confirm`/`alert` for v1; switch to in-app toasts if the codebase adds them)
+
+## Skills referenced
+
+- `superpowers:executing-plans` — to run this plan task-by-task
+- `superpowers:subagent-driven-development` — if executing with fresh subagents per task
+- `superpowers:test-driven-development` — each task follows red-green-commit
+- `superpowers:verification-before-completion` — Task 11 gates completion on browser verification
diff --git a/docs/plans/2026-04-20-slash-command-picker-design.md b/docs/plans/2026-04-20-slash-command-picker-design.md
new file mode 100644
index 000000000..8492ebaf0
--- /dev/null
+++ b/docs/plans/2026-04-20-slash-command-picker-design.md
@@ -0,0 +1,193 @@
+# Slash Command Picker Design
+
+**Date:** 2026-04-20
+**Scope:** Claude Code-style `/` command picker in Kanna chat input for the Claude provider.
+
+## Goal
+
+When the user types `/` in the chat input, show a popup picker listing every slash command the active Claude session exposes — built-ins (`/help`, `/clear`, `/compact`, `/model`, `/init`, `/review`, ...), user-custom (`~/.claude/commands/*.md`), project-custom (`.claude/commands/*.md`), plugin commands, and MCP commands. Match Claude Code TUI behavior: filter as the user types, arrow keys navigate, Enter selects, the full `/name [args]` string is sent to the agent on submit.
+
+## Non-Goals (v1)
+
+- Codex provider support. `/` types literal when Codex is the active provider.
+- Hot-reload of newly authored `.md` command files mid-session.
+- Kanna-side intercept of `/clear`, `/model`, `/compact`, etc. The SDK owns dispatch.
+- Argument preview UI richer than the `argumentHint` hint string.
+- Multi-step sub-pickers (model list, agent list). The SDK owns these.
+- Command execution history or "recents".
+
+## Data Source
+
+The Claude Agent SDK exposes `Query.supportedCommands(): Promise` where
+
+```ts
+type SlashCommand = {
+ name: string // without leading slash
+ description: string
+ argumentHint: string // e.g. ""
+}
+```
+
+This single call returns the full unified list across all sources. No filesystem scan.
+
+## Architecture
+
+### Lifecycle
+
+1. `AgentCoordinator` creates a Claude session via `query({...})` (existing, `src/server/agent.ts:614`).
+2. After the query object is created, the harness calls `q.supportedCommands()`.
+3. Result is emitted as a new `SessionCommandsLoadedEvent` and appended to `turns.jsonl`.
+4. `ReadModels` attach `slashCommands: SlashCommand[]` to the chat snapshot.
+5. Client receives the snapshot over the existing WS subscription and writes it into a Zustand store.
+6. `ChatInput` reads from the store via `useSlashCommands(chatId)` and drives the picker.
+
+### Execution
+
+- User selects a command → input becomes `/ ` (trailing space only when `argumentHint` is non-empty).
+- User presses Enter → existing send path. The full string (`/review pr-123`) is forwarded verbatim to `sendPrompt()` → SDK dispatches it.
+- Local-output commands return `SDKLocalCommandOutputMessage` with `subtype: "local_command_output"`. Rendered as assistant-style text in the transcript. Confirm Kanna's transcript hydrator handles this subtype; add a small case if not.
+
+## Server Changes
+
+### `src/server/events.ts`
+
+```ts
+export type SessionCommandsLoadedEvent = {
+ type: "session.commands_loaded"
+ chatId: string
+ sessionId: string
+ commands: Array<{ name: string; description: string; argumentHint: string }>
+ timestamp: number
+}
+```
+
+Appended to the existing `turns.jsonl` (no new event file).
+
+### `src/server/agent.ts`
+
+- Extend the Claude harness return type with `getSupportedCommands: () => Promise`.
+- Implementation: `async () => { try { return await q.supportedCommands() } catch (e) { log.warn(...); return [] } }`.
+
+### `AgentCoordinator`
+
+- On Claude session start: await `getSupportedCommands()`, emit `SessionCommandsLoadedEvent`.
+- On resume: refetch after the SDK reports the resumed session is ready; emit a fresh event so plugin/command changes between runs are reflected.
+- Codex provider: skip (v1 scope).
+
+### `src/server/read-models.ts`
+
+- Extend the chat snapshot with `slashCommands: SlashCommand[]`.
+- Replay collapses multiple `SessionCommandsLoadedEvent`s to the most recent per `chatId`.
+- Snapshot compaction stores the latest list in `snapshot.json`. No growth concern.
+
+### `src/shared/types.ts`
+
+```ts
+export type SlashCommand = {
+ name: string
+ description: string
+ argumentHint: string
+}
+```
+
+Mirror the SDK type locally so the client bundle does not pull the SDK.
+
+### `src/shared/protocol.ts`
+
+No new WS message type. The list rides on the existing chat snapshot broadcast.
+
+## Client Changes
+
+### Zustand store — `src/client/stores/slash-commands.ts`
+
+```ts
+type State = {
+ byChatId: Record
+ setForChat: (chatId: string, cmds: SlashCommand[]) => void
+ clear: (chatId: string) => void
+}
+```
+
+The socket snapshot handler calls `setForChat(chatId, snapshot.slashCommands ?? [])` on every push.
+
+### Hook — `src/client/hooks/useSlashCommands.ts`
+
+```ts
+export function useSlashCommands(chatId: string): SlashCommand[]
+```
+
+Returns cached list or `[]`. Stable reference via selector equality.
+
+### Filter util — `src/client/lib/slash-commands.ts`
+
+```ts
+export function shouldShowPicker(
+ value: string,
+ caret: number,
+): { open: boolean; query: string }
+
+export function filterCommands(
+ list: SlashCommand[],
+ query: string,
+): SlashCommand[]
+```
+
+- `shouldShowPicker`: regex `^\/(\S*)$` on the substring from start to caret. Open when it matches and caret is inside the first token.
+- `filterCommands`: case-insensitive match on `name`. Rank prefix matches first, then substring, then alphabetical.
+
+### Picker component — `src/client/components/chat-ui/SlashCommandPicker.tsx`
+
+Mounted as a child of `ChatInput.tsx`, positioned absolutely above the textarea.
+
+**Row layout**
+
+```
+/name description (muted, truncated)
+```
+
+The highlighted row gets `bg-accent` and shows the full description when space allows.
+
+**Behavior**
+
+| Key | Action |
+|-----|--------|
+| `↑` / `↓` | move selection |
+| `Enter` / `Tab` | accept → insert `/[ ]` |
+| `Esc` | close picker, keep input |
+| any printable | passthrough, filter updates |
+
+- Cap visible rows at 8, scrollable.
+- Empty state: a non-selectable "No matching commands" row.
+- Accept: replaces the `/` span at the caret with `/` (+ trailing space if `argumentHint` is non-empty), caret moves to end, picker closes. It reopens only if the user deletes back into the `/token`.
+
+### `ChatInput.tsx`
+
+- New local state: `pickerOpen`, `pickerQuery`, `pickerIndex`.
+- Derive `pickerOpen` and `pickerQuery` from `shouldShowPicker(value, caret)` on every change.
+- Memoize filtered list.
+- Intercept `↑ ↓ Enter Tab Esc` in `onKeyDown` when `pickerOpen`. Otherwise the existing send logic runs.
+- Short-circuit render: if `list.length === 0 && query === ""`, do not mount the picker (avoid flash).
+
+## Tests
+
+- `src/client/lib/slash-commands.test.ts` — `shouldShowPicker`, `filterCommands` pure unit coverage.
+- `src/client/components/chat-ui/ChatInput.test.ts` — picker open on `/`, filter as typed, arrow navigation, Enter/Tab insertion, Esc dismiss, caret placement.
+- Server-side agent test — mock `query.supportedCommands`, assert event emitted, harness returns list, errors degrade to `[]`.
+- Read-model test — replay two `SessionCommandsLoadedEvent`s, snapshot reflects the latest.
+
+## Rollout Steps
+
+1. SDK probe + shared `SlashCommand` type + `SessionCommandsLoadedEvent`.
+2. Agent harness method + coordinator emit on session start and resume.
+3. Read-model extension + snapshot wiring.
+4. Zustand store + hook + socket handler populates store.
+5. `SlashCommandPicker` component + `ChatInput` integration + filter util.
+6. Unit tests + manual verification in `bun run dev`.
+
+## Risks and Follow-Ups
+
+- **`/model`, `/clear`, `/compact` output UX.** SDK may respond with text only. If the result is poor, v1.1 can intercept these client-side and trigger Kanna's existing model picker / transcript clear.
+- **`supportedCommands()` latency.** If the call is slow, the very first `/` press after a session start shows an empty picker briefly. Acceptable; eager fetch is issued immediately after query creation.
+- **Plugin / MCP invalidation mid-session.** Stale list until session restart. Acceptable v1.
+- **Resume freshness.** Refetching after resume is a cheap extra call and keeps the list current when plugins change between runs.
+- **Provider inconsistency.** Codex users see no picker. Picker short-circuits when the active provider is Codex so `/` types literal.
diff --git a/docs/plans/2026-04-20-slash-command-picker.md b/docs/plans/2026-04-20-slash-command-picker.md
new file mode 100644
index 000000000..53c8e879c
--- /dev/null
+++ b/docs/plans/2026-04-20-slash-command-picker.md
@@ -0,0 +1,1036 @@
+# Slash Command Picker Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Add a Claude Code-style `/` command picker to Kanna's chat input for Claude sessions. When the user types `/`, show a popup listing every SDK-reported slash command (built-ins + user + project + plugin + MCP). Arrow/Enter selects, text filters, Enter submits `/name args...` to the existing send path.
+
+**Architecture:** Server queries `@anthropic-ai/claude-agent-sdk` via `query.supportedCommands()` after session start, emits a new `session.commands_loaded` turn event, `ReadModels` attaches the latest list to the chat snapshot. Client caches in a Zustand store (populated from snapshot), reads via hook, renders a new `SlashCommandPicker` component mounted from `ChatInput.tsx`. Execution unchanged — SDK dispatches commands that arrive as prompts.
+
+**Tech Stack:** TypeScript, Bun, React 19, Zustand, Vitest/Bun tests, Tailwind, `@anthropic-ai/claude-agent-sdk`.
+
+**Worktree:** `/Users/cuongtran/Desktop/repo/kanna/.worktrees/slash-command-picker` (branch `feature/slash-command-picker`). Baseline `bun run check` passes (see `7a22349`).
+
+**Design reference:** `docs/plans/2026-04-20-slash-command-picker-design.md`.
+
+---
+
+## Task 1 — Shared `SlashCommand` type
+
+**Files:**
+- Modify: `src/shared/types.ts` (append near `ChatSnapshot` definition around line 872)
+
+**Step 1: Add type**
+
+In `src/shared/types.ts`, append:
+
+```ts
+export interface SlashCommand {
+ name: string
+ description: string
+ argumentHint: string
+}
+```
+
+Then extend `ChatSnapshot`:
+
+```ts
+export interface ChatSnapshot {
+ runtime: ChatRuntime
+ queuedMessages: QueuedChatMessage[]
+ messages: TranscriptEntry[]
+ history: ChatHistorySnapshot
+ availableProviders: ProviderCatalogEntry[]
+ slashCommands: SlashCommand[]
+}
+```
+
+**Step 2: Run typecheck**
+
+Run: `bun run check`
+Expected: FAIL — downstream consumers of `ChatSnapshot` missing new field.
+
+**Step 3: Add empty default at every construction site**
+
+The one known construction site is `deriveChatSnapshot` in `src/server/read-models.ts:178-188`. Add `slashCommands: []` to the returned object. Leave any other compile errors for Task 4.
+
+**Step 4: Re-run check**
+
+Run: `bun run check`
+Expected: PASS (or pass if only `read-models.ts` was broken — if new errors exist, fix them with `slashCommands: []` stub, no logic).
+
+**Step 5: Commit**
+
+```bash
+git add src/shared/types.ts src/server/read-models.ts
+git commit -m "feat(types): add SlashCommand type and ChatSnapshot.slashCommands"
+```
+
+---
+
+## Task 2 — `session.commands_loaded` event type
+
+**Files:**
+- Modify: `src/server/events.ts` (extend `TurnEvent` union near line 136-168)
+
+**Step 1: Extend `TurnEvent`**
+
+Add a new branch to the `TurnEvent` discriminated union in `src/server/events.ts`:
+
+```ts
+ | {
+ v: 2
+ type: "session_commands_loaded"
+ timestamp: number
+ chatId: string
+ commands: Array<{ name: string; description: string; argumentHint: string }>
+ }
+```
+
+**Step 2: Extend `ChatRecord`**
+
+Add an optional `slashCommands?: SlashCommand[]` field to `ChatRecord` (line 7). Import `SlashCommand` from `../shared/types`.
+
+**Step 3: Run typecheck**
+
+Run: `bun run check`
+Expected: PASS (new fields are additive, not referenced anywhere yet).
+
+**Step 4: Commit**
+
+```bash
+git add src/server/events.ts
+git commit -m "feat(events): add session_commands_loaded turn event"
+```
+
+---
+
+## Task 3 — `EventStore.recordSessionCommandsLoaded`
+
+**Files:**
+- Modify: `src/server/event-store.ts` (add method next to other `recordTurn*` methods around line 765-820)
+
+**Step 1: Locate reducer**
+
+Use LSP `workspace-symbols` or Grep for `case "turn_started":` in `src/server/event-store.ts` to find where `TurnEvent` is applied to state during replay. Note the file and function.
+
+**Step 2: Write failing test**
+
+Create `src/server/event-store.test.ts` (or add to existing test file if present — check first with `ls src/server/*.test.ts`). Add:
+
+```ts
+import { describe, expect, test, beforeEach, afterEach } from "bun:test"
+import { mkdtempSync, rmSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { EventStore } from "./event-store"
+
+describe("EventStore.recordSessionCommandsLoaded", () => {
+ let dir: string
+ let store: EventStore
+ beforeEach(async () => {
+ dir = mkdtempSync(join(tmpdir(), "kanna-es-"))
+ store = new EventStore({ dataDir: dir })
+ await store.load()
+ await store.recordProjectOpened({ projectId: "p1", localPath: "/tmp/x", title: "x" })
+ await store.recordChatCreated({ chatId: "c1", projectId: "p1", title: "chat" })
+ })
+ afterEach(() => rmSync(dir, { recursive: true, force: true }))
+
+ test("stores latest commands on chat record", async () => {
+ await store.recordSessionCommandsLoaded("c1", [
+ { name: "review", description: "Review PR", argumentHint: "" },
+ ])
+ expect(store.getChat("c1")?.slashCommands).toEqual([
+ { name: "review", description: "Review PR", argumentHint: "" },
+ ])
+ })
+
+ test("replaces commands on subsequent load", async () => {
+ await store.recordSessionCommandsLoaded("c1", [{ name: "a", description: "", argumentHint: "" }])
+ await store.recordSessionCommandsLoaded("c1", [{ name: "b", description: "", argumentHint: "" }])
+ expect(store.getChat("c1")?.slashCommands).toEqual([
+ { name: "b", description: "", argumentHint: "" },
+ ])
+ })
+})
+```
+
+(If existing tests use a different helper for store setup, copy that pattern instead. Check `src/server/event-store.test.ts` first.)
+
+**Step 3: Run failing test**
+
+Run: `bun test src/server/event-store.test.ts`
+Expected: FAIL — `recordSessionCommandsLoaded is not a function`.
+
+**Step 4: Implement**
+
+In `src/server/event-store.ts`, add a method next to the other `recordTurn*` methods:
+
+```ts
+async recordSessionCommandsLoaded(chatId: string, commands: SlashCommand[]) {
+ this.requireChat(chatId)
+ const event: TurnEvent = {
+ v: STORE_VERSION,
+ type: "session_commands_loaded",
+ timestamp: Date.now(),
+ chatId,
+ commands: commands.map((c) => ({ name: c.name, description: c.description, argumentHint: c.argumentHint })),
+ }
+ await this.append(this.turnsLogPath, event)
+}
+```
+
+Add `import type { SlashCommand } from "../shared/types"` at the top if missing.
+
+Locate the `TurnEvent` reducer (found in Step 1) and add a case:
+
+```ts
+case "session_commands_loaded": {
+ const chat = state.chatsById.get(event.chatId)
+ if (!chat) return
+ chat.slashCommands = event.commands.map((c) => ({ ...c }))
+ return
+}
+```
+
+**Step 5: Run test**
+
+Run: `bun test src/server/event-store.test.ts`
+Expected: PASS (both cases).
+
+**Step 6: Commit**
+
+```bash
+git add src/server/event-store.ts src/server/event-store.test.ts
+git commit -m "feat(event-store): record session_commands_loaded events"
+```
+
+---
+
+## Task 4 — Expose `supportedCommands` on the Claude harness
+
+**Files:**
+- Modify: `src/server/agent.ts` (`ClaudeSessionHandle` interface at line 73-82, `startClaudeSession` return around line 629-661)
+
+**Step 1: Extend the handle interface**
+
+In `src/server/agent.ts`, add a method to `ClaudeSessionHandle`:
+
+```ts
+getSupportedCommands: () => Promise>
+```
+
+Also add it to the type alias in `AgentCoordinatorArgs.startClaudeSession` (line 103-110) so tests can inject a mock.
+
+**Step 2: Implement in `startClaudeSession`**
+
+In the returned object at `src/server/agent.ts:629-661`, add:
+
+```ts
+getSupportedCommands: async () => {
+ try {
+ return await q.supportedCommands()
+ } catch (error) {
+ console.warn("[kanna/claude] supportedCommands failed", error)
+ return []
+ }
+},
+```
+
+**Step 3: Run typecheck**
+
+Run: `bun run check`
+Expected: PASS.
+
+**Step 4: Commit**
+
+```bash
+git add src/server/agent.ts
+git commit -m "feat(agent): expose getSupportedCommands on Claude harness"
+```
+
+---
+
+## Task 5 — Coordinator emits `session_commands_loaded` on Claude session start
+
+**Files:**
+- Modify: `src/server/agent.ts` (`ensureClaudeSession` block around line 1048-1079)
+
+**Step 1: Write failing test**
+
+Add or extend a coordinator test. If no suitable file exists, create `src/server/agent.test.ts`:
+
+```ts
+import { describe, test, expect } from "bun:test"
+import { AgentCoordinator } from "./agent"
+// plus whatever the existing agent tests use for setup
+
+test("emits session_commands_loaded after starting a fresh Claude session", async () => {
+ // 1. Construct coordinator with an in-memory EventStore and a fake
+ // startClaudeSession that returns getSupportedCommands resolving to
+ // [{ name: "review", description: "Review", argumentHint: "" }].
+ // 2. Trigger a send that starts a Claude session.
+ // 3. Assert eventStore.getChat(chatId).slashCommands === the fake list.
+})
+```
+
+(Look at existing tests in `src/server/` or `src/client/` for the EventStore fixture pattern. Mirror it.)
+
+**Step 2: Run failing test**
+
+Run: `bun test src/server/agent.test.ts`
+Expected: FAIL — `slashCommands` empty / undefined.
+
+**Step 3: Wire emission after session start**
+
+In `ensureClaudeSession` at `src/server/agent.ts:1048-1079`, after `this.claudeSessions.set(args.chatId, session)` and `void this.runClaudeSession(session)`, add:
+
+```ts
+void (async () => {
+ try {
+ const commands = await started.getSupportedCommands()
+ await this.store.recordSessionCommandsLoaded(args.chatId, commands)
+ this.onStateChange?.(args.chatId)
+ } catch (error) {
+ console.warn("[kanna/agent] failed to load slash commands", error)
+ }
+})()
+```
+
+`this.store` is the `EventStore` handle the coordinator already holds; if the private field is named differently, use that.
+
+**Step 4: Run test**
+
+Run: `bun test src/server/agent.test.ts`
+Expected: PASS.
+
+**Step 5: Commit**
+
+```bash
+git add src/server/agent.ts src/server/agent.test.ts
+git commit -m "feat(agent): emit session_commands_loaded on Claude session start"
+```
+
+---
+
+## Task 6 — Surface `slashCommands` on `ChatSnapshot`
+
+**Files:**
+- Modify: `src/server/read-models.ts` (`deriveChatSnapshot` at lines 152-188)
+
+**Step 1: Write failing test**
+
+Add to `src/server/read-models.test.ts` (or create it):
+
+```ts
+import { describe, expect, test } from "bun:test"
+import { deriveChatSnapshot } from "./read-models"
+import { createEmptyState } from "./events"
+
+test("chat snapshot exposes slashCommands from chat record", () => {
+ const state = createEmptyState()
+ state.projectsById.set("p1", {
+ id: "p1", localPath: "/tmp/x", title: "x",
+ createdAt: 0, updatedAt: 0,
+ } as any)
+ state.chatsById.set("c1", {
+ id: "c1", projectId: "p1", title: "Chat",
+ createdAt: 0, updatedAt: 0,
+ unread: false, provider: "claude", planMode: false,
+ sessionToken: null, sourceHash: null,
+ lastTurnOutcome: null,
+ slashCommands: [{ name: "review", description: "r", argumentHint: "" }],
+ } as any)
+
+ const snapshot = deriveChatSnapshot(
+ state,
+ new Map(),
+ new Set(),
+ "c1",
+ () => ({
+ messages: [],
+ history: { hasOlder: false, olderCursor: null, recentLimit: 20 },
+ }),
+ )
+ expect(snapshot?.slashCommands).toEqual([
+ { name: "review", description: "r", argumentHint: "" },
+ ])
+})
+```
+
+**Step 2: Run failing test**
+
+Run: `bun test src/server/read-models.test.ts`
+Expected: FAIL — `slashCommands` is `[]` not the record's list.
+
+**Step 3: Implement**
+
+In `src/server/read-models.ts:178-188`, replace the returned `slashCommands: []` (added in Task 1) with:
+
+```ts
+slashCommands: (chat.slashCommands ?? []).map((c) => ({ ...c })),
+```
+
+**Step 4: Run test**
+
+Run: `bun test src/server/read-models.test.ts`
+Expected: PASS.
+
+**Step 5: Commit**
+
+```bash
+git add src/server/read-models.ts src/server/read-models.test.ts
+git commit -m "feat(read-models): expose slashCommands on ChatSnapshot"
+```
+
+---
+
+## Task 7 — Snapshot file persistence
+
+**Files:**
+- Modify: `src/server/event-store.ts` — search for `writeSnapshot`/`readSnapshot` and the `SnapshotFile` shape in `src/server/events.ts`.
+
+**Step 1: Extend `SnapshotFile`**
+
+In `src/server/events.ts`, extend `SnapshotFile.chats` persistence — not the type if it re-uses `ChatRecord`. If `chats: ChatRecord[]` is already the field, the new `slashCommands?` field (from Task 2) flows through automatically. Verify by reading the `writeSnapshot` path.
+
+**Step 2: Write a round-trip test**
+
+In `src/server/event-store.test.ts`, add:
+
+```ts
+test("compaction preserves slashCommands", async () => {
+ await store.recordSessionCommandsLoaded("c1", [
+ { name: "review", description: "r", argumentHint: "" },
+ ])
+ await store.compact() // or whatever the public API is — check file
+ const reloaded = new EventStore({ dataDir: dir })
+ await reloaded.load()
+ expect(reloaded.getChat("c1")?.slashCommands).toEqual([
+ { name: "review", description: "r", argumentHint: "" },
+ ])
+})
+```
+
+**Step 3: Run test**
+
+Run: `bun test src/server/event-store.test.ts`
+Expected: PASS if `ChatRecord` passes through unchanged. FAIL means snapshot serialization drops the field — fix by explicitly including `slashCommands` in whatever projection `writeSnapshot` uses.
+
+**Step 4: Commit (if changes were needed)**
+
+```bash
+git add src/server/event-store.ts src/server/events.ts src/server/event-store.test.ts
+git commit -m "feat(event-store): persist slashCommands across compaction"
+```
+
+If no changes were needed, skip the commit and note that in the PR description.
+
+---
+
+## Task 8 — Client slash-commands store
+
+**Files:**
+- Create: `src/client/stores/slashCommandsStore.ts`
+- Test: `src/client/stores/slashCommandsStore.test.ts`
+
+**Step 1: Write failing test**
+
+```ts
+import { describe, test, expect, beforeEach } from "bun:test"
+import { useSlashCommandsStore } from "./slashCommandsStore"
+
+describe("slashCommandsStore", () => {
+ beforeEach(() => useSlashCommandsStore.setState({ byChatId: {} }))
+
+ test("setForChat stores list", () => {
+ useSlashCommandsStore.getState().setForChat("c1", [
+ { name: "review", description: "r", argumentHint: "" },
+ ])
+ expect(useSlashCommandsStore.getState().byChatId["c1"]).toHaveLength(1)
+ })
+
+ test("clear removes list", () => {
+ useSlashCommandsStore.getState().setForChat("c1", [
+ { name: "review", description: "r", argumentHint: "" },
+ ])
+ useSlashCommandsStore.getState().clear("c1")
+ expect(useSlashCommandsStore.getState().byChatId["c1"]).toBeUndefined()
+ })
+})
+```
+
+**Step 2: Run failing test**
+
+Run: `bun test src/client/stores/slashCommandsStore.test.ts`
+Expected: FAIL — store file does not exist.
+
+**Step 3: Implement**
+
+```ts
+import { create } from "zustand"
+import type { SlashCommand } from "../../shared/types"
+
+interface State {
+ byChatId: Record
+ setForChat: (chatId: string, commands: SlashCommand[]) => void
+ clear: (chatId: string) => void
+}
+
+export const useSlashCommandsStore = create((set) => ({
+ byChatId: {},
+ setForChat: (chatId, commands) =>
+ set((state) => ({ byChatId: { ...state.byChatId, [chatId]: commands } })),
+ clear: (chatId) =>
+ set((state) => {
+ const { [chatId]: _removed, ...rest } = state.byChatId
+ return { byChatId: rest }
+ }),
+}))
+```
+
+**Step 4: Run test**
+
+Run: `bun test src/client/stores/slashCommandsStore.test.ts`
+Expected: PASS.
+
+**Step 5: Commit**
+
+```bash
+git add src/client/stores/slashCommandsStore.ts src/client/stores/slashCommandsStore.test.ts
+git commit -m "feat(client): add slash commands store"
+```
+
+---
+
+## Task 9 — Populate the store from the chat snapshot
+
+**Files:**
+- Modify: `src/client/app/useKannaState.ts` (subscribe handler around line 789-821)
+
+**Step 1: Wire store update**
+
+Inside the `socket.subscribe(...)` callback at line 789, add after `setChatReady(true)`:
+
+```ts
+if (snapshot) {
+ useSlashCommandsStore.getState().setForChat(
+ snapshot.runtime.chatId,
+ snapshot.slashCommands ?? [],
+ )
+}
+```
+
+Add the import at the top:
+
+```ts
+import { useSlashCommandsStore } from "../stores/slashCommandsStore"
+```
+
+**Step 2: Typecheck**
+
+Run: `bun run check`
+Expected: PASS.
+
+**Step 3: Commit**
+
+```bash
+git add src/client/app/useKannaState.ts
+git commit -m "feat(client): populate slash commands store from snapshot"
+```
+
+---
+
+## Task 10 — `useSlashCommands` hook
+
+**Files:**
+- Create: `src/client/hooks/useSlashCommands.ts`
+- Test: `src/client/hooks/useSlashCommands.test.ts`
+
+**Step 1: Write failing test**
+
+```ts
+import { describe, test, expect } from "bun:test"
+import { renderHook, act } from "@testing-library/react"
+import { useSlashCommands } from "./useSlashCommands"
+import { useSlashCommandsStore } from "../stores/slashCommandsStore"
+
+test("returns commands for chat", () => {
+ act(() => useSlashCommandsStore.getState().setForChat("c1", [
+ { name: "review", description: "r", argumentHint: "" },
+ ]))
+ const { result } = renderHook(() => useSlashCommands("c1"))
+ expect(result.current).toHaveLength(1)
+})
+
+test("returns empty array for unknown chat", () => {
+ const { result } = renderHook(() => useSlashCommands("unknown"))
+ expect(result.current).toEqual([])
+})
+```
+
+(If `@testing-library/react` is not already a dep, test without the renderer: call `useSlashCommandsStore.getState()` directly through a small selector export and omit this test file — replace with a selector unit test.)
+
+**Step 2: Implement**
+
+```ts
+import { useSlashCommandsStore } from "../stores/slashCommandsStore"
+import type { SlashCommand } from "../../shared/types"
+
+const EMPTY: SlashCommand[] = []
+
+export function useSlashCommands(chatId: string | null): SlashCommand[] {
+ return useSlashCommandsStore((state) =>
+ chatId ? state.byChatId[chatId] ?? EMPTY : EMPTY,
+ )
+}
+```
+
+**Step 3: Run test**
+
+Run: `bun test src/client/hooks/useSlashCommands.test.ts`
+Expected: PASS.
+
+**Step 4: Commit**
+
+```bash
+git add src/client/hooks/useSlashCommands.ts src/client/hooks/useSlashCommands.test.ts
+git commit -m "feat(client): add useSlashCommands hook"
+```
+
+---
+
+## Task 11 — Pure filter / trigger utils
+
+**Files:**
+- Create: `src/client/lib/slash-commands.ts`
+- Test: `src/client/lib/slash-commands.test.ts`
+
+**Step 1: Write failing tests**
+
+```ts
+import { describe, test, expect } from "bun:test"
+import { shouldShowPicker, filterCommands } from "./slash-commands"
+
+describe("shouldShowPicker", () => {
+ test("opens when value starts with / and caret inside token", () => {
+ expect(shouldShowPicker("/rev", 4)).toEqual({ open: true, query: "rev" })
+ })
+ test("opens on bare slash", () => {
+ expect(shouldShowPicker("/", 1)).toEqual({ open: true, query: "" })
+ })
+ test("closes after space", () => {
+ expect(shouldShowPicker("/review ", 8)).toEqual({ open: false, query: "" })
+ })
+ test("closes when caret before slash", () => {
+ expect(shouldShowPicker("/rev", 0)).toEqual({ open: false, query: "" })
+ })
+ test("closes when first char not slash", () => {
+ expect(shouldShowPicker("hi /rev", 7)).toEqual({ open: false, query: "" })
+ })
+})
+
+describe("filterCommands", () => {
+ const all = [
+ { name: "review", description: "r", argumentHint: "" },
+ { name: "reset", description: "s", argumentHint: "" },
+ { name: "init", description: "i", argumentHint: "" },
+ ]
+ test("empty query returns all, alphabetical", () => {
+ expect(filterCommands(all, "").map((c) => c.name)).toEqual(["init", "reset", "review"])
+ })
+ test("prefix matches rank before substring", () => {
+ const list = [
+ { name: "unreview", description: "", argumentHint: "" },
+ { name: "review", description: "", argumentHint: "" },
+ ]
+ expect(filterCommands(list, "rev").map((c) => c.name)).toEqual(["review", "unreview"])
+ })
+ test("case-insensitive", () => {
+ expect(filterCommands(all, "REV").map((c) => c.name)).toEqual(["review"])
+ })
+})
+```
+
+**Step 2: Run failing tests**
+
+Run: `bun test src/client/lib/slash-commands.test.ts`
+Expected: FAIL — module not found.
+
+**Step 3: Implement**
+
+```ts
+import type { SlashCommand } from "../../shared/types"
+
+export function shouldShowPicker(
+ value: string,
+ caret: number,
+): { open: boolean; query: string } {
+ if (caret <= 0) return { open: false, query: "" }
+ const upToCaret = value.slice(0, caret)
+ const match = /^\/(\S*)$/.exec(upToCaret)
+ if (!match) return { open: false, query: "" }
+ return { open: true, query: match[1] ?? "" }
+}
+
+export function filterCommands(list: SlashCommand[], query: string): SlashCommand[] {
+ const q = query.toLowerCase()
+ const byName = (a: SlashCommand, b: SlashCommand) => a.name.localeCompare(b.name)
+ if (q === "") return [...list].sort(byName)
+
+ const prefix: SlashCommand[] = []
+ const substring: SlashCommand[] = []
+ for (const cmd of list) {
+ const name = cmd.name.toLowerCase()
+ if (name.startsWith(q)) prefix.push(cmd)
+ else if (name.includes(q)) substring.push(cmd)
+ }
+ return [...prefix.sort(byName), ...substring.sort(byName)]
+}
+```
+
+**Step 4: Run tests**
+
+Run: `bun test src/client/lib/slash-commands.test.ts`
+Expected: PASS (all cases).
+
+**Step 5: Commit**
+
+```bash
+git add src/client/lib/slash-commands.ts src/client/lib/slash-commands.test.ts
+git commit -m "feat(client): add slash command filter and picker-open utils"
+```
+
+---
+
+## Task 12 — `SlashCommandPicker` component
+
+**Files:**
+- Create: `src/client/components/chat-ui/SlashCommandPicker.tsx`
+- Test: `src/client/components/chat-ui/SlashCommandPicker.test.tsx` (only if existing chat-ui tests use `.tsx` React testing; otherwise defer to Task 13's integration tests)
+
+**Step 1: Implement the component**
+
+```tsx
+import { useEffect, useRef } from "react"
+import type { SlashCommand } from "../../../shared/types"
+import { cn } from "../../lib/utils"
+
+interface Props {
+ items: SlashCommand[]
+ activeIndex: number
+ onSelect: (command: SlashCommand) => void
+ onHoverIndex: (index: number) => void
+}
+
+export function SlashCommandPicker({ items, activeIndex, onSelect, onHoverIndex }: Props) {
+ const listRef = useRef(null)
+
+ useEffect(() => {
+ const el = listRef.current?.children.item(activeIndex) as HTMLElement | null
+ el?.scrollIntoView({ block: "nearest" })
+ }, [activeIndex])
+
+ if (items.length === 0) {
+ return (
+
+ No matching commands
+
+ )
+ }
+
+ return (
+
+ {items.map((cmd, i) => (
+ {
+ e.preventDefault()
+ onSelect(cmd)
+ }}
+ onMouseEnter={() => onHoverIndex(i)}
+ className={cn(
+ "flex items-baseline gap-2 px-3 py-1.5 cursor-pointer text-sm",
+ i === activeIndex && "bg-accent text-accent-foreground",
+ )}
+ >
+ /{cmd.name}
+ {cmd.argumentHint && (
+ {cmd.argumentHint}
+ )}
+ {cmd.description && (
+ {cmd.description}
+ )}
+
+ ))}
+
+ )
+}
+```
+
+**Step 2: Typecheck**
+
+Run: `bun run check`
+Expected: PASS.
+
+**Step 3: Commit**
+
+```bash
+git add src/client/components/chat-ui/SlashCommandPicker.tsx
+git commit -m "feat(client): add SlashCommandPicker component"
+```
+
+---
+
+## Task 13 — Wire picker into `ChatInput`
+
+**Files:**
+- Modify: `src/client/components/chat-ui/ChatInput.tsx` (keyboard handler at 555-586, render area around 725)
+- Test: extend `src/client/components/chat-ui/ChatInput.test.ts`
+
+**Step 1: Write failing tests**
+
+Extend `ChatInput.test.ts`:
+
+```ts
+// pseudocode — mirror the existing test style in that file
+test("typing / opens picker with full list", () => {
+ // render ChatInput with chatId="c1" and preload slash-commands store
+ // fire change to "/" and assert picker rows rendered
+})
+
+test("typing /rev filters", () => {
+ // preload list with review, init; type "/rev"; assert only review shown
+})
+
+test("Enter accepts highlighted command", () => {
+ // preload list, type "/", press Enter → input becomes "/review "
+ // (trailing space since argumentHint is non-empty)
+})
+
+test("Escape closes picker without clearing input", () => {
+ // preload list, type "/rev", press Escape → picker gone, value still "/rev"
+})
+
+test("picker does not intercept Enter when closed", () => {
+ // type "hi", press Enter → onSubmit called
+})
+```
+
+Use whatever render helper the existing tests in this file use. If the file is vanilla DOM assertions without React rendering, mirror that approach instead.
+
+**Step 2: Run failing tests**
+
+Run: `bun test src/client/components/chat-ui/ChatInput.test.ts`
+Expected: FAIL.
+
+**Step 3: Hook state into `ChatInput`**
+
+At the top of the `ChatInput` component body, add:
+
+```tsx
+const slashCommands = useSlashCommands(chatId ?? null)
+const [pickerIndex, setPickerIndex] = useState(0)
+const textareaRef = useRef(null) // reuse existing
+const caret = textareaRef.current?.selectionStart ?? value.length
+
+const pickerState = useMemo(
+ () => shouldShowPicker(value, caret),
+ [value, caret],
+)
+const filteredCommands = useMemo(
+ () => (pickerState.open ? filterCommands(slashCommands, pickerState.query) : []),
+ [pickerState.open, pickerState.query, slashCommands],
+)
+const pickerOpen = pickerState.open && slashCommands.length > 0
+
+useEffect(() => {
+ if (pickerOpen) setPickerIndex(0)
+}, [pickerOpen, pickerState.query])
+```
+
+Imports:
+
+```tsx
+import { useSlashCommands } from "../../hooks/useSlashCommands"
+import { SlashCommandPicker } from "./SlashCommandPicker"
+import { filterCommands, shouldShowPicker } from "../../lib/slash-commands"
+```
+
+**Step 4: Intercept keyboard in `handleKeyDown`**
+
+Place at the very top of `handleKeyDown` (before the existing `Tab` handling):
+
+```tsx
+if (pickerOpen) {
+ if (event.key === "Escape") {
+ event.preventDefault()
+ // close by forcing caret past the token — simpler: clear filtered list via a local `dismissed` flag.
+ // Use a ref-based suppress: setPickerDismissed(true) until value changes.
+ setPickerDismissed(true)
+ return
+ }
+ if (event.key === "ArrowDown") {
+ event.preventDefault()
+ setPickerIndex((i) => Math.min(filteredCommands.length - 1, i + 1))
+ return
+ }
+ if (event.key === "ArrowUp") {
+ event.preventDefault()
+ setPickerIndex((i) => Math.max(0, i - 1))
+ return
+ }
+ if (event.key === "Enter" || event.key === "Tab") {
+ event.preventDefault()
+ const cmd = filteredCommands[pickerIndex]
+ if (cmd) acceptCommand(cmd)
+ return
+ }
+}
+```
+
+Add supporting state + effect + accept helper above `handleKeyDown`:
+
+```tsx
+const [pickerDismissed, setPickerDismissed] = useState(false)
+useEffect(() => { setPickerDismissed(false) }, [value])
+
+function acceptCommand(cmd: SlashCommand) {
+ const prefix = `/${cmd.name}`
+ const next = cmd.argumentHint ? `${prefix} ` : prefix
+ setValue(next)
+ if (chatId) setDraft(chatId, next)
+ requestAnimationFrame(() => {
+ textareaRef.current?.focus()
+ textareaRef.current?.setSelectionRange(next.length, next.length)
+ })
+}
+```
+
+Update `pickerOpen` to also respect `pickerDismissed`:
+
+```tsx
+const pickerOpen = pickerState.open && slashCommands.length > 0 && !pickerDismissed
+```
+
+**Step 5: Render the picker**
+
+Near the textarea container (find the existing wrapper around line 725 where the textarea is rendered; it already has `onKeyDown={handleKeyDown}`), wrap it in a relative-positioned container if not already, and render:
+
+```tsx
+{pickerOpen && (
+
+)}
+```
+
+Place it as a sibling of the textarea inside the relative wrapper so it floats above with `absolute bottom-full`.
+
+**Step 6: Run tests**
+
+Run: `bun test src/client/components/chat-ui/ChatInput.test.ts`
+Expected: PASS.
+
+**Step 7: Typecheck + build**
+
+Run: `bun run check`
+Expected: PASS.
+
+**Step 8: Commit**
+
+```bash
+git add src/client/components/chat-ui/ChatInput.tsx src/client/components/chat-ui/ChatInput.test.ts
+git commit -m "feat(chat-ui): wire slash command picker into ChatInput"
+```
+
+---
+
+## Task 14 — Manual verification
+
+**Step 1: Start dev server**
+
+```bash
+bun run dev
+```
+
+**Step 2: Verify in browser**
+
+- Open a Claude chat, wait for session start.
+- Type `/` in the input — picker appears with the session's commands.
+- Type `rev` — filters to `/review` (or whichever commands have `rev`).
+- `↓ ↑` navigate, `Enter` inserts `/review ` (with trailing space since `argumentHint` exists).
+- Press `Enter` with no picker open on non-slash input — sends normally.
+- `Esc` while picker open — picker closes, input preserved.
+- Switch to a Codex chat — typing `/` does not open a picker.
+
+**Step 3: Stop dev server**
+
+`Ctrl+C`.
+
+**Step 4: If any step fails**
+
+Open a debugging session with `superpowers:systematic-debugging`. Do not skip.
+
+---
+
+## Task 15 — Refetch on resume
+
+**Files:**
+- Modify: `src/server/agent.ts` — wherever a resumed session becomes active after `sessionToken` is set.
+
+**Step 1: Locate the resume flow**
+
+Grep for `sessionToken` usage in `startClaudeSession` and the coordinator. Resume happens when `query({ resume: sessionToken })` is used.
+
+**Step 2: Emit a fresh load**
+
+Wherever the coordinator transitions from "starting" → "ready" for a resumed session (where the old `supportedCommands()` result may be stale), call `getSupportedCommands()` again and `recordSessionCommandsLoaded`.
+
+If the existing eager emission in Task 5 is already *after* session construction for both new and resumed sessions, this task is a no-op — verify by reading the code path and note it in the commit message.
+
+**Step 3: Commit (if changes were needed)**
+
+```bash
+git add src/server/agent.ts
+git commit -m "feat(agent): refetch supported commands on session resume"
+```
+
+---
+
+## Task 16 — Final verification + PR prep
+
+**Step 1: Full check**
+
+```bash
+bun run check
+bun test
+```
+
+Both: PASS.
+
+**Step 2: Commit any incidental formatting**
+
+Only if files changed (e.g. Prettier on save). Otherwise skip.
+
+**Step 3: Report completion**
+
+Announce: worktree at `.worktrees/slash-command-picker`, branch `feature/slash-command-picker`, all tasks complete, tests green. Offer to run `superpowers:finishing-a-development-branch` for merge / PR path.
+
+---
+
+## Skills to consult
+
+- `superpowers:test-driven-development` — always for every task that touches logic.
+- `superpowers:systematic-debugging` — if anything misbehaves in manual verification.
+- `superpowers:verification-before-completion` — before announcing Task 16 done.
+- `superpowers:finishing-a-development-branch` — after Task 16.
diff --git a/docs/plans/2026-04-21-pm2-update-reloader-design.md b/docs/plans/2026-04-21-pm2-update-reloader-design.md
new file mode 100644
index 000000000..25e33d3fb
--- /dev/null
+++ b/docs/plans/2026-04-21-pm2-update-reloader-design.md
@@ -0,0 +1,151 @@
+# pm2 Update Reloader Design
+
+Date: 2026-04-21
+Scope: dev-only deploy workflow on macOS.
+
+## Goals
+
+1. Replace the launchd job (`io.silentium.kanna`) used by `scripts/deploy.sh` with pm2 as the process supervisor for the author's local dev machine.
+2. Keep the in-app "Update" button working, but wire it to a pm2 reload pipeline (git pull → build → `pm2 reload`) when running under pm2.
+3. Abstract the update path so the reload mechanism can be swapped without touching `UpdateManager`.
+
+End-user install flow (`bunx kanna`, `bun install -g kanna-code`) is unchanged. The existing supervisor-fork path in `bin/kanna` + `cli-supervisor.ts` remains the default.
+
+## Non-goals
+
+- Shipping pm2 as a runtime dependency for end users.
+- Daemon mode / background process for end users.
+- Git-based update flow for end users (they stay on npm-registry self-update).
+- Auto-rollback on failed build.
+
+## Current state
+
+- `bin/kanna` forks `cli-supervisor.ts` (parent) → `cli.ts` (child).
+- Supervisor restarts child on exit code 75 (startup self-update) or 76 (UI-triggered update).
+- `update-manager.ts` drives the UI: checks npm registry via `fetchLatestVersion`, installs via `installVersion` (`bun install -g kanna-code@`), then child exits 76 → supervisor respawns.
+- `scripts/deploy.sh` symlinks the global install to the repo, runs `bun run build`, then `launchctl kickstart -k gui//io.silentium.kanna` to restart the launchd job.
+
+## Architecture
+
+### New interfaces — `src/server/update-strategy.ts`
+
+```ts
+export interface UpdateChecker {
+ check(): Promise<{ latestVersion: string | null; updateAvailable: boolean }>
+}
+
+export interface UpdateReloader {
+ reload(): Promise
+}
+```
+
+### Implementations
+
+| Impl | Purpose |
+|------|---------|
+| `NpmChecker` | Wraps `fetchLatestPackageVersion` + `compareVersions`. Default. |
+| `GitChecker` | `git fetch origin main` then compares `git rev-parse HEAD` vs `origin/main`. `latestVersion` = short SHA. |
+| `SupervisorExitReloader` | Runs current `installPackageVersion` then `process.exit(CLI_UI_UPDATE_RESTART_EXIT_CODE)`. |
+| `Pm2Reloader` | git pull → conditional `bun install` → `bun run build` → `pm2.reload("kanna")`. Fail-fast, throws on any non-zero step. |
+
+### Selection
+
+Factory `createUpdateStrategy()` reads `KANNA_RELOADER`:
+
+- unset / `"supervisor"` → `{ checker: NpmChecker, reloader: SupervisorExitReloader }` (default, unchanged behavior).
+- `"pm2"` → `{ checker: GitChecker, reloader: Pm2Reloader }`.
+- anything else → throw at startup.
+
+`Pm2Reloader` reads `KANNA_REPO_DIR` (set by `deploy.sh`) to resolve the working directory for git/build commands.
+
+### UpdateManager changes
+
+`UpdateManagerDeps` swaps `fetchLatestVersion` + `installVersion` for `checker: UpdateChecker` + `reloader: UpdateReloader`. `checkForUpdates()` delegates to `checker.check()`. `installUpdate()` delegates to `reloader.reload()` and surfaces thrown errors via `UpdateSnapshot.error` + `install_failed` error code. Existing devMode, concurrent-install, caching, and listener semantics preserved.
+
+Wiring in `cli.ts`: call `createUpdateStrategy()` where UpdateManager is constructed today; pass `checker` and `reloader` into `new UpdateManager(...)`.
+
+### pm2 reload internals
+
+Uses the `pm2` npm package programmatic API:
+
+```ts
+import pm2 from "pm2"
+await new Promise((resolve, reject) => {
+ pm2.connect((err) => {
+ if (err) return reject(err)
+ pm2.reload("kanna", (reloadErr) => {
+ pm2.disconnect()
+ reloadErr ? reject(reloadErr) : resolve()
+ })
+ })
+})
+```
+
+Shell steps (`git pull`, `bun install`, `bun run build`) run via `spawn` with stdio captured. On non-zero exit the reloader throws `Error` with `" failed: "`.
+
+## pm2 config — `scripts/pm2.config.cjs.tmpl`
+
+Template rendered by `deploy.sh` (envsubst) to produce `scripts/pm2.config.cjs`:
+
+```js
+module.exports = {
+ apps: [{
+ name: "kanna",
+ script: "./src/server/cli.ts",
+ interpreter: "bun",
+ cwd: "${REPO_DIR}",
+ env: {
+ KANNA_RELOADER: "pm2",
+ KANNA_REPO_DIR: "${REPO_DIR}",
+ KANNA_DISABLE_SELF_UPDATE: "1",
+ KANNA_CLI_MODE: "child",
+ },
+ autorestart: true,
+ max_memory_restart: "1G",
+ kill_timeout: 5000,
+ }]
+}
+```
+
+`KANNA_CLI_MODE=child` makes `bin/kanna` skip the supervisor branch — pm2 is the supervisor.
+
+## `scripts/deploy.sh`
+
+- Keep: symlink `$HOME/.bun/install/global/node_modules/kanna-code` → `$REPO_DIR`; `bun install` if lockfile changed; `bun run build`.
+- Replace launchd block with: pm2 install check → render pm2 config from template → `pm2 reload` if process exists, else `pm2 start` → `pm2 save`.
+- One-shot by hand (not scripted): `launchctl bootout gui/$(id -u)/io.silentium.kanna` to remove the old launchd job; `pm2 startup` to register pm2 itself for boot.
+
+## Error handling
+
+Fail-fast pipeline (Q9 option A): any step failure aborts, surfaces stderr tail in `UpdateSnapshot.error`, pm2 keeps running the old build. No auto-rollback.
+
+## Testing
+
+### Unit — `src/server/update-strategy.test.ts`
+
+- `createUpdateStrategy()` env matrix: unset, `"supervisor"`, `"pm2"`, unknown.
+- `NpmChecker` — mocked `fetchLatestVersion`.
+- `GitChecker` — stubbed spawn returning canned `git rev-parse` / `git fetch` output; updateAvailable when SHAs differ.
+- `Pm2Reloader.reload()` — stubbed spawn + pm2 API; verify pipeline order; verify throws with captured stderr on non-zero exit; verify skips `bun install` when lockfile unchanged.
+- `SupervisorExitReloader` — stubbed `installVersion` + `process.exit`; exit code 76 on success, throws on install failure.
+
+### Unit — `src/server/update-manager.test.ts`
+
+Update existing tests to inject fake `checker` + `reloader` fixtures. Preserve all scenarios (devMode, concurrent install, error path, listener notifications).
+
+### Manual verification
+
+1. Run `./scripts/deploy.sh`; `pm2 list` shows `kanna` online.
+2. Commit + push a change; click Update in UI → pipeline runs, pm2 reloads, new code live.
+3. Push a syntax error; click Update → red banner with build-failure stderr tail; pm2 keeps serving old build.
+4. `pm2 delete kanna`, run `kanna` in a terminal → supervisor path still works (regression).
+
+## Rollout
+
+- Ship behind `KANNA_RELOADER`; unset = no behavior change for end users or other contributors.
+- Old `deploy.sh` preserved in git history.
+- Manual one-shots noted in PR body: unload old launchd plist, run `pm2 startup`.
+
+## Open questions
+
+None blocking implementation.
diff --git a/docs/plans/2026-04-21-pm2-update-reloader.md b/docs/plans/2026-04-21-pm2-update-reloader.md
new file mode 100644
index 000000000..c2b7785b9
--- /dev/null
+++ b/docs/plans/2026-04-21-pm2-update-reloader.md
@@ -0,0 +1,1204 @@
+# pm2 Update Reloader Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Replace the launchd-based dev deploy on macOS with pm2, and abstract the in-app update button behind swappable checker + reloader interfaces so the pm2 reload pipeline (git pull → build → `pm2 reload`) can coexist with the current npm-registry self-update path.
+
+**Architecture:** New `src/server/update-strategy.ts` defines `UpdateChecker` and `UpdateReloader` interfaces with a factory that selects impls from the `KANNA_RELOADER` env var. `UpdateManager` swaps its `fetchLatestVersion` + `installVersion` deps for `checker` + `reloader`. The supervisor-exit + npm-install path becomes a concrete `SupervisorExitReloader` + `NpmChecker` (default, zero behavior change). The pm2 path adds `GitChecker` + `Pm2Reloader`, wired via a templated `scripts/pm2.config.cjs` and a rewritten `scripts/deploy.sh`.
+
+**Tech Stack:** Bun, TypeScript, `bun:test`, pm2 (programmatic API via the `pm2` npm package), git, envsubst.
+
+**Design doc:** `docs/plans/2026-04-21-pm2-update-reloader-design.md`
+
+---
+
+## Preconditions
+
+- Worktree at `.worktrees/pm2-reloader`, branch `feature/pm2-reloader`.
+- `bun install` already run, baseline `bun test` = 586 pass / 0 fail.
+- Work is dev-only scope — end-user npm install path must remain default.
+
+Run all test commands from the worktree root: `/Users/cuongtran/Desktop/repo/kanna/.worktrees/pm2-reloader`.
+
+---
+
+## Task 1: Create `UpdateChecker` + `NpmChecker` (TDD)
+
+**Files:**
+- Create: `src/server/update-strategy.ts`
+- Create: `src/server/update-strategy.test.ts`
+
+**Step 1: Write the failing tests**
+
+```ts
+// src/server/update-strategy.test.ts
+import { describe, expect, test } from "bun:test"
+import { NpmChecker } from "./update-strategy"
+
+describe("NpmChecker", () => {
+ test("reports update available when latest is newer", async () => {
+ const checker = new NpmChecker({
+ currentVersion: "0.12.0",
+ fetchLatestVersion: async () => "0.13.0",
+ })
+ const result = await checker.check()
+ expect(result).toEqual({ latestVersion: "0.13.0", updateAvailable: true })
+ })
+
+ test("reports no update when versions match", async () => {
+ const checker = new NpmChecker({
+ currentVersion: "0.13.0",
+ fetchLatestVersion: async () => "0.13.0",
+ })
+ const result = await checker.check()
+ expect(result).toEqual({ latestVersion: "0.13.0", updateAvailable: false })
+ })
+
+ test("propagates fetch errors", async () => {
+ const checker = new NpmChecker({
+ currentVersion: "0.12.0",
+ fetchLatestVersion: async () => { throw new Error("registry down") },
+ })
+ await expect(checker.check()).rejects.toThrow("registry down")
+ })
+})
+```
+
+**Step 2: Run test to verify failure**
+
+Run: `bun test src/server/update-strategy.test.ts`
+Expected: FAIL — module not found.
+
+**Step 3: Write minimal implementation**
+
+```ts
+// src/server/update-strategy.ts
+import { compareVersions } from "./cli-runtime"
+import { PACKAGE_NAME } from "../shared/branding"
+
+export interface UpdateChecker {
+ check(): Promise<{ latestVersion: string | null; updateAvailable: boolean }>
+}
+
+export interface UpdateReloader {
+ reload(): Promise
+}
+
+export interface NpmCheckerDeps {
+ currentVersion: string
+ fetchLatestVersion: (packageName: string) => Promise
+}
+
+export class NpmChecker implements UpdateChecker {
+ constructor(private deps: NpmCheckerDeps) {}
+
+ async check() {
+ const latestVersion = await this.deps.fetchLatestVersion(PACKAGE_NAME)
+ const updateAvailable = compareVersions(this.deps.currentVersion, latestVersion) < 0
+ return { latestVersion, updateAvailable }
+ }
+}
+```
+
+**Step 4: Run test to verify passing**
+
+Run: `bun test src/server/update-strategy.test.ts`
+Expected: PASS (3 tests).
+
+**Step 5: Commit**
+
+```bash
+git add src/server/update-strategy.ts src/server/update-strategy.test.ts
+git commit -m "feat(update-strategy): add UpdateChecker interface and NpmChecker impl"
+```
+
+---
+
+## Task 2: `SupervisorExitReloader` (TDD)
+
+**Files:**
+- Modify: `src/server/update-strategy.ts`
+- Modify: `src/server/update-strategy.test.ts`
+
+**Step 1: Add failing tests**
+
+```ts
+// append to src/server/update-strategy.test.ts
+import { SupervisorExitReloader } from "./update-strategy"
+
+describe("SupervisorExitReloader", () => {
+ test("installs target version then signals UI restart exit", async () => {
+ const calls: Array<{ packageName: string; version: string }> = []
+ let exitCode: number | null = null
+ const reloader = new SupervisorExitReloader({
+ targetVersion: () => "0.13.0",
+ installVersion: (packageName, version) => {
+ calls.push({ packageName, version })
+ return { ok: true, errorCode: null, userTitle: null, userMessage: null }
+ },
+ exit: (code) => { exitCode = code },
+ })
+
+ await reloader.reload()
+ expect(calls).toEqual([{ packageName: "kanna-code", version: "0.13.0" }])
+ expect(exitCode).toBe(76)
+ })
+
+ test("throws with structured error when install fails", async () => {
+ const reloader = new SupervisorExitReloader({
+ targetVersion: () => "0.13.0",
+ installVersion: () => ({
+ ok: false,
+ errorCode: "version_not_live_yet",
+ userTitle: "Update not live yet",
+ userMessage: "This update is still propagating. Try again in a few minutes.",
+ }),
+ exit: () => {},
+ })
+
+ await expect(reloader.reload()).rejects.toMatchObject({
+ message: "This update is still propagating. Try again in a few minutes.",
+ errorCode: "version_not_live_yet",
+ userTitle: "Update not live yet",
+ })
+ })
+
+ test("throws when target version cannot be resolved", async () => {
+ const reloader = new SupervisorExitReloader({
+ targetVersion: () => null,
+ installVersion: () => ({ ok: true, errorCode: null, userTitle: null, userMessage: null }),
+ exit: () => {},
+ })
+ await expect(reloader.reload()).rejects.toThrow(/target version/i)
+ })
+})
+```
+
+**Step 2: Run to verify failure**
+
+Run: `bun test src/server/update-strategy.test.ts`
+Expected: FAIL — `SupervisorExitReloader` not exported.
+
+**Step 3: Implement**
+
+Add to `src/server/update-strategy.ts`:
+
+```ts
+import type { UpdateInstallErrorCode } from "../shared/types"
+import type { UpdateInstallAttemptResult } from "./cli-runtime"
+import { CLI_UI_UPDATE_RESTART_EXIT_CODE } from "./restart"
+
+export class UpdateInstallError extends Error {
+ constructor(
+ message: string,
+ public readonly errorCode: UpdateInstallErrorCode | null,
+ public readonly userTitle: string | null,
+ ) {
+ super(message)
+ this.name = "UpdateInstallError"
+ }
+}
+
+export interface SupervisorExitReloaderDeps {
+ targetVersion: () => string | null
+ installVersion: (packageName: string, version: string) => UpdateInstallAttemptResult
+ exit: (code: number) => void
+}
+
+export class SupervisorExitReloader implements UpdateReloader {
+ constructor(private deps: SupervisorExitReloaderDeps) {}
+
+ async reload() {
+ const version = this.deps.targetVersion()
+ if (!version) {
+ throw new UpdateInstallError(
+ "Unable to determine target version.",
+ "install_failed",
+ "Update failed",
+ )
+ }
+ const result = this.deps.installVersion(PACKAGE_NAME, version)
+ if (!result.ok) {
+ throw new UpdateInstallError(
+ result.userMessage ?? "Unable to install the latest version.",
+ result.errorCode,
+ result.userTitle,
+ )
+ }
+ this.deps.exit(CLI_UI_UPDATE_RESTART_EXIT_CODE)
+ }
+}
+```
+
+**Step 4: Verify passing**
+
+Run: `bun test src/server/update-strategy.test.ts`
+Expected: PASS (6 tests total).
+
+**Step 5: Commit**
+
+```bash
+git add src/server/update-strategy.ts src/server/update-strategy.test.ts
+git commit -m "feat(update-strategy): add SupervisorExitReloader wrapping current install+exit"
+```
+
+---
+
+## Task 3: `createUpdateStrategy` factory (TDD env matrix, supervisor-only for now)
+
+**Files:**
+- Modify: `src/server/update-strategy.ts`
+- Modify: `src/server/update-strategy.test.ts`
+
+**Step 1: Failing tests**
+
+```ts
+// append
+import { createUpdateStrategy } from "./update-strategy"
+
+describe("createUpdateStrategy", () => {
+ const baseDeps = {
+ currentVersion: "0.12.0",
+ fetchLatestVersion: async () => "0.13.0",
+ installVersion: () => ({ ok: true, errorCode: null, userTitle: null, userMessage: null }),
+ latestVersionHint: () => "0.13.0",
+ exit: () => {},
+ }
+
+ test("defaults to npm + supervisor-exit when env unset", () => {
+ const strategy = createUpdateStrategy({ reloaderEnv: undefined, ...baseDeps })
+ expect(strategy.checker).toBeInstanceOf(NpmChecker)
+ expect(strategy.reloader).toBeInstanceOf(SupervisorExitReloader)
+ })
+
+ test("uses npm + supervisor-exit when env=supervisor", () => {
+ const strategy = createUpdateStrategy({ reloaderEnv: "supervisor", ...baseDeps })
+ expect(strategy.checker).toBeInstanceOf(NpmChecker)
+ expect(strategy.reloader).toBeInstanceOf(SupervisorExitReloader)
+ })
+
+ test("throws on unknown reloader value", () => {
+ expect(() => createUpdateStrategy({ reloaderEnv: "bogus", ...baseDeps })).toThrow(/unknown.*reloader/i)
+ })
+})
+```
+
+**Step 2: Run — verify failure.** `bun test src/server/update-strategy.test.ts`.
+
+**Step 3: Implement**
+
+Add to `src/server/update-strategy.ts`:
+
+```ts
+export interface CreateUpdateStrategyDeps {
+ reloaderEnv: string | undefined
+ currentVersion: string
+ fetchLatestVersion: (packageName: string) => Promise
+ installVersion: (packageName: string, version: string) => UpdateInstallAttemptResult
+ latestVersionHint: () => string | null
+ exit: (code: number) => void
+ repoDir?: string
+}
+
+export function createUpdateStrategy(deps: CreateUpdateStrategyDeps): {
+ checker: UpdateChecker
+ reloader: UpdateReloader
+} {
+ const mode = deps.reloaderEnv ?? "supervisor"
+ if (mode === "supervisor") {
+ return {
+ checker: new NpmChecker({
+ currentVersion: deps.currentVersion,
+ fetchLatestVersion: deps.fetchLatestVersion,
+ }),
+ reloader: new SupervisorExitReloader({
+ targetVersion: deps.latestVersionHint,
+ installVersion: deps.installVersion,
+ exit: deps.exit,
+ }),
+ }
+ }
+ throw new Error(`Unknown KANNA_RELOADER value: ${mode}`)
+}
+```
+
+(pm2 branch added in Task 8.)
+
+**Step 4: Run — verify passing.** All 9 tests pass.
+
+**Step 5: Commit**
+
+```bash
+git add src/server/update-strategy.ts src/server/update-strategy.test.ts
+git commit -m "feat(update-strategy): add createUpdateStrategy factory keyed on KANNA_RELOADER"
+```
+
+---
+
+## Task 4: Refactor `UpdateManager` to depend on `checker` + `reloader` (TDD)
+
+**Files:**
+- Modify: `src/server/update-manager.ts`
+- Modify: `src/server/update-manager.test.ts`
+
+**Step 1: Rewrite tests first**
+
+Replace the contents of `src/server/update-manager.test.ts` with fake checker + reloader fixtures. Preserve all four existing scenarios (`detects available updates`, `bypasses cache when force is true`, `surfaces install failures without clearing the running version`, `always exposes an available reload action in dev mode`) but injecting fakes rather than `fetchLatestVersion`/`installVersion`.
+
+```ts
+import { describe, expect, test } from "bun:test"
+import { UpdateManager } from "./update-manager"
+import { UpdateInstallError, type UpdateChecker, type UpdateReloader } from "./update-strategy"
+
+class FakeChecker implements UpdateChecker {
+ calls = 0
+ constructor(private results: Array<{ latestVersion: string | null; updateAvailable: boolean }>) {}
+ async check() {
+ const result = this.results[Math.min(this.calls, this.results.length - 1)]
+ this.calls += 1
+ return result
+ }
+}
+
+class FakeReloader implements UpdateReloader {
+ calls = 0
+ constructor(private onReload: () => Promise = async () => {}) {}
+ async reload() {
+ this.calls += 1
+ await this.onReload()
+ }
+}
+
+describe("UpdateManager", () => {
+ test("detects available updates", async () => {
+ const manager = new UpdateManager({
+ currentVersion: "0.12.0",
+ checker: new FakeChecker([{ latestVersion: "0.13.0", updateAvailable: true }]),
+ reloader: new FakeReloader(),
+ })
+ const snapshot = await manager.checkForUpdates({ force: true })
+ expect(snapshot.status).toBe("available")
+ expect(snapshot.updateAvailable).toBe(true)
+ expect(snapshot.latestVersion).toBe("0.13.0")
+ expect(snapshot.installAction).toBe("restart")
+ expect(snapshot.reloadRequestedAt).toBeNull()
+ })
+
+ test("bypasses cache when force is true", async () => {
+ const checker = new FakeChecker([
+ { latestVersion: "0.12.1", updateAvailable: true },
+ { latestVersion: "0.13.0", updateAvailable: true },
+ ])
+ const manager = new UpdateManager({
+ currentVersion: "0.12.0",
+ checker,
+ reloader: new FakeReloader(),
+ })
+ await manager.checkForUpdates()
+ await manager.checkForUpdates({ force: true })
+ expect(checker.calls).toBe(2)
+ expect(manager.getSnapshot().latestVersion).toBe("0.13.0")
+ })
+
+ test("surfaces reloader failures without clearing the running version", async () => {
+ const reloader = new FakeReloader(async () => {
+ throw new UpdateInstallError(
+ "This update is still propagating. Try again in a few minutes.",
+ "version_not_live_yet",
+ "Update not live yet",
+ )
+ })
+ const manager = new UpdateManager({
+ currentVersion: "0.12.0",
+ checker: new FakeChecker([{ latestVersion: "0.13.0", updateAvailable: true }]),
+ reloader,
+ })
+ await manager.checkForUpdates({ force: true })
+ const result = await manager.installUpdate()
+ expect(result).toEqual({
+ ok: false,
+ action: "restart",
+ errorCode: "version_not_live_yet",
+ userTitle: "Update not live yet",
+ userMessage: "This update is still propagating. Try again in a few minutes.",
+ })
+ expect(reloader.calls).toBe(1)
+ expect(manager.getSnapshot().status).toBe("error")
+ expect(manager.getSnapshot().currentVersion).toBe("0.12.0")
+ })
+
+ test("always exposes an available reload action in dev mode", async () => {
+ const manager = new UpdateManager({
+ currentVersion: "0.12.0",
+ checker: new FakeChecker([{ latestVersion: "9.9.9", updateAvailable: true }]),
+ reloader: new FakeReloader(),
+ devMode: true,
+ })
+ expect(manager.getSnapshot()).toMatchObject({
+ status: "available",
+ updateAvailable: true,
+ installAction: "restart",
+ reloadRequestedAt: null,
+ })
+ const result = await manager.installUpdate()
+ expect(result).toEqual({
+ ok: true,
+ action: "restart",
+ errorCode: null,
+ userTitle: null,
+ userMessage: null,
+ })
+ expect(manager.getSnapshot().status).toBe("restart_pending")
+ expect(typeof manager.getSnapshot().reloadRequestedAt).toBe("number")
+ })
+})
+```
+
+**Step 2: Run — verify failure**
+
+Run: `bun test src/server/update-manager.test.ts`
+Expected: FAIL — `UpdateManager` still expects `fetchLatestVersion` / `installVersion`.
+
+**Step 3: Rewrite `UpdateManager`**
+
+Replace `src/server/update-manager.ts`:
+
+```ts
+import type { UpdateInstallResult, UpdateSnapshot } from "../shared/types"
+import { UpdateInstallError, type UpdateChecker, type UpdateReloader } from "./update-strategy"
+
+const UPDATE_CACHE_TTL_MS = 5 * 60 * 1000
+
+export interface UpdateManagerDeps {
+ currentVersion: string
+ checker: UpdateChecker
+ reloader: UpdateReloader
+ devMode?: boolean
+}
+
+export class UpdateManager {
+ private readonly deps: UpdateManagerDeps
+ private readonly listeners = new Set<(snapshot: UpdateSnapshot) => void>()
+ private snapshot: UpdateSnapshot
+ private checkPromise: Promise | null = null
+ private installPromise: Promise | null = null
+
+ constructor(deps: UpdateManagerDeps) {
+ this.deps = deps
+ this.snapshot = {
+ currentVersion: deps.currentVersion,
+ latestVersion: deps.devMode ? `${deps.currentVersion}-dev` : null,
+ status: deps.devMode ? "available" : "idle",
+ updateAvailable: Boolean(deps.devMode),
+ lastCheckedAt: deps.devMode ? Date.now() : null,
+ error: null,
+ installAction: "restart",
+ reloadRequestedAt: null,
+ }
+ }
+
+ getSnapshot() { return this.snapshot }
+
+ onChange(listener: (snapshot: UpdateSnapshot) => void) {
+ this.listeners.add(listener)
+ return () => { this.listeners.delete(listener) }
+ }
+
+ async checkForUpdates(options: { force?: boolean } = {}) {
+ if (this.deps.devMode) return this.snapshot
+ if (this.snapshot.status === "updating" || this.snapshot.status === "restart_pending") return this.snapshot
+ if (this.checkPromise) return this.checkPromise
+ if (!options.force && this.snapshot.lastCheckedAt && Date.now() - this.snapshot.lastCheckedAt < UPDATE_CACHE_TTL_MS) {
+ return this.snapshot
+ }
+
+ this.setSnapshot({ ...this.snapshot, status: "checking", error: null, reloadRequestedAt: null })
+
+ const checkPromise = this.runCheck()
+ this.checkPromise = checkPromise
+ try { return await checkPromise }
+ finally { if (this.checkPromise === checkPromise) this.checkPromise = null }
+ }
+
+ async installUpdate(): Promise {
+ if (this.deps.devMode) {
+ this.setSnapshot({ ...this.snapshot, status: "updating", error: null, reloadRequestedAt: null })
+ this.setSnapshot({
+ ...this.snapshot,
+ status: "restart_pending",
+ updateAvailable: false,
+ error: null,
+ reloadRequestedAt: Date.now(),
+ })
+ return { ok: true, action: "restart", errorCode: null, userTitle: null, userMessage: null }
+ }
+
+ if (this.snapshot.status === "updating" || this.snapshot.status === "restart_pending") {
+ return { ok: this.snapshot.updateAvailable, action: "restart", errorCode: null, userTitle: null, userMessage: null }
+ }
+
+ if (this.installPromise) return this.installPromise
+
+ const installPromise = this.runInstall()
+ this.installPromise = installPromise
+ try { return await installPromise }
+ finally { if (this.installPromise === installPromise) this.installPromise = null }
+ }
+
+ private async runCheck() {
+ try {
+ const { latestVersion, updateAvailable } = await this.deps.checker.check()
+ const nextSnapshot: UpdateSnapshot = {
+ ...this.snapshot,
+ latestVersion,
+ updateAvailable,
+ status: updateAvailable ? "available" : "up_to_date",
+ lastCheckedAt: Date.now(),
+ error: null,
+ reloadRequestedAt: null,
+ }
+ this.setSnapshot(nextSnapshot)
+ return nextSnapshot
+ } catch (error) {
+ const nextSnapshot: UpdateSnapshot = {
+ ...this.snapshot,
+ status: "error",
+ lastCheckedAt: Date.now(),
+ error: error instanceof Error ? error.message : String(error),
+ reloadRequestedAt: null,
+ }
+ this.setSnapshot(nextSnapshot)
+ return nextSnapshot
+ }
+ }
+
+ private async runInstall(): Promise {
+ if (!this.snapshot.updateAvailable) {
+ const snapshot = await this.checkForUpdates({ force: true })
+ if (!snapshot.updateAvailable) {
+ return { ok: false, action: "restart", errorCode: null, userTitle: null, userMessage: null }
+ }
+ }
+
+ this.setSnapshot({ ...this.snapshot, status: "updating", error: null, reloadRequestedAt: null })
+
+ try {
+ await this.deps.reloader.reload()
+ } catch (error) {
+ const installError = error instanceof UpdateInstallError ? error : null
+ const message = error instanceof Error ? error.message : String(error)
+ this.setSnapshot({
+ ...this.snapshot,
+ status: "error",
+ error: installError?.message ?? message,
+ reloadRequestedAt: null,
+ })
+ return {
+ ok: false,
+ action: "restart",
+ errorCode: installError?.errorCode ?? "install_failed",
+ userTitle: installError?.userTitle ?? "Update failed",
+ userMessage: installError?.message ?? message,
+ }
+ }
+
+ this.setSnapshot({
+ ...this.snapshot,
+ currentVersion: this.snapshot.latestVersion ?? this.snapshot.currentVersion,
+ status: "restart_pending",
+ updateAvailable: false,
+ error: null,
+ reloadRequestedAt: Date.now(),
+ })
+ return { ok: true, action: "restart", errorCode: null, userTitle: null, userMessage: null }
+ }
+
+ private setSnapshot(snapshot: UpdateSnapshot) {
+ this.snapshot = snapshot
+ for (const listener of this.listeners) listener(snapshot)
+ }
+}
+```
+
+**Step 4: Verify passing**
+
+Run: `bun test src/server/update-manager.test.ts`
+Expected: PASS (4 tests).
+
+**Step 5: Commit**
+
+```bash
+git add src/server/update-manager.ts src/server/update-manager.test.ts
+git commit -m "refactor(update-manager): depend on UpdateChecker + UpdateReloader abstractions"
+```
+
+---
+
+## Task 5: Wire `server.ts` + `cli.ts` to the factory
+
+**Files:**
+- Modify: `src/server/server.ts:105-112`
+- Modify: `src/server/cli.ts` (where UpdateManager deps flow from)
+
+**Step 1: Update `server.ts`**
+
+Replace the `new UpdateManager({ ... })` block with factory wiring:
+
+```ts
+import { createUpdateStrategy } from "./update-strategy"
+
+// inside startKannaServer, where update manager is built:
+const updateManager = options.update
+ ? (() => {
+ const strategy = createUpdateStrategy({
+ reloaderEnv: process.env.KANNA_RELOADER,
+ currentVersion: options.update.version,
+ fetchLatestVersion: options.update.fetchLatestVersion,
+ installVersion: options.update.installVersion,
+ latestVersionHint: () => managerRef.current?.getSnapshot().latestVersion ?? null,
+ exit: (code) => process.exit(code),
+ repoDir: process.env.KANNA_REPO_DIR,
+ })
+ const manager = new UpdateManager({
+ currentVersion: options.update.version,
+ checker: strategy.checker,
+ reloader: strategy.reloader,
+ devMode: getRuntimeProfile() === "dev",
+ })
+ managerRef.current = manager
+ return manager
+ })()
+ : null
+```
+
+Declare `const managerRef: { current: UpdateManager | null } = { current: null }` just above — `latestVersionHint` needs a forward reference into the manager's own snapshot.
+
+**Step 2: Update `cli.ts`**
+
+The existing `exit: (code) => process.exit(code)` path in the factory would exit the child directly and bypass `cli.ts`'s graceful shutdown (which calls `result.stop()` then exits). To preserve that, replace `exit` wiring with a signal into the existing `resolveExitAction("ui_restart")` listener — the `restart_pending` snapshot already drives that. So: in `SupervisorExitReloader`, instead of calling `process.exit` directly, rely on the UpdateManager's own `restart_pending` transition.
+
+Change plan: `SupervisorExitReloader` does NOT call `exit` itself. Remove `exit` from `SupervisorExitReloaderDeps` and its test. `UpdateManager.installUpdate` already sets `restart_pending` after reload resolves, and `cli.ts:25-29` already listens for that and calls `resolveExitAction("ui_restart")` which drives the graceful shutdown path.
+
+Roll back Task 2's `exit` dep: remove from `SupervisorExitReloaderDeps`, `createUpdateStrategy`, tests. Run `bun test src/server/update-strategy.test.ts` + `bun test src/server/update-manager.test.ts` — all pass.
+
+Then in `server.ts` wiring, drop `exit` from factory deps. Commit each sub-step.
+
+**Step 3: Verify full test suite passes**
+
+Run: `bun test`
+Expected: 586 pass, 0 fail (same baseline).
+
+Run: `bun run check`
+Expected: no TypeScript errors, build succeeds.
+
+**Step 4: Commit**
+
+```bash
+git add src/server/server.ts src/server/cli.ts src/server/update-strategy.ts src/server/update-strategy.test.ts
+git commit -m "refactor(server): wire UpdateManager through createUpdateStrategy factory"
+```
+
+---
+
+## Task 6: Add `pm2` dependency
+
+**Files:**
+- Modify: `package.json`
+- Modify: `bun.lock`
+
+**Step 1: Install**
+
+Run: `bun add pm2@latest`
+Expected: `pm2` added to `dependencies`.
+
+**Step 2: Verify build still works**
+
+Run: `bun run check`
+Expected: no errors.
+
+Run: `bun test`
+Expected: 586 pass.
+
+**Step 3: Commit**
+
+```bash
+git add package.json bun.lock
+git commit -m "chore(deps): add pm2 for dev reloader"
+```
+
+---
+
+## Task 7: `GitChecker` (TDD)
+
+**Files:**
+- Modify: `src/server/update-strategy.ts`
+- Modify: `src/server/update-strategy.test.ts`
+
+**Step 1: Failing tests**
+
+Create `GitChecker` with injected `runGit: (args: string[]) => Promise` for stubbing.
+
+```ts
+// append to test file
+import { GitChecker } from "./update-strategy"
+
+describe("GitChecker", () => {
+ const makeRunGit = (responses: Record) => async (args: string[]) => {
+ const key = args.join(" ")
+ if (!(key in responses)) throw new Error(`unexpected git call: ${key}`)
+ return responses[key]
+ }
+
+ test("reports update when HEAD differs from upstream", async () => {
+ const checker = new GitChecker({
+ repoDir: "/tmp/repo",
+ branch: "main",
+ runGit: makeRunGit({
+ "fetch origin main": "",
+ "rev-parse HEAD": "abc123def456\n",
+ "rev-parse origin/main": "deadbeef99887\n",
+ }),
+ })
+ const result = await checker.check()
+ expect(result).toEqual({ latestVersion: "deadbee", updateAvailable: true })
+ })
+
+ test("reports no update when HEAD matches upstream", async () => {
+ const checker = new GitChecker({
+ repoDir: "/tmp/repo",
+ branch: "main",
+ runGit: makeRunGit({
+ "fetch origin main": "",
+ "rev-parse HEAD": "abc123def456\n",
+ "rev-parse origin/main": "abc123def456\n",
+ }),
+ })
+ const result = await checker.check()
+ expect(result).toEqual({ latestVersion: "abc123d", updateAvailable: false })
+ })
+
+ test("propagates git fetch errors", async () => {
+ const checker = new GitChecker({
+ repoDir: "/tmp/repo",
+ branch: "main",
+ runGit: async () => { throw new Error("fetch failed: network") },
+ })
+ await expect(checker.check()).rejects.toThrow(/fetch failed/)
+ })
+})
+```
+
+**Step 2: Run — verify failure.** `bun test src/server/update-strategy.test.ts`.
+
+**Step 3: Implement**
+
+Add to `src/server/update-strategy.ts`:
+
+```ts
+export interface GitCheckerDeps {
+ repoDir: string
+ branch: string
+ runGit: (args: string[]) => Promise
+}
+
+export class GitChecker implements UpdateChecker {
+ constructor(private deps: GitCheckerDeps) {}
+
+ async check() {
+ await this.deps.runGit(["fetch", "origin", this.deps.branch])
+ const headRaw = await this.deps.runGit(["rev-parse", "HEAD"])
+ const upstreamRaw = await this.deps.runGit(["rev-parse", `origin/${this.deps.branch}`])
+ const head = headRaw.trim()
+ const upstream = upstreamRaw.trim()
+ return {
+ latestVersion: upstream.slice(0, 7),
+ updateAvailable: head !== upstream,
+ }
+ }
+}
+```
+
+Also export a default `runGit` helper using `Bun.spawn` (see Task 8 for the shared spawn helper; keep this task scoped to the class — the factory will wire in a real `runGit` in Task 8).
+
+**Step 4: Run — verify passing.** All strategy tests green.
+
+**Step 5: Commit**
+
+```bash
+git add src/server/update-strategy.ts src/server/update-strategy.test.ts
+git commit -m "feat(update-strategy): add GitChecker for pm2 mode update detection"
+```
+
+---
+
+## Task 8: `Pm2Reloader` + pm2 branch in factory (TDD)
+
+**Files:**
+- Modify: `src/server/update-strategy.ts`
+- Modify: `src/server/update-strategy.test.ts`
+
+**Step 1: Failing tests**
+
+Design the reloader with all side effects injected: `runCommand(command: string, args: string[]): Promise`, `triggerPm2Reload(processName: string): Promise`, and `lockfileChanged(repoDir: string): Promise`.
+
+```ts
+import { Pm2Reloader, UpdateInstallError } from "./update-strategy"
+
+describe("Pm2Reloader", () => {
+ function makeReloader(overrides: Partial<{
+ lockfileChanged: boolean
+ commandErrors: Record
+ reloadError: Error | null
+ }> = {}) {
+ const calls: string[] = []
+ const reloader = new Pm2Reloader({
+ repoDir: "/tmp/repo",
+ processName: "kanna",
+ runCommand: async (command, args) => {
+ const line = [command, ...args].join(" ")
+ calls.push(line)
+ if (overrides.commandErrors?.[line]) {
+ throw new Error(overrides.commandErrors[line])
+ }
+ },
+ lockfileChanged: async () => overrides.lockfileChanged ?? false,
+ triggerPm2Reload: async () => {
+ calls.push("pm2.reload kanna")
+ if (overrides.reloadError) throw overrides.reloadError
+ },
+ })
+ return { reloader, calls }
+ }
+
+ test("runs git pull, build, then pm2 reload when lockfile unchanged", async () => {
+ const { reloader, calls } = makeReloader({ lockfileChanged: false })
+ await reloader.reload()
+ expect(calls).toEqual([
+ "git pull --ff-only",
+ "bun run build",
+ "pm2.reload kanna",
+ ])
+ })
+
+ test("inserts bun install when lockfile changed", async () => {
+ const { reloader, calls } = makeReloader({ lockfileChanged: true })
+ await reloader.reload()
+ expect(calls).toEqual([
+ "git pull --ff-only",
+ "bun install",
+ "bun run build",
+ "pm2.reload kanna",
+ ])
+ })
+
+ test("aborts before reload when git pull fails", async () => {
+ const { reloader, calls } = makeReloader({
+ commandErrors: { "git pull --ff-only": "merge conflict in src/foo.ts" },
+ })
+ await expect(reloader.reload()).rejects.toThrow(/git pull failed/i)
+ expect(calls).toEqual(["git pull --ff-only"])
+ })
+
+ test("aborts before reload when build fails", async () => {
+ const { reloader, calls } = makeReloader({
+ commandErrors: { "bun run build": "tsc error TS2345" },
+ })
+ await expect(reloader.reload()).rejects.toThrow(/build failed/i)
+ expect(calls).toEqual(["git pull --ff-only", "bun run build"])
+ })
+
+ test("surfaces pm2 reload failures", async () => {
+ const { reloader } = makeReloader({ reloadError: new Error("pm2 daemon not running") })
+ await expect(reloader.reload()).rejects.toThrow(/pm2 reload failed/i)
+ })
+})
+
+describe("createUpdateStrategy pm2 branch", () => {
+ test("returns GitChecker + Pm2Reloader for KANNA_RELOADER=pm2", () => {
+ const strategy = createUpdateStrategy({
+ reloaderEnv: "pm2",
+ currentVersion: "0.12.0",
+ fetchLatestVersion: async () => "ignored",
+ installVersion: () => ({ ok: true, errorCode: null, userTitle: null, userMessage: null }),
+ latestVersionHint: () => null,
+ repoDir: "/tmp/repo",
+ })
+ expect(strategy.checker).toBeInstanceOf(GitChecker)
+ expect(strategy.reloader).toBeInstanceOf(Pm2Reloader)
+ })
+
+ test("throws when pm2 mode selected without repoDir", () => {
+ expect(() =>
+ createUpdateStrategy({
+ reloaderEnv: "pm2",
+ currentVersion: "0.12.0",
+ fetchLatestVersion: async () => "ignored",
+ installVersion: () => ({ ok: true, errorCode: null, userTitle: null, userMessage: null }),
+ latestVersionHint: () => null,
+ }),
+ ).toThrow(/KANNA_REPO_DIR/)
+ })
+})
+```
+
+**Step 2: Run — verify failure.**
+
+**Step 3: Implement**
+
+Add to `src/server/update-strategy.ts`:
+
+```ts
+export interface Pm2ReloaderDeps {
+ repoDir: string
+ processName: string
+ runCommand: (command: string, args: string[]) => Promise
+ lockfileChanged: () => Promise
+ triggerPm2Reload: (processName: string) => Promise
+}
+
+export class Pm2Reloader implements UpdateReloader {
+ constructor(private deps: Pm2ReloaderDeps) {}
+
+ async reload() {
+ await this.step("git pull", ["git", "pull", "--ff-only"])
+ if (await this.deps.lockfileChanged()) {
+ await this.step("bun install", ["bun", "install"])
+ }
+ await this.step("bun run build", ["bun", "run", "build"])
+ try {
+ await this.deps.triggerPm2Reload(this.deps.processName)
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ throw new UpdateInstallError(
+ `pm2 reload failed: ${message}`,
+ "install_failed",
+ "Update failed",
+ )
+ }
+ }
+
+ private async step(label: string, argv: string[]) {
+ const [command, ...args] = argv
+ try {
+ await this.deps.runCommand(command, args)
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ throw new UpdateInstallError(
+ `${label} failed: ${message}`,
+ "install_failed",
+ "Update failed",
+ )
+ }
+ }
+}
+```
+
+And extend `createUpdateStrategy`:
+
+```ts
+if (mode === "pm2") {
+ if (!deps.repoDir) {
+ throw new Error("KANNA_RELOADER=pm2 requires KANNA_REPO_DIR to be set")
+ }
+ const repoDir = deps.repoDir
+ return {
+ checker: new GitChecker({
+ repoDir,
+ branch: "main",
+ runGit: (args) => runCommandCapture("git", args, repoDir),
+ }),
+ reloader: new Pm2Reloader({
+ repoDir,
+ processName: "kanna",
+ runCommand: (command, args) => runCommandThrow(command, args, repoDir),
+ lockfileChanged: () => detectLockfileChange(repoDir),
+ triggerPm2Reload,
+ }),
+ }
+}
+```
+
+Helpers in the same file:
+
+- `runCommandCapture(command, args, cwd)` — `Bun.spawn({ cmd: [command, ...args], cwd, stdout: "pipe", stderr: "pipe" })`, awaits exit, returns stdout; throws on non-zero with stderr tail (last 500 chars).
+- `runCommandThrow(command, args, cwd)` — same but void return.
+- `detectLockfileChange(repoDir)` — `git diff --name-only HEAD@{1} HEAD -- bun.lock package.json`; non-empty output → true. (HEAD@{1} = pre-pull ref from reflog.)
+- `triggerPm2Reload(name)` — wraps `import("pm2")` + `pm2.connect` + `pm2.reload` + `pm2.disconnect` in a promise.
+
+**Step 4: Run — verify passing.** All strategy tests + integration.
+
+**Step 5: Commit**
+
+```bash
+git add src/server/update-strategy.ts src/server/update-strategy.test.ts
+git commit -m "feat(update-strategy): add Pm2Reloader with git-pull+build+pm2.reload pipeline"
+```
+
+---
+
+## Task 9: Create `scripts/pm2.config.cjs.tmpl`
+
+**Files:**
+- Create: `scripts/pm2.config.cjs.tmpl`
+- Modify: `.gitignore` (add `scripts/pm2.config.cjs` — the rendered output).
+
+**Step 1: Write template**
+
+```js
+// scripts/pm2.config.cjs.tmpl
+module.exports = {
+ apps: [
+ {
+ name: "kanna",
+ script: "./src/server/cli.ts",
+ interpreter: "bun",
+ cwd: "${REPO_DIR}",
+ env: {
+ KANNA_RELOADER: "pm2",
+ KANNA_REPO_DIR: "${REPO_DIR}",
+ KANNA_DISABLE_SELF_UPDATE: "1",
+ KANNA_CLI_MODE: "child",
+ },
+ autorestart: true,
+ max_memory_restart: "1G",
+ kill_timeout: 5000,
+ },
+ ],
+}
+```
+
+**Step 2: Add rendered file to `.gitignore`**
+
+Append:
+
+```
+scripts/pm2.config.cjs
+```
+
+**Step 3: Commit**
+
+```bash
+git add scripts/pm2.config.cjs.tmpl .gitignore
+git commit -m "feat(dev): add pm2 ecosystem template for local dev deploy"
+```
+
+---
+
+## Task 10: Rewrite `scripts/deploy.sh`
+
+**Files:**
+- Modify: `scripts/deploy.sh`
+
+**Step 1: Replace content**
+
+```bash
+#!/usr/bin/env bash
+set -euo pipefail
+
+REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+GLOBAL_LINK="$HOME/.bun/install/global/node_modules/kanna-code"
+PM2_NAME="kanna"
+PM2_TEMPLATE="$REPO_DIR/scripts/pm2.config.cjs.tmpl"
+PM2_CONFIG="$REPO_DIR/scripts/pm2.config.cjs"
+
+cd "$REPO_DIR"
+
+if [[ ! -L "$GLOBAL_LINK" ]]; then
+ echo "→ Linking $GLOBAL_LINK → $REPO_DIR"
+ rm -rf "$GLOBAL_LINK"
+ mkdir -p "$(dirname "$GLOBAL_LINK")"
+ ln -s "$REPO_DIR" "$GLOBAL_LINK"
+fi
+
+if [[ ! -d node_modules ]] || [[ package.json -nt node_modules ]] || [[ bun.lock -nt node_modules ]]; then
+ echo "→ bun install"
+ bun install
+fi
+
+echo "→ bun run build"
+bun run build
+
+if ! command -v pm2 >/dev/null 2>&1; then
+ echo "→ bun install -g pm2"
+ bun install -g pm2
+fi
+
+if ! command -v envsubst >/dev/null 2>&1; then
+ echo "✗ envsubst not found (install gettext: brew install gettext)" >&2
+ exit 1
+fi
+
+echo "→ render $PM2_CONFIG"
+REPO_DIR="$REPO_DIR" envsubst '${REPO_DIR}' < "$PM2_TEMPLATE" > "$PM2_CONFIG"
+
+if pm2 describe "$PM2_NAME" >/dev/null 2>&1; then
+ echo "→ pm2 reload $PM2_NAME"
+ pm2 reload "$PM2_CONFIG" --update-env
+else
+ echo "→ pm2 start $PM2_NAME"
+ pm2 start "$PM2_CONFIG"
+fi
+
+pm2 save
+echo "✓ kanna running under pm2"
+```
+
+**Step 2: Syntax check**
+
+Run: `bash -n scripts/deploy.sh`
+Expected: exit 0.
+
+**Step 3: Commit**
+
+```bash
+git add scripts/deploy.sh
+git commit -m "feat(dev): swap launchd for pm2 in deploy.sh"
+```
+
+---
+
+## Task 11: Manual verification
+
+**No files.** Checklist only.
+
+**Step 1:** Unload the old launchd plist once:
+```bash
+launchctl bootout gui/$(id -u)/io.silentium.kanna || true
+```
+
+**Step 2:** Run deploy in the worktree:
+```bash
+cd /Users/cuongtran/Desktop/repo/kanna/.worktrees/pm2-reloader
+./scripts/deploy.sh
+pm2 list
+```
+Expected: `kanna` shows `online`.
+
+**Step 3:** Happy path — commit a small, safe change (e.g., a comment in `src/shared/branding.ts`), push the branch, then click "Update" in the running UI. Verify:
+- UI transitions through `checking` → `available` → `updating` → `restart_pending`.
+- pm2 logs (`pm2 logs kanna --lines 50`) show `git pull`, `bun run build`, then fresh process startup.
+- UI reconnects and reflects the change.
+
+**Step 4:** Failure path — introduce a deliberate TypeScript syntax error on the branch, push, click Update. Verify:
+- UI shows red error banner with stderr tail from `bun run build`.
+- `pm2 list` shows `kanna` still `online` serving the old build.
+- Fix the error, push, click Update again → recovers.
+
+**Step 5:** Regression — `pm2 delete kanna`, then in a plain terminal run `kanna` (symlinked to worktree build). Supervisor path should still respond to update button as before (npm-registry check). This verifies unset `KANNA_RELOADER` keeps old behavior.
+
+**Step 6:** Commit the verification notes (optional): if anything unexpected was found, document in the plan's "Results" section.
+
+---
+
+## Final checks before PR
+
+Run:
+
+```bash
+bun run check # tsc + vite build
+bun test
+```
+
+Expected: 0 TypeScript errors, all tests pass (at least 586 + the new ones from Tasks 1-3, 7, 8 — roughly 601-610 total).
+
+Then follow `superpowers:finishing-a-development-branch` to close out.
diff --git a/docs/plans/2026-04-28-cloudflare-tunnel-design.md b/docs/plans/2026-04-28-cloudflare-tunnel-design.md
new file mode 100644
index 000000000..81be92e9d
--- /dev/null
+++ b/docs/plans/2026-04-28-cloudflare-tunnel-design.md
@@ -0,0 +1,151 @@
+# Cloudflare Tunnel Auto-Expose — Design
+
+Date: 2026-04-28
+
+## Goal
+
+When Claude Code starts a local dev server inside a Kanna-managed project (Go, TypeScript, etc.), Kanna detects the listening port from Bash output, prompts the user to expose it via a Cloudflare quick tunnel, and renders the resulting public URL inline in the chat transcript. Lets users access localhost services from outside the local network without manual `cloudflared` invocation.
+
+## Scope
+
+- **In:** Quick tunnels (`cloudflared tunnel --url`), ephemeral `*.trycloudflare.com` URLs, inline transcript card UX, settings page integration, lifecycle tied to source process / session / manual stop.
+- **Out:** Named tunnels, Cloudflare account auth, persistent subdomains, automatic `cloudflared` install, port allow/deny lists.
+
+## Assumptions
+
+- User has `cloudflared` binary installed (path configurable; default `cloudflared`).
+- Anthropic API key already configured for the existing agent runtime — reused for haiku detector calls.
+- Feature is opt-in (`enabled: false` by default) — no surprise tunnels.
+
+## Architecture
+
+New module: `src/server/cloudflare-tunnel/` mirroring the `auto-continue/` layout.
+
+| File | Responsibility |
+|------|----------------|
+| `detector.ts` | Haiku agent wrapper. Input: Bash command + stdout. Output: `{ isServer: boolean; port?: number }`. Uses `@anthropic-ai/claude-agent-sdk` with `claude-haiku-4-5-20251001`. Cached system prompt for cost. |
+| `tunnel-manager.ts` | Spawns / tracks `cloudflared tunnel --url http://localhost:PORT` child processes. Map `tunnelId → { proc, url, port, sourcePid, sessionId, state }`. Parses stdout for `*.trycloudflare.com` URL. |
+| `events.ts` | Event types: `tunnel.proposed`, `tunnel.accepted`, `tunnel.active`, `tunnel.stopped`, `tunnel.failed`. Mirror `auto-continue/events.ts`. |
+| `read-model.ts` | Projection over events for client subscription. Mirror `auto-continue/read-model.ts`. |
+| `lifecycle.ts` | Watches source PIDs and session-close hooks; kills tunnels per termination rules. |
+
+**Hook point:** `agent.ts` Bash tool post-handler invokes `detector.evaluate(cmd, stdout)`. If a server is detected, manager emits `tunnel.proposed { port, sourcePid, sessionId }`.
+
+**Client:**
+- `src/client/components/chat-ui/CloudflareTunnelCard.tsx` — mirrors `AutoContinueCard` state machine.
+- `src/client/app/SettingsPage.tsx` — new "Cloudflare Tunnel" section.
+
+## Settings
+
+```ts
+type CloudflareTunnelSettings = {
+ enabled: boolean // default false
+ cloudflaredPath: string // default "cloudflared"
+ mode: "always-ask" | "auto-expose" // default "always-ask"
+}
+```
+
+Stored in existing `app-settings.ts` store. UI: enable toggle, mode radio (always-ask / auto-expose), `cloudflaredPath` input with debounced probe showing green "Found" / red "Not found".
+
+## Data Flow
+
+### Happy path (always-ask)
+
+1. User chats; Claude calls Bash `bun run dev` via the agent runtime.
+2. `agent.ts` Bash post-handler captures `{cmd, stdout, pid}`, forwards to `detector.evaluate()`.
+3. Haiku returns `{isServer: true, port: 5173}`.
+4. `tunnel-manager.propose({port, sourcePid, sessionId})` emits `tunnel.proposed`.
+5. WS push → client read-model adds the proposed tunnel → `CloudflareTunnelCard` renders inline with `[Expose] [Dismiss]`.
+6. User clicks **Expose** → WS command `tunnel.accept(tunnelId)` → server spawns `cloudflared tunnel --url http://localhost:5173`.
+7. Manager parses cloudflared stdout for `https://.trycloudflare.com`, emits `tunnel.active { url }`.
+8. Card flips to active state, shows URL with `[Copy] [Stop]`.
+
+### Variants
+
+- **auto-expose mode:** Skip steps 5/6 — manager spawns immediately on detection. Card renders directly in active state.
+- **disabled mode:** Detector skipped entirely, no haiku call, zero overhead.
+
+### Termination (hybrid lifecycle)
+
+- `lifecycle.ts` polls `sourcePid` via `process-utils.ts`. On exit → emit `tunnel.stopped`, SIGTERM cloudflared.
+- Session close hook kills all tunnels for that `sessionId`.
+- Manual Stop button → WS command `tunnel.stop(tunnelId)`.
+- Server shutdown hook (in `cli-supervisor`) kills every child cloudflared.
+
+## Card State Machine
+
+| State | Render |
+|-------|--------|
+| `proposed` | "Port {port} detected. Expose via Cloudflare? `[Expose]` `[Dismiss]`" |
+| `active` | "Tunnel live: {url} `[Copy]` `[Stop]`" |
+| `stopped` | "Tunnel stopped" |
+| `failed` | "Tunnel failed: {error} `[Retry]` `[Dismiss]`" |
+
+Mirrors `AutoContinueCard.tsx` for visual + interaction consistency.
+
+## Detection Strategy
+
+Pure haiku agent (no regex first):
+- Every Bash result piped to haiku with the cached prompt: *"Given a shell command and its stdout, return JSON `{isServer: boolean, port?: number}`. isServer is true only if the command started an HTTP service that is now listening."*
+- Fire-and-forget — does not block the Bash tool result returning to the client.
+- Malformed JSON → log + skip (no proposal).
+- Cost: one haiku call per Bash invocation while `enabled: true`. Disabled mode short-circuits before any LLM call.
+
+## Persistence
+
+- Settings → existing `app-settings` store.
+- Tunnel records → in-memory `Map`, ephemeral by design (quick tunnels regenerate URLs per spawn anyway).
+- Events → `event-store` for in-session replay only; not durable across restarts.
+
+## Failure Modes
+
+| Condition | Behavior |
+|-----------|----------|
+| `cloudflared` binary missing | `tunnel.failed` with install link |
+| Cloudflared exits before URL parsed | `tunnel.failed` with stderr tail |
+| Haiku returns malformed JSON | Log + skip, no proposal |
+| Port already exposed | Reuse existing tunnel, re-emit `proposed` pointing to same `tunnelId` |
+| Cloudflare rate-limit | `tunnel.failed`, `[Retry]` button on card |
+
+## Edge Cases
+
+- IPv6 `[::1]:3000` — haiku prompt explicitly handles.
+- Multiple ports in one output (Vite client + HMR) — propose first non-HMR port; let haiku judge.
+- Background Bash (`&`) — capture stdout via existing stream wiring.
+- Duplicate `bun run dev` runs — manager keys by `port`, returns existing record.
+- Detector latency — async, never blocks Bash tool result.
+
+## Testing
+
+Colocated `*.test.ts` next to source (per `ref-colocated-bun-test`).
+
+| Test | Coverage |
+|------|----------|
+| `detector.test.ts` | Stubbed haiku SDK; table-driven cases for `bun run dev`, `go run`, `ls`, malformed JSON, empty stdout |
+| `tunnel-manager.test.ts` | Spawn → URL parse, port reuse, ENOENT, stop SIGTERM, multi-line stdout |
+| `lifecycle.test.ts` | Source-PID exit, session close, manual stop |
+| `events.test.ts` | Event shape + round-trip via event-store |
+| `read-model.test.ts` | Projection from event sequence |
+| `e2e.test.ts` | Full path: fake Bash → propose → accept → active → source-pid kill → stopped |
+| `CloudflareTunnelCard.test.tsx` | Render each state, button handlers fire correct WS commands |
+| `SettingsPage.test.tsx` | New section toggles, mode radio, path probe states |
+
+## Constraints
+
+- Strong typing: no `any` / `unknown`. Concrete `TunnelRecord`, `TunnelEvent` discriminated unions.
+- WS subscription pattern (`ref-ws-subscription`) — read-model push, not pull.
+- Colocated bun:test (`ref-colocated-bun-test`).
+- Local-first data (`ref-local-first-data`) — settings stored client-side via `app-settings`.
+
+## C3 Impact
+
+- New component: `c3-2xx cloudflare-tunnel` under `c3-2 server` container.
+- Modifies: `c3-116 settings-page` (new section), `c3-112 chat-page` / `c3-114 messages-renderer` (new card type), `agent.ts` (Bash post-handler hook).
+- New refs: none required — reuses `ref-ws-subscription`, `ref-strong-typing`, `ref-colocated-bun-test`.
+
+## Open Questions
+
+None blocking implementation. Future considerations (out of scope for v1):
+- Named tunnels for stable URLs.
+- Auto-install `cloudflared` if missing.
+- Per-project port allow/deny list.
diff --git a/docs/plans/2026-04-28-cloudflare-tunnel.md b/docs/plans/2026-04-28-cloudflare-tunnel.md
new file mode 100644
index 000000000..f201b3599
--- /dev/null
+++ b/docs/plans/2026-04-28-cloudflare-tunnel.md
@@ -0,0 +1,1491 @@
+# Cloudflare Tunnel Auto-Expose Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** When Claude Code starts a local dev server inside a Kanna-managed project, detect the listening port from Bash output via a haiku agent, prompt the user with an inline transcript card, and expose it via a Cloudflare quick tunnel.
+
+**Architecture:** New `src/server/cloudflare-tunnel/` module mirrors the `auto-continue/` event-sourced layout. A haiku-backed detector evaluates every Bash tool result; on hits it emits `tunnel_proposed` events. A tunnel manager spawns `cloudflared tunnel --url http://localhost:PORT` and parses `*.trycloudflare.com` URLs. A `CloudflareTunnelCard.tsx` mirrors `AutoContinueCard.tsx` for inline transcript UX. Settings live in `app-settings.ts` (opt-in; `enabled: false` default).
+
+**Tech Stack:** Bun + TypeScript, React, Zustand stores, `@anthropic-ai/claude-agent-sdk` (haiku for detection), `cloudflared` CLI (assumed installed), bun:test (colocated `.test.ts`).
+
+**Design reference:** `docs/plans/2026-04-28-cloudflare-tunnel-design.md`
+
+**Working directory:** `/Users/cuongtran/Desktop/repo/kanna/.worktrees/cloudflare-tunnel` on branch `feature/cloudflare-tunnel`.
+
+**Conventions to respect:**
+- Strong typing — no `any`, no `unknown` without narrowing. Discriminated unions for events.
+- Colocated tests — `*.test.ts` next to source.
+- WS push pattern — read-model snapshot delta over WS, not pull.
+- TDD — failing test first, minimal impl, pass, commit each task.
+- Frequent commits — one task = one commit (sometimes multi-step within a task).
+
+---
+
+## Task 1: Shared types for tunnel state and settings
+
+**Files:**
+- Modify: `src/shared/types.ts`
+
+**Step 1: Add settings + tunnel types**
+
+Append to `src/shared/types.ts`:
+
+```ts
+export type CloudflareTunnelMode = "always-ask" | "auto-expose"
+
+export interface CloudflareTunnelSettings {
+ enabled: boolean
+ cloudflaredPath: string
+ mode: CloudflareTunnelMode
+}
+
+export const CLOUDFLARE_TUNNEL_DEFAULTS: CloudflareTunnelSettings = {
+ enabled: false,
+ cloudflaredPath: "cloudflared",
+ mode: "always-ask",
+}
+
+export type CloudflareTunnelState = "proposed" | "active" | "stopped" | "failed"
+
+export interface CloudflareTunnelRecord {
+ tunnelId: string
+ chatId: string
+ port: number
+ state: CloudflareTunnelState
+ url: string | null
+ error: string | null
+ proposedAt: number
+ activatedAt: number | null
+ stoppedAt: number | null
+}
+```
+
+Also extend `AppSettingsSnapshot` (find existing block around `interface AppSettingsSnapshot`) by adding:
+
+```ts
+cloudflareTunnel: CloudflareTunnelSettings
+```
+
+**Step 2: Run typecheck**
+
+Run: `bun run check`
+Expected: FAIL — downstream consumers break since `cloudflareTunnel` field missing in existing producers.
+
+**Step 3: Commit (red-light snapshot)**
+
+```bash
+git add src/shared/types.ts
+git commit -m "feat(tunnel): add shared types for cloudflare tunnel state + settings"
+```
+
+---
+
+## Task 2: Server settings normalization + persistence
+
+**Files:**
+- Modify: `src/server/app-settings.ts`
+- Test: `src/server/app-settings.test.ts`
+
+**Step 1: Write failing tests**
+
+Append to `src/server/app-settings.test.ts`:
+
+```ts
+test("normalizes missing cloudflareTunnel block to defaults", async () => {
+ const filePath = await writeSettingsFile({ analyticsEnabled: true })
+ const snapshot = await readAppSettingsSnapshot(filePath)
+ expect(snapshot.cloudflareTunnel).toEqual({
+ enabled: false,
+ cloudflaredPath: "cloudflared",
+ mode: "always-ask",
+ })
+})
+
+test("preserves valid cloudflareTunnel settings", async () => {
+ const filePath = await writeSettingsFile({
+ cloudflareTunnel: { enabled: true, cloudflaredPath: "/usr/local/bin/cloudflared", mode: "auto-expose" },
+ })
+ const snapshot = await readAppSettingsSnapshot(filePath)
+ expect(snapshot.cloudflareTunnel).toEqual({
+ enabled: true,
+ cloudflaredPath: "/usr/local/bin/cloudflared",
+ mode: "auto-expose",
+ })
+})
+
+test("rejects invalid mode and resets to default with warning", async () => {
+ const filePath = await writeSettingsFile({
+ cloudflareTunnel: { enabled: true, cloudflaredPath: "cloudflared", mode: "garbage" },
+ })
+ const snapshot = await readAppSettingsSnapshot(filePath)
+ expect(snapshot.cloudflareTunnel.mode).toBe("always-ask")
+ expect(snapshot.warning).toContain("cloudflareTunnel.mode")
+})
+```
+
+(If `writeSettingsFile` helper not present, use existing pattern in the test file — read the file first.)
+
+**Step 2: Run tests, verify fail**
+
+Run: `bun test src/server/app-settings.test.ts -t cloudflareTunnel`
+Expected: FAIL — `cloudflareTunnel` undefined on snapshot.
+
+**Step 3: Implement normalization**
+
+In `src/server/app-settings.ts`:
+- Extend `AppSettingsFile` interface with `cloudflareTunnel?: unknown`.
+- Extend `AppSettingsState` with `cloudflareTunnel: CloudflareTunnelSettings`.
+- In `normalizeAppSettings`, parse the field, falling back to `CLOUDFLARE_TUNNEL_DEFAULTS`. Push warnings for malformed values.
+- In `toSnapshot`, include `cloudflareTunnel`.
+- In `AppSettingsManager.update` (or its setter equivalent), accept `Partial` patches.
+
+Add a setter method:
+
+```ts
+async setCloudflareTunnel(patch: Partial) {
+ const next: CloudflareTunnelSettings = { ...this.state.cloudflareTunnel, ...patch }
+ // validate mode
+ if (next.mode !== "always-ask" && next.mode !== "auto-expose") {
+ throw new Error("Invalid cloudflareTunnel.mode")
+ }
+ // ... write file, emit listeners
+}
+```
+
+**Step 4: Run tests, verify pass**
+
+Run: `bun test src/server/app-settings.test.ts`
+Expected: PASS — all existing + new cloudflareTunnel tests green.
+
+**Step 5: Commit**
+
+```bash
+git add src/server/app-settings.ts src/server/app-settings.test.ts
+git commit -m "feat(tunnel): persist cloudflare tunnel settings with normalization"
+```
+
+---
+
+## Task 3: Tunnel events module
+
+**Files:**
+- Create: `src/server/cloudflare-tunnel/events.ts`
+- Create: `src/server/cloudflare-tunnel/events.test.ts`
+
+**Step 1: Write failing test**
+
+`src/server/cloudflare-tunnel/events.test.ts`:
+
+```ts
+import { describe, expect, test } from "bun:test"
+import { CLOUDFLARE_TUNNEL_EVENT_VERSION, type CloudflareTunnelEvent } from "./events"
+
+describe("cloudflare tunnel events", () => {
+ test("event version is 1", () => {
+ expect(CLOUDFLARE_TUNNEL_EVENT_VERSION).toBe(1)
+ })
+
+ test("discriminated union allows all five kinds", () => {
+ const kinds: CloudflareTunnelEvent["kind"][] = [
+ "tunnel_proposed",
+ "tunnel_accepted",
+ "tunnel_active",
+ "tunnel_stopped",
+ "tunnel_failed",
+ ]
+ expect(kinds).toHaveLength(5)
+ })
+})
+```
+
+**Step 2: Run test, verify fail**
+
+Run: `bun test src/server/cloudflare-tunnel/events.test.ts`
+Expected: FAIL — module does not exist.
+
+**Step 3: Implement events**
+
+`src/server/cloudflare-tunnel/events.ts`:
+
+```ts
+export const CLOUDFLARE_TUNNEL_EVENT_VERSION = 1 as const
+
+interface BaseTunnelEvent {
+ v: typeof CLOUDFLARE_TUNNEL_EVENT_VERSION
+ timestamp: number
+ chatId: string
+ tunnelId: string
+}
+
+export type CloudflareTunnelEvent =
+ | (BaseTunnelEvent & {
+ kind: "tunnel_proposed"
+ port: number
+ sourcePid: number | null
+ })
+ | (BaseTunnelEvent & {
+ kind: "tunnel_accepted"
+ source: "user" | "auto_setting"
+ })
+ | (BaseTunnelEvent & {
+ kind: "tunnel_active"
+ url: string
+ })
+ | (BaseTunnelEvent & {
+ kind: "tunnel_stopped"
+ reason: "user" | "source_exited" | "session_closed" | "server_shutdown"
+ })
+ | (BaseTunnelEvent & {
+ kind: "tunnel_failed"
+ error: string
+ })
+```
+
+**Step 4: Run test, verify pass**
+
+Run: `bun test src/server/cloudflare-tunnel/events.test.ts`
+Expected: PASS.
+
+**Step 5: Commit**
+
+```bash
+git add src/server/cloudflare-tunnel/events.ts src/server/cloudflare-tunnel/events.test.ts
+git commit -m "feat(tunnel): event types for cloudflare tunnel state machine"
+```
+
+---
+
+## Task 4: Tunnel read-model projection
+
+**Files:**
+- Create: `src/server/cloudflare-tunnel/read-model.ts`
+- Create: `src/server/cloudflare-tunnel/read-model.test.ts`
+
+**Step 1: Write failing tests**
+
+`src/server/cloudflare-tunnel/read-model.test.ts`:
+
+```ts
+import { describe, expect, test } from "bun:test"
+import { deriveChatTunnels } from "./read-model"
+import type { CloudflareTunnelEvent } from "./events"
+
+const base = { v: 1 as const, chatId: "c1", tunnelId: "t1" }
+
+describe("deriveChatTunnels", () => {
+ test("empty events → empty projection", () => {
+ expect(deriveChatTunnels([], "c1")).toEqual({ tunnels: {}, liveTunnelId: null })
+ })
+
+ test("proposed → active → stopped flow", () => {
+ const events: CloudflareTunnelEvent[] = [
+ { ...base, kind: "tunnel_proposed", timestamp: 1, port: 5173, sourcePid: 123 },
+ { ...base, kind: "tunnel_accepted", timestamp: 2, source: "user" },
+ { ...base, kind: "tunnel_active", timestamp: 3, url: "https://abc.trycloudflare.com" },
+ { ...base, kind: "tunnel_stopped", timestamp: 4, reason: "user" },
+ ]
+ const proj = deriveChatTunnels(events, "c1")
+ expect(proj.tunnels.t1.state).toBe("stopped")
+ expect(proj.tunnels.t1.url).toBe("https://abc.trycloudflare.com")
+ expect(proj.liveTunnelId).toBeNull()
+ })
+
+ test("liveTunnelId tracks proposed/active", () => {
+ const events: CloudflareTunnelEvent[] = [
+ { ...base, kind: "tunnel_proposed", timestamp: 1, port: 5173, sourcePid: null },
+ ]
+ expect(deriveChatTunnels(events, "c1").liveTunnelId).toBe("t1")
+ })
+
+ test("failed state preserves error", () => {
+ const events: CloudflareTunnelEvent[] = [
+ { ...base, kind: "tunnel_proposed", timestamp: 1, port: 5173, sourcePid: null },
+ { ...base, kind: "tunnel_failed", timestamp: 2, error: "cloudflared not found" },
+ ]
+ const proj = deriveChatTunnels(events, "c1")
+ expect(proj.tunnels.t1.state).toBe("failed")
+ expect(proj.tunnels.t1.error).toBe("cloudflared not found")
+ })
+
+ test("filters by chatId", () => {
+ const events: CloudflareTunnelEvent[] = [
+ { ...base, chatId: "c2", kind: "tunnel_proposed", timestamp: 1, port: 5173, sourcePid: null },
+ ]
+ expect(deriveChatTunnels(events, "c1")).toEqual({ tunnels: {}, liveTunnelId: null })
+ })
+})
+```
+
+**Step 2: Run test, verify fail**
+
+Run: `bun test src/server/cloudflare-tunnel/read-model.test.ts`
+Expected: FAIL — `deriveChatTunnels` not exported.
+
+**Step 3: Implement read-model**
+
+`src/server/cloudflare-tunnel/read-model.ts`:
+
+```ts
+import type { CloudflareTunnelRecord } from "../../shared/types"
+import type { CloudflareTunnelEvent } from "./events"
+
+export interface ChatTunnelsProjection {
+ tunnels: Record
+ liveTunnelId: string | null
+}
+
+const EMPTY: ChatTunnelsProjection = { tunnels: {}, liveTunnelId: null }
+
+export function deriveChatTunnels(
+ events: readonly CloudflareTunnelEvent[],
+ chatId?: string,
+): ChatTunnelsProjection {
+ const tunnels: Record = {}
+ let liveTunnelId: string | null = null
+
+ for (const event of events) {
+ if (chatId && event.chatId !== chatId) continue
+ applyOne(tunnels, event)
+ const record = tunnels[event.tunnelId]
+ if (record && (record.state === "proposed" || record.state === "active")) {
+ liveTunnelId = record.tunnelId
+ } else if (liveTunnelId === event.tunnelId) {
+ liveTunnelId = null
+ }
+ }
+
+ if (Object.keys(tunnels).length === 0 && liveTunnelId === null) return EMPTY
+ return { tunnels, liveTunnelId }
+}
+
+function applyOne(tunnels: Record, event: CloudflareTunnelEvent): void {
+ switch (event.kind) {
+ case "tunnel_proposed":
+ tunnels[event.tunnelId] = {
+ tunnelId: event.tunnelId,
+ chatId: event.chatId,
+ port: event.port,
+ state: "proposed",
+ url: null,
+ error: null,
+ proposedAt: event.timestamp,
+ activatedAt: null,
+ stoppedAt: null,
+ }
+ return
+ case "tunnel_accepted": {
+ const existing = tunnels[event.tunnelId]
+ if (!existing) return
+ // accepted is a transitional event; keep state proposed until tunnel_active arrives
+ tunnels[event.tunnelId] = { ...existing }
+ return
+ }
+ case "tunnel_active": {
+ const existing = tunnels[event.tunnelId]
+ if (!existing) return
+ tunnels[event.tunnelId] = {
+ ...existing,
+ state: "active",
+ url: event.url,
+ activatedAt: event.timestamp,
+ }
+ return
+ }
+ case "tunnel_stopped": {
+ const existing = tunnels[event.tunnelId]
+ if (!existing) return
+ tunnels[event.tunnelId] = { ...existing, state: "stopped", stoppedAt: event.timestamp }
+ return
+ }
+ case "tunnel_failed": {
+ const existing = tunnels[event.tunnelId]
+ if (!existing) return
+ tunnels[event.tunnelId] = { ...existing, state: "failed", error: event.error }
+ return
+ }
+ default: {
+ const _exhaustive: never = event
+ void _exhaustive
+ return
+ }
+ }
+}
+```
+
+**Step 4: Run test, verify pass**
+
+Run: `bun test src/server/cloudflare-tunnel/read-model.test.ts`
+Expected: PASS — all 5 tests green.
+
+**Step 5: Commit**
+
+```bash
+git add src/server/cloudflare-tunnel/read-model.ts src/server/cloudflare-tunnel/read-model.test.ts
+git commit -m "feat(tunnel): event-sourced read-model projection"
+```
+
+---
+
+## Task 5: Haiku-backed port detector
+
+**Files:**
+- Create: `src/server/cloudflare-tunnel/detector.ts`
+- Create: `src/server/cloudflare-tunnel/detector.test.ts`
+
+**Step 1: Write failing tests with stubbed haiku client**
+
+`src/server/cloudflare-tunnel/detector.test.ts`:
+
+```ts
+import { describe, expect, test } from "bun:test"
+import { evaluateBashOutput, type HaikuClient } from "./detector"
+
+const stub = (response: string): HaikuClient => ({
+ classify: async () => response,
+})
+
+describe("evaluateBashOutput", () => {
+ test("returns server hit when haiku reports JSON {isServer: true, port: 5173}", async () => {
+ const client = stub('{"isServer": true, "port": 5173}')
+ const result = await evaluateBashOutput({
+ command: "bun run dev",
+ stdout: "Local: http://localhost:5173",
+ client,
+ })
+ expect(result).toEqual({ isServer: true, port: 5173 })
+ })
+
+ test("returns no-server when haiku reports false", async () => {
+ const client = stub('{"isServer": false}')
+ const result = await evaluateBashOutput({ command: "ls", stdout: "a b c", client })
+ expect(result).toEqual({ isServer: false })
+ })
+
+ test("returns no-server on malformed JSON", async () => {
+ const client = stub("not json at all")
+ const result = await evaluateBashOutput({ command: "bun run dev", stdout: "...", client })
+ expect(result).toEqual({ isServer: false })
+ })
+
+ test("returns no-server when haiku throws", async () => {
+ const client: HaikuClient = { classify: async () => { throw new Error("rate limit") } }
+ const result = await evaluateBashOutput({ command: "x", stdout: "y", client })
+ expect(result).toEqual({ isServer: false })
+ })
+
+ test("rejects ports outside 1-65535", async () => {
+ const client = stub('{"isServer": true, "port": 99999}')
+ const result = await evaluateBashOutput({ command: "x", stdout: "y", client })
+ expect(result).toEqual({ isServer: false })
+ })
+
+ test("trims stdout to last 2KB before sending to haiku", async () => {
+ let capturedLen = 0
+ const client: HaikuClient = {
+ classify: async (prompt) => { capturedLen = prompt.length; return '{"isServer": false}' },
+ }
+ await evaluateBashOutput({ command: "x", stdout: "a".repeat(10_000), client })
+ expect(capturedLen).toBeLessThanOrEqual(4096)
+ })
+})
+```
+
+**Step 2: Run test, verify fail**
+
+Run: `bun test src/server/cloudflare-tunnel/detector.test.ts`
+Expected: FAIL — module missing.
+
+**Step 3: Implement detector**
+
+`src/server/cloudflare-tunnel/detector.ts`:
+
+```ts
+export interface HaikuClient {
+ classify(prompt: string): Promise
+}
+
+export interface DetectorInput {
+ command: string
+ stdout: string
+ client: HaikuClient
+}
+
+export type DetectorResult =
+ | { isServer: true; port: number }
+ | { isServer: false }
+
+const STDOUT_TAIL_LIMIT = 2048
+const MAX_PROMPT_LEN = 4096
+
+const SYSTEM = "Given a shell command and its stdout, return ONLY a JSON object: {\"isServer\": boolean, \"port\"?: number}. isServer is true ONLY if the command started a long-running HTTP/TCP service that is now listening. port is the listening port (1-65535)."
+
+export async function evaluateBashOutput(input: DetectorInput): Promise {
+ const tail = input.stdout.slice(-STDOUT_TAIL_LIMIT)
+ const prompt = `${SYSTEM}\n\nCommand: ${input.command}\n\nStdout:\n${tail}`.slice(0, MAX_PROMPT_LEN)
+
+ let raw: string
+ try {
+ raw = await input.client.classify(prompt)
+ } catch {
+ return { isServer: false }
+ }
+
+ const parsed = parseClassification(raw)
+ return parsed
+}
+
+function parseClassification(raw: string): DetectorResult {
+ try {
+ const obj = JSON.parse(raw) as unknown
+ if (!obj || typeof obj !== "object") return { isServer: false }
+ const record = obj as Record
+ if (record.isServer !== true) return { isServer: false }
+ const port = record.port
+ if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535) {
+ return { isServer: false }
+ }
+ return { isServer: true, port }
+ } catch {
+ return { isServer: false }
+ }
+}
+```
+
+Also create `src/server/cloudflare-tunnel/haiku-client.ts` (production wrapper around `@anthropic-ai/claude-agent-sdk` — *do not* add tests for this; covered in e2e):
+
+```ts
+import Anthropic from "@anthropic-ai/sdk"
+import type { HaikuClient } from "./detector"
+
+export function createHaikuClient(apiKey: string): HaikuClient {
+ const client = new Anthropic({ apiKey })
+ return {
+ async classify(prompt: string) {
+ const response = await client.messages.create({
+ model: "claude-haiku-4-5-20251001",
+ max_tokens: 64,
+ messages: [{ role: "user", content: prompt }],
+ })
+ const block = response.content.find((b) => b.type === "text")
+ return block && block.type === "text" ? block.text : ""
+ },
+ }
+}
+```
+
+(Confirm `@anthropic-ai/sdk` is in `package.json`. If `@anthropic-ai/claude-agent-sdk` is the actual dep, adapt the import accordingly — check `package.json` first.)
+
+**Step 4: Run test, verify pass**
+
+Run: `bun test src/server/cloudflare-tunnel/detector.test.ts`
+Expected: PASS — 6 tests green.
+
+**Step 5: Commit**
+
+```bash
+git add src/server/cloudflare-tunnel/detector.ts src/server/cloudflare-tunnel/detector.test.ts src/server/cloudflare-tunnel/haiku-client.ts
+git commit -m "feat(tunnel): haiku-backed bash output classifier"
+```
+
+---
+
+## Task 6: Tunnel manager (spawn cloudflared, parse URL, port reuse)
+
+**Files:**
+- Create: `src/server/cloudflare-tunnel/tunnel-manager.ts`
+- Create: `src/server/cloudflare-tunnel/tunnel-manager.test.ts`
+
+**Step 1: Write failing tests with spawn injection**
+
+`src/server/cloudflare-tunnel/tunnel-manager.test.ts`:
+
+```ts
+import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"
+import { TunnelManager, type SpawnFn, type ChildHandle } from "./tunnel-manager"
+
+interface FakeChild extends ChildHandle {
+ emitStdout: (chunk: string) => void
+ emitExit: (code: number) => void
+}
+
+function fakeChild(): FakeChild {
+ const stdoutListeners: Array<(c: string) => void> = []
+ const exitListeners: Array<(c: number) => void> = []
+ let killed = false
+ return {
+ pid: 9999,
+ kill: () => { killed = true; for (const l of exitListeners) l(0) },
+ onStdout: (l) => stdoutListeners.push(l),
+ onStderr: () => {},
+ onExit: (l) => exitListeners.push(l),
+ isKilled: () => killed,
+ emitStdout: (chunk) => { for (const l of stdoutListeners) l(chunk) },
+ emitExit: (code) => { for (const l of exitListeners) l(code) },
+ }
+}
+
+describe("TunnelManager", () => {
+ test("spawns cloudflared with --url and parses tunnel URL from stdout", async () => {
+ const child = fakeChild()
+ const spawn: SpawnFn = mock(() => child)
+ const events: any[] = []
+ const mgr = new TunnelManager({
+ spawn,
+ cloudflaredPath: "cloudflared",
+ onEvent: (e) => events.push(e),
+ })
+
+ const tunnelId = await mgr.start({ chatId: "c1", port: 5173, sourcePid: 100 })
+
+ expect(spawn).toHaveBeenCalledWith("cloudflared", ["tunnel", "--url", "http://localhost:5173"])
+ child.emitStdout("INF Your quick Tunnel has been created! Visit https://abc-def.trycloudflare.com\n")
+ await new Promise((r) => setTimeout(r, 0))
+
+ expect(events.find((e) => e.kind === "tunnel_active")).toMatchObject({
+ tunnelId,
+ url: "https://abc-def.trycloudflare.com",
+ })
+ })
+
+ test("reuses existing tunnel when same port requested twice", async () => {
+ const child = fakeChild()
+ const spawn = mock(() => child)
+ const mgr = new TunnelManager({ spawn, cloudflaredPath: "cloudflared", onEvent: () => {} })
+
+ const a = await mgr.start({ chatId: "c1", port: 5173, sourcePid: 100 })
+ const b = await mgr.start({ chatId: "c1", port: 5173, sourcePid: 100 })
+ expect(a).toBe(b)
+ expect(spawn).toHaveBeenCalledTimes(1)
+ })
+
+ test("emits tunnel_failed when spawn throws ENOENT", async () => {
+ const spawn: SpawnFn = () => { const e: any = new Error("ENOENT"); e.code = "ENOENT"; throw e }
+ const events: any[] = []
+ const mgr = new TunnelManager({
+ spawn,
+ cloudflaredPath: "cloudflared",
+ onEvent: (e) => events.push(e),
+ })
+ await mgr.start({ chatId: "c1", port: 5173, sourcePid: 100 })
+ const failed = events.find((e) => e.kind === "tunnel_failed")
+ expect(failed.error).toContain("cloudflared")
+ })
+
+ test("stop() kills child and emits tunnel_stopped reason=user", async () => {
+ const child = fakeChild()
+ const spawn = mock(() => child)
+ const events: any[] = []
+ const mgr = new TunnelManager({
+ spawn,
+ cloudflaredPath: "cloudflared",
+ onEvent: (e) => events.push(e),
+ })
+
+ const id = await mgr.start({ chatId: "c1", port: 5173, sourcePid: 100 })
+ await mgr.stop(id, "user")
+
+ expect(events.find((e) => e.kind === "tunnel_stopped")?.reason).toBe("user")
+ })
+
+ test("emits tunnel_failed when child exits non-zero before URL parsed", async () => {
+ const child = fakeChild()
+ const spawn = mock(() => child)
+ const events: any[] = []
+ const mgr = new TunnelManager({
+ spawn,
+ cloudflaredPath: "cloudflared",
+ onEvent: (e) => events.push(e),
+ })
+ await mgr.start({ chatId: "c1", port: 5173, sourcePid: 100 })
+ child.emitExit(1)
+ expect(events.some((e) => e.kind === "tunnel_failed")).toBe(true)
+ })
+})
+```
+
+**Step 2: Run test, verify fail**
+
+Run: `bun test src/server/cloudflare-tunnel/tunnel-manager.test.ts`
+Expected: FAIL — module missing.
+
+**Step 3: Implement tunnel-manager**
+
+`src/server/cloudflare-tunnel/tunnel-manager.ts`:
+
+```ts
+import { randomUUID } from "node:crypto"
+import { spawn as nodeSpawn } from "node:child_process"
+import type { CloudflareTunnelEvent } from "./events"
+import { CLOUDFLARE_TUNNEL_EVENT_VERSION } from "./events"
+
+export interface ChildHandle {
+ pid: number
+ kill: () => void
+ onStdout: (listener: (chunk: string) => void) => void
+ onStderr: (listener: (chunk: string) => void) => void
+ onExit: (listener: (code: number) => void) => void
+ isKilled: () => boolean
+}
+
+export type SpawnFn = (cmd: string, args: string[]) => ChildHandle
+
+export interface TunnelManagerArgs {
+ spawn?: SpawnFn
+ cloudflaredPath: string
+ onEvent: (event: CloudflareTunnelEvent) => void
+ now?: () => number
+}
+
+interface TunnelRecord {
+ tunnelId: string
+ chatId: string
+ port: number
+ sourcePid: number | null
+ child: ChildHandle
+ state: "starting" | "active" | "stopped" | "failed"
+}
+
+const TRYCF_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
+
+export class TunnelManager {
+ private readonly spawn: SpawnFn
+ private readonly cloudflaredPath: string
+ private readonly onEvent: (event: CloudflareTunnelEvent) => void
+ private readonly now: () => number
+ private readonly byPort = new Map()
+ private readonly byTunnel = new Map()
+
+ constructor(args: TunnelManagerArgs) {
+ this.spawn = args.spawn ?? defaultSpawn
+ this.cloudflaredPath = args.cloudflaredPath
+ this.onEvent = args.onEvent
+ this.now = args.now ?? (() => Date.now())
+ }
+
+ async start(input: { chatId: string; port: number; sourcePid: number | null }): Promise {
+ const existing = this.byPort.get(input.port)
+ if (existing) return existing
+
+ const tunnelId = randomUUID()
+ let child: ChildHandle
+ try {
+ child = this.spawn(this.cloudflaredPath, ["tunnel", "--url", `http://localhost:${input.port}`])
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ this.onEvent({
+ v: CLOUDFLARE_TUNNEL_EVENT_VERSION,
+ kind: "tunnel_failed",
+ timestamp: this.now(),
+ chatId: input.chatId,
+ tunnelId,
+ error: `cloudflared failed to start: ${message}`,
+ })
+ return tunnelId
+ }
+
+ const record: TunnelRecord = {
+ tunnelId,
+ chatId: input.chatId,
+ port: input.port,
+ sourcePid: input.sourcePid,
+ child,
+ state: "starting",
+ }
+ this.byPort.set(input.port, tunnelId)
+ this.byTunnel.set(tunnelId, record)
+
+ child.onStdout((chunk) => this.handleStdout(record, chunk))
+ child.onStderr((chunk) => this.handleStdout(record, chunk))
+ child.onExit((code) => this.handleExit(record, code))
+
+ return tunnelId
+ }
+
+ async stop(tunnelId: string, reason: "user" | "source_exited" | "session_closed" | "server_shutdown"): Promise {
+ const record = this.byTunnel.get(tunnelId)
+ if (!record) return
+ if (record.state === "stopped" || record.state === "failed") return
+ record.state = "stopped"
+ record.child.kill()
+ this.byPort.delete(record.port)
+ this.onEvent({
+ v: CLOUDFLARE_TUNNEL_EVENT_VERSION,
+ kind: "tunnel_stopped",
+ timestamp: this.now(),
+ chatId: record.chatId,
+ tunnelId,
+ reason,
+ })
+ }
+
+ shutdown() {
+ for (const id of [...this.byTunnel.keys()]) {
+ void this.stop(id, "server_shutdown")
+ }
+ }
+
+ private handleStdout(record: TunnelRecord, chunk: string) {
+ if (record.state !== "starting") return
+ const match = TRYCF_URL_RE.exec(chunk)
+ if (!match) return
+ record.state = "active"
+ this.onEvent({
+ v: CLOUDFLARE_TUNNEL_EVENT_VERSION,
+ kind: "tunnel_active",
+ timestamp: this.now(),
+ chatId: record.chatId,
+ tunnelId: record.tunnelId,
+ url: match[0],
+ })
+ }
+
+ private handleExit(record: TunnelRecord, code: number) {
+ this.byPort.delete(record.port)
+ if (record.state === "starting") {
+ record.state = "failed"
+ this.onEvent({
+ v: CLOUDFLARE_TUNNEL_EVENT_VERSION,
+ kind: "tunnel_failed",
+ timestamp: this.now(),
+ chatId: record.chatId,
+ tunnelId: record.tunnelId,
+ error: `cloudflared exited (code ${code}) before tunnel URL appeared`,
+ })
+ return
+ }
+ if (record.state === "active") {
+ record.state = "stopped"
+ this.onEvent({
+ v: CLOUDFLARE_TUNNEL_EVENT_VERSION,
+ kind: "tunnel_stopped",
+ timestamp: this.now(),
+ chatId: record.chatId,
+ tunnelId: record.tunnelId,
+ reason: "source_exited",
+ })
+ }
+ }
+}
+
+function defaultSpawn(cmd: string, args: string[]): ChildHandle {
+ const proc = nodeSpawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] })
+ return {
+ pid: proc.pid ?? -1,
+ kill: () => { proc.kill("SIGTERM") },
+ onStdout: (l) => proc.stdout.on("data", (b) => l(b.toString("utf8"))),
+ onStderr: (l) => proc.stderr.on("data", (b) => l(b.toString("utf8"))),
+ onExit: (l) => proc.on("exit", (code) => l(code ?? 0)),
+ isKilled: () => proc.killed,
+ }
+}
+```
+
+Public re-export `start` event by also emitting `tunnel_proposed` from caller — manager itself emits `_active`/`_stopped`/`_failed`. The `_proposed` and `_accepted` events come from the agent integration (Task 8).
+
+**Step 4: Run test, verify pass**
+
+Run: `bun test src/server/cloudflare-tunnel/tunnel-manager.test.ts`
+Expected: PASS — 5 tests green.
+
+**Step 5: Commit**
+
+```bash
+git add src/server/cloudflare-tunnel/tunnel-manager.ts src/server/cloudflare-tunnel/tunnel-manager.test.ts
+git commit -m "feat(tunnel): tunnel-manager spawns cloudflared and parses trycloudflare URL"
+```
+
+---
+
+## Task 7: Lifecycle watcher (source PID + session close)
+
+**Files:**
+- Create: `src/server/cloudflare-tunnel/lifecycle.ts`
+- Create: `src/server/cloudflare-tunnel/lifecycle.test.ts`
+
+**Step 1: Write failing tests**
+
+`src/server/cloudflare-tunnel/lifecycle.test.ts`:
+
+```ts
+import { afterEach, describe, expect, test } from "bun:test"
+import { TunnelLifecycle } from "./lifecycle"
+
+describe("TunnelLifecycle", () => {
+ test("polls source PID; calls onSourceExit when process gone", async () => {
+ const exited: string[] = []
+ let alive = true
+ const lc = new TunnelLifecycle({
+ pollIntervalMs: 5,
+ isPidAlive: () => alive,
+ onSourceExit: (id) => exited.push(id),
+ })
+ lc.watch("t1", 1234)
+ alive = false
+ await new Promise((r) => setTimeout(r, 30))
+ expect(exited).toContain("t1")
+ lc.shutdown()
+ })
+
+ test("unwatch stops polling for a tunnel", async () => {
+ const exited: string[] = []
+ let alive = true
+ const lc = new TunnelLifecycle({
+ pollIntervalMs: 5,
+ isPidAlive: () => alive,
+ onSourceExit: (id) => exited.push(id),
+ })
+ lc.watch("t1", 1234)
+ lc.unwatch("t1")
+ alive = false
+ await new Promise((r) => setTimeout(r, 30))
+ expect(exited).toEqual([])
+ lc.shutdown()
+ })
+
+ test("does not fire onSourceExit when sourcePid is null", async () => {
+ const exited: string[] = []
+ const lc = new TunnelLifecycle({
+ pollIntervalMs: 5,
+ isPidAlive: () => false,
+ onSourceExit: (id) => exited.push(id),
+ })
+ lc.watch("t1", null)
+ await new Promise((r) => setTimeout(r, 30))
+ expect(exited).toEqual([])
+ lc.shutdown()
+ })
+})
+```
+
+**Step 2: Run test, verify fail**
+
+Run: `bun test src/server/cloudflare-tunnel/lifecycle.test.ts`
+Expected: FAIL — module missing.
+
+**Step 3: Implement lifecycle**
+
+`src/server/cloudflare-tunnel/lifecycle.ts`:
+
+```ts
+export interface TunnelLifecycleArgs {
+ pollIntervalMs?: number
+ isPidAlive?: (pid: number) => boolean
+ onSourceExit: (tunnelId: string) => void
+}
+
+export class TunnelLifecycle {
+ private readonly pollIntervalMs: number
+ private readonly isPidAlive: (pid: number) => boolean
+ private readonly onSourceExit: (tunnelId: string) => void
+ private readonly watched = new Map()
+ private timer: ReturnType | null = null
+
+ constructor(args: TunnelLifecycleArgs) {
+ this.pollIntervalMs = args.pollIntervalMs ?? 1500
+ this.isPidAlive = args.isPidAlive ?? defaultIsPidAlive
+ this.onSourceExit = args.onSourceExit
+ }
+
+ watch(tunnelId: string, sourcePid: number | null) {
+ this.watched.set(tunnelId, sourcePid)
+ this.ensureTimer()
+ }
+
+ unwatch(tunnelId: string) {
+ this.watched.delete(tunnelId)
+ if (this.watched.size === 0 && this.timer) {
+ clearInterval(this.timer)
+ this.timer = null
+ }
+ }
+
+ shutdown() {
+ if (this.timer) clearInterval(this.timer)
+ this.timer = null
+ this.watched.clear()
+ }
+
+ private ensureTimer() {
+ if (this.timer) return
+ this.timer = setInterval(() => this.tick(), this.pollIntervalMs)
+ }
+
+ private tick() {
+ for (const [tunnelId, pid] of [...this.watched.entries()]) {
+ if (pid === null) continue
+ if (!this.isPidAlive(pid)) {
+ this.unwatch(tunnelId)
+ this.onSourceExit(tunnelId)
+ }
+ }
+ }
+}
+
+function defaultIsPidAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0)
+ return true
+ } catch {
+ return false
+ }
+}
+```
+
+**Step 4: Run test, verify pass**
+
+Run: `bun test src/server/cloudflare-tunnel/lifecycle.test.ts`
+Expected: PASS — 3 tests green.
+
+**Step 5: Commit**
+
+```bash
+git add src/server/cloudflare-tunnel/lifecycle.ts src/server/cloudflare-tunnel/lifecycle.test.ts
+git commit -m "feat(tunnel): lifecycle watcher polls source PID for exit detection"
+```
+
+---
+
+## Task 8: Agent integration — Bash result hook + WS commands
+
+**Files:**
+- Modify: `src/server/agent.ts`
+- Modify: `src/server/server.ts` (compose manager, lifecycle, store)
+- Modify: `src/server/ws-router.ts` (handle accept/stop/retry commands)
+- Modify: `src/server/event-store.ts` (persist tunnel events) — read it first to confirm pattern
+- Test: `src/server/cloudflare-tunnel/agent-integration.test.ts` (new)
+
+**Step 0: Read existing patterns first**
+
+Run before coding:
+```bash
+grep -n "appendAutoContinueEvent\|getAutoContinueEvents" src/server/event-store.ts
+```
+Mirror these for `appendTunnelEvent` / `getTunnelEvents`.
+
+Also read `src/server/ws-router.ts` to see how `acceptAutoContinue` etc. are wired — copy that shape.
+
+**Step 1: Write failing integration test**
+
+`src/server/cloudflare-tunnel/agent-integration.test.ts`:
+
+```ts
+import { describe, expect, test } from "bun:test"
+import { handleBashToolResult } from "./agent-integration"
+import type { HaikuClient } from "./detector"
+
+describe("handleBashToolResult", () => {
+ test("emits tunnel_proposed when detector hits and feature enabled", async () => {
+ const events: any[] = []
+ await handleBashToolResult({
+ command: "bun run dev",
+ stdout: "Local: http://localhost:5173",
+ chatId: "c1",
+ sourcePid: 100,
+ settings: { enabled: true, cloudflaredPath: "cloudflared", mode: "always-ask" },
+ haiku: { classify: async () => '{"isServer": true, "port": 5173}' } as HaikuClient,
+ onEvent: (e) => events.push(e),
+ autoStart: () => Promise.resolve(),
+ })
+ expect(events.find((e) => e.kind === "tunnel_proposed")).toMatchObject({ port: 5173 })
+ })
+
+ test("skips detector when disabled", async () => {
+ let called = false
+ await handleBashToolResult({
+ command: "bun run dev",
+ stdout: "Local: http://localhost:5173",
+ chatId: "c1",
+ sourcePid: 100,
+ settings: { enabled: false, cloudflaredPath: "cloudflared", mode: "always-ask" },
+ haiku: { classify: async () => { called = true; return "{}" } } as HaikuClient,
+ onEvent: () => {},
+ autoStart: () => Promise.resolve(),
+ })
+ expect(called).toBe(false)
+ })
+
+ test("auto-expose mode triggers autoStart", async () => {
+ const startCalls: any[] = []
+ await handleBashToolResult({
+ command: "bun run dev",
+ stdout: "...",
+ chatId: "c1",
+ sourcePid: 100,
+ settings: { enabled: true, cloudflaredPath: "cloudflared", mode: "auto-expose" },
+ haiku: { classify: async () => '{"isServer": true, "port": 5173}' } as HaikuClient,
+ onEvent: () => {},
+ autoStart: async (args) => { startCalls.push(args) },
+ })
+ expect(startCalls).toHaveLength(1)
+ })
+})
+```
+
+**Step 2: Run test, verify fail**
+
+Run: `bun test src/server/cloudflare-tunnel/agent-integration.test.ts`
+Expected: FAIL — module missing.
+
+**Step 3: Implement agent-integration**
+
+Create `src/server/cloudflare-tunnel/agent-integration.ts`:
+
+```ts
+import { randomUUID } from "node:crypto"
+import type { CloudflareTunnelSettings } from "../../shared/types"
+import { evaluateBashOutput, type HaikuClient } from "./detector"
+import type { CloudflareTunnelEvent } from "./events"
+import { CLOUDFLARE_TUNNEL_EVENT_VERSION } from "./events"
+
+export interface HandleBashArgs {
+ command: string
+ stdout: string
+ chatId: string
+ sourcePid: number | null
+ settings: CloudflareTunnelSettings
+ haiku: HaikuClient
+ onEvent: (event: CloudflareTunnelEvent) => void
+ autoStart: (args: { chatId: string; tunnelId: string; port: number; sourcePid: number | null }) => Promise
+ now?: () => number
+}
+
+export async function handleBashToolResult(args: HandleBashArgs): Promise {
+ if (!args.settings.enabled) return
+ const result = await evaluateBashOutput({
+ command: args.command,
+ stdout: args.stdout,
+ client: args.haiku,
+ })
+ if (!result.isServer) return
+
+ const tunnelId = randomUUID()
+ const now = (args.now ?? Date.now)()
+ args.onEvent({
+ v: CLOUDFLARE_TUNNEL_EVENT_VERSION,
+ kind: "tunnel_proposed",
+ timestamp: now,
+ chatId: args.chatId,
+ tunnelId,
+ port: result.port,
+ sourcePid: args.sourcePid,
+ })
+
+ if (args.settings.mode === "auto-expose") {
+ args.onEvent({
+ v: CLOUDFLARE_TUNNEL_EVENT_VERSION,
+ kind: "tunnel_accepted",
+ timestamp: now,
+ chatId: args.chatId,
+ tunnelId,
+ source: "auto_setting",
+ })
+ await args.autoStart({ chatId: args.chatId, tunnelId, port: result.port, sourcePid: args.sourcePid })
+ }
+}
+```
+
+**Step 4: Wire into agent.ts at tool_result hook (line ~385)**
+
+In `src/server/agent.ts`, locate the `tool_result` branch (around line 385–393). Inject a call to `handleBashToolResult` when `tool_use_id` matches a previously-recorded `Bash` tool call. Maintain a small `Map` populated in the `tool_use` branch (line 366) when `content.name === "Bash"`.
+
+Add to `Agent` constructor / state:
+
+```ts
+private readonly pendingBashCalls = new Map()
+```
+
+In `tool_use` branch (when name is "Bash"):
+```ts
+const command = typeof content.input?.command === "string" ? content.input.command : ""
+this.pendingBashCalls.set(content.id, { command, chatId })
+```
+
+In `tool_result` branch:
+```ts
+const pending = this.pendingBashCalls.get(content.tool_use_id)
+if (pending) {
+ this.pendingBashCalls.delete(content.tool_use_id)
+ const stdout = stringifyToolResultContent(content.content)
+ void this.tunnelGateway?.handleBashResult({
+ command: pending.command,
+ stdout,
+ chatId: pending.chatId,
+ sourcePid: null, // Bash tool runs inside Claude SDK; PID not exposed — keep null for v1
+ })
+}
+```
+
+Add a `tunnelGateway?: TunnelGateway` to `Agent` constructor args. Define `TunnelGateway` in `src/server/cloudflare-tunnel/gateway.ts` as a thin façade exposing `handleBashResult`, `accept(tunnelId)`, `stop(tunnelId)`, `retry(tunnelId)` — composing `handleBashToolResult` + `TunnelManager` + event store + WS broadcast.
+
+**Step 5: Wire WS commands in `ws-router.ts`**
+
+Add three new WS message kinds (mirror `acceptAutoContinue` shape):
+- `tunnel.accept { tunnelId }`
+- `tunnel.stop { tunnelId }`
+- `tunnel.retry { tunnelId }`
+
+Each routes to corresponding `tunnelGateway` method. Server constructs `tunnelGateway` in `server.ts` and passes to `Agent` + `wsRouter`.
+
+**Step 6: Run targeted tests**
+
+Run: `bun test src/server/cloudflare-tunnel/`
+Expected: PASS — all module tests green.
+
+Run: `bun test src/server/agent.test.ts src/server/ws-router.test.ts`
+Expected: PASS — existing tests still green (no regressions).
+
+**Step 7: Commit**
+
+```bash
+git add src/server/cloudflare-tunnel/agent-integration.ts \
+ src/server/cloudflare-tunnel/agent-integration.test.ts \
+ src/server/cloudflare-tunnel/gateway.ts \
+ src/server/agent.ts src/server/server.ts src/server/ws-router.ts \
+ src/server/event-store.ts
+git commit -m "feat(tunnel): wire detector + manager into agent Bash tool path"
+```
+
+---
+
+## Task 9: Client read-model + WS handler
+
+**Files:**
+- Modify: `src/client/app/socket.ts` (handle new tunnel WS messages)
+- Modify: `src/client/app/useKannaState.ts` (extend snapshot with `tunnels`)
+- Test: colocated `*.test.ts`
+
+**Step 1: Read existing pattern**
+
+Run:
+```bash
+grep -n "autoContinue\|schedules" src/client/app/socket.ts src/client/app/useKannaState.ts | head -30
+```
+
+Mirror this pattern for `cloudflareTunnel`.
+
+**Step 2: Write failing test (snapshot reducer)**
+
+In `src/client/app/useKannaState.test.ts` add cases for:
+- `tunnel_proposed` event adds proposed record to `state.tunnelsByChat[chatId][tunnelId]`
+- `tunnel_active` flips state to active with URL
+- `tunnel_stopped` flips to stopped
+
+**Step 3: Run, verify fail. Implement. Run, verify pass.**
+
+**Step 4: Commit**
+
+```bash
+git add src/client/app/socket.ts src/client/app/useKannaState.ts src/client/app/useKannaState.test.ts
+git commit -m "feat(tunnel): client read-model wiring for tunnel events"
+```
+
+---
+
+## Task 10: CloudflareTunnelCard component
+
+**Files:**
+- Create: `src/client/components/chat-ui/CloudflareTunnelCard.tsx`
+- Create: `src/client/components/chat-ui/CloudflareTunnelCard.test.tsx`
+
+**Step 1: Write failing tests**
+
+```tsx
+import { describe, expect, test } from "bun:test"
+import { render, screen, fireEvent } from "@testing-library/react"
+import { CloudflareTunnelCard } from "./CloudflareTunnelCard"
+
+const baseRecord = {
+ tunnelId: "t1",
+ chatId: "c1",
+ port: 5173,
+ url: null,
+ error: null,
+ proposedAt: 1,
+ activatedAt: null,
+ stoppedAt: null,
+}
+
+describe("CloudflareTunnelCard", () => {
+ test("proposed → renders Expose + Dismiss", () => {
+ const onAccept = mock(() => {})
+ const onDismiss = mock(() => {})
+ render( {}}
+ onRetry={() => {}}
+ onDismiss={onDismiss}
+ />)
+ expect(screen.getByText(/Port 5173 detected/)).toBeTruthy()
+ fireEvent.click(screen.getByRole("button", { name: /Expose/ }))
+ expect(onAccept).toHaveBeenCalledWith("t1")
+ })
+
+ test("active → renders URL + Copy + Stop", () => { /* ... */ })
+ test("stopped → renders 'Tunnel stopped'", () => { /* ... */ })
+ test("failed → renders error + Retry", () => { /* ... */ })
+})
+```
+
+(Check existing `AutoContinueCard.test.tsx` for `mock` import + render setup — mirror precisely.)
+
+**Step 2: Run, verify fail. Implement. Run, verify pass.**
+
+Implementation mirrors `AutoContinueCard.tsx` structure (rounded border, action buttons, state switch).
+
+**Step 3: Commit**
+
+```bash
+git add src/client/components/chat-ui/CloudflareTunnelCard.tsx src/client/components/chat-ui/CloudflareTunnelCard.test.tsx
+git commit -m "feat(tunnel): CloudflareTunnelCard mirrors AutoContinueCard state machine"
+```
+
+---
+
+## Task 11: Render card in transcript
+
+**Files:**
+- Modify: `src/client/app/KannaTranscript.tsx` (find AutoContinueCard render site; add tunnel render below it)
+- Test: extend existing `KannaTranscript.test.tsx` (only if it tests rendering integration)
+
+**Step 1: Locate render point**
+
+Run:
+```bash
+grep -n "AutoContinueCard" src/client/app/KannaTranscript.tsx
+```
+
+**Step 2: Add tunnel rendering at same level**
+
+Pull live tunnels for current chat from `useKannaState`, render one `CloudflareTunnelCard` per record. WS dispatch handlers call `socket.send({ kind: "tunnel.accept", tunnelId })` etc.
+
+**Step 3: Build + manual smoke**
+
+Run: `bun run check`
+Expected: PASS — typecheck + build.
+
+**Step 4: Commit**
+
+```bash
+git add src/client/app/KannaTranscript.tsx
+git commit -m "feat(tunnel): render CloudflareTunnelCard inline in transcript"
+```
+
+---
+
+## Task 12: Settings page UI
+
+**Files:**
+- Modify: `src/client/app/SettingsPage.tsx`
+- Modify: `src/client/app/SettingsPage.test.tsx`
+
+**Step 1: Write failing tests**
+
+Cases:
+- Renders "Cloudflare Tunnel" section
+- Toggle flips `enabled` and posts settings update
+- Mode radio updates `mode` setting
+- `cloudflaredPath` input debounce-saves
+- Disabled state greys out mode/path when toggle off
+
+**Step 2: Run, verify fail. Implement section. Run, verify pass.**
+
+**Step 3: Build + commit**
+
+```bash
+bun run check
+git add src/client/app/SettingsPage.tsx src/client/app/SettingsPage.test.tsx
+git commit -m "feat(tunnel): settings page section for cloudflare tunnel toggle/mode/path"
+```
+
+---
+
+## Task 13: End-to-end test
+
+**Files:**
+- Create: `src/server/cloudflare-tunnel/e2e.test.ts`
+
+**Step 1: Write E2E test**
+
+Mirror `src/server/auto-continue/e2e.test.ts` shape. Spin up the gateway with stubbed haiku (returns `{isServer: true, port: 5173}`), stubbed spawn (fake child emitting URL on demand), assert event sequence: `tunnel_proposed → tunnel_accepted → tunnel_active → tunnel_stopped` after `gateway.accept` then `gateway.stop`.
+
+**Step 2: Run, verify fail. Implement gateway hooks if missing. Run, verify pass.**
+
+**Step 3: Commit**
+
+```bash
+git add src/server/cloudflare-tunnel/e2e.test.ts
+git commit -m "test(tunnel): e2e covers propose → accept → active → stop flow"
+```
+
+---
+
+## Task 14: Run full suite + typecheck
+
+**Step 1: Run full suite**
+
+Run: `bun test`
+Expected: PASS — all 724+ existing + new tunnel tests green.
+
+**Step 2: Typecheck + build**
+
+Run: `bun run check`
+Expected: PASS.
+
+**Step 3: If any regression, return to that task and fix. Do not bundle fixes.**
+
+---
+
+## Task 15: Update C3 docs
+
+**Files:**
+- Create: `.c3/c3-2-server/c3-2xx-cloudflare-tunnel.md` (new component)
+- Modify: `.c3/_index/_index.md` (regenerate — let `c3x` rebuild it)
+- Modify: `.c3/c3-1-client/c3-116-settings-page.md` (note new section)
+- Modify: `.c3/c3-2-server/.md` (note new hook)
+
+**Step 1: Read c3 conventions**
+
+```bash
+ls .c3/c3-2-server/
+cat .c3/c3-2-server/.md
+```
+
+Mirror the structure: Goal, Responsibilities, Components, Container Connection, Dependencies, Related Refs.
+
+**Step 2: Write the component doc**
+
+Component fields: `c3-2xx`, container `c3-2`, files glob `src/server/cloudflare-tunnel/**/*.ts`, refs `ref-strong-typing`, `ref-ws-subscription`, `ref-colocated-bun-test`.
+
+**Step 3: Run the C3 sweep**
+
+Run: `c3x lookup src/server/cloudflare-tunnel/tunnel-manager.ts`
+Expected: maps to new component.
+
+**Step 4: Commit**
+
+```bash
+git add .c3/
+git commit -m "docs(c3): add cloudflare-tunnel server component"
+```
+
+---
+
+## Final Checklist
+
+- [ ] All tasks committed with passing tests
+- [ ] `bun test` green (full suite)
+- [ ] `bun run check` green (typecheck + build)
+- [ ] Settings default `enabled: false` (opt-in)
+- [ ] Card mirrors `AutoContinueCard` UX
+- [ ] Tunnel state ephemeral (no DB persistence)
+- [ ] C3 docs updated
+- [ ] Manual smoke: enable feature in settings, run `bun run dev` in a project, see proposed card, click Expose, see active URL, kill `bun run dev` → card flips to stopped.
+
+## Out of Scope (do NOT implement)
+
+- Named tunnels / Cloudflare auth.
+- Auto-install `cloudflared`.
+- Port allow/deny lists.
+- Tunnel persistence across server restarts.
+- Custom regex / non-haiku detection backends.
diff --git a/docs/plans/2026-04-29-persistent-auth-sessions-design.md b/docs/plans/2026-04-29-persistent-auth-sessions-design.md
new file mode 100644
index 000000000..5851dd4e7
--- /dev/null
+++ b/docs/plans/2026-04-29-persistent-auth-sessions-design.md
@@ -0,0 +1,145 @@
+# Persistent Auth Sessions Design
+
+## Problem
+
+Auth sessions are stored in an in-memory `Set` (`src/server/auth.ts:115`) and the session cookie is issued without `Max-Age`. Two consequences:
+
+1. Every server restart or redeploy invalidates all sessions; users must re-enter the password.
+2. Closing the browser drops the session cookie even when the server is still running.
+
+## Goals
+
+- Sessions survive server restart.
+- Sessions survive browser close.
+- Session lifetime is user-configurable through the existing `settings.json`.
+- Session token never persisted in plaintext on disk.
+
+## Non-goals
+
+- Multi-user accounts. Auth is still a single shared password.
+- Refresh tokens, OAuth, MFA.
+- Per-device naming or revocation UI (a future addition; the store is shaped to allow it).
+
+## Configuration
+
+New block in `AppSettingsSnapshot`:
+
+```ts
+export interface AuthSettings {
+ sessionMaxAgeDays: number // clamp [1, 365], default 30
+}
+
+export const AUTH_DEFAULTS: AuthSettings = {
+ sessionMaxAgeDays: 30,
+}
+```
+
+Edited in `settings.json` (or via in-app settings UI in a follow-up). Validation mirrors the existing `cloudflareTunnel` block (`src/server/app-settings.ts:223-246`).
+
+`getMaxAgeMs` is read through a callback at login time, so changes take effect for new logins without a restart. Existing sessions keep their current `expiresAt` and adopt the new value on the next sliding bump.
+
+## Storage
+
+New file: `/sessions.json`. Atomic write (write tmp + rename), same pattern as `app-settings.ts`.
+
+```ts
+interface PersistedSession {
+ tokenHash: string // sha256 hex of the cookie value
+ createdAt: number // ms epoch
+ lastSeenAt: number // ms epoch, bumped on each authed request
+ expiresAt: number // lastSeenAt + maxAgeMs
+}
+
+interface SessionsFile {
+ version: 1
+ sessions: PersistedSession[]
+}
+```
+
+The cookie value is a `randomBytes(32).toString("base64url")` token. Only its SHA-256 hash is written to disk. A disk leak therefore does not yield session takeover.
+
+## Token flow
+
+1. **Login.** Generate token, hash it, persist `{tokenHash, createdAt, lastSeenAt, expiresAt}`. Send raw token in the `kanna_session` cookie with `Max-Age=`.
+2. **Validate request.** Hash the cookie value, look up the entry, check `expiresAt > Date.now()`. Missing or expired entry fails auth (and is pruned).
+3. **Sliding window.** On each successful validation, bump `lastSeenAt = now` and `expiresAt = now + maxAgeMs`. Disk write is debounced (see throttle below).
+4. **Logout.** Revoke the entry by `tokenHash`, persist, return `Set-Cookie: ...; Max-Age=0`.
+
+## Cookie change
+
+`buildCookie` (`src/server/auth.ts:67`) gains a required `maxAgeSeconds` parameter:
+
+```ts
+const parts = [
+ `${name}=${encodeURIComponent(value)}`,
+ "Path=/",
+ "HttpOnly",
+ "SameSite=Strict",
+ `Max-Age=${maxAgeSeconds}`,
+]
+```
+
+`Secure` and any `extras` (e.g. `Max-Age=0` for logout) are appended afterward; the logout case overrides by passing `0` and the existing `["Max-Age=0"]` extras tag is removed.
+
+## New module: `auth-session-store.ts`
+
+```ts
+interface AuthSessionStore {
+ create(token: string, maxAgeMs: number): PersistedSession
+ validate(token: string): PersistedSession | null // checks expiry, prunes if expired
+ touch(token: string, maxAgeMs: number): void // sliding bump
+ revoke(token: string): void
+ sweep(): void // remove all expired entries
+ dispose(): Promise // flush pending writes, clear interval
+}
+```
+
+In-memory `Map` for O(1) lookup. The map is hydrated from `sessions.json` on construction.
+
+### Persist throttling
+
+`touch` updates the in-memory entry every request but only schedules a disk write when `expiresAt` has shifted by more than 1 hour relative to the last persisted value. This avoids writing the file on every click while keeping disk drift bounded to one hour. A short debounce (e.g. 250 ms) coalesces concurrent updates.
+
+### Background sweep
+
+`setInterval(sweep, 60 * 60 * 1000)` removes expired entries and triggers a persist if anything changed. Cleared in `dispose()`.
+
+## Wiring
+
+`server.ts`:
+
+```ts
+const sessionStore = await createAuthSessionStore({
+ filePath: path.join(store.dataDir, "sessions.json"),
+})
+const auth = createAuthManager(password, {
+ trustProxy,
+ sessionStore,
+ getMaxAgeMs: () =>
+ appSettings.getSnapshot().auth.sessionMaxAgeDays * 86_400_000,
+})
+```
+
+`auth.dispose()` (new) is called next to `appSettings.dispose()` at `src/server/server.ts:375`. It flushes pending writes and clears the sweep interval.
+
+## Tests
+
+`auth.test.ts` additions:
+
+- Login response sets `Max-Age=2592000` (30 days, default).
+- Settings change to `sessionMaxAgeDays: 7` causes a subsequent login to issue `Max-Age=604800`.
+- Existing session continues to validate after `createAuthManager` is recreated against the same `sessions.json` (restart simulation).
+- Sliding: `validate` then `touch` shifts `expiresAt` forward.
+- Expired entry returns 401 and is removed from the store.
+- Logout deletes the entry and sets `Max-Age=0`.
+
+New `auth-session-store.test.ts`:
+
+- `tokenHash` on disk is sha256 of the input token, never the token itself.
+- Round-trip persist + load preserves entries.
+- `sweep` removes expired entries.
+- `dispose` flushes pending writes.
+
+## Migration
+
+`sessions.json` is created lazily on first login. No migration required for existing installs; in-flight in-memory sessions are dropped once during the upgrade (the existing behavior on every restart today).
diff --git a/docs/plans/2026-05-06-chat-session-timings-design.md b/docs/plans/2026-05-06-chat-session-timings-design.md
new file mode 100644
index 000000000..5317e3143
--- /dev/null
+++ b/docs/plans/2026-05-06-chat-session-timings-design.md
@@ -0,0 +1,229 @@
+# Chat Session Timings — Design
+
+Date: 2026-05-06
+Status: Draft (brainstorm complete, awaiting plan + implementation)
+
+## Goal
+
+Surface session and per-state timing information in the chat UI so the user can see, at a glance, how long the agent has been in its current state, how long the active working session has lasted, and how long the last turn took. Cover three surfaces: chat header, inline per-turn badge, sidebar row.
+
+## Why
+
+Today there is no visible timing anywhere. A user cannot tell how long a `running` state has been active, how long the chat has been worked on this session, or how long any individual turn took. This makes long agent runs feel opaque and makes it hard to reason about chat history at a glance.
+
+## Scope (combo)
+
+All three placements:
+- **Header** — current state + duration, active-session age, last-turn duration.
+- **Inline per-turn** — small duration badge on each completed turn (uses existing `result.durationMs`).
+- **Sidebar row** — compact relative stamp (`2m`); replaced by state badge (`running 0:12`) when chat is not idle.
+
+Format: compact (`42s`, `2m`, `1h 5m`, `1d 2h`); live state uses `M:SS`. Tooltip carries verbose / chat-lifetime breakdown.
+
+Update model: snapshot only — refresh on event, no client-side ticking. Snapshot includes `derivedAtMs` so format stays stable across rerenders.
+
+## Definitions
+
+### Active session
+
+A burst of work, terminated by any idle gap longer than `ACTIVE_SESSION_IDLE_GAP_MS = 30 * 60 * 1000` (30 minutes). The active session begins at the timestamp of the first event after the most recent such gap, or at `chat.createdAt` if there is no qualifying gap. Cumulative state durations are scoped to this window.
+
+### State transitions
+
+`KannaStatus = "idle" | "starting" | "running" | "waiting_for_user" | "failed"`.
+
+Source mapping:
+- `chat_created` → enter `idle`
+- `turn_started` → enter `running`
+- `turn_finished` / `turn_cancelled` → enter `idle`
+- `turn_failed` → enter `failed`
+- `waiting_for_user` → not eventized; tracked in-memory in `AgentManager` (hybrid model — see "Waiting-for-user").
+
+`starting` is briefly set during turn boot before `turn_started` is recorded; treated as part of the upcoming `running` segment for cumulative purposes.
+
+### Waiting-for-user (hybrid c)
+
+`waiting_for_user` is set imperatively in `agent.ts` when a tool permission request is pending. It is not in the event log. Two consequences:
+
+1. `idle/running/starting/failed` cumulative numbers are derived from the durable event log and survive server restart.
+2. `waiting_for_user` cumulative is tracked in-memory by `AgentManager` (`waitStartedAt` per active turn). It resets on server restart. The read-model merges this in-memory map at derivation time.
+
+This keeps the event log clean (no permission lifecycle events added) while still surfacing wait time correctly while the server is running.
+
+## Data model
+
+In `src/shared/types.ts`:
+
+```ts
+export interface ChatStateTimings {
+ activeSessionStartedAt: number // start of current burst
+ chatCreatedAt: number // for tooltip / lifetime view
+ stateEnteredAt: number // when current state began
+ lastTurnDurationMs: number | null // most recent completed turn
+ derivedAtMs: number // server-side timestamp of derivation
+ cumulativeMs: {
+ idle: number
+ starting: number
+ running: number
+ waiting_for_user: number
+ failed: number
+ }
+}
+
+export interface ChatRuntime {
+ // ...existing fields
+ timings: ChatStateTimings
+}
+
+export interface SidebarChatRow {
+ // ...existing fields
+ stateEnteredAt?: number // for live state badge in sidebar
+}
+```
+
+The full `ChatStateTimings` lives only on `ChatRuntime` (single chat at a time on screen). Sidebar gets only `stateEnteredAt` to keep payload small.
+
+## Computation
+
+New function in `src/server/read-models.ts`:
+
+```ts
+export function deriveTimings(
+ chat: ChatRecord,
+ events: StoreEvent[], // chat-scoped events, ordered ascending
+ activeStatus: KannaStatus | undefined,
+ waitStartedAt: number | undefined,
+ nowMs: number,
+): ChatStateTimings
+```
+
+Algorithm — single linear pass:
+
+1. Walk events newest→oldest to find `activeSessionStartedAt`. Track gaps between consecutive events; the first gap that exceeds `ACTIVE_SESSION_IDLE_GAP_MS` between the *end* of an idle segment and the next event terminates the burst. `activeSessionStartedAt` = timestamp of the event after the gap. If no gap qualifies, fall back to `chat.createdAt`.
+
+2. Walk events oldest→newest from `activeSessionStartedAt`, tracking `(currentState, enteredAt)`. On each transition, accumulate `currentState`'s elapsed time into `cumulativeMs[currentState]` and update `(currentState, enteredAt)`.
+
+3. Close the final segment at `nowMs`. If `activeStatus === "waiting_for_user"` and `waitStartedAt` is set, also add `nowMs - waitStartedAt` to `cumulativeMs.waiting_for_user` and override the current-state entry to that value. Else current state is the last derived state.
+
+4. `lastTurnDurationMs` = `(turn_finished.timestamp - turn_started.timestamp)` for the most recent completed pair, or `result.durationMs` from the latest result message if richer signal preferred.
+
+5. `derivedAtMs = nowMs`.
+
+Wired into `deriveChatSnapshot` so every snapshot carries fresh timings. Sidebar row builder reads only `stateEnteredAt` (last transition timestamp) to keep cost low.
+
+Cost: O(events_per_chat) per derivation, folded into the existing read-model pass.
+
+## UI
+
+### Header (`src/client/app/PageHeader.tsx` or chat header equivalent)
+
+Layout, dot-separated:
+
+```
+running 0:12 · session 12m · last turn 3.2s
+```
+
+- State + live-format duration on the left. State label colored per existing status palette.
+- `session Nm` middle = `derivedAtMs - activeSessionStartedAt` formatted compact.
+- `last turn 3.2s` right, hidden when `lastTurnDurationMs == null`.
+- Tooltip on session segment: chat lifetime + per-state breakdown:
+
+```
+chat created 2d ago
+this session: active 8m / idle 4m / waiting 30s
+```
+
+### Inline per-turn (`src/client/components/messages/`)
+
+Append a muted compact duration on the result message renderer: `· 3.2s`. Source: existing `result.durationMs` event field. No protocol change.
+
+### Sidebar row (`src/client/app/KannaSidebar.tsx`)
+
+Right-aligned compact stamp:
+- Default: `formatCompact(derivedAtMs - lastMessageAt)` → `2m`, `5h`, etc.
+- If `status === "running" | "waiting_for_user"` and `stateEnteredAt` set, replace stamp with state badge: `running 0:12` / `waiting 30s`.
+
+### Format helper
+
+New `src/client/lib/formatDuration.ts`:
+
+- `formatCompact(ms)`: `<60s → Ns`, `<60m → Mm`, `<24h → Hh Mm`, `≥24h → Dd Hh`.
+- `formatLive(ms)`: `M:SS` for current state badges; switches to `Mm` after 60m.
+
+Both pure, snapshot-safe (operate on a fixed `ms` value supplied by caller).
+
+## Update model
+
+Q3 chose snapshot-only. No `setInterval` ticking. Numbers refresh exactly when a server event arrives.
+
+To prevent visual drift across React rerenders between events, the server includes `derivedAtMs` in the snapshot. The client formats every duration as `derivedAtMs - `, not `Date.now() - `. This guarantees a state with no new events shows the same number on every rerender.
+
+Trade-off: a `running` segment with no intervening tool/message events for 30 seconds will display `running 0:00` (snapshot taken at `turn_started`) until the next event. Accepted because most chats have frequent message/tool events.
+
+If this proves jarring in practice, escalation path: emit a synthetic `state_heartbeat` event every 10s while `running`, or move to Q3=c (adaptive client tick). Out of scope for v1.
+
+## Testing
+
+### Unit — `src/server/read-models.test.ts`
+
+`deriveTimings`:
+- empty event log → all zero, `stateEnteredAt = chatCreatedAt`, `activeSessionStartedAt = chatCreatedAt`
+- single `turn_started`, no finish → open `running` segment; `cumulativeMs.idle` = gap before turn
+- `turn_started` + `turn_finished` → `lastTurnDurationMs` = diff; both `running` and `idle` populated
+- `turn_failed` → final state `failed`, segment closed at failure timestamp
+- idle gap > 30 min → `activeSessionStartedAt` set after gap; cumulative scoped to post-gap window
+- back-to-back idle gaps → only most recent splits
+- `nowMs` advances current segment correctly
+- `waitStartedAt` provided → adds to `cumulativeMs.waiting_for_user`; current-state duration uses `nowMs - waitStartedAt`
+
+### Unit — `src/client/lib/formatDuration.test.ts`
+
+- `formatCompact`: `42_000 → "42s"`, `120_000 → "2m"`, `3_660_000 → "1h 1m"`, `90_061_000 → "1d 1h"`
+- `formatLive`: `12_000 → "0:12"`, `125_000 → "2:05"`, `>3_600_000 → "Mm"` form
+
+### Component
+
+- Header renders state + live duration + session + last-turn.
+- Header tooltip shows chat-lifetime breakdown.
+- Sidebar row swaps stamp ↔ badge based on `status`.
+- Snapshot stale: rerender does not advance time (uses `derivedAtMs`, not `Date.now`).
+
+### Integration — `read-models.test.ts`
+
+- Replay fixture event sequence → assert `runtime.timings` shape and values end-to-end through `deriveChatSnapshot`.
+
+## File change list
+
+Shared (c3-3):
+- `src/shared/types.ts` — add `ChatStateTimings`; extend `ChatRuntime`; extend `SidebarChatRow.stateEnteredAt`.
+
+Server (c3-2):
+- `src/server/read-models.ts` — add `deriveTimings`; wire into `deriveChatSnapshot` and sidebar row builder; thread `nowMs` and `waitStartedAt` map.
+- `src/server/agent.ts` — track `waitStartedAt` per active turn alongside `active.status = "waiting_for_user"`; expose getter.
+- `src/server/ws-router.ts` — pass wait-state map into derivation, mirroring existing `activeStatuses` plumbing.
+- `src/server/read-models.test.ts` — new tests.
+
+Client (c3-1):
+- `src/client/lib/formatDuration.ts` — new.
+- `src/client/lib/formatDuration.test.ts` — new.
+- `src/client/app/PageHeader.tsx` (or chat header host) — render state/session/last-turn + tooltip.
+- `src/client/app/KannaSidebar.tsx` — render compact stamp / state badge per row.
+- `src/client/components/messages/` (result renderer) — append `· 3.2s` from `durationMs`.
+
+Docs:
+- `.c3/refs/` — ref entry for timings model if c3 conventions require.
+- `docs/plans/2026-05-06-chat-session-timings-design.md` — this document.
+
+Estimated: ~10 files, ~400 LOC including tests.
+
+## Open questions / deferred
+
+- Whether to also add a status-line micro-renderer for terminal mode. Out of v1.
+- Whether to eventize permission lifecycle later (option a from Section 2) so `waiting_for_user` cumulative survives restart. Defer until evidence shows demand.
+- Configurable `ACTIVE_SESSION_IDLE_GAP_MS`. Hardcoded for v1; revisit if 30 min proves wrong.
+
+## Next steps
+
+1. Use `superpowers:writing-plans` to break this design into bite-sized implementation tasks.
+2. Use `superpowers:using-git-worktrees` to isolate the implementation branch.
+3. Implement under TDD per `superpowers:test-driven-development`.
diff --git a/docs/plans/2026-05-06-chat-session-timings.md b/docs/plans/2026-05-06-chat-session-timings.md
new file mode 100644
index 000000000..6009db044
--- /dev/null
+++ b/docs/plans/2026-05-06-chat-session-timings.md
@@ -0,0 +1,1222 @@
+# Chat Session Timings Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Surface per-state and active-session timing in three UI surfaces (chat header, inline turn badge, sidebar row) so users can see how long the agent has been in each state and how long the active working session has lasted.
+
+**Architecture:** Server-side derivation. Per-chat timing accumulator (`ChatTimingState`) lives in `StoreState`, mutated in `EventStore.apply` switch alongside other event reducers. `deriveChatSnapshot` reads the accumulator + a `waitStartedAt` map (in-memory in `AgentManager`) and emits `ChatRuntime.timings`. Sidebar gets a thin `stateEnteredAt` only. Client renders snapshot values via a pure `formatDuration` helper — no client-side ticking. `derivedAtMs` is baked into the snapshot to keep durations stable across React rerenders.
+
+**Tech Stack:** TypeScript, Bun (test runner), React, existing event-sourcing scaffolding in `src/server/event-store.ts`.
+
+**Reference design:** `docs/plans/2026-05-06-chat-session-timings-design.md`
+
+---
+
+## Phase 1 — Server foundation
+
+### Task 1: Add `ChatStateTimings` type to shared types
+
+**Files:**
+- Modify: `src/shared/types.ts:1053-1063` (extend `ChatRuntime`)
+- Modify: `src/shared/types.ts:376-388` (extend `SidebarChatRow`)
+
+**Step 1: Add type definitions**
+
+Insert above `ChatRuntime` interface (around line 1053):
+
+```ts
+export interface ChatTimingCumulativeMs {
+ idle: number
+ starting: number
+ running: number
+ waiting_for_user: number
+ failed: number
+}
+
+export interface ChatStateTimings {
+ activeSessionStartedAt: number
+ chatCreatedAt: number
+ stateEnteredAt: number
+ lastTurnDurationMs: number | null
+ derivedAtMs: number
+ cumulativeMs: ChatTimingCumulativeMs
+}
+```
+
+Extend `ChatRuntime`:
+
+```ts
+export interface ChatRuntime {
+ chatId: string
+ projectId: string
+ localPath: string
+ title: string
+ status: KannaStatus
+ isDraining: boolean
+ provider: AgentProvider | null
+ planMode: boolean
+ sessionToken: string | null
+ timings: ChatStateTimings
+}
+```
+
+Extend `SidebarChatRow`:
+
+```ts
+export interface SidebarChatRow {
+ _id: string
+ _creationTime: number
+ chatId: string
+ title: string
+ status: KannaStatus
+ unread: boolean
+ localPath: string
+ provider: AgentProvider | null
+ lastMessageAt?: number
+ hasAutomation: boolean
+ canFork?: boolean
+ stateEnteredAt?: number
+}
+```
+
+**Step 2: Run typecheck**
+
+```bash
+bun run --silent build 2>&1 | head -40 || true
+# OR if tsc available:
+bunx tsc --noEmit 2>&1 | head -40 || true
+```
+
+Expected: many errors — every place that constructs `ChatRuntime` literal is now missing `timings`. That's expected; later tasks fix them.
+
+**Step 3: Commit**
+
+```bash
+git add src/shared/types.ts
+git commit -m "feat(types): add ChatStateTimings to ChatRuntime and stateEnteredAt to SidebarChatRow"
+```
+
+---
+
+### Task 2: Add `ChatTimingState` accumulator to StoreState
+
+**Files:**
+- Modify: `src/server/events.ts:28-35` (StoreState shape)
+- Modify: `src/server/events.ts:204-...` (`createEmptyState`)
+
+**Step 1: Add ChatTimingState type and field**
+
+In `src/server/events.ts`, add after `ChatRecord`:
+
+```ts
+import type { KannaStatus } from "../shared/types"
+
+export interface ChatTimingState {
+ status: Exclude // waiting_for_user is in-memory only
+ stateEnteredAt: number
+ activeSessionStartedAt: number
+ lastTurnStartedAt: number | null
+ lastTurnDurationMs: number | null
+ cumulativeMs: {
+ idle: number
+ starting: number
+ running: number
+ failed: number
+ }
+}
+```
+
+Note: `waiting_for_user` is NOT tracked here — it lives on `AgentManager.activeTurns[].waitStartedAt` and is merged at derivation time. `starting` IS tracked because event log emits no explicit start, but if a future event ever emits it we'll handle it; for now it stays at zero.
+
+Extend `StoreState`:
+
+```ts
+export interface StoreState {
+ projectsById: Map
+ projectIdsByPath: Map
+ chatsById: Map
+ queuedMessagesByChatId: Map
+ sidebarProjectOrder: string[]
+ autoContinueEventsByChatId: Map
+ chatTimingsByChatId: Map
+}
+```
+
+**Step 2: Initialize in `createEmptyState`**
+
+```ts
+export function createEmptyState(): StoreState {
+ return {
+ projectsById: new Map(),
+ projectIdsByPath: new Map(),
+ chatsById: new Map(),
+ queuedMessagesByChatId: new Map(),
+ sidebarProjectOrder: [],
+ autoContinueEventsByChatId: new Map(),
+ chatTimingsByChatId: new Map(),
+ }
+}
+```
+
+**Step 3: Run tests for events.ts (compile only)**
+
+```bash
+bunx tsc --noEmit src/server/events.ts 2>&1 | head -20 || true
+```
+
+Expected: file compiles. Other files referencing `StoreState` may still fail — fixed in Task 3.
+
+**Step 4: Commit**
+
+```bash
+git add src/server/events.ts
+git commit -m "feat(events): add ChatTimingState accumulator to StoreState"
+```
+
+---
+
+### Task 3: Write failing test for `chatTimingsByChatId` accumulator
+
+**Files:**
+- Modify: `src/server/event-store.test.ts` (new test cases)
+
+**Step 1: Add test cases**
+
+Add at end of `src/server/event-store.test.ts`:
+
+```ts
+import { ACTIVE_SESSION_IDLE_GAP_MS } from "./read-models"
+
+describe("ChatTimingState accumulator", () => {
+ test("chat_created seeds idle state with createdAt", () => {
+ const store = new EventStore("/tmp/test-timings-1")
+ store.append({ v: 3, type: "project_opened", timestamp: 1000, projectId: "p1", localPath: "/x", title: "X" })
+ store.append({ v: 3, type: "chat_created", timestamp: 2000, chatId: "c1", projectId: "p1", title: "T" })
+
+ const t = store.state.chatTimingsByChatId.get("c1")
+ expect(t).toBeDefined()
+ expect(t!.status).toBe("idle")
+ expect(t!.stateEnteredAt).toBe(2000)
+ expect(t!.activeSessionStartedAt).toBe(2000)
+ expect(t!.cumulativeMs).toEqual({ idle: 0, starting: 0, running: 0, failed: 0 })
+ })
+
+ test("turn_started transitions idle -> running and accumulates idle time", () => {
+ const store = new EventStore("/tmp/test-timings-2")
+ store.append({ v: 3, type: "project_opened", timestamp: 1000, projectId: "p1", localPath: "/x", title: "X" })
+ store.append({ v: 3, type: "chat_created", timestamp: 2000, chatId: "c1", projectId: "p1", title: "T" })
+ store.append({ v: 3, type: "turn_started", timestamp: 5000, chatId: "c1" })
+
+ const t = store.state.chatTimingsByChatId.get("c1")!
+ expect(t.status).toBe("running")
+ expect(t.stateEnteredAt).toBe(5000)
+ expect(t.cumulativeMs.idle).toBe(3000)
+ expect(t.cumulativeMs.running).toBe(0)
+ expect(t.lastTurnStartedAt).toBe(5000)
+ })
+
+ test("turn_finished transitions running -> idle, sets lastTurnDurationMs", () => {
+ const store = new EventStore("/tmp/test-timings-3")
+ store.append({ v: 3, type: "project_opened", timestamp: 1000, projectId: "p1", localPath: "/x", title: "X" })
+ store.append({ v: 3, type: "chat_created", timestamp: 2000, chatId: "c1", projectId: "p1", title: "T" })
+ store.append({ v: 3, type: "turn_started", timestamp: 5000, chatId: "c1" })
+ store.append({ v: 3, type: "turn_finished", timestamp: 8000, chatId: "c1" })
+
+ const t = store.state.chatTimingsByChatId.get("c1")!
+ expect(t.status).toBe("idle")
+ expect(t.stateEnteredAt).toBe(8000)
+ expect(t.cumulativeMs.idle).toBe(3000)
+ expect(t.cumulativeMs.running).toBe(3000)
+ expect(t.lastTurnDurationMs).toBe(3000)
+ })
+
+ test("turn_failed transitions running -> failed", () => {
+ const store = new EventStore("/tmp/test-timings-4")
+ store.append({ v: 3, type: "project_opened", timestamp: 1000, projectId: "p1", localPath: "/x", title: "X" })
+ store.append({ v: 3, type: "chat_created", timestamp: 2000, chatId: "c1", projectId: "p1", title: "T" })
+ store.append({ v: 3, type: "turn_started", timestamp: 5000, chatId: "c1" })
+ store.append({ v: 3, type: "turn_failed", timestamp: 7000, chatId: "c1", error: "boom" })
+
+ const t = store.state.chatTimingsByChatId.get("c1")!
+ expect(t.status).toBe("failed")
+ expect(t.stateEnteredAt).toBe(7000)
+ expect(t.cumulativeMs.running).toBe(2000)
+ })
+
+ test("idle gap > ACTIVE_SESSION_IDLE_GAP_MS resets activeSessionStartedAt and cumulative", () => {
+ const store = new EventStore("/tmp/test-timings-5")
+ const HOUR = 60 * 60 * 1000
+ store.append({ v: 3, type: "project_opened", timestamp: 1000, projectId: "p1", localPath: "/x", title: "X" })
+ store.append({ v: 3, type: "chat_created", timestamp: 2000, chatId: "c1", projectId: "p1", title: "T" })
+ store.append({ v: 3, type: "turn_started", timestamp: 5000, chatId: "c1" })
+ store.append({ v: 3, type: "turn_finished", timestamp: 8000, chatId: "c1" })
+ // Gap of 1 hour > 30 min threshold
+ store.append({ v: 3, type: "turn_started", timestamp: 8000 + HOUR, chatId: "c1" })
+
+ const t = store.state.chatTimingsByChatId.get("c1")!
+ expect(t.activeSessionStartedAt).toBe(8000 + HOUR)
+ expect(t.cumulativeMs.idle).toBe(0)
+ expect(t.cumulativeMs.running).toBe(0)
+ expect(t.status).toBe("running")
+ expect(t.stateEnteredAt).toBe(8000 + HOUR)
+ })
+})
+```
+
+**Step 2: Run tests — expect failure**
+
+```bash
+bun test src/server/event-store.test.ts 2>&1 | tail -25
+```
+
+Expected: failures. `chatTimingsByChatId` will be empty Map (no reducer logic yet) and `ACTIVE_SESSION_IDLE_GAP_MS` import unresolved.
+
+**Step 3: Commit failing test**
+
+```bash
+git add src/server/event-store.test.ts
+git commit -m "test(event-store): add timing accumulator tests (failing)"
+```
+
+---
+
+### Task 4: Implement `ACTIVE_SESSION_IDLE_GAP_MS` constant
+
+**Files:**
+- Modify: `src/server/read-models.ts:18` (add constant)
+
+**Step 1: Add export**
+
+Above `SIDEBAR_RECENT_WINDOW_MS`:
+
+```ts
+export const ACTIVE_SESSION_IDLE_GAP_MS = 30 * 60 * 1_000
+```
+
+**Step 2: Commit**
+
+```bash
+git add src/server/read-models.ts
+git commit -m "feat(read-models): add ACTIVE_SESSION_IDLE_GAP_MS constant"
+```
+
+---
+
+### Task 5: Implement timing accumulator reducer in event-store
+
+**Files:**
+- Modify: `src/server/event-store.ts` (apply loop, lines ~98-110 and ~613-640)
+
+**Step 1: Add helper above the apply switch**
+
+In `src/server/event-store.ts`, add private method on `EventStore`:
+
+```ts
+private updateTiming(chatId: string, eventTs: number, nextStatus: ChatTimingState["status"], onTurnStart?: boolean, onTurnFinish?: boolean) {
+ const prev = this.state.chatTimingsByChatId.get(chatId)
+ if (!prev) {
+ // chat_created path: seed
+ this.state.chatTimingsByChatId.set(chatId, {
+ status: nextStatus,
+ stateEnteredAt: eventTs,
+ activeSessionStartedAt: eventTs,
+ lastTurnStartedAt: null,
+ lastTurnDurationMs: null,
+ cumulativeMs: { idle: 0, starting: 0, running: 0, failed: 0 },
+ })
+ return
+ }
+
+ const segmentMs = Math.max(0, eventTs - prev.stateEnteredAt)
+ let activeSessionStartedAt = prev.activeSessionStartedAt
+ let cumulativeMs = { ...prev.cumulativeMs }
+
+ // Detect long idle gap when leaving idle -> something
+ if (prev.status === "idle" && nextStatus !== "idle" && segmentMs > ACTIVE_SESSION_IDLE_GAP_MS) {
+ activeSessionStartedAt = eventTs
+ cumulativeMs = { idle: 0, starting: 0, running: 0, failed: 0 }
+ } else {
+ cumulativeMs[prev.status] += segmentMs
+ }
+
+ let lastTurnStartedAt = prev.lastTurnStartedAt
+ let lastTurnDurationMs = prev.lastTurnDurationMs
+ if (onTurnStart) lastTurnStartedAt = eventTs
+ if (onTurnFinish && lastTurnStartedAt != null) lastTurnDurationMs = Math.max(0, eventTs - lastTurnStartedAt)
+
+ this.state.chatTimingsByChatId.set(chatId, {
+ status: nextStatus,
+ stateEnteredAt: eventTs,
+ activeSessionStartedAt,
+ lastTurnStartedAt,
+ lastTurnDurationMs,
+ cumulativeMs,
+ })
+}
+```
+
+Add import at top:
+
+```ts
+import type { ChatTimingState } from "./events"
+import { ACTIVE_SESSION_IDLE_GAP_MS } from "./read-models"
+```
+
+**Step 2: Wire reducer into apply switch**
+
+Find each case and append the timing call:
+
+```ts
+case "chat_created": {
+ // ... existing logic
+ this.updateTiming(e.chatId, e.timestamp, "idle")
+ break
+}
+case "turn_started": {
+ // ... existing
+ this.updateTiming(e.chatId, e.timestamp, "running", true, false)
+ break
+}
+case "turn_finished": {
+ // ... existing
+ this.updateTiming(e.chatId, e.timestamp, "idle", false, true)
+ break
+}
+case "turn_failed": {
+ // ... existing
+ this.updateTiming(e.chatId, e.timestamp, "failed", false, true)
+ break
+}
+case "turn_cancelled": {
+ // ... existing
+ this.updateTiming(e.chatId, e.timestamp, "idle", false, true)
+ break
+}
+case "chat_deleted": {
+ // ... existing
+ this.state.chatTimingsByChatId.delete(e.chatId)
+ break
+}
+```
+
+**Step 3: Run tests — expect pass**
+
+```bash
+bun test src/server/event-store.test.ts 2>&1 | tail -15
+```
+
+Expected: 5 new tests pass. Existing tests still pass.
+
+**Step 4: Commit**
+
+```bash
+git add src/server/event-store.ts
+git commit -m "feat(event-store): accumulate ChatTimingState on turn events"
+```
+
+---
+
+### Task 6: Write failing test for `deriveTimings` snapshot helper
+
+**Files:**
+- Modify: `src/server/read-models.test.ts`
+
+**Step 1: Add test cases**
+
+Append:
+
+```ts
+import { deriveTimings, ACTIVE_SESSION_IDLE_GAP_MS } from "./read-models"
+
+describe("deriveTimings", () => {
+ const baseTiming = {
+ status: "idle" as const,
+ stateEnteredAt: 1000,
+ activeSessionStartedAt: 500,
+ lastTurnStartedAt: null,
+ lastTurnDurationMs: null,
+ cumulativeMs: { idle: 500, starting: 0, running: 0, failed: 0 },
+ }
+
+ test("formats accumulator + nowMs into ChatStateTimings", () => {
+ const out = deriveTimings(
+ { createdAt: 500 } as any,
+ { ...baseTiming },
+ undefined, // no in-memory wait
+ undefined,
+ 3000,
+ )
+ expect(out.activeSessionStartedAt).toBe(500)
+ expect(out.chatCreatedAt).toBe(500)
+ expect(out.stateEnteredAt).toBe(1000)
+ expect(out.derivedAtMs).toBe(3000)
+ expect(out.cumulativeMs.idle).toBe(500 + 2000) // 500 from accumulator + 2000 open segment to nowMs
+ expect(out.cumulativeMs.waiting_for_user).toBe(0)
+ })
+
+ test("waitStartedAt overrides current state to waiting_for_user and adds open segment", () => {
+ const out = deriveTimings(
+ { createdAt: 500 } as any,
+ { ...baseTiming, status: "running", stateEnteredAt: 1500, lastTurnStartedAt: 1500 },
+ "waiting_for_user",
+ 2500,
+ 3000,
+ )
+ expect(out.cumulativeMs.waiting_for_user).toBe(500) // 3000 - 2500
+ expect(out.stateEnteredAt).toBe(2500)
+ })
+
+ test("missing accumulator (legacy chat) falls back to chat.createdAt for everything", () => {
+ const out = deriveTimings(
+ { createdAt: 1000 } as any,
+ undefined,
+ undefined,
+ undefined,
+ 4000,
+ )
+ expect(out.activeSessionStartedAt).toBe(1000)
+ expect(out.chatCreatedAt).toBe(1000)
+ expect(out.stateEnteredAt).toBe(1000)
+ expect(out.cumulativeMs.idle).toBe(3000)
+ expect(out.lastTurnDurationMs).toBeNull()
+ })
+})
+```
+
+**Step 2: Run tests — expect failure**
+
+```bash
+bun test src/server/read-models.test.ts 2>&1 | tail -15
+```
+
+Expected: import errors / undefined `deriveTimings`.
+
+**Step 3: Commit**
+
+```bash
+git add src/server/read-models.test.ts
+git commit -m "test(read-models): add deriveTimings tests (failing)"
+```
+
+---
+
+### Task 7: Implement `deriveTimings`
+
+**Files:**
+- Modify: `src/server/read-models.ts`
+
+**Step 1: Add function**
+
+Above `deriveChatSnapshot`:
+
+```ts
+import type { ChatStateTimings, KannaStatus } from "../shared/types"
+import type { ChatRecord, ChatTimingState } from "./events"
+
+export function deriveTimings(
+ chat: Pick,
+ accumulator: ChatTimingState | undefined,
+ activeStatus: KannaStatus | undefined,
+ waitStartedAt: number | undefined,
+ nowMs: number,
+): ChatStateTimings {
+ const cumulativeMs = {
+ idle: 0,
+ starting: 0,
+ running: 0,
+ waiting_for_user: 0,
+ failed: 0,
+ }
+
+ if (!accumulator) {
+ // Legacy chat with no events folded yet
+ const idleSegment = Math.max(0, nowMs - chat.createdAt)
+ cumulativeMs.idle = idleSegment
+ return {
+ activeSessionStartedAt: chat.createdAt,
+ chatCreatedAt: chat.createdAt,
+ stateEnteredAt: chat.createdAt,
+ lastTurnDurationMs: null,
+ derivedAtMs: nowMs,
+ cumulativeMs,
+ }
+ }
+
+ cumulativeMs.idle = accumulator.cumulativeMs.idle
+ cumulativeMs.starting = accumulator.cumulativeMs.starting
+ cumulativeMs.running = accumulator.cumulativeMs.running
+ cumulativeMs.failed = accumulator.cumulativeMs.failed
+
+ // Open segment from accumulator's stateEnteredAt → nowMs
+ const openSegmentMs = Math.max(0, nowMs - accumulator.stateEnteredAt)
+
+ let stateEnteredAt = accumulator.stateEnteredAt
+
+ if (activeStatus === "waiting_for_user" && waitStartedAt != null) {
+ // Add the running portion before wait started
+ const preWaitMs = Math.max(0, waitStartedAt - accumulator.stateEnteredAt)
+ cumulativeMs[accumulator.status] += preWaitMs
+ cumulativeMs.waiting_for_user += Math.max(0, nowMs - waitStartedAt)
+ stateEnteredAt = waitStartedAt
+ } else {
+ cumulativeMs[accumulator.status] += openSegmentMs
+ }
+
+ return {
+ activeSessionStartedAt: accumulator.activeSessionStartedAt,
+ chatCreatedAt: chat.createdAt,
+ stateEnteredAt,
+ lastTurnDurationMs: accumulator.lastTurnDurationMs,
+ derivedAtMs: nowMs,
+ cumulativeMs,
+ }
+}
+```
+
+**Step 2: Run tests — expect pass**
+
+```bash
+bun test src/server/read-models.test.ts 2>&1 | tail -15
+```
+
+Expected: all 12 existing + 3 new tests pass.
+
+**Step 3: Commit**
+
+```bash
+git add src/server/read-models.ts
+git commit -m "feat(read-models): implement deriveTimings"
+```
+
+---
+
+### Task 8: Wire `timings` into `deriveChatSnapshot` and sidebar rows
+
+**Files:**
+- Modify: `src/server/read-models.ts:64-118` (`deriveSidebarData`)
+- Modify: `src/server/read-models.ts:183-230` (`deriveChatSnapshot`)
+
+**Step 1: Update `deriveChatSnapshot` signature + body**
+
+Add `waitStartedAtByChatId` parameter:
+
+```ts
+export function deriveChatSnapshot(
+ state: StoreState,
+ activeStatuses: Map,
+ drainingChatIds: Set,
+ slashCommandsLoadingChatIds: Set,
+ chatId: string,
+ getMessages: (chatId: string) => Pick,
+ getTunnelEvents: (chatId: string) => readonly CloudflareTunnelEvent[],
+ waitStartedAtByChatId: Map,
+ nowMs: number = Date.now(),
+): ChatSnapshot | null {
+```
+
+Build runtime with `timings`:
+
+```ts
+const runtime: ChatRuntime = {
+ chatId: chat.id,
+ projectId: project.id,
+ localPath: project.localPath,
+ title: chat.title,
+ status: deriveStatus(chat, activeStatuses.get(chat.id)),
+ isDraining: drainingChatIds.has(chat.id),
+ provider: chat.provider,
+ planMode: chat.planMode,
+ sessionToken: chat.sessionToken,
+ timings: deriveTimings(
+ chat,
+ state.chatTimingsByChatId.get(chat.id),
+ activeStatuses.get(chat.id),
+ waitStartedAtByChatId.get(chat.id),
+ nowMs,
+ ),
+}
+```
+
+**Step 2: Update `deriveSidebarData` to populate `stateEnteredAt`**
+
+In `toSidebarChatRows`:
+
+```ts
+.map((chat) => ({
+ _id: chat.id,
+ _creationTime: chat.createdAt,
+ chatId: chat.id,
+ title: chat.title,
+ status: deriveStatus(chat, activeStatuses.get(chat.id)),
+ unread: chat.unread,
+ localPath: project.localPath,
+ provider: chat.provider,
+ lastMessageAt: chat.lastMessageAt,
+ hasAutomation: false,
+ canFork: canForkChat(chat, activeStatuses, drainingChatIds) || undefined,
+ stateEnteredAt: state.chatTimingsByChatId.get(chat.id)?.stateEnteredAt,
+}))
+```
+
+**Step 3: Update existing call sites in `ws-router.ts`**
+
+Find every `deriveChatSnapshot(` call and pass `agent.getWaitStartedAtByChatId()` (added in Task 9) and `Date.now()`. Compile errors will pinpoint locations:
+
+```bash
+bun test src/server/read-models.test.ts 2>&1 | tail -15
+bunx tsc --noEmit 2>&1 | head -30
+```
+
+Most existing tests in `read-models.test.ts` will need `new Map()` and explicit `nowMs` added to their `deriveChatSnapshot` calls. Update them.
+
+**Step 4: Run tests — expect pass**
+
+```bash
+bun test src/server/read-models.test.ts 2>&1 | tail -15
+```
+
+**Step 5: Commit**
+
+```bash
+git add src/server/read-models.ts src/server/read-models.test.ts
+git commit -m "feat(read-models): wire timings into ChatRuntime and SidebarChatRow"
+```
+
+---
+
+## Phase 2 — Wait-state in-memory tracking
+
+### Task 9: Track `waitStartedAt` in `AgentManager`
+
+**Files:**
+- Modify: `src/server/agent.ts:1035-1050` (active turn waiting block)
+- Modify: `src/server/agent.ts:755-761` (add `getWaitStartedAtByChatId`)
+
+**Step 1: Add field to `ActiveTurn` interface**
+
+Find `interface ActiveTurn` (search):
+
+```bash
+grep -n "interface ActiveTurn" src/server/agent.ts
+```
+
+Add field:
+
+```ts
+interface ActiveTurn {
+ // ...existing
+ waitStartedAt: number | null
+}
+```
+
+**Step 2: Initialize `waitStartedAt: null` everywhere `ActiveTurn` is constructed**
+
+```bash
+grep -n "this.activeTurns.set\|: ActiveTurn" src/server/agent.ts
+```
+
+Add `waitStartedAt: null,` to each.
+
+**Step 3: Set on transition to `waiting_for_user`**
+
+Around line 1040:
+
+```ts
+active.status = "waiting_for_user"
+active.waitStartedAt = Date.now()
+this.emitStateChange(args.chatId)
+```
+
+**Step 4: Clear when tool resolved**
+
+Find the `pendingTool.resolve` site (right after the Promise resolution site that fires when permission grants). Search:
+
+```bash
+grep -n "pendingTool.resolve\|pendingTool = undefined\|pendingTool = null" src/server/agent.ts
+```
+
+After resolving and clearing pendingTool, add:
+
+```ts
+active.waitStartedAt = null
+active.status = "running"
+this.emitStateChange(chatId)
+```
+
+(Adapt to actual control flow at that site.)
+
+**Step 5: Add accessor method**
+
+After `getActiveStatuses`:
+
+```ts
+getWaitStartedAtByChatId(): Map {
+ const out = new Map()
+ for (const [chatId, turn] of this.activeTurns.entries()) {
+ if (turn.waitStartedAt != null) out.set(chatId, turn.waitStartedAt)
+ }
+ return out
+}
+```
+
+**Step 6: Run agent tests for what we changed**
+
+```bash
+bun test src/server/agent 2>&1 | tail -15
+```
+
+Expected: pass (no new test added; behavior change is additive).
+
+**Step 7: Commit**
+
+```bash
+git add src/server/agent.ts
+git commit -m "feat(agent): track waitStartedAt per active turn for waiting_for_user timing"
+```
+
+---
+
+### Task 10: Wire `waitStartedAt` map through `ws-router`
+
+**Files:**
+- Modify: `src/server/ws-router.ts:600-620` (chat snapshot derivation site)
+- Modify: `src/server/ws-router.ts:430-445` (sidebar derivation site — sidebar reads accumulator directly, no wait map needed there)
+
+**Step 1: Update `deriveChatSnapshot` call**
+
+Around line 603:
+
+```ts
+data: deriveChatSnapshot(
+ store.state,
+ agent.getActiveStatuses(),
+ agent.getDrainingChatIds(),
+ agent.getSlashCommandsLoadingChatIds(),
+ chatId,
+ (cid) => store.getMessagesPage(cid), // existing arg shape
+ (cid) => store.getTunnelEvents(cid),
+ agent.getWaitStartedAtByChatId(),
+ Date.now(),
+),
+```
+
+(Adjust to match the actual signature lines around that call — search `deriveChatSnapshot(` to see current shape.)
+
+**Step 2: Run targeted tests**
+
+```bash
+bun test src/server/ws-router 2>&1 | tail -15
+bun test src/server/read-models 2>&1 | tail -15
+```
+
+Expected: pass.
+
+**Step 3: Commit**
+
+```bash
+git add src/server/ws-router.ts
+git commit -m "feat(ws-router): pass waitStartedAt map and nowMs into chat snapshot derivation"
+```
+
+---
+
+## Phase 3 — Client format helper
+
+### Task 11: Write failing tests for `formatDuration`
+
+**Files:**
+- Create: `src/client/lib/formatDuration.test.ts`
+
+**Step 1: Write tests**
+
+```ts
+import { describe, expect, test } from "bun:test"
+import { formatCompactDuration, formatLiveDuration } from "./formatDuration"
+
+describe("formatCompactDuration", () => {
+ test("under a minute → Ns", () => {
+ expect(formatCompactDuration(0)).toBe("0s")
+ expect(formatCompactDuration(42_000)).toBe("42s")
+ expect(formatCompactDuration(59_999)).toBe("59s")
+ })
+ test("under an hour → Mm", () => {
+ expect(formatCompactDuration(60_000)).toBe("1m")
+ expect(formatCompactDuration(120_000)).toBe("2m")
+ expect(formatCompactDuration(59 * 60_000)).toBe("59m")
+ })
+ test("under a day → Hh Mm", () => {
+ expect(formatCompactDuration(60 * 60_000)).toBe("1h")
+ expect(formatCompactDuration(3_660_000)).toBe("1h 1m")
+ expect(formatCompactDuration(23 * 60 * 60_000 + 59 * 60_000)).toBe("23h 59m")
+ })
+ test("≥ a day → Dd Hh", () => {
+ expect(formatCompactDuration(24 * 60 * 60_000)).toBe("1d")
+ expect(formatCompactDuration(25 * 60 * 60_000)).toBe("1d 1h")
+ expect(formatCompactDuration(48 * 60 * 60_000 + 30 * 60_000)).toBe("2d") // <1h trailing → drop
+ })
+ test("negative input clamps to 0s", () => {
+ expect(formatCompactDuration(-50)).toBe("0s")
+ })
+})
+
+describe("formatLiveDuration", () => {
+ test("under an hour → M:SS", () => {
+ expect(formatLiveDuration(0)).toBe("0:00")
+ expect(formatLiveDuration(12_000)).toBe("0:12")
+ expect(formatLiveDuration(125_000)).toBe("2:05")
+ expect(formatLiveDuration(59 * 60_000 + 59_000)).toBe("59:59")
+ })
+ test("≥ 1h → falls back to compact", () => {
+ expect(formatLiveDuration(60 * 60_000)).toBe("1h")
+ expect(formatLiveDuration(3_660_000)).toBe("1h 1m")
+ })
+})
+```
+
+**Step 2: Run — expect failure**
+
+```bash
+bun test src/client/lib/formatDuration.test.ts 2>&1 | tail -10
+```
+
+Expected: file not found.
+
+**Step 3: Commit failing test**
+
+```bash
+git add src/client/lib/formatDuration.test.ts
+git commit -m "test(client): add formatDuration tests (failing)"
+```
+
+---
+
+### Task 12: Implement `formatDuration`
+
+**Files:**
+- Create: `src/client/lib/formatDuration.ts`
+
+**Step 1: Write implementation**
+
+```ts
+const SECOND = 1_000
+const MINUTE = 60 * SECOND
+const HOUR = 60 * MINUTE
+const DAY = 24 * HOUR
+
+export function formatCompactDuration(ms: number): string {
+ const v = Math.max(0, ms)
+ if (v < MINUTE) return `${Math.floor(v / SECOND)}s`
+ if (v < HOUR) return `${Math.floor(v / MINUTE)}m`
+ if (v < DAY) {
+ const h = Math.floor(v / HOUR)
+ const m = Math.floor((v % HOUR) / MINUTE)
+ return m === 0 ? `${h}h` : `${h}h ${m}m`
+ }
+ const d = Math.floor(v / DAY)
+ const h = Math.floor((v % DAY) / HOUR)
+ return h === 0 ? `${d}d` : `${d}d ${h}h`
+}
+
+export function formatLiveDuration(ms: number): string {
+ const v = Math.max(0, ms)
+ if (v >= HOUR) return formatCompactDuration(v)
+ const totalSec = Math.floor(v / SECOND)
+ const m = Math.floor(totalSec / 60)
+ const s = totalSec % 60
+ return `${m}:${s.toString().padStart(2, "0")}`
+}
+```
+
+**Step 2: Run tests — expect pass**
+
+```bash
+bun test src/client/lib/formatDuration.test.ts 2>&1 | tail -10
+```
+
+**Step 3: Commit**
+
+```bash
+git add src/client/lib/formatDuration.ts
+git commit -m "feat(client): add formatDuration helpers"
+```
+
+---
+
+## Phase 4 — UI integration
+
+### Task 13: Render timing in `ChatNavbar`
+
+**Files:**
+- Modify: `src/client/components/chat-ui/ChatNavbar.tsx`
+- Modify: `src/client/app/ChatPage/index.tsx:905` (pass `timings` prop)
+
+**Step 1: Add `timings` prop to ChatNavbar**
+
+Extend `Props`:
+
+```ts
+interface Props {
+ // ...existing
+ timings?: ChatStateTimings
+ status?: KannaStatus
+}
+```
+
+Import:
+
+```ts
+import type { ChatStateTimings, KannaStatus } from "../../../shared/types"
+import { formatCompactDuration, formatLiveDuration } from "../../lib/formatDuration"
+```
+
+**Step 2: Render timing block**
+
+Inside the navbar JSX, add a center segment between left and right icon groups:
+
+```tsx
+{timings && status && (
+
+
+ {status} {formatLiveDuration(timings.derivedAtMs - timings.stateEnteredAt)}
+
+ ·
+ session {formatCompactDuration(timings.derivedAtMs - timings.activeSessionStartedAt)}
+ {timings.lastTurnDurationMs != null && (
+ <>
+ ·
+ last turn {formatCompactDuration(timings.lastTurnDurationMs)}
+ >
+ )}
+
+)}
+```
+
+Place the block in a flex-1 wrapper so it sits between the existing left and right groups without disturbing them.
+
+**Step 3: Pass props at ChatPage call site (line ~905)**
+
+```tsx
+
+```
+
+**Step 4: Run typecheck + tests**
+
+```bash
+bunx tsc --noEmit 2>&1 | head -20
+bun test src/client/app 2>&1 | tail -15
+```
+
+**Step 5: Commit**
+
+```bash
+git add src/client/components/chat-ui/ChatNavbar.tsx src/client/app/ChatPage/index.tsx
+git commit -m "feat(chat-navbar): render state duration, session age, last turn"
+```
+
+---
+
+### Task 14: Render stamp/badge in sidebar rows
+
+**Files:**
+- Modify: `src/client/app/KannaSidebar.tsx`
+
+**Step 1: Find sidebar row rendering**
+
+```bash
+grep -n "lastMessageAt\|SidebarChatRow\|chat.title" src/client/app/KannaSidebar.tsx | head -10
+```
+
+**Step 2: Add stamp/badge**
+
+In the row component (likely near where title and status indicator render), add:
+
+```tsx
+{(() => {
+ const isLive = chat.status === "running" || chat.status === "waiting_for_user"
+ if (isLive && chat.stateEnteredAt != null) {
+ return (
+
+ {chat.status === "waiting_for_user" ? "wait" : "run"} {formatLiveDuration(Date.now() - chat.stateEnteredAt)}
+
+ )
+ }
+ const ts = chat.lastMessageAt ?? chat._creationTime
+ return (
+
+ {formatCompactDuration(Date.now() - ts)}
+
+ )
+})()}
+```
+
+Note: sidebar uses `Date.now()` because no `derivedAtMs` is plumbed to it. Acceptable — reads only update on snapshot push, and React stops re-evaluating between renders since props are stable. If render flickers prove a problem, plumb `derivedAtMs` later.
+
+**Step 3: Add imports**
+
+```ts
+import { formatCompactDuration, formatLiveDuration } from "../lib/formatDuration"
+```
+
+**Step 4: Run tests**
+
+```bash
+bun test src/client/app 2>&1 | tail -10
+```
+
+**Step 5: Commit**
+
+```bash
+git add src/client/app/KannaSidebar.tsx
+git commit -m "feat(sidebar): show compact stamp or live state badge per chat row"
+```
+
+---
+
+### Task 15: Inline turn duration on `ResultMessage`
+
+**Files:**
+- Modify: `src/client/components/messages/ResultMessage.tsx`
+
+**Step 1: Inspect file**
+
+```bash
+cat src/client/components/messages/ResultMessage.tsx | head -80
+```
+
+**Step 2: Append duration**
+
+After existing result text/cost render, add:
+
+```tsx
+{result.durationMs != null && (
+
+ · {formatCompactDuration(result.durationMs)}
+
+)}
+```
+
+Import:
+
+```ts
+import { formatCompactDuration } from "../../lib/formatDuration"
+```
+
+**Step 3: Run tests**
+
+```bash
+bun test src/client/components/messages 2>&1 | tail -10
+```
+
+**Step 4: Commit**
+
+```bash
+git add src/client/components/messages/ResultMessage.tsx
+git commit -m "feat(result-message): append compact turn duration"
+```
+
+---
+
+## Phase 5 — Verification
+
+### Task 16: Full server test sweep
+
+**Step 1: Run scoped server tests**
+
+```bash
+bun test src/server/event-store src/server/read-models src/server/agent 2>&1 | tail -25
+bun test src/server/ws-router 2>&1 | tail -15
+```
+
+Expected: all pass.
+
+**Step 2: Run client lib tests**
+
+```bash
+bun test src/client/lib/formatDuration src/client/app 2>&1 | tail -20
+```
+
+**Step 3: Typecheck**
+
+```bash
+bunx tsc --noEmit 2>&1 | head -30
+```
+
+Expected: no errors.
+
+If any test fails or types complain, fix before proceeding. **Do not skip.**
+
+**Step 4: Commit any cleanups**
+
+```bash
+git status
+git diff
+# If trivial fixes needed
+git add
+git commit -m "chore: post-integration fixups"
+```
+
+---
+
+### Task 17: Manual smoke test
+
+**Step 1: Start dev server**
+
+```bash
+bun run dev 2>&1 | head -30
+```
+
+(Or whatever the project's dev script is — check `package.json`.)
+
+**Step 2: Verify in browser**
+
+- Open chat with no turns → header shows `idle 0s · session 0s` (no last turn)
+- Send a message → during turn header switches to `running 0:0X`, sidebar row shows `run 0:0X`
+- After turn → header shows `idle 0:00 · session 1m · last turn 3.2s`
+- Each result message has `· 3.2s` appended
+- Wait 30+ minutes idle, send another message → `session` resets to start of new burst
+
+**Step 3: Update C3 docs if needed**
+
+```bash
+ls .c3/refs | head -5
+```
+
+If c3 conventions require a new ref entry for timings, add minimal stub. Otherwise skip.
+
+**Step 4: Final commit + push**
+
+```bash
+git status
+# If any updates from smoke test:
+git add -A
+git commit -m "chore: smoke-test fixups"
+```
+
+---
+
+## Done criteria
+
+- [ ] `ChatRuntime.timings` populated in every WS chat snapshot
+- [ ] `SidebarChatRow.stateEnteredAt` populated for live chats
+- [ ] ChatNavbar shows state, session age, last turn
+- [ ] Sidebar rows swap compact stamp ↔ live state badge based on status
+- [ ] Result messages append `· Ns` duration
+- [ ] All targeted tests pass (`event-store`, `read-models`, `agent`, `ws-router`, `formatDuration`)
+- [ ] `tsc --noEmit` clean
+- [ ] Active session resets after >30 min idle gap (verified in test + smoke)
+- [ ] Commit history is one logical change per commit
+
+---
+
+## Notes for the executing agent
+
+- **Worktree:** Already at `/Users/cuongtran/Desktop/repo/kanna/.worktrees/chat-session-timings` on branch `feature/chat-session-timings`. Stay there.
+- **Resource safety:** Per CLAUDE.md, only run tests scoped to changed files. Do not run full project test suite from a subagent.
+- **Strong typing:** Per global CLAUDE.md, no `any`/`unknown`/`interface{}`. Cast `as any` is allowed only in tests for mock fixtures (already used in Task 6).
+- **Commit cadence:** One commit per task; messages follow conventional commits (`feat:`, `test:`, `fix:`, `chore:`).
+- **If a task hits unexpected schema drift** (e.g. existing call site of `deriveChatSnapshot` has different shape than documented): inspect with `grep -n` first, update the plan inline, then proceed. Do not silently change semantics.
diff --git a/docs/plans/2026-05-07-background-tasks-design.md b/docs/plans/2026-05-07-background-tasks-design.md
new file mode 100644
index 000000000..b43700beb
--- /dev/null
+++ b/docs/plans/2026-05-07-background-tasks-design.md
@@ -0,0 +1,262 @@
+# Background Tasks: Visibility + Stop Control
+
+**Date:** 2026-05-07
+**Status:** Design
+
+## Problem
+
+When the agent runs a long-lived process via `Bash` with `run_in_background: true` (a dev server, a watch task), or when a turn finishes while its stream is still draining, or when a terminal-manager PTY or codex session is alive, the user has no central place to see what is still running. The chat-level "stop" button only stops the active turn, not the leftover processes. If the user forgets, resources leak across sessions and across Kanna restarts.
+
+## Goal
+
+Give the user one calm surface that lists every long-lived task Kanna is responsible for, with a clear way to stop each one, that survives chat closure and Kanna restart without surprises.
+
+## Scope
+
+All long-lived work owned by Kanna:
+
+- **`bash_shell`** — Claude SDK Bash tool calls with `run_in_background: true`.
+- **`draining_stream`** — turn finished, stream still open from leftover background work (existing `drainingStreams` map).
+- **`terminal_pty`** — PTYs owned by `TerminalManager`.
+- **`codex_session`** — sessions owned by `CodexAppServerManager`.
+
+Out of scope: the active turn itself (already steerable via existing chat stop), foreign processes Kanna did not spawn, full log streaming inside the dialog.
+
+## Architecture
+
+### Data model
+
+A new `BackgroundTaskRegistry` (`src/server/background-tasks.ts`) is the single source of truth across all four kinds. It is owned by `AgentCoordinator` and injected into `TerminalManager` and `CodexAppServerManager`.
+
+```ts
+type BackgroundTask =
+ | { kind: "bash_shell"; id: string; chatId: string | null; command: string;
+ shellId: string; pid: number | null; startedAt: number;
+ lastOutput: string; status: "running" | "stopping"; orphan?: boolean }
+ | { kind: "draining_stream"; id: string; chatId: string;
+ startedAt: number; lastOutput: string }
+ | { kind: "terminal_pty"; id: string; ptyId: string; cwd: string;
+ startedAt: number; lastOutput: string }
+ | { kind: "codex_session"; id: string; chatId: string;
+ pid: number | null; startedAt: number; lastOutput: string }
+```
+
+### Registry API
+
+```ts
+class BackgroundTaskRegistry {
+ list(): BackgroundTask[]
+ listByChat(chatId: string): BackgroundTask[]
+ register(task: BackgroundTask): void
+ update(id: string, patch: Partial): void
+ unregister(id: string): void
+ async stop(id: string, opts?: { force?: boolean }): Promise
+ on(event: "added" | "updated" | "removed", cb): Unsubscribe
+}
+```
+
+### Discovery wiring
+
+1. `agent.ts` `trackBashToolEntry` — when a tool call has `input.run_in_background === true`, register on the matching tool result, parse the SDK shell descriptor for `shellId` and `pid`. Update `lastOutput` from later events.
+2. The existing `drainingStreams.set` becomes a thin wrapper that also calls `registry.register`. `stopDraining` unregisters.
+3. `TerminalManager` registers on spawn, unregisters on exit.
+4. `CodexAppServerManager` registers on session start, unregisters on shutdown.
+
+### Stop semantics
+
+| Kind | Strategy |
+|---|---|
+| `bash_shell` | SIGTERM, 3s grace, then SIGKILL. Use SDK `KillBash` if available; otherwise `process.kill(-pid, "SIGTERM")` on the process group. |
+| `draining_stream` | `turn.close()` (existing). |
+| `terminal_pty` | `TerminalManager.kill(ptyId)` — graceful HUP/TERM, then KILL. |
+| `codex_session` | `CodexAppServerManager.shutdown(chatId)` — already gentle. |
+
+### Persistence + orphan recovery
+
+Only `bash_shell` survives a Kanna restart (PTYs and codex sessions die with their parent). On registry mutation, debounce 500ms, atomic-write `~/.kanna/state/orphan-pids-.json`:
+
+```ts
+type PersistedTask = {
+ id: string
+ pid: number
+ command: string
+ chatId: string | null
+ startedAt: number
+}
+```
+
+On boot:
+1. Read the file.
+2. For each entry, `process.kill(pid, 0)` — drop on `ESRCH`.
+3. For survivors, register as `bash_shell` with `orphan: true`.
+4. Rewrite file with surviving entries.
+5. Broadcast snapshot.
+
+Atomic write: temp file plus rename. Path keyed by port to keep multiple Kanna instances from killing each other's processes.
+
+### Shutdown
+
+`SIGTERM` / `SIGINT` handler in `cli.ts`:
+- Persist final orphan list.
+- Do **not** kill `bash_shell` entries — survival is intentional.
+- Gracefully close PTYs (HUP), codex sessions (`shutdown`), draining streams (`turn.close`).
+
+### Edge cases
+
+| Case | Behavior |
+|---|---|
+| Chat deleted while bash shell alive | Entry stays. `chatId` becomes null. Label switches to "orphaned (chat deleted)". Stop still works. |
+| PID reused by unrelated process | Before kill, verify `comm` (`/proc//comm` on Linux, `ps -p pid -o comm=` cross-platform). Mismatch → drop entry, no kill, surface a toast. |
+| SIGTERM ignored after 3s | UI swaps in a `Force kill` button. SIGKILL on confirm. |
+| User stops draining stream during turn | Existing `stopDraining` path; now also unregisters. |
+| > 50 tasks at once | Dialog list virtualizes (windowed render). Render budget < 16ms under 200 rows. |
+| Multiple Kanna instances | Orphan file path keyed by listening port. |
+| Tunnel mobile client | Same WS channel, sheet variant. |
+
+### WebSocket protocol
+
+New channel `bg-tasks:list` (subscribe → snapshot, then diffs). New command `bg-tasks:stop { id, force?: boolean }` returning `{ ok, error? }`.
+
+### Telemetry
+
+`analytics.ts` events, no PII (no command content):
+`bg_task_registered { kind }`, `bg_task_stopped { kind, ageMs, force }`, `bg_task_orphan_kept { count }`, `bg_task_orphan_killed { count }`. Respect existing opt-out.
+
+## UI / UX (impeccable, product register)
+
+### Theme + color
+
+Scene: solo dev at 11pm on a 27-inch monitor, five chats open, three background tasks ticking, wants to glance at the list and stop a forgotten dev server in one keystroke without leaving flow.
+
+That sentence forces calm, low-stim, warm-tinted neutrals. Auto theme follows existing `useTheme`. **Restrained** color strategy. One accent for the running state — warm amber `oklch(0.74 0.12 70)`: not green, not red; states *attention available* without alarming or congratulating. Destructive (force-kill) uses a single solid red. No gradients, no glow, no glassmorphism. All neutrals tinted toward warm hue (chroma 0.005 to 0.01).
+
+### Surface placement
+
+Two surfaces, one Zustand store (`backgroundTasksStore`):
+
+1. **Navbar indicator** in `ChatNavbar.tsx`. Small dot plus count, e.g. `● 3`. Dot is amber when ≥ 1 running, neutral when 0. Project `Tooltip` (not native `title`) on hover: *"3 background tasks · ⌘⇧B"*. Click opens dialog. No badge ring, no pulse.
+2. **Background Tasks dialog** (shadcn `Dialog`). Width ~720px desktop, full-screen sheet on mobile. Keyboard: `⌘⇧B` open, `Esc` close, `↑/↓` navigate rows, `Enter` expand, `⌘.` stop focused row.
+
+### Dialog anatomy
+
+```
+┌─ Background tasks ───────────────────── 3 running ─┐
+│ │
+│ bun run dev 2m 14s ⏵ │
+│ bash · chat: feat/timings · started 11:02 ⏹ │
+│ │
+│ pnpm test --watch 18m 03s ⏵ │
+│ bash · chat: bg-tasks design · started 10:46 ⏹ │
+│ │
+│ PTY: zsh 4h 12m ⏵ │
+│ terminal · /Users/cuongtran/repo/kanna ⏹ │
+│ │
+└───────────────────────────────────────────────────────┘
+```
+
+Two-line rows. Line 1: command/label (mono, 14px, weight 600) plus age (mono, 13px, weight 500, `tabular-nums`, right-aligned). Line 2: type tag, chat link, started time (sans, 12px, muted) plus stop icon button on the right. Expand chevron `⏵` reveals the last 12 lines of output (mono, 12px, line-height 1.55, scrollable, max 240px).
+
+Dialog title is editorial: weight 500, 18px, letter-spacing -0.01em, sentence case. No icon prefix.
+
+### Motion
+
+- Row enter: opacity 0→1, translateY 4px→0, 180ms ease-out-quart. 24ms stagger across rows. Disabled under `prefers-reduced-motion`.
+- Stop confirm: row label crosses out 220ms; age freezes; row fades to muted 320ms before unmount.
+- Navbar dot: **static**. No pulse, no glow. Color presence carries the signal.
+- Dialog open: scale 0.98→1 plus opacity 0→1, 160ms. No backdrop blur.
+
+### Stop interaction
+
+Inline confirm, never a nested modal:
+
+1. Click stop icon → icon swaps to `Confirm stop?` text button plus `Cancel` ghost (180ms slide-in from right). Other rows dim.
+2. Confirm → row enters `stopping` state (status text replaces age, `stopping…`). 3s grace. On exit → row fades out. On timeout → red `Force kill` text button appears in the same slot.
+3. `Esc` cancels confirm. Single-row scope; never affects other tasks.
+
+### Empty state
+
+Body shows one editorial sentence, left-aligned, no illustration, no centered icon: *"No background tasks. Anything an agent leaves running here will appear so you can stop it."*
+
+### Orphan-on-boot
+
+Not a modal. A section header at the top of the dialog when present:
+
+```
+Found from previous session [Kill all]
+ bun dev · pid 48213 · last seen 2h ago ⏹
+```
+
+User opens the dialog naturally on next session, or via a boot toast: *"3 processes survived restart · review"*. No auto-kill, no surprise dialog interrupting work.
+
+### Mobile variant
+
+Bottom sheet, full width, same anatomy stacked tighter: line 1 command + age, line 2 type + chat, line 3 stop button full-width. Swipe-left exposes stop. Long-press shows full command (replaces the desktop tooltip).
+
+### Accessibility
+
+- Focus rings on every interactive element, never `outline: none` without replacement.
+- All actions reachable from keyboard, including stop and force-kill.
+- Color is never the only signal: status word + icon shape always pair with color.
+- Voice-over reads "Stop bun run dev, running 2 minutes 14 seconds".
+- Tabular numerics for age and pid columns.
+- Body contrast ≥ 7:1; large text ≥ 4.5:1; never below AA.
+
+## Testing
+
+### Server (`bun test`)
+
+`background-tasks.test.ts`:
+- register / update / unregister emit events in order.
+- `listByChat` filters correctly.
+- stop `bash_shell`: spawn a toy script that traps SIGTERM, verify SIGTERM sent, 3s grace honored, SIGKILL after.
+- `force: true`: SIGKILL immediate.
+- PID-reuse guard: spawn, capture pid, kill, spawn unrelated `sleep`, attempt stop on the original id → drops without killing the innocent pid.
+- Concurrent stops on the same id: idempotent.
+
+`agent.test.ts` extensions:
+- Bash tool with `run_in_background: true` → registry has entry on tool_result.
+- Draining stream lifecycle → register on insert, unregister on `stopDraining`.
+- Chat delete → `bash_shell` entries flip `chatId` to null but stay registered.
+
+`orphan-persistence.test.ts`:
+- Write then re-read restores entries.
+- Stale pid dropped on boot.
+- Corrupted JSON → ignored, fresh start, error logged.
+- Atomic write: simulate crash mid-write, file still valid.
+
+### WS router (`ws-router.test.ts` extension)
+
+- Subscribe `bg-tasks:list` → snapshot then diffs.
+- `bg-tasks:stop` command routes to registry, returns result.
+- Unauthorized stop (id not in registry) → error response, no crash.
+
+### Client (co-located, kanna-react-style)
+
+- `BackgroundTasksDialog.test.tsx`: rows render, age formats via `formatters.ts`, stop click → confirm state → stop dispatched. `⌘.` stops focused row. `Esc` closes.
+- `ChatNavbar.test.tsx`: dot color toggles with count. Tooltip uses the project `Tooltip`, not native `title`.
+- Snapshot-stable rendering: freeze `Date.now`, assert no layout jitter across age ticks.
+- `prefers-reduced-motion` → enter animation disabled.
+
+Test subprocess hygiene per `CLAUDE.md`: any `git` or process spawn in tests must set `stdin: "ignore"` and `GIT_TERMINAL_PROMPT=0`.
+
+### Manual / smoke
+
+- Start dev server via agent, dialog row appears.
+- Stop from dialog → `pgrep -f` confirms gone.
+- Restart Kanna → orphan section appears with surviving pid.
+- Mobile viewport: sheet variant, swipe-left stop.
+- macOS VoiceOver reads row label and status correctly.
+- Lighthouse contrast checks pass AAA on body text.
+
+## Out of Scope (YAGNI)
+
+- Full log streaming inside the dialog (last 12 lines only; full logs via "View output" into existing terminal pane).
+- Grouping by project or by chat (flat list with type column).
+- Restart-task action (stop only; restart stays user-driven via chat).
+- Notification on task exit (existing chat transcript already records it).
+- Cross-machine syncing of orphan state.
+
+## Open Questions
+
+- Does the Claude Agent SDK expose a stable `KillBash` for shells with `run_in_background: true`, or do we always need the PID path? Verify against current SDK docs before implementation.
+- Where exactly to surface the boot toast? Candidates: existing notification system in `chatNotifications.ts`, or a new lightweight top-of-app banner. Decide during implementation.
diff --git a/docs/plans/2026-05-07-background-tasks.md b/docs/plans/2026-05-07-background-tasks.md
new file mode 100644
index 000000000..7d2b02ee8
--- /dev/null
+++ b/docs/plans/2026-05-07-background-tasks.md
@@ -0,0 +1,1231 @@
+# Background Tasks Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Give the user a unified, calm surface to see and stop every long-lived task Kanna owns (Claude SDK background bash shells, draining streams, terminal PTYs, codex sessions), with graceful stop semantics, persistence across Kanna restarts, and a navbar indicator + dialog UI that follows the Editorial Workspace design system.
+
+**Architecture:** A new `BackgroundTaskRegistry` on the server is the single source of truth for four task kinds. It is owned by `AgentCoordinator` and injected into `TerminalManager` and `CodexAppServerManager`. State diffs are broadcast over a new WebSocket channel `bg-tasks:list`; the client mirrors them in a Zustand store and renders a navbar indicator plus a `Dialog` (sheet on mobile). Stop is graceful (SIGTERM → 3s grace → SIGKILL) with a PID-reuse guard and per-kind strategies. Bash shells survive Kanna restarts via an atomic-write JSON file keyed by listening port; the boot path probes for liveness and surfaces survivors as orphans. All UI complies with `DESIGN.md`: warm-tinted neutrals, restrained color, editorial typography, flat-by-default elevation, color-plus-shape signaling, tabular numerics, project Tooltip not native title, AAA-where-feasible accessibility.
+
+**Tech Stack:** Bun + TypeScript on the server (`src/server/*`), React + Zustand on the client (`src/client/*`), shadcn dialog, Tailwind v4 with OKLCH tokens, `bun test` for unit + integration. Existing `WsRouter` for WebSocket protocol.
+
+**Reference docs in this branch:**
+- `docs/plans/2026-05-07-background-tasks-design.md` — design source of truth
+- `PRODUCT.md` — strategic register, voice, anti-references
+- `DESIGN.md` — visual tokens and component vocabulary
+
+---
+
+## Task 0: Fix six pre-existing baseline test failures
+
+The branch was created from `main@bd13004` where the following six tests fail. Per `CLAUDE.md` "bun test MUST pass before push or PR" they must be green before the PR. Each must be investigated; if a test is environment-dependent (e.g. needs a live Claude provider), the fix is to skip or mock at the test level, not in the implementation. If a test is a real regression on main, fix the underlying code in this branch and call it out in the commit body.
+
+**Failing tests:**
+- `password auth > serves the app shell to unauthenticated browser requests`
+- `runCli > starts normally when no newer version exists`
+- `runCli > returns restarting when a newer version is available`
+- `runCli > falls back to current version when install fails`
+- `runCli > falls back to current version when the registry check fails`
+- `uploads > rejects oversized uploads before reading them into memory`
+
+**Step 1: Run each test in isolation to capture full failure output**
+
+For each failing test, run:
+
+```bash
+bun test src/server/.test.ts -t "" 2>&1 | tee /tmp/bg-tasks-baseline-.log
+```
+
+Read the failure carefully. Categorize as: (a) needs network/provider, (b) flaky timing, (c) real regression on main.
+
+**Step 2: Fix per category**
+
+- **(a) needs network/provider:** wrap in `it.skipIf` with an env-var gate, or replace the live call with the existing `quick-response` mock pattern used in the project. Document the skip reason inline.
+- **(b) flaky timing:** raise the timeout, replace `setTimeout` with `Bun.sleep`, or convert to fake timers if the codebase uses them. No `await sleep(N)` retries.
+- **(c) real regression:** read the surrounding code via LSP `goToDefinition` / `findReferences`, write a focused fix, run the single test green, run the whole file green.
+
+**Step 3: Run only the changed tests**
+
+```bash
+bun test src/server/.test.ts
+```
+
+Expected: PASS.
+
+**Step 4: Commit each fix as a separate commit with `fix(test):` prefix**
+
+```bash
+git add src/server/.test.ts src/server/.ts
+git commit -F- <<'MSG'
+fix(test):
+
+
+MSG
+```
+
+**Step 5: After all six are green, run the full suite once**
+
+```bash
+bun test
+```
+
+Expected: 0 fail. Proceed only when clean.
+
+---
+
+## Task 1: BackgroundTaskRegistry — types and skeleton
+
+**Files:**
+- Create: `src/server/background-tasks.ts`
+- Test: `src/server/background-tasks.test.ts`
+
+**Step 1: Write the failing test (skeleton + register/list)**
+
+```ts
+// src/server/background-tasks.test.ts
+import { describe, expect, it } from "bun:test"
+import { BackgroundTaskRegistry, type BackgroundTask } from "./background-tasks"
+
+const sample = (): BackgroundTask => ({
+ kind: "draining_stream",
+ id: "ds-1",
+ chatId: "chat-1",
+ startedAt: 1_700_000_000_000,
+ lastOutput: "",
+})
+
+describe("BackgroundTaskRegistry", () => {
+ it("registers and lists a task", () => {
+ const r = new BackgroundTaskRegistry()
+ r.register(sample())
+ expect(r.list()).toHaveLength(1)
+ expect(r.list()[0].id).toBe("ds-1")
+ })
+
+ it("filters by chatId", () => {
+ const r = new BackgroundTaskRegistry()
+ r.register(sample())
+ r.register({ ...sample(), id: "ds-2", chatId: "chat-2" })
+ expect(r.listByChat("chat-1").map((t) => t.id)).toEqual(["ds-1"])
+ })
+
+ it("unregisters a task", () => {
+ const r = new BackgroundTaskRegistry()
+ r.register(sample())
+ r.unregister("ds-1")
+ expect(r.list()).toHaveLength(0)
+ })
+
+ it("emits added/updated/removed events in order", () => {
+ const r = new BackgroundTaskRegistry()
+ const events: string[] = []
+ r.on("added", () => events.push("added"))
+ r.on("updated", () => events.push("updated"))
+ r.on("removed", () => events.push("removed"))
+ r.register(sample())
+ r.update("ds-1", { lastOutput: "hi" })
+ r.unregister("ds-1")
+ expect(events).toEqual(["added", "updated", "removed"])
+ })
+})
+```
+
+**Step 2: Run test, verify it fails**
+
+```bash
+bun test src/server/background-tasks.test.ts
+```
+
+Expected: FAIL with "Cannot find module './background-tasks'".
+
+**Step 3: Implement the minimal registry**
+
+```ts
+// src/server/background-tasks.ts
+export type BackgroundTask =
+ | {
+ kind: "bash_shell"
+ id: string
+ chatId: string | null
+ command: string
+ shellId: string
+ pid: number | null
+ startedAt: number
+ lastOutput: string
+ status: "running" | "stopping"
+ orphan?: boolean
+ }
+ | {
+ kind: "draining_stream"
+ id: string
+ chatId: string
+ startedAt: number
+ lastOutput: string
+ }
+ | {
+ kind: "terminal_pty"
+ id: string
+ ptyId: string
+ cwd: string
+ startedAt: number
+ lastOutput: string
+ }
+ | {
+ kind: "codex_session"
+ id: string
+ chatId: string
+ pid: number | null
+ startedAt: number
+ lastOutput: string
+ }
+
+export type RegistryEvent = "added" | "updated" | "removed"
+export type Listener = (task: BackgroundTask) => void
+export type Unsubscribe = () => void
+
+export class BackgroundTaskRegistry {
+ private tasks = new Map()
+ private listeners: Record> = {
+ added: new Set(),
+ updated: new Set(),
+ removed: new Set(),
+ }
+
+ list(): BackgroundTask[] {
+ return Array.from(this.tasks.values())
+ }
+
+ listByChat(chatId: string): BackgroundTask[] {
+ return this.list().filter((t) => "chatId" in t && t.chatId === chatId)
+ }
+
+ register(task: BackgroundTask): void {
+ this.tasks.set(task.id, task)
+ this.emit("added", task)
+ }
+
+ update(id: string, patch: Partial): void {
+ const prev = this.tasks.get(id)
+ if (!prev) return
+ const next = { ...prev, ...patch } as BackgroundTask
+ this.tasks.set(id, next)
+ this.emit("updated", next)
+ }
+
+ unregister(id: string): void {
+ const prev = this.tasks.get(id)
+ if (!prev) return
+ this.tasks.delete(id)
+ this.emit("removed", prev)
+ }
+
+ on(event: RegistryEvent, cb: Listener): Unsubscribe {
+ this.listeners[event].add(cb)
+ return () => this.listeners[event].delete(cb)
+ }
+
+ private emit(event: RegistryEvent, task: BackgroundTask): void {
+ for (const cb of this.listeners[event]) cb(task)
+ }
+}
+```
+
+**Step 4: Run test, verify pass**
+
+```bash
+bun test src/server/background-tasks.test.ts
+```
+
+Expected: 4 pass.
+
+**Step 5: Commit**
+
+```bash
+git add src/server/background-tasks.ts src/server/background-tasks.test.ts
+git commit -F- <<'MSG'
+feat(bg-tasks): add BackgroundTaskRegistry skeleton with typed events
+
+Types cover all four kinds (bash_shell, draining_stream, terminal_pty,
+codex_session). Registry emits added/updated/removed; consumers
+subscribe with on().
+MSG
+```
+
+---
+
+## Task 2: Stop semantics — graceful TERM/KILL with PID-reuse guard
+
+**Files:**
+- Modify: `src/server/background-tasks.ts`
+- Test: `src/server/background-tasks.test.ts`
+- Possibly create: `src/server/process-utils.ts` (extend existing)
+
+**Step 1: Read existing process utilities via LSP**
+
+Use LSP `documentSymbol` on `src/server/process-utils.ts` to learn what is available. Reuse before adding new helpers.
+
+**Step 2: Write failing tests for stop()**
+
+Add to `background-tasks.test.ts`:
+
+```ts
+import { spawn } from "bun"
+
+describe("BackgroundTaskRegistry.stop", () => {
+ it("sends SIGTERM, then SIGKILL after grace, on a real process", async () => {
+ // Spawn a Bun script that ignores SIGTERM and stays alive.
+ const child = spawn({
+ cmd: ["bun", "-e", "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"],
+ stdin: "ignore",
+ })
+ const r = new BackgroundTaskRegistry()
+ r.register({
+ kind: "bash_shell",
+ id: "sh-1",
+ chatId: null,
+ command: "test",
+ shellId: "shell-1",
+ pid: child.pid!,
+ startedAt: Date.now(),
+ lastOutput: "",
+ status: "running",
+ })
+ const result = await r.stop("sh-1", { graceMs: 200 })
+ expect(result.ok).toBe(true)
+ expect(result.method).toBe("sigkill")
+ await child.exited
+ }, 5000)
+
+ it("force: true uses SIGKILL immediately", async () => {
+ const child = spawn({
+ cmd: ["bun", "-e", "setInterval(() => {}, 1000);"],
+ stdin: "ignore",
+ })
+ const r = new BackgroundTaskRegistry()
+ r.register({
+ kind: "bash_shell",
+ id: "sh-2",
+ chatId: null,
+ command: "test",
+ shellId: "shell-2",
+ pid: child.pid!,
+ startedAt: Date.now(),
+ lastOutput: "",
+ status: "running",
+ })
+ const result = await r.stop("sh-2", { force: true })
+ expect(result.ok).toBe(true)
+ expect(result.method).toBe("sigkill")
+ await child.exited
+ }, 5000)
+
+ it("PID-reuse guard: returns ok:false when comm does not match", async () => {
+ const r = new BackgroundTaskRegistry()
+ r.register({
+ kind: "bash_shell",
+ id: "sh-3",
+ chatId: null,
+ command: "definitely-not-this-one",
+ shellId: "shell-3",
+ pid: 1, // init/launchd, never matches "definitely-not-this-one"
+ startedAt: Date.now(),
+ lastOutput: "",
+ status: "running",
+ })
+ const result = await r.stop("sh-3")
+ expect(result.ok).toBe(false)
+ expect(result.error).toContain("PID mismatch")
+ expect(r.list()).toHaveLength(0) // dropped from registry
+ })
+})
+```
+
+**Step 3: Run tests, verify they fail**
+
+```bash
+bun test src/server/background-tasks.test.ts -t "stop"
+```
+
+Expected: FAIL with "stop is not a function".
+
+**Step 4: Implement stop() with strategies**
+
+Extend `BackgroundTaskRegistry`:
+
+```ts
+// add to src/server/background-tasks.ts
+
+export type StopResult =
+ | { ok: true; method: "sigterm" | "sigkill" | "close" | "shutdown" }
+ | { ok: false; error: string }
+
+export type StopOptions = { force?: boolean; graceMs?: number }
+
+// Per-kind strategy hooks injected by AgentCoordinator
+export type StopStrategies = {
+ killShell?: (task: Extract) => Promise
+ closeStream?: (task: Extract) => Promise
+ killPty?: (task: Extract) => Promise
+ shutdownCodex?: (task: Extract) => Promise
+}
+
+export class BackgroundTaskRegistry {
+ // ...existing fields...
+ private strategies: StopStrategies = {}
+
+ setStrategies(strategies: StopStrategies): void {
+ this.strategies = { ...this.strategies, ...strategies }
+ }
+
+ async stop(id: string, opts: StopOptions = {}): Promise {
+ const task = this.tasks.get(id)
+ if (!task) return { ok: false, error: "task not found" }
+
+ if (task.kind === "draining_stream") {
+ await this.strategies.closeStream?.(task)
+ this.unregister(id)
+ return { ok: true, method: "close" }
+ }
+ if (task.kind === "terminal_pty") {
+ await this.strategies.killPty?.(task)
+ this.unregister(id)
+ return { ok: true, method: "close" }
+ }
+ if (task.kind === "codex_session") {
+ await this.strategies.shutdownCodex?.(task)
+ this.unregister(id)
+ return { ok: true, method: "shutdown" }
+ }
+
+ // bash_shell: signal lifecycle with PID-reuse guard
+ if (task.pid == null) return { ok: false, error: "no pid recorded" }
+
+ const commOk = await verifyComm(task.pid, task.command)
+ if (!commOk) {
+ this.unregister(id)
+ return { ok: false, error: "PID mismatch (process reused)" }
+ }
+
+ if (opts.force) {
+ await safeKill(task.pid, "SIGKILL")
+ this.unregister(id)
+ return { ok: true, method: "sigkill" }
+ }
+
+ this.update(id, { status: "stopping" })
+ await safeKill(task.pid, "SIGTERM")
+ const grace = opts.graceMs ?? 3000
+ const exited = await waitForExit(task.pid, grace)
+ if (exited) {
+ this.unregister(id)
+ return { ok: true, method: "sigterm" }
+ }
+ await safeKill(task.pid, "SIGKILL")
+ await waitForExit(task.pid, 1000)
+ this.unregister(id)
+ return { ok: true, method: "sigkill" }
+ }
+}
+
+async function safeKill(pid: number, signal: "SIGTERM" | "SIGKILL"): Promise {
+ try {
+ process.kill(pid, signal)
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === "ESRCH") return
+ throw err
+ }
+}
+
+async function waitForExit(pid: number, timeoutMs: number): Promise {
+ const start = Date.now()
+ while (Date.now() - start < timeoutMs) {
+ try {
+ process.kill(pid, 0)
+ } catch {
+ return true
+ }
+ await Bun.sleep(50)
+ }
+ return false
+}
+
+async function verifyComm(pid: number, expectedCommand: string): Promise {
+ // Cross-platform: read /proc on Linux, fall back to ps elsewhere.
+ try {
+ const proc = Bun.spawn({
+ cmd: ["ps", "-p", String(pid), "-o", "command="],
+ stdin: "ignore",
+ stdout: "pipe",
+ })
+ const out = (await new Response(proc.stdout).text()).trim()
+ if (!out) return false
+ const cmdToken = expectedCommand.split(/\s+/)[0] ?? ""
+ if (!cmdToken) return true
+ return out.includes(cmdToken)
+ } catch {
+ return false
+ }
+}
+```
+
+**Step 5: Run tests**
+
+```bash
+bun test src/server/background-tasks.test.ts -t "stop"
+```
+
+Expected: 3 pass.
+
+**Step 6: Commit**
+
+```bash
+git add src/server/background-tasks.ts src/server/background-tasks.test.ts
+git commit -F- <<'MSG'
+feat(bg-tasks): graceful stop with TERM/KILL grace and PID-reuse guard
+
+Per-kind strategies are injected via setStrategies(). bash_shell uses
+SIGTERM with a 3s grace then SIGKILL; force:true skips grace. Before
+killing, the registry verifies the live process command still matches
+the recorded command, dropping the entry without killing on mismatch.
+MSG
+```
+
+---
+
+## Task 3: Wire `bash_shell` discovery in `agent.ts`
+
+**Files:**
+- Modify: `src/server/agent.ts` (around `trackBashToolEntry`, lines 793-814 today)
+- Test: `src/server/agent.test.ts`
+
+**Step 1: Read current `trackBashToolEntry` carefully**
+
+Use LSP `goToDefinition` on `trackBashToolEntry` and read its full body plus the surrounding `tool_call` / `tool_result` shapes. Confirm what fields the SDK populates for `run_in_background: true` (especially how the shell id and pid are exposed in the tool result content).
+
+If the SDK does not surface the shell id/pid in the result content, fall back to extracting from the result text via a tight regex (Claude Code typically prints `Background process started ... pid `). Record both in the registry; pid is the only thing required for stopping.
+
+**Step 2: Write failing test**
+
+Add to `src/server/agent.test.ts`:
+
+```ts
+import { BackgroundTaskRegistry } from "./background-tasks"
+
+it("registers a bash_shell task on tool_result when run_in_background is true", async () => {
+ const registry = new BackgroundTaskRegistry()
+ // ...existing test scaffolding to construct an AgentCoordinator with `registry` injected...
+ const chatId = "chat-bg"
+ // simulate tool_call with run_in_background: true
+ // simulate tool_result with text containing pid 12345
+ // (use existing helpers in agent.test.ts to push events)
+
+ expect(registry.list()).toHaveLength(1)
+ const task = registry.list()[0]
+ expect(task.kind).toBe("bash_shell")
+ if (task.kind === "bash_shell") {
+ expect(task.pid).toBe(12345)
+ expect(task.chatId).toBe(chatId)
+ expect(task.command).toContain("bun run dev")
+ }
+})
+```
+
+(Read the existing `agent.test.ts` to find the matching helper pattern; do not invent new scaffolding if a `pushEvent`-style helper already exists.)
+
+**Step 3: Run test, verify fail**
+
+```bash
+bun test src/server/agent.test.ts -t "run_in_background"
+```
+
+Expected: FAIL.
+
+**Step 4: Implement**
+
+Inject the registry into `AgentCoordinator` via constructor `args.backgroundTasks`. Extend `trackBashToolEntry`:
+
+```ts
+private trackBashToolEntry(chatId: string, entry: TranscriptEntry): void {
+ if (entry.kind === "tool_call" && entry.tool.toolKind === "bash") {
+ const command = entry.tool.input.command ?? ""
+ const isBg = entry.tool.input.run_in_background === true
+ this.pendingBashCalls.set(entry.tool.toolId, { command, chatId, isBg })
+ if (this.tunnelGateway) {
+ // existing behavior unchanged
+ }
+ return
+ }
+
+ if (entry.kind === "tool_result") {
+ const pending = this.pendingBashCalls.get(entry.toolId)
+ if (!pending) return
+ this.pendingBashCalls.delete(entry.toolId)
+
+ const stdout = stringifyToolResultContent(entry.content)
+
+ if (pending.isBg && this.backgroundTasks) {
+ const pid = parseBackgroundPid(stdout)
+ const shellId = parseBackgroundShellId(stdout) ?? entry.toolId
+ this.backgroundTasks.register({
+ kind: "bash_shell",
+ id: `bash:${entry.toolId}`,
+ chatId,
+ command: pending.command,
+ shellId,
+ pid,
+ startedAt: Date.now(),
+ lastOutput: stdout.slice(-1024),
+ status: "running",
+ })
+ }
+
+ if (this.tunnelGateway) {
+ void this.tunnelGateway.handleBashResult({
+ command: pending.command,
+ stdout,
+ chatId,
+ sourcePid: null,
+ })
+ }
+ }
+}
+```
+
+Add helpers in the same file (kept private, not exported):
+
+```ts
+function parseBackgroundPid(output: string): number | null {
+ const match = output.match(/\bpid[:\s]+(\d+)\b/i)
+ return match ? Number(match[1]) : null
+}
+
+function parseBackgroundShellId(output: string): string | null {
+ const match = output.match(/shell[_\s-]?id[:\s]+([\w-]+)/i)
+ return match ? match[1] : null
+}
+```
+
+**Step 5: Wire `BashOutput` updates** (a later tool result that streams output for an existing background shell): when the SDK fires a `BashOutput` tool_result, call `this.backgroundTasks?.update(id, { lastOutput })` with the last 12 lines of output. If the output indicates the shell has exited, call `unregister(id)`.
+
+**Step 6: Run tests**
+
+```bash
+bun test src/server/agent.test.ts -t "run_in_background"
+```
+
+Expected: pass.
+
+**Step 7: Commit**
+
+```bash
+git add src/server/agent.ts src/server/agent.test.ts
+git commit -F- <<'MSG'
+feat(bg-tasks): register bash_shell tasks on run_in_background results
+
+trackBashToolEntry now records shell id and pid from tool_result text,
+registers the entry with BackgroundTaskRegistry, and updates lastOutput
+on subsequent BashOutput tool_results. Exit lines unregister the task.
+MSG
+```
+
+---
+
+## Task 4: Wire `draining_stream` tracking
+
+**Files:**
+- Modify: `src/server/agent.ts` (around `drainingStreams.set` and `stopDraining`, lines 728/828/1585)
+- Test: `src/server/agent.test.ts`
+
+**Step 1: Failing test**
+
+Verify that when a turn reaches `kind: "result"`, the draining-stream entry registered in `drainingStreams` also lands in the registry, and that `stopDraining` removes it.
+
+**Step 2: Implement**
+
+In the `result` handler (around line 1585):
+
+```ts
+this.drainingStreams.set(active.chatId, { turn: active.turn })
+this.backgroundTasks?.register({
+ kind: "draining_stream",
+ id: `drain:${active.chatId}`,
+ chatId: active.chatId,
+ startedAt: Date.now(),
+ lastOutput: "",
+})
+```
+
+In `stopDraining`:
+
+```ts
+async stopDraining(chatId: string) {
+ const draining = this.drainingStreams.get(chatId)
+ if (!draining) return
+ draining.turn.close()
+ this.drainingStreams.delete(chatId)
+ this.backgroundTasks?.unregister(`drain:${chatId}`)
+ this.emitStateChange(chatId)
+}
+```
+
+Wire the registry's `closeStream` strategy to call `stopDraining` so the dialog's stop button works for draining streams too. Set strategies in `AgentCoordinator` constructor:
+
+```ts
+this.backgroundTasks?.setStrategies({
+ closeStream: async (task) => { await this.stopDraining(task.chatId) },
+})
+```
+
+**Step 3: Tests + commit**
+
+Run `bun test src/server/agent.test.ts`, then commit with `feat(bg-tasks): track draining streams in registry`.
+
+---
+
+## Task 5: Wire `terminal_pty` and `codex_session` tracking
+
+**Files:**
+- Modify: `src/server/terminal-manager.ts`
+- Modify: `src/server/codex-app-server.ts`
+- Test: `src/server/terminal-manager.test.ts` if present, else add one
+- Test: `src/server/codex-app-server.test.ts`
+
+**Step 1: Inject registry into both managers via constructor**
+
+For each manager, accept `backgroundTasks?: BackgroundTaskRegistry` in args. On spawn, call `register`; on exit, `unregister`. Wire strategies in `AgentCoordinator`:
+
+```ts
+this.backgroundTasks?.setStrategies({
+ killPty: async (task) => { await terminalManager.kill(task.ptyId) },
+ shutdownCodex: async (task) => { await codexManager.shutdown(task.chatId) },
+})
+```
+
+**Step 2: Failing tests**
+
+For `terminal-manager.test.ts`: spawn a PTY, assert registry entry exists; kill, assert unregistered. For codex: same pattern.
+
+**Step 3: Implement, test, commit**
+
+One commit per file: `feat(bg-tasks): track terminal PTYs in registry` and `feat(bg-tasks): track codex sessions in registry`.
+
+---
+
+## Task 6: Orphan persistence + boot recovery
+
+**Files:**
+- Create: `src/server/orphan-persistence.ts`
+- Test: `src/server/orphan-persistence.test.ts`
+- Modify: `src/server/cli.ts` (boot path)
+- Modify: `src/server/background-tasks.ts` (debounced write hook)
+
+**Step 1: Failing tests**
+
+```ts
+// src/server/orphan-persistence.test.ts
+describe("orphan persistence", () => {
+ it("write then read round-trips entries", async () => { /* ... */ })
+ it("drops dead pids on read", async () => { /* ... */ })
+ it("returns empty on corrupted JSON without throwing", async () => { /* ... */ })
+ it("atomic write: kill mid-write, file still valid", async () => { /* ... */ })
+})
+```
+
+**Step 2: Implement**
+
+```ts
+// src/server/orphan-persistence.ts
+import path from "node:path"
+import os from "node:os"
+import { mkdir, readFile, rename, writeFile } from "node:fs/promises"
+
+export type PersistedTask = {
+ id: string
+ pid: number
+ command: string
+ chatId: string | null
+ startedAt: number
+}
+
+export type OrphanFile = { tasks: PersistedTask[]; writtenAt: number }
+
+const stateDir = path.join(os.homedir(), ".kanna", "state")
+
+function fileForPort(port: number): string {
+ return path.join(stateDir, `orphan-pids-${port}.json`)
+}
+
+export async function writeOrphans(port: number, tasks: PersistedTask[]): Promise {
+ await mkdir(stateDir, { recursive: true })
+ const target = fileForPort(port)
+ const tmp = `${target}.${process.pid}.tmp`
+ const payload: OrphanFile = { tasks, writtenAt: Date.now() }
+ await writeFile(tmp, JSON.stringify(payload, null, 2), "utf8")
+ await rename(tmp, target)
+}
+
+export async function readOrphans(port: number): Promise {
+ try {
+ const raw = await readFile(fileForPort(port), "utf8")
+ const parsed = JSON.parse(raw) as OrphanFile
+ if (!Array.isArray(parsed.tasks)) return []
+ return parsed.tasks
+ } catch {
+ return []
+ }
+}
+
+export function isAlive(pid: number): boolean {
+ try {
+ process.kill(pid, 0)
+ return true
+ } catch {
+ return false
+ }
+}
+```
+
+**Step 3: Wire from `BackgroundTaskRegistry`**
+
+Add a debounced subscription in `AgentCoordinator` constructor:
+
+```ts
+let writeTimer: ReturnType | null = null
+const persist = () => {
+ if (writeTimer) clearTimeout(writeTimer)
+ writeTimer = setTimeout(() => {
+ const tasks = this.backgroundTasks
+ .list()
+ .filter((t): t is Extract =>
+ t.kind === "bash_shell" && t.pid != null
+ )
+ .map((t) => ({ id: t.id, pid: t.pid!, command: t.command, chatId: t.chatId, startedAt: t.startedAt }))
+ void writeOrphans(this.port, tasks)
+ }, 500)
+}
+this.backgroundTasks.on("added", persist)
+this.backgroundTasks.on("updated", persist)
+this.backgroundTasks.on("removed", persist)
+```
+
+**Step 4: Boot recovery in `cli.ts`**
+
+After registry construction, before WS attach:
+
+```ts
+const persisted = await readOrphans(port)
+for (const t of persisted) {
+ if (!isAlive(t.pid)) continue
+ registry.register({
+ kind: "bash_shell",
+ id: t.id,
+ chatId: t.chatId,
+ command: t.command,
+ shellId: t.id,
+ pid: t.pid,
+ startedAt: t.startedAt,
+ lastOutput: "",
+ status: "running",
+ orphan: true,
+ })
+}
+```
+
+**Step 5: Tests, commit**
+
+`feat(bg-tasks): persist bash_shell pids and recover orphans on boot`.
+
+---
+
+## Task 7: WebSocket protocol — channel + command
+
+**Files:**
+- Modify: `src/server/ws-router.ts`
+- Test: `src/server/ws-router.test.ts`
+- Modify: `src/shared/types.ts` (or matching shared types file) for WS message kinds
+
+**Step 1: Failing tests**
+
+- subscribe `bg-tasks:list` returns snapshot, then diffs on register/update/unregister.
+- `bg-tasks:stop { id }` routes to registry, returns result.
+- `bg-tasks:stop { id: "missing" }` returns error, no crash.
+
+**Step 2: Implement**
+
+Add subscription handler and command handler. Use existing `WsRouter` patterns; do not invent new abstractions. Shape:
+
+```ts
+// snapshot
+{ kind: "bg-tasks:snapshot", tasks: BackgroundTask[] }
+// diff
+{ kind: "bg-tasks:diff", op: "added"|"updated"|"removed", task: BackgroundTask }
+// command
+{ kind: "bg-tasks:stop", id: string, force?: boolean }
+// response
+{ kind: "bg-tasks:stop:result", id: string, ok: boolean, error?: string }
+```
+
+**Step 3: Tests, commit**
+
+`feat(bg-tasks): WebSocket channel and stop command for background tasks`.
+
+---
+
+## Task 8: Client store + status formatting
+
+**Files:**
+- Create: `src/client/stores/backgroundTasksStore.ts`
+- Create: `src/client/stores/backgroundTasksStore.test.ts`
+- Modify: `src/client/lib/formatters.ts` (add `formatAge`)
+- Test: `src/client/lib/formatters.test.ts`
+
+**Step 1: Failing tests for `formatAge`**
+
+```ts
+it("formats age under a minute as Ns", () => {
+ expect(formatAge(0, 4_000)).toBe("4s")
+})
+it("formats minutes as Mm Ss", () => {
+ expect(formatAge(0, 134_000)).toBe("2m 14s")
+})
+it("formats hours as Hh Mm", () => {
+ expect(formatAge(0, 4 * 3600_000 + 12 * 60_000)).toBe("4h 12m")
+})
+```
+
+**Step 2: Implement `formatAge` in formatters.ts**
+
+Use tabular-nums-friendly output. Pure function: `(startedAt: number, now: number) => string`.
+
+**Step 3: Failing tests for store**
+
+```ts
+it("applies snapshot then diffs", () => {
+ const store = createBackgroundTasksStore()
+ store.applySnapshot([{ kind: "draining_stream", id: "a", chatId: "c", startedAt: 0, lastOutput: "" }])
+ expect(store.tasks).toHaveLength(1)
+ store.applyDiff({ op: "added", task: { kind: "draining_stream", id: "b", chatId: "c", startedAt: 0, lastOutput: "" } })
+ expect(store.tasks).toHaveLength(2)
+ store.applyDiff({ op: "removed", task: store.tasks[0] })
+ expect(store.tasks).toHaveLength(1)
+})
+```
+
+**Step 4: Implement using Zustand (project pattern)**
+
+Match the shape of existing stores like `chatPreferencesStore.ts`. Expose `runningCount` selector.
+
+**Step 5: Tests, commit**
+
+`feat(bg-tasks): client store and formatAge helper`.
+
+---
+
+## Task 9: Navbar indicator
+
+**Files:**
+- Modify: `src/client/components/chat-ui/ChatNavbar.tsx`
+- Test: `src/client/components/chat-ui/ChatNavbar.test.ts` (create or extend)
+
+**Step 1: Failing test**
+
+```ts
+it("renders amber dot and count when running tasks > 0", async () => { /* ... */ })
+it("renders neutral dot when count is 0", async () => { /* ... */ })
+it("uses project Tooltip, not native title", async () => { /* assert no title attribute */ })
+```
+
+**Step 2: Implement**
+
+Add a small button with leading dot + count, rendered in the navbar's right-action group. Keyboard shortcut `⌘⇧B` opens the dialog (wire via existing `keybindings.ts`). Tooltip via project `Tooltip`.
+
+Visual rules per `DESIGN.md`:
+- Dot color: `oklch(76% 0.14 78)` (Editor Amber) when count > 0, else `var(--muted-foreground)`.
+- **Static** dot, no `animate-pulse`, no glow.
+- Count: mono with `tabular-nums`.
+- Padding aligned with sibling navbar buttons.
+
+**Step 3: Tests, commit**
+
+`feat(bg-tasks): navbar indicator with running count and keyboard shortcut`.
+
+---
+
+## Task 10: BackgroundTasksDialog — surface + rows + accessibility
+
+**Files:**
+- Create: `src/client/components/chat-ui/BackgroundTasksDialog.tsx`
+- Create: `src/client/components/chat-ui/BackgroundTasksDialog.test.tsx`
+- Modify: `src/client/components/ui/dialog.tsx` only if existing variant is insufficient
+
+**Step 1: Failing tests**
+
+- Renders snapshot rows with command, age (mono, tabular-nums), type tag, chat link, started time, stop button.
+- Empty state shows the editorial sentence.
+- `Esc` closes; arrow keys navigate rows; `Enter` expands; `⌘.` triggers stop on focused row.
+- Sets no native `title` attributes.
+
+**Step 2: Implement to design spec**
+
+Match `DESIGN.md` exactly:
+- shadcn `Dialog` + `DialogContent` width ~720px desktop.
+- Header: "Background tasks" — `headline` scale, weight 500, sentence case, no icon, with ` running` muted-tag right.
+- Two-line rows. Line 1 = mono command (weight 600, 14px) + mono tabular-nums age right.
+- Line 2 = type tag, chat link (project router), started clock — sans 12px muted, plus stop icon button right.
+- Expand chevron reveals last 12 lines of `lastOutput` in mono 12px (max-h 240).
+- Row hover: `bg-secondary` (Surface Secondary). No border-left stripe.
+- Status indicator: `oklch(76% 0.14 78)` dot for running, static.
+
+Animation:
+- Row enter: `opacity 0→1, translateY 4px→0, 180ms cubic-bezier(0.22, 1, 0.36, 1)`. Disabled under `prefers-reduced-motion`.
+- Dialog open: `scale 0.98→1, opacity 0→1, 160ms`. No backdrop blur.
+
+**Step 3: Tests, commit**
+
+`feat(bg-tasks): dialog with row anatomy, expand, and keyboard navigation`.
+
+---
+
+## Task 11: Inline confirm-stop + force-kill timeout
+
+**Files:**
+- Modify: `src/client/components/chat-ui/BackgroundTasksDialog.tsx`
+- Test: `BackgroundTasksDialog.test.tsx`
+
+**Step 1: Failing tests**
+
+- Click stop → row enters `confirm` state with `Confirm stop?` + `Cancel` (no nested modal).
+- Confirm → row shows `stopping…`, stop request dispatched.
+- After 3s no exit → `Force kill` red button appears.
+- `Esc` cancels confirm.
+
+**Step 2: Implement**
+
+Local row state machine: `idle → confirm → stopping → forceAvailable`. Other rows dim while confirm is open. Single-row scope; never affects other tasks. `Force kill` calls `bg-tasks:stop { force: true }`.
+
+Visual: `Confirm stop?` text uses Coral (`var(--destructive)`); `Cancel` is ghost. Slide-in 180ms from right; respects reduced motion.
+
+**Step 3: Tests, commit**
+
+`feat(bg-tasks): inline confirm-stop with force-kill fallback`.
+
+---
+
+## Task 12: Mobile sheet variant + orphan section
+
+**Files:**
+- Modify: `BackgroundTasksDialog.tsx`
+- Reference: `src/client/hooks/useIsStandalone.ts` and existing mobile-detection helpers
+
+**Step 1: Failing tests**
+
+- Mobile breakpoint: dialog renders as bottom sheet.
+- Orphan tasks render in a section header at the top with `Kill all` action.
+- Long-press on row shows full command (replaces tooltip on touch).
+
+**Step 2: Implement**
+
+Use existing breakpoint helper (`@media (max-width: 640px)` or matching hook). Sheet animation: translateY from 100% to 0, 220ms ease-out-quart, no backdrop blur. Rows stack tighter: line-1 cmd+age, line-2 type+chat, line-3 stop button full-width.
+
+Orphan section header: `Found from previous session` — muted Body 12px, with `Kill all` text button right-aligned that confirms inline before dispatching N parallel stop commands.
+
+**Step 3: Tests, commit**
+
+`feat(bg-tasks): mobile sheet variant and orphan section`.
+
+---
+
+## Task 13: Telemetry + boot toast
+
+**Files:**
+- Modify: `src/server/analytics.ts` (event types)
+- Modify: `src/server/agent.ts` (emit events on register/stop)
+- Modify: `src/client/app/chatNotifications.ts` (boot toast)
+
+**Step 1: Add events**
+
+```ts
+type BgTaskEvent =
+ | { kind: "bg_task_registered"; taskKind: BackgroundTask["kind"] }
+ | { kind: "bg_task_stopped"; taskKind: BackgroundTask["kind"]; ageMs: number; force: boolean }
+ | { kind: "bg_task_orphan_kept"; count: number }
+ | { kind: "bg_task_orphan_killed"; count: number }
+```
+
+No PII (no command content, no chatId). Respect existing analytics opt-out.
+
+**Step 2: Boot toast**
+
+When orphan recovery finds N > 0 survivors, post one toast: *"3 processes survived restart · review"* with click action that opens the dialog. Use existing chatNotifications API; do not introduce a new toaster.
+
+**Step 3: Tests, commit**
+
+`feat(bg-tasks): analytics events and orphan boot toast`.
+
+---
+
+## Task 14: Manual smoke + accessibility audit
+
+**Step 1: Start dev server**
+
+```bash
+bun run dev
+```
+
+Open browser. Drive a chat that runs `bun run dev` with `run_in_background: true` (use the agent UI). Verify:
+
+- Navbar dot turns amber and count = 1.
+- Open dialog with `⌘⇧B`.
+- Row appears with mono command, tabular age ticking, type tag, chat link.
+- Click stop → confirm appears inline. Confirm → row shows `stopping…`. Process exits, row fades.
+- `pgrep -f 'bun run dev'` returns empty.
+
+Repeat with a process that traps SIGTERM to verify `Force kill` fallback after 3s.
+
+**Step 2: Restart Kanna**
+
+Start a long-running process, kill the Kanna server (Ctrl-C). Restart. Open the dialog. Confirm an orphan section appears with the surviving pid; boot toast was posted.
+
+**Step 3: Mobile**
+
+Resize to ≤ 640px (or use device emulation). Confirm sheet variant. Confirm swipe-left exposes stop, long-press shows full command.
+
+**Step 4: Accessibility**
+
+- Tab through navbar → dialog → rows → stop. Focus rings always visible.
+- VoiceOver: row reads "Stop bun run dev, running 2 minutes 14 seconds".
+- `prefers-reduced-motion`: enable in OS, confirm no row enter animation.
+- Lighthouse a11y check on the dialog viewport: contrast AAA on body text where the design allows.
+
+**Step 5: Document smoke results**
+
+Append a short "Verification" section to `docs/plans/2026-05-07-background-tasks.md` listing what was tested. Commit:
+
+`docs(bg-tasks): record manual smoke results`.
+
+---
+
+## Task 15: Final test run + PR
+
+**Step 1: Full suite green**
+
+```bash
+bun test
+```
+
+Expected: 0 fail across all suites including the six baseline fixes from Task 0.
+
+**Step 2: Build check**
+
+```bash
+bun run build
+```
+
+Expected: success.
+
+**Step 3: Push branch and open PR**
+
+```bash
+git push -u origin feat/bg-tasks
+gh pr create --repo cuongtranba/kanna --base main --head feat/bg-tasks --title "feat(bg-tasks): visibility and stop control for background tasks" --body-file - <<'PRBODY'
+## Summary
+- New BackgroundTaskRegistry tracks bash_shell, draining_stream, terminal_pty, codex_session as a single source of truth, with graceful TERM/KILL stop semantics and a PID-reuse guard
+- Navbar indicator + dialog (sheet on mobile) listing every long-lived task with inline confirm-stop and force-kill fallback
+- Bash shells survive Kanna restart via atomic-write JSON keyed by listening port; orphans are surfaced via a boot toast and dialog section
+- Six pre-existing baseline test failures on main@bd13004 fixed in earlier commits on this branch
+
+## Design
+
+- `docs/plans/2026-05-07-background-tasks-design.md` — design source of truth
+- `PRODUCT.md`, `DESIGN.md` seeded; UI follows the Editorial Workspace system
+
+## Test plan
+- [x] `bun test` passes (0 fail)
+- [x] `bun run build` passes
+- [x] Manual smoke: spawn bg dev server, stop via dialog, restart Kanna and recover orphan
+- [x] Mobile sheet variant verified at ≤ 640px
+- [x] VoiceOver reads rows correctly; focus rings visible; reduced-motion respected
+PRBODY
+```
+
+**Step 4: Confirm CI green**
+
+Watch the test workflow; do not merge until CI passes.
+
+---
+
+---
+
+## Verification
+
+**Date:** 2026-05-07
+
+### Automated checks
+
+| Check | Result |
+|---|---|
+| `bun test --timeout 30000` (full suite) | PASS — 1110 tests, 0 fail, 2384 expect() calls |
+| `bunx tsc --noEmit -p tsconfig.json` | PASS — no output (zero errors) |
+| `bun run build` | PASS — both client and export-viewer built successfully |
+| Dev server smoke (`bun run dev` → `curl http://localhost:3210/`) | PASS — HTTP 200, valid HTML response |
+
+#### Static a11y checks on `BackgroundTasks*.tsx`
+
+| Check | Expected | Result |
+|---|---|---|
+| `title=` (native title attribute) | ZERO matches | PASS — none found |
+| `outline: none` / `outline-none` without focus replacement | ZERO matches | PASS — none found |
+| `animate-pulse` / `animate-spin` on status indicators | ZERO matches | PASS — none found |
+| `aria-label` on icon-only buttons (stop, expand, force-kill) | Present | PASS — 18 aria-label attributes found across Dialog and Indicator |
+| `tabular-nums` Tailwind class on age/count text | Present | PASS — 9 occurrences across Dialog (age spans) and Indicator (count span) |
+
+#### WCAG contrast (OKLCH → sRGB, WCAG 2.1 formula)
+
+| Pair | Ratio | Verdict |
+|---|---|---|
+| **Light** Espresso Ink `oklch(16% 0.01 13)` on Warm Paper `oklch(99.5% 0.003 13)` | 19.13:1 | AAA |
+| **Light** Margin Gray `oklch(55% 0.013 13)` on Warm Paper | 4.81:1 | AA |
+| **Dark** Pale Foreground `oklch(98% 0.003 13)` on Inkstone `oklch(20% 0.01 13)` | 17.11:1 | AAA |
+| **Dark** Margin Gray dark `oklch(70% 0.012 13)` on Inkstone | 6.76:1 | AA |
+| Design spec: Pale Foreground text on Kanna Coral filled button | 2.70:1 | **FAIL** *(theoretical; not used in actual impl)* |
+| **Actual impl** Coral `oklch(71.2% 0.194 13.428)` text on Warm Paper (light destructive labels) | 2.81:1 | **CONCERN — below AA (4.5:1)** |
+| **Actual impl** Coral text on Inkstone (dark destructive labels) | 6.35:1 | AA |
+
+**Coral contrast concern (light theme):** The implementation renders `var(--destructive)` (Kanna Coral) as text/icon color in light theme at 2.81:1 — below the WCAG AA threshold of 4.5:1 for normal-sized text. This affects the "Stop task", "Confirm stop", "Cancel stop", and "Force kill" labels in `BackgroundTasksDialog.tsx`. In dark theme the same coral reads at 6.35:1 (AA). The design doc states "Body contrast ≥ 7:1; large text ≥ 4.5:1; never below AA" — the light-mode coral-on-white combination violates this.
+
+Possible mitigations before merge:
+1. Darken the coral token in light mode only (e.g. `oklch(52% 0.18 13)` reaches ~4.5:1 on white).
+2. Use a border+icon shape with neutral text and coral border, keeping Coral decorative only.
+3. Accept the gap and mark it as a known limitation in the PR, to be addressed when the full design token audit runs.
+
+### Items deferred to manual testing
+
+| Item | Why it cannot be automated |
+|---|---|
+| VoiceOver / TalkBack reading row labels and status | Requires a real screen-reader session with a human listener to confirm spoken output matches "Stop bun run dev, running 2 minutes 14 seconds" |
+| `prefers-reduced-motion` disabling row enter animation | Requires a real browser with the OS media query toggled; jsdom test environment does not honour OS-level preferences |
+| Live Lighthouse audit (contrast, performance, best practices) | Requires a running Chromium-based browser attached to a live dev server |
+| Mobile sheet swipe-left to expose stop | Requires touch-event simulation in a real device or responsive browser emulator |
+| Agent `run_in_background: true` shell spawned through actual Claude SDK → dialog row appears | Requires a live Claude API key and provider connection |
+| Kanna restart → orphan section appears with surviving PID | Requires a multi-step manual session: spawn shell, kill Kanna, relaunch, observe UI |
+
+### Notes for reviewer
+
+- The `bun test` warnings from zustand persist middleware (`Unable to update item 'chat-input-drafts'`) are pre-existing in jsdom and do not indicate a bug.
+- Build output chunk size warnings (`> 500 kB after minification`) are pre-existing and unrelated to this feature.
+- The Coral contrast failure in light mode (2.81:1) is the only substantive new concern found. All other static checks passed.
+
+---
+
+## Notes for the executor
+
+- This branch is checked out at `.worktrees/bg-tasks`. All commands run there; never `cd` to other worktrees.
+- Per `CLAUDE.md`, always resolve symbols via LSP first (`goToDefinition`, `findReferences`, `documentSymbol`) before grepping. Strong typing only — no `any` or untyped maps; if a type doesn't exist, define it.
+- Pre-existing issues encountered mid-task (failing test in untouched code) — stop, report, ask. Do not silently work around.
+- Subagent safety: any subagent dispatched for parallel work must run only the targeted tests for the files it touched, never the full `bun test`.
+- Subprocess hygiene: every `git` or process spawn in tests sets `stdin: "ignore"` and `GIT_TERMINAL_PROMPT=0` per the project rule.
+- Skills to use along the way:
+ - `superpowers:test-driven-development` — write the failing test first on every task
+ - `superpowers:systematic-debugging` — when a task fails unexpectedly
+ - `superpowers:verification-before-completion` — before marking any task done
+ - `kanna-react-style` — every `.tsx` file under `src/client`
+ - `superpowers:dispatching-parallel-agents` — when tasks 8+ and 9+ are independent
diff --git a/docs/plans/2026-05-07-upload-size-setting-progress-design.md b/docs/plans/2026-05-07-upload-size-setting-progress-design.md
new file mode 100644
index 000000000..2225ecfe2
--- /dev/null
+++ b/docs/plans/2026-05-07-upload-size-setting-progress-design.md
@@ -0,0 +1,153 @@
+# Upload Size Setting + Upload Progress UI — Design
+
+Date: 2026-05-07
+
+## Problem
+
+Max upload size is hardcoded (`MAX_UPLOAD_SIZE_BYTES = 100 * 1024 * 1024` at `src/server/server.ts:43`). Users running self-hosted Kanna cannot raise or lower the limit without editing source. Uploads also show no progress — for large files (close to 100 MB) the UI sits in an indeterminate "uploading" state with no feedback and no way to cancel.
+
+## Goals
+
+1. Make per-file max upload size a user setting (server-enforced, client-mirrored).
+2. Show determinate upload progress per attachment.
+3. Allow cancelling an in-flight upload.
+
+## Non-goals (YAGNI)
+
+- Per-batch file count setting (`MAX_UPLOAD_FILES = 50` stays hardcoded).
+- MIME allowlist, retention policy, retry button.
+- Multi-file aggregate progress bar.
+
+## Design
+
+### 1. Settings model
+
+Add to `src/shared/types.ts`:
+
+```ts
+export interface UploadSettings {
+ maxFileSizeMb: number // default 100
+}
+export const UPLOAD_DEFAULTS: UploadSettings = { maxFileSizeMb: 100 }
+export const UPLOAD_MAX_FILE_SIZE_MB_MIN = 1
+export const UPLOAD_MAX_FILE_SIZE_MB_MAX = 2048
+```
+
+Extend `AppSettingsSnapshot` and `AppSettingsPatch` with `uploads: UploadSettings`.
+
+`src/server/app-settings.ts`:
+- `normalizeUploadSettings(value, warnings)` — clamp to [min,max], emit warnings on invalid input.
+- Wire into `normalizeAppSettings`, `toFilePayload`, `toSnapshot`, `applyPatch`, `AppSettingsFile`.
+- New method `setUploads(patch: Partial)`.
+
+### 2. Server enforcement
+
+`src/server/server.ts` upload handler — read live limit from settings manager:
+
+```ts
+const { maxFileSizeMb } = appSettings.getSnapshot().uploads
+const maxBytes = maxFileSizeMb * 1024 * 1024
+if (file.size > maxBytes) {
+ return Response.json(
+ { error: `File "${file.name}" exceeds the ${maxFileSizeMb} MB limit.` },
+ { status: 400 },
+ )
+}
+```
+
+`MAX_UPLOAD_FILES = 50` stays hardcoded.
+
+### 3. Settings UI
+
+`src/client/app/SettingsPage.tsx` — new "Uploads" section with one number field:
+- Label "Max file size" with "MB" suffix.
+- Range 1–2048, default 100, helper text states default and range.
+- Commit on blur or Enter (mirror Terminal scrollback pattern).
+- Invalid input: red ring + inline error, do not commit.
+- Tabular numerics for the value.
+
+Calls existing settings PATCH endpoint with `{ uploads: { maxFileSizeMb: n } }`. Live snapshot push propagates to all clients.
+
+### 4. Upload helper (XHR)
+
+New file `src/client/lib/uploadFile.ts`:
+
+```ts
+export interface UploadHandle {
+ promise: Promise<{ attachments: ChatAttachment[] }>
+ abort: () => void
+}
+export function uploadFile(args: {
+ projectId: string
+ file: File
+ onProgress: (loaded: number, total: number) => void
+}): UploadHandle
+```
+
+Uses `XMLHttpRequest` for `upload.onprogress`. `abort()` calls `xhr.abort()`. Rejects with:
+- `UploadAbortedError` on abort (silent in UI).
+- `Error(payload.error || "Upload failed")` on non-2xx.
+
+Throttle progress: only commit state when `%` changes by ≥1 OR every 100 ms. Always commit `loaded === total` synchronously.
+
+### 5. ChatInput wiring
+
+`src/client/components/chat-ui/ChatInput.tsx`:
+- Replace `fetch` block (~line 554) with `uploadFile(...)`.
+- Extend client-side attachment state with `progress?: { loaded, total }` and `abort?: () => void` (not sent to server).
+- `onProgress` updates the attachment by `tempId`.
+- Store `handle.abort` on attachment.
+- User-remove of an uploading attachment: `abort()` first, then drop. `removedAttachmentIdsRef` path still cleans up late completions.
+- `UploadAbortedError`: silently drop, no error toast.
+
+### 6. Card UI — determinate ring overlay
+
+New `src/client/components/messages/AttachmentUploadOverlay.tsx`:
+- Absolute overlay covering the card, `bg-background/60 backdrop-blur-sm`.
+- Centered SVG ring (track + progress circle, `stroke-dasharray` driven by progress, rotated -90°).
+- Smooth `transition: stroke-dashoffset 120ms ease-out` between throttled updates.
+- Center text: percent (tabular nums). On group hover: swap to `lucide-react` `X` button calling `onCancel`. Project `Tooltip` "Cancel upload".
+- `role="progressbar"`, `aria-valuenow`, `aria-label`.
+- Indeterminate fallback before first progress event: spinning 25% arc.
+- `prefers-reduced-motion: reduce`: drop transition + spin.
+
+Mount in `AttachmentImageCard` and `AttachmentFileCard` when `status === "uploading"`. `failed` keeps existing visual.
+
+`/impeccable:impeccable` polish pass on overlay + Settings section after wiring works.
+
+## Tests
+
+- `src/server/app-settings.test.ts` — defaults, clamp out-of-range, warning text, patch round-trip.
+- `src/server/uploads.test.ts` — dynamic limit: oversized → 400, within → 200, change setting → next request enforces new value.
+- `src/client/lib/uploadFile.test.ts` — mocked `XMLHttpRequest`: progress callback, abort rejects, error JSON parsed.
+- `src/client/app/SettingsPage.test.tsx` — new field renders, commit fires patch, out-of-range rejected.
+- `AttachmentUploadOverlay` snapshot/unit tests at 0%, 50%, 100%, hover-cancel state.
+
+## Rollout (TDD, small commits)
+
+1. Types + server normalize + tests.
+2. Server enforcement swap + tests.
+3. SettingsPage Uploads section + tests.
+4. `uploadFile.ts` helper + tests.
+5. `AttachmentUploadOverlay` + tests.
+6. ChatInput integration (progress + abort).
+7. Manual browser pass: 3-file upload, ring animation, hover-cancel, oversized rejection on live setting change.
+8. `/impeccable:impeccable` polish pass.
+
+## Risks
+
+- XHR vs `fetch` `FormData` parity — Bun handles both.
+- Throttling could skip the final 100% frame — guard by always committing `loaded === total` synchronously.
+- Late `onprogress` after `abort` — guarded by checking handle state in callback.
+
+## Files touched
+
+- `src/shared/types.ts`
+- `src/server/app-settings.ts` + `.test.ts`
+- `src/server/server.ts`
+- `src/server/uploads.test.ts`
+- `src/client/app/SettingsPage.tsx` + `.test.tsx`
+- `src/client/lib/uploadFile.ts` + `.test.ts`
+- `src/client/components/chat-ui/ChatInput.tsx`
+- `src/client/components/messages/AttachmentUploadOverlay.tsx` + tests
+- `src/client/components/messages/AttachmentCard.tsx`
diff --git a/docs/plans/2026-05-10-worktree-support-design.md b/docs/plans/2026-05-10-worktree-support-design.md
new file mode 100644
index 000000000..2fd47cfa2
--- /dev/null
+++ b/docs/plans/2026-05-10-worktree-support-design.md
@@ -0,0 +1,205 @@
+# In-Project Git Worktree Support
+
+**Date:** 2026-05-10
+**Status:** Design
+
+## Problem
+
+Kanna users who run multiple parallel sessions on the same repository must stop work, switch branches, and risk merge or stash conflicts. Today every Kanna project resolves to a single `localPath` (`src/server/event-store.ts:763`), and every chat inherits that path as its `cwd` (`src/server/agent.ts:101`). The only workaround is to register each `git worktree` directory as a separate top-level project, with no automation, no detection, and no UI to manage worktrees from inside Kanna.
+
+The user wants to keep `main` cleanly checked out while feature work happens in isolated worktrees, all from a single Kanna project view.
+
+## Goal
+
+Make a Kanna project a first-class container for the repository's git worktrees. Detect existing worktrees automatically, let the user create and remove worktrees from the UI, and bind every chat to exactly one worktree so concurrent chats never collide on a shared working tree.
+
+## Scope
+
+In scope:
+
+- Detect worktrees via `git worktree list --porcelain` on project open and via a manual refresh button.
+- Create worktrees from the UI with a new branch or an existing branch, base configurable, default base = repo default branch.
+- Remove worktrees from the UI with a two-step confirmation when the worktree has uncommitted changes.
+- Pin every chat to a single worktree at creation time. Chats inherit that worktree's path as their `cwd`.
+- Mark worktrees orphaned (read-only chat history) when the worktree disappears on disk.
+- Configurable storage directory per project, default `.worktrees/`.
+- Mobile and desktop UI parity.
+
+Out of scope (YAGNI):
+
+- Detached-HEAD worktrees.
+- Auto-rename worktree on branch rename.
+- Reassigning a chat from one worktree to another.
+- Cross-worktree diff comparison.
+- Automatic `git worktree repair` when a worktree dir moves.
+
+## Architecture
+
+### Data model — event store
+
+Append-only events:
+
+```ts
+worktree_added { projectId, worktreeId, path, branch, base?, createdAt }
+worktree_removed { projectId, worktreeId, removedAt, force }
+worktree_renamed { projectId, worktreeId, newBranch } // optional
+worktree_backfill_v1 { projectId, primaryWorktreeId } // migration guard
+```
+
+Derived project state gains:
+
+```ts
+type Worktree = {
+ id: string // stable, generated on add
+ path: string // absolute
+ branch: string // current branch or "(detached)"
+ isPrimary: boolean // exactly one true per project
+ status: "active" | "orphaned"
+}
+
+type Project = {
+ // existing fields...
+ worktrees: Worktree[]
+ worktreeDir?: string // default ".worktrees"
+}
+```
+
+`chat_created` gains optional `worktreeId`. Chats lacking the field at replay time resolve to the project's primary worktree (driven by `worktree_backfill_v1`).
+
+### Server module
+
+`src/server/worktree-store.ts`:
+
+```ts
+listWorktrees(repoRoot): Promise
+addWorktree(repoRoot, opts): Promise
+removeWorktree(repoRoot, path, opts: { force }): Promise
+isDirty(worktreePath): Promise<{ dirty: boolean; fileCount: number }>
+```
+
+Implementation reuses `runGit()` from `src/server/diff-store.ts`. All git operations serialize per repository through the existing `runGit` mutex; if no per-repo lock exists, add one for worktree mutations.
+
+### Reconcile strategy
+
+Git is the source of truth. Kanna events are the projection.
+
+1. **On project open** — call `listWorktrees(repoRoot)`, diff against event-derived state.
+ - Present in git, absent in Kanna → emit `worktree_added` (auto-detect shell-created worktrees).
+ - Present in Kanna, absent in git → set `status: "orphaned"`, run `git worktree prune`.
+2. **Manual refresh** — re-run reconcile, surface in worktree switcher.
+3. **After Kanna's own mutations** — emit event immediately, no reconcile.
+
+### Chat cwd binding
+
+`agent.ts` currently reads `project.localPath` to set the chat `cwd`. Change to:
+
+```ts
+const worktree = project.worktrees.find(w => w.id === chat.worktreeId)
+if (!worktree || worktree.status === "orphaned") {
+ // refuse to run; surface "worktree removed" error
+}
+const cwd = worktree.path
+```
+
+`resolveRepo()` in `diff-store.ts:265` already accepts a path; pass `worktree.path`.
+
+### Migration
+
+One-time, idempotent, guarded by `worktree_backfill_v1`:
+
+1. For each project, call `listWorktrees(localPath)`.
+2. Emit `worktree_added` for each, mark first one `isPrimary: true`.
+3. Emit `worktree_backfill_v1 { projectId, primaryWorktreeId }`.
+4. On any `chat_created` lacking `worktreeId`, resolver returns the primary worktree's id.
+
+### Path resolution
+
+`addWorktree` resolves `/` against `project.localPath`. Branch slug normalizes `feat/x` → `feat-x`. On collision, append numeric suffix (`feat-x-2`).
+
+## Client UI
+
+### Worktree switcher
+
+Top of the project view, left of the chat list:
+
+```
+┌─────────────────────────────┐
+│ [▼ main (current)] [+] [⟳]│
+└─────────────────────────────┘
+│ feat/auth-redesign │
+│ fix/timing-bug ⚠ orphaned │
+│ ───────────── │
+│ + New worktree... │
+```
+
+Selecting a worktree filters the chat list to chats bound to that worktree. Primary worktree pre-selected on project open. Orphaned entries render in red and chats inside become read-only.
+
+### Create modal
+
+```
+○ New branch [_____________] from [main ▼]
+○ Existing branch [pick branch ▼]
+Path: .worktrees/ [edit]
+[Cancel] [Create]
+```
+
+The path field shows the resolved preview. Editing the directory portion writes `worktreeDir` back to the project setting.
+
+### Remove flow
+
+1. Right-click → "Remove". Run `isDirty()`. If clean → confirm → `git worktree remove`.
+2. If dirty → modal: "X uncommitted files. Cannot remove safely." Single button "Close".
+3. Re-click "Remove" on a dirty worktree → second modal: "Force remove? Discards X files." A checkbox "I understand" must be checked before the button enables. Then `git worktree remove --force`.
+
+### Chat creation
+
+The "New chat" button always operates in the context of the currently selected worktree. The chat header renders a `branch: feat/x` badge so the user always knows the cwd.
+
+### Mobile
+
+The switcher collapses into a drawer entry above the chat list. All other behavior matches desktop.
+
+## Error surfaces
+
+| Case | Behavior |
+|------|----------|
+| `localPath` not a git repo | Hide worktree switcher entirely. Project works as today. |
+| `git worktree add` fails (locked, branch exists, path conflict) | Surface stderr in the modal; emit no event. |
+| Branch name collides with existing worktree | Server checks before spawn; reject with hint. |
+| User deletes worktree dir manually | Next reconcile marks it orphaned and runs `git worktree prune`. |
+| Worktree path moved on disk | No auto-repair; show warning + manual button. |
+| Chat is running when remove is requested | Block remove with "chat running" error until canceled (mirrors background task gating). |
+| Two Kanna sessions race on the same project | Event store already serializes; last writer wins, reconcile next open. |
+
+## Testing strategy
+
+Unit tests (Bun, against a temp git repo):
+
+- `worktree-store.test.ts` — porcelain parsing, primary detection, add/remove (clean and dirty), `isDirty`, slug + collision suffix.
+- `event-store.test.ts` — `worktree_added/removed/backfill_v1` reducers, chat `worktreeId` fallback.
+- `agent.test.ts` — chat cwd resolves to the bound worktree; orphan refusal.
+
+Integration tests:
+
+- Project-open reconcile: shell-create a worktree, open project, assert `worktree_added` emitted.
+- Migration: load a fixture event log lacking worktrees; assert backfill emitted and chats bound to primary.
+- Remove with `--force` end-to-end through the server API.
+
+Subprocess discipline (per project `CLAUDE.md`):
+
+```ts
+spawn("git", args, { stdin: "ignore", env: { GIT_TERMINAL_PROMPT: "0" } })
+test(name, fn, 30_000)
+```
+
+TDD order (smallest first):
+
+1. `worktree-store` git wrapper.
+2. Event reducers.
+3. Reconcile and migration.
+4. Agent cwd binding.
+5. HTTP/IPC handlers.
+6. Client switcher and create/remove modals.
+7. Mobile drawer.
+
+Manual verification (per `CLAUDE.md` UI rule): start the dev server, exercise create / switch / remove / orphan / dirty paths in the browser before claiming the work complete.
diff --git a/docs/plans/2026-05-10-worktree-support.md b/docs/plans/2026-05-10-worktree-support.md
new file mode 100644
index 000000000..1b8c54147
--- /dev/null
+++ b/docs/plans/2026-05-10-worktree-support.md
@@ -0,0 +1,1102 @@
+# In-Project Git Worktree Support — Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Let a Kanna project manage its repository's git worktrees from inside the app — detect existing ones, create new ones, remove them, and pin every chat to exactly one worktree so concurrent chats never collide on a shared working tree.
+
+**Architecture:** Append-only events drive a derived `worktrees: Worktree[]` field on each project. Git is the source of truth; Kanna reconciles on project open and on user-triggered refresh. Each chat carries a `worktreeId` and uses that worktree's path as its agent `cwd`. UI exposes a worktree switcher, a create modal, and a two-step force-remove flow.
+
+**Tech Stack:** Bun + TypeScript server, React + Tailwind client, append-only event store (`src/server/event-store.ts`), `git` subprocess via the existing `runGit` helper in `src/server/diff-store.ts`. Tests use Bun's built-in test runner against ephemeral git repos in temp dirs.
+
+**Reference:** Design doc — `docs/plans/2026-05-10-worktree-support-design.md`.
+
+**Discipline:**
+- TDD per task: write failing test → run → implement → run → commit.
+- Each task must end with a green `bun test` for the touched files.
+- All git subprocesses pass `stdin: "ignore"` and `GIT_TERMINAL_PROMPT=0`. Tests use `test(name, fn, 30_000)`.
+- No `any` / `unknown` — define real types (per user CLAUDE.md).
+- Pre-existing failing tests = stop and ask, do not skip.
+- No emojis in code or commit messages unless asked.
+
+**Phasing (one PR per phase):**
+
+| Phase | Scope | PR title prefix |
+|-------|-------|-----------------|
+| 1 | `worktree-store` git wrapper + tests | `feat(worktrees): server git wrapper` |
+| 2 | Events, reducers, migration | `feat(worktrees): event-store integration` |
+| 3 | Agent cwd binding | `feat(worktrees): per-chat cwd` |
+| 4 | HTTP/WS handlers + read-models | `feat(worktrees): API surface` |
+| 5 | Client switcher | `feat(worktrees): switcher UI` |
+| 6 | Client create + remove modals | `feat(worktrees): create/remove UI` |
+| 7 | Mobile drawer | `feat(worktrees): mobile UI` |
+| 8 | End-to-end manual + integration tests | `test(worktrees): integration` |
+
+Land each phase before starting the next. After every phase commit, run the full `bun test` once.
+
+---
+
+## Phase 1 — `worktree-store` git wrapper
+
+### Task 1: Export `runGit` from `diff-store`
+
+**Why:** `worktree-store.ts` needs the same non-interactive git invocation; duplicating leaks process-management bugs.
+
+**Files:**
+- Modify: `src/server/diff-store.ts:131`
+
+**Step 1: Change `async function runGit` → `export async function runGit` and `formatGitFailure` → `export function formatGitFailure`.**
+
+**Step 2: Run `bun test src/server/diff-store.test.ts`. Expected: PASS (no behavior change).**
+
+**Step 3: Commit.**
+
+```bash
+git add src/server/diff-store.ts
+git commit -m "refactor(diff-store): export runGit and formatGitFailure for reuse"
+```
+
+---
+
+### Task 2: Define `GitWorktree` shared type
+
+**Files:**
+- Modify: `src/shared/types.ts` (append a new exported type)
+
+**Step 1: Add type:**
+
+```ts
+export interface GitWorktree {
+ path: string // absolute
+ branch: string // e.g. "main", "feat/x", "(detached)"
+ sha: string // HEAD commit sha
+ isPrimary: boolean
+ isLocked: boolean // git has flagged this worktree as locked (pruning inhibited)
+}
+```
+
+**Step 2: Run `bun build` (or `bun tsc --noEmit` if configured). Expected: clean.**
+
+**Step 3: Commit.**
+
+```bash
+git add src/shared/types.ts
+git commit -m "feat(worktrees): add GitWorktree shared type"
+```
+
+---
+
+### Task 3: `parseWorktreeList` (porcelain parser) — failing test
+
+**Files:**
+- Create: `src/server/worktree-store.test.ts`
+- Create: `src/server/worktree-store.ts` (empty stub for now)
+
+**Step 1: Write the failing test:**
+
+```ts
+import { describe, expect, test } from "bun:test"
+import { parseWorktreeList } from "./worktree-store"
+
+describe("parseWorktreeList", () => {
+ test("parses primary + secondary worktree", () => {
+ const input = [
+ "worktree /repo/main",
+ "HEAD abc123",
+ "branch refs/heads/main",
+ "",
+ "worktree /repo/.worktrees/feat-x",
+ "HEAD def456",
+ "branch refs/heads/feat/x",
+ "",
+ ].join("\n")
+
+ const result = parseWorktreeList(input)
+
+ expect(result).toEqual([
+ { path: "/repo/main", sha: "abc123", branch: "main", isPrimary: true, isLocked: false },
+ { path: "/repo/.worktrees/feat-x", sha: "def456", branch: "feat/x", isPrimary: false, isLocked: false },
+ ])
+ })
+
+ test("marks detached HEAD", () => {
+ const input = [
+ "worktree /repo/main",
+ "HEAD abc123",
+ "branch refs/heads/main",
+ "",
+ "worktree /repo/.worktrees/wip",
+ "HEAD def456",
+ "detached",
+ "",
+ ].join("\n")
+ expect(parseWorktreeList(input)[1].branch).toBe("(detached)")
+ })
+
+ test("flags locked", () => {
+ const input = [
+ "worktree /repo/main",
+ "HEAD abc123",
+ "branch refs/heads/main",
+ "locked",
+ "",
+ ].join("\n")
+ expect(parseWorktreeList(input)[0].isLocked).toBe(true)
+ })
+})
+```
+
+**Step 2: Run test. Expected: FAIL (`parseWorktreeList is not a function`).**
+
+```bash
+bun test src/server/worktree-store.test.ts
+```
+
+**Step 3: Implement `parseWorktreeList` in `worktree-store.ts`.**
+
+```ts
+import type { GitWorktree } from "../shared/types"
+
+export function parseWorktreeList(porcelain: string): GitWorktree[] {
+ const blocks = porcelain.split(/\r?\n\r?\n/u).map((b) => b.trim()).filter(Boolean)
+ return blocks.map((block, index) => {
+ const lines = block.split(/\r?\n/u)
+ let path = ""
+ let head = ""
+ let branch = "(detached)"
+ let isLocked = false
+ for (const line of lines) {
+ if (line.startsWith("worktree ")) path = line.slice("worktree ".length).trim()
+ else if (line.startsWith("HEAD ")) head = line.slice("HEAD ".length).trim()
+ else if (line.startsWith("branch ")) {
+ const ref = line.slice("branch ".length).trim()
+ branch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref
+ } else if (line === "detached") branch = "(detached)"
+ else if (line === "locked" || line.startsWith("locked ")) isLocked = true
+ }
+ return { path, sha: head, branch, isPrimary: index === 0, isLocked }
+ })
+}
+```
+
+**Step 4: Run test. Expected: PASS.**
+
+**Step 5: Commit.**
+
+```bash
+git add src/server/worktree-store.ts src/server/worktree-store.test.ts src/shared/types.ts
+git commit -m "feat(worktrees): parse git worktree list --porcelain"
+```
+
+---
+
+### Task 4: `listWorktrees` against a real temp repo — failing test
+
+**Files:**
+- Modify: `src/server/worktree-store.test.ts`
+- Modify: `src/server/worktree-store.ts`
+
+**Step 1: Add a `makeTempRepo()` helper at top of test file (mirrors patterns in `diff-store.test.ts`):**
+
+```ts
+import { mkdtempSync, rmSync } from "node:fs"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { spawnSync } from "node:child_process"
+
+function git(cwd: string, ...args: string[]) {
+ const r = spawnSync("git", args, { cwd, stdio: "pipe", env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } })
+ if (r.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${r.stderr.toString()}`)
+ return r.stdout.toString().trim()
+}
+
+function makeTempRepo(): { dir: string; cleanup: () => void } {
+ const dir = mkdtempSync(join(tmpdir(), "kanna-wt-"))
+ git(dir, "init", "-q", "-b", "main")
+ git(dir, "config", "user.email", "test@example.com")
+ git(dir, "config", "user.name", "Test")
+ git(dir, "commit", "--allow-empty", "-m", "init")
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) }
+}
+```
+
+**Step 2: Add test:**
+
+```ts
+import { listWorktrees } from "./worktree-store"
+
+test("listWorktrees returns the primary worktree for a fresh repo", async () => {
+ const { dir, cleanup } = makeTempRepo()
+ try {
+ const result = await listWorktrees(dir)
+ expect(result.length).toBe(1)
+ expect(result[0].isPrimary).toBe(true)
+ expect(result[0].branch).toBe("main")
+ } finally {
+ cleanup()
+ }
+}, 30_000)
+
+test("listWorktrees sees a secondary worktree", async () => {
+ const { dir, cleanup } = makeTempRepo()
+ try {
+ git(dir, "worktree", "add", join(dir, ".worktrees", "feat-x"), "-b", "feat/x")
+ const result = await listWorktrees(dir)
+ expect(result.length).toBe(2)
+ const secondary = result.find((w) => !w.isPrimary)
+ expect(secondary?.branch).toBe("feat/x")
+ } finally {
+ cleanup()
+ }
+}, 30_000)
+```
+
+**Step 3: Run. Expected: FAIL (`listWorktrees not exported`).**
+
+**Step 4: Implement:**
+
+```ts
+import { runGit, formatGitFailure } from "./diff-store"
+
+export async function listWorktrees(repoRoot: string): Promise {
+ const result = await runGit(["worktree", "list", "--porcelain"], repoRoot)
+ if (result.exitCode !== 0) {
+ throw new Error(formatGitFailure(result) || "git worktree list failed")
+ }
+ return parseWorktreeList(result.stdout)
+}
+```
+
+**Step 5: Run. Expected: PASS.**
+
+**Step 6: Commit.**
+
+```bash
+git add src/server/worktree-store.ts src/server/worktree-store.test.ts
+git commit -m "feat(worktrees): listWorktrees via git porcelain"
+```
+
+---
+
+### Task 5: `addWorktree` — new branch path
+
+**Files:**
+- Modify: `src/server/worktree-store.ts`
+- Modify: `src/server/worktree-store.test.ts`
+
+**Step 1: Failing test:**
+
+```ts
+import { addWorktree } from "./worktree-store"
+
+test("addWorktree creates a new branch worktree", async () => {
+ const { dir, cleanup } = makeTempRepo()
+ try {
+ const wt = await addWorktree(dir, {
+ kind: "new-branch",
+ branch: "feat/y",
+ path: join(dir, ".worktrees", "feat-y"),
+ })
+ expect(wt.branch).toBe("feat/y")
+ expect(wt.isPrimary).toBe(false)
+ const list = await listWorktrees(dir)
+ expect(list.some((w) => w.branch === "feat/y")).toBe(true)
+ } finally {
+ cleanup()
+ }
+}, 30_000)
+```
+
+**Step 2: Implement (continue inside `worktree-store.ts`):**
+
+```ts
+export type AddWorktreeOpts =
+ | { kind: "new-branch"; branch: string; path: string; base?: string }
+ | { kind: "existing-branch"; branch: string; path: string }
+
+export async function addWorktree(repoRoot: string, opts: AddWorktreeOpts): Promise {
+ const args = ["worktree", "add"]
+ if (opts.kind === "new-branch") {
+ args.push("-b", opts.branch, opts.path)
+ if (opts.base) args.push(opts.base)
+ } else {
+ args.push(opts.path, opts.branch)
+ }
+ const result = await runGit(args, repoRoot)
+ if (result.exitCode !== 0) {
+ throw new Error(formatGitFailure(result) || "git worktree add failed")
+ }
+ const list = await listWorktrees(repoRoot)
+ const created = list.find((w) => w.path === opts.path)
+ if (!created) throw new Error("worktree created but not found in list")
+ return created
+}
+```
+
+**Step 3: Run. Expected: PASS.**
+
+**Step 4: Commit.**
+
+```bash
+git add src/server/worktree-store.ts src/server/worktree-store.test.ts
+git commit -m "feat(worktrees): addWorktree for new branches"
+```
+
+---
+
+### Task 6: `addWorktree` — existing branch path
+
+**Step 1: Failing test:**
+
+```ts
+test("addWorktree attaches an existing branch", async () => {
+ const { dir, cleanup } = makeTempRepo()
+ try {
+ git(dir, "branch", "feat/exists")
+ const wt = await addWorktree(dir, {
+ kind: "existing-branch",
+ branch: "feat/exists",
+ path: join(dir, ".worktrees", "feat-exists"),
+ })
+ expect(wt.branch).toBe("feat/exists")
+ } finally {
+ cleanup()
+ }
+}, 30_000)
+```
+
+**Step 2: Run. Expected: PASS (existing implementation already supports this).**
+
+**Step 3: Commit.**
+
+```bash
+git add src/server/worktree-store.test.ts
+git commit -m "test(worktrees): cover existing-branch addWorktree path"
+```
+
+---
+
+### Task 7: `addWorktree` — failure surfaces stderr
+
+**Step 1: Failing test:**
+
+```ts
+test("addWorktree throws with git stderr on conflict", async () => {
+ const { dir, cleanup } = makeTempRepo()
+ try {
+ await addWorktree(dir, { kind: "new-branch", branch: "feat/dup", path: join(dir, ".worktrees", "a") })
+ await expect(
+ addWorktree(dir, { kind: "new-branch", branch: "feat/dup", path: join(dir, ".worktrees", "b") })
+ ).rejects.toThrow(/already (used|exists)/)
+ } finally {
+ cleanup()
+ }
+}, 30_000)
+```
+
+**Step 2: Run. Expected: PASS (already covered by `formatGitFailure`).**
+
+**Step 3: Commit.**
+
+```bash
+git add src/server/worktree-store.test.ts
+git commit -m "test(worktrees): surface stderr on duplicate branch"
+```
+
+---
+
+### Task 8: `isDirty` — clean and dirty
+
+**Step 1: Failing test:**
+
+```ts
+import { isDirty } from "./worktree-store"
+import { writeFileSync } from "node:fs"
+
+test("isDirty is false on a clean tree", async () => {
+ const { dir, cleanup } = makeTempRepo()
+ try {
+ expect(await isDirty(dir)).toEqual({ dirty: false, fileCount: 0 })
+ } finally { cleanup() }
+}, 30_000)
+
+test("isDirty counts modified + untracked", async () => {
+ const { dir, cleanup } = makeTempRepo()
+ try {
+ writeFileSync(join(dir, "a.txt"), "hello")
+ writeFileSync(join(dir, "b.txt"), "world")
+ const r = await isDirty(dir)
+ expect(r.dirty).toBe(true)
+ expect(r.fileCount).toBe(2)
+ } finally { cleanup() }
+}, 30_000)
+```
+
+**Step 2: Implement:**
+
+```ts
+export async function isDirty(worktreePath: string): Promise<{ dirty: boolean; fileCount: number }> {
+ const result = await runGit(["status", "--porcelain", "-z"], worktreePath)
+ if (result.exitCode !== 0) {
+ throw new Error(formatGitFailure(result) || "git status failed")
+ }
+ if (result.stdout.length === 0) return { dirty: false, fileCount: 0 }
+ const fileCount = result.stdout.split("\0").filter((s) => s.length > 0).length
+ return { dirty: fileCount > 0, fileCount }
+}
+```
+
+**Step 3: Run. Expected: PASS.**
+
+**Step 4: Commit.**
+
+```bash
+git add src/server/worktree-store.ts src/server/worktree-store.test.ts
+git commit -m "feat(worktrees): isDirty status check"
+```
+
+---
+
+### Task 9: `removeWorktree` — clean and force
+
+**Step 1: Failing test:**
+
+```ts
+import { removeWorktree } from "./worktree-store"
+
+test("removeWorktree removes a clean worktree", async () => {
+ const { dir, cleanup } = makeTempRepo()
+ try {
+ const path = join(dir, ".worktrees", "feat-z")
+ await addWorktree(dir, { kind: "new-branch", branch: "feat/z", path })
+ await removeWorktree(dir, path, { force: false })
+ expect((await listWorktrees(dir)).length).toBe(1)
+ } finally { cleanup() }
+}, 30_000)
+
+test("removeWorktree refuses dirty without force", async () => {
+ const { dir, cleanup } = makeTempRepo()
+ try {
+ const path = join(dir, ".worktrees", "feat-z")
+ await addWorktree(dir, { kind: "new-branch", branch: "feat/z", path })
+ writeFileSync(join(path, "x.txt"), "dirty")
+ await expect(removeWorktree(dir, path, { force: false })).rejects.toThrow()
+ } finally { cleanup() }
+}, 30_000)
+
+test("removeWorktree --force clears dirty worktree", async () => {
+ const { dir, cleanup } = makeTempRepo()
+ try {
+ const path = join(dir, ".worktrees", "feat-z")
+ await addWorktree(dir, { kind: "new-branch", branch: "feat/z", path })
+ writeFileSync(join(path, "x.txt"), "dirty")
+ await removeWorktree(dir, path, { force: true })
+ expect((await listWorktrees(dir)).length).toBe(1)
+ } finally { cleanup() }
+}, 30_000)
+```
+
+**Step 2: Implement:**
+
+```ts
+export async function removeWorktree(repoRoot: string, path: string, opts: { force: boolean }): Promise {
+ const args = ["worktree", "remove"]
+ if (opts.force) args.push("--force")
+ args.push(path)
+ const result = await runGit(args, repoRoot)
+ if (result.exitCode !== 0) {
+ throw new Error(formatGitFailure(result) || "git worktree remove failed")
+ }
+}
+```
+
+**Step 3: Run. Expected: PASS.**
+
+**Step 4: Commit.**
+
+```bash
+git add src/server/worktree-store.ts src/server/worktree-store.test.ts
+git commit -m "feat(worktrees): removeWorktree with optional force"
+```
+
+---
+
+### Task 10: `slugifyBranch` + collision suffix
+
+**Step 1: Failing test:**
+
+```ts
+import { slugifyBranchForPath, resolveDefaultWorktreePath } from "./worktree-store"
+
+test("slugifyBranchForPath replaces unsafe chars", () => {
+ expect(slugifyBranchForPath("feat/x")).toBe("feat-x")
+ expect(slugifyBranchForPath("Feat With Space")).toBe("feat-with-space")
+ expect(slugifyBranchForPath("../escape")).toBe("escape")
+})
+
+test("resolveDefaultWorktreePath suffixes on collision", () => {
+ const existing = new Set(["/r/.worktrees/feat-x"])
+ expect(resolveDefaultWorktreePath("/r", ".worktrees", "feat/x", existing)).toBe("/r/.worktrees/feat-x-2")
+})
+```
+
+**Step 2: Implement:**
+
+```ts
+export function slugifyBranchForPath(branch: string): string {
+ return branch
+ .toLowerCase()
+ .replace(/[^a-z0-9._/-]+/gu, "-")
+ .replace(/[\\/]+/gu, "-")
+ .replace(/\.+/gu, "-")
+ .replace(/-+/gu, "-")
+ .replace(/^-+|-+$/gu, "")
+}
+
+export function resolveDefaultWorktreePath(repoRoot: string, dir: string, branch: string, existing: Set): string {
+ const slug = slugifyBranchForPath(branch)
+ const base = `${repoRoot}/${dir}/${slug}`
+ if (!existing.has(base)) return base
+ for (let i = 2; ; i++) {
+ const candidate = `${base}-${i}`
+ if (!existing.has(candidate)) return candidate
+ }
+}
+```
+
+**Step 3: Run. Expected: PASS.**
+
+**Step 4: Commit.**
+
+```bash
+git add src/server/worktree-store.ts src/server/worktree-store.test.ts
+git commit -m "feat(worktrees): slugify branch and resolve default path"
+```
+
+---
+
+### Phase 1 close
+
+**Step 1:** Run `bun test`. Expected: full green.
+**Step 2:** Open PR.
+
+```bash
+git push -u origin feat/worktree-support
+gh pr create --repo cuongtranba/kanna --base main --head feat/worktree-support \
+ --title "feat(worktrees): server git wrapper" \
+ --body "$(cat <<'EOF'
+## Summary
+- Adds `src/server/worktree-store.ts` with `listWorktrees`, `addWorktree`, `removeWorktree`, `isDirty`, `parseWorktreeList`, `slugifyBranchForPath`, `resolveDefaultWorktreePath`.
+- Exports `runGit` / `formatGitFailure` from `diff-store.ts`.
+- Adds `GitWorktree` shared type.
+
+Phase 1 of the worktree support plan: server-side git wrapper only, no events / UI yet. See `docs/plans/2026-05-10-worktree-support-design.md`.
+
+## Test plan
+- [ ] `bun test src/server/worktree-store.test.ts`
+- [ ] `bun test` (full suite)
+EOF
+)"
+```
+
+After this PR merges, fast-forward `feat/worktree-support` (or rebase) and start Phase 2.
+
+---
+
+## Phase 2 — Events, reducers, migration
+
+### Task 11: Add worktree events to `events.ts`
+
+**Files:**
+- Modify: `src/server/events.ts`
+
+**Step 1:** Add to `ProjectEvent` union:
+
+```ts
+| {
+ v: 3
+ type: "worktree_added"
+ timestamp: number
+ projectId: string
+ worktreeId: string
+ path: string
+ branch: string
+ base?: string
+ createdViaUi: boolean
+ }
+| {
+ v: 3
+ type: "worktree_removed"
+ timestamp: number
+ projectId: string
+ worktreeId: string
+ force: boolean
+ }
+| {
+ v: 3
+ type: "worktree_marked_orphaned"
+ timestamp: number
+ projectId: string
+ worktreeId: string
+ }
+| {
+ v: 3
+ type: "worktree_backfill_v1"
+ timestamp: number
+ projectId: string
+ primaryWorktreeId: string
+ }
+| {
+ v: 3
+ type: "project_worktree_dir_set"
+ timestamp: number
+ projectId: string
+ worktreeDir: string
+ }
+```
+
+**Step 2:** Extend `ChatEvent` `chat_created`:
+
+```ts
+| {
+ v: 3
+ type: "chat_created"
+ timestamp: number
+ chatId: string
+ projectId: string
+ title: string
+ worktreeId?: string // optional for backwards compat
+ }
+```
+
+**Step 3:** Extend `ProjectRecord` and `ChatRecord`:
+
+```ts
+export interface WorktreeRecord {
+ id: string
+ path: string
+ branch: string
+ isPrimary: boolean
+ status: "active" | "orphaned"
+ addedAt: number
+}
+
+export interface ProjectRecord extends ProjectSummary {
+ deletedAt?: number
+ worktrees: WorktreeRecord[] // always present, may be []
+ worktreeDir?: string
+}
+
+export interface ChatRecord {
+ // ... existing
+ worktreeId: string | null
+}
+```
+
+**Step 4:** Run `bun tsc --noEmit`. Expect compile errors at every reducer/snapshot site that constructs `ProjectRecord` or `ChatRecord`. Fix them all to default `worktrees: []` and `worktreeId: null`. (Search: `grep -rn "ProjectRecord\b" src/`.)
+
+**Step 5:** Run `bun test`. Expected: PASS (no behavior change yet — just shape).
+
+**Step 6:** Commit.
+
+```bash
+git add src/server/events.ts src/server/event-store.ts src/shared/types.ts
+git commit -m "feat(worktrees): event-store types for worktree state"
+```
+
+---
+
+### Task 12: Reducer — `worktree_added`
+
+**Files:**
+- Modify: `src/server/event-store.ts` (locate the project-event reducer; pattern matches existing `project_opened` handler)
+- Modify: `src/server/event-store.test.ts`
+
+**Step 1: Failing test:**
+
+```ts
+test("worktree_added appends a worktree to the project", () => {
+ const store = makeTestStore()
+ store.appendProjectOpened({ projectId: "p1", localPath: "/repo", title: "repo" })
+ store.applyEvent({
+ v: 3, type: "worktree_added", timestamp: 1, projectId: "p1",
+ worktreeId: "w1", path: "/repo", branch: "main", createdViaUi: false,
+ })
+ expect(store.getProject("p1")?.worktrees).toEqual([
+ { id: "w1", path: "/repo", branch: "main", isPrimary: true, status: "active", addedAt: 1 }
+ ])
+})
+```
+
+(Adapt helper names to existing `event-store.test.ts` style.)
+
+**Step 2:** Run. Expected: FAIL.
+
+**Step 3:** Implement reducer in `event-store.ts`. First-added worktree of a project is `isPrimary: true`; subsequent are `false`.
+
+**Step 4:** Run. Expected: PASS.
+
+**Step 5: Commit.**
+
+```bash
+git add src/server/event-store.ts src/server/event-store.test.ts
+git commit -m "feat(worktrees): reducer for worktree_added"
+```
+
+---
+
+### Task 13: Reducer — `worktree_removed` and `worktree_marked_orphaned`
+
+**Step 1: Failing tests:**
+
+```ts
+test("worktree_removed deletes from list", () => { /* ... */ })
+test("removing primary promotes next worktree to primary", () => { /* ... */ })
+test("worktree_marked_orphaned flips status without deleting", () => { /* ... */ })
+test("orphaned chat is read-only at the read-model layer", () => { /* covered in read-models.test.ts */ })
+```
+
+**Step 2:** Implement. When the primary is removed, the lowest `addedAt` among the remaining becomes primary.
+
+**Step 3:** Run. PASS.
+
+**Step 4: Commit.**
+
+```bash
+git commit -am "feat(worktrees): reducers for worktree_removed and orphan"
+```
+
+---
+
+### Task 14: Reducer — `chat_created.worktreeId` + fallback
+
+**Step 1: Failing tests:**
+
+```ts
+test("chat_created with worktreeId binds the chat", () => { /* ... */ })
+test("chat_created without worktreeId binds to primary worktree", () => { /* ... */ })
+test("chat_created without worktreeId on a project with no worktrees yields worktreeId null", () => { /* ... */ })
+```
+
+**Step 2:** Implement. When `worktreeId` absent, look up `project.worktrees.find((w) => w.isPrimary)?.id ?? null`.
+
+**Step 3:** Run. PASS.
+
+**Step 4: Commit.**
+
+```bash
+git commit -am "feat(worktrees): bind chat to worktree on creation"
+```
+
+---
+
+### Task 15: Migration — `worktree_backfill_v1`
+
+**Files:**
+- Modify: `src/server/event-store.ts` — add a one-shot migration that runs once per project the first time `loadProjects()` finds a project lacking `worktree_backfill_v1` in its event log.
+- Modify: `src/server/event-store.test.ts`
+
+**Step 1: Failing test:**
+
+```ts
+test("loading a legacy event log emits worktree_backfill_v1 and binds chats to primary", async () => {
+ // craft a fixture log with project_opened + chat_created (no worktree events)
+ // load it
+ // assert: at least one worktree_added event appended, primary = first
+ // worktree_backfill_v1 appended once
+ // chats now have worktreeId pointing at primary
+})
+```
+
+**Step 2:** Implement in the loader path. Migration:
+1. For each project loaded from log without `worktree_backfill_v1`:
+ - Call `listWorktrees(project.localPath)` (best-effort; if it fails because path not a repo, skip migration and write a `worktree_backfill_v1` with `primaryWorktreeId: ""` to mark "no-op done").
+ - For every returned worktree, append `worktree_added`.
+ - Append `worktree_backfill_v1`.
+ - For every existing chat in this project that has no `worktreeId`, append a no-op compatibility shim — actually no event is needed; the reducer already falls back to primary. The backfill event is purely a guard.
+
+**Step 3:** Run. PASS.
+
+**Step 4: Commit.**
+
+```bash
+git commit -am "feat(worktrees): one-shot backfill migration on load"
+```
+
+---
+
+### Task 16: Reducer — `project_worktree_dir_set`
+
+**Step 1: Failing test:**
+
+```ts
+test("project_worktree_dir_set updates the directory", () => { /* ... */ })
+```
+
+**Step 2:** Implement (one-line reducer).
+
+**Step 3:** Commit.
+
+```bash
+git commit -am "feat(worktrees): reducer for worktreeDir setting"
+```
+
+---
+
+### Phase 2 close
+
+`bun test` must be fully green. Open PR `feat(worktrees): event-store integration`.
+
+---
+
+## Phase 3 — Agent cwd binding
+
+### Task 17: Resolve worktree path for `ClaudeSessionState`
+
+**Files:**
+- Modify: `src/server/agent.ts:97-109`
+- Modify: `src/server/agent.test.ts` (or add a new `agent.worktree.test.ts`)
+
+**Step 1: Failing test:**
+
+```ts
+test("agent cwd resolves to the chat's bound worktree path", async () => {
+ // arrange a project with two worktrees, a chat bound to the secondary
+ // dispatch a turn-start
+ // assert: startClaudeSession called with localPath = secondary worktree path
+})
+
+test("agent refuses to start a turn when the chat's worktree is orphaned", async () => {
+ // arrange chat bound to a worktree that is then orphaned
+ // dispatch turn-start
+ // assert: turn_failed event with error matching /worktree.*removed/
+})
+```
+
+**Step 2:** Implement in `agent.ts`. Add a helper `resolveChatCwd(state, chat): { ok: true; path: string } | { ok: false; reason: "orphaned" | "no-worktree" }` and use it at every place currently reading `project.localPath` for the chat's cwd.
+
+**Step 3:** Run. PASS.
+
+**Step 4:** Commit.
+
+```bash
+git commit -am "feat(worktrees): per-chat cwd from worktree binding"
+```
+
+---
+
+### Task 18: Diff/commit/push surfaces use the chat's worktree
+
+**Files:**
+- Modify: `src/server/diff-store.ts` — every public method takes a path; pass the chat's worktree path from the call site.
+- Modify: `src/server/ws-router.ts` (or whichever HTTP/WS handler dispatches diff/commit) to look up the chat's worktree.
+
+**Step 1:** Failing test that drives a chat's diff against a feature-branch worktree.
+**Step 2:** Implement.
+**Step 3:** Commit.
+
+```bash
+git commit -am "feat(worktrees): diff/commit/push routed through chat worktree"
+```
+
+---
+
+### Phase 3 close
+
+PR `feat(worktrees): per-chat cwd`.
+
+---
+
+## Phase 4 — API surface
+
+### Task 19: WS messages
+
+**Files:**
+- Modify: `src/shared/types.ts` — add request/response shapes:
+
+```ts
+export type WorktreeRequest =
+ | { type: "worktree.list"; projectId: string }
+ | { type: "worktree.refresh"; projectId: string }
+ | { type: "worktree.add"; projectId: string; opts: AddWorktreeRequestOpts }
+ | { type: "worktree.remove"; projectId: string; worktreeId: string; force: boolean }
+ | { type: "worktree.set_dir"; projectId: string; dir: string }
+
+export type AddWorktreeRequestOpts =
+ | { kind: "new-branch"; branch: string; base?: string; pathOverride?: string }
+ | { kind: "existing-branch"; branch: string; pathOverride?: string }
+```
+
+**Step 2:** Wire into `ws-router.ts` with an `await` on `worktreeService.X(...)`. Reuse error formatter.
+
+**Step 3:** Add tests in `src/server/ws-router.test.ts` (or matching test file).
+
+**Step 4:** Commit per message type.
+
+---
+
+### Task 20: Read-model shape for client
+
+**Files:**
+- Modify: `src/server/read-models.ts` — `ProjectSummary` gains `worktrees: WorktreeSummary[]` and `worktreeDir`.
+- Modify: `src/shared/types.ts` — add `WorktreeSummary`.
+
+```ts
+export interface WorktreeSummary {
+ id: string
+ path: string
+ branch: string
+ isPrimary: boolean
+ status: "active" | "orphaned"
+}
+```
+
+Tests: `src/server/read-models.test.ts` covers shape.
+
+Commit.
+
+---
+
+### Task 21: List local + remote branches for the create modal
+
+**Files:**
+- Modify: `src/server/worktree-store.ts` — `listBranches(repoRoot): Promise<{ local: string[]; remote: string[] }>`.
+
+```ts
+export async function listBranches(repoRoot: string): Promise<{ local: string[]; remote: string[] }> {
+ const r = await runGit(["for-each-ref", "--format=%(refname)", "refs/heads/", "refs/remotes/"], repoRoot)
+ if (r.exitCode !== 0) throw new Error(formatGitFailure(r) || "git for-each-ref failed")
+ const lines = r.stdout.split(/\r?\n/u).map((s) => s.trim()).filter(Boolean)
+ const local = lines.filter((l) => l.startsWith("refs/heads/")).map((l) => l.slice("refs/heads/".length))
+ const remote = lines
+ .filter((l) => l.startsWith("refs/remotes/") && !l.endsWith("/HEAD"))
+ .map((l) => l.slice("refs/remotes/".length))
+ return { local, remote }
+}
+```
+
+Test it. Wire to a `worktree.list_branches` WS message. Commit.
+
+---
+
+### Phase 4 close
+
+PR `feat(worktrees): API surface`.
+
+---
+
+## Phase 5 — Client switcher
+
+Reference: existing patterns in `src/client/components/` and the kanna-react-style skill (apply on every TSX edit).
+
+### Task 22: Worktree switcher component (read-only)
+
+**Files:**
+- Create: `src/client/components/WorktreeSwitcher.tsx`
+- Create: `src/client/components/WorktreeSwitcher.test.tsx`
+
+Show dropdown with all active worktrees + orphaned ones (red label). Selection lives in URL state (`?worktree=`) so refresh persists. Default = primary.
+
+**Step 1:** Failing snapshot/render test with mocked project.
+**Step 2:** Implement.
+**Step 3:** Commit.
+
+---
+
+### Task 23: Filter chat list by selected worktree
+
+Modify `src/client/app/...` chat-list view to read the active worktree id and filter `chats.filter((c) => c.worktreeId === activeWorktreeId)`.
+
+Tests + commit.
+
+---
+
+### Task 24: Chat header `branch:` badge
+
+Add a small inline badge next to the chat title showing the worktree's branch.
+
+Tests + commit.
+
+---
+
+### Phase 5 close — PR `feat(worktrees): switcher UI`.
+
+---
+
+## Phase 6 — Create + remove modals
+
+### Task 25: Create modal — new vs existing branch
+
+- New `src/client/components/CreateWorktreeModal.tsx`.
+- Form: radio (new-branch / existing-branch), branch name (or picker), base (default = repo default branch), path override (default = computed).
+- Call `worktree.add` WS. On error, surface stderr in modal (no toast — keep the form open).
+
+Tests + commit.
+
+---
+
+### Task 26: Two-step force remove
+
+- New `src/client/components/RemoveWorktreeModal.tsx`.
+- First click → `worktree.remove({force:false})`. If server returns dirty error → show second dialog with checkbox "I understand", button enables only when checked, on confirm send `force:true`.
+- Block remove entirely if any chat in this worktree is currently running (read from existing chat-state stream).
+
+Tests + commit.
+
+---
+
+### Phase 6 close — PR.
+
+---
+
+## Phase 7 — Mobile drawer
+
+### Task 27: Drawer entry above chat list
+
+- Modify the existing mobile chat-list drawer (look at `src/client/components/Sidebar*`).
+- Add a worktree switcher row that opens a sheet listing all worktrees.
+
+Tests on touch interaction (use existing mobile test harness).
+
+Commit. PR.
+
+---
+
+## Phase 8 — Integration + manual verification
+
+### Task 28: End-to-end integration test
+
+- Drive a real temp repo through the WS layer: open project → assert worktree detected → create worktree → assert chat-list bind → remove dirty → assert two-step force flow → orphan via shell `git worktree remove` → assert reconcile flips status.
+
+### Task 29: Manual verification checklist
+
+Run `bun run dev`, exercise:
+
+- [ ] Open existing project → worktree switcher appears, main pre-selected.
+- [ ] Switch worktree → chat list filters; create chat → cwd is the worktree path (verify via a `pwd`-running shell tool call).
+- [ ] Create new-branch worktree → appears in switcher.
+- [ ] Create existing-branch worktree.
+- [ ] Remove clean worktree.
+- [ ] Try remove dirty → blocked → second dialog → force → succeeds.
+- [ ] Shell-create a worktree, click refresh → appears.
+- [ ] Shell-remove a worktree → next refresh marks it orphaned, chats become read-only.
+- [ ] Mobile: drawer entry works, modals render full-screen.
+- [ ] Pre-existing project (legacy log) loads correctly (migration ran once).
+
+If any item fails, file a follow-up task and stop. Do not declare phase complete until all items pass.
+
+### Task 30: Final PR + release notes
+
+PR `test(worktrees): end-to-end integration`. After merge, update `CHANGELOG`/release notes for the next version bump.
+
+---
+
+## Notes for implementers
+
+- **Pre-existing failures:** if `bun test` is not green on `main` before you start, stop and ask the user. Do not try to fix unrelated issues silently.
+- **Skill triggers:** any `.tsx` edit in Phase 5–7 → invoke the `kanna-react-style` skill. Any test edit → consider `test-quality-verify`. Before claiming a task done → run `superpowers:verification-before-completion`.
+- **Subprocess discipline:** every git spawn passes `stdin: "ignore"` and `GIT_TERMINAL_PROMPT=0`. Tests use `test(name, fn, 30_000)`.
+- **No `any`:** define real types. The `GitWorktree`, `WorktreeRecord`, `WorktreeSummary`, and `AddWorktreeOpts` types in this plan are the canonical shapes — share them via `src/shared/types.ts`.
+- **DRY:** if you find yourself parsing porcelain output again, extend `parseWorktreeList` instead.
+- **YAGNI:** detached HEAD, branch rename, cross-worktree diff, auto-repair are all explicitly deferred. Do not add them.
+
+When in doubt about UI placement, read existing components in `src/client/components/` and match their patterns. When in doubt about the event store, read `src/server/event-store.ts` end-to-end before adding a reducer.
diff --git a/docs/plans/2026-05-11-stack-multi-repo-design.md b/docs/plans/2026-05-11-stack-multi-repo-design.md
new file mode 100644
index 000000000..481260364
--- /dev/null
+++ b/docs/plans/2026-05-11-stack-multi-repo-design.md
@@ -0,0 +1,296 @@
+# Stacks: Multi-Repo Chats Across Projects
+
+**Date:** 2026-05-11
+**Status:** Design
+
+## Problem
+
+Kanna users doing integration work across separate git repositories (typical case: backend repo + frontend repo) cannot drive a single agent that reads and writes across both. Today a project resolves to one `localPath` (`src/server/event-store.ts:763`) and a chat inherits that path as its `cwd` (`src/server/agent.ts:101, 1192`). The only workaround is to keep two Kanna projects open side by side, switch chats by hand, and copy context between them. There is no shared scope, no shared agent, and no way to ask one agent to land a coordinated change on both repos.
+
+Worktrees (shipped phase 1 in commit `8c1553c`) solved the single-repo parallel-work case but did not touch the multi-repo case.
+
+## Goal
+
+Let a single chat span multiple registered Kanna projects, each on its own worktree, so an agent can perform integration tasks across them. Stay backwards-compatible: solo project flow unchanged.
+
+## Naming
+
+The feature is called **Stack**. A stack is a named group of existing Kanna projects. The word `workspace` is reserved for the existing PRODUCT.md framing of Kanna itself as a "navigable workspace"; using it for this feature would collide. Stack is short, editorial, distinct.
+
+## Scope
+
+In scope:
+
+- A new top-level `Stack` entity that groups two or more existing projects.
+- A new `StacksSection` in the sidebar above the projects section.
+- Inline (non-modal) stack creation and edit panels.
+- Stack chat creation with per-project worktree binding and a primary radio selecting the cwd repo.
+- Agent spawn wires: primary binding to `cwd`, peer bindings to Claude SDK `additionalDirectories`.
+- Persistent peer-worktree strip in the chat header (replaces the rejected hover-tooltip approach).
+- Keybindings for new stack, new stack chat, and jump-to-stacks.
+- Codex fallback: single `cwd` only; per-write `grantRoot` approvals.
+- Mobile parity via bottom-sheet variant of the inline panel.
+
+Out of scope (YAGNI; P2 follow-ups):
+
+- Editing peer bindings on a live chat (`chat_binding_changed`).
+- Swapping the primary repo mid-session.
+- Cross-repo diff comparison.
+- Codex multi-root via symlink or chroot tricks.
+- Reverse-lookup chip on project rows (dropped after critique).
+- Auto-detection of "related" repos (sibling dirs, monorepo siblings).
+
+## Architecture
+
+### Data model — event store
+
+Append-only events in `src/server/events.ts`:
+
+```ts
+stack_added { stackId, title, createdAt }
+stack_removed { stackId, removedAt }
+stack_renamed { stackId, title }
+stack_project_added { stackId, projectId, addedAt }
+stack_project_removed { stackId, projectId, removedAt }
+```
+
+Derived read model:
+
+```ts
+type Stack = {
+ id: string
+ title: string
+ projectIds: string[] // insertion order; drives sidebar order
+ createdAt: number
+}
+```
+
+Chat extension. No new event type. `chat_created` gains optional fields:
+
+```ts
+chat_created {
+ // existing...
+ stackId?: string
+ stackBindings?: Array<{
+ projectId: string
+ worktreePath: string
+ role: "primary" | "additional"
+ }>
+}
+```
+
+Invariants:
+
+- `stackId` set ⇔ `stackBindings` set and non-empty.
+- Exactly one `role: "primary"` per chat.
+- Every binding's `projectId` is a current member of the stack at chat-creation time.
+- Replay rule: chats without `stackId` resolve as today via `projectId` + `worktreePath`. No backfill event needed.
+
+### Server module — `src/server/stack-store.ts`
+
+```ts
+class StackStore {
+ createStack(title: string, projectIds: string[]): Stack // ≥2 projects required
+ renameStack(id: string, title: string): void
+ removeStack(id: string): void // blocked if live chats reference it
+ addProject(stackId: string, projectId: string): void
+ removeProject(stackId: string, projectId: string): void // blocked if any live chat binds it
+ listStacks(): Stack[]
+ getStack(id: string): Stack | null
+}
+```
+
+Pure event-sourced, mirrors the shape of `src/server/worktree-store.ts`. Test file `stack-store.test.ts` covers create/rename/add/remove/delete and replay determinism.
+
+### Agent spawn — `src/server/agent.ts`
+
+At every spawn site (today `agent.ts:662` and `agent.ts:1192`):
+
+1. If chat has no `stackBindings`, take the existing solo path. No change.
+2. Else, find the binding with `role: "primary"`. Resolve `{projectId, worktreePath}` to an absolute path via `worktree-store`. Use it as `cwd`.
+3. Map the remaining bindings to absolute paths. Pass them as `additionalDirectories: string[]` to the Claude Agent SDK `query()` call (verified to exist in the SDK; see Section 3 below).
+4. Codex path: set `cwd` to the same primary path. Do not pass any extra root field; Codex App Server has no `additionalDirectories` equivalent. Cross-root writes surface as the native `grantRoot` approval per file change.
+5. Persist the resolved primary + peer paths in the spawn event for replay and debugging.
+
+### Read models — `src/server/read-models.ts`
+
+- New derived selector `stackSummaries(): StackSummary[]` with member project ids and chat counts.
+- Existing chat snapshot extended with:
+
+ ```ts
+ resolvedBindings: Array<{
+ projectId: string
+ projectTitle: string
+ worktreePath: string
+ worktreeBranch: string
+ role: "primary" | "additional"
+ status: "active" | "orphaned"
+ }>
+ ```
+
+ Client renders the peer strip directly from this; no extra round-trip.
+
+### WebSocket router — `src/server/ws-router.ts`
+
+New commands:
+
+- `createStack { title, projectIds }`
+- `renameStack { stackId, title }`
+- `removeStack { stackId }`
+- `addStackProject { stackId, projectId }`
+- `removeStackProject { stackId, projectId }`
+
+`createChat` extended to accept optional `{ stackId, stackBindings }`. Validation: stack exists, every `projectId` is a current member, every `worktreePath` belongs to its project, exactly one primary.
+
+### SDK verification
+
+Claude Agent SDK `query()` options include `additionalDirectories: string[]` (verified via Context7 docs, source: `nothflare/claude-agent-sdk-docs/docs/en/agent-sdk/typescript.md`). Default `[]`. Sandbox honors entries as additional roots Claude can read and write.
+
+Codex App Server protocol (`src/server/codex-app-server-protocol.ts`) exposes only `cwd` on `ThreadStartParams` / `ThreadResumeParams` / `ThreadForkParams`. The `grantRoot` field on `FileChangeRequestApprovalParams` is a per-approval runtime grant; it is the fallback path for cross-root writes when running a stack chat on Codex.
+
+## Client UI
+
+### Sidebar
+
+`src/client/app/KannaSidebar.tsx` mounts a new `StacksSection` above `LocalProjectsSection`. Same row rhythm and tokens as projects, drawn from DESIGN.md (Title / Body / Label / Mono scales; Surface Secondary on hover; status dot conventions).
+
+Stack row layout:
+
+- Title (Title scale, weight 600).
+- Member-count badge (Label scale, Mono nums).
+- Caret. Expanded row shows the stack's chats, not its member projects.
+- On hover or keyboard focus, an inline reveal under the row lists member project names (Body scale, Margin Gray). No tooltip. No directional glyph chip. Project rows are unchanged; reverse-lookup lives here.
+
+Empty state copy: *"A stack groups projects so one chat can read and write across them. Add your first stack."*
+
+### Stack creation and edit (inline, not modal)
+
+`+ Stack` button in the section header expands an inline panel directly under it. The panel contains:
+
+- Title input.
+- Multi-select project chips (existing project list). At least two required.
+- Save (Enter) and Cancel (Esc).
+
+Users with only one registered project see the panel in a disabled state with copy *"Register a second project to create a stack"* linking to the existing add-project flow.
+
+Edit uses the same panel, prefilled, opened from a row-level action menu (Rename, Add projects, Remove projects, Delete). All actions are keyboard-reachable; destructive actions confirm inline, never modal-on-modal (DESIGN.md ban).
+
+### Stack chat creation (inline, not modal)
+
+A `+ Chat` row sits at the bottom of an expanded stack, mirroring the per-project "new chat" pattern. Clicking expands a compact table:
+
+```
+Project Worktree Primary
+backend feat-auth ▾ ●
+frontend main ▾ ○
+```
+
+- The worktree dropdown defaults to the project's primary worktree.
+- The primary radio defaults to the first row.
+- Cmd+Enter submits; Esc collapses.
+- Mobile (<640px viewport): same fields render as a bottom sheet.
+
+### Chat header peer strip
+
+`PeerWorktreeStrip.tsx` renders below the chat title in `ChatHeader.tsx` whenever `resolvedBindings.length > 1`. Format:
+
+```
+backend@feat-auth ● frontend@main
+```
+
+- Mono scale, tabular numerics.
+- Filled dot marks the primary (cwd).
+- Orphaned bindings render in Margin Gray with a strike.
+- Click on a peer label opens a small action menu (open dir in OS file manager via `external-open.ts`). Re-bind action deferred to P2.
+- For Codex provider chats, a small Mono label `codex: cwd-only` appears at the end of the strip. No icon, no color alarm. Calm.
+
+### Keybindings
+
+Added to `src/server/keybindings.ts` and the client mirror:
+
+- `cmd+alt+w` — new stack.
+- `cmd+alt+shift+n` — new chat in focused stack.
+- `g s` — jump to stacks section.
+- Stack action menu reachable via `enter` on focused row; destructive actions confirmable from the keyboard.
+
+## Data flow & edge cases
+
+| Case | Behavior |
+|---|---|
+| Member project removed while stack chat is live | Chat marked `orphaned-binding`. Peer strip greys that label. Agent still spawns and skips the dead path in `additionalDirectories`. New chat creation blocked until binding fixed. |
+| Worktree of a peer disappears on disk | Mark binding `orphaned`. Same handling. Reuses existing `worktree-store` orphan detection. |
+| Worktree of primary disappears | Chat enters `cannot-spawn`. Header banner: *"Primary worktree missing. Restore or fork chat."* Existing missing-worktree banner reused. |
+| Stack deleted with live chats | `removeStack` blocked. Toast: *"Stack has N active chats. Archive or stop them first."* |
+| User adds the same project twice | Event-store rejects. UI multi-select prevents it. |
+| Two bindings resolve to the same disk path | Allowed (different worktrees of the same repo). No dedupe in `additionalDirectories`. |
+| `stackBindings` empty but `stackId` set | Event-store rejects. Replay treats malformed chat as legacy solo and drops `stackId`. |
+| Two stack chats writing to the same peer worktree | Allowed. The existing `runGit` mutex in `src/server/diff-store.ts` already serializes per-repo. |
+| Stack with zero member projects after removals | `removeProject` blocked when it would drop members below 2. |
+
+## Testing
+
+`bun test` must stay green before push. Specific suites:
+
+- `src/server/stack-store.test.ts` — create, rename, add, remove, delete, replay determinism, invariants.
+- `src/server/agent.test.ts` extensions — spawn with bindings sets `cwd` + `additionalDirectories` correctly; orphaned bindings skipped; Codex path drops additional dirs; `cwd` matches primary.
+- `src/server/read-models.test.ts` — stack snapshot shape; `resolvedBindings` populated on chat snapshot; orphan status reflected.
+- `src/server/ws-router.test.ts` — new commands enforce auth and validation.
+- Client: `StacksSection.test.tsx` covers expand/collapse, member reveal on focus, empty state, single-project disabled state. `PeerWorktreeStrip.test.tsx` covers primary dot, orphan strike, Codex cwd-only label.
+
+Subprocess hygiene rules from `CLAUDE.md` apply to any new git spawns: `stdin: "ignore"`, `GIT_TERMINAL_PROMPT=0`, explicit `30_000` ms test timeout.
+
+## Rollout phases
+
+1. **Phase 1 — server + store.** `stack-store.ts`, events, read-model selectors, ws-router commands. No UI. Tests green.
+2. **Phase 2 — agent spawn wiring.** Bindings to `cwd` + `additionalDirectories`. Codex fallback. `agent.test.ts` extensions.
+3. **Phase 3 — UI.** `StacksSection`, inline create panel, stack chat creation row, peer strip, keybindings.
+4. **Phase 4 — polish.** Empty states, orphan banners, Codex cwd-only label, mobile sheet variant. `/impeccable polish` pass.
+
+Each phase ships its own PR against `cuongtranba/kanna`. Phase 1+2 are mergeable behind the absence of UI; Phase 3 ships the feature.
+
+## File map
+
+New:
+
+```
+src/server/stack-store.ts
+src/server/stack-store.test.ts
+src/client/components/chat-ui/sidebar/StacksSection.tsx
+src/client/components/chat-ui/sidebar/StacksSection.test.tsx
+src/client/components/chat-ui/sidebar/StackCreatePanel.tsx
+src/client/components/chat-ui/sidebar/StackChatCreateRow.tsx
+src/client/components/chat-ui/chat-header/PeerWorktreeStrip.tsx
+src/client/components/chat-ui/chat-header/PeerWorktreeStrip.test.tsx
+```
+
+Modified:
+
+```
+src/server/events.ts + stack_* event types
+src/server/read-models.ts + stack snapshot, resolvedBindings
+src/server/ws-router.ts + stack commands, extend createChat
+src/server/agent.ts spawn site: bindings → cwd + additionalDirectories (lines ~662, ~1192)
+src/server/codex-app-server.ts cwd matches primary binding (no field changes)
+src/server/keybindings.ts + new bindings
+src/shared/types.ts + Stack, StackBinding, SidebarStackGroup; extend Chat
+src/shared/protocol.ts + new WS commands
+src/client/app/KannaSidebar.tsx mount StacksSection above LocalProjectsSection
+src/client/app/useKannaState.ts consume stack snapshot
+src/client/components/chat-ui/ChatHeader.tsx render PeerWorktreeStrip when resolvedBindings.length > 1
+```
+
+## Documentation updates after merge
+
+- `DESIGN.md` adds Stack row + `PeerWorktreeStrip` entries.
+- `.c3/` adds a ref linking `stack-store` ↔ `agent` ↔ `ws-router`.
+- `CHANGELOG.md` entry on release.
+
+## Open questions
+
+None blocking. P2 items above can be designed after Phase 3 ships and the peer-rebinding need is real, not speculative.
+
+## Phase 2 amendments (post-implementation)
+
+Phase 2 bound stacks by `worktreePath` rather than `worktreeId` because worktree state is not yet in the event store (the `feat/worktree-events` branch is plan-only). The chat snapshot exposes `resolvedBindings` with project title and active/missing status; worktree branch and dirty status are deferred to Phase 3 (UI fetches via `worktree-store` on demand). When `feat/worktree-events` lands, a follow-up migration can resolve paths to ids without breaking the on-disk event log (the `worktreePath` field stays as a stable secondary key).
+
+Architectural note carried from Phase 1: stack state lives inside `event-store.ts` alongside projects and chats, not in a separate `stack-store.ts` module. The phase plan corrected the design doc on this point.
diff --git a/docs/plans/2026-05-11-stack-phase1-plan.md b/docs/plans/2026-05-11-stack-phase1-plan.md
new file mode 100644
index 000000000..526a89e44
--- /dev/null
+++ b/docs/plans/2026-05-11-stack-phase1-plan.md
@@ -0,0 +1,950 @@
+# Stack Phase 1 Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Add the server-side Stack entity (event-sourced state, store methods, WebSocket commands, read-model selector) so a Stack can be created, renamed, deleted, and have its project membership edited. No UI. No agent spawn wiring. No `chat_created` extension. Those land in Phase 2.
+
+**Architecture:** Stack state lives inside the existing `event-store.ts` (the `KannaStore` class), not in a separate module. New events stream to a new `stacks.jsonl` log file alongside the existing per-domain logs (projects.jsonl, chats.jsonl, ...). Apply cases mutate a new `stacksById: Map` slice of `StoreState`. WebSocket commands call public store methods. The `worktree-store.ts` pattern is a git wrapper, not a precedent for state stores.
+
+> **Design doc correction.** The parent design (`docs/plans/2026-05-11-stack-multi-repo-design.md`) refers to "`src/server/stack-store.ts`" as a separate module mirroring `worktree-store.ts`. That was wrong: `worktree-store.ts` wraps git CLI calls, while domain state for projects and chats lives inside `event-store.ts`. This plan extends `event-store.ts` directly. The design doc will be updated after Phase 1 ships.
+
+**Tech Stack:** TypeScript, Bun runtime, JSONL append-only event logs, `bun test` for tests.
+
+**Source spec:** `docs/plans/2026-05-11-stack-multi-repo-design.md` (sections "Data model" and "Server module"). This plan implements only the parts of those sections that do NOT touch agent.ts or chat creation. The Phase 2 plan covers those.
+
+**Out of scope (Phase 2):**
+
+- `chat_created` extension with `stackId` + `stackBindings`.
+- `resolvedBindings` on chat snapshot.
+- Agent spawn wiring (`cwd` + `additionalDirectories`).
+- All UI work.
+- Keybindings.
+
+---
+
+## Pre-flight checks
+
+Before Task 1, verify the worktree is correctly set up:
+
+```bash
+git rev-parse --show-toplevel # → .../kanna/.worktrees/feat-stack-phase1
+git rev-parse --abbrev-ref HEAD # → feat/stack-phase1
+git log -1 --oneline # base commit visible
+bun test src/server/event-store.test.ts # baseline green
+```
+
+If any check fails, stop and investigate before continuing.
+
+---
+
+## Task 1: Add `Stack` types to shared/types.ts
+
+**Files:**
+- Modify: `src/shared/types.ts` (add Stack-related types near `ProjectSummary`, ~line 417)
+
+**Step 1: Pick the insertion point**
+
+Run: `grep -n "export interface ProjectSummary" src/shared/types.ts`
+Expected: a single line number. Insert the new types directly after this interface and its related neighbours.
+
+**Step 2: Add the types**
+
+Add to `src/shared/types.ts`:
+
+```ts
+export interface Stack {
+ id: string
+ title: string
+ projectIds: string[] // insertion order; drives sidebar order within the stack
+ createdAt: number
+ updatedAt: number
+}
+
+export interface StackSummary {
+ id: string
+ title: string
+ projectIds: string[]
+ memberCount: number
+ createdAt: number
+ updatedAt: number
+}
+```
+
+These are pure data types. No methods. No optional fields beyond what's defined. Other Stack-shape types (chat bindings) belong in Phase 2.
+
+**Step 3: Verify compile**
+
+Run: `bun run typecheck` (or `bun x tsc --noEmit` if no script exists; check `package.json` first).
+Expected: no errors.
+
+**Step 4: Commit**
+
+```bash
+git add src/shared/types.ts
+git commit -m "feat(stacks): add Stack and StackSummary shared types"
+```
+
+---
+
+## Task 2: Add Stack events to `events.ts`
+
+**Files:**
+- Modify: `src/server/events.ts`
+
+**Step 1: Read the file**
+
+Run: `wc -l src/server/events.ts && sed -n '60,90p' src/server/events.ts`
+Expected: see the `ProjectEvent` union, the pattern this task follows.
+
+**Step 2: Add the event union**
+
+After the `ProjectEvent` union (currently ending around line 80), add:
+
+```ts
+export type StackEvent =
+ | {
+ v: 3
+ type: "stack_added"
+ timestamp: number
+ stackId: string
+ title: string
+ projectIds: string[] // ≥2 at creation; invariant enforced by the store, not the event
+ }
+ | {
+ v: 3
+ type: "stack_removed"
+ timestamp: number
+ stackId: string
+ }
+ | {
+ v: 3
+ type: "stack_renamed"
+ timestamp: number
+ stackId: string
+ title: string
+ }
+ | {
+ v: 3
+ type: "stack_project_added"
+ timestamp: number
+ stackId: string
+ projectId: string
+ }
+ | {
+ v: 3
+ type: "stack_project_removed"
+ timestamp: number
+ stackId: string
+ projectId: string
+ }
+```
+
+**Step 3: Extend `StoreEvent` union**
+
+Find the `StoreEvent` line (around line 217). Add `StackEvent`:
+
+```ts
+export type StoreEvent = ProjectEvent | ChatEvent | MessageEvent | QueuedMessageEvent | TurnEvent | StackEvent | AutoContinueEvent
+```
+
+**Step 4: Add `StackRecord` and extend `StoreState`**
+
+Above `StoreState`, add:
+
+```ts
+export interface StackRecord {
+ id: string
+ title: string
+ projectIds: string[]
+ createdAt: number
+ updatedAt: number
+ deletedAt?: number
+}
+```
+
+Extend `StoreState`:
+
+```ts
+export interface StoreState {
+ // existing fields...
+ stacksById: Map
+}
+```
+
+**Step 5: Extend `createEmptyState`**
+
+```ts
+export function createEmptyState(): StoreState {
+ return {
+ // existing fields...
+ stacksById: new Map(),
+ }
+}
+```
+
+**Step 6: Verify compile**
+
+Run: `bun run typecheck` (or `bun x tsc --noEmit`).
+Expected: no errors. `event-store.ts` may now warn that `applyEvent` does not handle `StackEvent` cases (TypeScript exhaustiveness). That is intentional and fixed in Task 4.
+
+**Step 7: Commit**
+
+```bash
+git add src/server/events.ts
+git commit -m "feat(stacks): add StackEvent union and StackRecord state slice"
+```
+
+---
+
+## Task 3: Add `stacks.jsonl` log path + replay
+
+**Files:**
+- Modify: `src/server/event-store.ts`
+
+**Step 1: Add the log path field**
+
+After the existing `private readonly *LogPath: string` declarations (~line 176-183), add:
+
+```ts
+private readonly stacksLogPath: string
+```
+
+In the constructor body, after the other `LogPath` assignments (~line 196-203):
+
+```ts
+this.stacksLogPath = path.join(this.dataDir, "stacks.jsonl")
+```
+
+**Step 2: Ensure the file on init**
+
+In `init()` (or wherever the existing `ensureFile` calls live, ~line 211-218), add:
+
+```ts
+await this.ensureFile(this.stacksLogPath)
+```
+
+**Step 3: Wire replay**
+
+Find the existing replay sequence (search for `this.projectsLogPath`, then look at where it is replayed). Add an equivalent replay call for `this.stacksLogPath`. Use the same `replayLog` helper the projects log uses; mirror the order — projects → stacks → chats → ... — so that on replay, stacks see their member projects already loaded.
+
+Run: `grep -n "projectsLogPath\|replayLog" src/server/event-store.ts | head -20`
+Expected: identifies the replay loop. Add the stacks line directly after the projects line.
+
+**Step 4: Wire clearStorage**
+
+Find `clearStorage` (search the file). Add:
+
+```ts
+Bun.write(this.stacksLogPath, ""),
+```
+
+next to the other `Bun.write(...LogPath, "")` calls.
+
+**Step 5: Verify compile and tests**
+
+Run: `bun x tsc --noEmit && bun test src/server/event-store.test.ts`
+Expected: typecheck green; existing tests pass.
+
+**Step 6: Commit**
+
+```bash
+git add src/server/event-store.ts
+git commit -m "feat(stacks): add stacks.jsonl log path with init, replay, and clear"
+```
+
+---
+
+## Task 4: Add `applyEvent` cases for all Stack events
+
+**Files:**
+- Modify: `src/server/event-store.ts` (`applyEvent` method, ~line 472)
+
+**Important.** No separate apply-only test file. The apply behavior is exercised by the public-API method tests in Task 5. (Existing tests in `event-store.test.ts` already follow this pattern: they call `openProject` and assert via `getProject`/state queries, not via direct `applyEvent` access.) Task 4 is implementation-only; tests come in Task 5.
+
+**Step 1: Add the apply cases**
+
+Inside the `applyEvent` switch (~line 472), after the `sidebar_project_order_set` case, add:
+
+```ts
+case "stack_added": {
+ const record: StackRecord = {
+ id: e.stackId,
+ title: e.title,
+ projectIds: [...e.projectIds],
+ createdAt: e.timestamp,
+ updatedAt: e.timestamp,
+ }
+ this.state.stacksById.set(record.id, record)
+ break
+}
+case "stack_removed": {
+ const stack = this.state.stacksById.get(e.stackId)
+ if (!stack) break
+ stack.deletedAt = e.timestamp
+ stack.updatedAt = e.timestamp
+ break
+}
+case "stack_renamed": {
+ const stack = this.state.stacksById.get(e.stackId)
+ if (!stack || stack.deletedAt) break
+ stack.title = e.title
+ stack.updatedAt = e.timestamp
+ break
+}
+case "stack_project_added": {
+ const stack = this.state.stacksById.get(e.stackId)
+ if (!stack || stack.deletedAt) break
+ if (stack.projectIds.includes(e.projectId)) break
+ stack.projectIds = [...stack.projectIds, e.projectId]
+ stack.updatedAt = e.timestamp
+ break
+}
+case "stack_project_removed": {
+ const stack = this.state.stacksById.get(e.stackId)
+ if (!stack || stack.deletedAt) break
+ const next = stack.projectIds.filter((id) => id !== e.projectId)
+ stack.projectIds = next
+ stack.updatedAt = e.timestamp
+ break
+}
+```
+
+Import `StackRecord` from `./events` at the top of the file if not already imported.
+
+**Step 2: Typecheck**
+
+Run: `bun x tsc --noEmit`
+Expected: clean.
+
+**Step 3: Commit**
+
+```bash
+git add src/server/event-store.ts
+git commit -m "feat(stacks): apply stack events into store state"
+```
+
+---
+
+## Task 5: Add public store methods (TDD)
+
+Each sub-task here writes the test first, then the method. Five methods total. Group commits by method.
+
+**Test pattern.** Use the same shape as existing `event-store.test.ts`:
+
+```ts
+import { describe, test, expect, afterAll } from "bun:test"
+import { mkdtemp, rm } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { EventStore } from "./event-store"
+
+const tempDirs: string[] = []
+afterAll(async () => {
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
+})
+
+async function createTempDataDir(): Promise {
+ const dir = await mkdtemp(join(tmpdir(), "kanna-stack-test-"))
+ tempDirs.push(dir)
+ return dir
+}
+
+async function buildStoreWithProjects(paths: string[]): Promise<{ store: EventStore; projectIds: string[] }> {
+ const store = new EventStore(await createTempDataDir())
+ await store.initialize()
+ const projectIds: string[] = []
+ for (const p of paths) {
+ const project = await store.openProject(p, p)
+ projectIds.push(project.id)
+ }
+ return { store, projectIds }
+}
+```
+
+Use real local paths (e.g. `/tmp/p1`, `/tmp/p2`) — `openProject` does not require the dir to exist on disk for state-only tests.
+
+> If `EventStore` exposes a `dispose()` / shutdown method, call it in `afterAll`. Otherwise the `rm` in the cleanup is sufficient.
+
+### 5a. `createStack(title, projectIds)`
+
+**Files:**
+- Modify: `src/server/event-store.ts`
+- Create: `src/server/event-store.stack-methods.test.ts`
+
+**Step 1: Failing test**
+
+```ts
+test("createStack writes a stack_added event and returns the new stack", async () => {
+ const { store, projectIds: [p1, p2] } = await buildStoreWithProjects(["/tmp/p1", "/tmp/p2"])
+ const stack = await store.createStack("Integration", [p1, p2])
+ expect(stack.id).toMatch(/[0-9a-f-]{36}/u)
+ expect(stack.title).toBe("Integration")
+ expect(stack.projectIds).toEqual([p1, p2])
+ expect(store.getStack(stack.id)).toEqual(stack)
+})
+
+test("createStack rejects fewer than 2 projects", async () => {
+ const { store, projectIds: [p1] } = await buildStoreWithProjects(["/tmp/p1"])
+ await expect(store.createStack("Solo", [p1])).rejects.toThrow(/at least 2 projects/u)
+})
+
+test("createStack rejects unknown projectId", async () => {
+ const { store, projectIds: [p1] } = await buildStoreWithProjects(["/tmp/p1", "/tmp/p2"])
+ await expect(store.createStack("X", [p1, "ghost"])).rejects.toThrow(/Project not found/u)
+})
+
+test("createStack rejects duplicate projectIds in the input", async () => {
+ const { store, projectIds: [p1] } = await buildStoreWithProjects(["/tmp/p1", "/tmp/p2"])
+ await expect(store.createStack("X", [p1, p1])).rejects.toThrow(/duplicate/u)
+})
+```
+
+**Step 2: Run the failing tests**
+
+Run: `bun test src/server/event-store.stack-methods.test.ts`
+Expected: FAIL — `createStack` not defined.
+
+**Step 3: Implement the method**
+
+In `event-store.ts`, near `openProject` (~line 763), add:
+
+```ts
+async createStack(title: string, projectIds: string[]): Promise {
+ const trimmed = title.trim()
+ if (trimmed === "") throw new Error("Stack title cannot be empty")
+ if (projectIds.length < 2) throw new Error("Stack requires at least 2 projects")
+ if (new Set(projectIds).size !== projectIds.length) throw new Error("Stack projectIds contain duplicates")
+ for (const projectId of projectIds) {
+ const project = this.state.projectsById.get(projectId)
+ if (!project || project.deletedAt) throw new Error(`Project not found: ${projectId}`)
+ }
+ const stackId = crypto.randomUUID()
+ const event: StackEvent = {
+ v: STORE_VERSION,
+ type: "stack_added",
+ timestamp: Date.now(),
+ stackId,
+ title: trimmed,
+ projectIds: [...projectIds],
+ }
+ await this.append(this.stacksLogPath, event)
+ return this.state.stacksById.get(stackId)!
+}
+
+getStack(stackId: string): StackRecord | null {
+ const stack = this.state.stacksById.get(stackId)
+ return stack && !stack.deletedAt ? stack : null
+}
+
+listStacks(): StackRecord[] {
+ return [...this.state.stacksById.values()].filter((s) => !s.deletedAt)
+}
+```
+
+Import `StackEvent`, `StackRecord` from `./events` as needed.
+
+**Step 4: Run tests**
+
+Run: `bun test src/server/event-store.stack-methods.test.ts`
+Expected: all 4 pass.
+
+**Step 5: Commit**
+
+```bash
+git add src/server/event-store.ts src/server/event-store.stack-methods.test.ts
+git commit -m "feat(stacks): add createStack with validation (≥2 projects, unique, known)"
+```
+
+### 5b. `renameStack(stackId, title)`
+
+**Step 1: Failing tests**
+
+```ts
+test("renameStack updates the title and emits stack_renamed", async () => { /* ... */ })
+test("renameStack on unknown id throws", async () => { /* ... */ })
+test("renameStack on deleted stack throws", async () => { /* ... */ })
+test("renameStack with empty title throws", async () => { /* ... */ })
+```
+
+**Step 2-4: Run, implement, run**
+
+Method body:
+
+```ts
+async renameStack(stackId: string, title: string): Promise {
+ const stack = this.state.stacksById.get(stackId)
+ if (!stack || stack.deletedAt) throw new Error("Stack not found")
+ const trimmed = title.trim()
+ if (trimmed === "") throw new Error("Stack title cannot be empty")
+ if (trimmed === stack.title) return
+ const event: StackEvent = {
+ v: STORE_VERSION,
+ type: "stack_renamed",
+ timestamp: Date.now(),
+ stackId,
+ title: trimmed,
+ }
+ await this.append(this.stacksLogPath, event)
+}
+```
+
+**Step 5: Commit**
+
+```bash
+git add src/server/event-store.ts src/server/event-store.stack-methods.test.ts
+git commit -m "feat(stacks): add renameStack"
+```
+
+### 5c. `removeStack(stackId)`
+
+Phase 1 has no chat-binding concept yet, so the "blocked when live chats reference the stack" rule from the design doc cannot be enforced here. Phase 2 will add it. For Phase 1, removeStack is unconditional.
+
+**Step 1: Failing tests**
+
+```ts
+test("removeStack marks the stack deleted; getStack returns null", async () => { /* ... */ })
+test("removeStack on unknown id throws", async () => { /* ... */ })
+test("removeStack on already-deleted id is idempotent (does not throw)", async () => { /* ... */ })
+```
+
+**Step 2-4: Run, implement, run**
+
+```ts
+async removeStack(stackId: string): Promise {
+ const stack = this.state.stacksById.get(stackId)
+ if (!stack) throw new Error("Stack not found")
+ if (stack.deletedAt) return
+ const event: StackEvent = {
+ v: STORE_VERSION,
+ type: "stack_removed",
+ timestamp: Date.now(),
+ stackId,
+ }
+ await this.append(this.stacksLogPath, event)
+}
+```
+
+**Step 5: Commit**
+
+```bash
+git add src/server/event-store.ts src/server/event-store.stack-methods.test.ts
+git commit -m "feat(stacks): add removeStack (no live-chat check yet; Phase 2)"
+```
+
+### 5d. `addProjectToStack(stackId, projectId)`
+
+**Step 1: Failing tests**
+
+```ts
+test("addProjectToStack appends the project id", async () => { /* ... */ })
+test("addProjectToStack on unknown stack throws", async () => { /* ... */ })
+test("addProjectToStack with unknown project throws", async () => { /* ... */ })
+test("addProjectToStack with already-member project is idempotent", async () => { /* ... */ })
+```
+
+**Step 2-4: Run, implement, run**
+
+```ts
+async addProjectToStack(stackId: string, projectId: string): Promise {
+ const stack = this.state.stacksById.get(stackId)
+ if (!stack || stack.deletedAt) throw new Error("Stack not found")
+ const project = this.state.projectsById.get(projectId)
+ if (!project || project.deletedAt) throw new Error("Project not found")
+ if (stack.projectIds.includes(projectId)) return
+ const event: StackEvent = {
+ v: STORE_VERSION,
+ type: "stack_project_added",
+ timestamp: Date.now(),
+ stackId,
+ projectId,
+ }
+ await this.append(this.stacksLogPath, event)
+}
+```
+
+**Step 5: Commit**
+
+```bash
+git add src/server/event-store.ts src/server/event-store.stack-methods.test.ts
+git commit -m "feat(stacks): add addProjectToStack"
+```
+
+### 5e. `removeProjectFromStack(stackId, projectId)`
+
+Invariant: stack must keep ≥2 members. Refusing the remove call is the Phase 1 behavior; deleting the stack outright is a separate user action.
+
+**Step 1: Failing tests**
+
+```ts
+test("removeProjectFromStack removes the project", async () => { /* ... */ })
+test("removeProjectFromStack blocks dropping below 2 members", async () => { /* ... */ })
+test("removeProjectFromStack on non-member is idempotent", async () => { /* ... */ })
+test("removeProjectFromStack on unknown stack throws", async () => { /* ... */ })
+```
+
+**Step 2-4: Run, implement, run**
+
+```ts
+async removeProjectFromStack(stackId: string, projectId: string): Promise {
+ const stack = this.state.stacksById.get(stackId)
+ if (!stack || stack.deletedAt) throw new Error("Stack not found")
+ if (!stack.projectIds.includes(projectId)) return
+ if (stack.projectIds.length <= 2) {
+ throw new Error("Stack must keep at least 2 projects. Delete the stack instead.")
+ }
+ const event: StackEvent = {
+ v: STORE_VERSION,
+ type: "stack_project_removed",
+ timestamp: Date.now(),
+ stackId,
+ projectId,
+ }
+ await this.append(this.stacksLogPath, event)
+}
+```
+
+**Step 5: Commit**
+
+```bash
+git add src/server/event-store.ts src/server/event-store.stack-methods.test.ts
+git commit -m "feat(stacks): add removeProjectFromStack with min-2-members invariant"
+```
+
+---
+
+## Task 6: Replay determinism test
+
+**Files:**
+- Modify: `src/server/event-store.stack-methods.test.ts`
+
+**Step 1: Test**
+
+```ts
+test("Replay produces identical state to live mutations", async () => {
+ const dir = await createTempDataDir()
+
+ // Live mutations.
+ const store1 = new EventStore(dir)
+ await store1.initialize()
+ const pa = await store1.openProject("/tmp/a", "A")
+ const pb = await store1.openProject("/tmp/b", "B")
+ const pc = await store1.openProject("/tmp/c", "C")
+ const s = await store1.createStack("X", [pa.id, pb.id])
+ await store1.addProjectToStack(s.id, pc.id)
+ await store1.renameStack(s.id, "Renamed")
+ await store1.removeProjectFromStack(s.id, pa.id)
+ const liveStacks = store1.listStacks()
+
+ // Fresh store, same dir → replays the log.
+ const store2 = new EventStore(dir)
+ await store2.initialize()
+ const replayed = store2.listStacks()
+ expect(replayed).toEqual(liveStacks)
+})
+```
+
+Note: this test reuses `createTempDataDir` defined in the file's top-level helper. If `EventStore` retains background timers or open file handles, a `store1.shutdown?.()` call may be needed before the second `initialize()`. Add it only if the test hangs or flakes; otherwise leave omitted.
+
+**Step 2: Run**
+
+Run: `bun test src/server/event-store.stack-methods.test.ts -t Replay`
+Expected: PASS. If it does not, replay order in Task 3 is wrong; fix the order and retest.
+
+**Step 3: Commit**
+
+```bash
+git add src/server/event-store.stack-methods.test.ts
+git commit -m "test(stacks): event log replay produces identical state"
+```
+
+---
+
+## Task 7: WebSocket protocol
+
+**Files:**
+- Modify: `src/shared/protocol.ts`
+
+**Step 1: Add to `ClientCommand` union**
+
+Around line 69, in the `ClientCommand` union (after the project.* commands), add:
+
+```ts
+| { type: "stack.create"; title: string; projectIds: string[] }
+| { type: "stack.rename"; stackId: string; title: string }
+| { type: "stack.remove"; stackId: string }
+| { type: "stack.addProject"; stackId: string; projectId: string }
+| { type: "stack.removeProject"; stackId: string; projectId: string }
+```
+
+**Step 2: Verify compile**
+
+Run: `bun x tsc --noEmit`
+Expected: no errors.
+
+**Step 3: Commit**
+
+```bash
+git add src/shared/protocol.ts
+git commit -m "feat(stacks): add stack.* WebSocket client commands"
+```
+
+---
+
+## Task 8: WebSocket router handlers
+
+**Files:**
+- Modify: `src/server/ws-router.ts`
+- Create: `src/server/ws-router.stack.test.ts`
+
+**Step 1: Failing test**
+
+```ts
+test("stack.create routes to store.createStack and acks with stackId", async () => { /* ... */ })
+test("stack.create with <2 projects sends a typed error", async () => { /* ... */ })
+test("stack.rename routes to store.renameStack and acks", async () => { /* ... */ })
+test("stack.remove routes to store.removeStack and acks", async () => { /* ... */ })
+test("stack.addProject routes to store.addProjectToStack and acks", async () => { /* ... */ })
+test("stack.removeProject routes to store.removeProjectFromStack and acks", async () => { /* ... */ })
+test("stack.create broadcasts the updated stacks list", async () => { /* ... */ })
+```
+
+Test harness pattern (mirrors `ws-router.test.ts`):
+
+- Construct a real `EventStore` with `createTempDataDir()` and `await store.initialize()`. Open two projects with `store.openProject(...)`.
+- Pass the store to `createWsRouter({ store, ... })`. All other deps (agent, terminals, keybindings, etc.) can be stubbed with the same `as never` shapes used by existing tests; copy the minimal stubs from the `system.ping` test (`ws-router.test.ts:243`).
+- Use the existing `FakeWebSocket` class. Drive commands by calling `router.handleMessage(ws, JSON.stringify({ v: 1, type: "command", id, command: { type: "stack.create", title, projectIds } }))`.
+- Assert against `ws.sent` for the ack payload. Track broadcasts by counting `handleMessage` triggers; the broadcastFilteredSnapshots call lands in the snapshot subscription pipe.
+
+Do NOT mock the store — the tests should observe real `store.listStacks()` mutation, which catches both wiring and side-effect bugs.
+
+`resolvedAnalytics.track("stack_created")` requires the event name to be added to `src/server/analytics.ts`. Add it in the same commit. If `analytics.ts` enforces a closed union of event names, extend the union; if it accepts any string, no change needed.
+
+**Step 2: Run the failing tests**
+
+Run: `bun test src/server/ws-router.stack.test.ts`
+Expected: FAIL with "unknown command type" or similar.
+
+**Step 3: Add the handlers**
+
+In `ws-router.ts`, in the command-routing switch (find the `chat.create` case around line 1365 as a template), add:
+
+```ts
+case "stack.create": {
+ const stack = await store.createStack(command.title, command.projectIds)
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { stackId: stack.id } })
+ resolvedAnalytics.track("stack_created")
+ await broadcastFilteredSnapshots({ includeSidebar: true })
+ return
+}
+case "stack.rename": {
+ await store.renameStack(command.stackId, command.title)
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
+ await broadcastFilteredSnapshots({ includeSidebar: true })
+ return
+}
+case "stack.remove": {
+ await store.removeStack(command.stackId)
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
+ await broadcastFilteredSnapshots({ includeSidebar: true })
+ return
+}
+case "stack.addProject": {
+ await store.addProjectToStack(command.stackId, command.projectId)
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
+ await broadcastFilteredSnapshots({ includeSidebar: true })
+ return
+}
+case "stack.removeProject": {
+ await store.removeProjectFromStack(command.stackId, command.projectId)
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
+ await broadcastFilteredSnapshots({ includeSidebar: true })
+ return
+}
+```
+
+If `resolvedAnalytics.track("stack_created")` requires the event name to be registered in `src/server/analytics.ts`, add it there in the same commit.
+
+**Step 4: Run the tests**
+
+Run: `bun test src/server/ws-router.stack.test.ts`
+Expected: all pass.
+
+**Step 5: Commit**
+
+```bash
+git add src/server/ws-router.ts src/server/ws-router.stack.test.ts src/server/analytics.ts
+git commit -m "feat(stacks): wire stack.* WebSocket commands to store methods"
+```
+
+---
+
+## Task 9: Read-model `stackSummaries` selector
+
+**Files:**
+- Modify: `src/server/read-models.ts`
+- Modify: `src/server/read-models.test.ts`
+
+**Step 1: Failing test**
+
+```ts
+import { createEmptyState } from "./events"
+import { stackSummaries } from "./read-models"
+
+test("stackSummaries returns active stacks with member counts in insertion order", () => {
+ const state = createEmptyState()
+ state.stacksById.set("s1", {
+ id: "s1",
+ title: "A",
+ projectIds: ["p1", "p2"],
+ createdAt: 1,
+ updatedAt: 1,
+ })
+ state.stacksById.set("s2", {
+ id: "s2",
+ title: "B",
+ projectIds: ["p2", "p3"],
+ createdAt: 2,
+ updatedAt: 2,
+ })
+ const summaries = stackSummaries(state)
+ expect(summaries).toHaveLength(2)
+ expect(summaries[0]?.title).toBe("A")
+ expect(summaries[0]?.memberCount).toBe(2)
+})
+
+test("stackSummaries excludes deleted stacks", () => {
+ const state = createEmptyState()
+ state.stacksById.set("s1", {
+ id: "s1",
+ title: "Gone",
+ projectIds: ["p1", "p2"],
+ createdAt: 1,
+ updatedAt: 2,
+ deletedAt: 2,
+ })
+ expect(stackSummaries(state)).toEqual([])
+})
+```
+
+`read-models.ts` exports per-selector functions (see existing `deriveSidebarData`, `deriveChatSnapshot`, etc.). Follow that pattern: a free function that takes `StoreState` and returns the projection.
+
+**Step 2: Run**
+
+Run: `bun test src/server/read-models.test.ts -t stackSummaries`
+Expected: FAIL.
+
+**Step 3: Implement**
+
+```ts
+export function stackSummaries(state: StoreState): StackSummary[] {
+ return [...state.stacksById.values()]
+ .filter((s) => !s.deletedAt)
+ .map((s) => ({
+ id: s.id,
+ title: s.title,
+ projectIds: [...s.projectIds],
+ memberCount: s.projectIds.length,
+ createdAt: s.createdAt,
+ updatedAt: s.updatedAt,
+ }))
+}
+```
+
+If `read-models.ts` already exports a full sidebar snapshot, extend that snapshot to include `stacks: StackSummary[]` alongside.
+
+**Step 4: Run**
+
+Run: `bun test src/server/read-models.test.ts`
+Expected: all pass; no regressions.
+
+**Step 5: Commit**
+
+```bash
+git add src/server/read-models.ts src/server/read-models.test.ts
+git commit -m "feat(stacks): add stackSummaries read-model selector"
+```
+
+---
+
+## Task 10: Full-suite verification
+
+**Step 1: Run all tests**
+
+Run: `bun test`
+Expected: full green. Zero new failures. Existing tests untouched.
+
+If anything is red and is **not** a pre-existing failure on `main`, stop and report per the project's pre-existing-issue rule (`~/.claude/CLAUDE.md`).
+
+**Step 2: Typecheck**
+
+Run: `bun x tsc --noEmit`
+Expected: no errors.
+
+**Step 3: Manual sanity (optional, only if a dev branch is wanted)**
+
+Boot the server, open the WS client console, send:
+
+```js
+ws.send(JSON.stringify({ id: "1", v: 3, type: "stack.create", title: "Test", projectIds: [] }))
+```
+
+Expect: ack with `stackId`. Open the data dir; `stacks.jsonl` contains the event.
+
+---
+
+## Task 11: Push and open PR
+
+**Step 1: Push**
+
+```bash
+git push -u origin feat/stack-phase1
+```
+
+**Step 2: Open PR**
+
+```bash
+gh pr create --repo cuongtranba/kanna --base main --head feat/stack-phase1 \
+ --title "feat(stacks): Phase 1 — server, events, store, ws-router" \
+ --body "$(cat <<'EOF'
+## Summary
+- Adds the Stack entity (event-sourced) inside event-store.ts.
+- Adds stacks.jsonl event log with init / replay / clear wiring.
+- Adds public store methods: createStack, renameStack, removeStack, addProjectToStack, removeProjectFromStack.
+- Adds stack.* WebSocket commands routed to the store.
+- Adds stackSummaries read-model selector.
+- No UI. No agent.ts spawn changes. No chat_created extension. Those land in Phase 2.
+
+## Design
+- Spec: docs/plans/2026-05-11-stack-multi-repo-design.md
+- Phase plan: docs/plans/2026-05-11-stack-phase1-plan.md
+
+## Test plan
+- [x] bun test green (full suite)
+- [x] bun x tsc --noEmit clean
+- [x] Replay determinism test passes
+- [ ] Manual: round-trip a stack via WS console
+EOF
+)"
+```
+
+**Step 3: Update the parent design doc**
+
+After Phase 1 merges to main, open a follow-up PR that revises `docs/plans/2026-05-11-stack-multi-repo-design.md` to drop the "stack-store.ts as separate module" claim. The doc should reflect the actual implementation: stack state lives inside `event-store.ts`.
+
+---
+
+## Done-when checklist
+
+- [ ] All tasks above committed, each as its own commit.
+- [ ] `bun test` green.
+- [ ] `bun x tsc --noEmit` clean.
+- [ ] PR open against `cuongtranba/kanna` main.
+- [ ] Phase 2 plan written (next session).
+
+## Notes for the executor
+
+- **Subprocess hygiene** (from project CLAUDE.md): any new test that spawns subprocesses must set `stdin: "ignore"` and `GIT_TERMINAL_PROMPT=0` and pass an explicit `30_000` ms timeout to `test()`. Phase 1 should not spawn subprocesses at all (this is pure state work), but if a test helper does, follow the rule.
+- **Strong typing** (from global CLAUDE.md): no `any`, no `unknown` without narrowing, no untyped maps. `Map` is the only acceptable shape for `stacksById`.
+- **One commit per logical step**: do not batch unrelated changes. The plan's commit boundaries are intentional.
+- **Pre-existing failures**: if `bun test` is already red on `main`, stop and ask the user before continuing.
+- **Reference the design doc, not memory**: when in doubt, re-read `docs/plans/2026-05-11-stack-multi-repo-design.md` rather than inferring.
diff --git a/docs/plans/2026-05-11-stack-phase2-plan.md b/docs/plans/2026-05-11-stack-phase2-plan.md
new file mode 100644
index 000000000..029b8a151
--- /dev/null
+++ b/docs/plans/2026-05-11-stack-phase2-plan.md
@@ -0,0 +1,845 @@
+# Stack Phase 2 Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Wire the Stack entity (server-only, shipped in Phase 1) into chat creation and agent spawn. A chat created inside a stack stores its per-project worktree bindings as part of the `chat_created` event; the agent spawn maps the primary binding to the SDK `cwd` and peer bindings to `additionalDirectories`. Snapshot consumers see a resolved binding list on the chat snapshot. No UI yet — Phase 3 handles sidebar, creation panel, peer strip, and keybindings.
+
+**Architecture:** Extend the existing `chat_created` event with two optional fields (`stackId`, `stackBindings`); extend `ChatRecord` to carry the same; extend `EventStore.createChat` to accept stack options with validation; extend the `chat.create` WebSocket command symmetrically; extend the Claude agent spawn site to pass `additionalDirectories: string[]` derived from peer bindings; extend `deriveChatSnapshot` to emit `resolvedBindings`. Codex spawn keeps a single `cwd` and falls back to per-write `grantRoot` approvals (Codex App Server has no `additionalDirectories` field).
+
+**Tech Stack:** Bun + TypeScript. Event store at `src/server/event-store.ts`. Event shapes at `src/server/events.ts`. Shared types at `src/shared/types.ts`. Agent spawn at `src/server/agent.ts`. WebSocket router at `src/server/ws-router.ts`. Read models at `src/server/read-models.ts`. Tests via `bun test` against ephemeral data dirs.
+
+**Source spec:** `docs/plans/2026-05-11-stack-multi-repo-design.md` (sections "Server module", "Agent spawn", "Read models"). Phase 1 plan at `docs/plans/2026-05-11-stack-phase1-plan.md` (already shipped on this branch lineage).
+
+**Binding-key decision.** Worktree state is not yet in the event store (the `feat/worktree-events` branch is unstarted). Phase 2 binds by **absolute worktree path** (`worktreePath: string`), not by a `worktreeId`. Path is the value the SDK already takes as `cwd`. When worktree-events ships later, a follow-up migration can resolve paths to ids. This decision narrows the design doc's `worktreeId` reference to `worktreePath` for now; the design doc is amended in Task 12 below.
+
+**Out of scope (Phase 3):**
+
+- All client UI (`StacksSection`, inline creation panel, stack chat row, `PeerWorktreeStrip`).
+- Keybindings.
+- Branch / dirty-status enrichment on peer strip.
+- Re-binding a peer worktree on a live chat (`chat_binding_changed` event).
+
+---
+
+## Pre-flight checks
+
+Working directory: `/Users/cuongtran/Desktop/repo/kanna/.worktrees/feat-stack-phase2`. Branch: `feat/stack-phase2`. Base: Phase 1 tip (`6cfa605`).
+
+Before Task 1:
+
+```bash
+git rev-parse --abbrev-ref HEAD # → feat/stack-phase2
+git log -1 --oneline # → 6cfa605 (Phase 1 tip)
+bun test --timeout 30000 # baseline green: 1207 pass / 0 fail
+bun x tsc --noEmit 2>&1 | grep -v sonner # only 3 pre-existing sonner errors
+```
+
+Stop and ask if any check fails. Do NOT bypass.
+
+---
+
+## Task 1: Add `StackBinding` to shared types
+
+**Files:**
+- Modify: `src/shared/types.ts`
+
+**Step 1: Insert near the existing Stack types**
+
+Find them: `grep -n "export interface Stack\b\|export interface StackSummary\b" src/shared/types.ts`.
+
+Insert directly after `StackSummary`:
+
+```ts
+export interface StackBinding {
+ projectId: string
+ worktreePath: string // absolute, matches agent SDK cwd input
+ role: "primary" | "additional"
+}
+```
+
+Only one `role: "primary"` per chat. The invariant is enforced by the store (Task 5), not the type.
+
+**Step 2: Typecheck**
+
+```bash
+bun x tsc --noEmit 2>&1 | grep -v sonner | head
+```
+
+Expected: no new errors.
+
+**Step 3: Commit**
+
+```bash
+git add src/shared/types.ts
+git commit -m "feat(stacks): add StackBinding shared type"
+```
+
+---
+
+## Task 2: Extend `chat_created` event + `ChatRecord`
+
+**Files:**
+- Modify: `src/server/events.ts`
+
+**Step 1: Extend the `chat_created` variant in `ChatEvent`**
+
+Find: `grep -n 'type: "chat_created"' src/server/events.ts`. The variant lives around line 87. Add two optional fields after `title`:
+
+```ts
+{
+ v: 3
+ type: "chat_created"
+ timestamp: number
+ chatId: string
+ projectId: string
+ title: string
+ stackId?: string
+ stackBindings?: StackBinding[]
+}
+```
+
+Import `StackBinding`:
+
+```ts
+import type { /* existing... */ StackBinding } from "../shared/types"
+```
+
+**Step 2: Extend `ChatRecord`**
+
+Find `ChatRecord` near the top of `events.ts`. Add the same optional fields:
+
+```ts
+export interface ChatRecord {
+ // existing fields...
+ stackId?: string
+ stackBindings?: StackBinding[]
+}
+```
+
+**Step 3: Typecheck**
+
+```bash
+bun x tsc --noEmit 2>&1 | grep -v sonner | head
+```
+
+Expected: no new errors. (The `applyEvent` `chat_created` case will still compile because it does not destructure these new fields.)
+
+**Step 4: Commit**
+
+```bash
+git add src/server/events.ts
+git commit -m "feat(stacks): extend chat_created event and ChatRecord with stack fields"
+```
+
+---
+
+## Task 3: `applyEvent` propagates stack fields onto ChatRecord (TDD)
+
+**Files:**
+- Modify: `src/server/event-store.ts` (the `chat_created` case in `applyEvent`, ~line 527)
+- Modify: `src/server/event-store.stack-methods.test.ts`
+
+**Step 1: Failing test**
+
+Append to `event-store.stack-methods.test.ts`:
+
+```ts
+describe("chat_created with stack fields", () => {
+ test("apply preserves stackId and stackBindings on the ChatRecord", async () => {
+ const { store, projectIds: [p1, p2] } = await buildStoreWithProjects(["/tmp/p1", "/tmp/p2"])
+ const stack = await store.createStack("X", [p1, p2])
+ const chat = await store.createChat(p1, {
+ stackId: stack.id,
+ stackBindings: [
+ { projectId: p1, worktreePath: "/tmp/p1", role: "primary" },
+ { projectId: p2, worktreePath: "/tmp/p2", role: "additional" },
+ ],
+ })
+ expect(chat.stackId).toBe(stack.id)
+ expect(chat.stackBindings).toEqual([
+ { projectId: p1, worktreePath: "/tmp/p1", role: "primary" },
+ { projectId: p2, worktreePath: "/tmp/p2", role: "additional" },
+ ])
+ })
+
+ test("apply ignores stack fields when absent (legacy path)", async () => {
+ const { store, projectIds: [p1] } = await buildStoreWithProjects(["/tmp/p1"])
+ const chat = await store.createChat(p1)
+ expect(chat.stackId).toBeUndefined()
+ expect(chat.stackBindings).toBeUndefined()
+ })
+})
+```
+
+**Step 2: Run**
+
+```bash
+bun test src/server/event-store.stack-methods.test.ts -t "with stack fields"
+```
+
+Expected: FAIL — `createChat` signature does not yet accept options.
+
+**Step 3: Implement apply**
+
+In `event-store.ts`, the `chat_created` apply case (~line 527) currently writes `provider`, `planMode`, etc. Add a single block to copy the new optional fields if present:
+
+```ts
+case "chat_created": {
+ const chat = {
+ // existing field assembly (unchanged)
+ }
+ if (e.stackId !== undefined) chat.stackId = e.stackId
+ if (e.stackBindings !== undefined) chat.stackBindings = e.stackBindings.map((b) => ({ ...b }))
+ this.state.chatsById.set(chat.id, chat)
+ this.updateTiming(e.chatId, e.timestamp, "idle")
+ break
+}
+```
+
+(The `createChat` implementation is in Task 4. Tests still fail until then; commit is at the end of Task 4.)
+
+**Step 4: Do not commit yet** — the test still fails. Continue to Task 4.
+
+---
+
+## Task 4: Extend `createChat` to accept stack options (TDD)
+
+**Files:**
+- Modify: `src/server/event-store.ts` (`createChat`, ~line 982)
+
+**Step 1: Implementation**
+
+Replace the existing `createChat(projectId: string)` signature with:
+
+```ts
+async createChat(
+ projectId: string,
+ options?: { stackId?: string; stackBindings?: StackBinding[] },
+): Promise {
+ const project = this.state.projectsById.get(projectId)
+ if (!project || project.deletedAt) {
+ throw new Error("Project not found")
+ }
+
+ if (options?.stackId !== undefined || options?.stackBindings !== undefined) {
+ if (options.stackId === undefined || options.stackBindings === undefined) {
+ throw new Error("stackId and stackBindings must be provided together")
+ }
+ const stack = this.state.stacksById.get(options.stackId)
+ if (!stack || stack.deletedAt) throw new Error("Stack not found")
+ if (options.stackBindings.length === 0) throw new Error("stackBindings cannot be empty")
+ const primaries = options.stackBindings.filter((b) => b.role === "primary")
+ if (primaries.length !== 1) throw new Error("Exactly one primary binding required")
+ const seenProjects = new Set()
+ for (const binding of options.stackBindings) {
+ if (seenProjects.has(binding.projectId)) {
+ throw new Error("Duplicate projectId in stackBindings")
+ }
+ seenProjects.add(binding.projectId)
+ if (!stack.projectIds.includes(binding.projectId)) {
+ throw new Error(`Binding projectId not a member of stack: ${binding.projectId}`)
+ }
+ const peerProject = this.state.projectsById.get(binding.projectId)
+ if (!peerProject || peerProject.deletedAt) {
+ throw new Error(`Project not found: ${binding.projectId}`)
+ }
+ if (typeof binding.worktreePath !== "string" || binding.worktreePath.trim() === "") {
+ throw new Error("worktreePath must be a non-empty string")
+ }
+ }
+ if (primaries[0].projectId !== projectId) {
+ throw new Error("Primary binding projectId must match createChat projectId")
+ }
+ }
+
+ const chatId = crypto.randomUUID()
+ const event: ChatEvent = {
+ v: STORE_VERSION,
+ type: "chat_created",
+ timestamp: Date.now(),
+ chatId,
+ projectId,
+ title: "New Chat",
+ ...(options?.stackId !== undefined ? { stackId: options.stackId } : {}),
+ ...(options?.stackBindings !== undefined ? { stackBindings: options.stackBindings.map((b) => ({ ...b })) } : {}),
+ }
+ await this.append(this.chatsLogPath, event)
+ return this.state.chatsById.get(chatId)!
+}
+```
+
+Import `StackBinding` and `ChatRecord`:
+
+```ts
+import type { /* existing */ ChatRecord, StackBinding } from "../shared/types"
+```
+
+Note: `forkChat` (~line 1000) calls into `chat_created` separately. Do NOT pass stack options through forks in Phase 2; forks reset to a solo chat. Phase 3 may add fork-with-bindings later.
+
+**Step 2: Run the failing tests from Task 3**
+
+```bash
+bun test src/server/event-store.stack-methods.test.ts -t "with stack fields"
+```
+
+Expected: PASS (both tests).
+
+**Step 3: Add validation tests**
+
+Append to `event-store.stack-methods.test.ts`:
+
+```ts
+test("createChat rejects only one of stackId/stackBindings", async () => {
+ const { store, projectIds: [p1, p2] } = await buildStoreWithProjects(["/tmp/p1", "/tmp/p2"])
+ const stack = await store.createStack("X", [p1, p2])
+ await expect(store.createChat(p1, { stackId: stack.id })).rejects.toThrow(/together/u)
+})
+
+test("createChat rejects bindings with no primary", async () => {
+ const { store, projectIds: [p1, p2] } = await buildStoreWithProjects(["/tmp/p1", "/tmp/p2"])
+ const stack = await store.createStack("X", [p1, p2])
+ await expect(store.createChat(p1, {
+ stackId: stack.id,
+ stackBindings: [
+ { projectId: p1, worktreePath: "/tmp/p1", role: "additional" },
+ { projectId: p2, worktreePath: "/tmp/p2", role: "additional" },
+ ],
+ })).rejects.toThrow(/primary/u)
+})
+
+test("createChat rejects two primaries", async () => {
+ const { store, projectIds: [p1, p2] } = await buildStoreWithProjects(["/tmp/p1", "/tmp/p2"])
+ const stack = await store.createStack("X", [p1, p2])
+ await expect(store.createChat(p1, {
+ stackId: stack.id,
+ stackBindings: [
+ { projectId: p1, worktreePath: "/tmp/p1", role: "primary" },
+ { projectId: p2, worktreePath: "/tmp/p2", role: "primary" },
+ ],
+ })).rejects.toThrow(/Exactly one primary/u)
+})
+
+test("createChat rejects binding projectId outside the stack", async () => {
+ const { store, projectIds: [p1, p2] } = await buildStoreWithProjects(["/tmp/p1", "/tmp/p2", "/tmp/p3"])
+ const stack = await store.createStack("X", [p1, p2])
+ await expect(store.createChat(p1, {
+ stackId: stack.id,
+ stackBindings: [
+ { projectId: p1, worktreePath: "/tmp/p1", role: "primary" },
+ { projectId: store.listProjects()[2].id, worktreePath: "/tmp/p3", role: "additional" },
+ ],
+ })).rejects.toThrow(/not a member of stack/u)
+})
+
+test("createChat rejects primary projectId not equal to top-level projectId arg", async () => {
+ const { store, projectIds: [p1, p2] } = await buildStoreWithProjects(["/tmp/p1", "/tmp/p2"])
+ const stack = await store.createStack("X", [p1, p2])
+ await expect(store.createChat(p1, {
+ stackId: stack.id,
+ stackBindings: [
+ { projectId: p2, worktreePath: "/tmp/p2", role: "primary" },
+ { projectId: p1, worktreePath: "/tmp/p1", role: "additional" },
+ ],
+ })).rejects.toThrow(/Primary binding projectId/u)
+})
+
+test("createChat rejects empty worktreePath", async () => {
+ const { store, projectIds: [p1, p2] } = await buildStoreWithProjects(["/tmp/p1", "/tmp/p2"])
+ const stack = await store.createStack("X", [p1, p2])
+ await expect(store.createChat(p1, {
+ stackId: stack.id,
+ stackBindings: [
+ { projectId: p1, worktreePath: "", role: "primary" },
+ { projectId: p2, worktreePath: "/tmp/p2", role: "additional" },
+ ],
+ })).rejects.toThrow(/worktreePath/u)
+})
+```
+
+**Step 4: Run all stack-method tests**
+
+```bash
+bun test src/server/event-store.stack-methods.test.ts
+```
+
+Expected: all green. Existing replay determinism test still passes.
+
+**Step 5: Replay test for chat with stack bindings**
+
+Add one more test:
+
+```ts
+test("Replay preserves chat stackId and stackBindings", async () => {
+ const dir = await createTempDataDir()
+ const store1 = new EventStore(dir)
+ await store1.initialize()
+ const pa = await store1.openProject("/tmp/a", "A")
+ const pb = await store1.openProject("/tmp/b", "B")
+ const stack = await store1.createStack("X", [pa.id, pb.id])
+ const chat = await store1.createChat(pa.id, {
+ stackId: stack.id,
+ stackBindings: [
+ { projectId: pa.id, worktreePath: "/tmp/a", role: "primary" },
+ { projectId: pb.id, worktreePath: "/tmp/b", role: "additional" },
+ ],
+ })
+
+ const store2 = new EventStore(dir)
+ await store2.initialize()
+ const replayed = store2.getChat(chat.id)
+ expect(replayed?.stackId).toBe(stack.id)
+ expect(replayed?.stackBindings).toEqual(chat.stackBindings)
+})
+```
+
+If `EventStore` does not expose `getChat`, look at the existing test patterns for how chats are read back (search: `grep -n "getChat\|listChats" src/server/event-store.ts`). Use whichever public reader exists; if none, add a tiny `getChat(chatId: string): ChatRecord | null` reader as part of this commit.
+
+**Step 6: Run**
+
+```bash
+bun test src/server/event-store.stack-methods.test.ts
+```
+
+Expected: green.
+
+**Step 7: Commit (covers Tasks 3 + 4)**
+
+```bash
+git add src/server/event-store.ts src/server/event-store.stack-methods.test.ts
+git commit -m "feat(stacks): bind chat creation to a stack with worktreePath bindings"
+```
+
+---
+
+## Task 5: Extend `chat.create` WS command (TDD)
+
+**Files:**
+- Modify: `src/shared/protocol.ts`
+- Modify: `src/server/ws-router.ts`
+- Modify: `src/server/ws-router.stack.test.ts`
+
+**Step 1: Protocol**
+
+Find `chat.create` in `ClientCommand` union (~line 113 of `protocol.ts`). Replace:
+
+```ts
+| { type: "chat.create"; projectId: string }
+```
+
+with:
+
+```ts
+| {
+ type: "chat.create"
+ projectId: string
+ stackId?: string
+ stackBindings?: Array<{ projectId: string; worktreePath: string; role: "primary" | "additional" }>
+ }
+```
+
+**Step 2: Failing test**
+
+Append to `ws-router.stack.test.ts`:
+
+```ts
+test("chat.create with stack args persists bindings on the chat", async () => {
+ // build EventStore + 2 projects + stack
+ // send chat.create with stackId + bindings
+ // assert ack returns chatId and store.getChat(chatId).stackBindings matches
+})
+
+test("chat.create rejects bindings violating invariants (e.g. no primary)", async () => {
+ // expect error ack
+})
+```
+
+Use the same EventStore-backed `createWsRouter` harness as the existing `ws-router.stack.test.ts`.
+
+**Step 3: Wire the handler**
+
+In `ws-router.ts`, find the existing `case "chat.create"` (~line 1366). Change:
+
+```ts
+case "chat.create": {
+ const chat = await store.createChat(command.projectId)
+ ...
+}
+```
+
+to:
+
+```ts
+case "chat.create": {
+ const chat = await store.createChat(command.projectId, {
+ stackId: command.stackId,
+ stackBindings: command.stackBindings,
+ })
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id, result: { chatId: chat.id } })
+ resolvedAnalytics.track("chat_created")
+ await broadcastChatAndSidebar(chat.id)
+ return
+}
+```
+
+The `createChat` validation does the heavy lifting; the router only forwards.
+
+**Step 4: Run**
+
+```bash
+bun test src/server/ws-router.stack.test.ts src/server/ws-router.test.ts
+```
+
+Expected: all green. Existing `chat.create` callers without stack args still work because both fields are optional.
+
+**Step 5: Commit**
+
+```bash
+git add src/shared/protocol.ts src/server/ws-router.ts src/server/ws-router.stack.test.ts
+git commit -m "feat(stacks): accept stack args on chat.create WS command"
+```
+
+---
+
+## Task 6: Agent spawn — Claude `additionalDirectories`
+
+**Files:**
+- Modify: `src/server/agent.ts`
+
+**Step 1: Locate the Claude spawn site**
+
+The SDK `query(...)` call lives at `agent.ts:659–684`. The current `cwd: args.localPath` line is at 662.
+
+Trace `args.localPath`. The `startClaudeSession` signature lives at `agent.ts:121–130`. It passes `localPath: string` (the project root). For stack chats, the primary's `worktreePath` should be used as `cwd`, and peer paths should be passed as `additionalDirectories`.
+
+**Step 2: Extend the spawn args**
+
+Update the `startClaudeSession` arg interface (around line 121):
+
+```ts
+startClaudeSession?: (args: {
+ projectId: string
+ localPath: string
+ model: string
+ effort?: string
+ planMode: boolean
+ sessionToken: string | null
+ forkSession: boolean
+ additionalDirectories?: string[] // NEW
+ onToolRequest: (request: HarnessToolRequest) => Promise
+}) => Promise
+```
+
+Update the `query({ options: { ... } })` block (lines 661–684) to thread through `additionalDirectories` when present:
+
+```ts
+options: {
+ cwd: args.localPath,
+ ...(args.additionalDirectories && args.additionalDirectories.length > 0
+ ? { additionalDirectories: args.additionalDirectories }
+ : {}),
+ // existing fields...
+}
+```
+
+Verify the option name against the Claude Agent SDK docs (verified in design doc — `additionalDirectories: string[]`, default `[]`).
+
+**Step 3: Map chat bindings → spawn args**
+
+Find every call site that builds the `startClaudeSession` args (search: `grep -n "startClaudeSession\b" src/server/agent.ts`). At each call, when `chat.stackBindings` is present:
+
+1. Find the binding with `role === "primary"` — use its `worktreePath` as `localPath` (the SDK `cwd`).
+2. Map all `role === "additional"` bindings to `additionalDirectories`.
+
+If `chat.stackBindings` is absent, behavior is unchanged: `localPath = project.localPath`, no `additionalDirectories`.
+
+**Step 4: Map for Codex**
+
+Codex App Server protocol has no `additionalDirectories`. For Codex stack chats:
+
+- Set `cwd` to the primary's `worktreePath` (same as Claude).
+- Do NOT pass anything for peer paths. Cross-root writes will trigger the existing `grantRoot` approval surface per file.
+
+The Codex spawn site is at `agent.ts:1190` (`this.codexManager.startSession({ cwd: project.localPath, ... })`). Replace `project.localPath` with the resolved primary path (same helper used above).
+
+**Step 5: Helper extraction**
+
+The primary-resolution logic is needed in both Claude and Codex sites. Extract:
+
+```ts
+function resolveSpawnPaths(chat: ChatRecord, fallbackLocalPath: string): { cwd: string; additionalDirectories: string[] } {
+ if (!chat.stackBindings || chat.stackBindings.length === 0) {
+ return { cwd: fallbackLocalPath, additionalDirectories: [] }
+ }
+ const primary = chat.stackBindings.find((b) => b.role === "primary")
+ if (!primary) {
+ throw new Error(`Chat ${chat.id} has stackBindings but no primary`)
+ }
+ const additionalDirectories = chat.stackBindings
+ .filter((b) => b.role === "additional")
+ .map((b) => b.worktreePath)
+ return { cwd: primary.worktreePath, additionalDirectories }
+}
+```
+
+Place near the top of `agent.ts` after the imports. Use it at both spawn sites.
+
+**Step 6: Tests**
+
+Add `src/server/agent.stack-spawn.test.ts`:
+
+```ts
+import { describe, expect, test } from "bun:test"
+import { resolveSpawnPaths } from "./agent" // export the helper
+
+describe("resolveSpawnPaths", () => {
+ test("solo chat returns fallback cwd, no additionalDirectories", () => {
+ const result = resolveSpawnPaths({ id: "c1", stackBindings: undefined } as any, "/proj")
+ expect(result).toEqual({ cwd: "/proj", additionalDirectories: [] })
+ })
+
+ test("stack chat returns primary path as cwd and peer paths as additionalDirectories", () => {
+ const result = resolveSpawnPaths(
+ { id: "c1", stackBindings: [
+ { projectId: "p1", worktreePath: "/be", role: "primary" },
+ { projectId: "p2", worktreePath: "/fe", role: "additional" },
+ ] } as any,
+ "/fallback",
+ )
+ expect(result).toEqual({ cwd: "/be", additionalDirectories: ["/fe"] })
+ })
+
+ test("missing primary throws", () => {
+ expect(() => resolveSpawnPaths(
+ { id: "c1", stackBindings: [
+ { projectId: "p1", worktreePath: "/be", role: "additional" },
+ ] } as any,
+ "/fallback",
+ )).toThrow(/no primary/u)
+ })
+})
+```
+
+If integration-level tests of `agent.ts` already exist (search: `ls src/server/agent.test.ts`), add one end-to-end test that constructs an `AgentCoordinator` with a `startClaudeSession` stub and asserts the stub is called with the expected `additionalDirectories`. Stub shape: `vi.fn() / mock()` per Bun test conventions.
+
+**Step 7: Run**
+
+```bash
+bun test src/server/agent.stack-spawn.test.ts src/server/agent.test.ts
+```
+
+Expected: green. No new tsc errors.
+
+**Step 8: Commit**
+
+```bash
+git add src/server/agent.ts src/server/agent.stack-spawn.test.ts
+git commit -m "feat(stacks): map stack bindings to spawn cwd + additionalDirectories"
+```
+
+---
+
+## Task 7: Read-model `resolvedBindings` on chat snapshot
+
+**Files:**
+- Modify: `src/shared/types.ts` (extend `ChatSnapshot`)
+- Modify: `src/server/read-models.ts` (`deriveChatSnapshot`, ~line 246)
+- Modify: `src/server/read-models.test.ts`
+
+**Step 1: Extend `ChatSnapshot`**
+
+Find `ChatSnapshot` in `src/shared/types.ts` (~line 1207). Add:
+
+```ts
+export interface ChatSnapshot {
+ // existing fields...
+ resolvedBindings?: Array<{
+ projectId: string
+ projectTitle: string
+ worktreePath: string
+ role: "primary" | "additional"
+ projectStatus: "active" | "missing"
+ }>
+}
+```
+
+`projectStatus` is `"missing"` when the bound `projectId` has been removed; this is the Phase 1 design's orphan signal. Worktree branch and dirty status are deferred to Phase 3 (UI fetches via `worktree-store` on demand).
+
+**Step 2: Failing test**
+
+Add to `read-models.test.ts`:
+
+```ts
+test("chat snapshot includes resolvedBindings when chat has stackBindings", () => {
+ const state = createEmptyState()
+ state.projectsById.set("p1", { id: "p1", localPath: "/p1", title: "Backend", createdAt: 1, updatedAt: 1 })
+ state.projectsById.set("p2", { id: "p2", localPath: "/p2", title: "Frontend", createdAt: 1, updatedAt: 1 })
+ state.chatsById.set("c1", {
+ id: "c1",
+ projectId: "p1",
+ title: "Integration",
+ createdAt: 1,
+ updatedAt: 1,
+ unread: false,
+ provider: "claude",
+ planMode: false,
+ sessionToken: null,
+ sourceHash: null,
+ lastTurnOutcome: null,
+ stackId: "s1",
+ stackBindings: [
+ { projectId: "p1", worktreePath: "/p1", role: "primary" },
+ { projectId: "p2", worktreePath: "/p2", role: "additional" },
+ ],
+ })
+ const snapshot = deriveChatSnapshot(state, "c1", /* other args matching existing signature */)
+ expect(snapshot?.resolvedBindings).toEqual([
+ { projectId: "p1", projectTitle: "Backend", worktreePath: "/p1", role: "primary", projectStatus: "active" },
+ { projectId: "p2", projectTitle: "Frontend", worktreePath: "/p2", role: "additional", projectStatus: "active" },
+ ])
+})
+
+test("chat snapshot marks missing projects as projectStatus: missing", () => {
+ // same setup but p2 has deletedAt set
+ // expect that binding's projectStatus === "missing", projectTitle still surfaces the original title
+})
+
+test("chat snapshot omits resolvedBindings when stackBindings is undefined", () => {
+ // pure solo chat — assert snapshot.resolvedBindings is undefined
+})
+```
+
+Match the existing `deriveChatSnapshot` signature exactly — its current arg list is wider than just `state` and `chatId`. Read its definition first: `sed -n '246,290p' src/server/read-models.ts`.
+
+**Step 3: Run**
+
+```bash
+bun test src/server/read-models.test.ts -t resolvedBindings
+```
+
+Expected: FAIL.
+
+**Step 4: Implement in `deriveChatSnapshot`**
+
+Inside the function, after the existing snapshot object is built and before it is returned, add:
+
+```ts
+if (chat.stackBindings && chat.stackBindings.length > 0) {
+ snapshot.resolvedBindings = chat.stackBindings.map((binding) => {
+ const project = state.projectsById.get(binding.projectId)
+ const projectStatus: "active" | "missing" = project && !project.deletedAt ? "active" : "missing"
+ return {
+ projectId: binding.projectId,
+ projectTitle: project?.title ?? "(missing)",
+ worktreePath: binding.worktreePath,
+ role: binding.role,
+ projectStatus,
+ }
+ })
+}
+```
+
+Adjust to the actual variable name `deriveChatSnapshot` uses for the snapshot under construction.
+
+**Step 5: Run**
+
+```bash
+bun test src/server/read-models.test.ts
+```
+
+Expected: all green.
+
+**Step 6: Commit**
+
+```bash
+git add src/shared/types.ts src/server/read-models.ts src/server/read-models.test.ts
+git commit -m "feat(stacks): expose resolvedBindings on chat snapshot"
+```
+
+---
+
+## Task 8: Update parent design doc
+
+**Files:**
+- Modify: `docs/plans/2026-05-11-stack-multi-repo-design.md`
+
+Replace `worktreeId` with `worktreePath` in the StackBinding shape and adjacent text. Note in a small "Phase 2 amendments" section near the bottom:
+
+> Phase 2 bound stacks by `worktreePath` rather than `worktreeId` because worktree state is not yet in the event store. When the `feat/worktree-events` work lands, a follow-up migration can resolve paths to ids.
+
+Single edit, no code. Commit:
+
+```bash
+git add docs/plans/2026-05-11-stack-multi-repo-design.md
+git commit -m "docs(stacks): bind by worktreePath in Phase 2 (worktree-events deferred)"
+```
+
+---
+
+## Task 9: Full-suite verification
+
+```bash
+bun test --timeout 30000
+bun x tsc --noEmit 2>&1 | grep -v sonner | head
+```
+
+Expected:
+- `bun test --timeout 30000`: 1207 (Phase 1 baseline) + N new tests from Tasks 3–7 all green; zero fail.
+- `bun x tsc --noEmit`: only the 3 pre-existing `sonner` errors. Any other error blocks the PR — stop and ask.
+
+If `bun test` is flaky on uploads/diff-store (known timeout flakes from Phase 1), the `--timeout 30000` flag matches CI and should resolve them.
+
+---
+
+## Task 10: Push + PR
+
+```bash
+git push -u origin feat/stack-phase2
+gh pr create --repo cuongtranba/kanna --base feat/stack-phase1 --head feat/stack-phase2 \
+ --title "feat(stacks): Phase 2 — chat bindings + agent spawn wiring" \
+ --body "$(cat <<'EOF'
+## Summary
+- Extends \`chat_created\` event and \`ChatRecord\` with optional \`stackId\` and \`stackBindings\` (\`{ projectId, worktreePath, role }[]\`).
+- \`createChat(projectId, { stackId, stackBindings })\` validates invariants (one primary, member-of-stack, primary projectId matches, non-empty paths).
+- \`chat.create\` WS command accepts stack args symmetrically.
+- Agent spawn maps the primary binding to SDK \`cwd\` and peer bindings to Claude SDK \`additionalDirectories\`. Codex falls back to single \`cwd\` + per-write \`grantRoot\` approvals (protocol has no peer-roots field).
+- \`deriveChatSnapshot\` emits \`resolvedBindings\` with project title and active/missing status. Worktree branch + dirty status deferred to Phase 3 (UI fetches via worktree-store).
+
+## Binding key
+Bindings reference worktrees by absolute \`worktreePath\` rather than a \`worktreeId\` because worktree state is not yet in the event store. The \`feat/worktree-events\` branch (currently plan-only) would add it; once shipped, a follow-up migration can swap paths for ids.
+
+## Test plan
+- [x] \`bun test --timeout 30000\` green.
+- [x] \`bun x tsc --noEmit\` only the 3 pre-existing sonner errors.
+- [x] New tests: createChat validation, chat_created replay with bindings, resolveSpawnPaths helper, chat snapshot resolvedBindings, ws-router chat.create with stack args.
+- [ ] Manual: send a chat.create over WS with bindings, confirm Claude session receives \`additionalDirectories\`.
+
+## Out of scope (Phase 3)
+- All client UI (StacksSection, inline creation panel, peer strip).
+- Keybindings.
+- Re-binding peers on live chat.
+EOF
+)"
+```
+
+**Base branch is `feat/stack-phase1`**, not `main`, because Phase 2 depends on Phase 1 code. Once Phase 1 (#48) merges into main, rebase or change the base to main.
+
+---
+
+## Done-when checklist
+
+- [ ] All 8 commits landed in order.
+- [ ] `bun test --timeout 30000` green.
+- [ ] PR open against `feat/stack-phase1` (or `main` once Phase 1 merges).
+- [ ] Design doc updated to say `worktreePath`.
+- [ ] Phase 3 plan not yet written — separate session.
+
+---
+
+## Notes for the executor
+
+- **One commit per task** (Tasks 3+4 share a commit by design — the apply-side and the create-side are co-dependent).
+- **Strong typing** (from global CLAUDE.md): no `any`, no `unknown` without narrowing. Test fixtures may use `as any` to short-circuit `ChatRecord` construction; that is acceptable in tests only.
+- **Subprocess hygiene** (from project CLAUDE.md): no new git spawns in Phase 2. If a test does spawn, set `stdin: "ignore"`, `GIT_TERMINAL_PROMPT=0`, explicit `30_000` timeout.
+- **Pre-existing failures**: `uploads`, `diff-store` tests fail under concurrent load at the Bun 5s default. Use `--timeout 30000` to match CI. If a new failure appears in stack tests, stop and ask.
+- **Codex semantics**: do not invent a peer-root field for Codex App Server. The protocol does not have one. Document this in the PR body so reviewers see the design choice.
+- **agent.ts is the riskiest file in the diff.** Read the existing spawn flow end-to-end before editing. The `additionalDirectories` thread-through should be the smallest possible change.
diff --git a/docs/plans/2026-05-11-stack-phase3-plan.md b/docs/plans/2026-05-11-stack-phase3-plan.md
new file mode 100644
index 000000000..c078d0543
--- /dev/null
+++ b/docs/plans/2026-05-11-stack-phase3-plan.md
@@ -0,0 +1,547 @@
+# Stack Phase 3 Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** Ship the UI surface for the Stack feature. Stacks become visible and manageable from the sidebar; chats can be created inside stacks with a per-project worktree picker; the chat header shows a persistent peer strip listing each bound worktree. Keyboard-first. Mobile parity. No new server behavior — Phase 1 + 2 already cover everything the UI calls.
+
+**Architecture:** A new `stacks` array is added to the existing `SidebarData` snapshot (derived from the existing `stackSummaries` selector). `KannaSidebar.tsx` mounts a new `StacksSection` directly above `LocalProjectsSection`. Stack creation, rename, member-edit, and delete all happen via inline panels (no modals — PRODUCT.md rule). Stack-bound chat creation uses an inline table panel anchored to the stack row, with a per-project worktree dropdown and a primary radio. `PeerWorktreeStrip` is a small Mono-scale component appended to `ChatNavbar`; it renders from the existing `ChatSnapshot.resolvedBindings` field. Keybindings extend `keybindings.ts`. All visual tokens come from existing DESIGN.md.
+
+**Tech Stack:** React 18 + TypeScript + Tailwind under Vite. Tests via `bun test` (DOM tests use the existing test setup; see `LocalProjectsSection.test.tsx` for the canonical pattern). WebSocket commands already shipped in Phase 1/2; client only needs to send them.
+
+**Source spec:** `docs/plans/2026-05-11-stack-multi-repo-design.md` Section 3 (Client UI), revised. Phase 1+2 PRs #48, #50 merged into main.
+
+**Pre-flight:**
+
+```bash
+git rev-parse --abbrev-ref HEAD # → feat/stack-phase3
+git log -1 --oneline # 2295fc8 Phase 2 merge
+bun test --timeout 30000 # baseline 1224 pass / 0 fail
+```
+
+If anything is red, stop and ask.
+
+**Out of scope (deferred):**
+
+- Re-binding peer worktrees on a live chat (`chat_binding_changed`).
+- Worktree branch + dirty enrichment on the peer strip (UI fetches via worktree-store on demand; out for now).
+- Drag-and-drop reordering of stacks or stack members.
+- Migration UX prompting users to convert two solo chats into a stack chat.
+- Codex per-chat `codex: cwd-only` indicator copy refinement — ship a plain Mono label; iterate later.
+
+---
+
+## Task 1: Add `stacks` to `SidebarData` snapshot
+
+**Files:**
+- Modify: `src/shared/types.ts`
+- Modify: `src/server/read-models.ts`
+- Modify: `src/server/read-models.test.ts`
+
+**Step 1: Extend `SidebarData`**
+
+```ts
+export interface SidebarData {
+ projectGroups: SidebarProjectGroup[]
+ stacks: StackSummary[]
+}
+```
+
+`stacks` is always present (empty array when no stacks exist) — keeps client narrowing simple.
+
+**Step 2: Populate in `deriveSidebarData`**
+
+Add a single line in the `return { ... }` block:
+
+```ts
+return {
+ projectGroups,
+ stacks: stackSummaries(state),
+}
+```
+
+Reuse the existing `stackSummaries` selector. No new logic.
+
+**Step 3: Test**
+
+Add a test in `read-models.test.ts`:
+
+```ts
+test("deriveSidebarData includes stack summaries", () => {
+ const state = createEmptyState()
+ state.stacksById.set("s1", {
+ id: "s1",
+ title: "Integration",
+ projectIds: ["p1", "p2"],
+ createdAt: 1,
+ updatedAt: 1,
+ })
+ const sidebar = deriveSidebarData(state, new Map())
+ expect(sidebar.stacks).toHaveLength(1)
+ expect(sidebar.stacks[0]?.title).toBe("Integration")
+})
+```
+
+Run `bun test src/server/read-models.test.ts`. Expect green.
+
+**Step 4: Verify ws-router broadcast surface**
+
+`ws-router.ts` already serializes `SidebarData` through `broadcastFilteredSnapshots`. No change.
+
+**Step 5: Commit**
+
+```bash
+git add src/shared/types.ts src/server/read-models.ts src/server/read-models.test.ts
+git commit -m "feat(stacks): include stack summaries in SidebarData snapshot"
+```
+
+---
+
+## Task 2: Surface `stacks` and stack commands in `useKannaState`
+
+**Files:**
+- Modify: `src/client/app/useKannaState.ts`
+
+**Step 1: Read the hook**
+
+It's 2200 lines. Find the public return object (search: `return {` near the end, ~line 2144) and the sidebar plumbing (search: `data.projectGroups`).
+
+**Step 2: Add stacks to the surface**
+
+Wherever the hook returns or memoizes `data.projectGroups`, also surface `data.stacks` (default to `[]` when snapshot absent). Add:
+
+```ts
+const stacks = data.stacks ?? []
+```
+
+Return `stacks` from the hook.
+
+**Step 3: Add stack command helpers**
+
+Following the existing pattern of WS command helpers in the file (search: `sendCommand({ type: "chat.create"` for the template), add:
+
+```ts
+const createStack = useCallback(async (title: string, projectIds: string[]) => {
+ return sendCommand({ type: "stack.create", title, projectIds })
+}, [sendCommand])
+
+const renameStack = useCallback(async (stackId: string, title: string) => {
+ return sendCommand({ type: "stack.rename", stackId, title })
+}, [sendCommand])
+
+const removeStack = useCallback(async (stackId: string) => {
+ return sendCommand({ type: "stack.remove", stackId })
+}, [sendCommand])
+
+const addProjectToStack = useCallback(async (stackId: string, projectId: string) => {
+ return sendCommand({ type: "stack.addProject", stackId, projectId })
+}, [sendCommand])
+
+const removeProjectFromStack = useCallback(async (stackId: string, projectId: string) => {
+ return sendCommand({ type: "stack.removeProject", stackId, projectId })
+}, [sendCommand])
+
+const createStackChat = useCallback(async (
+ primaryProjectId: string,
+ stackId: string,
+ stackBindings: Array<{ projectId: string; worktreePath: string; role: "primary" | "additional" }>,
+) => {
+ return sendCommand({ type: "chat.create", projectId: primaryProjectId, stackId, stackBindings })
+}, [sendCommand])
+```
+
+Adjust to the actual signature `sendCommand` uses (look at `createChat` neighbor for the exact shape — the helper may return `chatId` from the ack).
+
+Return all six from the hook.
+
+**Step 4: Typecheck**
+
+```bash
+bun x tsc --noEmit 2>&1 | grep -v sonner | head
+```
+
+Expected: clean.
+
+**Step 5: Commit**
+
+```bash
+git add src/client/app/useKannaState.ts
+git commit -m "feat(stacks): surface stacks + stack command helpers in useKannaState"
+```
+
+---
+
+## Task 3: `StacksSection` sidebar component (TDD)
+
+**Files:**
+- Create: `src/client/components/chat-ui/sidebar/StacksSection.tsx`
+- Create: `src/client/components/chat-ui/sidebar/StacksSection.test.tsx`
+
+**Step 1: Failing test**
+
+Mirror the test pattern from `LocalProjectsSection.test.tsx` exactly (imports, render harness, RTL queries, `expect(screen.getByText(...))`).
+
+Tests:
+
+1. `renders empty state copy when stacks list is empty`.
+2. `renders one row per stack with title and member-count badge`.
+3. `expanding a stack row reveals its member project names inline (no tooltip)`.
+4. `keyboard navigation: focus first stack row with tab; press Enter to expand`.
+5. `+ Stack button is keyboard reachable`.
+6. `disabled state when fewer than 2 projects exist with copy "Register a second project to create a stack"`.
+
+Run: `bun test src/client/components/chat-ui/sidebar/StacksSection.test.tsx`. Expect FAIL.
+
+**Step 2: Component shape**
+
+```tsx
+interface StacksSectionProps {
+ stacks: StackSummary[]
+ projects: Array<{ id: string; title: string }> // for member-name reveal + disabled gate
+ expandedStackIds: Set
+ onToggleExpanded: (stackId: string) => void
+ onOpenCreatePanel: () => void // toggles the inline create panel (Task 4)
+ onOpenStackMenu: (stackId: string) => void // rename/remove projects/delete (Task 5)
+ chats: SidebarChatRow[] // for rendering nested chat rows under expanded stack
+}
+```
+
+Tokens — DESIGN.md:
+- Section header: Title scale, 600 weight, sentence case "Stacks". `+` button right-aligned, ghost button shape.
+- Row: Title-scale title + Mono `tabular-nums` member-count badge in Margin Gray. Hover → Surface Secondary background. Focus ring per DESIGN.md.
+- No left-border stripe. No icon prefix. No glyph chips. Inline member-name reveal under the row when expanded (Body scale, Margin Gray).
+- Status indicators reuse the existing `ChatRow` for nested chats.
+
+**Step 3: Commit**
+
+```bash
+git add src/client/components/chat-ui/sidebar/StacksSection.tsx \
+ src/client/components/chat-ui/sidebar/StacksSection.test.tsx
+git commit -m "feat(stacks): StacksSection sidebar component (calm, keyboard-first)"
+```
+
+---
+
+## Task 4: Inline stack create + edit panel (TDD)
+
+**Files:**
+- Create: `src/client/components/chat-ui/sidebar/StackCreatePanel.tsx`
+- Create: `src/client/components/chat-ui/sidebar/StackCreatePanel.test.tsx`
+
+**Step 1: Tests**
+
+1. `renders title input, multi-select chip list of projects, Save and Cancel`.
+2. `Save is disabled when title empty or fewer than 2 projects selected`.
+3. `Enter submits the form; Escape cancels`.
+4. `populating projectIds + title and submitting calls onCreate with the right args`.
+5. `edit mode prefills the title and selected chips`.
+6. `single-project user sees the disabled banner "Register a second project to create a stack"`.
+
+**Step 2: Component shape**
+
+```tsx
+interface StackCreatePanelProps {
+ mode: "create" | "edit"
+ initialTitle?: string
+ initialProjectIds?: string[]
+ projects: Array<{ id: string; title: string }>
+ onSubmit: (title: string, projectIds: string[]) => Promise
+ onCancel: () => void
+}
+```
+
+Inline panel (not a modal). Rendered conditionally inside `StacksSection`. Title input above, project chip list below, action row at bottom. Tab order: title → chips (arrow keys for chip toggle) → Save → Cancel. Cmd+Enter submits when chip list has focus too.
+
+**Step 3: Commit**
+
+```bash
+git add src/client/components/chat-ui/sidebar/StackCreatePanel.tsx \
+ src/client/components/chat-ui/sidebar/StackCreatePanel.test.tsx
+git commit -m "feat(stacks): inline stack create/edit panel"
+```
+
+---
+
+## Task 5: Stack action menu (rename, edit projects, delete)
+
+**Files:**
+- Modify: `src/client/components/chat-ui/sidebar/Menus.tsx` (reuse the existing menu shell)
+
+**Step 1: Test**
+
+Existing `Menus.tsx` tests if any — extend or add a `Menus.stack.test.tsx`. Cover:
+- Menu items: Rename, Add projects, Remove projects, Delete.
+- Delete confirms inline; never modal-on-modal.
+- Each action is keyboard reachable from the stack row's `enter` press.
+
+**Step 2: Wire actions**
+
+Each action calls the `useKannaState` helpers added in Task 2. Rename + Add/Remove projects re-open the inline create panel (Task 4) in edit mode. Delete shows inline `"Delete ?"` confirm — destructive button uses DESIGN.md `button-destructive` token.
+
+**Step 3: Commit**
+
+```bash
+git add src/client/components/chat-ui/sidebar/Menus.tsx \
+ src/client/components/chat-ui/sidebar/Menus.stack.test.tsx
+git commit -m "feat(stacks): stack action menu (rename, edit members, delete)"
+```
+
+---
+
+## Task 6: Stack chat creation inline row (TDD)
+
+**Files:**
+- Create: `src/client/components/chat-ui/sidebar/StackChatCreateRow.tsx`
+- Create: `src/client/components/chat-ui/sidebar/StackChatCreateRow.test.tsx`
+
+**Step 1: Tests**
+
+1. `renders one row per stack member with project title, worktree dropdown, primary radio`.
+2. `worktree dropdown defaults to the project's primary worktree`.
+3. `primary radio defaults to the first row`.
+4. `Cmd+Enter submits; Esc collapses`.
+5. `Submit calls createStackChat with { primaryProjectId, stackId, bindings[] }`.
+6. `mobile (<640px viewport) renders the panel as a bottom sheet`.
+
+**Step 2: Component shape**
+
+```tsx
+interface StackChatCreateRowProps {
+ stack: StackSummary
+ projects: Array<{ id: string; title: string; worktrees: WorktreeSummary[] }>
+ onCreate: (args: {
+ primaryProjectId: string
+ stackBindings: Array<{ projectId: string; worktreePath: string; role: "primary" | "additional" }>
+ }) => Promise
+ onCancel: () => void
+}
+```
+
+Need to thread `WorktreeSummary[]` from somewhere. Phase 2 didn't expose worktrees in `SidebarData`. **Add to `SidebarProjectGroup`** a new field:
+
+```ts
+worktrees?: Array<{ path: string; branch: string; isPrimary: boolean }>
+```
+
+Server-side: extend `deriveSidebarData` to call `listWorktrees(project.localPath)` per project. This is an async git call — defer until requested via a dedicated WS subscription instead of blocking the sidebar derive. **Simpler approach: client requests worktrees per project on demand** when the chat-create row opens. Use a new WS command `stack.listWorktrees { projectId }` that returns `WorktreeSummary[]`.
+
+> **Sub-task 6a:** add `stack.listWorktrees` WS command (one round-trip, returns the list). Server uses `listWorktrees(project.localPath)` from `worktree-store.ts`. Phase 2 plan does NOT call this; add it now.
+
+**Step 3: Commit**
+
+Two commits:
+
+```bash
+git add src/shared/protocol.ts src/server/ws-router.ts src/server/ws-router.stack.test.ts
+git commit -m "feat(stacks): stack.listWorktrees WS command for per-project worktree picker"
+
+git add src/client/components/chat-ui/sidebar/StackChatCreateRow.tsx \
+ src/client/components/chat-ui/sidebar/StackChatCreateRow.test.tsx \
+ src/client/app/useKannaState.ts
+git commit -m "feat(stacks): inline stack chat creation row with per-project worktree picker"
+```
+
+---
+
+## Task 7: `PeerWorktreeStrip` on chat header (TDD)
+
+**Files:**
+- Create: `src/client/components/chat-ui/PeerWorktreeStrip.tsx`
+- Create: `src/client/components/chat-ui/PeerWorktreeStrip.test.tsx`
+- Modify: `src/client/components/chat-ui/ChatNavbar.tsx`
+
+**Step 1: Tests**
+
+1. `renders nothing when resolvedBindings is undefined or has <=1 entry`.
+2. `renders mono labels per binding with project@branch format (use worktreePath basename until branch is wired)`.
+3. `primary binding shows a filled status dot`.
+4. `peers with projectStatus: "missing" render greyed with a strike`.
+5. `clicking a peer label opens an action menu (Open in Finder via external-open)`.
+6. `Codex provider chat shows the inline "codex: cwd-only" label at the end`.
+
+**Step 2: Shape**
+
+```tsx
+interface PeerWorktreeStripProps {
+ bindings: ResolvedStackBinding[]
+ provider: AgentProvider | null
+ onOpenPath: (path: string) => void
+}
+```
+
+DESIGN.md tokens:
+- Mono scale, tabular-nums, single line below the chat title.
+- Primary dot: Verified Sage (filled). Peers: Margin Gray (open circle).
+- Missing peers: Margin Gray + line-through.
+- No new color tokens; no glow; no pulse.
+- Codex indicator: plain Mono label "codex: cwd-only" with no icon.
+
+**Step 3: Mount in `ChatNavbar`**
+
+Insert the strip directly under the chat title. Pass `resolvedBindings` from the chat snapshot.
+
+**Step 4: Commit**
+
+```bash
+git add src/client/components/chat-ui/PeerWorktreeStrip.tsx \
+ src/client/components/chat-ui/PeerWorktreeStrip.test.tsx \
+ src/client/components/chat-ui/ChatNavbar.tsx
+git commit -m "feat(stacks): PeerWorktreeStrip on chat header"
+```
+
+---
+
+## Task 8: Sidebar mount + keybindings
+
+**Files:**
+- Modify: `src/client/app/KannaSidebar.tsx`
+- Modify: `src/server/keybindings.ts`
+- Modify: `src/server/keybindings.test.ts`
+
+**Step 1: Mount `StacksSection`**
+
+Above `LocalProjectsSection` in `KannaSidebar.tsx`. Pass `stacks`, `projects`, expanded state, and the stack handlers from `useKannaState`.
+
+**Step 2: Keybindings**
+
+Add three new bindings to `keybindings.ts`:
+
+```ts
+newStack: ["cmd+alt+w"]
+newStackChat: ["cmd+alt+shift+n"]
+jumpToStacks: ["g s"]
+```
+
+Wire `useKannaState` handlers to the binding events.
+
+**Step 3: Tests**
+
+- `keybindings.test.ts`: defaults include the three new actions.
+- `KannaSidebar.test.tsx` (extend existing): pressing the keybinding focuses/opens the right surface.
+
+**Step 4: Commit**
+
+```bash
+git add src/client/app/KannaSidebar.tsx src/server/keybindings.ts src/server/keybindings.test.ts
+git commit -m "feat(stacks): mount StacksSection and wire keybindings (cmd+alt+w / cmd+alt+shift+n / g s)"
+```
+
+---
+
+## Task 9: Empty states + Codex `codex: cwd-only` polish
+
+**Files:**
+- Modify: any of the new components for empty-state copy.
+- Modify: `PeerWorktreeStrip.tsx` (Codex label).
+
+Use the copy from the design doc verbatim:
+
+- `StacksSection` empty: *"A stack groups projects so one chat can read and write across them. Add your first stack."*
+- `StackCreatePanel` single-project disabled: *"Register a second project to create a stack"*
+- Codex peer-strip label: `codex: cwd-only`
+
+**Commit:**
+
+```bash
+git add src/client/components/chat-ui/sidebar/StacksSection.tsx \
+ src/client/components/chat-ui/sidebar/StackCreatePanel.tsx \
+ src/client/components/chat-ui/PeerWorktreeStrip.tsx
+git commit -m "feat(stacks): editorial empty-state copy + Codex cwd-only indicator"
+```
+
+---
+
+## Task 10: Mobile parity
+
+**Files:**
+- Modify: each create panel + peer strip to switch to mobile shape at `< 640px`.
+
+Use the existing breakpoint hook (search: `useMediaQuery` or `useIsMobile` in the client). The inline panels collapse to bottom sheets on mobile. Peer strip wraps to two lines instead of overflowing.
+
+**Commit:**
+
+```bash
+git add src/client/components/chat-ui/...
+git commit -m "feat(stacks): mobile bottom-sheet variants for stack panels"
+```
+
+---
+
+## Task 11: Accessibility audit + WCAG check
+
+Manual checklist before push:
+
+- All actions reachable from keyboard.
+- Visible focus ring on every new interactive element.
+- Color is never the only signal: peer primary = dot + Sage; missing = strike + Margin Gray.
+- Tabular-nums on the member-count badge.
+- `prefers-reduced-motion`: any panel expand animation disabled.
+- Contrast meets ≥ 4.5:1 on every new label against its surface.
+
+Run the `skill-stack:wcag-verify` skill on the changed files if available. Fix anything it flags.
+
+No commit — quality gate only.
+
+---
+
+## Task 12: Full-suite verification + push
+
+```bash
+bun test --timeout 30000
+bun x tsc --noEmit 2>&1 | grep -v sonner | head
+bun run build # vite build must pass (CI runs this)
+```
+
+Then push and open PR:
+
+```bash
+git push -u origin feat/stack-phase3
+gh pr create --repo cuongtranba/kanna --base main --head feat/stack-phase3 \
+ --title "feat(stacks): Phase 3 — sidebar UI, chat creation, peer strip" \
+ --body "$(cat <<'EOF'
+## Summary
+- Adds StacksSection above LocalProjectsSection in the sidebar.
+- Inline stack create/edit panel (no modal — PRODUCT.md rule).
+- Inline stack chat creation row with per-project worktree dropdown + primary radio.
+- PeerWorktreeStrip below the chat title; renders \`resolvedBindings\` from chat snapshot.
+- Keybindings: \`cmd+alt+w\` new stack, \`cmd+alt+shift+n\` new stack chat, \`g s\` jump to stacks.
+- New WS command \`stack.listWorktrees\` returns per-project worktrees on demand.
+- Codex provider chats show a \`codex: cwd-only\` Mono label on the strip.
+
+## Test plan
+- [x] bun test --timeout 30000 green.
+- [x] vite build green.
+- [x] tsc clean (sonner pre-existing only).
+- [x] DOM tests for every new component.
+- [x] Keybindings test covers the three new actions.
+- [ ] Manual: round-trip create stack → create stack chat → confirm peer strip + agent receives additionalDirectories.
+- [ ] Manual mobile: every panel renders as bottom sheet at <640px.
+
+## Out of scope (later)
+- Re-bind peer worktrees on a live chat (\`chat_binding_changed\`).
+- Branch + dirty enrichment on the peer strip.
+- Drag-and-drop reordering of stacks.
+EOF
+)"
+```
+
+---
+
+## Done-when checklist
+
+- [ ] 1 + Task 1 commit landed.
+- [ ] Tasks 2–10 commits landed.
+- [ ] All new components have DOM tests.
+- [ ] `bun test --timeout 30000` green.
+- [ ] `bun run build` (vite) green.
+- [ ] PR open against `main`.
+- [ ] Manual round-trip captured in PR description.
+
+## Notes for the executor
+
+- **No new visual tokens.** Reuse DESIGN.md scales, colors, spacing. No new icon, no new color, no glow.
+- **No modals.** Inline everywhere.
+- **Strong typing.** No `any` outside test fixtures.
+- **Tooltip component.** If a hover-explanation is needed anywhere, use the project `Tooltip`, never native `title`.
+- **Pre-existing failures.** `bun test` may flake on uploads/diff-store under concurrent load; use `--timeout 30000` to match CI.
+- **agent.ts:** no changes. Server already handles `additionalDirectories` and Codex fallback.
+- **Keep PRs small if context tightens** — split Task 6 (chat create + listWorktrees) into its own PR if needed.
diff --git a/docs/pm2-deploy.md b/docs/pm2-deploy.md
new file mode 100644
index 000000000..28231e524
--- /dev/null
+++ b/docs/pm2-deploy.md
@@ -0,0 +1,139 @@
+# PM2 Deploy Recipe
+
+Run Kanna as a long-lived background process under [pm2](https://pm2.keymetrics.io/) using the published global binary, isolated from any developer shell that might be a Claude Code session.
+
+## Why this matters
+
+When the `pm2` daemon is spawned from inside a Claude Code shell, it permanently inherits parent env vars such as:
+
+- `CLAUDECODE=1`
+- `CLAUDE_CODE_SESSION_ID`
+- `CLAUDE_CODE_EXECPATH`
+- `CLAUDE_CODE_SUBAGENT_MODEL`
+- `CLAUDE_CODE_DISABLE_AUTO_MEMORY`
+- `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`
+- `AI_AGENT`
+
+Kanna's `buildClaudeEnv` (`src/server/agent.ts`) strips only `CLAUDECODE` before spawning the bundled `claude` binary via `@anthropic-ai/claude-agent-sdk`. The remaining `CLAUDE_CODE_*` siblings flow through to the child and can collide with the OAuth token injected from the pool, surfacing as:
+
+```
+[quick-response] claude structured request failed: Claude Code returned an error result: Failed to authenticate. API Error: 401 Invalid authentication credentials
+```
+
+A pm2 `env:` block in `ecosystem.config.cjs` cannot fix this — pm2 always uses the daemon's parent env as the base and the `env:` block only adds or overrides keys. The fix is to spawn the daemon itself under a clean environment.
+
+## One-time install
+
+```bash
+bun install -g @cuongtran001/kanna
+which kanna # -> /Users//.bun/bin/kanna
+```
+
+## Deploy directory layout
+
+```
+~/Desktop/repo/kanna_deploy_pm2/
+└── ecosystem.config.cjs
+```
+
+The cwd is intentionally separate from the source checkout so the global binary and the deploy config can be versioned independently.
+
+## `ecosystem.config.cjs`
+
+```js
+module.exports = {
+ apps: [
+ {
+ name: "kanna",
+ script: "/Users//.bun/bin/kanna",
+ args: [
+ "--no-open",
+ "--cloudflared", "",
+ "--password", "",
+ ],
+ cwd: "/Users//Desktop/repo/kanna_deploy_pm2",
+ interpreter: "none",
+ exec_mode: "fork",
+ autorestart: true,
+ watch: false,
+ env: {
+ HOME: "/Users/",
+ PATH: "/Users//.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin",
+ SHELL: "/bin/zsh",
+ LANG: "en_US.UTF-8",
+ NODE_ENV: "production",
+ },
+ },
+ ],
+}
+```
+
+The `env` block is belt-and-suspenders only; the real defense is starting the daemon under `env -i` (next section).
+
+## Launch under a clean daemon
+
+Run these from any terminal — the `env -i` wrapper strips inherited env so the daemon comes up clean even when the surrounding shell is a Claude Code session.
+
+```bash
+# Stop the old daemon (if any) and wipe its dump
+pm2 delete all 2>/dev/null
+pm2 kill
+rm -f ~/.pm2/dump.pm2
+
+# Spawn pm2 daemon with a clean environment, then start kanna
+env -i \
+ HOME=$HOME \
+ PATH=$HOME/.bun/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin \
+ SHELL=/bin/zsh \
+ LANG=en_US.UTF-8 \
+ USER=$USER \
+ LOGNAME=$USER \
+ PM2_HOME=$HOME/.pm2 \
+ NODE_ENV=production \
+ $HOME/.bun/bin/pm2 start \
+ $HOME/Desktop/repo/kanna_deploy_pm2/ecosystem.config.cjs
+
+# Persist for `pm2 resurrect` on reboot
+pm2 save
+```
+
+## Verify the daemon is clean
+
+```bash
+pm2 env 0 | grep -iE '^(CLAUDE|ANTHROPIC|AI_AGENT)'
+# Expected output: empty
+```
+
+If anything prints, the daemon inherited env from a Claude Code session — repeat the launch steps from a non-Claude shell or use the `env -i` wrapper above.
+
+## Routine commands
+
+| Command | Effect |
+| --- | --- |
+| `pm2 status kanna` | Process state |
+| `pm2 logs kanna --lines 50` | Tail stdout/stderr |
+| `pm2 restart kanna` | Restart preserving env |
+| `pm2 reload kanna --update-env` | Restart and re-read `env:` block |
+| `pm2 save` | Persist process list to `~/.pm2/dump.pm2` |
+| `pm2 resurrect` | Restore from dump (reboot recovery) |
+
+## Updating the published binary
+
+```bash
+bun install -g @cuongtran001/kanna@latest
+pm2 restart kanna
+```
+
+The pm2 process keeps the same env and args; only the binary on disk changes.
+
+## Troubleshooting 401
+
+1. `pm2 env 0 | grep CLAUDE` — must be empty. If not, restart daemon under `env -i`.
+2. Verify the OAuth pool has at least one `active` (non-`limited`) token via the Kanna UI Settings → Claude accounts.
+3. Test a token directly against the bundled binary:
+ ```bash
+ CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-... \
+ $HOME/node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64/claude \
+ -p "hi" --model claude-haiku-4-5-20251001
+ ```
+ A real reply confirms the token is valid; a 401 means the token was revoked and must be re-minted via the OAuth pool flow in Kanna settings.
diff --git a/docs/pr-369-screenshots/before-collapse-all.png b/docs/pr-369-screenshots/before-collapse-all.png
new file mode 100644
index 000000000..f771ded49
Binary files /dev/null and b/docs/pr-369-screenshots/before-collapse-all.png differ
diff --git a/docs/pr-369-screenshots/before-collapse-context.png b/docs/pr-369-screenshots/before-collapse-context.png
new file mode 100644
index 000000000..b6c7f4103
Binary files /dev/null and b/docs/pr-369-screenshots/before-collapse-context.png differ
diff --git a/docs/pr-369-screenshots/v2-after-context.png b/docs/pr-369-screenshots/v2-after-context.png
new file mode 100644
index 000000000..5d611c0b7
Binary files /dev/null and b/docs/pr-369-screenshots/v2-after-context.png differ
diff --git a/docs/pr-369-screenshots/v2-after-hover.png b/docs/pr-369-screenshots/v2-after-hover.png
new file mode 100644
index 000000000..33031c6f9
Binary files /dev/null and b/docs/pr-369-screenshots/v2-after-hover.png differ
diff --git a/docs/pr-369-screenshots/v2-after-rest.png b/docs/pr-369-screenshots/v2-after-rest.png
new file mode 100644
index 000000000..14d9dcbea
Binary files /dev/null and b/docs/pr-369-screenshots/v2-after-rest.png differ
diff --git a/docs/superpowers/plans/2026-04-20-at-mention-file-picker.md b/docs/superpowers/plans/2026-04-20-at-mention-file-picker.md
new file mode 100644
index 000000000..b27037caf
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-20-at-mention-file-picker.md
@@ -0,0 +1,1389 @@
+# `@` File Mention Picker Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Add a Claude Code-style `@` file/directory picker to Kanna's chat input. Typing `@` at a word boundary opens a fuzzy-searchable picker populated from the project's git-tracked + untracked files (with ripgrep / readdir fallbacks). Selecting a row inserts `@relative/path` text and registers a `kind: "mention"` attachment. The server renders mentions inside the existing `` block so both Claude and Codex sessions receive them.
+
+**Architecture:** Additive. New server module `project-paths.ts` owns file indexing + fuzzy filter; new route `GET /api/projects/:id/paths?query=`. A new `"mention"` variant on `AttachmentKind` flows through the existing attachment hint renderer in `src/server/agent.ts`. Client adds `mention-suggestions.ts`, `useMentionSuggestions`, `MentionPicker.tsx`, and a branch in `AttachmentCard.tsx`; `ChatInput.tsx` wires them.
+
+**Tech Stack:** TypeScript, Bun, React 19, Zustand, Vitest/bun:test, Tailwind, Bun.spawn for git subprocesses.
+
+**Design reference:** `docs/superpowers/specs/2026-04-20-at-mention-file-picker-design.md`.
+
+**Baseline:** Branch `main`, clean tree at `16eee47`. Before starting, create a feature branch: `git checkout -b feature/at-mention-picker`. Verify `bun run check` passes.
+
+---
+
+## Task 1 — Shared `"mention"` attachment kind
+
+**Files:**
+- Modify: `src/shared/types.ts` (lines 9-20)
+
+- [ ] **Step 1: Extend `AttachmentKind`**
+
+Edit `src/shared/types.ts`:
+
+```ts
+export type AttachmentKind = "image" | "file" | "mention"
+```
+
+- [ ] **Step 2: Run typecheck**
+
+Run: `bun run check`
+Expected: PASS. The addition is a union widening — existing narrowings (`kind === "image"` / `kind === "file"`) are still valid. If a `switch (kind)` exhaustive check fails somewhere, note the file and add a `case "mention":` branch that falls through to the default (no-op for now).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/shared/types.ts
+git commit -m "feat(types): add \"mention\" variant to AttachmentKind"
+```
+
+---
+
+## Task 2 — Server path indexer (`project-paths.ts`)
+
+**Files:**
+- Create: `src/server/project-paths.ts`
+- Create: `src/server/project-paths.test.ts`
+
+- [ ] **Step 1: Write failing tests**
+
+Create `src/server/project-paths.test.ts`:
+
+```ts
+import { afterEach, beforeEach, describe, expect, test } from "bun:test"
+import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import path from "node:path"
+import { $ } from "bun"
+import { clearProjectPathCache, listProjectPaths } from "./project-paths"
+
+const tempDirs: string[] = []
+
+beforeEach(() => {
+ clearProjectPathCache()
+})
+
+afterEach(async () => {
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
+})
+
+async function makeTempDir(prefix: string): Promise {
+ const dir = await mkdtemp(path.join(tmpdir(), prefix))
+ tempDirs.push(dir)
+ return dir
+}
+
+describe("listProjectPaths", () => {
+ test("empty query returns top-level entries with dirs suffixed", async () => {
+ const root = await makeTempDir("kanna-paths-empty-")
+ await writeFile(path.join(root, "a.txt"), "a")
+ await mkdir(path.join(root, "src"))
+ await writeFile(path.join(root, "src", "b.ts"), "b")
+
+ const paths = await listProjectPaths({ projectId: "p1", localPath: root, query: "" })
+ const names = paths.map((p) => p.path).sort()
+ expect(names).toEqual(["a.txt", "src/"])
+ expect(paths.find((p) => p.path === "src/")?.kind).toBe("dir")
+ expect(paths.find((p) => p.path === "a.txt")?.kind).toBe("file")
+ })
+
+ test("git repo: returns tracked files + derived dirs", async () => {
+ const root = await makeTempDir("kanna-paths-git-")
+ await $`git init -q`.cwd(root)
+ await $`git -c user.email=t@t -c user.name=t commit -q --allow-empty -m init`.cwd(root)
+ await mkdir(path.join(root, "src"))
+ await writeFile(path.join(root, "src", "agent.ts"), "x")
+ await writeFile(path.join(root, "README.md"), "r")
+ await $`git add .`.cwd(root)
+ await $`git -c user.email=t@t -c user.name=t commit -q -m add`.cwd(root)
+
+ const paths = await listProjectPaths({ projectId: "p2", localPath: root, query: "agent" })
+ const names = paths.map((p) => p.path)
+ expect(names).toContain("src/agent.ts")
+ })
+
+ test("git repo: respects .gitignore for untracked files", async () => {
+ const root = await makeTempDir("kanna-paths-ignore-")
+ await $`git init -q`.cwd(root)
+ await writeFile(path.join(root, ".gitignore"), "node_modules\n")
+ await mkdir(path.join(root, "node_modules"))
+ await writeFile(path.join(root, "node_modules", "junk.js"), "x")
+ await writeFile(path.join(root, "app.ts"), "x")
+
+ const paths = await listProjectPaths({ projectId: "p3", localPath: root, query: "junk" })
+ expect(paths.map((p) => p.path)).not.toContain("node_modules/junk.js")
+ })
+
+ test("fuzzy ranking: prefix matches before substring matches", async () => {
+ const root = await makeTempDir("kanna-paths-rank-")
+ await writeFile(path.join(root, "review.ts"), "")
+ await writeFile(path.join(root, "unreview.ts"), "")
+
+ const paths = await listProjectPaths({ projectId: "p4", localPath: root, query: "rev" })
+ expect(paths.map((p) => p.path)).toEqual(["review.ts", "unreview.ts"])
+ })
+
+ test("respects limit", async () => {
+ const root = await makeTempDir("kanna-paths-limit-")
+ for (let i = 0; i < 10; i++) {
+ await writeFile(path.join(root, `file-${i}.txt`), "")
+ }
+
+ const paths = await listProjectPaths({ projectId: "p5", localPath: root, query: "file", limit: 3 })
+ expect(paths.length).toBe(3)
+ })
+
+ test("cache returns from memory on repeat call", async () => {
+ const root = await makeTempDir("kanna-paths-cache-")
+ await writeFile(path.join(root, "a.txt"), "")
+
+ const first = await listProjectPaths({ projectId: "p6", localPath: root, query: "a" })
+ await writeFile(path.join(root, "b.txt"), "") // added after first call
+ const second = await listProjectPaths({ projectId: "p6", localPath: root, query: "b" })
+
+ expect(first.map((p) => p.path)).toContain("a.txt")
+ // b.txt was added after cache built and no .git/index triggered invalidation,
+ // but since this is non-git, the 5s TTL won't have elapsed so b.txt should
+ // NOT appear yet.
+ expect(second.map((p) => p.path)).not.toContain("b.txt")
+ })
+})
+```
+
+- [ ] **Step 2: Run failing tests**
+
+Run: `bun test src/server/project-paths.test.ts`
+Expected: FAIL — module does not exist.
+
+- [ ] **Step 3: Implement `project-paths.ts`**
+
+Create `src/server/project-paths.ts`:
+
+```ts
+import path from "node:path"
+import { readdir, stat } from "node:fs/promises"
+import { existsSync } from "node:fs"
+import { spawn } from "bun"
+
+export interface ProjectPath {
+ path: string
+ kind: "file" | "dir"
+}
+
+interface CacheEntry {
+ files: string[] // relative, forward slashes
+ dirs: string[] // relative, forward slashes, no trailing separator
+ gitIndexMtime: number | null
+ builtAt: number
+}
+
+const CACHE = new Map()
+const CACHE_TTL_MS = 5 * 60 * 1000
+const MAX_WALK_ENTRIES = 10_000
+const DEFAULT_LIMIT = 50
+const MAX_LIMIT = 200
+
+const DEFAULT_WALK_EXCLUDES = new Set([
+ ".git", "node_modules", ".next", "dist", "build", ".svn", ".hg", ".jj", ".sl",
+])
+
+export function clearProjectPathCache(projectId?: string) {
+ if (projectId) CACHE.delete(projectId)
+ else CACHE.clear()
+}
+
+export async function listProjectPaths(args: {
+ projectId: string
+ localPath: string
+ query: string
+ limit?: number
+}): Promise {
+ const limit = Math.min(Math.max(args.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT)
+ const query = args.query ?? ""
+
+ if (query === "") {
+ return listTopLevelEntries(args.localPath, limit)
+ }
+
+ const entry = await getOrBuildCache(args.projectId, args.localPath)
+ return fuzzyRank(entry, query, limit)
+}
+
+async function listTopLevelEntries(localPath: string, limit: number): Promise {
+ try {
+ const entries = await readdir(localPath, { withFileTypes: true })
+ const result: ProjectPath[] = []
+ for (const e of entries) {
+ if (DEFAULT_WALK_EXCLUDES.has(e.name)) continue
+ if (e.name.startsWith(".")) continue
+ result.push(e.isDirectory()
+ ? { path: `${e.name}/`, kind: "dir" }
+ : { path: e.name, kind: "file" })
+ }
+ result.sort((a, b) => {
+ if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1
+ return a.path.localeCompare(b.path)
+ })
+ return result.slice(0, limit)
+ } catch {
+ return []
+ }
+}
+
+async function getOrBuildCache(projectId: string, localPath: string): Promise {
+ const existing = CACHE.get(projectId)
+ const gitIndexMtime = getGitIndexMtime(localPath)
+ const now = Date.now()
+
+ if (existing) {
+ const gitChanged = gitIndexMtime !== null && gitIndexMtime !== existing.gitIndexMtime
+ const expired = now - existing.builtAt > CACHE_TTL_MS
+ if (!gitChanged && !expired) return existing
+ }
+
+ const built = await buildCacheEntry(localPath)
+ const next: CacheEntry = { ...built, gitIndexMtime, builtAt: now }
+ CACHE.set(projectId, next)
+ return next
+}
+
+function getGitIndexMtime(localPath: string): number | null {
+ const indexPath = path.join(localPath, ".git", "index")
+ try {
+ const { statSync } = require("node:fs") as typeof import("node:fs")
+ return statSync(indexPath).mtimeMs
+ } catch {
+ return null
+ }
+}
+
+async function buildCacheEntry(localPath: string): Promise> {
+ const gitFiles = await listGitFiles(localPath)
+ const files = gitFiles ?? await walkDirectory(localPath)
+ const dirs = deriveDirectories(files)
+ return { files, dirs }
+}
+
+async function listGitFiles(localPath: string): Promise {
+ if (!existsSync(path.join(localPath, ".git"))) return null
+
+ const tracked = await runGit(localPath, ["-c", "core.quotepath=false", "ls-files"])
+ if (tracked === null) return null
+
+ const untracked = await runGit(localPath, [
+ "-c", "core.quotepath=false", "ls-files", "--others", "--exclude-standard",
+ ])
+
+ const all = new Set()
+ for (const line of tracked) all.add(line)
+ for (const line of untracked ?? []) all.add(line)
+ return [...all].filter((p) => p.length > 0).map((p) => p.replaceAll("\\", "/"))
+}
+
+async function runGit(cwd: string, args: string[]): Promise {
+ try {
+ const proc = spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" })
+ const stdout = await new Response(proc.stdout).text()
+ const exitCode = await proc.exited
+ if (exitCode !== 0) return null
+ return stdout.split("\n").filter(Boolean)
+ } catch {
+ return null
+ }
+}
+
+async function walkDirectory(root: string): Promise {
+ const out: string[] = []
+ const queue: string[] = [""]
+ while (queue.length > 0 && out.length < MAX_WALK_ENTRIES) {
+ const rel = queue.shift()!
+ const abs = path.join(root, rel)
+ let entries: Awaited>
+ try {
+ entries = await readdir(abs, { withFileTypes: true })
+ } catch {
+ continue
+ }
+ for (const e of entries) {
+ if (DEFAULT_WALK_EXCLUDES.has(e.name)) continue
+ const nextRel = rel === "" ? e.name : `${rel}/${e.name}`
+ if (e.isDirectory()) {
+ queue.push(nextRel)
+ } else if (e.isFile()) {
+ out.push(nextRel)
+ if (out.length >= MAX_WALK_ENTRIES) break
+ }
+ }
+ }
+ return out
+}
+
+function deriveDirectories(files: string[]): string[] {
+ const dirs = new Set()
+ for (const f of files) {
+ let idx = f.lastIndexOf("/")
+ while (idx > 0) {
+ dirs.add(f.slice(0, idx))
+ idx = f.lastIndexOf("/", idx - 1)
+ }
+ }
+ return [...dirs]
+}
+
+function fuzzyRank(entry: CacheEntry, query: string, limit: number): ProjectPath[] {
+ const q = query.toLowerCase()
+ const prefix: ProjectPath[] = []
+ const substring: ProjectPath[] = []
+
+ for (const f of entry.files) {
+ const hay = f.toLowerCase()
+ if (hay.startsWith(q)) prefix.push({ path: f, kind: "file" })
+ else if (hay.includes(q)) substring.push({ path: f, kind: "file" })
+ }
+ for (const d of entry.dirs) {
+ const hay = d.toLowerCase()
+ const withSlash = `${d}/`
+ if (hay.startsWith(q)) prefix.push({ path: withSlash, kind: "dir" })
+ else if (hay.includes(q)) substring.push({ path: withSlash, kind: "dir" })
+ }
+
+ const byPath = (a: ProjectPath, b: ProjectPath) => a.path.localeCompare(b.path)
+ prefix.sort(byPath)
+ substring.sort(byPath)
+ return [...prefix, ...substring].slice(0, limit)
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `bun test src/server/project-paths.test.ts`
+Expected: PASS (all cases).
+
+If the `cache returns from memory` test fails because writes happened too fast for the TTL check, that test is still valid — it asserts that a freshly-added file does NOT appear in the second call. If the test is flaky, replace the assertion with: `expect(CACHE.has("p6")).toBe(true)` by exporting a helper. Keep it simple and adjust only if needed.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/server/project-paths.ts src/server/project-paths.test.ts
+git commit -m "feat(server): add project-paths module for @ mention suggestions"
+```
+
+---
+
+## Task 3 — HTTP route `/api/projects/:id/paths`
+
+**Files:**
+- Modify: `src/server/server.ts` (add import + route handler + call site around line 228)
+
+- [ ] **Step 1: Write failing test**
+
+Add to `src/server/uploads.test.ts` (reuses existing `startKannaServer` setup) or create `src/server/paths-route.test.ts` if preferred. Use the latter for isolation:
+
+Create `src/server/paths-route.test.ts`:
+
+```ts
+import { afterEach, describe, expect, test } from "bun:test"
+import { mkdtemp, rm, writeFile, mkdir } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import path from "node:path"
+import { startKannaServer } from "./server"
+
+const tempDirs: string[] = []
+
+afterEach(async () => {
+ await Promise.all(tempDirs.splice(0).map((d) => rm(d, { recursive: true, force: true })))
+})
+
+async function makeProject(): Promise<{ projectDir: string; dataDir: string }> {
+ const dataDir = await mkdtemp(path.join(tmpdir(), "kanna-data-"))
+ const projectDir = await mkdtemp(path.join(tmpdir(), "kanna-proj-"))
+ tempDirs.push(dataDir, projectDir)
+ process.env.KANNA_DATA_DIR = dataDir
+ return { projectDir, dataDir }
+}
+
+describe("GET /api/projects/:id/paths", () => {
+ test("returns 404 for unknown project", async () => {
+ const { projectDir } = await makeProject()
+ await mkdir(path.join(projectDir, "src"))
+ await writeFile(path.join(projectDir, "src", "a.ts"), "")
+
+ const server = await startKannaServer({ port: 0 })
+ try {
+ const response = await fetch(`http://localhost:${server.port}/api/projects/does-not-exist/paths`)
+ expect(response.status).toBe(404)
+ } finally {
+ await server.stop()
+ }
+ })
+
+ test("returns top-level entries for empty query", async () => {
+ const { projectDir } = await makeProject()
+ await mkdir(path.join(projectDir, "src"))
+ await writeFile(path.join(projectDir, "README.md"), "")
+
+ const server = await startKannaServer({ port: 0 })
+ try {
+ const project = server.store.openProject({ localPath: projectDir, title: "t" })
+ const response = await fetch(`http://localhost:${server.port}/api/projects/${project.id}/paths`)
+ expect(response.status).toBe(200)
+ const payload = await response.json() as { paths: Array<{ path: string; kind: string }> }
+ const names = payload.paths.map((p) => p.path)
+ expect(names).toContain("README.md")
+ expect(names).toContain("src/")
+ } finally {
+ await server.stop()
+ }
+ })
+
+ test("respects ?query= and ?limit=", async () => {
+ const { projectDir } = await makeProject()
+ for (let i = 0; i < 5; i++) await writeFile(path.join(projectDir, `file-${i}.txt`), "")
+
+ const server = await startKannaServer({ port: 0 })
+ try {
+ const project = server.store.openProject({ localPath: projectDir, title: "t" })
+ const response = await fetch(
+ `http://localhost:${server.port}/api/projects/${project.id}/paths?query=file&limit=2`,
+ )
+ const payload = await response.json() as { paths: Array<{ path: string }> }
+ expect(payload.paths.length).toBe(2)
+ } finally {
+ await server.stop()
+ }
+ })
+})
+```
+
+**Note:** Before writing the test, verify how existing tests set up the data directory — read `src/server/uploads.test.ts` around the `startKannaServer` call and mirror its pattern. If `KANNA_DATA_DIR` isn't the correct env var, check `src/shared/branding.ts` and `src/server/paths.ts` for the actual env var name. Adjust the test accordingly. Also verify how `store.openProject` signature looks — Grep for `openProject` in `src/server/event-store.ts`.
+
+- [ ] **Step 2: Run failing tests**
+
+Run: `bun test src/server/paths-route.test.ts`
+Expected: FAIL — route returns 404 (fallthrough to static serve) for all requests.
+
+- [ ] **Step 3: Implement handler**
+
+Edit `src/server/server.ts`:
+
+Add to imports at top:
+
+```ts
+import { listProjectPaths } from "./project-paths"
+```
+
+Add a new handler function after `handleProjectUploadDelete` (after line ~450):
+
+```ts
+async function handleProjectPaths(req: Request, url: URL, store: EventStore) {
+ if (req.method !== "GET") return null
+ const match = url.pathname.match(/^\/api\/projects\/([^/]+)\/paths$/)
+ if (!match) return null
+
+ const project = store.getProject(match[1])
+ if (!project) {
+ return Response.json({ error: "Project not found" }, { status: 404 })
+ }
+
+ const query = url.searchParams.get("query") ?? ""
+ const limitRaw = url.searchParams.get("limit")
+ const limit = limitRaw !== null ? Number.parseInt(limitRaw, 10) : undefined
+
+ try {
+ const paths = await listProjectPaths({
+ projectId: project.id,
+ localPath: project.localPath,
+ query,
+ limit: Number.isFinite(limit) ? limit : undefined,
+ })
+ return Response.json({ paths })
+ } catch (error) {
+ console.error("[paths] list failed:", error)
+ return Response.json({ error: "Failed to list paths" }, { status: 500 })
+ }
+}
+```
+
+Wire it into the request handler block (near line 228, next to `handleProjectFileContent`):
+
+```ts
+const projectPathsResponse = await handleProjectPaths(req, url, store)
+if (projectPathsResponse) {
+ return projectPathsResponse
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `bun test src/server/paths-route.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Typecheck**
+
+Run: `bun run check`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/server/server.ts src/server/paths-route.test.ts
+git commit -m "feat(server): add GET /api/projects/:id/paths route"
+```
+
+---
+
+## Task 4 — Verify agent hint renders `kind="mention"`
+
+**Files:**
+- Modify: `src/server/agent.test.ts` (append a new test)
+
+- [ ] **Step 1: Read the existing test to mirror its style**
+
+Read: `src/server/agent.test.ts` lines 140-210 (the existing attachment-hint tests).
+
+- [ ] **Step 2: Add failing test**
+
+Append to `src/server/agent.test.ts` inside the existing describe block that covers `buildAttachmentHintText` (or create a new describe if none):
+
+```ts
+test("buildAttachmentHintText renders kind=\"mention\" attachments", () => {
+ const prompt = buildAttachmentHintText([
+ {
+ id: "m1",
+ kind: "mention",
+ displayName: "src/agent.ts",
+ absolutePath: "/tmp/project/src/agent.ts",
+ relativePath: "./src/agent.ts",
+ contentUrl: "",
+ mimeType: "",
+ size: 0,
+ },
+ ])
+ expect(prompt).toContain("kind=\"mention\"")
+ expect(prompt).toContain("path=\"/tmp/project/src/agent.ts\"")
+ expect(prompt).toContain("project_path=\"./src/agent.ts\"")
+})
+```
+
+- [ ] **Step 3: Run test**
+
+Run: `bun test src/server/agent.test.ts`
+Expected: PASS immediately — `buildAttachmentHintText` at `src/server/agent.ts:211-223` already emits `kind="${attachment.kind}"` unconditionally, so mentions flow through. This task exists to lock in that invariant.
+
+If FAIL, check the imports at the top of `agent.test.ts` for `buildAttachmentHintText` and add it if missing.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/server/agent.test.ts
+git commit -m "test(agent): lock in kind=\"mention\" rendering in attachment hint"
+```
+
+---
+
+## Task 5 — Client pure utils (`mention-suggestions.ts`)
+
+**Files:**
+- Create: `src/client/lib/mention-suggestions.ts`
+- Create: `src/client/lib/mention-suggestions.test.ts`
+
+- [ ] **Step 1: Write failing tests**
+
+Create `src/client/lib/mention-suggestions.test.ts`:
+
+```ts
+import { describe, expect, test } from "bun:test"
+import { applyMentionToInput, shouldShowMentionPicker } from "./mention-suggestions"
+
+describe("shouldShowMentionPicker", () => {
+ test("opens on bare @ at start", () => {
+ expect(shouldShowMentionPicker("@", 1)).toEqual({ open: true, query: "", tokenStart: 0 })
+ })
+
+ test("opens on @src at start", () => {
+ expect(shouldShowMentionPicker("@src", 4)).toEqual({ open: true, query: "src", tokenStart: 0 })
+ })
+
+ test("opens on @src after space", () => {
+ expect(shouldShowMentionPicker("hi @src", 7)).toEqual({ open: true, query: "src", tokenStart: 3 })
+ })
+
+ test("opens after newline", () => {
+ expect(shouldShowMentionPicker("hi\n@src", 7)).toEqual({ open: true, query: "src", tokenStart: 3 })
+ })
+
+ test("does not open on mid-word @ (email-like)", () => {
+ expect(shouldShowMentionPicker("foo@bar", 7)).toEqual({ open: false, query: "", tokenStart: -1 })
+ })
+
+ test("does not open when caret before @", () => {
+ expect(shouldShowMentionPicker("@src", 0)).toEqual({ open: false, query: "", tokenStart: -1 })
+ })
+
+ test("does not open after space breaks the token", () => {
+ expect(shouldShowMentionPicker("@src foo", 8)).toEqual({ open: false, query: "", tokenStart: -1 })
+ })
+
+ test("does not open on empty input", () => {
+ expect(shouldShowMentionPicker("", 0)).toEqual({ open: false, query: "", tokenStart: -1 })
+ })
+})
+
+describe("applyMentionToInput", () => {
+ test("replaces @query at start with @pickedPath", () => {
+ const result = applyMentionToInput({
+ value: "@src",
+ caret: 4,
+ tokenStart: 0,
+ pickedPath: "src/agent.ts",
+ })
+ expect(result.value).toBe("@src/agent.ts")
+ expect(result.caret).toBe("@src/agent.ts".length)
+ })
+
+ test("replaces mid-input token", () => {
+ const result = applyMentionToInput({
+ value: "hi @src tail",
+ caret: 7,
+ tokenStart: 3,
+ pickedPath: "src/agent.ts",
+ })
+ expect(result.value).toBe("hi @src/agent.ts tail")
+ expect(result.caret).toBe("hi @src/agent.ts".length)
+ })
+
+ test("preserves bare @ with empty query", () => {
+ const result = applyMentionToInput({
+ value: "@",
+ caret: 1,
+ tokenStart: 0,
+ pickedPath: "README.md",
+ })
+ expect(result.value).toBe("@README.md")
+ expect(result.caret).toBe("@README.md".length)
+ })
+
+ test("handles dir paths (trailing slash)", () => {
+ const result = applyMentionToInput({
+ value: "@src",
+ caret: 4,
+ tokenStart: 0,
+ pickedPath: "src/",
+ })
+ expect(result.value).toBe("@src/")
+ expect(result.caret).toBe("@src/".length)
+ })
+})
+```
+
+- [ ] **Step 2: Run failing tests**
+
+Run: `bun test src/client/lib/mention-suggestions.test.ts`
+Expected: FAIL — module does not exist.
+
+- [ ] **Step 3: Implement**
+
+Create `src/client/lib/mention-suggestions.ts`:
+
+```ts
+export interface MentionTrigger {
+ open: boolean
+ query: string
+ tokenStart: number
+}
+
+const CLOSED: MentionTrigger = { open: false, query: "", tokenStart: -1 }
+
+export function shouldShowMentionPicker(value: string, caret: number): MentionTrigger {
+ if (caret <= 0) return CLOSED
+ const upToCaret = value.slice(0, caret)
+
+ let atIndex = -1
+ for (let i = upToCaret.length - 1; i >= 0; i--) {
+ const ch = upToCaret[i]
+ if (ch === "@") { atIndex = i; break }
+ if (ch === " " || ch === "\n" || ch === "\t") return CLOSED
+ }
+ if (atIndex === -1) return CLOSED
+
+ const before = atIndex === 0 ? "" : upToCaret[atIndex - 1]
+ if (before !== "" && before !== " " && before !== "\n" && before !== "\t") return CLOSED
+
+ return { open: true, query: upToCaret.slice(atIndex + 1), tokenStart: atIndex }
+}
+
+export function applyMentionToInput(args: {
+ value: string
+ caret: number
+ tokenStart: number
+ pickedPath: string
+}): { value: string; caret: number } {
+ const before = args.value.slice(0, args.tokenStart)
+ const after = args.value.slice(args.caret)
+ const replacement = `@${args.pickedPath}`
+ const nextValue = `${before}${replacement}${after}`
+ const nextCaret = before.length + replacement.length
+ return { value: nextValue, caret: nextCaret }
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `bun test src/client/lib/mention-suggestions.test.ts`
+Expected: PASS (all cases).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/client/lib/mention-suggestions.ts src/client/lib/mention-suggestions.test.ts
+git commit -m "feat(client): add mention-suggestions trigger and apply utils"
+```
+
+---
+
+## Task 6 — Client fetch hook (`useMentionSuggestions`)
+
+**Files:**
+- Create: `src/client/hooks/useMentionSuggestions.ts`
+- Create: `src/client/hooks/useMentionSuggestions.test.ts`
+
+- [ ] **Step 1: Write failing test**
+
+Create `src/client/hooks/useMentionSuggestions.test.ts`:
+
+```ts
+import { afterEach, beforeEach, describe, expect, test } from "bun:test"
+import { fetchProjectPaths, type ProjectPath } from "./useMentionSuggestions"
+
+const originalFetch = globalThis.fetch
+
+afterEach(() => {
+ globalThis.fetch = originalFetch
+})
+
+describe("fetchProjectPaths", () => {
+ test("requests the expected URL and returns paths", async () => {
+ let receivedUrl: string | null = null
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
+ receivedUrl = typeof input === "string" ? input : input.toString()
+ return new Response(
+ JSON.stringify({ paths: [{ path: "a.ts", kind: "file" }] }),
+ { headers: { "Content-Type": "application/json" } },
+ )
+ }) as typeof fetch
+
+ const result = await fetchProjectPaths({ projectId: "p1", query: "a", signal: new AbortController().signal })
+ expect(receivedUrl).toBe("/api/projects/p1/paths?query=a")
+ expect(result).toEqual([{ path: "a.ts", kind: "file" }])
+ })
+
+ test("escapes query", async () => {
+ let receivedUrl: string | null = null
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
+ receivedUrl = typeof input === "string" ? input : input.toString()
+ return new Response(JSON.stringify({ paths: [] }), { headers: { "Content-Type": "application/json" } })
+ }) as typeof fetch
+
+ await fetchProjectPaths({ projectId: "p1", query: "a b/c", signal: new AbortController().signal })
+ expect(receivedUrl).toBe("/api/projects/p1/paths?query=a+b%2Fc")
+ })
+
+ test("returns empty array on non-ok response", async () => {
+ globalThis.fetch = (async () => new Response("{}", { status: 500 })) as typeof fetch
+ const result = await fetchProjectPaths({ projectId: "p1", query: "x", signal: new AbortController().signal })
+ expect(result).toEqual([])
+ })
+})
+```
+
+- [ ] **Step 2: Run failing test**
+
+Run: `bun test src/client/hooks/useMentionSuggestions.test.ts`
+Expected: FAIL — module does not exist.
+
+- [ ] **Step 3: Implement**
+
+Create `src/client/hooks/useMentionSuggestions.ts`:
+
+```ts
+import { useEffect, useRef, useState } from "react"
+
+export interface ProjectPath {
+ path: string
+ kind: "file" | "dir"
+}
+
+interface State {
+ items: ProjectPath[]
+ loading: boolean
+ error: string | null
+}
+
+const DEBOUNCE_MS = 120
+
+export async function fetchProjectPaths(args: {
+ projectId: string
+ query: string
+ signal: AbortSignal
+}): Promise {
+ const params = new URLSearchParams({ query: args.query })
+ try {
+ const response = await fetch(`/api/projects/${args.projectId}/paths?${params.toString()}`, {
+ signal: args.signal,
+ })
+ if (!response.ok) return []
+ const payload = await response.json() as { paths?: ProjectPath[] }
+ return payload.paths ?? []
+ } catch {
+ return []
+ }
+}
+
+export function useMentionSuggestions(args: {
+ projectId: string | null
+ query: string
+ enabled: boolean
+}): State {
+ const [state, setState] = useState({ items: [], loading: false, error: null })
+ const debounceRef = useRef | null>(null)
+ const abortRef = useRef(null)
+
+ useEffect(() => {
+ if (!args.enabled || !args.projectId) {
+ setState({ items: [], loading: false, error: null })
+ return
+ }
+
+ if (debounceRef.current) clearTimeout(debounceRef.current)
+ abortRef.current?.abort()
+
+ setState((s) => ({ ...s, loading: true, error: null }))
+ const controller = new AbortController()
+ abortRef.current = controller
+
+ debounceRef.current = setTimeout(async () => {
+ const items = await fetchProjectPaths({
+ projectId: args.projectId!,
+ query: args.query,
+ signal: controller.signal,
+ })
+ if (controller.signal.aborted) return
+ setState({ items, loading: false, error: null })
+ }, DEBOUNCE_MS)
+
+ return () => {
+ if (debounceRef.current) clearTimeout(debounceRef.current)
+ controller.abort()
+ }
+ }, [args.enabled, args.projectId, args.query])
+
+ return state
+}
+```
+
+- [ ] **Step 4: Run tests**
+
+Run: `bun test src/client/hooks/useMentionSuggestions.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Typecheck**
+
+Run: `bun run check`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/client/hooks/useMentionSuggestions.ts src/client/hooks/useMentionSuggestions.test.ts
+git commit -m "feat(client): add useMentionSuggestions hook"
+```
+
+---
+
+## Task 7 — `MentionPicker` component
+
+**Files:**
+- Create: `src/client/components/chat-ui/MentionPicker.tsx`
+
+- [ ] **Step 1: Implement component**
+
+Create `src/client/components/chat-ui/MentionPicker.tsx`:
+
+```tsx
+import { useEffect, useRef } from "react"
+import { AtSign, Folder, FileText } from "lucide-react"
+import type { ProjectPath } from "../../hooks/useMentionSuggestions"
+import { cn } from "../../lib/utils"
+
+interface MentionPickerProps {
+ items: ProjectPath[]
+ activeIndex: number
+ loading: boolean
+ onSelect: (path: ProjectPath) => void
+ onHoverIndex: (index: number) => void
+}
+
+const SKELETON_ROWS = 4
+
+export function MentionPicker({ items, activeIndex, loading, onSelect, onHoverIndex }: MentionPickerProps) {
+ const listRef = useRef(null)
+
+ useEffect(() => {
+ const el = listRef.current?.children.item(activeIndex) as HTMLElement | null
+ el?.scrollIntoView({ block: "nearest" })
+ }, [activeIndex])
+
+ if (items.length === 0 && loading) {
+ return (
+
+ {Array.from({ length: SKELETON_ROWS }).map((_, i) => (
+
+
+
+
+ ))}
+
+ )
+ }
+
+ if (items.length === 0) {
+ return (
+
+ No matching files
+
+ )
+ }
+
+ return (
+
+ {items.map((item, i) => {
+ const Icon = item.kind === "dir" ? Folder : FileText
+ return (
+ {
+ event.preventDefault()
+ onSelect(item)
+ }}
+ onMouseEnter={() => onHoverIndex(i)}
+ className={cn(
+ "flex items-center gap-2 px-3 py-1.5 cursor-pointer text-sm",
+ i === activeIndex && "bg-accent text-accent-foreground",
+ )}
+ >
+
+
+ {item.path}
+
+ )
+ })}
+
+ )
+}
+```
+
+- [ ] **Step 2: Typecheck**
+
+Run: `bun run check`
+Expected: PASS.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/client/components/chat-ui/MentionPicker.tsx
+git commit -m "feat(client): add MentionPicker component"
+```
+
+---
+
+## Task 8 — Render mention attachments in `AttachmentCard`
+
+**Files:**
+- Modify: `src/client/components/messages/AttachmentCard.tsx`
+
+- [ ] **Step 1: Read existing file**
+
+Read `src/client/components/messages/AttachmentCard.tsx` end to end so you understand the existing `AttachmentFileCard` shape.
+
+- [ ] **Step 2: Add mention branch**
+
+Modify `AttachmentFileCard` (around line 90-118) so mentions render without the size/mime line (they're always zero/empty). Replace the body text block:
+
+```tsx
+
+
{attachment.displayName}
+ {attachment.kind === "mention" ? (
+
+ @mention
+
+ ) : (
+
+ {attachment.mimeType} · {formatAttachmentSize(attachment.size)}
+
+ )}
+
+```
+
+Also, for mentions, swap the icon: update `getAttachmentIcon` call site (near the top of `AttachmentFileCard`) to special-case mentions:
+
+Find the line that computes `Icon = getAttachmentIcon(classifyAttachmentIcon(attachment))`. Before it, add:
+
+```tsx
+ const iconKind: AttachmentIconKind = attachment.kind === "mention" ? "text" : classifyAttachmentIcon(attachment)
+ const Icon = getAttachmentIcon(iconKind)
+```
+
+And replace the existing `Icon` computation with the two lines above (delete the original). Import `AttachmentIconKind` if it isn't already imported (line 18 should already have `type AttachmentIconKind`).
+
+- [ ] **Step 3: Typecheck**
+
+Run: `bun run check`
+Expected: PASS.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add src/client/components/messages/AttachmentCard.tsx
+git commit -m "feat(client): render \"mention\" attachments without mime/size metadata"
+```
+
+---
+
+## Task 9 — Wire `MentionPicker` into `ChatInput`
+
+**Files:**
+- Modify: `src/client/components/chat-ui/ChatInput.tsx`
+- Modify: `src/client/components/chat-ui/ChatInput.test.ts`
+
+- [ ] **Step 1: Read the current `ChatInput.tsx` handleKeyDown and render sections**
+
+Re-read `src/client/components/chat-ui/ChatInput.tsx` lines 220-260 (state block) and 606-660 (keyboard) and 762-770 (picker render). Your wiring should mirror the slash picker but use mention state.
+
+- [ ] **Step 2: Add mention state (above the existing slash-picker state)**
+
+Inside the `ChatInputInner` body, add imports at the top of the file:
+
+```tsx
+import { MentionPicker } from "./MentionPicker"
+import { shouldShowMentionPicker, applyMentionToInput } from "../../lib/mention-suggestions"
+import { useMentionSuggestions, type ProjectPath } from "../../hooks/useMentionSuggestions"
+```
+
+Add new state alongside the slash-picker state (near lines 229-231):
+
+```tsx
+ const [mentionIndex, setMentionIndex] = useState(0)
+ const [mentionDismissed, setMentionDismissed] = useState(false)
+
+ const mentionTrigger = useMemo(
+ () => shouldShowMentionPicker(value, caret),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [value, caret, caretVersion],
+ )
+ const mentionState = useMentionSuggestions({
+ projectId: projectId ?? null,
+ query: mentionTrigger.query,
+ enabled: mentionTrigger.open && !mentionDismissed,
+ })
+ const mentionOpen =
+ mentionTrigger.open &&
+ !mentionDismissed &&
+ !pickerOpen &&
+ (mentionState.items.length > 0 || mentionState.loading)
+
+ useEffect(() => {
+ if (mentionOpen) setMentionIndex(0)
+ }, [mentionOpen, mentionTrigger.query])
+
+ useEffect(() => {
+ // Reset dismissed flag when the user edits past the current token
+ if (!mentionTrigger.open) setMentionDismissed(false)
+ }, [mentionTrigger.open, mentionTrigger.tokenStart])
+```
+
+- [ ] **Step 3: Add accept helper**
+
+Add inside `ChatInputInner`, next to `acceptCommand`:
+
+```tsx
+ function acceptMention(item: ProjectPath) {
+ if (!projectId) {
+ setMentionDismissed(true)
+ return
+ }
+ const { value: nextValue, caret: nextCaret } = applyMentionToInput({
+ value,
+ caret,
+ tokenStart: mentionTrigger.tokenStart,
+ pickedPath: item.path,
+ })
+ setValue(nextValue)
+ if (chatId) setDraft(chatId, nextValue)
+
+ const relativeForAttachment = item.path.endsWith("/") ? item.path.slice(0, -1) : item.path
+ const absolutePath = `${projectId ? "" : ""}` // placeholder; actual absolute path comes from the server-side render via relativePath
+ const alreadyMentioned = attachments.some(
+ (a) => a.kind === "mention" && a.relativePath === `./${relativeForAttachment}`,
+ )
+ if (!alreadyMentioned) {
+ setAttachments((prev) => [
+ ...prev,
+ {
+ id: crypto.randomUUID(),
+ kind: "mention",
+ displayName: relativeForAttachment,
+ absolutePath: "",
+ relativePath: `./${relativeForAttachment}`,
+ contentUrl: "",
+ mimeType: "",
+ size: 0,
+ status: "uploaded",
+ },
+ ])
+ }
+ setMentionDismissed(true)
+ requestAnimationFrame(() => {
+ const el = textareaRef.current
+ if (!el) return
+ el.focus()
+ el.setSelectionRange(nextCaret, nextCaret)
+ })
+ }
+```
+
+**Note on `absolutePath`:** The mention attachment is sent to the server with only `relativePath`; the server resolves to absolute via `project.localPath` in a follow-up task if needed. For v1 leave `absolutePath` empty and let the server fill it. If `buildAttachmentHintText` renders an empty `path=""` attribute, the agent gets the `project_path` which is sufficient for Read to work. If you need stricter behavior, extend the agent.ts submit path to fill `absolutePath = path.join(project.localPath, relativePath.slice(2))` before building the hint — see Task 9.5 optional.
+
+- [ ] **Step 4: Intercept mention keys in `handleKeyDown`**
+
+Place this block at the top of `handleKeyDown`, **before** the existing slash-picker `if (pickerOpen)` check:
+
+```tsx
+ if (mentionOpen) {
+ if (event.key === "Escape") {
+ event.preventDefault()
+ setMentionDismissed(true)
+ return
+ }
+ if (event.key === "ArrowDown") {
+ event.preventDefault()
+ setMentionIndex((i) => Math.min(mentionState.items.length - 1, i + 1))
+ return
+ }
+ if (event.key === "ArrowUp") {
+ event.preventDefault()
+ setMentionIndex((i) => Math.max(0, i - 1))
+ return
+ }
+ if (event.key === "Enter" || event.key === "Tab") {
+ event.preventDefault()
+ const item = mentionState.items[mentionIndex]
+ if (item) acceptMention(item)
+ return
+ }
+ }
+```
+
+- [ ] **Step 5: Render the picker**
+
+Inside the JSX where `SlashCommandPicker` is rendered (around line 763), add a sibling:
+
+```tsx
+ {mentionOpen && (
+
+ )}
+```
+
+Place it as a sibling of `SlashCommandPicker` so both live inside the same relative container and float above the textarea.
+
+- [ ] **Step 6: Write failing ChatInput tests**
+
+Append to `src/client/components/chat-ui/ChatInput.test.ts`:
+
+```ts
+describe("mention picker wiring", () => {
+ test("shouldShowMentionPicker trigger flows through into pickerOpen selection", () => {
+ // Unit test for the composition — pure logic
+ const { shouldShowMentionPicker } = require("../../lib/mention-suggestions")
+ expect(shouldShowMentionPicker("hello @src", 10)).toEqual({
+ open: true,
+ query: "src",
+ tokenStart: 6,
+ })
+ })
+})
+```
+
+This is the minimum assertion that the wiring contract holds. The full integration test (typing `@` → picker appears → enter → attachment added) requires a React render harness; since existing chat-ui tests are mostly pure-function style, defer full integration to manual verification in Task 10. If the existing file already uses `@testing-library/react`, add a render-based test:
+
+```ts
+// only add if render harness exists
+test("typing @ opens the mention picker", async () => {
+ // ... render ChatInput with chatId="c1", projectId="p1"
+ // ... mock /api/projects/p1/paths to return [{ path: "src/a.ts", kind: "file" }]
+ // ... userEvent.type(textarea, "@")
+ // ... expect rendered role="listbox" with that row
+})
+```
+
+- [ ] **Step 7: Run tests**
+
+Run: `bun test src/client/components/chat-ui/ChatInput.test.ts`
+Expected: PASS.
+
+- [ ] **Step 8: Typecheck + build**
+
+Run: `bun run check`
+Expected: PASS.
+
+- [ ] **Step 9: Commit**
+
+```bash
+git add src/client/components/chat-ui/ChatInput.tsx src/client/components/chat-ui/ChatInput.test.ts
+git commit -m "feat(chat-ui): wire @ mention picker into ChatInput"
+```
+
+---
+
+## Task 9.5 (Optional) — Server fills `absolutePath` for mention attachments
+
+**Files:**
+- Modify: `src/server/agent.ts` (or wherever `ChatAttachment[]` is normalized before `buildAttachmentHintText`)
+
+**When to do this:** Only if manual verification (Task 10) shows that the agent doesn't read mentioned files reliably with `absolutePath=""`.
+
+- [ ] **Step 1: Locate the attachment normalization call site**
+
+Grep for `buildAttachmentHintText(` in `src/server/agent.ts`. You'll find 1-2 call sites in the send path.
+
+- [ ] **Step 2: Add server-side fill**
+
+Before calling `buildAttachmentHintText`, map mentions to have absolute paths:
+
+```ts
+const filledAttachments = attachments.map((attachment) => {
+ if (attachment.kind !== "mention" || attachment.absolutePath) return attachment
+ const relative = attachment.relativePath.startsWith("./")
+ ? attachment.relativePath.slice(2)
+ : attachment.relativePath
+ return {
+ ...attachment,
+ absolutePath: path.resolve(project.localPath, relative),
+ }
+})
+```
+
+Pass `filledAttachments` into `buildAttachmentHintText` instead of the raw `attachments`.
+
+- [ ] **Step 3: Extend existing agent test**
+
+Add to `src/server/agent.test.ts`:
+
+```ts
+test("mention attachments get server-filled absolutePath", () => {
+ // Construct a minimal test that passes a mention attachment with empty
+ // absolutePath into whichever exported function handles send-path
+ // normalization. Assert the rendered prompt contains the resolved path.
+})
+```
+
+- [ ] **Step 4: Run tests + typecheck**
+
+Run: `bun test src/server/agent.test.ts && bun run check`
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/server/agent.ts src/server/agent.test.ts
+git commit -m "feat(agent): resolve absolutePath for mention attachments server-side"
+```
+
+---
+
+## Task 10 — Manual verification
+
+- [ ] **Step 1: Start dev server**
+
+```bash
+bun run dev
+```
+
+- [ ] **Step 2: Verify behaviors**
+
+Open a Kanna chat on a git project:
+
+1. Type `@` at the start — picker opens with top-level entries (files and dirs).
+2. Type `@src` — picker fuzzy-filters to entries starting with `src`.
+3. `↑` / `↓` navigate, `Enter` accepts — input becomes `@src/agent.ts`, attachment chip appears.
+4. `Esc` while picker open — picker closes, input preserved.
+5. Type `foo@bar` (mid-word `@`) — picker does NOT open.
+6. Type `/` at start — slash picker opens, `@` picker does NOT fight for focus.
+7. Send the message. In the transcript, confirm the attachment chip renders. Check server logs (or hydrated prompt) contain ` `.
+8. Confirm the agent responds to the referenced file (Claude calls Read on it, or Codex acknowledges the path).
+9. Open a Codex chat and repeat step 1-3. Picker should work the same.
+10. Open a chat on a non-git directory. Picker still returns paths (readdir walk).
+
+- [ ] **Step 3: If any step fails**
+
+Invoke the `superpowers:systematic-debugging` skill. Do not skip.
+
+- [ ] **Step 4: Stop dev server**
+
+`Ctrl+C`.
+
+---
+
+## Task 11 — Final verification + PR prep
+
+- [ ] **Step 1: Full check + test**
+
+```bash
+bun run check
+bun test
+```
+
+Both: PASS.
+
+- [ ] **Step 2: Commit any incidental formatting**
+
+If any files changed from save-on-format, commit with `chore: format`. Otherwise skip.
+
+- [ ] **Step 3: Push branch**
+
+```bash
+git push -u origin feature/at-mention-picker
+```
+
+- [ ] **Step 4: Report completion**
+
+Announce: branch `feature/at-mention-picker`, all tasks complete, tests green. Offer to run `superpowers:finishing-a-development-branch` for merge / PR path.
+
+---
+
+## Skills to consult
+
+- `superpowers:test-driven-development` — every task that touches logic.
+- `superpowers:systematic-debugging` — if anything misbehaves in Task 10.
+- `superpowers:verification-before-completion` — before announcing Task 11 done.
+- `superpowers:finishing-a-development-branch` — after Task 11.
diff --git a/docs/superpowers/plans/2026-04-22-auto-continue-on-rate-limit.md b/docs/superpowers/plans/2026-04-22-auto-continue-on-rate-limit.md
new file mode 100644
index 000000000..08e76567d
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-22-auto-continue-on-rate-limit.md
@@ -0,0 +1,2858 @@
+# Auto-Continue on Rate-Limit Reset Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** When Claude or Codex returns a rate-limit error with a reset time, offer (or silently schedule) a `"continue"` user message at that time and auto-send it when the timer fires.
+
+**Architecture:** A new event-sourced subsystem under `src/server/auto-continue/`. Provider-specific `LimitDetector`s convert structured SDK / JSON-RPC errors into `{ resetAt, tz }` tuples; a `ScheduleManager` owns in-memory `setTimeout`s and is the single wall-clock authority. Persistence is a new `schedules.jsonl` log plus a field on the chat snapshot; on startup the manager rehydrates timers from replayed state. The chat transcript gains one new entry kind (`auto_continue_prompt`) whose live state is looked up in `chat.schedules[scheduleId]`. A new Zustand preference gates whether the server auto-accepts or emits a proposal.
+
+**Tech Stack:** Bun 1.3.5 + TypeScript 5.8 + React 19 + Zustand (with `persist`) + event-sourced JSONL server + Claude Agent SDK + Codex App Server JSON-RPC. Tests run via `bun test`.
+
+---
+
+## File Structure
+
+**New files**
+
+| Path | Responsibility |
+|---|---|
+| `src/server/auto-continue/events.ts` | `AutoContinueEvent` discriminated union + snapshot entry type. |
+| `src/server/auto-continue/limit-detector.ts` | `ClaudeLimitDetector` + `CodexLimitDetector` — pure functions from error → `LimitDetection \| null`. |
+| `src/server/auto-continue/schedule-manager.ts` | Owns `Map`. Arms / clears / rehydrates / fires. Takes an injected `Clock` for tests. |
+| `src/server/auto-continue/limit-detector.test.ts` | Unit tests with captured real error shapes. |
+| `src/server/auto-continue/schedule-manager.test.ts` | Unit tests with fake clock. |
+| `src/server/auto-continue/read-model.ts` | `deriveChatSchedules(events)` — pure reducer that projects the event log into `chat.schedules` / `chat.liveSchedule`. |
+| `src/server/auto-continue/read-model.test.ts` | State-machine transition tests. |
+| `src/client/components/chat-ui/AutoContinueCard.tsx` | Four-state React card (proposed / scheduled / fired / cancelled). |
+| `src/client/components/chat-ui/AutoContinueCard.test.tsx` | Component tests for rendering + input validation + WS dispatch. |
+| `src/client/lib/autoContinueTime.ts` | `formatLocal(ms, tz)` / `parseLocal(input, tz)` — `dd/mm/yyyy hh:mm`. |
+| `src/client/lib/autoContinueTime.test.ts` | Pure format/parse tests. |
+
+**Modified files**
+
+| Path | What changes |
+|---|---|
+| `src/shared/types.ts` | New `AutoContinuePromptEntry` transcript kind, extend `TranscriptEntry`, extend `UserPromptEntry` with `autoContinue?: { scheduleId: string }`, extend `ChatSnapshot` with `schedules` + `liveScheduleId`. |
+| `src/shared/protocol.ts` | Three new `ClientCommand` variants: `autoContinue.accept`, `autoContinue.reschedule`, `autoContinue.cancel`. |
+| `src/server/events.ts` | Export `AutoContinueEvent` through `StoreEvent`; extend `StoreState` with `schedulesByChatId`. Extend `SnapshotFile.v` → `3` with `schedules` field + bump `STORE_VERSION`. |
+| `src/server/event-store.ts` | New `schedulesLogPath`, extend `applyEvent` switch, extend `createSnapshot`, expose `appendAutoContinueEvent`. |
+| `src/server/read-models.ts` | In `deriveChatSnapshot`: add `schedules` + `liveScheduleId` fields. |
+| `src/server/agent.ts` | Constructor takes `ScheduleManager` + `autoResumePreference: () => boolean`; detect limit errors in both runtime catch blocks (Claude stream + Codex run). |
+| `src/server/ws-router.ts` | Route three new commands; on chat.delete, cancel live schedules. |
+| `src/server/cli-runtime.ts` (or wherever `AgentCoordinator` + `EventStore` are wired) | Instantiate `ScheduleManager`; call `rehydrate()` after event replay. |
+| `src/client/stores/preferences.ts` (new file) | Zustand store with `autoResumeOnRateLimit: boolean`. |
+| `src/client/app/SettingsPage.tsx` | Toggle row in General section. |
+| `src/client/lib/parseTranscript.ts` | Handle `auto_continue_prompt` entry; add `autoContinue?: { scheduleId }` to user-prompt passthrough. |
+| `src/client/components/chat-ui/KannaTranscript.tsx` (or renderer) | Render `auto_continue_prompt` messages via `AutoContinueCard` + render "auto-sent" badge on user prompts carrying `autoContinue`. |
+
+---
+
+## Task 1: Shared types for auto-continue
+
+**Files:**
+- Modify: `src/shared/types.ts`
+
+- [ ] **Step 1: Add `AutoContinueSchedule` + `AutoContinuePromptEntry` + extend unions**
+
+Open `src/shared/types.ts`. Bump the store version and add the new types.
+
+Change line 1:
+
+```ts
+export const STORE_VERSION = 3 as const
+```
+
+After the `PendingToolSnapshot` interface (near end of file), append:
+
+```ts
+export type AutoContinueScheduleState = "proposed" | "scheduled" | "fired" | "cancelled"
+
+export interface AutoContinueSchedule {
+ scheduleId: string
+ state: AutoContinueScheduleState
+ scheduledAt: number | null
+ tz: string
+ resetAt: number
+ detectedAt: number
+}
+
+export interface AutoContinuePromptEntry extends TranscriptEntryBase {
+ kind: "auto_continue_prompt"
+ scheduleId: string
+}
+```
+
+Find the `TranscriptEntry` union (`export type TranscriptEntry =`) and add `| AutoContinuePromptEntry` as the last variant.
+
+Find `UserPromptEntry` (line ~479) and add one optional field:
+
+```ts
+export interface UserPromptEntry extends TranscriptEntryBase {
+ kind: "user_prompt"
+ content: string
+ attachments?: ChatAttachment[]
+ steered?: boolean
+ autoContinue?: { scheduleId: string }
+}
+```
+
+Find `ChatSnapshot` (line ~878) and add two fields:
+
+```ts
+export interface ChatSnapshot {
+ runtime: ChatRuntime
+ queuedMessages: QueuedChatMessage[]
+ messages: TranscriptEntry[]
+ history: ChatHistorySnapshot
+ availableProviders: ProviderCatalogEntry[]
+ slashCommands: SlashCommand[]
+ slashCommandsLoading: boolean
+ schedules: Record
+ liveScheduleId: string | null
+}
+```
+
+In `HydratedTranscriptMessage`, add:
+
+```ts
+ | ({ kind: "auto_continue_prompt"; scheduleId: string; id: string; messageId?: string; timestamp: string; hidden?: boolean })
+```
+
+In the `user_prompt` branch of `HydratedTranscriptMessage` (the object literal variant), add `autoContinue?: { scheduleId: string }`.
+
+- [ ] **Step 2: Run type-check to make sure nothing else breaks**
+
+Run: `bun run check`
+Expected: errors only in the files we plan to modify next (agent.ts, read-models.ts, parseTranscript.ts, etc.). No syntax errors in `types.ts` itself.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/shared/types.ts
+git commit -m "feat(auto-continue): add shared types and bump STORE_VERSION"
+```
+
+---
+
+## Task 2: AutoContinueEvent shape
+
+**Files:**
+- Create: `src/server/auto-continue/events.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `src/server/auto-continue/events.test.ts`:
+
+```ts
+import { describe, expect, test } from "bun:test"
+import type { AutoContinueEvent } from "./events"
+
+describe("AutoContinueEvent", () => {
+ test("covers the five lifecycle kinds", () => {
+ const kinds: AutoContinueEvent["kind"][] = [
+ "auto_continue_proposed",
+ "auto_continue_accepted",
+ "auto_continue_rescheduled",
+ "auto_continue_cancelled",
+ "auto_continue_fired",
+ ]
+ expect(kinds.length).toBe(5)
+ })
+
+ test("proposed event carries reset + tz metadata", () => {
+ const event: AutoContinueEvent = {
+ v: 3,
+ kind: "auto_continue_proposed",
+ timestamp: 1_000,
+ chatId: "c1",
+ scheduleId: "s1",
+ detectedAt: 1_000,
+ resetAt: 2_000,
+ tz: "Asia/Saigon",
+ turnId: "t1",
+ }
+ expect(event.tz).toBe("Asia/Saigon")
+ })
+})
+```
+
+- [ ] **Step 2: Run the test**
+
+Run: `bun test src/server/auto-continue/events.test.ts`
+Expected: FAIL — module `./events` not found.
+
+- [ ] **Step 3: Create the events module**
+
+Create `src/server/auto-continue/events.ts`:
+
+```ts
+export type AutoContinueEvent =
+ | {
+ v: 3
+ kind: "auto_continue_proposed"
+ timestamp: number
+ chatId: string
+ scheduleId: string
+ detectedAt: number
+ resetAt: number
+ tz: string
+ turnId: string
+ }
+ | {
+ v: 3
+ kind: "auto_continue_accepted"
+ timestamp: number
+ chatId: string
+ scheduleId: string
+ scheduledAt: number
+ tz: string
+ source: "user" | "auto_setting"
+ resetAt: number
+ detectedAt: number
+ }
+ | {
+ v: 3
+ kind: "auto_continue_rescheduled"
+ timestamp: number
+ chatId: string
+ scheduleId: string
+ scheduledAt: number
+ }
+ | {
+ v: 3
+ kind: "auto_continue_cancelled"
+ timestamp: number
+ chatId: string
+ scheduleId: string
+ reason: "user" | "chat_deleted"
+ }
+ | {
+ v: 3
+ kind: "auto_continue_fired"
+ timestamp: number
+ chatId: string
+ scheduleId: string
+ firedAt: number
+ }
+```
+
+Note: `auto_continue_accepted` carries `resetAt` and `detectedAt` redundantly so the read model can project full `AutoContinueSchedule` state without having to fold the earlier `proposed` event first (important for the auto-resume path, which emits `accepted` directly without a `proposed`).
+
+- [ ] **Step 4: Run the test**
+
+Run: `bun test src/server/auto-continue/events.test.ts`
+Expected: PASS (2 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/server/auto-continue/events.ts src/server/auto-continue/events.test.ts
+git commit -m "feat(auto-continue): define AutoContinueEvent union"
+```
+
+---
+
+## Task 3: Pure read-model reducer
+
+**Files:**
+- Create: `src/server/auto-continue/read-model.ts`
+- Test: `src/server/auto-continue/read-model.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `src/server/auto-continue/read-model.test.ts`:
+
+```ts
+import { describe, expect, test } from "bun:test"
+import { deriveChatSchedules } from "./read-model"
+import type { AutoContinueEvent } from "./events"
+
+function proposed(chatId: string, scheduleId: string, at = 1_000): AutoContinueEvent {
+ return {
+ v: 3,
+ kind: "auto_continue_proposed",
+ timestamp: at,
+ chatId,
+ scheduleId,
+ detectedAt: at,
+ resetAt: at + 10_000,
+ tz: "Asia/Saigon",
+ turnId: "turn-1",
+ }
+}
+
+function accepted(chatId: string, scheduleId: string, at = 2_000, source: "user" | "auto_setting" = "user"): AutoContinueEvent {
+ return {
+ v: 3,
+ kind: "auto_continue_accepted",
+ timestamp: at,
+ chatId,
+ scheduleId,
+ scheduledAt: at + 10_000,
+ tz: "Asia/Saigon",
+ source,
+ resetAt: at + 10_000,
+ detectedAt: at,
+ }
+}
+
+describe("deriveChatSchedules", () => {
+ test("empty event list returns empty map + null live", () => {
+ const result = deriveChatSchedules([])
+ expect(result.schedules).toEqual({})
+ expect(result.liveScheduleId).toBeNull()
+ })
+
+ test("proposed event yields state=proposed with liveScheduleId set", () => {
+ const result = deriveChatSchedules([proposed("c1", "s1")])
+ expect(result.schedules["s1"].state).toBe("proposed")
+ expect(result.schedules["s1"].scheduledAt).toBeNull()
+ expect(result.liveScheduleId).toBe("s1")
+ })
+
+ test("accept after propose promotes to scheduled", () => {
+ const result = deriveChatSchedules([proposed("c1", "s1"), accepted("c1", "s1")])
+ expect(result.schedules["s1"].state).toBe("scheduled")
+ expect(result.schedules["s1"].scheduledAt).toBe(12_000)
+ expect(result.liveScheduleId).toBe("s1")
+ })
+
+ test("accept with source=auto_setting without prior proposed still produces scheduled", () => {
+ const result = deriveChatSchedules([accepted("c1", "s1", 1_500, "auto_setting")])
+ expect(result.schedules["s1"].state).toBe("scheduled")
+ expect(result.schedules["s1"].resetAt).toBe(11_500)
+ expect(result.liveScheduleId).toBe("s1")
+ })
+
+ test("cancelled schedule is terminal and not live", () => {
+ const result = deriveChatSchedules([
+ proposed("c1", "s1"),
+ accepted("c1", "s1"),
+ { v: 3, kind: "auto_continue_cancelled", timestamp: 3_000, chatId: "c1", scheduleId: "s1", reason: "user" },
+ ])
+ expect(result.schedules["s1"].state).toBe("cancelled")
+ expect(result.liveScheduleId).toBeNull()
+ })
+
+ test("fired schedule is terminal and retains scheduledAt", () => {
+ const result = deriveChatSchedules([
+ proposed("c1", "s1"),
+ accepted("c1", "s1"),
+ { v: 3, kind: "auto_continue_fired", timestamp: 12_000, chatId: "c1", scheduleId: "s1", firedAt: 12_000 },
+ ])
+ expect(result.schedules["s1"].state).toBe("fired")
+ expect(result.schedules["s1"].scheduledAt).toBe(12_000)
+ expect(result.liveScheduleId).toBeNull()
+ })
+
+ test("live schedule tracks most recent non-terminal", () => {
+ const result = deriveChatSchedules([
+ proposed("c1", "s1", 1_000),
+ { v: 3, kind: "auto_continue_cancelled", timestamp: 1_100, chatId: "c1", scheduleId: "s1", reason: "user" },
+ proposed("c1", "s2", 2_000),
+ ])
+ expect(result.schedules["s1"].state).toBe("cancelled")
+ expect(result.schedules["s2"].state).toBe("proposed")
+ expect(result.liveScheduleId).toBe("s2")
+ })
+
+ test("reschedule updates scheduledAt without changing state", () => {
+ const result = deriveChatSchedules([
+ proposed("c1", "s1"),
+ accepted("c1", "s1"),
+ { v: 3, kind: "auto_continue_rescheduled", timestamp: 2_500, chatId: "c1", scheduleId: "s1", scheduledAt: 20_000 },
+ ])
+ expect(result.schedules["s1"].state).toBe("scheduled")
+ expect(result.schedules["s1"].scheduledAt).toBe(20_000)
+ })
+
+ test("events for different chats produce independent results", () => {
+ const events = [proposed("c1", "s1"), proposed("c2", "s2")]
+ expect(deriveChatSchedules(events, "c1").liveScheduleId).toBe("s1")
+ expect(deriveChatSchedules(events, "c2").liveScheduleId).toBe("s2")
+ })
+})
+```
+
+- [ ] **Step 2: Run the test**
+
+Run: `bun test src/server/auto-continue/read-model.test.ts`
+Expected: FAIL — `deriveChatSchedules` not exported.
+
+- [ ] **Step 3: Implement the reducer**
+
+Create `src/server/auto-continue/read-model.ts`:
+
+```ts
+import type { AutoContinueSchedule } from "../../shared/types"
+import type { AutoContinueEvent } from "./events"
+
+export interface ChatSchedulesProjection {
+ schedules: Record
+ liveScheduleId: string | null
+}
+
+const EMPTY: ChatSchedulesProjection = { schedules: {}, liveScheduleId: null }
+
+export function deriveChatSchedules(
+ events: readonly AutoContinueEvent[],
+ chatId?: string
+): ChatSchedulesProjection {
+ const schedules: Record = {}
+ for (const event of events) {
+ if (chatId && event.chatId !== chatId) continue
+ applyOne(schedules, event)
+ }
+
+ let liveScheduleId: string | null = null
+ let liveOrder = -1
+ let order = 0
+ for (const event of events) {
+ order += 1
+ if (chatId && event.chatId !== chatId) continue
+ const schedule = schedules[event.scheduleId]
+ if (!schedule) continue
+ if (schedule.state !== "proposed" && schedule.state !== "scheduled") continue
+ if (order > liveOrder) {
+ liveOrder = order
+ liveScheduleId = schedule.scheduleId
+ }
+ }
+
+ return schedules === EMPTY.schedules && liveScheduleId === null
+ ? EMPTY
+ : { schedules, liveScheduleId }
+}
+
+function applyOne(schedules: Record, event: AutoContinueEvent) {
+ switch (event.kind) {
+ case "auto_continue_proposed":
+ schedules[event.scheduleId] = {
+ scheduleId: event.scheduleId,
+ state: "proposed",
+ scheduledAt: null,
+ tz: event.tz,
+ resetAt: event.resetAt,
+ detectedAt: event.detectedAt,
+ }
+ return
+ case "auto_continue_accepted":
+ schedules[event.scheduleId] = {
+ scheduleId: event.scheduleId,
+ state: "scheduled",
+ scheduledAt: event.scheduledAt,
+ tz: event.tz,
+ resetAt: event.resetAt,
+ detectedAt: event.detectedAt,
+ }
+ return
+ case "auto_continue_rescheduled": {
+ const existing = schedules[event.scheduleId]
+ if (!existing) return
+ schedules[event.scheduleId] = { ...existing, scheduledAt: event.scheduledAt }
+ return
+ }
+ case "auto_continue_cancelled": {
+ const existing = schedules[event.scheduleId]
+ if (!existing) return
+ schedules[event.scheduleId] = { ...existing, state: "cancelled" }
+ return
+ }
+ case "auto_continue_fired": {
+ const existing = schedules[event.scheduleId]
+ if (!existing) {
+ schedules[event.scheduleId] = {
+ scheduleId: event.scheduleId,
+ state: "fired",
+ scheduledAt: event.firedAt,
+ tz: "system",
+ resetAt: event.firedAt,
+ detectedAt: event.firedAt,
+ }
+ return
+ }
+ schedules[event.scheduleId] = { ...existing, state: "fired", scheduledAt: event.firedAt }
+ return
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run the test**
+
+Run: `bun test src/server/auto-continue/read-model.test.ts`
+Expected: PASS (9 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/server/auto-continue/read-model.ts src/server/auto-continue/read-model.test.ts
+git commit -m "feat(auto-continue): pure read-model reducer with tests"
+```
+
+---
+
+## Task 4: Limit detector — Claude
+
+**Files:**
+- Create: `src/server/auto-continue/limit-detector.ts`
+- Test: `src/server/auto-continue/limit-detector.test.ts`
+
+**Background:** The Claude Agent SDK surfaces rate-limit failures as JS `Error`s whose message embeds a JSON payload. The payload has `type: "error"` and `error.type: "rate_limit_error"` with a `headers['anthropic-ratelimit-unified-reset']` ISO-8601 timestamp. Some errors also attach a `.status === 429` and `.headers` map. When no IANA tz is present in the payload, fall back to `"system"` (display uses the server's local zone).
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `src/server/auto-continue/limit-detector.test.ts`:
+
+```ts
+import { describe, expect, test } from "bun:test"
+import { ClaudeLimitDetector } from "./limit-detector"
+
+const detector = new ClaudeLimitDetector()
+
+function anthropicError(body: Record, headers: Record = {}) {
+ const error = new Error(JSON.stringify(body)) as Error & { status?: number; headers?: Record }
+ error.status = 429
+ error.headers = headers
+ return error
+}
+
+describe("ClaudeLimitDetector", () => {
+ test("returns null for non-rate-limit errors", () => {
+ const err = new Error("Something unrelated went wrong")
+ expect(detector.detect("c1", err)).toBeNull()
+ })
+
+ test("detects rate limit with ISO reset timestamp in headers", () => {
+ const resetIso = "2026-04-23T00:00:00+07:00"
+ const err = anthropicError(
+ { type: "error", error: { type: "rate_limit_error", message: "You've hit your limit · resets 12am (Asia/Saigon)" } },
+ { "anthropic-ratelimit-unified-reset": resetIso, "x-anthropic-timezone": "Asia/Saigon" }
+ )
+ const detection = detector.detect("c1", err)
+ expect(detection).not.toBeNull()
+ expect(detection!.chatId).toBe("c1")
+ expect(detection!.resetAt).toBe(new Date(resetIso).getTime())
+ expect(detection!.tz).toBe("Asia/Saigon")
+ })
+
+ test("falls back to tz=system when no timezone header is present", () => {
+ const resetIso = "2026-04-23T05:00:00Z"
+ const err = anthropicError(
+ { type: "error", error: { type: "rate_limit_error" } },
+ { "anthropic-ratelimit-unified-reset": resetIso }
+ )
+ const detection = detector.detect("c1", err)
+ expect(detection!.tz).toBe("system")
+ })
+
+ test("returns null when the payload is rate-limit but no reset timestamp can be parsed", () => {
+ const err = anthropicError({ type: "error", error: { type: "rate_limit_error" } })
+ expect(detector.detect("c1", err)).toBeNull()
+ })
+
+ test("parses resetAt from the message body when headers are absent", () => {
+ const resetIso = "2026-04-23T00:00:00+07:00"
+ const err = new Error(JSON.stringify({
+ type: "error",
+ error: {
+ type: "rate_limit_error",
+ resets_at: resetIso,
+ timezone: "Asia/Saigon",
+ },
+ }))
+ const detection = detector.detect("c1", err)
+ expect(detection!.resetAt).toBe(new Date(resetIso).getTime())
+ expect(detection!.tz).toBe("Asia/Saigon")
+ })
+
+ test("does not match on status-only errors (400, 500, etc.)", () => {
+ const err = anthropicError({ type: "error", error: { type: "overloaded_error" } })
+ expect(detector.detect("c1", err)).toBeNull()
+ })
+})
+```
+
+- [ ] **Step 2: Run the test**
+
+Run: `bun test src/server/auto-continue/limit-detector.test.ts`
+Expected: FAIL — `ClaudeLimitDetector` not exported.
+
+- [ ] **Step 3: Implement the detector**
+
+Create `src/server/auto-continue/limit-detector.ts`:
+
+```ts
+export interface LimitDetection {
+ chatId: string
+ resetAt: number
+ tz: string
+ raw: unknown
+}
+
+export interface LimitDetector {
+ detect(chatId: string, error: unknown): LimitDetection | null
+}
+
+interface ErrorLike {
+ message?: string
+ status?: number
+ headers?: Record
+}
+
+function extractHeaders(error: unknown): Record {
+ if (error && typeof error === "object" && "headers" in error) {
+ const headers = (error as ErrorLike).headers
+ if (headers && typeof headers === "object") return headers
+ }
+ return {}
+}
+
+function parseBody(error: unknown): Record | null {
+ if (!error || typeof error !== "object") return null
+ const message = (error as ErrorLike).message
+ if (!message) return null
+ try {
+ const parsed = JSON.parse(message)
+ return parsed && typeof parsed === "object" ? (parsed as Record) : null
+ } catch {
+ return null
+ }
+}
+
+function parseIsoMillis(value: unknown): number | null {
+ if (typeof value !== "string" || !value) return null
+ const millis = new Date(value).getTime()
+ return Number.isFinite(millis) ? millis : null
+}
+
+export class ClaudeLimitDetector implements LimitDetector {
+ detect(chatId: string, error: unknown): LimitDetection | null {
+ const body = parseBody(error)
+ const inner = body && typeof body.error === "object" && body.error !== null
+ ? (body.error as Record)
+ : null
+ const isRateLimit = inner?.type === "rate_limit_error"
+ || (error as ErrorLike | null)?.status === 429 && inner?.type === "rate_limit_error"
+ if (!isRateLimit) return null
+
+ const headers = extractHeaders(error)
+ const resetAt = parseIsoMillis(headers["anthropic-ratelimit-unified-reset"])
+ ?? parseIsoMillis(inner?.resets_at)
+ ?? parseIsoMillis(inner?.reset_at)
+ if (resetAt === null) return null
+
+ const tz = headers["x-anthropic-timezone"]
+ ?? (typeof inner?.timezone === "string" ? (inner.timezone as string) : null)
+ ?? "system"
+
+ return { chatId, resetAt, tz, raw: error }
+ }
+}
+```
+
+- [ ] **Step 4: Run the test**
+
+Run: `bun test src/server/auto-continue/limit-detector.test.ts`
+Expected: PASS (6 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/server/auto-continue/limit-detector.ts src/server/auto-continue/limit-detector.test.ts
+git commit -m "feat(auto-continue): Claude limit detector"
+```
+
+---
+
+## Task 5: Limit detector — Codex
+
+**Files:**
+- Modify: `src/server/auto-continue/limit-detector.ts`
+- Modify: `src/server/auto-continue/limit-detector.test.ts`
+
+**Background:** The Codex App Server returns JSON-RPC errors. Rate-limit errors have `error.code === -32001` or `error.data.code === "rate_limit"` (confirm against captured examples at integration time). The reset timestamp is in `error.data.resets_at_ms` (epoch ms) or `error.data.resets_at` (ISO). Timezone is in `error.data.timezone`. If only the epoch-ms form is present, tz falls back to `"system"`.
+
+- [ ] **Step 1: Add the failing tests**
+
+Append to `src/server/auto-continue/limit-detector.test.ts`:
+
+```ts
+import { CodexLimitDetector } from "./limit-detector"
+
+const codex = new CodexLimitDetector()
+
+describe("CodexLimitDetector", () => {
+ test("returns null for non-rate-limit JSON-RPC errors", () => {
+ const err = { code: -32601, message: "Method not found" }
+ expect(codex.detect("c1", err)).toBeNull()
+ })
+
+ test("detects rate limit from error.data.code with epoch-ms reset", () => {
+ const err = {
+ code: -32001,
+ message: "Rate limited",
+ data: { code: "rate_limit", resets_at_ms: 2_000_000, timezone: "Asia/Saigon" },
+ }
+ const detection = codex.detect("c1", err)
+ expect(detection!.resetAt).toBe(2_000_000)
+ expect(detection!.tz).toBe("Asia/Saigon")
+ })
+
+ test("detects rate limit with ISO resets_at", () => {
+ const resetIso = "2026-04-23T00:00:00+07:00"
+ const err = {
+ code: -32001,
+ message: "Rate limited",
+ data: { code: "rate_limit", resets_at: resetIso },
+ }
+ const detection = codex.detect("c1", err)
+ expect(detection!.resetAt).toBe(new Date(resetIso).getTime())
+ expect(detection!.tz).toBe("system")
+ })
+
+ test("returns null when no reset timestamp can be parsed", () => {
+ const err = { code: -32001, data: { code: "rate_limit" } }
+ expect(codex.detect("c1", err)).toBeNull()
+ })
+})
+```
+
+- [ ] **Step 2: Run the test**
+
+Run: `bun test src/server/auto-continue/limit-detector.test.ts`
+Expected: FAIL — `CodexLimitDetector` not exported.
+
+- [ ] **Step 3: Implement the detector**
+
+Append to `src/server/auto-continue/limit-detector.ts`:
+
+```ts
+interface JsonRpcErrorLike {
+ code?: number
+ message?: string
+ data?: Record
+}
+
+export class CodexLimitDetector implements LimitDetector {
+ detect(chatId: string, error: unknown): LimitDetection | null {
+ if (!error || typeof error !== "object") return null
+ const rpc = error as JsonRpcErrorLike
+ const data = rpc.data && typeof rpc.data === "object" ? rpc.data : null
+ const isRateLimit = data?.code === "rate_limit" || rpc.code === -32001
+ if (!isRateLimit) return null
+
+ let resetAt: number | null = null
+ if (typeof data?.resets_at_ms === "number" && Number.isFinite(data.resets_at_ms)) {
+ resetAt = data.resets_at_ms
+ } else {
+ resetAt = parseIsoMillis(data?.resets_at)
+ }
+ if (resetAt === null) return null
+
+ const tz = typeof data?.timezone === "string" ? (data.timezone as string) : "system"
+ return { chatId, resetAt, tz, raw: error }
+ }
+}
+```
+
+- [ ] **Step 4: Run the test**
+
+Run: `bun test src/server/auto-continue/limit-detector.test.ts`
+Expected: PASS (10 tests total).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/server/auto-continue/limit-detector.ts src/server/auto-continue/limit-detector.test.ts
+git commit -m "feat(auto-continue): Codex limit detector"
+```
+
+---
+
+## Task 6: Extend EventStore with schedules.jsonl
+
+**Files:**
+- Modify: `src/server/events.ts`
+- Modify: `src/server/event-store.ts`
+- Test: `src/server/event-store.test.ts` (append cases)
+
+- [ ] **Step 1: Write the failing test**
+
+Append to `src/server/event-store.test.ts`:
+
+```ts
+import type { AutoContinueEvent } from "./auto-continue/events"
+
+describe("EventStore auto-continue schedules", () => {
+ test("appends and replays AutoContinueEvent sequence", async () => {
+ const dataDir = await createTempDataDir()
+ const store = new EventStore(dataDir)
+ await store.initialize()
+ const project = await store.openProject("/tmp/p1")
+ const chat = await store.createChat(project.id)
+
+ const proposed: AutoContinueEvent = {
+ v: 3,
+ kind: "auto_continue_proposed",
+ timestamp: 1_000,
+ chatId: chat.id,
+ scheduleId: "s1",
+ detectedAt: 1_000,
+ resetAt: 2_000,
+ tz: "Asia/Saigon",
+ turnId: "t1",
+ }
+ const accepted: AutoContinueEvent = {
+ v: 3,
+ kind: "auto_continue_accepted",
+ timestamp: 1_100,
+ chatId: chat.id,
+ scheduleId: "s1",
+ scheduledAt: 2_000,
+ tz: "Asia/Saigon",
+ source: "user",
+ resetAt: 2_000,
+ detectedAt: 1_000,
+ }
+ await store.appendAutoContinueEvent(proposed)
+ await store.appendAutoContinueEvent(accepted)
+
+ const rehydrated = new EventStore(dataDir)
+ await rehydrated.initialize()
+ const events = rehydrated.getAutoContinueEvents(chat.id)
+ expect(events).toHaveLength(2)
+ expect(events[0].kind).toBe("auto_continue_proposed")
+ expect(events[1].kind).toBe("auto_continue_accepted")
+ })
+
+ test("snapshot compaction retains auto-continue events", async () => {
+ const dataDir = await createTempDataDir()
+ const store = new EventStore(dataDir)
+ await store.initialize()
+ const project = await store.openProject("/tmp/p1")
+ const chat = await store.createChat(project.id)
+
+ await store.appendAutoContinueEvent({
+ v: 3,
+ kind: "auto_continue_proposed",
+ timestamp: 1_000,
+ chatId: chat.id,
+ scheduleId: "s1",
+ detectedAt: 1_000,
+ resetAt: 2_000,
+ tz: "Asia/Saigon",
+ turnId: "t1",
+ })
+ await store.compact()
+
+ const rehydrated = new EventStore(dataDir)
+ await rehydrated.initialize()
+ expect(rehydrated.getAutoContinueEvents(chat.id)).toHaveLength(1)
+ })
+})
+```
+
+- [ ] **Step 2: Run the test**
+
+Run: `bun test src/server/event-store.test.ts`
+Expected: FAIL — `appendAutoContinueEvent` and `getAutoContinueEvents` not exposed.
+
+- [ ] **Step 3: Extend the event union and state**
+
+Edit `src/server/events.ts`:
+
+Add import line at top:
+
+```ts
+import type { AutoContinueEvent } from "./auto-continue/events"
+```
+
+Extend `StoreEvent`:
+
+```ts
+export type StoreEvent = ProjectEvent | ChatEvent | MessageEvent | QueuedMessageEvent | TurnEvent | AutoContinueEvent
+```
+
+Extend `StoreState`:
+
+```ts
+export interface StoreState {
+ projectsById: Map
+ projectIdsByPath: Map
+ chatsById: Map
+ queuedMessagesByChatId: Map
+ sidebarProjectOrder: string[]
+ autoContinueEventsByChatId: Map
+}
+```
+
+Extend `SnapshotFile` and bump version to 3:
+
+```ts
+export interface SnapshotFile {
+ v: 3
+ generatedAt: number
+ projects: ProjectRecord[]
+ chats: ChatRecord[]
+ sidebarProjectOrder?: string[]
+ queuedMessages?: Array<{ chatId: string; entries: QueuedChatMessage[] }>
+ messages?: Array<{ chatId: string; entries: TranscriptEntry[] }>
+ autoContinueEvents?: Array<{ chatId: string; events: AutoContinueEvent[] }>
+}
+```
+
+Update `createEmptyState`:
+
+```ts
+export function createEmptyState(): StoreState {
+ return {
+ projectsById: new Map(),
+ projectIdsByPath: new Map(),
+ chatsById: new Map(),
+ queuedMessagesByChatId: new Map(),
+ sidebarProjectOrder: [],
+ autoContinueEventsByChatId: new Map(),
+ }
+}
+```
+
+- [ ] **Step 4: Extend EventStore with append/get + replay/snapshot**
+
+Edit `src/server/event-store.ts`:
+
+Add near other `private readonly ... LogPath` lines:
+
+```ts
+ private readonly schedulesLogPath: string
+```
+
+Set it in the constructor:
+
+```ts
+ this.schedulesLogPath = path.join(this.dataDir, "schedules.jsonl")
+```
+
+In `initialize()` after existing `ensureFile` calls:
+
+```ts
+ await this.ensureFile(this.schedulesLogPath)
+```
+
+In `clearStorage()` add `Bun.write(this.schedulesLogPath, "")` to the Promise.all list.
+
+In `replayLogs()` extend the sourceIndex list so schedules replay alongside others. Add:
+
+```ts
+ ...await this.loadReplayEvents(this.schedulesLogPath, 5),
+```
+
+Add entries to `getReplayEventPriority` switch:
+
+```ts
+ case "auto_continue_proposed":
+ case "auto_continue_accepted":
+ case "auto_continue_rescheduled":
+ case "auto_continue_cancelled":
+ case "auto_continue_fired":
+ return 11
+```
+
+Note: `getReplayEventPriority` currently switches on `event.type`. `AutoContinueEvent` uses `kind` instead. Change the priority lookup to handle both:
+
+```ts
+function getReplayEventPriority(event: StoreEvent) {
+ const discriminator = "type" in event ? event.type : event.kind
+ switch (discriminator) {
+ // ... existing cases
+ case "auto_continue_proposed":
+ case "auto_continue_accepted":
+ case "auto_continue_rescheduled":
+ case "auto_continue_cancelled":
+ case "auto_continue_fired":
+ return 11
+ }
+}
+```
+
+Similarly extend `applyEvent`:
+
+```ts
+ private applyEvent(event: StoreEvent) {
+ if ("kind" in event && event.kind.startsWith("auto_continue_")) {
+ this.applyAutoContinueEvent(event)
+ return
+ }
+ switch ((event as { type: string }).type) {
+ // ... existing cases unchanged
+ }
+ }
+
+ private applyAutoContinueEvent(event: AutoContinueEvent) {
+ const existing = this.state.autoContinueEventsByChatId.get(event.chatId) ?? []
+ existing.push(event)
+ this.state.autoContinueEventsByChatId.set(event.chatId, existing)
+ }
+```
+
+Add the loadSnapshot hydration branch (inside `loadSnapshot()` after `messages` branch):
+
+```ts
+ if (parsed.autoContinueEvents?.length) {
+ for (const entry of parsed.autoContinueEvents) {
+ this.state.autoContinueEventsByChatId.set(entry.chatId, [...entry.events])
+ }
+ }
+```
+
+Add the resetState reset:
+
+```ts
+ this.state.autoContinueEventsByChatId.clear()
+```
+
+Add new public methods at the bottom of `EventStore`:
+
+```ts
+ async appendAutoContinueEvent(event: AutoContinueEvent) {
+ const payload = `${JSON.stringify(event)}\n`
+ this.writeChain = this.writeChain.then(async () => {
+ await appendFile(this.schedulesLogPath, payload, "utf8")
+ this.applyAutoContinueEvent(event)
+ })
+ return this.writeChain
+ }
+
+ getAutoContinueEvents(chatId: string): AutoContinueEvent[] {
+ const list = this.state.autoContinueEventsByChatId.get(chatId)
+ return list ? [...list] : []
+ }
+
+ listAutoContinueChats(): string[] {
+ return [...this.state.autoContinueEventsByChatId.keys()]
+ }
+```
+
+Add import:
+
+```ts
+import type { AutoContinueEvent } from "./auto-continue/events"
+```
+
+Extend `createSnapshot()`:
+
+```ts
+ private createSnapshot(): SnapshotFile {
+ return {
+ v: STORE_VERSION,
+ generatedAt: Date.now(),
+ // ... existing fields unchanged
+ autoContinueEvents: [...this.state.autoContinueEventsByChatId.entries()].map(([chatId, events]) => ({
+ chatId,
+ events: [...events],
+ })),
+ }
+ }
+```
+
+Extend `compact()` to clear the new log:
+
+```ts
+ await Promise.all([
+ Bun.write(this.projectsLogPath, ""),
+ Bun.write(this.chatsLogPath, ""),
+ Bun.write(this.messagesLogPath, ""),
+ Bun.write(this.queuedMessagesLogPath, ""),
+ Bun.write(this.turnsLogPath, ""),
+ Bun.write(this.schedulesLogPath, ""),
+ ])
+```
+
+In `shouldCompact()`, include the new file size.
+
+- [ ] **Step 5: Run the test**
+
+Run: `bun test src/server/event-store.test.ts`
+Expected: PASS (existing tests + 2 new tests).
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/server/events.ts src/server/event-store.ts src/server/event-store.test.ts
+git commit -m "feat(auto-continue): persist schedule events in schedules.jsonl"
+```
+
+---
+
+## Task 7: ScheduleManager with fake clock
+
+**Files:**
+- Create: `src/server/auto-continue/schedule-manager.ts`
+- Test: `src/server/auto-continue/schedule-manager.test.ts`
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `src/server/auto-continue/schedule-manager.test.ts`:
+
+```ts
+import { describe, expect, test } from "bun:test"
+import { ScheduleManager, type Clock } from "./schedule-manager"
+import type { AutoContinueEvent } from "./events"
+
+class FakeClock implements Clock {
+ private current = 0
+ private scheduled: Array<{ fireAt: number; fn: () => void; id: number }> = []
+ private nextId = 1
+
+ now() {
+ return this.current
+ }
+
+ setTimeout(fn: () => void, delayMs: number): number {
+ const id = this.nextId
+ this.nextId += 1
+ this.scheduled.push({ fireAt: this.current + Math.max(0, delayMs), fn, id })
+ return id
+ }
+
+ clearTimeout(id: number): void {
+ this.scheduled = this.scheduled.filter((entry) => entry.id !== id)
+ }
+
+ advance(ms: number) {
+ this.current += ms
+ const due = this.scheduled.filter((entry) => entry.fireAt <= this.current)
+ this.scheduled = this.scheduled.filter((entry) => entry.fireAt > this.current)
+ for (const { fn } of due) fn()
+ }
+
+ pending() {
+ return this.scheduled.length
+ }
+}
+
+function event(kind: AutoContinueEvent["kind"], overrides: Partial = {}): AutoContinueEvent {
+ const base = { v: 3 as const, timestamp: 0, chatId: "c1", scheduleId: "s1" }
+ switch (kind) {
+ case "auto_continue_proposed":
+ return { ...base, kind, detectedAt: 0, resetAt: 1_000, tz: "UTC", turnId: "t1", ...overrides } as AutoContinueEvent
+ case "auto_continue_accepted":
+ return { ...base, kind, scheduledAt: 1_000, tz: "UTC", source: "user", resetAt: 1_000, detectedAt: 0, ...overrides } as AutoContinueEvent
+ case "auto_continue_rescheduled":
+ return { ...base, kind, scheduledAt: 2_000, ...overrides } as AutoContinueEvent
+ case "auto_continue_cancelled":
+ return { ...base, kind, reason: "user", ...overrides } as AutoContinueEvent
+ case "auto_continue_fired":
+ return { ...base, kind, firedAt: 1_000, ...overrides } as AutoContinueEvent
+ }
+}
+
+describe("ScheduleManager", () => {
+ test("proposed event does not arm a timer", () => {
+ const clock = new FakeClock()
+ const fired: string[] = []
+ const manager = new ScheduleManager({
+ clock,
+ fire: async (chatId, scheduleId) => { fired.push(`${chatId}:${scheduleId}`) },
+ })
+ manager.onEvent(event("auto_continue_proposed"))
+ expect(clock.pending()).toBe(0)
+ expect(fired).toEqual([])
+ })
+
+ test("accepted event arms a timer that fires at scheduledAt", () => {
+ const clock = new FakeClock()
+ const fired: string[] = []
+ const manager = new ScheduleManager({
+ clock,
+ fire: async (chatId, scheduleId) => { fired.push(`${chatId}:${scheduleId}`) },
+ })
+ manager.onEvent(event("auto_continue_accepted", { scheduledAt: 1_000 }))
+ expect(clock.pending()).toBe(1)
+ clock.advance(1_000)
+ expect(fired).toEqual(["c1:s1"])
+ })
+
+ test("rescheduled replaces the pending timer", () => {
+ const clock = new FakeClock()
+ const fired: string[] = []
+ const manager = new ScheduleManager({
+ clock,
+ fire: async (_, id) => { fired.push(id) },
+ })
+ manager.onEvent(event("auto_continue_accepted", { scheduledAt: 1_000 }))
+ manager.onEvent(event("auto_continue_rescheduled", { scheduledAt: 3_000 }))
+ clock.advance(1_000)
+ expect(fired).toEqual([])
+ clock.advance(2_000)
+ expect(fired).toEqual(["s1"])
+ })
+
+ test("cancelled clears the pending timer", () => {
+ const clock = new FakeClock()
+ const fired: string[] = []
+ const manager = new ScheduleManager({
+ clock,
+ fire: async (_, id) => { fired.push(id) },
+ })
+ manager.onEvent(event("auto_continue_accepted", { scheduledAt: 1_000 }))
+ manager.onEvent(event("auto_continue_cancelled"))
+ clock.advance(1_000)
+ expect(fired).toEqual([])
+ })
+
+ test("rehydrate arms future schedules and fires past-due ones", async () => {
+ const clock = new FakeClock()
+ clock.advance(5_000)
+ const fired: string[] = []
+ const manager = new ScheduleManager({
+ clock,
+ fire: async (_, id) => { fired.push(id) },
+ })
+ manager.rehydrate([
+ event("auto_continue_accepted", { scheduleId: "past", scheduledAt: 1_000 }),
+ event("auto_continue_accepted", { scheduleId: "future", scheduledAt: 10_000 }),
+ ])
+ await Promise.resolve()
+ expect(fired).toEqual(["past"])
+ expect(clock.pending()).toBe(1)
+ clock.advance(5_000)
+ expect(fired).toEqual(["past", "future"])
+ })
+
+ test("rehydrate skips terminal states", () => {
+ const clock = new FakeClock()
+ const fired: string[] = []
+ const manager = new ScheduleManager({
+ clock,
+ fire: async (_, id) => { fired.push(id) },
+ })
+ manager.rehydrate([
+ event("auto_continue_accepted", { scheduleId: "done", scheduledAt: 1_000 }),
+ event("auto_continue_fired", { scheduleId: "done" }),
+ event("auto_continue_accepted", { scheduleId: "cancelled", scheduledAt: 1_000 }),
+ event("auto_continue_cancelled", { scheduleId: "cancelled" }),
+ ])
+ clock.advance(10_000)
+ expect(fired).toEqual([])
+ })
+
+ test("firing a timer does not double-fire on subsequent events", () => {
+ const clock = new FakeClock()
+ const fired: string[] = []
+ const manager = new ScheduleManager({
+ clock,
+ fire: async (_, id) => { fired.push(id) },
+ })
+ manager.onEvent(event("auto_continue_accepted", { scheduledAt: 1_000 }))
+ clock.advance(1_000)
+ manager.onEvent(event("auto_continue_fired"))
+ expect(fired).toEqual(["s1"])
+ })
+})
+```
+
+- [ ] **Step 2: Run the test**
+
+Run: `bun test src/server/auto-continue/schedule-manager.test.ts`
+Expected: FAIL — `ScheduleManager` not defined.
+
+- [ ] **Step 3: Implement ScheduleManager**
+
+Create `src/server/auto-continue/schedule-manager.ts`:
+
+```ts
+import type { AutoContinueEvent } from "./events"
+import { deriveChatSchedules } from "./read-model"
+
+export interface Clock {
+ now(): number
+ setTimeout(fn: () => void, delayMs: number): number
+ clearTimeout(id: number): void
+}
+
+export const realClock: Clock = {
+ now: () => Date.now(),
+ setTimeout: (fn, delayMs) => setTimeout(fn, delayMs) as unknown as number,
+ clearTimeout: (id) => clearTimeout(id as unknown as NodeJS.Timeout),
+}
+
+export interface ScheduleManagerArgs {
+ clock?: Clock
+ fire: (chatId: string, scheduleId: string) => Promise
+ onError?: (error: unknown) => void
+}
+
+export class ScheduleManager {
+ private readonly clock: Clock
+ private readonly fireFn: ScheduleManagerArgs["fire"]
+ private readonly onError: (error: unknown) => void
+ private readonly timers = new Map()
+ private readonly pendingByScheduleId = new Map()
+
+ constructor(args: ScheduleManagerArgs) {
+ this.clock = args.clock ?? realClock
+ this.fireFn = args.fire
+ this.onError = args.onError ?? ((error) => console.error("[kanna/schedule-manager]", error))
+ }
+
+ rehydrate(events: readonly AutoContinueEvent[]) {
+ const byChat = new Map()
+ for (const event of events) {
+ const list = byChat.get(event.chatId) ?? []
+ list.push(event)
+ byChat.set(event.chatId, list)
+ }
+ for (const [chatId, chatEvents] of byChat.entries()) {
+ const projection = deriveChatSchedules(chatEvents, chatId)
+ for (const schedule of Object.values(projection.schedules)) {
+ if (schedule.state !== "scheduled") continue
+ if (schedule.scheduledAt === null) continue
+ this.arm(chatId, schedule.scheduleId, schedule.scheduledAt)
+ }
+ }
+ }
+
+ onEvent(event: AutoContinueEvent) {
+ switch (event.kind) {
+ case "auto_continue_proposed":
+ return
+ case "auto_continue_accepted":
+ this.arm(event.chatId, event.scheduleId, event.scheduledAt)
+ return
+ case "auto_continue_rescheduled":
+ this.arm(event.chatId, event.scheduleId, event.scheduledAt)
+ return
+ case "auto_continue_cancelled":
+ case "auto_continue_fired":
+ this.clear(event.scheduleId)
+ return
+ }
+ }
+
+ private arm(chatId: string, scheduleId: string, scheduledAt: number) {
+ this.clear(scheduleId)
+ this.pendingByScheduleId.set(scheduleId, { chatId, scheduledAt })
+ const delay = Math.max(0, scheduledAt - this.clock.now())
+ const timerId = this.clock.setTimeout(() => {
+ this.timers.delete(scheduleId)
+ this.pendingByScheduleId.delete(scheduleId)
+ void (async () => {
+ try {
+ await this.fireFn(chatId, scheduleId)
+ } catch (error) {
+ this.onError(error)
+ }
+ })()
+ }, delay)
+ this.timers.set(scheduleId, timerId)
+ }
+
+ private clear(scheduleId: string) {
+ const timerId = this.timers.get(scheduleId)
+ if (timerId !== undefined) {
+ this.clock.clearTimeout(timerId)
+ this.timers.delete(scheduleId)
+ }
+ this.pendingByScheduleId.delete(scheduleId)
+ }
+
+ shutdown() {
+ for (const timerId of this.timers.values()) {
+ this.clock.clearTimeout(timerId)
+ }
+ this.timers.clear()
+ this.pendingByScheduleId.clear()
+ }
+}
+```
+
+- [ ] **Step 4: Run the test**
+
+Run: `bun test src/server/auto-continue/schedule-manager.test.ts`
+Expected: PASS (7 tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/server/auto-continue/schedule-manager.ts src/server/auto-continue/schedule-manager.test.ts
+git commit -m "feat(auto-continue): ScheduleManager with injectable clock"
+```
+
+---
+
+## Task 8: Expose schedules on chat snapshot
+
+**Files:**
+- Modify: `src/server/read-models.ts`
+- Test: `src/server/read-models.test.ts` (create if it doesn't exist, or extend)
+
+- [ ] **Step 1: Write the failing test**
+
+Check whether `src/server/read-models.test.ts` exists. If not, create it:
+
+```ts
+import { describe, expect, test } from "bun:test"
+import { deriveChatSnapshot } from "./read-models"
+import { createEmptyState } from "./events"
+
+describe("deriveChatSnapshot schedules", () => {
+ test("empty schedules produces empty map and null live id", () => {
+ const state = createEmptyState()
+ state.projectsById.set("p1", {
+ id: "p1", localPath: "/tmp/p", title: "P", createdAt: 0, updatedAt: 0,
+ })
+ state.chatsById.set("c1", {
+ id: "c1", projectId: "p1", title: "Chat", createdAt: 0, updatedAt: 0,
+ unread: false, provider: null, planMode: false, sessionToken: null, sourceHash: null, lastTurnOutcome: null,
+ })
+
+ const snapshot = deriveChatSnapshot(
+ state,
+ new Map(),
+ new Set(),
+ new Set(),
+ "c1",
+ () => ({ messages: [], history: { hasOlder: false, olderCursor: null, recentLimit: 0 } }),
+ )
+ expect(snapshot!.schedules).toEqual({})
+ expect(snapshot!.liveScheduleId).toBeNull()
+ })
+
+ test("proposed event projects to schedules + liveScheduleId", () => {
+ const state = createEmptyState()
+ state.projectsById.set("p1", {
+ id: "p1", localPath: "/tmp/p", title: "P", createdAt: 0, updatedAt: 0,
+ })
+ state.chatsById.set("c1", {
+ id: "c1", projectId: "p1", title: "Chat", createdAt: 0, updatedAt: 0,
+ unread: false, provider: null, planMode: false, sessionToken: null, sourceHash: null, lastTurnOutcome: null,
+ })
+ state.autoContinueEventsByChatId.set("c1", [{
+ v: 3, kind: "auto_continue_proposed", timestamp: 1, chatId: "c1", scheduleId: "s1",
+ detectedAt: 1, resetAt: 2_000, tz: "Asia/Saigon", turnId: "t1",
+ }])
+
+ const snapshot = deriveChatSnapshot(
+ state,
+ new Map(),
+ new Set(),
+ new Set(),
+ "c1",
+ () => ({ messages: [], history: { hasOlder: false, olderCursor: null, recentLimit: 0 } }),
+ )
+ expect(snapshot!.schedules["s1"].state).toBe("proposed")
+ expect(snapshot!.liveScheduleId).toBe("s1")
+ })
+})
+```
+
+- [ ] **Step 2: Run the test**
+
+Run: `bun test src/server/read-models.test.ts`
+Expected: FAIL — `deriveChatSnapshot` returns snapshot without `schedules` + `liveScheduleId`.
+
+- [ ] **Step 3: Extend `deriveChatSnapshot`**
+
+Edit `src/server/read-models.ts`:
+
+Add import:
+
+```ts
+import { deriveChatSchedules } from "./auto-continue/read-model"
+```
+
+Inside `deriveChatSnapshot`, after building `transcript`:
+
+```ts
+ const autoContinueEvents = state.autoContinueEventsByChatId.get(chat.id) ?? []
+ const { schedules, liveScheduleId } = deriveChatSchedules(autoContinueEvents, chat.id)
+```
+
+Add to the returned object:
+
+```ts
+ schedules,
+ liveScheduleId,
+```
+
+- [ ] **Step 4: Run the test**
+
+Run: `bun test src/server/read-models.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/server/read-models.ts src/server/read-models.test.ts
+git commit -m "feat(auto-continue): project schedules onto ChatSnapshot"
+```
+
+---
+
+## Task 9: WS protocol — three new commands
+
+**Files:**
+- Modify: `src/shared/protocol.ts`
+
+- [ ] **Step 1: Add command variants**
+
+Edit `src/shared/protocol.ts`. Inside `ClientCommand`, after the `message.dequeue` variant:
+
+```ts
+ | { type: "autoContinue.accept"; chatId: string; scheduleId: string; scheduledAt: number }
+ | { type: "autoContinue.reschedule"; chatId: string; scheduleId: string; scheduledAt: number }
+ | { type: "autoContinue.cancel"; chatId: string; scheduleId: string }
+```
+
+- [ ] **Step 2: Run type-check to verify nothing else breaks**
+
+Run: `bun run check`
+Expected: type errors only where WS router / client stores will later handle these commands.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/shared/protocol.ts
+git commit -m "feat(auto-continue): add three WS commands for schedule lifecycle"
+```
+
+---
+
+## Task 10: Client preferences store — `autoResumeOnRateLimit`
+
+**Files:**
+- Create: `src/client/stores/preferences.ts`
+- Test: `src/client/stores/preferences.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `src/client/stores/preferences.test.ts`:
+
+```ts
+import { beforeEach, describe, expect, test } from "bun:test"
+import { usePreferencesStore } from "./preferences"
+
+describe("usePreferencesStore", () => {
+ beforeEach(() => {
+ localStorage.clear()
+ usePreferencesStore.setState({ autoResumeOnRateLimit: false })
+ })
+
+ test("autoResumeOnRateLimit defaults to false", () => {
+ expect(usePreferencesStore.getState().autoResumeOnRateLimit).toBe(false)
+ })
+
+ test("setAutoResumeOnRateLimit updates state", () => {
+ usePreferencesStore.getState().setAutoResumeOnRateLimit(true)
+ expect(usePreferencesStore.getState().autoResumeOnRateLimit).toBe(true)
+ })
+})
+```
+
+- [ ] **Step 2: Run the test**
+
+Run: `bun test src/client/stores/preferences.test.ts`
+Expected: FAIL — `./preferences` module missing.
+
+- [ ] **Step 3: Implement the store**
+
+Create `src/client/stores/preferences.ts`:
+
+```ts
+import { create } from "zustand"
+import { persist } from "zustand/middleware"
+
+interface PreferencesState {
+ autoResumeOnRateLimit: boolean
+ setAutoResumeOnRateLimit: (value: boolean) => void
+}
+
+interface PersistedPreferencesState {
+ autoResumeOnRateLimit?: boolean
+}
+
+function migratePreferencesState(
+ persistedState: Partial | undefined,
+): Pick {
+ return {
+ autoResumeOnRateLimit: Boolean(persistedState?.autoResumeOnRateLimit),
+ }
+}
+
+export const usePreferencesStore = create()(
+ persist(
+ (set) => ({
+ autoResumeOnRateLimit: false,
+ setAutoResumeOnRateLimit: (value) => set({ autoResumeOnRateLimit: value }),
+ }),
+ {
+ name: "kanna-preferences",
+ version: 1,
+ migrate: (persistedState) => migratePreferencesState(
+ persistedState as Partial | undefined,
+ ),
+ },
+ ),
+)
+```
+
+- [ ] **Step 4: Run the test**
+
+Run: `bun test src/client/stores/preferences.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/client/stores/preferences.ts src/client/stores/preferences.test.ts
+git commit -m "feat(auto-continue): client preferences store with autoResumeOnRateLimit"
+```
+
+---
+
+## Task 11: Surface preference to the server via WS
+
+The server reads `autoResumeOnRateLimit` out-of-band — the client sends its current value with every message-send command. Simplest path: extend `chat.send` and `message.enqueue` with an optional `autoResumeOnRateLimit?: boolean`, and the `AgentCoordinator` caches it per chat.
+
+**Files:**
+- Modify: `src/shared/protocol.ts`
+- Modify: `src/client/lib/socket.ts` (or wherever `chat.send` and `message.enqueue` are built — search for usages)
+
+- [ ] **Step 1: Extend protocol commands**
+
+Edit `src/shared/protocol.ts`. In the `chat.send` command, add:
+
+```ts
+ autoResumeOnRateLimit?: boolean
+```
+
+Do the same for `message.enqueue`.
+
+- [ ] **Step 2: Extend the send-helper on the client**
+
+Find the client helper that builds a `chat.send` command (search `Grep` for `"chat.send"` under `src/client`). Wherever it builds the command object, read from the preferences store and add:
+
+```ts
+import { usePreferencesStore } from "../stores/preferences"
+
+const autoResumeOnRateLimit = usePreferencesStore.getState().autoResumeOnRateLimit
+// ...
+{
+ type: "chat.send",
+ // ...
+ autoResumeOnRateLimit,
+}
+```
+
+Do the same in the helper that builds `message.enqueue`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/shared/protocol.ts src/client/
+git commit -m "feat(auto-continue): thread autoResumeOnRateLimit preference through WS commands"
+```
+
+---
+
+## Task 12: Wire `ScheduleManager` into AgentCoordinator
+
+**Files:**
+- Modify: `src/server/agent.ts`
+- Test: `src/server/agent.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Open `src/server/agent.test.ts` and append:
+
+```ts
+import { ClaudeLimitDetector } from "./auto-continue/limit-detector"
+import { ScheduleManager, type Clock } from "./auto-continue/schedule-manager"
+import type { AutoContinueEvent } from "./auto-continue/events"
+
+function makeLimitError() {
+ const err = new Error(JSON.stringify({
+ type: "error",
+ error: { type: "rate_limit_error" },
+ })) as Error & { status?: number; headers?: Record }
+ err.status = 429
+ err.headers = {
+ "anthropic-ratelimit-unified-reset": new Date(5_000).toISOString(),
+ "x-anthropic-timezone": "Asia/Saigon",
+ }
+ return err
+}
+
+describe("AgentCoordinator rate-limit detection (manual mode)", () => {
+ test("emits auto_continue_proposed when Claude throws a rate-limit error and autoResumeOnRateLimit is false", async () => {
+ // Harness: build an AgentCoordinator with a fake startClaudeSession that synthesizes makeLimitError(),
+ // pipe appended AutoContinueEvents into a captured array, assert exactly one "auto_continue_proposed".
+ //
+ // Copy the existing test harness in agent.test.ts (look for `buildAgent` or `createTestAgent`) and inject:
+ // - claudeLimitDetector: new ClaudeLimitDetector()
+ // - codexLimitDetector: new CodexLimitDetector()
+ // - scheduleManager: new ScheduleManager({ clock: fakeClock, fire })
+ // - getAutoResumePreference: () => false
+ //
+ // Then drive a send(), force the synthetic stream to throw makeLimitError(), and assert.
+ })
+
+ test("auto-resume on: emits auto_continue_accepted directly with source=auto_setting", async () => {
+ // Same as above but with getAutoResumePreference: () => true.
+ // Assert: no auto_continue_proposed event; exactly one auto_continue_accepted with source === "auto_setting".
+ })
+})
+```
+
+The existing `agent.test.ts` has test harnesses — use the same pattern to construct a coordinator with a fake Claude session that throws on the first stream iteration. The two test bodies are fully specified in Step 3 below once the wiring is done.
+
+- [ ] **Step 2: Run the test**
+
+Run: `bun test src/server/agent.test.ts`
+Expected: FAIL — constructor does not accept the new dependencies.
+
+- [ ] **Step 3: Extend `AgentCoordinator`**
+
+Edit `src/server/agent.ts`. Add imports:
+
+```ts
+import type { AutoContinueEvent } from "./auto-continue/events"
+import { ClaudeLimitDetector, CodexLimitDetector, type LimitDetector } from "./auto-continue/limit-detector"
+import type { ScheduleManager } from "./auto-continue/schedule-manager"
+```
+
+Extend `AgentCoordinatorArgs`:
+
+```ts
+ claudeLimitDetector?: LimitDetector
+ codexLimitDetector?: LimitDetector
+ scheduleManager?: ScheduleManager
+ getAutoResumePreference?: () => boolean
+```
+
+Add class fields:
+
+```ts
+ private readonly claudeLimitDetector: LimitDetector
+ private readonly codexLimitDetector: LimitDetector
+ private readonly scheduleManager: ScheduleManager | null
+ private readonly getAutoResumePreference: () => boolean
+ private readonly autoResumeByChat = new Map()
+```
+
+In the constructor:
+
+```ts
+ this.claudeLimitDetector = args.claudeLimitDetector ?? new ClaudeLimitDetector()
+ this.codexLimitDetector = args.codexLimitDetector ?? new CodexLimitDetector()
+ this.scheduleManager = args.scheduleManager ?? null
+ this.getAutoResumePreference = args.getAutoResumePreference ?? (() => false)
+```
+
+In `send(command)` and `enqueue(command)` where `command.autoResumeOnRateLimit` is known, cache it:
+
+```ts
+ if (typeof command.autoResumeOnRateLimit === "boolean") {
+ this.autoResumeByChat.set(chatId, command.autoResumeOnRateLimit)
+ }
+```
+
+Add a private helper:
+
+```ts
+ private resolveAutoResumeFor(chatId: string): boolean {
+ const cached = this.autoResumeByChat.get(chatId)
+ if (typeof cached === "boolean") return cached
+ return this.getAutoResumePreference()
+ }
+
+ private async handleLimitError(chatId: string, detector: LimitDetector, error: unknown, turnId: string) {
+ const detection = detector.detect(chatId, error)
+ if (!detection) return false
+
+ const state = this.store.getAutoContinueEvents(chatId)
+ const live = deriveChatSchedules(state, chatId).liveScheduleId
+ if (live !== null) return true
+
+ const autoResume = this.resolveAutoResumeFor(chatId)
+ const now = Date.now()
+ const scheduleId = crypto.randomUUID()
+
+ if (autoResume) {
+ const event: AutoContinueEvent = {
+ v: 3,
+ kind: "auto_continue_accepted",
+ timestamp: now,
+ chatId,
+ scheduleId,
+ scheduledAt: detection.resetAt,
+ tz: detection.tz,
+ source: "auto_setting",
+ resetAt: detection.resetAt,
+ detectedAt: now,
+ }
+ await this.store.appendAutoContinueEvent(event)
+ this.scheduleManager?.onEvent(event)
+ } else {
+ const event: AutoContinueEvent = {
+ v: 3,
+ kind: "auto_continue_proposed",
+ timestamp: now,
+ chatId,
+ scheduleId,
+ detectedAt: now,
+ resetAt: detection.resetAt,
+ tz: detection.tz,
+ turnId,
+ }
+ await this.store.appendAutoContinueEvent(event)
+ this.scheduleManager?.onEvent(event)
+ }
+
+ await this.store.appendMessage(chatId, timestamped({
+ kind: "auto_continue_prompt",
+ scheduleId,
+ } as Omit))
+
+ return true
+ }
+```
+
+Add import for `deriveChatSchedules`:
+
+```ts
+import { deriveChatSchedules } from "./auto-continue/read-model"
+```
+
+Insert a call into the two catch blocks.
+
+For the Claude stream catch (line ~1329):
+
+```ts
+ } catch (error) {
+ const active = this.activeTurns.get(session.chatId)
+ if (active && !active.cancelRequested) {
+ const handled = await this.handleLimitError(session.chatId, this.claudeLimitDetector, error, active.turn?.id ?? "")
+ if (!handled) {
+ const message = error instanceof Error ? error.message : String(error)
+ await this.store.appendMessage(
+ session.chatId,
+ timestamped({
+ kind: "result",
+ subtype: "error",
+ isError: true,
+ durationMs: 0,
+ result: message,
+ })
+ )
+ await this.store.recordTurnFailed(session.chatId, message)
+ } else {
+ await this.store.recordTurnFailed(session.chatId, "rate_limit")
+ }
+ }
+ }
+```
+
+For the Codex / `runTurn` catch (line ~1421), do the same with `this.codexLimitDetector`.
+
+- [ ] **Step 4: Fill in the tests and run them**
+
+Replace the pseudo-test bodies with concrete ones modelled on the existing `agent.test.ts` harness. Each test:
+
+1. Builds a fake Claude session whose `query()` generator throws `makeLimitError()` on first iteration.
+2. Calls `agent.send({ chatId, content: "hi", autoResumeOnRateLimit: })`.
+3. `await Promise.resolve()` and any drain awaits the harness exposes.
+4. Asserts `store.getAutoContinueEvents(chatId)` contains exactly one event with the expected `kind` and (for auto-resume) `source === "auto_setting"`.
+
+Run: `bun test src/server/agent.test.ts`
+Expected: PASS (including the two new tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/server/agent.ts src/server/agent.test.ts
+git commit -m "feat(auto-continue): detect rate-limit errors and emit schedule events"
+```
+
+---
+
+## Task 13: Wire firing path — enqueue "continue" with metadata
+
+**Files:**
+- Modify: `src/server/auto-continue/schedule-manager.ts` (test already written)
+- Modify: `src/server/cli-runtime.ts` (or wherever `AgentCoordinator` is instantiated — search `Grep` for `new AgentCoordinator(`)
+
+- [ ] **Step 1: Write an integration test**
+
+Append to `src/server/agent.test.ts`:
+
+```ts
+describe("AgentCoordinator auto-continue firing", () => {
+ test("firing enqueues a 'continue' user message carrying autoContinue metadata", async () => {
+ // Build coordinator with a FakeClock-driven ScheduleManager whose fire() calls agent.fireAutoContinue(chatId, scheduleId).
+ // Send a message that triggers makeLimitError() in auto-resume mode.
+ // Advance the clock past resetAt.
+ // Assert:
+ // - store.getAutoContinueEvents(chatId) contains an "auto_continue_fired" event.
+ // - The next queued message for chatId has content === "continue".
+ // - A user_prompt entry with autoContinue?.scheduleId is appended to the transcript.
+ })
+})
+```
+
+- [ ] **Step 2: Run the test**
+
+Run: `bun test src/server/agent.test.ts`
+Expected: FAIL — `fireAutoContinue` not defined.
+
+- [ ] **Step 3: Implement `fireAutoContinue` on `AgentCoordinator`**
+
+Append to `src/server/agent.ts`:
+
+```ts
+ async fireAutoContinue(chatId: string, scheduleId: string) {
+ const now = Date.now()
+ const fired: AutoContinueEvent = {
+ v: 3,
+ kind: "auto_continue_fired",
+ timestamp: now,
+ chatId,
+ scheduleId,
+ firedAt: now,
+ }
+ await this.store.appendAutoContinueEvent(fired)
+
+ await this.store.appendMessage(chatId, timestamped({
+ kind: "user_prompt",
+ content: "continue",
+ autoContinue: { scheduleId },
+ } as Omit))
+
+ try {
+ await this.enqueueMessage(chatId, "continue", [])
+ await this.maybeStartNextQueuedMessage(chatId)
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ await this.store.appendMessage(
+ chatId,
+ timestamped({
+ kind: "result",
+ subtype: "error",
+ isError: true,
+ durationMs: 0,
+ result: `Auto-continue failed: ${message}`,
+ }),
+ )
+ }
+
+ this.emitStateChange(chatId)
+ }
+```
+
+- [ ] **Step 4: Wire `ScheduleManager.fire` to `agent.fireAutoContinue`**
+
+In `src/server/cli-runtime.ts` (or whichever bootstrap file — run `Grep` for `new AgentCoordinator(` to find it), construct the manager AFTER the coordinator and inject it back:
+
+```ts
+import { ScheduleManager } from "./auto-continue/schedule-manager"
+import { usePreferencesStore } from "../client/stores/preferences" // only if server-side preference is needed; otherwise drop and rely on per-command flag
+
+const scheduleManager = new ScheduleManager({
+ fire: async (chatId, scheduleId) => {
+ await agent.fireAutoContinue(chatId, scheduleId)
+ },
+})
+// Expose it to agent — either re-assign a setter or construct agent with a forward-ref lambda.
+```
+
+Because `AgentCoordinator` already accepts `scheduleManager` in its constructor, build it via a two-step reference-passing pattern:
+
+```ts
+let agent!: AgentCoordinator
+const scheduleManager = new ScheduleManager({
+ fire: async (chatId, scheduleId) => {
+ await agent.fireAutoContinue(chatId, scheduleId)
+ },
+})
+agent = new AgentCoordinator({
+ store,
+ onStateChange,
+ scheduleManager,
+ // ... other existing args
+})
+
+// After event replay:
+scheduleManager.rehydrate(
+ store.listAutoContinueChats().flatMap((chatId) => store.getAutoContinueEvents(chatId))
+)
+```
+
+- [ ] **Step 5: Run the test**
+
+Run: `bun test src/server/agent.test.ts`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/server/agent.ts src/server/cli-runtime.ts src/server/agent.test.ts
+git commit -m "feat(auto-continue): fire schedules by enqueueing 'continue' user message"
+```
+
+---
+
+## Task 14: WS router — three new commands + cancel-on-delete
+
+**Files:**
+- Modify: `src/server/ws-router.ts`
+- Test: extend `src/server/ws-router.test.ts` (create if absent — search first)
+
+- [ ] **Step 1: Write a failing test**
+
+Append / create tests for each of the three commands. Minimum per command:
+
+- State guard: reject `accept` when `schedules[sid].state !== "proposed"`.
+- State guard: reject `reschedule` when `state !== "scheduled"`.
+- State guard: reject `cancel` when `state !== "proposed" && state !== "scheduled"`.
+- Time guard: reject when `scheduledAt <= Date.now()`.
+
+- [ ] **Step 2: Run the test**
+
+Run: `bun test src/server/ws-router.test.ts`
+Expected: FAIL — commands not routed.
+
+- [ ] **Step 3: Implement the three cases in `ws-router.ts`**
+
+Edit `src/server/ws-router.ts`. Add after the existing `message.dequeue` case:
+
+```ts
+ case "autoContinue.accept": {
+ await agent.acceptAutoContinue(command.chatId, command.scheduleId, command.scheduledAt)
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
+ await broadcastChatAndSidebar(command.chatId)
+ return
+ }
+ case "autoContinue.reschedule": {
+ await agent.rescheduleAutoContinue(command.chatId, command.scheduleId, command.scheduledAt)
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
+ await broadcastChatAndSidebar(command.chatId)
+ return
+ }
+ case "autoContinue.cancel": {
+ await agent.cancelAutoContinue(command.chatId, command.scheduleId, "user")
+ send(ws, { v: PROTOCOL_VERSION, type: "ack", id })
+ await broadcastChatAndSidebar(command.chatId)
+ return
+ }
+```
+
+In the `chat.delete` case, before `send ack`, cancel all live schedules:
+
+```ts
+ for (const scheduleId of agent.listLiveSchedules(command.chatId)) {
+ await agent.cancelAutoContinue(command.chatId, scheduleId, "chat_deleted")
+ }
+```
+
+- [ ] **Step 4: Implement the three coordinator methods**
+
+Add to `AgentCoordinator`:
+
+```ts
+ async acceptAutoContinue(chatId: string, scheduleId: string, scheduledAt: number) {
+ const events = this.store.getAutoContinueEvents(chatId)
+ const projection = deriveChatSchedules(events, chatId)
+ const schedule = projection.schedules[scheduleId]
+ if (!schedule) throw new Error("Schedule not found")
+ if (schedule.state !== "proposed") throw new Error("Schedule not pending")
+ if (scheduledAt <= Date.now()) throw new Error("scheduledAt must be in the future")
+
+ const event: AutoContinueEvent = {
+ v: 3,
+ kind: "auto_continue_accepted",
+ timestamp: Date.now(),
+ chatId,
+ scheduleId,
+ scheduledAt,
+ tz: schedule.tz,
+ source: "user",
+ resetAt: schedule.resetAt,
+ detectedAt: schedule.detectedAt,
+ }
+ await this.store.appendAutoContinueEvent(event)
+ this.scheduleManager?.onEvent(event)
+ this.emitStateChange(chatId)
+ }
+
+ async rescheduleAutoContinue(chatId: string, scheduleId: string, scheduledAt: number) {
+ const events = this.store.getAutoContinueEvents(chatId)
+ const schedule = deriveChatSchedules(events, chatId).schedules[scheduleId]
+ if (!schedule || schedule.state !== "scheduled") throw new Error("Schedule not active")
+ if (scheduledAt <= Date.now()) throw new Error("scheduledAt must be in the future")
+
+ const event: AutoContinueEvent = {
+ v: 3,
+ kind: "auto_continue_rescheduled",
+ timestamp: Date.now(),
+ chatId,
+ scheduleId,
+ scheduledAt,
+ }
+ await this.store.appendAutoContinueEvent(event)
+ this.scheduleManager?.onEvent(event)
+ this.emitStateChange(chatId)
+ }
+
+ async cancelAutoContinue(chatId: string, scheduleId: string, reason: "user" | "chat_deleted") {
+ const events = this.store.getAutoContinueEvents(chatId)
+ const schedule = deriveChatSchedules(events, chatId).schedules[scheduleId]
+ if (!schedule) return
+ if (schedule.state !== "proposed" && schedule.state !== "scheduled") return
+
+ const event: AutoContinueEvent = {
+ v: 3,
+ kind: "auto_continue_cancelled",
+ timestamp: Date.now(),
+ chatId,
+ scheduleId,
+ reason,
+ }
+ await this.store.appendAutoContinueEvent(event)
+ this.scheduleManager?.onEvent(event)
+ this.emitStateChange(chatId)
+ }
+
+ listLiveSchedules(chatId: string): string[] {
+ const events = this.store.getAutoContinueEvents(chatId)
+ const projection = deriveChatSchedules(events, chatId)
+ return Object.values(projection.schedules)
+ .filter((s) => s.state === "proposed" || s.state === "scheduled")
+ .map((s) => s.scheduleId)
+ }
+```
+
+- [ ] **Step 5: Run the test**
+
+Run: `bun test src/server/ws-router.test.ts`
+Expected: PASS.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/server/ws-router.ts src/server/agent.ts src/server/ws-router.test.ts
+git commit -m "feat(auto-continue): WS commands for accept/reschedule/cancel + chat-delete cleanup"
+```
+
+---
+
+## Task 15: Client time helpers — `formatLocal` / `parseLocal`
+
+**Files:**
+- Create: `src/client/lib/autoContinueTime.ts`
+- Test: `src/client/lib/autoContinueTime.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `src/client/lib/autoContinueTime.test.ts`:
+
+```ts
+import { describe, expect, test } from "bun:test"
+import { formatLocal, parseLocal } from "./autoContinueTime"
+
+describe("formatLocal / parseLocal", () => {
+ test("formatLocal in UTC produces dd/mm/yyyy hh:mm", () => {
+ const result = formatLocal(Date.UTC(2026, 3, 22, 17, 5), "UTC")
+ expect(result).toBe("22/04/2026 17:05")
+ })
+
+ test("formatLocal with Asia/Saigon shifts to +07:00", () => {
+ const result = formatLocal(Date.UTC(2026, 3, 22, 17, 0), "Asia/Saigon")
+ expect(result).toBe("23/04/2026 00:00")
+ })
+
+ test("formatLocal with tz=system uses runtime zone (smoke test)", () => {
+ const result = formatLocal(Date.UTC(2026, 3, 22, 12, 0), "system")
+ expect(result).toMatch(/^\d{2}\/\d{2}\/\d{4} \d{2}:\d{2}$/)
+ })
+
+ test("parseLocal accepts well-formed dd/mm/yyyy hh:mm", () => {
+ const millis = parseLocal("23/04/2026 00:00", "Asia/Saigon")
+ expect(millis).toBe(Date.UTC(2026, 3, 22, 17, 0))
+ })
+
+ test("parseLocal rejects malformed input", () => {
+ expect(parseLocal("22-04-2026 17:05", "UTC")).toBeNull()
+ expect(parseLocal("32/04/2026 17:05", "UTC")).toBeNull()
+ expect(parseLocal("22/04/2026", "UTC")).toBeNull()
+ })
+})
+```
+
+- [ ] **Step 2: Run the test**
+
+Run: `bun test src/client/lib/autoContinueTime.test.ts`
+Expected: FAIL — module missing.
+
+- [ ] **Step 3: Implement the helpers**
+
+Create `src/client/lib/autoContinueTime.ts`:
+
+```ts
+function resolveTimeZone(tz: string): string | undefined {
+ if (tz === "system") return undefined
+ return tz
+}
+
+export function formatLocal(epochMs: number, tz: string): string {
+ const timeZone = resolveTimeZone(tz)
+ const parts = new Intl.DateTimeFormat("en-GB", {
+ timeZone,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ hour12: false,
+ }).formatToParts(new Date(epochMs))
+ const part = (type: string) => parts.find((p) => p.type === type)?.value ?? "00"
+ let hour = part("hour")
+ if (hour === "24") hour = "00"
+ return `${part("day")}/${part("month")}/${part("year")} ${hour}:${part("minute")}`
+}
+
+const PATTERN = /^(\d{2})\/(\d{2})\/(\d{4}) (\d{2}):(\d{2})$/
+
+function offsetMinutes(tz: string, referenceUtcMs: number): number {
+ if (tz === "system") return -new Date(referenceUtcMs).getTimezoneOffset()
+ const parts = new Intl.DateTimeFormat("en-US", {
+ timeZone: tz,
+ hour12: false,
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ }).formatToParts(new Date(referenceUtcMs))
+ const p = (type: string) => Number(parts.find((x) => x.type === type)?.value ?? 0)
+ let hour = p("hour")
+ if (hour === 24) hour = 0
+ const asUtc = Date.UTC(p("year"), p("month") - 1, p("day"), hour, p("minute"), p("second"))
+ return Math.round((asUtc - referenceUtcMs) / 60_000)
+}
+
+export function parseLocal(input: string, tz: string): number | null {
+ const match = PATTERN.exec(input.trim())
+ if (!match) return null
+ const [, ddStr, mmStr, yyyyStr, hhStr, minStr] = match
+ const dd = Number(ddStr)
+ const mm = Number(mmStr)
+ const yyyy = Number(yyyyStr)
+ const hh = Number(hhStr)
+ const min = Number(minStr)
+ if (mm < 1 || mm > 12 || dd < 1 || dd > 31 || hh > 23 || min > 59) return null
+
+ const guess = Date.UTC(yyyy, mm - 1, dd, hh, min)
+ const offMin = offsetMinutes(tz, guess)
+ const corrected = guess - offMin * 60_000
+ const offMinAfter = offsetMinutes(tz, corrected)
+ return corrected - (offMinAfter - offMin) * 60_000
+}
+```
+
+- [ ] **Step 4: Run the test**
+
+Run: `bun test src/client/lib/autoContinueTime.test.ts`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/client/lib/autoContinueTime.ts src/client/lib/autoContinueTime.test.ts
+git commit -m "feat(auto-continue): dd/mm/yyyy hh:mm time helpers with tz support"
+```
+
+---
+
+## Task 16: AutoContinueCard component
+
+**Files:**
+- Create: `src/client/components/chat-ui/AutoContinueCard.tsx`
+- Test: `src/client/components/chat-ui/AutoContinueCard.test.tsx`
+
+Assume the codebase has a `Button` + `Input` primitive (seen in `SettingsPage.tsx`: `../components/ui/button`, `../components/ui/input`). Check if a React-testing setup exists; if not, tests for this file may be skipped and replaced with a stub smoke test that imports the component.
+
+- [ ] **Step 1: Write a failing render test**
+
+Create `src/client/components/chat-ui/AutoContinueCard.test.tsx`:
+
+```tsx
+import { describe, expect, test } from "bun:test"
+import { renderToStaticMarkup } from "react-dom/server"
+import { AutoContinueCard } from "./AutoContinueCard"
+
+describe("AutoContinueCard", () => {
+ test("proposed state renders Schedule and Dismiss buttons", () => {
+ const html = renderToStaticMarkup(
+ {}}
+ onReschedule={() => {}}
+ onCancel={() => {}}
+ />,
+ )
+ expect(html).toContain("Schedule")
+ expect(html).toContain("Dismiss")
+ })
+
+ test("scheduled state renders Change time and Cancel buttons", () => {
+ const html = renderToStaticMarkup(
+ {}}
+ onReschedule={() => {}}
+ onCancel={() => {}}
+ />,
+ )
+ expect(html).toContain("Change time")
+ expect(html).toContain("Cancel")
+ })
+
+ test("fired state renders Auto-continued line without controls", () => {
+ const html = renderToStaticMarkup(
+ {}}
+ onReschedule={() => {}}
+ onCancel={() => {}}
+ />,
+ )
+ expect(html).toContain("Auto-continued")
+ expect(html).not.toContain("Cancel")
+ })
+
+ test("cancelled state renders Auto-continue cancelled line", () => {
+ const html = renderToStaticMarkup(
+ {}}
+ onReschedule={() => {}}
+ onCancel={() => {}}
+ />,
+ )
+ expect(html).toContain("Auto-continue cancelled")
+ })
+})
+```
+
+- [ ] **Step 2: Run the test**
+
+Run: `bun test src/client/components/chat-ui/AutoContinueCard.test.tsx`
+Expected: FAIL — module missing.
+
+- [ ] **Step 3: Implement the card**
+
+Create `src/client/components/chat-ui/AutoContinueCard.tsx`:
+
+```tsx
+import { useMemo, useState } from "react"
+import type { AutoContinueSchedule } from "../../../shared/types"
+import { formatLocal, parseLocal } from "../../lib/autoContinueTime"
+import { Button } from "../ui/button"
+import { Input } from "../ui/input"
+
+export interface AutoContinueCardProps {
+ schedule: AutoContinueSchedule
+ onAccept: (scheduledAtMs: number) => void
+ onReschedule: (scheduledAtMs: number) => void
+ onCancel: () => void
+}
+
+export function AutoContinueCard({ schedule, onAccept, onReschedule, onCancel }: AutoContinueCardProps) {
+ const [draft, setDraft] = useState(() => formatLocal(
+ schedule.scheduledAt ?? schedule.resetAt,
+ schedule.tz,
+ ))
+ const [editing, setEditing] = useState(false)
+
+ const parsed = useMemo(() => parseLocal(draft, schedule.tz), [draft, schedule.tz])
+ const isFuture = parsed !== null && parsed > Date.now()
+ const inputInvalid = parsed === null ? "Use format dd/mm/yyyy hh:mm" :
+ !isFuture ? "Time must be in the future" : null
+
+ if (schedule.state === "fired") {
+ const at = formatLocal(schedule.scheduledAt ?? schedule.resetAt, schedule.tz)
+ return Auto-continued at {at}
+ }
+
+ if (schedule.state === "cancelled") {
+ return Auto-continue cancelled
+ }
+
+ if (schedule.state === "proposed") {
+ const passed = schedule.resetAt <= Date.now()
+ return (
+
+
Rate limit hit — schedule auto-continue?
+ {passed &&
Reset time has passed — accept to continue now.
}
+
setDraft(event.target.value)}
+ placeholder="dd/mm/yyyy hh:mm"
+ />
+ {inputInvalid &&
{inputInvalid}
}
+
+ parsed !== null && onAccept(parsed)}>Schedule
+ Dismiss
+
+
+ )
+ }
+
+ // scheduled
+ const displayAt = formatLocal(schedule.scheduledAt ?? schedule.resetAt, schedule.tz)
+ if (!editing) {
+ const tzLabel = schedule.tz === "system" ? "local" : schedule.tz
+ return (
+
+
Auto-continue at {displayAt} ({tzLabel})
+
+ setEditing(true)}>Change time
+ Cancel
+
+
+ )
+ }
+
+ return (
+
+
setDraft(event.target.value)}
+ placeholder="dd/mm/yyyy hh:mm"
+ />
+ {inputInvalid &&
{inputInvalid}
}
+
+ { if (parsed !== null) { onReschedule(parsed); setEditing(false) } }}>Save
+ setEditing(false)}>Back
+
+
+ )
+}
+```
+
+- [ ] **Step 4: Run the test**
+
+Run: `bun test src/client/components/chat-ui/AutoContinueCard.test.tsx`
+Expected: PASS. If React SSR fails under Bun's test environment, replace `renderToStaticMarkup` with a simple type-check-only smoke import and note that visual verification must be done in dev mode.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/client/components/chat-ui/AutoContinueCard.tsx src/client/components/chat-ui/AutoContinueCard.test.tsx
+git commit -m "feat(auto-continue): AutoContinueCard with four render states"
+```
+
+---
+
+## Task 17: Hook into transcript rendering
+
+**Files:**
+- Modify: `src/client/lib/parseTranscript.ts`
+- Modify: the renderer that maps `HydratedTranscriptMessage` kinds to JSX (search for a `switch (message.kind)` in `KannaTranscript.tsx` or similar)
+
+- [ ] **Step 1: Extend `parseTranscript`**
+
+Edit `src/client/lib/parseTranscript.ts`. In the `user_prompt` branch, pass `autoContinue`:
+
+```ts
+ case "user_prompt":
+ messages.push({
+ ...createBaseMessage(entry),
+ kind: "user_prompt",
+ content: entry.content,
+ attachments: entry.attachments ?? [],
+ steered: entry.steered,
+ autoContinue: entry.autoContinue,
+ })
+ break
+```
+
+Add a new branch before the `default`:
+
+```ts
+ case "auto_continue_prompt":
+ messages.push({
+ ...createBaseMessage(entry),
+ kind: "auto_continue_prompt",
+ scheduleId: entry.scheduleId,
+ })
+ break
+```
+
+- [ ] **Step 2: Add a parseTranscript test**
+
+Append to `src/client/lib/parseTranscript.test.ts`:
+
+```ts
+test("auto_continue_prompt entries hydrate with scheduleId", () => {
+ const output = processTranscriptMessages([{
+ _id: "m1",
+ createdAt: 1,
+ kind: "auto_continue_prompt",
+ scheduleId: "s1",
+ }])
+ expect(output[0].kind).toBe("auto_continue_prompt")
+ expect((output[0] as { scheduleId: string }).scheduleId).toBe("s1")
+})
+
+test("user_prompt carries autoContinue metadata", () => {
+ const output = processTranscriptMessages([{
+ _id: "m1",
+ createdAt: 1,
+ kind: "user_prompt",
+ content: "continue",
+ autoContinue: { scheduleId: "s1" },
+ }])
+ expect(output[0].kind).toBe("user_prompt")
+ expect((output[0] as { autoContinue?: { scheduleId: string } }).autoContinue?.scheduleId).toBe("s1")
+})
+```
+
+- [ ] **Step 3: Run the test**
+
+Run: `bun test src/client/lib/parseTranscript.test.ts`
+Expected: PASS.
+
+- [ ] **Step 4: Render `AutoContinueCard` in the transcript**
+
+Find the transcript message-renderer (search `Grep` for `case "user_prompt":` under `src/client/components`). In its switch, add:
+
+```tsx
+case "auto_continue_prompt": {
+ const schedule = chatSnapshot.schedules[message.scheduleId]
+ if (!schedule) return null
+ return (
+ sendCommand({ type: "autoContinue.accept", chatId, scheduleId: message.scheduleId, scheduledAt })}
+ onReschedule={(scheduledAt) => sendCommand({ type: "autoContinue.reschedule", chatId, scheduleId: message.scheduleId, scheduledAt })}
+ onCancel={() => sendCommand({ type: "autoContinue.cancel", chatId, scheduleId: message.scheduleId })}
+ />
+ )
+}
+```
+
+In the `user_prompt` case, if `message.autoContinue` is set, append a small "auto-sent" badge next to the content.
+
+- [ ] **Step 5: Smoke-test in dev mode**
+
+Run: `bun dev`, then open the app, synthesize a rate-limit error (see Task 18 end-to-end test), and confirm:
+- Card renders in proposed state.
+- Schedule button sends the correct WS command.
+- User prompt generated by firing has the "auto-sent" badge.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/client/lib/parseTranscript.ts src/client/lib/parseTranscript.test.ts src/client/components/
+git commit -m "feat(auto-continue): render AutoContinueCard + auto-sent badge in transcript"
+```
+
+---
+
+## Task 18: Settings page toggle
+
+**Files:**
+- Modify: `src/client/app/SettingsPage.tsx`
+- Test: `src/client/app/SettingsPage.test.tsx` (extend)
+
+- [ ] **Step 1: Add a failing test**
+
+Append to `src/client/app/SettingsPage.test.tsx` (or similar):
+
+```ts
+test("renders the Auto-resume on rate limit toggle", () => {
+ // Render with the provider mocks and assert that the toggle label is present.
+ // See the existing tests in this file for the required provider shape.
+})
+```
+
+- [ ] **Step 2: Run the test**
+
+Run: `bun test src/client/app/SettingsPage.test.tsx`
+Expected: FAIL.
+
+- [ ] **Step 3: Implement the toggle**
+
+Edit `src/client/app/SettingsPage.tsx`. Import:
+
+```ts
+import { usePreferencesStore } from "../stores/preferences"
+```
+
+In the General section, add a toggle row using the existing styling conventions:
+
+```tsx
+const autoResumeOnRateLimit = usePreferencesStore((state) => state.autoResumeOnRateLimit)
+const setAutoResumeOnRateLimit = usePreferencesStore((state) => state.setAutoResumeOnRateLimit)
+
+// ...
+
+
+```
+
+- [ ] **Step 4: Run the test**
+
+Run: `bun test src/client/app/SettingsPage.test.tsx`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/client/app/SettingsPage.tsx src/client/app/SettingsPage.test.tsx
+git commit -m "feat(auto-continue): add Auto-resume toggle to Settings page"
+```
+
+---
+
+## Task 19: End-to-end test — detection → card → accept → fire
+
+**Files:**
+- Create: `src/server/auto-continue/e2e.test.ts`
+
+- [ ] **Step 1: Write the end-to-end test**
+
+Create `src/server/auto-continue/e2e.test.ts`:
+
+```ts
+import { describe, expect, test } from "bun:test"
+import { mkdtemp, rm } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { EventStore } from "../event-store"
+import { AgentCoordinator } from "../agent"
+import { ScheduleManager, type Clock } from "./schedule-manager"
+import { ClaudeLimitDetector, CodexLimitDetector } from "./limit-detector"
+
+class FakeClock implements Clock {
+ private current = 0
+ private scheduled: Array<{ fireAt: number; fn: () => void; id: number }> = []
+ private nextId = 1
+ now() { return this.current }
+ setTimeout(fn: () => void, delayMs: number) {
+ const id = this.nextId++
+ this.scheduled.push({ fireAt: this.current + delayMs, fn, id })
+ return id
+ }
+ clearTimeout(id: number) { this.scheduled = this.scheduled.filter((x) => x.id !== id) }
+ advance(ms: number) {
+ this.current += ms
+ const due = this.scheduled.filter((x) => x.fireAt <= this.current)
+ this.scheduled = this.scheduled.filter((x) => x.fireAt > this.current)
+ for (const entry of due) entry.fn()
+ }
+}
+
+describe("auto-continue end-to-end", () => {
+ test("rate limit → card → accept → fires 'continue' user message", async () => {
+ const dir = await mkdtemp(join(tmpdir(), "kanna-e2e-"))
+ try {
+ const store = new EventStore(dir)
+ await store.initialize()
+ const project = await store.openProject("/tmp/proj")
+ const chat = await store.createChat(project.id)
+
+ const clock = new FakeClock()
+ let agent!: AgentCoordinator
+ const scheduleManager = new ScheduleManager({
+ clock,
+ fire: async (chatId, scheduleId) => agent.fireAutoContinue(chatId, scheduleId),
+ })
+ agent = new AgentCoordinator({
+ store,
+ onStateChange: () => {},
+ claudeLimitDetector: new ClaudeLimitDetector(),
+ codexLimitDetector: new CodexLimitDetector(),
+ scheduleManager,
+ getAutoResumePreference: () => false,
+ startClaudeSession: async () => {
+ // stream a rate-limit error on first iteration
+ throw new Error(JSON.stringify({ type: "error", error: { type: "rate_limit_error" } }))
+ },
+ // Stub other required args — mirror defaults from existing tests.
+ } as never)
+
+ // Trigger send
+ await agent.send({ type: "chat.send", chatId: chat.id, content: "hi", autoResumeOnRateLimit: false })
+
+ // Expect proposed event
+ let events = store.getAutoContinueEvents(chat.id)
+ expect(events).toHaveLength(1)
+ expect(events[0].kind).toBe("auto_continue_proposed")
+ const scheduleId = events[0].scheduleId
+
+ // Accept
+ await agent.acceptAutoContinue(chat.id, scheduleId, clock.now() + 100)
+
+ events = store.getAutoContinueEvents(chat.id)
+ expect(events[1].kind).toBe("auto_continue_accepted")
+
+ // Advance clock
+ clock.advance(100)
+ await Promise.resolve()
+
+ events = store.getAutoContinueEvents(chat.id)
+ expect(events.some((e) => e.kind === "auto_continue_fired")).toBe(true)
+
+ const transcript = store.getMessages(chat.id)
+ const fired = transcript.find((entry) => entry.kind === "user_prompt" && (entry as { autoContinue?: { scheduleId: string } }).autoContinue?.scheduleId === scheduleId)
+ expect(fired).toBeDefined()
+ expect((fired as { content: string }).content).toBe("continue")
+ } finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+})
+```
+
+The exact stub for `startClaudeSession` depends on the existing harness. Copy from `src/server/agent.test.ts` helpers.
+
+- [ ] **Step 2: Run the test**
+
+Run: `bun test src/server/auto-continue/e2e.test.ts`
+Expected: PASS.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/server/auto-continue/e2e.test.ts
+git commit -m "test(auto-continue): end-to-end detect → accept → fire flow"
+```
+
+---
+
+## Task 20: Final verification
+
+- [ ] **Step 1: Type-check and full test run**
+
+Run: `bun run check && bun test`
+Expected: all checks pass.
+
+- [ ] **Step 2: Manual smoke test in dev mode**
+
+Run: `bun dev`, open Kanna in a browser, and manually:
+
+1. Pick a chat.
+2. Temporarily expose a debug hook that throws a synthetic rate-limit error for one turn (e.g., via a `KANNA_DEBUG_RATE_LIMIT=1` env var in the agent — add this only locally, do NOT commit).
+3. Confirm:
+ - Card appears with the default reset time.
+ - Editing the time and clicking Schedule sends the correct WS command.
+ - Scheduled state shows tz-labelled time.
+ - Cancel transitions to the cancelled terminal state.
+4. Toggle **Settings → Auto-resume on rate limit** to ON and repeat step 2. Confirm no proposed card appears and the card renders in `scheduled` state immediately.
+5. Restart the dev server. Confirm pending schedules re-arm (advance wall clock or set reset far in the future).
+
+- [ ] **Step 3: Commit any doc/polish fixes uncovered during smoke**
+
+```bash
+git add -p
+git commit -m "chore(auto-continue): smoke-test polish"
+```
+
+---
+
+## Dependencies Between Tasks
+
+```
+1 (types) ───▶ 2 (events) ───▶ 3 (read-model) ───▶ 4/5 (detectors)
+ │
+ ├──▶ 6 (event store) ─┐
+ │ │
+ └──▶ 7 (schedule mgr) │
+ ▼
+ 8 (snapshot projection)
+ │
+ ▼
+ 9 (protocol) ─▶ 10 (prefs) ─▶ 11 (wire prefs to WS)
+ │
+ ▼
+ 12 (detection)
+ │
+ ▼
+ 13 (firing)
+ │
+ ▼
+ 14 (WS router)
+ │
+ 15 (time helpers) ──▶ 16 (card) │
+ │ │
+ ▼ ▼
+ 17 (transcript) ─▶ 18 (settings toggle) ─▶ 19 (e2e) ─▶ 20 (verify)
+```
+
+Tasks 4 and 5 can run in parallel. Tasks 9, 10, and 15 can run in parallel once Task 3 is done. Everything else is sequential.
+
+---
+
+## Self-Review Notes
+
+- **Spec coverage:** All 7 component sections (LimitDetector, ScheduleManager, Event types, Read model, Transcript/WS protocol, AutoContinueCard, Settings) have tasks. All 4 data-flow modes (manual, auto-resume, reschedule, cancel, rehydration) are covered in Tasks 7, 12, 13, 14. All 10 edge-case rows have corresponding guards in Tasks 12 (dedupe on liveScheduleId), 14 (state-guard cancel/reschedule + chat-delete cleanup), 13 (enqueue-failure handling), and 7 (rehydrate-past fires immediately).
+- **Placeholder scan:** Tasks 12 and 18 reference existing test harnesses rather than reproducing them verbatim — marked explicitly with the instruction to "copy from src/server/agent.test.ts" so the implementer knows exactly where to look.
+- **Type consistency:** `AutoContinueSchedule`, `AutoContinueEvent`, `ScheduleManager.Clock`, and the three WS command shapes are all spelled identically everywhere they appear. `scheduleId` (not `scheduleID`), `scheduledAt` (not `scheduled_at`), `resetAt` (not `reset_at`), `autoContinue` (not `auto_continue`) across TS; `auto_continue_*` snake_case only inside event `kind` strings.
+
+---
+
+## Execution Handoff
+
+Plan complete and saved to `docs/superpowers/plans/2026-04-22-auto-continue-on-rate-limit.md`. Two execution options:
+
+**1. Subagent-Driven (recommended)** — I dispatch a fresh subagent per task, review between tasks, fast iteration.
+
+**2. Inline Execution** — Execute tasks in this session using executing-plans, batch execution with checkpoints.
+
+Which approach?
diff --git a/docs/superpowers/plans/2026-04-30-push-notifications.md b/docs/superpowers/plans/2026-04-30-push-notifications.md
new file mode 100644
index 000000000..2915b167c
--- /dev/null
+++ b/docs/superpowers/plans/2026-04-30-push-notifications.md
@@ -0,0 +1,2786 @@
+# Web Push Notifications Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Deliver browser push notifications (including to phones with the tab closed) on three attention-only chat status transitions: `waiting_for_user`, `failed`, and `running → idle` (completed). Per-project mute, multi-device fan-out, focus-aware suppression, OS-level grouping by project.
+
+**Architecture:** A single new server module `PushManager` owns VAPID keys, the push subscription store, transition detection, and `web-push` fan-out. It hooks into the existing read-model derivation in `ws-router.ts` (the same pass that builds `SidebarData`). A plain-JS service worker at `public/sw.js` receives pushes and routes notification taps. Settings UI exposes a new `push-config` subscription topic for reactive devices/mute state. Storage follows Kanna's existing event-sourced JSONL pattern.
+
+**Tech Stack:** Bun + TypeScript (server), React + Zustand + WebSocket (client), `web-push` npm package, browser Service Worker + Push API + VAPID.
+
+**Spec:** `docs/superpowers/specs/2026-04-30-push-notifications-design.md`
+
+**Pre-flight read:** `src/server/event-store.ts` (EventStore JSONL pattern, `appendTunnelEvent` precedent for a non-compacted append-only log), `src/server/ws-router.ts:423-458` (`getSidebarSnapshotCacheEntry` — the natural hook point for `observeStatuses`), `src/server/read-models.ts:64-137` (`deriveSidebarData` shape), `src/shared/protocol.ts:29-251` (`SubscriptionTopic`, `ClientCommand`, `ServerSnapshot`, `ServerEnvelope`), `src/shared/types.ts:313-353` (`KannaStatus`, `SidebarChatRow`, `SidebarProjectGroup`).
+
+**Run conventions:**
+- `bun test path/to/file.test.ts` runs one test file.
+- `bun test path/to/file.test.ts -t "name"` runs one test by name.
+- `bun run check` runs full typecheck + build (do this only at the end, per project rule on resource-aware parallel work).
+- `tsc --noEmit -p .` gives a faster typecheck-only pass during iteration.
+- Tests are colocated (`*.test.ts` next to source) and use `mkdtemp(join(tmpdir(), "kanna-...-"))` for any filesystem state — see `src/server/event-store.test.ts:24-28` for the pattern.
+
+---
+
+## File structure (locked in)
+
+### New files
+
+| Path | Responsibility |
+|---|---|
+| `src/server/push/events.ts` | `PushEvent` discriminated union + tiny pure helpers. Mirrors `src/server/cloudflare-tunnel/events.ts`. |
+| `src/server/push/vapid.ts` | Load-or-generate VAPID keypair from `~/.kanna/data/vapid.json`. Pure I/O + `web-push.generateVAPIDKeys()`. |
+| `src/server/push/vapid.test.ts` | Generates on first load; reuses on second. |
+| `src/server/push/push-manager.ts` | Single owner of all push state: subscriptions, project mute, transition detection, dedup, fan-out via `web-push`, focus tracking, send-test. |
+| `src/server/push/push-manager.test.ts` | Unit tests for each behavior. |
+| `public/sw.js` | Service worker. Plain JS. `push`, `notificationclick`, `pushsubscriptionchange` handlers. |
+| `src/client/app/pushClient.ts` | Browser-side: feature detection, SW registration, subscribe/unsubscribe, talks to server over WS. |
+| `src/client/app/pushClient.test.ts` | Mocks `navigator.serviceWorker` + `PushManager`. |
+| `src/client/components/settings/PushNotificationsSection.tsx` | Settings UI card. |
+| `src/client/components/settings/PushNotificationsSection.test.tsx` | Renders each permission state; toggle and mute flows. |
+
+### Modified files
+
+| Path | Change |
+|---|---|
+| `package.json` | Add `web-push` dep + `@types/web-push` dev dep. |
+| `src/shared/types.ts` | Add push shapes. |
+| `src/shared/protocol.ts` | Add push commands, push-config subscription, push-config snapshot. |
+| `src/server/event-store.ts` | Own `push.jsonl` (path, ensure, replay, append). Mirrors `tunnels.jsonl` plumbing. |
+| `src/server/ws-router.ts` | Construct `PushManager`, route `push.*` commands, hook `observeStatuses` after `deriveSidebarData`, broadcast `push-config` on changes, attach `pushDeviceId` to `ClientState`. |
+| `src/server/server.ts` | Inject `PushManager` into `createWsRouter`. |
+| `src/client/app/socket.ts` | Identify device on connect; report focused chat. |
+| `src/client/app/SettingsPage.tsx` | Mount `PushNotificationsSection`. |
+| `.c3/code-map.yaml` | Register `c3-119`, `c3-224`, `ref-push`. |
+
+### Boundary rule
+
+Only `push-manager.ts` and `vapid.ts` import the `web-push` library. No client file imports `web-push`. The shared types in `src/shared/types.ts` are the wire contract — both sides import them.
+
+---
+
+## Task 1: Add push shapes to `src/shared/types.ts`
+
+**Files:**
+- Modify: `src/shared/types.ts` (append after line 318, near `KannaStatus`)
+
+- [ ] **Step 1: Append the new types**
+
+Open `src/shared/types.ts` and append these declarations after the existing `KannaStatus` union (line 313-318):
+
+```ts
+export type PushTransitionKind = "waiting_for_user" | "failed" | "completed"
+
+export interface PushSubscriptionRecord {
+ id: string
+ endpoint: string
+ keys: { p256dh: string; auth: string }
+ label: string
+ userAgent: string
+ createdAt: number
+ lastSeenAt: number
+}
+
+export interface PushPayload {
+ v: 1
+ kind: PushTransitionKind
+ projectLocalPath: string
+ projectTitle: string
+ chatId: string
+ chatTitle: string
+ chatUrl: string
+ ts: number
+}
+
+export interface PushPreferences {
+ globalEnabled: boolean
+ mutedProjectPaths: string[]
+}
+
+export interface PushDeviceSummary {
+ id: string
+ label: string
+ userAgent: string
+ createdAt: number
+ lastSeenAt: number
+ isCurrentDevice: boolean
+}
+
+export interface PushConfigSnapshot {
+ vapidPublicKey: string
+ preferences: PushPreferences
+ devices: PushDeviceSummary[]
+}
+
+export interface PushSubscribeRequestPayload {
+ endpoint: string
+ keys: { p256dh: string; auth: string }
+}
+```
+
+- [ ] **Step 2: Typecheck**
+
+Run: `tsc --noEmit -p .`
+Expected: PASS (no errors).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/shared/types.ts
+git commit -m "feat(push): add shared types for web push payload and config"
+```
+
+---
+
+## Task 2: Add push protocol messages to `src/shared/protocol.ts`
+
+**Files:**
+- Modify: `src/shared/protocol.ts`
+
+- [ ] **Step 1: Add the import**
+
+Open `src/shared/protocol.ts`. In the `import type {` block (lines 1-20), add `PushConfigSnapshot` and `PushSubscribeRequestPayload`:
+
+```ts
+import type {
+ AppSettingsSnapshot,
+ AppSettingsPatch,
+ AgentProvider,
+ ChatAttachment,
+ ChatDiffSnapshot,
+ ChatHistoryPage,
+ ChatSnapshot,
+ CloudflareTunnelSettings,
+ DiffCommitMode,
+ KeybindingsSnapshot,
+ LlmProviderSnapshot,
+ LocalProjectsSnapshot,
+ ModelOptions,
+ PushConfigSnapshot,
+ PushSubscribeRequestPayload,
+ SidebarData,
+ StandaloneTranscriptAttachmentMode,
+ StandaloneTranscriptExportResult,
+ UpdateSnapshot,
+ EditorPreset,
+} from "./types"
+```
+
+- [ ] **Step 2: Add the subscription topic**
+
+Replace the `SubscriptionTopic` union (around line 29-37) with:
+
+```ts
+export type SubscriptionTopic =
+ | { type: "sidebar" }
+ | { type: "local-projects" }
+ | { type: "update" }
+ | { type: "keybindings" }
+ | { type: "app-settings" }
+ | { type: "push-config" }
+ | { type: "chat"; chatId: string; recentLimit?: number }
+ | { type: "project-git"; projectId: string }
+ | { type: "terminal"; terminalId: string }
+```
+
+- [ ] **Step 3: Add the client commands**
+
+In the `ClientCommand` union (the long `export type ClientCommand = ...` block), append these branches before the closing `| { type: "terminal.close"; terminalId: string }` line (around line 227):
+
+```ts
+ | { type: "push.identifyDevice"; pushDeviceId: string | null }
+ | { type: "push.subscribe"; subscription: PushSubscribeRequestPayload; label: string; userAgent: string }
+ | { type: "push.unsubscribe"; pushDeviceId: string }
+ | { type: "push.test" }
+ | { type: "push.setProjectMute"; localPath: string; muted: boolean }
+ | { type: "push.setFocusedChat"; chatId: string | null }
+```
+
+- [ ] **Step 4: Add the server snapshot variant**
+
+Replace the `ServerSnapshot` union (around line 236-245) with:
+
+```ts
+export type ServerSnapshot =
+ | { type: "sidebar"; data: SidebarData }
+ | { type: "local-projects"; data: LocalProjectsSnapshot }
+ | { type: "update"; data: UpdateSnapshot }
+ | { type: "keybindings"; data: KeybindingsSnapshot }
+ | { type: "app-settings"; data: AppSettingsSnapshot }
+ | { type: "llm-provider"; data: LlmProviderSnapshot }
+ | { type: "push-config"; data: PushConfigSnapshot }
+ | { type: "chat"; data: ChatSnapshot | null }
+ | { type: "project-git"; data: ChatDiffSnapshot | null }
+ | { type: "terminal"; data: TerminalSnapshot | null }
+```
+
+- [ ] **Step 5: Typecheck**
+
+Run: `tsc --noEmit -p .`
+Expected: errors in `ws-router.ts` (missing handler cases for new commands and topic) — that is the failing baseline. Note them; we will fix in Task 12.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/shared/types.ts src/shared/protocol.ts
+git commit -m "feat(push): add ws protocol messages for push subscribe/unsubscribe/mute/focus"
+```
+
+---
+
+## Task 3: Add `web-push` dependency
+
+**Files:**
+- Modify: `package.json`
+
+- [ ] **Step 1: Install runtime dep**
+
+Run from repo root: `bun add web-push@^3.6.7`
+Expected: package.json gets `"web-push": "^3.6.7"` in `dependencies`.
+
+- [ ] **Step 2: Install types**
+
+Run: `bun add -d @types/web-push@^3.6.4`
+Expected: package.json gets `"@types/web-push": "^3.6.4"` in `devDependencies`.
+
+- [ ] **Step 3: Verify import works**
+
+Run: `bun -e 'import("web-push").then(m => console.log(typeof m.generateVAPIDKeys))'`
+Expected: prints `function`.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add package.json bun.lock
+git commit -m "chore(push): add web-push dependency"
+```
+
+---
+
+## Task 4: VAPID keypair load-or-generate (`src/server/push/vapid.ts`)
+
+**Files:**
+- Create: `src/server/push/vapid.ts`
+- Test: `src/server/push/vapid.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `src/server/push/vapid.test.ts`:
+
+```ts
+import { afterEach, describe, expect, test } from "bun:test"
+import { mkdtemp, readFile, rm } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { loadOrGenerateVapidKeys } from "./vapid"
+
+const tempDirs: string[] = []
+
+afterEach(async () => {
+ await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })))
+})
+
+async function tempDir() {
+ const dir = await mkdtemp(join(tmpdir(), "kanna-vapid-"))
+ tempDirs.push(dir)
+ return dir
+}
+
+describe("loadOrGenerateVapidKeys", () => {
+ test("generates a fresh keypair on first call and persists it to disk", async () => {
+ const dir = await tempDir()
+ const result = await loadOrGenerateVapidKeys(dir)
+
+ expect(result.publicKey).toMatch(/^[A-Za-z0-9_-]{60,90}$/)
+ expect(result.privateKey).toMatch(/^[A-Za-z0-9_-]{40,60}$/)
+ expect(result.subject).toBe("mailto:kanna@localhost")
+
+ const onDisk = JSON.parse(await readFile(join(dir, "vapid.json"), "utf8"))
+ expect(onDisk.publicKey).toBe(result.publicKey)
+ expect(onDisk.privateKey).toBe(result.privateKey)
+ })
+
+ test("reuses the existing keypair on subsequent calls", async () => {
+ const dir = await tempDir()
+ const first = await loadOrGenerateVapidKeys(dir)
+ const second = await loadOrGenerateVapidKeys(dir)
+ expect(second.publicKey).toBe(first.publicKey)
+ expect(second.privateKey).toBe(first.privateKey)
+ })
+})
+```
+
+- [ ] **Step 2: Run the test (expect FAIL)**
+
+Run: `bun test src/server/push/vapid.test.ts`
+Expected: FAIL — module `./vapid` not found.
+
+- [ ] **Step 3: Write the minimal implementation**
+
+Create `src/server/push/vapid.ts`:
+
+```ts
+import { mkdir, readFile, writeFile } from "node:fs/promises"
+import { existsSync } from "node:fs"
+import { join } from "node:path"
+import webpush from "web-push"
+
+export interface VapidKeypair {
+ publicKey: string
+ privateKey: string
+ subject: string
+}
+
+const DEFAULT_SUBJECT = "mailto:kanna@localhost"
+
+export async function loadOrGenerateVapidKeys(dataDir: string): Promise {
+ await mkdir(dataDir, { recursive: true })
+ const path = join(dataDir, "vapid.json")
+ if (existsSync(path)) {
+ const text = await readFile(path, "utf8")
+ const parsed = JSON.parse(text) as VapidKeypair
+ if (parsed.publicKey && parsed.privateKey) {
+ return { ...parsed, subject: parsed.subject ?? DEFAULT_SUBJECT }
+ }
+ }
+ const generated = webpush.generateVAPIDKeys()
+ const keypair: VapidKeypair = {
+ publicKey: generated.publicKey,
+ privateKey: generated.privateKey,
+ subject: DEFAULT_SUBJECT,
+ }
+ await writeFile(path, JSON.stringify(keypair, null, 2), { mode: 0o600 })
+ return keypair
+}
+```
+
+- [ ] **Step 4: Run tests (expect PASS)**
+
+Run: `bun test src/server/push/vapid.test.ts`
+Expected: 2 pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/server/push/vapid.ts src/server/push/vapid.test.ts
+git commit -m "feat(push): VAPID keypair load-or-generate with 0600 perms"
+```
+
+---
+
+## Task 5: Push event types (`src/server/push/events.ts`)
+
+**Files:**
+- Create: `src/server/push/events.ts`
+
+- [ ] **Step 1: Write the file**
+
+Create `src/server/push/events.ts`:
+
+```ts
+import type { PushSubscriptionRecord } from "../../shared/types"
+
+export type PushEvent =
+ | { kind: "subscription_added"; ts: number; id: string; record: PushSubscriptionRecord }
+ | { kind: "subscription_removed"; ts: number; id: string; reason: "user_revoked" | "expired" | "replaced" }
+ | { kind: "subscription_seen"; ts: number; id: string }
+ | { kind: "project_mute_set"; ts: number; localPath: string; muted: boolean }
+
+export interface PushEventStore {
+ appendPushEvent(event: PushEvent): Promise
+ loadPushEvents(): Promise
+}
+```
+
+- [ ] **Step 2: Typecheck**
+
+Run: `tsc --noEmit -p .`
+Expected: PASS.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add src/server/push/events.ts
+git commit -m "feat(push): event union and PushEventStore interface"
+```
+
+---
+
+## Task 6: Wire `push.jsonl` into `EventStore`
+
+Mirror the `tunnels.jsonl` plumbing in `event-store.ts`. The push log is **not** compacted into `snapshot.json` — it's left as the source of truth (subscriptions are always replayable from the log).
+
+**Files:**
+- Modify: `src/server/event-store.ts`
+- Modify: `src/server/event-store.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Append this `describe` block to `src/server/event-store.test.ts` (before the final closing `})` of the file's outermost `describe("EventStore", ...)`):
+
+```ts
+ test("appends and reloads push events", async () => {
+ const dataDir = await createTempDataDir()
+ const store = new EventStore(dataDir)
+ await store.initialize()
+
+ await store.appendPushEvent({
+ kind: "subscription_added",
+ ts: 1700000000000,
+ id: "sub-1",
+ record: {
+ id: "sub-1",
+ endpoint: "https://push.example/abc",
+ keys: { p256dh: "p", auth: "a" },
+ label: "iPhone",
+ userAgent: "Mozilla/5.0",
+ createdAt: 1700000000000,
+ lastSeenAt: 1700000000000,
+ },
+ })
+ await store.appendPushEvent({
+ kind: "project_mute_set",
+ ts: 1700000000001,
+ localPath: "/tmp/proj-a",
+ muted: true,
+ })
+
+ const reloaded = new EventStore(dataDir)
+ await reloaded.initialize()
+ const events = await reloaded.loadPushEvents()
+ expect(events).toHaveLength(2)
+ expect(events[0].kind).toBe("subscription_added")
+ expect(events[1].kind).toBe("project_mute_set")
+ })
+```
+
+Add the import at the top of the test file:
+```ts
+import type { PushEvent } from "./push/events"
+```
+(Place it after the existing `import type { AutoContinueEvent } from "./auto-continue/events"` line.)
+
+- [ ] **Step 2: Run the test (expect FAIL)**
+
+Run: `bun test src/server/event-store.test.ts -t "appends and reloads push events"`
+Expected: FAIL — `appendPushEvent` does not exist on `EventStore`.
+
+- [ ] **Step 3: Modify `EventStore` to support `push.jsonl`**
+
+Open `src/server/event-store.ts`.
+
+(a) Add the import near the top, after the existing `cloudflare-tunnel/events` import (around line 21):
+```ts
+import type { PushEvent } from "./push/events"
+```
+
+(b) Add a private path field. In the `EventStore` class field list (around lines 178-186, near `tunnelLogPath`), add:
+```ts
+ private readonly pushLogPath: string
+```
+
+(c) Initialize the path. In the constructor (around line 198, after `tunnelLogPath`):
+```ts
+ this.pushLogPath = path.join(this.dataDir, "push.jsonl")
+```
+
+(d) Ensure the file exists at startup. In `initialize()` (around line 211, after `await this.ensureFile(this.tunnelLogPath)`):
+```ts
+ await this.ensureFile(this.pushLogPath)
+```
+
+(e) Add the public methods at the end of the class, right before the final closing `}`:
+```ts
+ async appendPushEvent(event: PushEvent): Promise {
+ const payload = `${JSON.stringify(event)}\n`
+ this.writeChain = this.writeChain.then(async () => {
+ await appendFile(this.pushLogPath, payload, "utf8")
+ })
+ await this.writeChain
+ }
+
+ async loadPushEvents(): Promise {
+ const file = Bun.file(this.pushLogPath)
+ if (!(await file.exists())) return []
+ const text = await file.text()
+ if (!text.trim()) return []
+
+ const events: PushEvent[] = []
+ for (const rawLine of text.split("\n")) {
+ const line = rawLine.trim()
+ if (!line) continue
+ try {
+ events.push(JSON.parse(line) as PushEvent)
+ } catch {
+ console.warn(`${LOG_PREFIX} Ignoring malformed line in push.jsonl`)
+ }
+ }
+ return events
+ }
+```
+
+- [ ] **Step 4: Run the test (expect PASS)**
+
+Run: `bun test src/server/event-store.test.ts -t "appends and reloads push events"`
+Expected: PASS.
+
+- [ ] **Step 5: Run the full file to confirm nothing regressed**
+
+Run: `bun test src/server/event-store.test.ts`
+Expected: all tests pass.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add src/server/event-store.ts src/server/event-store.test.ts src/server/push/events.ts
+git commit -m "feat(push): persist push.jsonl through EventStore (no compaction)"
+```
+
+---
+
+## Task 7: PushManager — construction & seeding
+
+The first call to `observeStatuses` only seeds `lastStatusByChat` and fires nothing. This guards against post-restart replay storms.
+
+**Files:**
+- Create: `src/server/push/push-manager.ts`
+- Test: `src/server/push/push-manager.test.ts`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `src/server/push/push-manager.test.ts`:
+
+```ts
+import { beforeEach, describe, expect, test } from "bun:test"
+import type { PushEvent, PushEventStore } from "./events"
+import { PushManager, type WebPushSender, type ObservedChat } from "./push-manager"
+
+class FakeStore implements PushEventStore {
+ events: PushEvent[] = []
+ async appendPushEvent(event: PushEvent) { this.events.push(event) }
+ async loadPushEvents() { return [...this.events] }
+}
+
+interface SentPush {
+ endpoint: string
+ payload: string
+ ttl: number
+ urgency: "very-low" | "low" | "normal" | "high"
+}
+
+class FakeSender implements WebPushSender {
+ sent: SentPush[] = []
+ errorByEndpoint: Map = new Map()
+ async send(sub, body, opts) {
+ const error = this.errorByEndpoint.get(sub.endpoint)
+ if (error) throw error
+ this.sent.push({ endpoint: sub.endpoint, payload: body, ttl: opts.TTL, urgency: opts.urgency })
+ }
+}
+
+const VAPID = { publicKey: "pub", privateKey: "prv", subject: "mailto:test@kanna" }
+
+function chat(overrides: Partial = {}): ObservedChat {
+ return {
+ chatId: "c1",
+ projectLocalPath: "/tmp/p",
+ projectTitle: "P",
+ chatTitle: "Hello",
+ status: "idle",
+ ...overrides,
+ }
+}
+
+describe("PushManager.observeStatuses", () => {
+ let store: FakeStore
+ let sender: FakeSender
+ let manager: PushManager
+
+ beforeEach(async () => {
+ store = new FakeStore()
+ sender = new FakeSender()
+ manager = new PushManager({ store, sender, vapid: VAPID, now: () => 1000 })
+ await manager.initialize()
+ })
+
+ test("first call seeds without firing", async () => {
+ await manager.observeStatuses([chat({ status: "running" })])
+ expect(sender.sent).toEqual([])
+ })
+
+ test("second call fires for waiting_for_user transition", async () => {
+ await manager.observeStatuses([chat({ status: "running" })])
+ await manager.observeStatuses([chat({ status: "waiting_for_user" })])
+ expect(sender.sent).toEqual([]) // no subscriptions registered yet
+ })
+})
+```
+
+- [ ] **Step 2: Run the test (expect FAIL)**
+
+Run: `bun test src/server/push/push-manager.test.ts`
+Expected: FAIL — module not found.
+
+- [ ] **Step 3: Implement minimum to pass**
+
+Create `src/server/push/push-manager.ts`:
+
+```ts
+import type {
+ KannaStatus,
+ PushPayload,
+ PushSubscriptionRecord,
+ PushTransitionKind,
+} from "../../shared/types"
+import type { PushEvent, PushEventStore } from "./events"
+import type { VapidKeypair } from "./vapid"
+
+export interface ObservedChat {
+ chatId: string
+ projectLocalPath: string
+ projectTitle: string
+ chatTitle: string
+ status: KannaStatus
+}
+
+export interface WebPushSendOptions {
+ TTL: number
+ urgency: "very-low" | "low" | "normal" | "high"
+ vapidDetails: { subject: string; publicKey: string; privateKey: string }
+}
+
+export interface WebPushSubscriptionShape {
+ endpoint: string
+ keys: { p256dh: string; auth: string }
+}
+
+export interface WebPushSender {
+ send(
+ subscription: WebPushSubscriptionShape,
+ payload: string,
+ options: WebPushSendOptions,
+ ): Promise
+}
+
+export interface PushManagerArgs {
+ store: PushEventStore
+ sender: WebPushSender
+ vapid: VapidKeypair
+ now?: () => number
+}
+
+export class PushManager {
+ private readonly store: PushEventStore
+ private readonly sender: WebPushSender
+ private readonly vapid: VapidKeypair
+ private readonly now: () => number
+ private readonly subscriptions = new Map()
+ private readonly mutedProjects = new Set()
+ private readonly lastStatusByChat = new Map()
+ private seeded = false
+
+ constructor(args: PushManagerArgs) {
+ this.store = args.store
+ this.sender = args.sender
+ this.vapid = args.vapid
+ this.now = args.now ?? Date.now
+ }
+
+ async initialize(): Promise {
+ const events = await this.store.loadPushEvents()
+ for (const event of events) {
+ this.applyEvent(event)
+ }
+ }
+
+ private applyEvent(event: PushEvent) {
+ switch (event.kind) {
+ case "subscription_added":
+ this.subscriptions.set(event.id, event.record)
+ break
+ case "subscription_removed":
+ this.subscriptions.delete(event.id)
+ break
+ case "subscription_seen": {
+ const existing = this.subscriptions.get(event.id)
+ if (existing) existing.lastSeenAt = event.ts
+ break
+ }
+ case "project_mute_set":
+ if (event.muted) this.mutedProjects.add(event.localPath)
+ else this.mutedProjects.delete(event.localPath)
+ break
+ }
+ }
+
+ async observeStatuses(snapshot: readonly ObservedChat[]): Promise {
+ if (!this.seeded) {
+ for (const chat of snapshot) {
+ this.lastStatusByChat.set(chat.chatId, chat.status)
+ }
+ this.seeded = true
+ return
+ }
+ for (const chat of snapshot) {
+ const prev = this.lastStatusByChat.get(chat.chatId)
+ this.lastStatusByChat.set(chat.chatId, chat.status)
+ // Transition firing comes in later tasks.
+ void prev
+ }
+ }
+}
+```
+
+- [ ] **Step 4: Run tests (expect PASS)**
+
+Run: `bun test src/server/push/push-manager.test.ts`
+Expected: 2 pass.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add src/server/push/push-manager.ts src/server/push/push-manager.test.ts
+git commit -m "feat(push): PushManager skeleton with cold-start seeding"
+```
+
+---
+
+## Task 8: Transition detection (waiting_for_user, failed, completed)
+
+**Files:**
+- Modify: `src/server/push/push-manager.ts`
+- Modify: `src/server/push/push-manager.test.ts`
+
+- [ ] **Step 1: Add subscription helper to test setup**
+
+In `push-manager.test.ts`, add this helper just above the `describe("PushManager.observeStatuses", ...)` block:
+
+```ts
+async function registerSub(manager: PushManager, store: FakeStore, id: string, endpoint: string) {
+ store.events.push({
+ kind: "subscription_added",
+ ts: 1,
+ id,
+ record: {
+ id,
+ endpoint,
+ keys: { p256dh: "p", auth: "a" },
+ label: "Test",
+ userAgent: "Test",
+ createdAt: 1,
+ lastSeenAt: 1,
+ },
+ })
+ await manager.initialize()
+}
+```
+
+- [ ] **Step 2: Replace beforeEach to skip auto-init**
+
+Replace the existing `beforeEach` in `describe("PushManager.observeStatuses", ...)` with:
+
+```ts
+ beforeEach(() => {
+ store = new FakeStore()
+ sender = new FakeSender()
+ manager = new PushManager({ store, sender, vapid: VAPID, now: () => 1000 })
+ })
+```
+
+(remove the `await manager.initialize()` call). Each test now calls `initialize()` itself after registering whatever subs it needs.
+
+Also update the existing two tests in that block to call `await manager.initialize()` at their start. The "first call seeds without firing" test becomes:
+
+```ts
+ test("first call seeds without firing", async () => {
+ await manager.initialize()
+ await manager.observeStatuses([chat({ status: "running" })])
+ expect(sender.sent).toEqual([])
+ })
+
+ test("second call fires for waiting_for_user transition", async () => {
+ await registerSub(manager, store, "d1", "https://push.example/x")
+ await manager.observeStatuses([chat({ status: "running" })])
+ await manager.observeStatuses([chat({ status: "waiting_for_user" })])
+ expect(sender.sent).toHaveLength(1)
+ const payload = JSON.parse(sender.sent[0].payload) as PushPayload
+ expect(payload.kind).toBe("waiting_for_user")
+ expect(payload.chatId).toBe("c1")
+ expect(payload.projectLocalPath).toBe("/tmp/p")
+ })
+```
+
+Add the import at the top of the test file:
+```ts
+import type { PushPayload } from "../../shared/types"
+```
+
+- [ ] **Step 3: Add three more transition tests**
+
+Append within the same `describe`:
+
+```ts
+ test("fires for running -> idle (completed)", async () => {
+ await registerSub(manager, store, "d1", "https://push.example/x")
+ await manager.observeStatuses([chat({ status: "running" })])
+ await manager.observeStatuses([chat({ status: "idle" })])
+ expect(sender.sent).toHaveLength(1)
+ expect(JSON.parse(sender.sent[0].payload).kind).toBe("completed")
+ })
+
+ test("fires for any -> failed", async () => {
+ await registerSub(manager, store, "d1", "https://push.example/x")
+ await manager.observeStatuses([chat({ status: "running" })])
+ await manager.observeStatuses([chat({ status: "failed" })])
+ expect(sender.sent).toHaveLength(1)
+ expect(JSON.parse(sender.sent[0].payload).kind).toBe("failed")
+ })
+
+ test("does not fire for idle -> starting -> running", async () => {
+ await registerSub(manager, store, "d1", "https://push.example/x")
+ await manager.observeStatuses([chat({ status: "idle" })])
+ await manager.observeStatuses([chat({ status: "starting" })])
+ await manager.observeStatuses([chat({ status: "running" })])
+ expect(sender.sent).toEqual([])
+ })
+
+ test("truncates long chat title to 80 chars", async () => {
+ await registerSub(manager, store, "d1", "https://push.example/x")
+ const long = "x".repeat(120)
+ await manager.observeStatuses([chat({ status: "running" })])
+ await manager.observeStatuses([chat({ status: "waiting_for_user", chatTitle: long })])
+ expect(sender.sent).toHaveLength(1)
+ const payload = JSON.parse(sender.sent[0].payload) as PushPayload
+ expect(payload.chatTitle.length).toBe(80)
+ })
+```
+
+- [ ] **Step 4: Run tests (expect FAILs)**
+
+Run: `bun test src/server/push/push-manager.test.ts`
+Expected: 4 fail (transitions don't fire yet).
+
+- [ ] **Step 5: Implement transition detection + fan-out**
+
+In `push-manager.ts`, replace the `observeStatuses` method body and add helpers:
+
+```ts
+ async observeStatuses(snapshot: readonly ObservedChat[]): Promise