diff --git a/Directory.Packages.props b/Directory.Packages.props
index f2f79942f..19876bd98 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -16,6 +16,7 @@
+
diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
index f8fba1926..e67b5fcf5 100644
--- a/IMPLEMENTATION_PLAN.md
+++ b/IMPLEMENTATION_PLAN.md
@@ -200,9 +200,9 @@ Full task breakdowns:
> **Gateway note:** Netclaw.App was temporarily changed from `Microsoft.NET.Sdk.Web`
> to `Microsoft.NET.Sdk` (plain console host) for the proof-of-concept console
-> adapter. The web gateway (health endpoints, future API surface) must be
-> restored before Slack adapter or any multi-client scenario. Track as part of
-> Task 1.11 (CLI scaffold) or earlier if needed.
+> adapter. Task 1.11 restores `Microsoft.NET.Sdk.Web` for daemon modes per
+> SPEC-011. Single-process architecture validated by research in
+> `docs/research/agent-gateway-architecture.md`.
### Task 1.1: Framework protocol and persistence-safe message envelopes (DONE)
@@ -364,28 +364,36 @@ Done when:
- [ ] CI tests pass without live provider credentials.
- [ ] Tests for provider switching, fallback activation, tool calling round-trip.
-### Task 1.11: CLI scaffold with Cocona
+### Task 1.11: Daemon architecture scaffold with mode routing
**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md` (CLI-010, CLI-012)
+**Spec:** `docs/spec/SPEC-011-daemon-architecture.md`
**OpenSpec:** `openspec/specs/netclaw-cli/spec.md`
**OpenSpec Changes:** `openspec/changes/add-tui-adapter-and-config-hot-reload/` (Section 1)
-**Surface area:** CLI framework
+**Surface area:** CLI framework + gateway
**Verification:** L1
**Previously:** Task 1.13
-Replaces the bare console loop with a proper CLI framework. Restore web gateway
-hosting (`Microsoft.NET.Sdk.Web`) if needed for daemon mode.
+Replaces the bare console loop with mode-selected startup per SPEC-011.
+Daemon modes use `WebApplication.CreateBuilder()` (ASP.NET host for SignalR
+and health endpoints). Lightweight modes use `Host.CreateApplicationBuilder()`.
+Cocona is archived (Dec 2025) — replaced with simple arg routing.
Done when:
-- [ ] Cocona and Termina package references added to `Directory.Packages.props` and `Netclaw.App.csproj`.
-- [ ] `Program.cs` rewritten as Cocona entry point with DI registration.
-- [ ] `RunCommand.cs` created for daemon mode (`netclaw run`).
-- [ ] Termina wired as hosted service for TUI commands.
+- [ ] Termina and System.Reactive package references added to `Directory.Packages.props`.
+- [ ] `Netclaw.App.csproj` SDK changed to `Microsoft.NET.Sdk.Web`, Termina added, Hosting removed.
+- [ ] `Program.cs` rewritten with mode routing: `run`/`chat`/headless use `WebApplication`, `init`/`doctor` use `Host`.
+- [ ] Shared config services extracted to `ConfigureConfigServices()` method.
+- [ ] Daemon-only services extracted to `ConfigureDaemonServices()` method.
+- [ ] SignalR hub mapped at `/hub/session` (stub — Phase 1 placeholder).
+- [ ] Health probe mapped at `/api/health/ready`.
+- [ ] `ConsoleChannel.cs` deleted (replaced by Termina TUI).
- [ ] `dotnet build` passes with new dependencies.
### Task 1.12: TUI chat adapter (`netclaw chat`)
**PRD:** `docs/prd/PRD-004-cli-onboarding-and-config.md` (CLI-011), `docs/prd/PRD-009-input-adapters-and-unified-input.md` (INPUT-005)
+**Spec:** `docs/spec/SPEC-011-daemon-architecture.md`
**OpenSpec:** `openspec/specs/netclaw-input-adapters/spec.md`, `openspec/specs/netclaw-cli/spec.md`
**OpenSpec Changes:** `openspec/changes/add-tui-adapter-and-config-hot-reload/` (Section 2)
**Surface area:** TUI + adapter
@@ -393,14 +401,15 @@ Done when:
**Wireframe:** `docs/ui/TUI-001-command-wireframes.md` (netclaw chat)
**Previously:** Task 1.14
+The TUI uses `SessionPipeline` directly — no SignalR indirection. Same
+in-process pattern as the current ConsoleChannel/HeadlessChannel.
+
Done when:
-- [ ] `TuiInputAdapter` implementing adapter contract (`SendUserMessage` with entity key `tui/{sessionId}`).
-- [ ] `ChatCommand.cs` hosts actor system in-process and launches TUI.
- [ ] `ChatPage.cs` with `StreamingTextNode` (scrollable history) and `TextInputNode` (multi-line input).
-- [ ] `ChatViewModel.cs` with session lifecycle and broadcast subscription.
+- [ ] `ChatViewModel.cs` uses `SessionPipeline` directly via DI — calls `CreateAsync()`, pushes `ChannelInput`, subscribes to `SessionOutput`.
- [ ] Inline tool activity panel (completed with duration, in-progress with spinner).
- [ ] MCP status indicator in status bar (green/yellow/red).
-- [ ] E2E: user types → `SendUserMessage` → session actor → LLM → streaming response in TUI.
+- [ ] E2E: user types → `ChannelInput` → `SessionPipeline` → session actor → LLM → streaming response in TUI.
---
@@ -703,3 +712,6 @@ the linked research documents.
- `docs/research/actor-llm-optimization-patterns.md` — Prompt caching,
safety circuit breakers, parallel execution, streaming, retry, and
sub-agent isolation patterns for actor-based LLM systems
+- `docs/research/agent-gateway-architecture.md` — Single-process vs
+ multi-process architecture analysis (OpenClaw, IronClaw, ZeroClaw).
+ Validates single-process model for homelab/personal agent use.
diff --git a/docs/prd/PRD-001-netclaw-mvp.md b/docs/prd/PRD-001-netclaw-mvp.md
index 2296ee666..e9a2391a3 100644
--- a/docs/prd/PRD-001-netclaw-mvp.md
+++ b/docs/prd/PRD-001-netclaw-mvp.md
@@ -55,9 +55,37 @@ irrelevant — the differentiator is the instructions attached to the context.
10. Agent can modify its own configuration through conversation.
11. MCP integration provides external memory (Memorizer) and tool capabilities.
+## Daemon Architecture
+
+Netclaw runs as a single OS process. All components — Akka actor system,
+persistence, gateway endpoints, TUI, and tool execution — share one process
+boundary. This is validated by architecture research across comparable projects
+(see `docs/research/agent-gateway-architecture.md`).
+
+The executable supports multiple modes selected by command-line arguments:
+
+| Mode | Command | Description |
+|------|---------|-------------|
+| Daemon | `netclaw run` | Full service stack. Slack, schedules, SignalR hub, health endpoint. |
+| Chat | `netclaw chat` | Full service stack + Termina TUI. Interactive chat via `SessionPipeline`. |
+| Headless | `netclaw -p "..."` | Full service stack + headless client. Single turn, exits on completion. |
+| Init | `netclaw init` | Lightweight. Config services only, no Akka/persistence. Reentrant TUI wizard. |
+| Doctor | `netclaw doctor` | Lightweight. Config services only. Health checks and diagnostics. |
+
+In-process channels (TUI, headless) use `SessionPipeline` directly — no network
+hop. The SignalR hub exists for future remote clients (Blazor ops console) and
+uses the same `SessionPipeline` internally.
+
+Tools execute on the host process. This is the only model that works for Slack
+(no client to delegate to), scheduled tasks (autonomous), and Docker deployment
+(tools need access to the host's Docker socket).
+
+See `SPEC-011-daemon-architecture.md` for full specification.
+
## Non-Goals (MVP)
-- Multi-process gateway/agent split
+- Multi-process gateway/agent split (validated by architecture research —
+ single-process is the correct model for homelab/personal agent use)
- Ambient channel monitoring with per-channel instructions
- Webhook ingress (Tailscale Serve / Cloudflare Tunnel)
- Sub-agent model routing (cheaper models for high-token tasks)
@@ -269,3 +297,5 @@ schedule changes → timer reconfiguration).
- Agent Personality and Memory: PRD-007
- Scheduling: PRD-008
- Input Adapters: PRD-009 (post-MVP)
+- Daemon Architecture: SPEC-011
+- Architecture Research: `docs/research/agent-gateway-architecture.md`
diff --git a/docs/prd/PRD-004-cli-onboarding-and-config.md b/docs/prd/PRD-004-cli-onboarding-and-config.md
index a7575c57d..626f7f6ed 100644
--- a/docs/prd/PRD-004-cli-onboarding-and-config.md
+++ b/docs/prd/PRD-004-cli-onboarding-and-config.md
@@ -23,19 +23,34 @@ diagnostics using CLI commands and guided output.
## CLI Framework
-- **Cocona** for command routing (lightweight, convention-based, DI integration)
+- **Simple arg routing** in `Program.cs` for mode selection (Cocona is archived
+ as of Dec 2025 — replaced with direct `args[0]` routing)
- **Termina 0.5.1** for interactive TUI commands (`netclaw init`, `netclaw chat`)
-- All other commands use plain console output via Cocona
+- All other commands use plain console output
- `netclaw run` is the explicit daemon entry point (Slack + timers + health
endpoints, no TUI)
+- All CLI modes are in-process — no REST client in Phase 1
+- Configuration is privileged local file I/O, never exposed over the wire
+ (contains API keys/secrets)
## Two-Phase Onboarding
### Phase 1: CLI Wizard (`netclaw init`)
-Technical setup, no LLM required:
+Technical setup, no LLM required. `netclaw init` runs as a **lightweight mode**
+— no Akka actor system, no persistence, no SignalR. Only config services are
+booted. Provider testing uses direct DI service calls (`ChatClientFactory`),
+not REST endpoints.
-1. LLM provider configuration (OpenRouter API key, model selection)
+The wizard is **reentrant** — re-running `netclaw init` detects existing config
+and shows a section dashboard with status per section. Each section is
+independently enterable for modification. First-run guides linearly through
+all steps.
+
+Steps:
+
+1. LLM provider configuration (endpoint URL, API key, model selection,
+ connectivity test via direct HTTP to provider)
2. Slack app setup (bot token, app token for Socket Mode)
3. PostgreSQL connection string
4. ACL bootstrap (owner identity, initial channel rules)
@@ -175,7 +190,7 @@ Results are persisted to the environment inventory file.
`netclaw init` and `netclaw chat` SHALL use Termina 0.5.1 for interactive TUI
rendering. All other commands SHALL use plain console output. TUI commands SHALL
-launch Termina as a hosted service within the Cocona command handler.
+launch Termina as a hosted service within the mode-selected host builder.
### CLI-011 Local Chat Adapter
@@ -219,3 +234,5 @@ rendering. This is the primary production entry point.
- MCP setup: PRD-006
- Memory and personality: PRD-007
- Scheduling: PRD-008
+- Daemon architecture: SPEC-011
+- TUI wireframes: TUI-001
diff --git a/docs/prd/PRD-009-input-adapters-and-unified-input.md b/docs/prd/PRD-009-input-adapters-and-unified-input.md
index 388095dff..22661aa3a 100644
--- a/docs/prd/PRD-009-input-adapters-and-unified-input.md
+++ b/docs/prd/PRD-009-input-adapters-and-unified-input.md
@@ -37,10 +37,11 @@ child session actor.
### MVP Input Adapters
**Local TUI Adapter** (Phase 1):
-- Hosted in-process by `netclaw chat` command
+- Hosted in-process by `netclaw chat` command (daemon mode — full service stack)
+- Uses `SessionPipeline` directly — no SignalR indirection for in-process channels
- Receives keyboard input via Termina TextInputNode
-- Produces `SendUserMessage` commands with entity key `tui/{sessionId}`
-- Subscribes to session broadcasts for reply delivery
+- Pushes `ChannelInput` to `SessionPipeline` input sink
+- Subscribes to `SessionOutput` source for rendering
- Renders responses as streaming text via StreamingTextNode
- Displays tool invocation status inline (name, duration, spinner)
- Shows MCP server connectivity in status bar
@@ -75,9 +76,12 @@ child session actor.
- Each webhook hit creates a new session with source-specific instructions
**Web UI Adapter** (Phase 5):
-- WebSocket connection from Blazor Server ops console
+- Connects via SignalR hub (`/hub/session`) — the gateway surface documented in
+ SPEC-011
- Provides interactive session control and real-time updates
-- Same pub/sub broadcast pattern as Slack adapter
+- Same `SessionPipeline` abstraction as all other channels
+- SignalR hub is mapped from Phase 1 (for future remote clients) but not used
+ by TUI or headless modes
## Adapter Contract
@@ -174,3 +178,5 @@ SHALL display tool invocation status inline between user message and response.
- Security (ACL per source): PRD-002
- Scheduling (timer source): PRD-008
- Ops console (web UI source): PRD-003
+- Daemon architecture and gateway surface: SPEC-011
+- SessionPipeline and channel abstraction: `src/Netclaw.Actors/Channels/ChannelPipeline.cs`
diff --git a/docs/research/credential-storage-patterns.md b/docs/research/credential-storage-patterns.md
new file mode 100644
index 000000000..b2371efeb
--- /dev/null
+++ b/docs/research/credential-storage-patterns.md
@@ -0,0 +1,885 @@
+# Credential Storage Patterns in Self-Hosted AI Agents and Assistant Frameworks
+
+Research date: 2026-02-23
+
+## Executive Summary
+
+This document surveys how real-world self-hosted AI agents, assistant frameworks,
+and homelab platforms store and manage credentials (API keys, OAuth tokens,
+integration secrets). The findings reveal a spectrum from plaintext-with-permissions
+to encrypted-at-rest-with-vault-integration, with most projects landing somewhere
+in the middle.
+
+**Key patterns observed:**
+
+1. **Plaintext JSON with filesystem permissions** -- the most common pattern for
+ dev-oriented CLI tools (dotnet user-secrets, OpenCode, Home Assistant secrets.yaml)
+2. **OS keychain integration** -- used by Electron apps and polished CLI tools
+ (Claude Code on macOS, VS Code, Cursor, Git Credential Manager)
+3. **Application-level AES encryption with a user-provided key** -- used by
+ multi-user web apps (n8n, LibreChat, Activepieces)
+4. **Environment variable injection into subprocesses** -- universal pattern for
+ MCP servers, Docker containers, workflow tools
+5. **External vault delegation** -- emerging pattern for enterprise deployments
+ (n8n External Secrets, Rasa + Vault, Docker MCP Gateway)
+
+---
+
+## 1. Claude Code
+
+**Type:** CLI tool (Node.js/TypeScript)
+
+### Where credentials are stored
+
+| Platform | Storage Location | Encryption |
+|----------|-----------------|------------|
+| macOS | macOS Keychain (service: `Claude Code-credentials`) | OS-managed keychain encryption |
+| Linux | `~/.claude/.credentials.json` (mode 0600) | **None** -- plaintext JSON |
+| Windows | `~/.claude/.credentials.json` (mode 0600) | **None** -- plaintext JSON |
+
+### Credential format (Linux/Windows `.credentials.json`)
+
+```json
+{
+ "claudeAiOauth": {
+ "accessToken": "sk-ant-oat01-...",
+ "refreshToken": "sk-ant-ort01-...",
+ "expiresAt": 1748658860401,
+ "scopes": ["user:inference", "user:profile"]
+ }
+}
+```
+
+### How credentials are provided
+
+- **OAuth flow**: `claude` CLI launches browser-based OAuth, receives tokens,
+ stores them in keychain (macOS) or `.credentials.json` (Linux/Windows)
+- **API key**: Set `ANTHROPIC_API_KEY` environment variable
+- **API key helper**: Configure `apiKeyHelper` in settings to run a shell script
+ that returns an API key. Called on startup, refreshed every 5 minutes or on
+ HTTP 401. TTL configurable via `CLAUDE_CODE_API_KEY_HELPER_TTL_MS`.
+
+### MCP server credential passing
+
+MCP servers are subprocesses. Credentials are passed via the `env` block in
+`.mcp.json` configuration:
+
+```json
+{
+ "mcpServers": {
+ "postgres": {
+ "command": "npx",
+ "args": ["-y", "@modelcontextprotocol/server-postgres", "..."],
+ "env": {
+ "PGPASSWORD": "the-actual-password"
+ }
+ }
+ }
+}
+```
+
+**Known limitation:** Environment variable substitution (`${VAR}`) is NOT
+supported in `.mcp.json` files. The `env` block contains literal values. This
+means committing `.mcp.json` to source control with credentials is unsafe. The
+recommended workaround is to use a wrapper script or keep credentials in
+non-committed config.
+
+For stdio transport MCP servers, process isolation is the security boundary --
+only the process that launches the server can communicate with it via stdin/stdout.
+
+For HTTP/SSE transport MCP servers, OAuth is supported. The `MCP_CLIENT_SECRET`
+env var can skip interactive OAuth prompts. OAuth tokens are stored in the macOS
+keychain or a credentials file.
+
+### References
+
+- [Claude Code Authentication Docs](https://code.claude.com/docs/en/authentication)
+- [Managing API key environment variables](https://support.claude.com/en/articles/12304248-managing-api-key-environment-variables-in-claude-code)
+- [MCP server env var security issue #2065](https://github.com/anthropics/claude-code/issues/2065)
+- [Credential file bug #10039](https://github.com/anthropics/claude-code/issues/10039)
+- [Keychain issue #9403](https://github.com/anthropics/claude-code/issues/9403)
+
+---
+
+## 2. OpenCode
+
+**Type:** CLI tool (Go, now TypeScript/Bun)
+
+### Where credentials are stored
+
+- `~/.local/share/opencode/auth.json` -- plaintext JSON, mode 0600
+- `~/.local/share/opencode/mcp-auth.json` -- OAuth tokens for MCP servers
+- Environment variables auto-detected (e.g., `OPENAI_API_KEY`)
+- `.env` file in project root
+
+### Encryption at rest
+
+**None.** The `auth.json` file is plaintext. There is an open feature request
+([#4318](https://github.com/sst/opencode/issues/4318)) to add system keyring
+support using `@napi-rs/keyring` or `Bun.secrets` for cross-platform keychain
+access (GNOME Keyring, macOS Keychain, Windows Credential Manager).
+
+### How credentials are provided
+
+- `opencode auth login` -- interactive CLI flow, stores to `auth.json`
+- Environment variables -- auto-detected per provider convention
+- `.env` file -- loaded from project root
+
+### Secret reference pattern
+
+OpenCode does not currently support referencing external secret stores. The
+proposed keyring integration would store credentials with service name `opencode`
+and account name as the provider ID.
+
+### References
+
+- [OpenCode Providers Docs](https://opencode.ai/docs/providers/)
+- [OpenCode CLI Docs](https://opencode.ai/docs/cli/)
+- [Keyring feature request #4318](https://github.com/sst/opencode/issues/4318)
+
+---
+
+## 3. Cursor / Windsurf / AI IDEs (Electron-based)
+
+**Type:** Desktop IDE (Electron/VS Code fork)
+
+### Where credentials are stored
+
+All Electron-based IDEs (Cursor, Windsurf, VS Code) use the same underlying
+mechanism inherited from VS Code:
+
+| Platform | Backend | Location |
+|----------|---------|----------|
+| macOS | macOS Keychain | Keychain Access, service: ` Safe Storage` |
+| Windows | DPAPI | `%APPDATA%\\` encrypted SQLite |
+| Linux | libsecret (GNOME Keyring) / kwallet | `~/.config//` SQLite (encrypted if keyring available) |
+
+The actual storage is an **encrypted SQLite database** in the user data directory.
+Electron's `safeStorage` API encrypts values before writing them to SQLite.
+
+### Encryption mechanism
+
+Electron's `safeStorage` is a thin wrapper (~100 lines C++) around Chromium's
+`OSCrypt` package:
+- Uses **AES-128-CBC** for encryption
+- Keys are derived from the OS credential store (Keychain/DPAPI/libsecret)
+- **Linux fallback**: If no secret store is available, uses a hardcoded plaintext
+ password -- effectively no encryption
+
+### How credentials are provided
+
+- **Built-in auth**: Cursor/Windsurf use account-based auth, synced to their
+ cloud service. API keys are sent to their backend with each request.
+- **BYOK (Bring Your Own Key)**: Users paste API keys into settings UI. Keys
+ are stored via `safeStorage` in the encrypted SQLite database.
+- **MCP config**: `~/.codeium/windsurf/mcp_config.json` for Windsurf MCP
+ servers, with `env` blocks for API keys (same pattern as Claude Code)
+
+### VS Code SecretStorage API (for extension developers)
+
+```typescript
+// Extensions use context.secrets
+const token = await context.secrets.get('myExtension.apiKey');
+await context.secrets.store('myExtension.apiKey', 'sk-...');
+```
+
+This API was previously backed by `keytar` (now archived), migrated to
+Electron's `safeStorage` in VS Code 1.80 (June 2023).
+
+### References
+
+- [Cursor API Keys Docs](https://cursor.com/docs/settings/api-keys)
+- [Windsurf Provider API Keys](https://windsurf.com/subscription/provider-api-keys)
+- [Electron safeStorage Docs](https://www.electronjs.org/docs/latest/api/safe-storage)
+- [VS Code keytar migration #185677](https://github.com/microsoft/vscode/issues/185677)
+- [VS Code SecretStorage discussion #748](https://github.com/microsoft/vscode-discussions/discussions/748)
+
+---
+
+## 4. Open WebUI
+
+**Type:** Self-hosted web app (Python/Svelte)
+
+### Where credentials are stored
+
+- **Database**: SQLite (default), PostgreSQL, or cloud storage backends
+- **Environment variables**: Provider API keys (`OPENAI_API_KEY`, etc.)
+- **OAuth client credentials**: Encrypted in database with `OAUTH_CLIENT_INFO_ENCRYPTION_KEY`
+
+### Encryption at rest
+
+- OAuth client credentials are encrypted using **Fernet symmetric encryption**
+ (AES-128-CBC with HMAC-SHA256) with the `OAUTH_CLIENT_INFO_ENCRYPTION_KEY`
+- API keys for providers are stored in the database but documentation is unclear
+ on whether they are encrypted at rest
+- SQLite itself can optionally be encrypted
+
+### How credentials are provided
+
+- **Web UI**: Admin settings page for configuring provider connections
+- **Environment variables**: `OPENAI_API_KEY`, `OLLAMA_BASE_URL`, etc.
+- **Per-user API keys**: JWT-based, disabled by default, must be explicitly enabled
+
+### Special handling
+
+- Connection management UI to enable/disable individual OpenAI and Ollama
+ connections
+- API key functionality is disabled by default for security
+- Supports S3, GCS, Azure Blob for storage backends
+
+### References
+
+- [Open WebUI Features](https://docs.openwebui.com/features/)
+- [Open WebUI API Keys & Monitoring](https://docs.openwebui.com/reference/monitoring/)
+- [Open WebUI Auth & Security (DeepWiki)](https://deepwiki.com/open-webui/open-webui/11-authentication-and-access-control)
+
+---
+
+## 5. AnythingLLM
+
+**Type:** Self-hosted desktop + Docker app (Node.js/React)
+
+### Where credentials are stored
+
+- **SQLite database**: `anythingllm.db` in the storage directory
+- **`.env` file**: `server/.env` for environment-level configuration
+- **Desktop app**: Local settings in OS-specific app data directory
+
+### Encryption at rest
+
+**Credentials are NOT encrypted at rest.** API keys entered through the web UI
+are stored in the SQLite database in plaintext or in the `.env` file. The
+documentation advises treating the host storage as sensitive.
+
+### How credentials are provided
+
+- **Web UI**: Provider configuration pages for each LLM provider (15+ supported)
+- **Environment variables**: `.env` file with provider-specific keys
+ (`OPEN_AI_KEY`, `ANTHROPIC_API_KEY`, etc.)
+- **Docker**: Mount `.env` to `/app/server/.env` and storage to
+ `/app/server/storage`
+
+### Storage structure
+
+```
+storage/
+ anythingllm.db # SQLite database
+ vector-cache/ # Embedded file cache
+ models/ # Locally stored LLMs
+ plugins/ # Custom agent skills
+ direct-uploads/ # User-uploaded files
+```
+
+### References
+
+- [AnythingLLM Configuration](https://docs.anythingllm.com/configuration)
+- [AnythingLLM Desktop Storage](https://docs.anythingllm.com/installation-desktop/storage)
+- [AnythingLLM Docker Installation](https://docs.anythingllm.com/installation-docker/local-docker)
+
+---
+
+## 6. LibreChat
+
+**Type:** Self-hosted web app (Node.js/React, MongoDB backend)
+
+### Where credentials are stored
+
+- **MongoDB**: User API keys are encrypted and stored in the database
+- **`.env` file**: Server-side provider keys, encryption keys, JWT secrets
+
+### Encryption at rest
+
+**Yes -- AES encryption with application-managed keys.** This is one of the more
+sophisticated implementations in this survey.
+
+**Required environment variables:**
+```bash
+CREDS_KEY= # openssl rand -base64 32 (AES encryption key)
+CREDS_IV= # openssl rand -base64 16 (AES initialization vector)
+JWT_SECRET= # For session tokens
+JWT_REFRESH_SECRET= # For refresh tokens
+```
+
+The application **will crash on startup** if `CREDS_KEY` and `CREDS_IV` are not
+set. Encryption is implemented in `api/server/services/Config/encrypt.js`.
+
+### How credentials are provided
+
+- **Admin `.env`**: Server-wide provider API keys
+- **User-provided keys**: Users can enter their own API keys via the UI; these
+ are encrypted with `CREDS_KEY`/`CREDS_IV` before storage in MongoDB
+- **Config file reference pattern**: `librechat.yaml` supports `${VARIABLE_NAME}`
+ syntax to reference environment variables from `.env`
+- **MCP server user vars**: `customUserVars` allows per-user credentials for MCP
+ servers using `{{VARIABLE_NAME}}` syntax
+
+### Secret reference pattern
+
+```yaml
+# librechat.yaml
+endpoints:
+ custom:
+ - name: "My Provider"
+ apiKey: "${MY_PROVIDER_KEY}" # References .env variable
+```
+
+### Credentials generator tool
+
+LibreChat provides a [web-based credentials generator](https://www.librechat.ai/toolkit/creds_generator)
+to generate `CREDS_KEY`, `CREDS_IV`, `JWT_SECRET`, and `JWT_REFRESH_SECRET`.
+
+### References
+
+- [LibreChat Environment Variables](https://www.librechat.ai/docs/configuration/dotenv)
+- [LibreChat Credentials Generator](https://www.librechat.ai/toolkit/creds_generator)
+- [LibreChat Config Structure](https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/config)
+- [Manual Encryption Discussion #6678](https://github.com/danny-avila/LibreChat/discussions/6678)
+
+---
+
+## 7. Home Assistant
+
+**Type:** Self-hosted homelab platform (Python)
+
+### Where credentials are stored
+
+Home Assistant has **three distinct credential storage mechanisms**:
+
+| Type | Location | Format | Encrypted? |
+|------|----------|--------|------------|
+| User passwords | `.storage/auth_provider.homeassistant` | JSON | **Hashed + salted** (bcrypt) |
+| OAuth2 app credentials | `.storage/application_credentials` | JSON | **No** (plaintext in JSON) |
+| Integration config entries | `.storage/core.config_entries` | JSON | **No** (plaintext tokens/keys) |
+| User-defined secrets | `secrets.yaml` | YAML | **No** (plaintext) |
+| OAuth2 refresh tokens | `.storage/auth` | JSON | **No** (plaintext) |
+
+### The `secrets.yaml` pattern
+
+```yaml
+# secrets.yaml (NOT encrypted, just separated from config)
+slack_token: "xoxb-your-token-here"
+github_pat: "ghp_xxxxxxxxxxxx"
+
+# configuration.yaml
+slack:
+ token: !secret slack_token
+```
+
+**The `!secret` directive provides separation, not security.** The file is
+plaintext. Its purpose is to allow sharing `configuration.yaml` publicly (e.g.,
+GitHub) while keeping secrets in a `.gitignore`d file.
+
+### OAuth2 integration flow
+
+- Modern integrations use the **Application Credentials** component
+- Users register OAuth client ID/secret via the web UI
+- Home Assistant handles the OAuth2 authorization code flow
+- Refresh tokens are stored in `.storage/auth` as plaintext JSON
+- Token refresh happens automatically in the background
+
+### Why no encryption at rest?
+
+Home Assistant's security model assumes the host filesystem is the trust boundary.
+If an attacker has filesystem access, they have access to everything. Encryption
+at rest would require a key management solution that doesn't align with the
+"run on a Raspberry Pi" deployment model.
+
+### References
+
+- [Home Assistant Storing Secrets](https://www.home-assistant.io/docs/configuration/secrets)
+- [Home Assistant Application Credentials](https://developers.home-assistant.io/docs/core/platform/application_credentials/)
+- [Home Assistant Authentication](https://developers.home-assistant.io/docs/auth_index/)
+- [Community: Where are credentials stored?](https://community.home-assistant.io/t/where-are-config-values-credentials-stored/588068)
+
+---
+
+## 8. n8n
+
+**Type:** Self-hosted workflow automation (Node.js/TypeScript)
+
+### Where credentials are stored
+
+- **Database**: SQLite (default) or PostgreSQL
+- All credential data is **encrypted before writing to the database**
+
+### Encryption at rest
+
+**Yes -- AES-256 encryption.** This is the strongest built-in encryption in the
+survey.
+
+**How it works:**
+1. On first launch, n8n generates a random encryption key and saves it to
+ `~/.n8n/` (or uses `N8N_ENCRYPTION_KEY` environment variable)
+2. Every credential is encrypted with AES-256 before database insertion
+3. Credentials are decrypted only at workflow execution time
+
+```bash
+# Set custom encryption key
+export N8N_ENCRYPTION_KEY="your-32-char-hex-key"
+```
+
+**Security model:** If an attacker has only database access, decryption is hard.
+If they have full server access (including the encryption key file), credentials
+are compromised.
+
+### External secrets (vault integration)
+
+n8n supports **runtime secret resolution** from external vaults:
+
+| Provider | Reference Syntax |
+|----------|-----------------|
+| HashiCorp Vault | `={{ $secrets.vault.secretName }}` |
+| AWS Secrets Manager | `={{ $secrets.awsSecretsManager.secretName }}` |
+| Infisical | `={{ $secrets.infisical.secretName }}` |
+| Azure Key Vault | `={{ $secrets.azureKeyVault.secretName }}` |
+| GCP Secret Manager | (supported) |
+
+Secrets are fetched at runtime, not stored in n8n's database. Secret names must
+be alphanumeric with underscores only (no hyphens or spaces).
+
+### How credentials are provided
+
+- **Web UI**: Credential creation forms with type-specific fields
+- **OAuth flows**: Built-in OAuth2 redirect handling for supported services
+- **Environment variables**: `N8N_ENCRYPTION_KEY`, database connection, etc.
+
+### Cloud vs self-hosted
+
+n8n Cloud adds Azure server-side encryption (AES-256, FIPS-140-2 compliant) on
+top of the application-level encryption.
+
+### References
+
+- [n8n Security](https://n8n.io/legal/security/)
+- [n8n Custom Encryption Key](https://docs.n8n.io/hosting/configuration/configuration-examples/encryption-key/)
+- [n8n External Secrets](https://docs.n8n.io/external-secrets/)
+- [Community: How are credentials stored?](https://community.n8n.io/t/how-are-credentials-stored/40166)
+
+---
+
+## 9. Activepieces
+
+**Type:** Self-hosted workflow automation (TypeScript)
+
+### Where credentials are stored
+
+- **Database**: PostgreSQL (primary) or SQLite
+- Credentials encrypted before storage
+
+### Encryption at rest
+
+**Yes -- AES-256 encryption** with a user-configured key.
+
+```bash
+# .env
+AP_ENCRYPTION_KEY= # 256-bit / 32 hex character encryption key
+```
+
+### Authentication types supported
+
+- **SecretText**: Masked input for API keys and passwords
+- **OAuth2**: Full OAuth2 flow with auth URL, token URL, scope
+- **BasicAuth**: Username + password
+- **CustomAuth**: Arbitrary properties (base URL, access token, etc.)
+
+### How credentials are provided
+
+- **Web UI**: Type-specific credential forms
+- **OAuth2**: Built-in redirect flow
+- **Predefined connections**: Admin can pre-configure connections for embedding
+ scenarios
+
+### References
+
+- [Activepieces Authentication](https://www.activepieces.com/docs/developers/piece-reference/authentication)
+- [Activepieces Predefined Connection](https://www.activepieces.com/docs/embedding/predefined-connection)
+- [Activepieces .env.example](https://github.com/activepieces/activepieces/blob/main/.env.example)
+
+---
+
+## 10. Botpress
+
+**Type:** Cloud-first chatbot platform (TypeScript)
+
+### Where credentials are stored
+
+- **Cloud**: Botpress-managed secure storage
+- **Integration definitions**: Secrets declared in `integration.definition.ts`
+
+### How secrets work
+
+Secrets are defined declaratively in the integration schema:
+
+```typescript
+export default new IntegrationDefinition({
+ name: 'my-integration',
+ secrets: {
+ CLIENT_ID: { description: 'OAuth Client ID' },
+ CLIENT_SECRET: { description: 'OAuth Client Secret' },
+ SIGNING_SECRET: { description: 'Webhook Signing Secret' },
+ },
+})
+```
+
+Secrets are accessed at runtime via `ctx.secrets`:
+
+```typescript
+const handler = async ({ ctx }) => {
+ const apiKey = ctx.secrets.CLIENT_SECRET;
+ // ...
+}
+```
+
+### How credentials are provided
+
+- **Botpress Studio UI**: Users enter secrets when installing an integration
+- **OAuth**: Automatic OAuth flow for supported channels (Slack, etc.)
+- Secrets are never exposed in plaintext in the UI after initial entry
+
+### Rasa (comparison)
+
+Rasa takes a very different approach:
+
+- **`credentials.yml`**: Plaintext YAML file for channel integrations (Slack
+ tokens, Facebook secrets, etc.)
+- **Environment variables**: Recommended for sensitive values
+- **HashiCorp Vault integration**: Enterprise feature via `endpoints.yml`
+ - Credentials stored in Vault, encrypted at rest
+ - Transit Engine for additional encryption layer
+ - Token auto-renewal (15s before expiry)
+ - Namespace isolation support
+
+```yaml
+# Rasa endpoints.yml
+secrets_manager:
+ type: "vault"
+ url: "https://vault.example.com"
+ secrets_path: "rasa-secrets"
+ token: "${VAULT_TOKEN}"
+```
+
+### References
+
+- [Botpress Secrets Docs](https://botpress.com/docs/integration/concepts/secrets/)
+- [Botpress Slack Integration](https://botpress.com/docs/cloud/channels/slack)
+- [Rasa Secrets Managers](https://rasa.com/docs/rasa/secrets-managers)
+- [Rasa Vault Integration](https://rasa.com/docs/reference/integrations/secrets-managers/)
+
+---
+
+## 11. MCP Server Credential Patterns
+
+MCP (Model Context Protocol) servers use a consistent credential pattern across
+all client implementations.
+
+### Standard pattern: env block injection
+
+```json
+{
+ "mcpServers": {
+ "my-server": {
+ "command": "npx",
+ "args": ["-y", "my-mcp-server"],
+ "env": {
+ "API_KEY": "sk-actual-key-value",
+ "DATABASE_URL": "postgres://..."
+ }
+ }
+ }
+}
+```
+
+**Key properties:**
+- Environment variables are injected into the subprocess at spawn time
+- Each MCP server gets its own isolated env (servers cannot see each other's keys)
+- The config file itself is the secret store -- **no env var substitution**
+- Process isolation (stdin/stdout) is the security boundary for stdio transport
+
+### Docker MCP Gateway (newer pattern)
+
+Docker's MCP Gateway introduces a more sophisticated model:
+
+- **Secret store**: `docker mcp secret` command manages encrypted secrets
+- **OAuth flow**: `docker mcp oauth` handles OAuth token acquisition
+- **Runtime scanning**: Gateway scans payloads for leaked secrets
+- Secrets managed through Docker Desktop's credential store
+- OAuth tokens managed automatically, no plaintext in env vars
+
+### Workarounds for the "no substitution" problem
+
+1. **Wrapper script**: Shell script that loads `.env` and exec's the MCP server
+2. **`apiKeyHelper`** (Claude Code): Shell command that returns a key
+3. **Non-committed config**: Keep `.mcp.json` in `.gitignore`, use a template
+4. **Docker secret mounting**: Mount secrets as files in the container
+
+### References
+
+- [MCP Configuration is a sh*tshow (Medium)](https://0xhagen.medium.com/mcp-configuration-is-a-sh-tshow-but-heres-how-i-fixed-secrets-handling-5395010762a1)
+- [Docker MCP Gateway](https://docs.docker.com/ai/mcp-catalog-and-toolkit/mcp-gateway/)
+- [Docker MCP Gateway secret bypass #317](https://github.com/docker/mcp-gateway/issues/317)
+- [MCP env var management](https://apxml.com/courses/getting-started-model-context-protocol/chapter-4-debugging-and-client-integration/managing-environment-variables)
+
+---
+
+## 12. OS Keychain / Credential Store APIs
+
+### Cross-platform comparison
+
+| Platform | API | Used By | Encryption |
+|----------|-----|---------|------------|
+| macOS | Keychain Services | Claude Code, Electron apps, GCM | AES-256-GCM, hardware-backed |
+| Windows | DPAPI / Credential Manager | Electron apps, GCM, VS Code | DPAPI (user/machine key) |
+| Linux | libsecret / Secret Service API | Electron apps (when available) | Depends on backend (GNOME Keyring, KWallet) |
+| Linux (fallback) | File with hardcoded key | Electron apps (no keyring) | **Effectively none** |
+
+### Electron `safeStorage` (used by Cursor, Windsurf, VS Code)
+
+```javascript
+// Encrypt
+const encrypted = safeStorage.encryptString('my-api-key');
+// Store encrypted buffer in SQLite or file
+
+// Decrypt
+const decrypted = safeStorage.decryptString(encrypted);
+```
+
+Technical details:
+- Thin wrapper (~100 LOC C++) around Chromium's `OSCrypt`
+- AES-128-CBC encryption
+- Key derived from OS credential store
+- **Linux caveat**: Without a running Secret Service, falls back to a hardcoded
+ password, providing no real security
+
+### Git Credential Manager (reference implementation)
+
+GCM is the gold standard for cross-platform credential storage in CLI tools:
+
+| Store | Platform | Mechanism |
+|-------|----------|-----------|
+| `wincredman` | Windows | Windows Credential Manager (DPAPI) |
+| `dpapi` | Windows | DPAPI-encrypted files in `%USERPROFILE%\.gcm\` |
+| `keychain` | macOS | macOS Keychain |
+| `secretservice` | Linux | libsecret/Secret Service API |
+| `gpg` | Linux | GPG-encrypted files |
+| `cache` | All | In-memory, no persistence |
+| `plaintext` | All | **Plaintext file** (last resort) |
+
+### References
+
+- [Electron safeStorage](https://www.electronjs.org/docs/latest/api/safe-storage)
+- [Git Credential Manager credential stores](https://github.com/git-ecosystem/git-credential-manager/blob/release/docs/credstores.md)
+- [VS Code Secret Storage discussion](https://github.com/microsoft/vscode-discussions/discussions/748)
+
+---
+
+## 13. .NET Ecosystem Patterns
+
+### `dotnet user-secrets` (development only)
+
+**Storage:**
+- Windows: `%APPDATA%\Microsoft\UserSecrets\\secrets.json`
+- macOS/Linux: `~/.microsoft/usersecrets//secrets.json`
+
+**Encryption: None.** Plaintext JSON. Explicitly documented as "not a trusted
+store" -- development only.
+
+```bash
+# Initialize
+dotnet user-secrets init
+
+# Set a secret
+dotnet user-secrets set "Slack:BotToken" "xoxb-..."
+
+# Access in code
+builder.Configuration.AddUserSecrets();
+var token = config["Slack:BotToken"];
+```
+
+### ASP.NET Core Data Protection API (production)
+
+The Data Protection API is the .NET ecosystem's answer to cross-platform
+credential encryption:
+
+**Default behavior (Windows):**
+- Keys stored in `%LOCALAPPDATA%\ASP.NET\DataProtection-Keys`
+- Encrypted at rest with DPAPI
+
+**Default behavior (Linux/macOS):**
+- Keys stored in user home directory
+- **NOT encrypted at rest** by default (no DPAPI equivalent)
+- Must explicitly configure encryption
+
+**Configuration options:**
+
+```csharp
+// File system with certificate encryption
+services.AddDataProtection()
+ .PersistKeysToFileSystem(new DirectoryInfo("/keys"))
+ .ProtectKeysWithCertificate(cert);
+
+// Database with Azure Key Vault
+services.AddDataProtection()
+ .PersistKeysToDbContext()
+ .ProtectKeysWithAzureKeyVault(keyUri, credential);
+
+// Redis
+services.AddDataProtection()
+ .PersistKeysToStackExchangeRedis(redis, "DataProtection-Keys");
+```
+
+**Key protection options:**
+- X.509 certificate (cross-platform)
+- Windows DPAPI (Windows only)
+- Windows DPAPI-NG with certificate rule (Windows Server 2012 R2+)
+- Azure Key Vault
+- Null protector (no encryption -- for testing)
+
+### Production patterns for self-hosted .NET apps
+
+1. **Azure Key Vault**: `Azure.Extensions.AspNetCore.Configuration.Secrets`
+ package. Secrets appear as regular `IConfiguration` keys.
+2. **Environment variables**: Standard `IConfiguration` provider, highest
+ priority in the default chain.
+3. **Docker secrets**: Mounted as files, read via file-based config provider.
+4. **Data Protection API**: For encrypting/decrypting arbitrary data at the
+ application level, with pluggable key storage and protection backends.
+
+### References
+
+- [ASP.NET Core App Secrets](https://learn.microsoft.com/en-us/aspnet/core/security/app-secrets)
+- [Data Protection Configuration](https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/configuration/overview)
+- [Key Storage Providers](https://learn.microsoft.com/en-us/aspnet/core/security/data-protection/implementation/key-storage-providers)
+- [Secrets Management in .NET (Auth0)](https://auth0.com/blog/secret-management-in-dotnet-applications/)
+
+---
+
+## Comparative Analysis
+
+### Encryption at rest
+
+| Project | Encrypted at rest? | Method | Key management |
+|---------|-------------------|--------|----------------|
+| Claude Code (macOS) | Yes | OS Keychain | OS-managed |
+| Claude Code (Linux) | **No** | Plaintext JSON | N/A |
+| OpenCode | **No** | Plaintext JSON | N/A |
+| Cursor/Windsurf | Yes* | Electron safeStorage (AES-128) | OS keychain |
+| Open WebUI | Partial | Fernet for OAuth | App env var |
+| AnythingLLM | **No** | Plaintext in SQLite/.env | N/A |
+| LibreChat | Yes | AES (custom key/IV) | User-provided env vars |
+| Home Assistant | **No** (secrets) / Hashed (passwords) | bcrypt for passwords | N/A |
+| n8n | Yes | AES-256 | Auto-generated or env var |
+| Activepieces | Yes | AES-256 | User-provided env var |
+| Botpress | Yes (cloud) | Platform-managed | Platform-managed |
+| Rasa + Vault | Yes | Vault Transit Engine | Vault-managed |
+
+*Cursor/Windsurf on Linux without a keyring daemon: effectively no encryption.
+
+### Credential provision methods
+
+| Method | Used By |
+|--------|---------|
+| Environment variables | All projects |
+| Web UI form | Open WebUI, AnythingLLM, LibreChat, n8n, Activepieces, Botpress, Home Assistant |
+| CLI interactive | Claude Code, OpenCode |
+| Config file | Home Assistant (secrets.yaml), Rasa (credentials.yml), MCP (.mcp.json) |
+| OAuth browser flow | Claude Code, n8n, Activepieces, Botpress, Home Assistant, Docker MCP |
+| OS keychain | Claude Code (macOS), Electron apps |
+| External vault | n8n, Rasa, Docker MCP Gateway |
+| Shell script helper | Claude Code (apiKeyHelper) |
+
+### Subprocess credential passing
+
+| Method | Used By | Security Model |
+|--------|---------|---------------|
+| Env var injection at spawn | MCP servers, Docker, n8n | Process isolation |
+| Stdin/stdout pipe | MCP stdio transport | Process isolation |
+| File mount | Docker secrets, Kubernetes | Filesystem permissions |
+| Runtime vault fetch | n8n external secrets, Rasa | Network + auth |
+| HTTP header injection | LibreChat MCP, Docker Gateway | TLS + auth |
+
+---
+
+## Patterns Relevant to Netclaw
+
+Given Netclaw is a self-hosted .NET homelab assistant with Slack integration:
+
+### Immediate patterns to consider
+
+1. **Home Assistant's `secrets.yaml` model**: Simple, well-understood by homelab
+ users. Plaintext but separated from config. Effective for single-user,
+ single-machine deployments where filesystem = trust boundary.
+
+2. **n8n's encryption model**: AES-256 with an auto-generated key stored on
+ first run. Good balance of security and usability. The key can be
+ user-provided via env var for advanced deployments.
+
+3. **ASP.NET Core Data Protection**: Native .NET, cross-platform, pluggable
+ backends. Could encrypt credentials at rest with DPAPI on Windows or
+ X.509 certificate on Linux. Already part of the framework.
+
+4. **`dotnet user-secrets` for development**: Keep Slack tokens and API keys
+ out of `appsettings.json` during development. Already standard .NET practice.
+
+5. **Environment variable injection for tools/MCP**: When Netclaw spawns tool
+ subprocesses, pass credentials via env vars (not command-line args, which
+ appear in `ps` output).
+
+### Architecture decision points
+
+- **Single-user homelab**: Home Assistant's model (plaintext secrets file,
+ filesystem trust boundary) is arguably sufficient and is the simplest to
+ implement and support.
+- **Multi-user or shared access**: n8n/LibreChat's model (AES encryption with
+ app-managed key) becomes necessary.
+- **Enterprise/corporate**: External vault integration (n8n's pattern) or
+ ASP.NET Core Data Protection with Azure Key Vault.
+
+### Implementation tiers
+
+**Tier 1 (MVP):**
+- `appsettings.json` + environment variable overrides for provider API keys
+- `dotnet user-secrets` for development
+- Slack OAuth tokens stored in Akka persistence (serialized state)
+- No encryption at rest beyond filesystem permissions
+
+**Tier 2 (hardened):**
+- ASP.NET Core Data Protection for encrypting stored credentials
+- Auto-generated encryption key on first run (n8n pattern)
+- `NETCLAW_ENCRYPTION_KEY` env var override for advanced deployments
+- Separate secrets from config (Home Assistant pattern)
+
+**Tier 3 (enterprise):**
+- Azure Key Vault / HashiCorp Vault integration via `IConfiguration`
+- External secret resolution at runtime (n8n external secrets pattern)
+- Per-user credential isolation
+
+---
+
+## Key Takeaways
+
+1. **Almost no one encrypts credentials at rest in single-user self-hosted
+ tools.** Claude Code, OpenCode, Home Assistant, AnythingLLM -- all store
+ plaintext. The security model is "if they have filesystem access, game over."
+
+2. **Multi-user web apps encrypt because they must.** n8n, LibreChat, and
+ Activepieces all encrypt because their database might be on a shared server
+ or backed up to cloud storage.
+
+3. **OS keychain is the gold standard for desktop/CLI tools** but has poor
+ Linux support (requires a running keyring daemon, which headless servers
+ don't have).
+
+4. **Environment variables are the universal credential transport** -- every
+ single project uses them for at least some credentials.
+
+5. **The "secret reference" pattern is rare** outside of workflow tools. Most
+ projects either embed credentials or use env vars. LibreChat's
+ `${VAR_NAME}` and n8n's `={{ $secrets.vault.name }}` are notable exceptions.
+
+6. **OAuth token lifecycle management is uniformly poor.** Most projects store
+ refresh tokens but handle rotation ad-hoc. Only Rasa (via Vault) and Docker
+ MCP Gateway have structured token lifecycle management.
+
+7. **The .NET ecosystem has strong primitives** (Data Protection API, user-secrets,
+ IConfiguration) that most of these projects would benefit from if they were
+ .NET-based. Netclaw can leverage these immediately.
diff --git a/docs/spec/README.md b/docs/spec/README.md
index e6c2f34ee..38798c51d 100644
--- a/docs/spec/README.md
+++ b/docs/spec/README.md
@@ -14,5 +14,6 @@ This directory contains implementation-facing specifications derived from PRDs.
- `SPEC-008-model-provider-abstraction.md` (PRD-005)
- `SPEC-009-mcp-integration.md` (PRD-006)
- `SPEC-010-testing-and-smoke-strategy.md` (PRD-001, PRD-005)
+- `SPEC-011-daemon-architecture.md` (PRD-001, PRD-002, PRD-004)
OpenSpec equivalents live in `openspec/specs/` and should remain aligned.
diff --git a/docs/spec/SPEC-011-daemon-architecture.md b/docs/spec/SPEC-011-daemon-architecture.md
new file mode 100644
index 000000000..fc8150cf6
--- /dev/null
+++ b/docs/spec/SPEC-011-daemon-architecture.md
@@ -0,0 +1,279 @@
+# SPEC-011: Daemon Architecture and Process Model
+
+Source PRDs: `PRD-001`, `PRD-002`, `PRD-004`
+
+## Purpose
+
+Define the single-process daemon model, mode selection, gateway surface,
+configuration hot-reload, and deployment model for Netclaw.
+
+This spec complements:
+- `SPEC-001` (runtime boundaries — logical separation within the process)
+- `SPEC-004` (CLI contract — command surface)
+- `SPEC-006` (gateway exposure — network access controls)
+- `SPEC-007` (guided onboarding — init wizard flow)
+
+Research basis: `docs/research/agent-gateway-architecture.md` — analysis of
+OpenClaw, IronClaw, and ZeroClaw validates single-process architecture for
+homelab/personal agent use.
+
+## Single-Process Model
+
+Netclaw runs as a single OS process. All components — Akka actor system,
+persistence, gateway endpoints, TUI, and tool execution — share one process
+boundary. This matches the consensus architecture from OpenClaw, IronClaw, and
+ZeroClaw.
+
+The CLI/TUI is "just another channel" that uses the same `SessionPipeline`
+abstraction as any other channel adapter (Slack, scheduled tasks, webhooks).
+
+### Logical Boundaries (within one process)
+
+```
+┌────────────────────────────────────────────────────────────┐
+│ Netclaw Process │
+│ │
+│ ┌──────────────────┐ ┌────────────────────────────────┐ │
+│ │ Presentation │ │ Gateway │ │
+│ │ │ │ │ │
+│ │ Termina TUI │ │ SignalR Hub (/hub/session) │ │
+│ │ Headless Client │ │ Health Probe (/api/health) │ │
+│ └────────┬─────────┘ └──────────────┬─────────────────┘ │
+│ │ │ │
+│ ▼ ▼ │
+│ ┌─────────────────────────────────────────────────────┐ │
+│ │ SessionPipeline │ │
+│ │ (Akka.Streams — typed Input/Output channels) │ │
+│ └────────────────────────┬────────────────────────────┘ │
+│ │ │
+│ ┌────────────────────────▼────────────────────────────┐ │
+│ │ Akka Actor System │ │
+│ │ SessionManager → LlmSessionActor (per session) │ │
+│ │ Persistence (journal + snapshots) │ │
+│ │ Tool Execution (shell, file, MCP) │ │
+│ └─────────────────────────────────────────────────────┘ │
+└────────────────────────────────────────────────────────────┘
+```
+
+In-process channels (TUI, headless) call `SessionPipeline.CreateAsync()`
+directly — no network hop. The SignalR hub uses the same `SessionPipeline`
+for remote clients.
+
+## Mode Selection
+
+The executable supports multiple modes selected by command-line arguments.
+Modes fall into two categories based on service requirements.
+
+### Daemon Modes (full service stack)
+
+These modes boot the complete service stack: Akka actor system, persistence,
+`SessionPipeline`, tool registry, SignalR hub, and health endpoint.
+
+| Command | Behavior |
+|---------|----------|
+| `netclaw run` | Daemon only. Hosts actors, gateway, Slack adapter, scheduler. Runs as a service. |
+| `netclaw chat` | Daemon + Termina TUI. Interactive chat via `SessionPipeline`. |
+| `netclaw -p "prompt"` | Daemon + headless client. Single turn via `SessionPipeline`, exits on `TurnCompleted`. |
+
+### Lightweight Modes (config services only)
+
+These modes boot a minimal host with configuration services only. No Akka
+actor system, no persistence, no SignalR, no tool execution.
+
+| Command | Behavior |
+|---------|----------|
+| `netclaw init` | Reentrant TUI wizard for provider/model/Slack/MCP configuration. |
+| `netclaw doctor` | Health checks against config files and provider connectivity. |
+
+### Service Registration Split
+
+Shared services (all modes):
+- `NetclawPaths` — directory layout
+- `IConfiguration` chain — netclaw.json + secrets.json + NETCLAW_* env vars
+- `ChatClientFactory` — creates `IChatClient` from provider config
+- `IChatClientProvider` — resolves clients by model role
+- `TimeProvider` — virtualized time
+
+Daemon-only services:
+- Akka actor system (with `WithNetclawActors()`)
+- Persistence (journal + snapshot store)
+- `SessionPipeline` — stream factory for channels
+- `ToolRegistry` + `IToolExecutor` — tool execution
+- `ISystemPromptProvider` — layered system prompt assembly
+- `ConfigWatcherService` — file system hot-reload
+- SignalR hub
+
+### Host Selection
+
+Lightweight modes use `Host.CreateApplicationBuilder()` (standard .NET host).
+Daemon modes use `WebApplication.CreateBuilder()` (ASP.NET host for SignalR
+and health endpoints).
+
+## Gateway Surface
+
+### Phase 1 (MVP)
+
+Minimal external surface. In-process channels are the primary interaction model.
+
+**SignalR Hub** (`/hub/session`):
+Mapped and documented for future remote clients (Blazor ops console, remote
+CLI). Not actively used by the TUI or headless modes in Phase 1.
+
+Contract:
+```
+Client → Server:
+ CreateSession(channelType: string) → sessionId: string
+ SendMessage(sessionId: string, text: string) → void
+
+Server → Client:
+ ReceiveOutput(output: SessionOutputDto) → void
+```
+
+`SessionOutputDto` is a wire-safe mapping of `SessionOutput` (the actor
+protocol type). The mapper handles discriminated union → flat DTO conversion.
+
+**Health Probe** (`GET /api/health/ready`):
+Returns `200 OK` when the host is accepting connections. Used for Docker
+health checks and external monitoring.
+
+### Future Phases
+
+REST endpoints for schedule CRUD, project management, tool listing, etc. will
+be added when remote clients (Blazor ops console) require them. The ASP.NET
+pipeline is already in place — no architectural debt.
+
+## Tool Execution Model
+
+Tools execute on the host process. The daemon runs shell commands, file
+operations, Docker commands, and MCP tool calls. This is the only model that
+works for:
+
+- **Slack channel**: No client process to delegate tool execution to.
+- **Scheduled tasks**: Execute autonomously without any connected client.
+- **Docker deployment**: Tools need access to the host's Docker socket.
+
+The TUI is a presentation layer — it renders tool call/result output but does
+not execute tools.
+
+## Configuration Hot-Reload
+
+### Trigger
+
+`FileSystemWatcher` on `~/.netclaw/config/` monitors for changes to
+`netclaw.json` and `secrets.json`.
+
+### Behavior
+
+1. File change event received
+2. 500ms debounce timer started (reset on additional events)
+3. After debounce: read and validate new configuration
+4. **Valid config**: rebuild `IChatClientProvider`, notify actor system
+5. **Invalid config**: log warning with validation errors, preserve previous config
+
+### What Reloads
+
+- Provider credentials and endpoints
+- Model selections (main, fallback, compaction)
+- Session parameters (compaction threshold, tool iteration limits)
+- Tool configuration (shell timeout, output limits)
+
+### What Does Not Reload (requires restart)
+
+- Akka actor system configuration
+- Persistence provider
+- Network binding / exposure mode
+
+### Sources of Config Changes
+
+- `netclaw init` TUI wizard (writes incrementally per section)
+- Manual file editing by operator
+- Agent self-configuration via `config_write` tool grant (SEC-008)
+
+All changes go to disk first. The `FileSystemWatcher` is the single reload
+trigger — there is no in-memory config mutation path.
+
+## Reentrant Init Wizard
+
+`netclaw init` is designed for both first-run and reconfiguration.
+
+### First Run
+
+Linear guided flow through all sections (per SPEC-007). Config written
+incrementally as each section completes.
+
+### Subsequent Runs
+
+Dashboard view showing current configuration state per section. Each section
+shows status (configured/unconfigured/error). Operator can jump to any section
+to modify or add entries.
+
+### Sections
+
+| Section | Purpose |
+|---------|---------|
+| Providers | Add/modify LLM provider endpoints and credentials |
+| Models | Assign provider + model to each role (main, fallback, compaction) |
+| Slack | Bot token, app token, Socket Mode configuration |
+| Persistence | PostgreSQL connection string (future, in-memory for MVP) |
+| MCP | Add/modify MCP server connections |
+| Exposure | Choose network exposure mode (local/tailscale/cloudflare) |
+| Health Check | Run validation across all configured services |
+
+### Provider Onboarding Flow (within Providers section)
+
+1. Choose provider type (Ollama, OpenRouter, future: Anthropic, OpenAI)
+2. Enter endpoint URL
+3. Enter API key (if required, masked input)
+4. Test connectivity (direct HTTP to provider endpoint)
+5. List available models from provider
+6. Select default model
+7. Write to `netclaw.json` / `secrets.json`
+
+This is a local operation — the wizard calls `ChatClientFactory` and provider
+APIs directly through DI. No daemon or REST endpoint required.
+
+## Security Context
+
+Every connection (SignalR, in-process channel) carries a `ChannelSecurityContext`
+that identifies the trust level.
+
+| Level | Description | Phase |
+|-------|-------------|-------|
+| `LocalOperator` | Local connection, full trust | Phase 1 (MVP) |
+| `Authenticated` | Validated remote sender, ACL-gated | Future |
+| `Anonymous` | Default deny | Future |
+
+In Phase 1, all connections are `LocalOperator` and the gateway binds
+loopback-only (SEC-005).
+
+## Docker Deployment Model
+
+Recommended production deployment:
+
+```bash
+docker run -d \
+ -v /var/run/docker.sock:/var/run/docker.sock \
+ -v ~/.netclaw:/root/.netclaw \
+ -p 127.0.0.1:5000:5000 \
+ netclaw run
+```
+
+- Docker socket mount enables host container management
+- Config volume persists across container restarts
+- Port binding on loopback only (SEC-005 default)
+- Container includes Docker CLI, git, and other management tools
+
+Tools executed by the agent (shell commands, Docker operations) run inside the
+container and reach the host Docker daemon via the mounted socket. This is the
+same pattern used by Portainer, Watchtower, and Dockge.
+
+## Cross-References
+
+- Runtime boundaries: SPEC-001
+- Session lifecycle: SPEC-002
+- Security controls: SPEC-003
+- CLI contract: SPEC-004
+- Operator UI: SPEC-005
+- Gateway exposure: SPEC-006
+- Guided onboarding: SPEC-007
+- Architecture research: `docs/research/agent-gateway-architecture.md`
diff --git a/docs/ui/TUI-001-command-wireframes.md b/docs/ui/TUI-001-command-wireframes.md
index ab4bd7060..8e035a0eb 100644
--- a/docs/ui/TUI-001-command-wireframes.md
+++ b/docs/ui/TUI-001-command-wireframes.md
@@ -4,15 +4,15 @@ Source PRDs: `PRD-004`, `PRD-009`
## Overview
-Netclaw's CLI uses **Cocona** for command routing and **Termina 0.5.1** for
-interactive TUI commands. Only two commands use Termina TUI rendering — all
-other commands use plain console output via Cocona.
+Netclaw's CLI uses **simple arg routing** in `Program.cs` for mode selection and
+**Termina 0.5.1** for interactive TUI commands. Only two commands use Termina
+TUI rendering — all other commands use plain console output.
| Command | Interface | Framework |
|-----------------|-----------|-----------|
-| `netclaw init` | TUI | Termina |
-| `netclaw chat` | TUI | Termina |
-| All others | Plain CLI | Cocona |
+| `netclaw init` | TUI | Termina (lightweight mode — no Akka) |
+| `netclaw chat` | TUI | Termina (daemon mode — full stack) |
+| All others | Plain CLI | Plain console output |
## Termina Component Vocabulary
@@ -232,7 +232,7 @@ Result: 1 error, 1 warning. Netclaw cannot start.
## Commands That Stay Plain CLI (No TUI)
-All of the following commands use standard console output via Cocona.
+All of the following commands use standard console output.
No Termina TUI components are used.
| Command | Output Style |
@@ -265,5 +265,5 @@ This is the primary production entry point.
- CLI command surface: PRD-004
- TUI adapter contract: PRD-009
- Ops console (web): UI-001
-- Cocona framework: implementation decision
+- Daemon architecture and mode selection: SPEC-011
- Termina 0.5.1: implementation decision
diff --git a/src/Netclaw.App/ConsoleChannel.cs b/src/Netclaw.App/ConsoleChannel.cs
deleted file mode 100644
index c4bd253fd..000000000
--- a/src/Netclaw.App/ConsoleChannel.cs
+++ /dev/null
@@ -1,215 +0,0 @@
-using Akka.Actor;
-using Akka.Streams;
-using Akka.Streams.Dsl;
-using Microsoft.Extensions.AI;
-using Microsoft.Extensions.Hosting;
-using Microsoft.Extensions.Logging;
-using Netclaw.Actors.Channels;
-using Netclaw.Configuration;
-using Netclaw.Actors.Protocol;
-using Netclaw.Channels;
-
-namespace Netclaw.App;
-
-///
-/// Interactive console channel. Reads user input from stdin and renders
-/// session output to stdout with color formatting.
-///
-/// Creates a single session for the channel's lifetime using a
-/// for stream-based communication.
-///
-/// All session activity is logged to ~/.netclaw/logs/{sessionId}.log.
-/// Console output is reserved exclusively for the chat UI.
-///
-public sealed class ConsoleChannel : IChannel
-{
- private readonly SessionPipeline _pipeline;
- private readonly ActorSystem _system;
- private readonly NetclawPaths _paths;
- private readonly IHostApplicationLifetime _lifetime;
- private readonly TimeProvider _timeProvider;
- private readonly ILogger _logger;
-
- private CancellationTokenRegistration _shutdownRegistration;
- private MaterializedSession? _session;
-
- public string ChannelType => "console";
- public string DisplayName => "Console Chat";
-
- public ChannelHealth GetHealth() => _session is not null
- ? new ChannelHealth(ChannelHealthStatus.Healthy)
- : new ChannelHealth(ChannelHealthStatus.Disconnected, "No active session");
-
- public ConsoleChannel(
- SessionPipeline pipeline,
- ActorSystem system,
- NetclawPaths paths,
- IHostApplicationLifetime lifetime,
- TimeProvider timeProvider,
- ILogger logger)
- {
- _pipeline = pipeline;
- _system = system;
- _paths = paths;
- _lifetime = lifetime;
- _timeProvider = timeProvider;
- _logger = logger;
- }
-
- public Task StartAsync(CancellationToken cancellationToken)
- {
- _shutdownRegistration = _lifetime.ApplicationStarted.Register(() =>
- {
- _ = Task.Run(() => RunChatLoopAsync(_lifetime.ApplicationStopping), CancellationToken.None);
- });
-
- return Task.CompletedTask;
- }
-
- public async Task StopAsync(CancellationToken cancellationToken)
- {
- if (_session is not null)
- await _session.DisposeAsync();
- _shutdownRegistration.Dispose();
- }
-
- private async Task RunChatLoopAsync(CancellationToken stopping)
- {
- try
- {
- var sessionId = new SessionId($"console/{Guid.NewGuid():N}");
-
- // Set up session log file
- _paths.EnsureDirectoriesExist();
- var logFileName = $"{sessionId.Value.Replace("/", "-")}.log";
- var logPath = Path.Combine(_paths.LogsDirectory, logFileName);
- var logWriter = new StreamWriter(logPath, append: false) { AutoFlush = true };
-
- logWriter.WriteLine($"[{_timeProvider.GetUtcNow():o}] Session started: {sessionId}");
-
- // Create session pipeline
- _session = await _pipeline.CreateAsync(sessionId, new SessionPipelineOptions
- {
- ChannelType = ChannelType
- }, stopping);
-
- // Materialize output stream → console rendering + disk logging
- _session.Output
- .To(Sink.ForEach(output => RenderOutput(output, logWriter)))
- .Run(_system);
-
- // Materialize input with queue for imperative push from readline
- var inputQueue = Source.Queue(16, OverflowStrategy.Backpressure)
- .ToMaterialized(_session.Input, Keep.Left)
- .Run(_system);
-
- _logger.LogInformation("Session started: {SessionId} (log: {LogPath})", sessionId, logPath);
- Console.WriteLine();
- Console.WriteLine($"Netclaw console chat (log: {logPath})");
- Console.WriteLine("Type 'exit' to quit.");
- Console.WriteLine("──────────────────────────────────────────");
- Console.WriteLine();
-
- while (!stopping.IsCancellationRequested)
- {
- Console.Write("You> ");
- var input = Console.ReadLine();
-
- if (input is null || string.Equals(input.Trim(), "exit", StringComparison.OrdinalIgnoreCase))
- {
- logWriter.WriteLine($"[{_timeProvider.GetUtcNow():o}] User exited chat");
- _lifetime.StopApplication();
- break;
- }
-
- if (string.IsNullOrWhiteSpace(input))
- continue;
-
- logWriter.WriteLine($"[{_timeProvider.GetUtcNow():o}] USER: {input}");
-
- await inputQueue.OfferAsync(new ChannelInput
- {
- SenderId = "local-user",
- Contents = [new TextContent(input)],
- ReceivedAt = _timeProvider.GetUtcNow()
- });
- }
- }
- catch (OperationCanceledException ex)
- {
- _logger.LogDebug(ex, "Console chat loop cancelled (shutdown)");
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "Console chat loop failed");
- _lifetime.StopApplication();
- }
- }
-
- private static void RenderOutput(SessionOutput output, StreamWriter log)
- {
- switch (output)
- {
- case SessionJoined msg:
- Log(log, $"SESSION_JOINED turn_count={msg.TurnCount} title={msg.Title ?? "(none)"}");
- break;
-
- case TextOutput msg:
- Console.WriteLine();
- Console.WriteLine($"Netclaw> {msg.Text}");
- Console.WriteLine();
- Log(log, $"ASSISTANT: {msg.Text}");
- break;
-
- case ThinkingOutput msg:
- Console.ForegroundColor = ConsoleColor.DarkGray;
- Console.WriteLine($" [thinking] {msg.Text}");
- Console.ResetColor();
- Log(log, $"THINKING: {msg.Text}");
- break;
-
- case ToolCallOutput msg:
- Console.ForegroundColor = ConsoleColor.Cyan;
- Console.WriteLine($" [tool] {msg.ToolName}({msg.ArgumentsJson ?? ""})");
- Console.ResetColor();
- Log(log, $"TOOL_CALL: {msg.ToolName} call_id={msg.CallId} args={msg.ArgumentsJson ?? "{}"}");
- break;
-
- case ToolResultOutput msg:
- Log(log, $"TOOL_RESULT: {msg.ToolName} call_id={msg.CallId} result={msg.Result}");
- break;
-
- case UsageOutput msg:
- var usage = msg.UsagePercent.HasValue
- ? $" ({msg.UsagePercent.Value:P0} context)"
- : "";
- Log(log, $"USAGE: in={msg.InputTokens} out={msg.OutputTokens} total={msg.TotalTokens} cached={msg.CachedInputTokens} reasoning={msg.ReasoningTokens} context_window={msg.ContextWindowTokens}{usage}");
- break;
-
- case ErrorOutput msg:
- Console.ForegroundColor = ConsoleColor.Red;
- Console.WriteLine($" [error] {msg.Message}");
- Console.ResetColor();
- Log(log, $"ERROR: {msg.Message}");
- if (msg.Cause is not null)
- Log(log, $"EXCEPTION: {msg.Cause}");
- break;
-
- case TurnCompleted msg:
- Log(log, $"TURN_COMPLETED: turn={msg.TurnNumber}");
- break;
-
- case CompactionOutput msg:
- Console.ForegroundColor = ConsoleColor.Yellow;
- Console.WriteLine($" [compaction] {msg.MessagesBefore} → {msg.MessagesAfter} messages");
- Console.ResetColor();
- Log(log, $"COMPACTION: before={msg.MessagesBefore} after={msg.MessagesAfter} tool_results_cleared={msg.ToolResultsCleared} summarized={msg.Summarized}");
- break;
- }
- }
-
- private static void Log(StreamWriter log, string message)
- {
- log.WriteLine($"[{DateTimeOffset.UtcNow:o}] {message}");
- }
-}
diff --git a/src/Netclaw.App/Gateway/ChannelSecurityContext.cs b/src/Netclaw.App/Gateway/ChannelSecurityContext.cs
new file mode 100644
index 000000000..c01c8ee6a
--- /dev/null
+++ b/src/Netclaw.App/Gateway/ChannelSecurityContext.cs
@@ -0,0 +1,35 @@
+namespace Netclaw.App.Gateway;
+
+///
+/// Trust level for a connection to the Netclaw gateway.
+/// Every connection (SignalR, in-process channel) carries a security context
+/// that identifies the trust level. See SPEC-011 §Security Context.
+///
+public enum SecurityTrust
+{
+ /// Local connection, full trust. Only level implemented in Phase 1.
+ LocalOperator,
+
+ /// Validated remote sender, ACL-gated. Future.
+ Authenticated,
+
+ /// Default deny. Future.
+ Anonymous
+}
+
+///
+/// Security context attached to every gateway connection.
+/// Phase 1: all connections are .
+///
+public sealed record ChannelSecurityContext
+{
+ public required SecurityTrust Trust { get; init; }
+
+ public string? SenderId { get; init; }
+
+ ///
+ /// Creates a local operator context (full trust, Phase 1 default).
+ ///
+ public static ChannelSecurityContext LocalOperator(string? senderId = null) =>
+ new() { Trust = SecurityTrust.LocalOperator, SenderId = senderId };
+}
diff --git a/src/Netclaw.App/Gateway/SessionHub.cs b/src/Netclaw.App/Gateway/SessionHub.cs
new file mode 100644
index 000000000..bd608c9b1
--- /dev/null
+++ b/src/Netclaw.App/Gateway/SessionHub.cs
@@ -0,0 +1,38 @@
+using Microsoft.AspNetCore.SignalR;
+
+namespace Netclaw.App.Gateway;
+
+///
+/// SignalR hub for remote session access. Bridges remote clients
+/// (future Blazor ops console, remote CLI) to .
+///
+/// Phase 1: mapped at /hub/session but not actively used by TUI or headless
+/// modes (they use SessionPipeline directly, in-process).
+///
+/// Contract:
+///
+/// Client → Server:
+/// CreateSession(channelType: string) → sessionId: string
+/// SendMessage(sessionId: string, text: string) → void
+///
+/// Server → Client:
+/// ReceiveOutput(output: SessionOutputDto) → void
+///
+///
+public sealed class SessionHub : Hub
+{
+ // Phase 1 stub — hub is mapped but not wired to SessionPipeline.
+ // Implementation deferred until remote clients (Blazor ops console) need it.
+
+ public Task CreateSession(string channelType)
+ {
+ // TODO: wire to SessionPipeline.CreateAsync() when remote clients are implemented
+ throw new HubException("Remote sessions are not yet implemented. Use netclaw chat for local sessions.");
+ }
+
+ public Task SendMessage(string sessionId, string text)
+ {
+ // TODO: wire to materialized session input queue
+ throw new HubException("Remote sessions are not yet implemented. Use netclaw chat for local sessions.");
+ }
+}
diff --git a/src/Netclaw.App/Gateway/SessionOutputDto.cs b/src/Netclaw.App/Gateway/SessionOutputDto.cs
new file mode 100644
index 000000000..6f13ce0e0
--- /dev/null
+++ b/src/Netclaw.App/Gateway/SessionOutputDto.cs
@@ -0,0 +1,50 @@
+namespace Netclaw.App.Gateway;
+
+///
+/// Wire-safe DTO for session output. Flattens the discriminated union
+/// () into a single
+/// serializable type for SignalR transport.
+///
+public sealed record SessionOutputDto
+{
+ ///
+ /// Output type discriminator (e.g. "text", "thinking", "tool_call",
+ /// "tool_result", "usage", "turn_completed", "error", "compaction",
+ /// "session_joined", "session_title").
+ ///
+ public required string Type { get; init; }
+
+ public required string SessionId { get; init; }
+
+ public long TimestampMs { get; init; }
+
+ // Text / Thinking
+ public string? Text { get; init; }
+
+ // Tool Call / Tool Result
+ public string? CallId { get; init; }
+ public string? ToolName { get; init; }
+ public string? ArgumentsJson { get; init; }
+ public string? Result { get; init; }
+
+ // Usage
+ public long? InputTokens { get; init; }
+ public long? OutputTokens { get; init; }
+ public long? TotalTokens { get; init; }
+ public int? ContextWindowTokens { get; init; }
+ public double? UsagePercent { get; init; }
+
+ // Turn Completed
+ public int? TurnNumber { get; init; }
+
+ // Error
+ public string? ErrorMessage { get; init; }
+
+ // Compaction
+ public int? MessagesBefore { get; init; }
+ public int? MessagesAfter { get; init; }
+
+ // Session Joined
+ public string? Title { get; init; }
+ public int? TurnCount { get; init; }
+}
diff --git a/src/Netclaw.App/Gateway/SessionOutputMapper.cs b/src/Netclaw.App/Gateway/SessionOutputMapper.cs
new file mode 100644
index 000000000..851a673bb
--- /dev/null
+++ b/src/Netclaw.App/Gateway/SessionOutputMapper.cs
@@ -0,0 +1,110 @@
+using Netclaw.Actors.Protocol;
+
+namespace Netclaw.App.Gateway;
+
+///
+/// Maps discriminated union types to
+/// for wire transport.
+///
+public static class SessionOutputMapper
+{
+ public static SessionOutputDto ToDto(SessionOutput output) => output switch
+ {
+ TextOutput msg => new SessionOutputDto
+ {
+ Type = "text",
+ SessionId = msg.SessionId.Value,
+ TimestampMs = msg.TimestampMs,
+ Text = msg.Text
+ },
+
+ ThinkingOutput msg => new SessionOutputDto
+ {
+ Type = "thinking",
+ SessionId = msg.SessionId.Value,
+ TimestampMs = msg.TimestampMs,
+ Text = msg.Text
+ },
+
+ ToolCallOutput msg => new SessionOutputDto
+ {
+ Type = "tool_call",
+ SessionId = msg.SessionId.Value,
+ TimestampMs = msg.TimestampMs,
+ CallId = msg.CallId,
+ ToolName = msg.ToolName,
+ ArgumentsJson = msg.ArgumentsJson
+ },
+
+ ToolResultOutput msg => new SessionOutputDto
+ {
+ Type = "tool_result",
+ SessionId = msg.SessionId.Value,
+ TimestampMs = msg.TimestampMs,
+ CallId = msg.CallId,
+ ToolName = msg.ToolName,
+ Result = msg.Result
+ },
+
+ UsageOutput msg => new SessionOutputDto
+ {
+ Type = "usage",
+ SessionId = msg.SessionId.Value,
+ TimestampMs = msg.TimestampMs,
+ InputTokens = msg.InputTokens,
+ OutputTokens = msg.OutputTokens,
+ TotalTokens = msg.TotalTokens,
+ ContextWindowTokens = msg.ContextWindowTokens,
+ UsagePercent = msg.UsagePercent
+ },
+
+ TurnCompleted msg => new SessionOutputDto
+ {
+ Type = "turn_completed",
+ SessionId = msg.SessionId.Value,
+ TimestampMs = msg.TimestampMs,
+ TurnNumber = msg.TurnNumber
+ },
+
+ SessionTitleOutput msg => new SessionOutputDto
+ {
+ Type = "session_title",
+ SessionId = msg.SessionId.Value,
+ TimestampMs = msg.TimestampMs,
+ Title = msg.Title
+ },
+
+ ErrorOutput msg => new SessionOutputDto
+ {
+ Type = "error",
+ SessionId = msg.SessionId.Value,
+ TimestampMs = msg.TimestampMs,
+ ErrorMessage = msg.Message
+ },
+
+ CompactionOutput msg => new SessionOutputDto
+ {
+ Type = "compaction",
+ SessionId = msg.SessionId.Value,
+ TimestampMs = msg.TimestampMs,
+ MessagesBefore = msg.MessagesBefore,
+ MessagesAfter = msg.MessagesAfter
+ },
+
+ SessionJoined msg => new SessionOutputDto
+ {
+ Type = "session_joined",
+ SessionId = msg.SessionId.Value,
+ TimestampMs = msg.TimestampMs,
+ Title = msg.Title,
+ TurnCount = msg.TurnCount
+ },
+
+ _ => new SessionOutputDto
+ {
+ Type = "unknown",
+ SessionId = output.SessionId.Value,
+ TimestampMs = output.TimestampMs
+ }
+ };
+}
diff --git a/src/Netclaw.App/HeadlessChannel.cs b/src/Netclaw.App/HeadlessChannel.cs
index 1c8521f04..76d270f29 100644
--- a/src/Netclaw.App/HeadlessChannel.cs
+++ b/src/Netclaw.App/HeadlessChannel.cs
@@ -169,8 +169,8 @@ private void HandleOutput(SessionOutput output, StreamWriter log)
}
}
- private static void Log(StreamWriter log, string message)
+ private void Log(StreamWriter log, string message)
{
- log.WriteLine($"[{DateTimeOffset.UtcNow:o}] {message}");
+ log.WriteLine($"[{_timeProvider.GetUtcNow():o}] {message}");
}
}
diff --git a/src/Netclaw.App/Netclaw.App.csproj b/src/Netclaw.App/Netclaw.App.csproj
index 20e7e6a96..f4d50fa86 100644
--- a/src/Netclaw.App/Netclaw.App.csproj
+++ b/src/Netclaw.App/Netclaw.App.csproj
@@ -1,4 +1,4 @@
-
+
net10.0
@@ -10,9 +10,8 @@
-
-
+
diff --git a/src/Netclaw.App/Program.cs b/src/Netclaw.App/Program.cs
index 0782f541b..4d987c179 100644
--- a/src/Netclaw.App/Program.cs
+++ b/src/Netclaw.App/Program.cs
@@ -9,117 +9,236 @@
using Netclaw.Actors.Tools;
using Netclaw.App;
using Netclaw.App.Configuration;
+using Netclaw.App.Gateway;
+using Netclaw.App.Services;
+using Netclaw.App.Tui;
using Netclaw.Channels;
using Netclaw.Configuration;
+using Termina.Hosting;
-// -- CLI mode selection --
-string? headlessPrompt = null;
-for (var i = 0; i < args.Length; i++)
+try
{
- if (args[i] is "-p" or "--prompt" && i + 1 < args.Length)
+ await RunAsync(args);
+}
+catch (Exception ex)
+{
+ // Write crash log to ~/.netclaw/logs/ so fatal errors are always diagnosable
+ WriteCrashLog(ex);
+ throw;
+}
+
+static async Task RunAsync(string[] args)
+{
+ // ── Mode selection from CLI args ──
+ var mode = args.Length > 0 ? args[0] : "chat";
+ string? headlessPrompt = null;
+
+ if (mode is "-p" or "--prompt")
+ {
+ headlessPrompt = args.Length > 1
+ ? args[1]
+ : throw new InvalidOperationException("Missing prompt argument after -p/--prompt");
+ mode = "headless";
+ }
+
+ // ── Lightweight modes (no Akka, no persistence, no SignalR) ──
+ if (mode is "init" or "doctor")
+ {
+ var builder = Host.CreateApplicationBuilder(args);
+ ConfigureConfigServices(builder.Services, builder.Configuration);
+
+ // Suppress framework console logging
+ builder.Logging.ClearProviders();
+ builder.Logging.SetMinimumLevel(LogLevel.Warning);
+
+ // TODO: init → Termina TUI wizard (Task 1.22)
+ // TODO: doctor → health checks (Task 1.21)
+ Console.WriteLine($"netclaw {mode}: not yet implemented");
+
+ await builder.Build().RunAsync();
+ return;
+ }
+
+ // ── Daemon modes (Akka, persistence, SignalR, tools) ──
+ var webBuilder = WebApplication.CreateBuilder(args);
+
+ // Use port 5199 to avoid conflicts with Aspire (5000) and other defaults
+ webBuilder.WebHost.UseUrls("http://127.0.0.1:5199");
+
+ ConfigureConfigServices(webBuilder.Services, webBuilder.Configuration);
+ ConfigureDaemonServices(webBuilder.Services, webBuilder.Configuration);
+
+ // Suppress framework console logging — session logs go to disk,
+ // console is reserved for the chat UI
+ webBuilder.Logging.ClearProviders();
+ webBuilder.Logging.SetMinimumLevel(LogLevel.Warning);
+
+ // SignalR for future remote clients (Blazor ops console)
+ webBuilder.Services.AddSignalR();
+
+ // Channel selection based on mode
+ switch (mode)
{
- headlessPrompt = args[i + 1];
- break;
+ case "chat":
+ webBuilder.Services.AddTermina("/chat", termina =>
+ {
+ termina.RegisterRoute("/chat");
+ });
+ break;
+
+ case "headless":
+ webBuilder.Services.AddSingleton(sp =>
+ ActivatorUtilities.CreateInstance(sp, headlessPrompt!));
+ webBuilder.Services.AddSingleton(sp => sp.GetRequiredService());
+ webBuilder.Services.AddSingleton(sp => sp.GetRequiredService());
+ break;
+
+ case "run":
+ // Daemon only — no interactive channel. Slack adapter, scheduled tasks, etc.
+ // TODO: Slack adapter (Task 1.23)
+ break;
+
+ default:
+ // Treat unknown commands as "chat" for backward compatibility
+ webBuilder.Services.AddTermina("/chat", termina =>
+ {
+ termina.RegisterRoute("/chat");
+ });
+ break;
}
+
+ var app = webBuilder.Build();
+
+ // Gateway surface (Phase 1 — minimal)
+ app.MapHub("/hub/session");
+ app.MapGet("/api/health/ready", () => Results.Ok("healthy"));
+
+ await app.RunAsync();
}
-var builder = Host.CreateApplicationBuilder(args);
-
-// -- Netclaw paths (creates ~/.netclaw/ structure) --
-var paths = new NetclawPaths();
-paths.EnsureDirectoriesExist();
-builder.Services.AddSingleton(paths);
-
-// -- Layered configuration chain --
-// 1. netclaw.json (base config, optional)
-// 2. secrets.json (credentials overlay, optional)
-// 3. NETCLAW_* environment variables (highest priority)
-builder.Configuration
- .AddJsonFile(paths.NetclawConfigPath, optional: true, reloadOnChange: false)
- .AddJsonFile(paths.SecretsPath, optional: true, reloadOnChange: false)
- .AddEnvironmentVariables("NETCLAW_");
-
-// Suppress all framework console logging — session logs go to disk,
-// console is reserved for the chat UI
-builder.Logging.ClearProviders();
-builder.Logging.SetMinimumLevel(LogLevel.Warning);
-
-// -- TimeProvider --
-builder.Services.AddSingleton(TimeProvider.System);
-
-// -- Providers and models --
-var providers = builder.Configuration.GetSection("Providers")
- .Get>()
- ?? new() { ["local-ollama"] = new ProviderEntry() };
-var models = builder.Configuration.GetSection("Models")
- .Get() ?? new ModelSelection();
-
-var factory = new ChatClientFactory(providers);
-var clientProvider = new NetclawChatClientProvider(factory, models);
-builder.Services.AddSingleton(clientProvider);
-
-// -- Session config from resolved models --
-var sessionSection = builder.Configuration.GetSection("Session");
-builder.Services.AddSingleton(new SessionConfig
-{
- ModelId = models.Main.ModelId,
- ContextWindowTokens = models.Main.ContextWindow ?? 32_768,
- CompactionModelId = models.Compaction?.ModelId,
- CompactionThreshold = sessionSection.GetValue("CompactionThreshold", 0.75),
- SnapshotInterval = sessionSection.GetValue("SnapshotInterval", 20),
- KeepRecentToolResults = sessionSection.GetValue("KeepRecentToolResults", 3),
- MaxToolIterationsPerTurn = sessionSection.GetValue("MaxToolIterationsPerTurn", 10),
-});
-
-// -- Tools (auto-bound, no required properties) --
-var toolConfig = builder.Configuration.GetSection("Tools")
- .Get() ?? new ToolConfig();
-builder.Services.AddSingleton(toolConfig);
-
-var toolRegistry = new ToolRegistry();
-toolRegistry.WithFirstPartyTools(toolConfig);
-builder.Services.AddSingleton(toolRegistry);
-builder.Services.AddSingleton(new DispatchingToolExecutor(toolRegistry));
-
-// -- System prompt (file-based, with first-run seed) --
-if (!File.Exists(paths.PersonalityPath))
- File.WriteAllText(paths.PersonalityPath,
- "You are Netclaw, a helpful homelab operations assistant. "
- + "Be concise and direct.");
-builder.Services.AddSingleton(
- new FileSystemPromptProvider(paths));
-
-// -- Akka.NET actor system --
-builder.Services.AddAkka("netclaw", (akkaBuilder, sp) =>
-{
- akkaBuilder
- .ConfigureLoggers(setup =>
- {
- setup.ClearLoggers();
- setup.AddLoggerFactory();
- setup.LogLevel = Akka.Event.LogLevel.WarningLevel;
- })
- .WithInMemoryJournal()
- .WithInMemorySnapshotStore()
- .WithNetclawActors();
-});
-
-// -- Session pipeline (stream API for channels) --
-builder.Services.AddSingleton();
-
-// -- Channel selection --
-if (headlessPrompt is not null)
+static void WriteCrashLog(Exception ex)
{
- builder.Services.AddSingleton(sp =>
- ActivatorUtilities.CreateInstance(sp, headlessPrompt));
- builder.Services.AddSingleton(sp => sp.GetRequiredService());
- builder.Services.AddSingleton(sp => sp.GetRequiredService());
+ try
+ {
+ var logsDir = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".netclaw", "logs");
+ Directory.CreateDirectory(logsDir);
+
+ var crashPath = Path.Combine(logsDir,
+ $"crash-{DateTime.UtcNow:yyyyMMdd-HHmmss}.log");
+ File.WriteAllText(crashPath,
+ $"""
+ Netclaw crash at {DateTime.UtcNow:O}
+
+ {ex}
+ """);
+
+ Console.Error.WriteLine($"Fatal error — crash log written to {crashPath}");
+ }
+ catch
+ {
+ // Last resort: write to stderr if we can't write the log file
+ Console.Error.WriteLine($"Fatal error (could not write crash log): {ex}");
+ }
}
-else
+
+// ═══════════════════════════════════════════════════════════════════════
+// Shared configuration services (all modes)
+// ═══════════════════════════════════════════════════════════════════════
+
+static void ConfigureConfigServices(IServiceCollection services, IConfigurationManager configuration)
{
- builder.Services.AddSingleton();
- builder.Services.AddSingleton(sp => sp.GetRequiredService());
- builder.Services.AddSingleton(sp => sp.GetRequiredService());
+ // Netclaw paths (creates ~/.netclaw/ structure)
+ var paths = new NetclawPaths();
+ paths.EnsureDirectoriesExist();
+ services.AddSingleton(paths);
+
+ // Layered configuration chain:
+ // 1. netclaw.json (base config, optional)
+ // 2. secrets.json (credentials overlay, optional)
+ // 3. NETCLAW_* environment variables (highest priority)
+ configuration
+ .AddJsonFile(paths.NetclawConfigPath, optional: true, reloadOnChange: false)
+ .AddJsonFile(paths.SecretsPath, optional: true, reloadOnChange: false)
+ .AddEnvironmentVariables("NETCLAW_");
+
+ // TimeProvider (virtualized for testing)
+ services.AddSingleton(TimeProvider.System);
+
+ // Providers and model resolution
+ var providers = configuration.GetSection("Providers")
+ .Get>()
+ ?? new() { ["local-ollama"] = new ProviderEntry() };
+ var models = configuration.GetSection("Models")
+ .Get() ?? new ModelSelection();
+
+ var factory = new ChatClientFactory(providers);
+ var clientProvider = new NetclawChatClientProvider(factory, models);
+ services.AddSingleton(clientProvider);
}
-await builder.Build().RunAsync();
+// ═══════════════════════════════════════════════════════════════════════
+// Daemon-only services (run, chat, headless modes)
+// ═══════════════════════════════════════════════════════════════════════
+
+static void ConfigureDaemonServices(IServiceCollection services, IConfigurationManager configuration)
+{
+ // Resolve models for session config
+ var models = configuration.GetSection("Models")
+ .Get() ?? new ModelSelection();
+
+ // Session config from resolved models
+ var sessionSection = configuration.GetSection("Session");
+ services.AddSingleton(new SessionConfig
+ {
+ ModelId = models.Main.ModelId,
+ ContextWindowTokens = models.Main.ContextWindow ?? 32_768,
+ CompactionModelId = models.Compaction?.ModelId,
+ CompactionThreshold = sessionSection.GetValue("CompactionThreshold", 0.75),
+ SnapshotInterval = sessionSection.GetValue("SnapshotInterval", 20),
+ KeepRecentToolResults = sessionSection.GetValue("KeepRecentToolResults", 3),
+ MaxToolIterationsPerTurn = sessionSection.GetValue("MaxToolIterationsPerTurn", 10),
+ });
+
+ // Tools (auto-bound, no required properties)
+ var toolConfig = configuration.GetSection("Tools")
+ .Get() ?? new ToolConfig();
+ services.AddSingleton(toolConfig);
+
+ var toolRegistry = new ToolRegistry();
+ toolRegistry.WithFirstPartyTools(toolConfig);
+ services.AddSingleton(toolRegistry);
+ services.AddSingleton(new DispatchingToolExecutor(toolRegistry));
+
+ // System prompt (file-based, with first-run seed)
+ var paths = new NetclawPaths();
+ if (!File.Exists(paths.PersonalityPath))
+ File.WriteAllText(paths.PersonalityPath,
+ "You are Netclaw, a helpful homelab operations assistant. "
+ + "Be concise and direct.");
+ services.AddSingleton(
+ new FileSystemPromptProvider(paths));
+
+ // Akka.NET actor system
+ services.AddAkka("netclaw", (akkaBuilder, sp) =>
+ {
+ akkaBuilder
+ .ConfigureLoggers(setup =>
+ {
+ setup.ClearLoggers();
+ setup.AddLoggerFactory();
+ setup.LogLevel = Akka.Event.LogLevel.WarningLevel;
+ })
+ .WithInMemoryJournal()
+ .WithInMemorySnapshotStore()
+ .WithNetclawActors();
+ });
+
+ // Session pipeline (stream API for channels)
+ services.AddSingleton();
+
+ // Config hot-reload watcher
+ services.AddSingleton();
+ services.AddSingleton(sp => sp.GetRequiredService());
+}
diff --git a/src/Netclaw.App/Services/ConfigWatcherService.cs b/src/Netclaw.App/Services/ConfigWatcherService.cs
new file mode 100644
index 000000000..bdec8958f
--- /dev/null
+++ b/src/Netclaw.App/Services/ConfigWatcherService.cs
@@ -0,0 +1,136 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Netclaw.Configuration;
+
+namespace Netclaw.App.Services;
+
+///
+/// Monitors ~/.netclaw/config/ for changes to netclaw.json and
+/// secrets.json. Debounces file system events and validates new config
+/// before applying. See SPEC-011 §Configuration Hot-Reload.
+///
+///
+/// Single reload trigger: all config changes go to disk first (TUI wizard,
+/// manual editing, agent self-configuration). This watcher is the only
+/// mechanism that triggers config reload — there is no in-memory config
+/// mutation path.
+///
+///
+public sealed class ConfigWatcherService : IHostedService, IDisposable
+{
+ private readonly NetclawPaths _paths;
+ private readonly TimeProvider _timeProvider;
+ private readonly ILogger _logger;
+
+ private FileSystemWatcher? _watcher;
+ private CancellationTokenSource? _debounceCts;
+ private readonly TimeSpan _debounceInterval = TimeSpan.FromMilliseconds(500);
+
+ public ConfigWatcherService(
+ NetclawPaths paths,
+ TimeProvider timeProvider,
+ ILogger logger)
+ {
+ _paths = paths;
+ _timeProvider = timeProvider;
+ _logger = logger;
+ }
+
+ public Task StartAsync(CancellationToken cancellationToken)
+ {
+ var configDir = Path.GetDirectoryName(_paths.NetclawConfigPath);
+ if (configDir is null || !Directory.Exists(configDir))
+ {
+ _logger.LogWarning("Config directory does not exist: {ConfigDir}. Hot-reload disabled.", configDir);
+ return Task.CompletedTask;
+ }
+
+ _watcher = new FileSystemWatcher(configDir)
+ {
+ NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime,
+ EnableRaisingEvents = true
+ };
+
+ _watcher.Changed += OnFileChanged;
+ _watcher.Created += OnFileChanged;
+ _watcher.Deleted += OnFileDeleted;
+
+ _logger.LogInformation("Config hot-reload watching: {ConfigDir}", configDir);
+ return Task.CompletedTask;
+ }
+
+ public Task StopAsync(CancellationToken cancellationToken)
+ {
+ _watcher?.Dispose();
+ _debounceCts?.Cancel();
+ return Task.CompletedTask;
+ }
+
+ private void OnFileChanged(object sender, FileSystemEventArgs e)
+ {
+ if (!IsWatchedFile(e.Name))
+ return;
+
+ _logger.LogDebug("Config file changed: {FileName}", e.Name);
+ ScheduleReload();
+ }
+
+ private void OnFileDeleted(object sender, FileSystemEventArgs e)
+ {
+ if (!IsWatchedFile(e.Name))
+ return;
+
+ _logger.LogWarning("Config file deleted: {FileName}. Keeping current config.", e.Name);
+ }
+
+ private void ScheduleReload()
+ {
+ // Cancel any pending debounce timer and start a new one
+ _debounceCts?.Cancel();
+ _debounceCts = new CancellationTokenSource();
+ var token = _debounceCts.Token;
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await Task.Delay(_debounceInterval, token);
+ if (token.IsCancellationRequested) return;
+
+ ApplyReload();
+ }
+ catch (OperationCanceledException)
+ {
+ _logger.LogDebug("Config reload debounce cancelled by newer event");
+ }
+ }, CancellationToken.None);
+ }
+
+ private void ApplyReload()
+ {
+ try
+ {
+ // TODO: Read and validate new configuration
+ // TODO: Rebuild IChatClientProvider on valid change
+ // TODO: Notify actor system of config changes via Akka pub/sub
+ // TODO: Log validation errors on invalid change, preserve previous config
+
+ _logger.LogInformation(
+ "[{Timestamp:o}] Config reload triggered (validation not yet implemented)",
+ _timeProvider.GetUtcNow());
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Config reload failed. Keeping previous config.");
+ }
+ }
+
+ private static bool IsWatchedFile(string? fileName) =>
+ fileName is "netclaw.json" or "secrets.json";
+
+ public void Dispose()
+ {
+ _watcher?.Dispose();
+ _debounceCts?.Dispose();
+ }
+}
diff --git a/src/Netclaw.App/Tui/ChatPage.cs b/src/Netclaw.App/Tui/ChatPage.cs
new file mode 100644
index 000000000..6fe591d0f
--- /dev/null
+++ b/src/Netclaw.App/Tui/ChatPage.cs
@@ -0,0 +1,273 @@
+using System.Reactive.Disposables;
+using System.Reactive.Linq;
+using Netclaw.Actors.Protocol;
+using Termina.Components.Streaming;
+using Termina.Extensions;
+using Termina.Input;
+using Termina.Layout;
+using Termina.Reactive;
+using Termina.Rendering;
+using Termina.Terminal;
+
+namespace Netclaw.App.Tui;
+
+///
+/// Termina page for the interactive chat UI (netclaw chat).
+/// Layout: scrollable chat history (fill) + fixed input panel (3 rows) + status bar.
+///
+public sealed class ChatPage : ReactivePage
+{
+ private StreamingTextNode _chatHistory = null!;
+ private TextInputNode _promptInput = null!;
+
+ private int _nextSegmentId = 1;
+
+ private SegmentId NextSegmentId() => new(_nextSegmentId++);
+
+ // Track the current "thinking" spinner segment so we can replace it
+ private SegmentId _thinkingSegmentId;
+
+ // Track active tool timer so we can read final elapsed on completion
+ private ElapsedTimeSegment? _toolTimer;
+
+ protected override void OnBound()
+ {
+ base.OnBound();
+
+ _chatHistory = StreamingTextNode.Create();
+ _promptInput = new TextInputNode()
+ .WithPlaceholder("Type a message...");
+
+ // Handle prompt submission — Buffer coalesces rapid-fire submissions
+ // (e.g., pasting multi-line text where each CRLF triggers Submitted)
+ // into a single message. Normal typing produces one item per buffer window.
+ _promptInput.Submitted
+ .Where(text => !string.IsNullOrWhiteSpace(text))
+ .Buffer(TimeSpan.FromMilliseconds(100))
+ .Where(batch => batch.Count > 0)
+ .Subscribe(batch =>
+ {
+ _promptInput.Clear();
+
+ var combined = string.Join("\n", batch);
+
+ // Render user message
+ _chatHistory.AppendLine("");
+ _chatHistory.AppendLine($"You: {combined}", Color.Cyan);
+
+ _ = ViewModel.SubmitAsync(combined);
+ })
+ .DisposeWith(Subscriptions);
+
+ // Subscribe to session output
+ ViewModel.SessionOutput
+ .ObserveOn(System.Reactive.Concurrency.CurrentThreadScheduler.Instance)
+ .Subscribe(HandleOutput)
+ .DisposeWith(Subscriptions);
+
+ // Route keyboard input
+ ViewModel.Input.OfType()
+ .Subscribe(HandleKeyPress)
+ .DisposeWith(Subscriptions);
+ }
+
+ public override ILayoutNode BuildLayout()
+ {
+ return Layouts.Vertical()
+ // Chat history panel (fills available space)
+ .WithChild(
+ new PanelNode()
+ .WithTitle("Netclaw Chat")
+ .WithBorder(BorderStyle.Rounded)
+ .WithBorderColor(Color.Gray)
+ .WithContent(_chatHistory.Fill())
+ .Fill())
+ // Input panel (fixed 3 rows)
+ .WithChild(
+ new PanelNode()
+ .WithTitle("Input")
+ .WithBorder(BorderStyle.Rounded)
+ .WithBorderColor(Color.Cyan)
+ .WithContent(_promptInput)
+ .Height(3))
+ // Status bar
+ .WithChild(
+ BuildStatusBar());
+ }
+
+ private LayoutNode BuildStatusBar()
+ {
+ return Observable.CombineLatest(
+ ViewModel.IsGeneratingChanged,
+ ViewModel.StatusMessageChanged,
+ ViewModel.UsageDisplayChanged.StartWith((string?)null),
+ (isGenerating, status, usage) =>
+ {
+ var keys = isGenerating
+ ? "[Ctrl+Q] Quit"
+ : "[Enter] Send [PgUp/PgDn] Scroll [Ctrl+Q] Quit";
+
+ var usagePart = usage is not null ? $" | {usage}" : "";
+ var text = $" {keys} | {status} | {ViewModel.ModelId}{usagePart}";
+
+ var barColor = status switch
+ {
+ "Ready" => Color.Green,
+ "Connecting..." => Color.Yellow,
+ _ when status.StartsWith("Generating") => Color.Yellow,
+ _ when status.StartsWith("Connection failed") => Color.Red,
+ _ => Color.BrightBlack
+ };
+
+ return (ILayoutNode)new TextNode(text).WithForeground(barColor);
+ })
+ .AsLayout()
+ .Height(1);
+ }
+
+ private void HandleKeyPress(KeyPressed key)
+ {
+ var keyInfo = key.KeyInfo;
+
+ // Ctrl+Q always quits
+ if (keyInfo.Key == ConsoleKey.Q && keyInfo.Modifiers.HasFlag(ConsoleModifiers.Control))
+ {
+ ViewModel.RequestAppShutdown();
+ return;
+ }
+
+ // Escape: cancel or quit
+ if (keyInfo.Key == ConsoleKey.Escape)
+ {
+ if (ViewModel.IsGenerating)
+ {
+ // TODO: cancel generation when supported
+ }
+ else
+ {
+ ViewModel.RequestAppShutdown();
+ }
+
+ return;
+ }
+
+ // PageUp/PageDown: scroll chat history
+ if (_chatHistory.HandleInput(keyInfo, viewportHeight: 20, viewportWidth: 80))
+ return;
+
+ // Everything else goes to the text input
+ _promptInput.HandleInput(keyInfo);
+ }
+
+ private void HandleOutput(SessionOutput output)
+ {
+ switch (output)
+ {
+ case SessionJoined msg:
+ _chatHistory.AppendLine(
+ $"System: Session started. {(msg.Title is not null ? $"Title: {msg.Title}" : "New session.")}",
+ Color.BrightBlack);
+ _chatHistory.AppendLine("");
+ break;
+
+ case TextOutput msg:
+ // Remove thinking spinner if present
+ RemoveThinkingSpinner();
+ _chatHistory.AppendLine("");
+ _chatHistory.AppendLine($"Netclaw: {msg.Text}", Color.White);
+ _chatHistory.AppendLine("");
+ _chatHistory.ScrollToBottom();
+ break;
+
+ case ThinkingOutput:
+ // Hidden — reasoning output is too verbose for the chat view.
+ // TODO: collapsible thinking sections when Termina supports it.
+ break;
+
+ case ToolCallOutput msg:
+ RemoveThinkingSpinner();
+ var toolSegmentId = NextSegmentId();
+ _thinkingSegmentId = toolSegmentId;
+ _toolTimer = new ElapsedTimeSegment(Color.BrightBlack);
+ _chatHistory.AppendTracked(toolSegmentId,
+ new CompositeTextSegment(
+ new SpinnerSegment(Termina.Components.Streaming.SpinnerStyle.Dots, Color.Yellow, intervalMs: 80),
+ new StaticTextSegment($" {msg.ToolName}({TruncateArgs(msg.ArgumentsJson)})",
+ Color.Yellow),
+ _toolTimer));
+ break;
+
+ case ToolResultOutput msg:
+ // Replace spinner+timer with checkmark and final elapsed time
+ if (_thinkingSegmentId.Value != 0)
+ {
+ // Read elapsed from the timer segment before it gets disposed by Replace
+ var elapsed = _toolTimer is not null
+ ? $" ({FormatElapsed(_toolTimer.Elapsed)})"
+ : "";
+ _toolTimer = null;
+
+ _chatHistory.Replace(_thinkingSegmentId,
+ new StaticTextSegment(
+ $" \u2713 {msg.ToolName} \u2192 {Truncate(msg.Result, 80)}{elapsed}",
+ Color.Green),
+ keepTracked: false);
+ _thinkingSegmentId = default;
+ }
+
+ break;
+
+ case UsageOutput msg:
+ // Compute context % from ViewModel's SessionConfig (known-good)
+ // rather than msg.ContextWindowTokens which may be default(0)
+ var ctxWindow = ViewModel.ContextWindowTokens;
+ var usagePercent = msg.InputTokens.HasValue && ctxWindow > 0
+ ? (double)msg.InputTokens.Value / ctxWindow
+ : (double?)null;
+ var ctxPart = usagePercent.HasValue
+ ? $" ({usagePercent.Value:P0} ctx)"
+ : "";
+ ViewModel.UsageDisplay = $"in={msg.InputTokens ?? 0} out={msg.OutputTokens ?? 0}{ctxPart}";
+ break;
+
+ case ErrorOutput msg:
+ RemoveThinkingSpinner();
+ _chatHistory.AppendLine($" [error] {msg.Message}", Color.Red);
+ break;
+
+ case TurnCompleted:
+ RemoveThinkingSpinner();
+ ViewModel.StatusMessage = "Ready";
+ _chatHistory.ScrollToBottom();
+ break;
+
+ case CompactionOutput msg:
+ _chatHistory.AppendLine(
+ $" [compaction] {msg.MessagesBefore} \u2192 {msg.MessagesAfter} messages",
+ Color.Yellow);
+ break;
+ }
+
+ ViewModel.RequestRedraw();
+ }
+
+ private void RemoveThinkingSpinner()
+ {
+ if (_thinkingSegmentId.Value != 0)
+ {
+ _chatHistory.Remove(_thinkingSegmentId);
+ _thinkingSegmentId = default;
+ }
+ }
+
+ private static string TruncateArgs(string? json) =>
+ json is null or "" ? "" : Truncate(json, 60);
+
+ private static string Truncate(string text, int maxLength) =>
+ text.Length <= maxLength ? text : string.Concat(text.AsSpan(0, maxLength - 3), "...");
+
+ private static string FormatElapsed(TimeSpan elapsed) =>
+ elapsed.TotalSeconds < 60
+ ? $"{elapsed.TotalSeconds:F1}s"
+ : $"{(int)elapsed.TotalMinutes}m {elapsed.Seconds}s";
+}
diff --git a/src/Netclaw.App/Tui/ChatViewModel.cs b/src/Netclaw.App/Tui/ChatViewModel.cs
new file mode 100644
index 000000000..0ea55ed3f
--- /dev/null
+++ b/src/Netclaw.App/Tui/ChatViewModel.cs
@@ -0,0 +1,151 @@
+using System.Reactive.Linq;
+using System.Reactive.Subjects;
+using Akka.Actor;
+using Akka.Streams;
+using Akka.Streams.Dsl;
+using Microsoft.Extensions.AI;
+using Netclaw.Actors.Channels;
+using Netclaw.Actors.Protocol;
+using Netclaw.Configuration;
+using Termina.Reactive;
+
+namespace Netclaw.App.Tui;
+
+///
+/// Reactive ViewModel for the chat page. Uses
+/// directly (in-process, no SignalR indirection). Manages session lifecycle,
+/// input submission, and output forwarding to the page.
+///
+public partial class ChatViewModel : ReactiveViewModel
+{
+ private readonly SessionPipeline _pipeline;
+ private readonly ActorSystem _system;
+ private readonly TimeProvider _timeProvider;
+ private readonly SessionConfig _sessionConfig;
+
+ private MaterializedSession? _session;
+ private ISourceQueueWithComplete? _inputQueue;
+ private readonly Subject _outputSubject = new();
+
+#pragma warning disable CS0169, CS0414 // Backing fields used by [Reactive] source generator
+ [Reactive] private bool _isGenerating;
+ [Reactive] private string _statusMessage = "Connecting...";
+ [Reactive] private string? _sessionIdDisplay;
+ [Reactive] private string? _usageDisplay;
+#pragma warning restore CS0169, CS0414
+
+ ///
+ /// Observable stream of session output events. The page subscribes to this
+ /// to render chat messages, tool activity, usage, etc.
+ ///
+ public IObservable SessionOutput => _outputSubject.AsObservable();
+
+ ///
+ /// The configured model identifier for display in the status bar.
+ ///
+ public string ModelId => _sessionConfig.ModelId;
+
+ public int ContextWindowTokens => _sessionConfig.ContextWindowTokens;
+
+ public ChatViewModel(
+ SessionPipeline pipeline,
+ ActorSystem system,
+ TimeProvider timeProvider,
+ SessionConfig sessionConfig)
+ {
+ _pipeline = pipeline;
+ _system = system;
+ _timeProvider = timeProvider;
+ _sessionConfig = sessionConfig;
+ }
+
+ public override void OnActivated()
+ {
+ base.OnActivated();
+ _ = InitializeSessionAsync();
+ }
+
+ private async Task InitializeSessionAsync()
+ {
+ try
+ {
+ var sessionId = new SessionId($"tui/{Guid.NewGuid():N}");
+ SessionIdDisplay = sessionId.Value;
+
+ _session = await _pipeline.CreateAsync(sessionId, new SessionPipelineOptions
+ {
+ ChannelType = "tui"
+ });
+
+ // Materialize output stream → forward to Subject for page rendering
+ _session.Output
+ .To(Sink.ForEach(output =>
+ {
+ _outputSubject.OnNext(output);
+
+ // Track turn lifecycle for generation state
+ switch (output)
+ {
+ case TurnCompleted:
+ IsGenerating = false;
+ break;
+ case ErrorOutput:
+ IsGenerating = false;
+ break;
+ }
+
+ RequestRedraw();
+ }))
+ .Run(_system);
+
+ // Materialize input with queue for imperative push
+ _inputQueue = Source.Queue(16, OverflowStrategy.Backpressure)
+ .ToMaterialized(_session.Input, Keep.Left)
+ .Run(_system);
+
+ StatusMessage = "Ready";
+ RequestRedraw();
+ }
+ catch (Exception ex)
+ {
+ StatusMessage = $"Connection failed: {ex.Message}";
+ RequestRedraw();
+ }
+ }
+
+ ///
+ /// Submit user text to the session pipeline.
+ ///
+ public async Task SubmitAsync(string text)
+ {
+ if (_inputQueue is null || string.IsNullOrWhiteSpace(text))
+ return;
+
+ IsGenerating = true;
+ StatusMessage = "Generating...";
+
+ await _inputQueue.OfferAsync(new ChannelInput
+ {
+ SenderId = "local-user",
+ Contents = [new TextContent(text)],
+ ReceivedAt = _timeProvider.GetUtcNow()
+ });
+ }
+
+ public void RequestAppShutdown()
+ {
+ Shutdown();
+ }
+
+ public override void Dispose()
+ {
+ _outputSubject.Dispose();
+ if (_session is not null)
+ {
+ _ = _session.DisposeAsync();
+ }
+
+ DisposeReactiveFields();
+ base.Dispose();
+ }
+}
diff --git a/src/Netclaw.App/Tui/ElapsedTimeSegment.cs b/src/Netclaw.App/Tui/ElapsedTimeSegment.cs
new file mode 100644
index 000000000..1118e648f
--- /dev/null
+++ b/src/Netclaw.App/Tui/ElapsedTimeSegment.cs
@@ -0,0 +1,74 @@
+using System.Diagnostics;
+using System.Reactive;
+using System.Reactive.Linq;
+using System.Reactive.Subjects;
+using Termina.Components.Streaming;
+using Termina.Rendering;
+using Termina.Terminal;
+
+namespace Netclaw.App.Tui;
+
+///
+/// Animated text segment that displays elapsed time since creation.
+/// Updates every second: "0s", "1s", ..., "1m 5s", etc.
+/// Used alongside in tool call progress display.
+///
+public sealed class ElapsedTimeSegment : IAnimatedTextSegment
+{
+ private readonly Stopwatch _stopwatch = Stopwatch.StartNew();
+ private readonly System.Timers.Timer _timer;
+ private readonly Subject _invalidated = new();
+ private readonly TextStyle _style;
+ private bool _disposed;
+
+ public IObservable Invalidated => _invalidated.AsObservable();
+
+ public bool IsAnimating => _timer.Enabled;
+
+ ///
+ /// The current elapsed time. Read this before disposing to capture the final value.
+ ///
+ public TimeSpan Elapsed => _stopwatch.Elapsed;
+
+ public ElapsedTimeSegment(Color? color = null, int intervalMs = 1000)
+ {
+ _style = new TextStyle(color ?? Color.BrightBlack);
+ _timer = new System.Timers.Timer(intervalMs);
+ _timer.Elapsed += OnTick;
+ _timer.AutoReset = true;
+ Start();
+ }
+
+ public StyledSegment GetCurrentSegment()
+ {
+ var elapsed = _stopwatch.Elapsed;
+ var text = elapsed.TotalSeconds < 60
+ ? $" {elapsed.TotalSeconds:F0}s"
+ : $" {(int)elapsed.TotalMinutes}m {elapsed.Seconds}s";
+ return new StyledSegment(text, _style);
+ }
+
+ public void Start()
+ {
+ if (!_disposed)
+ _timer.Start();
+ }
+
+ public void Stop() => _timer.Stop();
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ Stop();
+ _timer.Dispose();
+ _invalidated.OnCompleted();
+ _invalidated.Dispose();
+ }
+
+ private void OnTick(object? sender, System.Timers.ElapsedEventArgs e)
+ {
+ if (!_disposed)
+ _invalidated.OnNext(Unit.Default);
+ }
+}