diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7ca383a..824c9c2 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,6 +25,12 @@ jobs:
run: dotnet restore SharpClawCode.sln
- name: Build
run: dotnet build SharpClawCode.sln --no-restore --configuration Release
+ - name: Build examples
+ run: |
+ dotnet build examples/WebApiAgent/WebApiAgent.csproj --no-restore --configuration Release
+ dotnet build examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj --no-restore --configuration Release
+ dotnet build examples/WorkerServiceHost/WorkerServiceHost.csproj --no-restore --configuration Release
+ dotnet build examples/McpToolAgent/McpToolAgent.csproj --no-restore --configuration Release
- name: Test
run: dotnet test SharpClawCode.sln --no-build --configuration Release --collect:"XPlat Code Coverage" --results-directory ./coverage
- name: Upload coverage
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 09e29fc..3027c60 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -16,14 +16,18 @@
+
+
+
+
-
\ No newline at end of file
+
diff --git a/README.md b/README.md
index 4a74792..02b732c 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,8 @@ SharpClaw Code is a C# and .NET-native coding agent runtime for teams building A
It combines durable sessions, permission-aware tool execution, provider abstraction, structured telemetry, and an automation-friendly command-line surface in a runtime shaped for real .NET systems: explicit, testable, and operationally legible.
+The repository now ships both a terminal-first agent runtime and an embeddable host SDK through `SharpClaw.Code`, which makes it viable for standalone CLIs, local editor backends, and tenant-aware embedded services.
+
## What It Is
SharpClaw Code is an open-source runtime for building and operating coding-agent experiences in the .NET ecosystem.
@@ -65,6 +67,21 @@ dotnet run --project src/SharpClaw.Code.Cli -- prompt "Summarize this workspace"
dotnet run --project src/SharpClaw.Code.Cli -- doctor
dotnet run --project src/SharpClaw.Code.Cli -- status
+# Refresh and query the workspace knowledge index
+dotnet run --project src/SharpClaw.Code.Cli -- index refresh
+dotnet run --project src/SharpClaw.Code.Cli -- index query WidgetService
+
+# Save and inspect durable memory
+dotnet run --project src/SharpClaw.Code.Cli -- memory save --scope project "Keep prompts concise"
+dotnet run --project src/SharpClaw.Code.Cli -- memory list --scope project
+
+# Inspect metering summaries and details
+dotnet run --project src/SharpClaw.Code.Cli -- usage summary
+dotnet run --project src/SharpClaw.Code.Cli -- usage detail --limit 25
+
+# Manage packaged tool bundles
+dotnet run --project src/SharpClaw.Code.Cli -- tool-packages list
+
# Emit machine-readable output
dotnet run --project src/SharpClaw.Code.Cli -- --output-format json doctor
```
@@ -73,6 +90,8 @@ Built-in REPL slash commands include `/help`, `/status`, `/doctor`, `/session`,
Parity-oriented commands now include:
+- `index` / `/index`
+- `memory` / `/memory`
- `models` / `/models`
- `usage` / `/usage`
- `cost` / `/cost`
@@ -101,12 +120,16 @@ Primary workflow modes:
| Durable sessions | Persist conversation state, turn history, checkpoints, and recovery metadata for longer-running agent work |
| Permission-aware tools | Route file, shell, and plugin-backed actions through explicit policy and approval decisions |
| Provider abstraction | Run against Anthropic and OpenAI-compatible backends through a typed runtime surface |
+| Local runtime catalog | Surface Ollama, llama.cpp, and other OpenAI-compatible profiles with health, model discovery, and embedding defaults |
| MCP support | Register, supervise, and integrate MCP servers with explicit lifecycle state |
| Plugins and skills | Extend the runtime with trusted plugin manifests and discoverable workspace skills |
+| Workspace knowledge | Build a durable local index for lexical, symbol, and semantic workspace search |
+| Cross-session memory | Persist project and user memory so later sessions can recall repo-specific guidance and user preferences |
| Structured telemetry | Emit runtime events and usage signals that support diagnostics, replay, and automation |
+| Enterprise host controls | Add tenant-aware storage, authenticated approvals, admin APIs, and usage metering for embedded deployments |
| JSON-friendly CLI | Use the same runtime through human-readable terminal flows or machine-readable command output |
| Spec workflow mode | Turn prompts into structured requirements, technical design, and task documents for feature proposals |
-| Embedded local server | Expose prompt, session, status, doctor, and share endpoints for editor or automation clients |
+| Embedded SDK + server | Host the runtime via `SharpClaw.Code` or expose prompt, session, admin, and SSE endpoints for editor or automation clients |
| Config + agent catalog | Layer user/workspace JSONC config with typed agent defaults, tool allowlists, and runtime hooks |
| Session sharing | Create self-hosted share links and durable sanitized share snapshots under `.sharpclaw/` |
| Diagnostics context | Surface configured diagnostics sources into prompt context, status, and machine-readable output |
@@ -123,6 +146,7 @@ Primary workflow modes:
| Area | Project(s) |
|---|---|
+| Embeddable SDK | `SharpClaw.Code` |
| CLI and command handlers | `SharpClaw.Code.Cli`, `SharpClaw.Code.Commands` |
| Core contracts | `SharpClaw.Code.Protocol` |
| Runtime orchestration | `SharpClaw.Code.Runtime` |
@@ -135,12 +159,27 @@ Primary workflow modes:
For dependency boundaries and project responsibilities, see [docs/architecture.md](docs/architecture.md) and [AGENTS.md](AGENTS.md).
+## Example Hosts
+
+The solution includes embeddable host samples under `examples/`:
+
+- `MinimalConsoleAgent` for direct SDK prompt execution
+- `WebApiAgent` for an HTTP-hosted runtime surface
+- `WorkerServiceHost` for lifecycle-managed background hosting
+- `McpToolAgent` for MCP-aware host composition
+
## Testing
```bash
# Run all tests
dotnet test SharpClawCode.sln
+# Build example hosts as part of local validation
+dotnet build examples/WebApiAgent/WebApiAgent.csproj
+dotnet build examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj
+dotnet build examples/WorkerServiceHost/WorkerServiceHost.csproj
+dotnet build examples/McpToolAgent/McpToolAgent.csproj
+
# Run a single test by name
dotnet test SharpClawCode.sln --filter "FullyQualifiedName~YourTestName"
@@ -167,8 +206,12 @@ dotnet test SharpClawCode.sln --filter "FullyQualifiedName~ParityScenarioTests"
| `--primary-mode ` | Workflow bias for prompts: `build`, `plan`, or `spec` |
| `--session ` | Reuse a specific SharpClaw session id for prompt execution |
| `--agent ` | Select the active agent for prompt execution |
+| `--host-id ` | Stable embedded-host identifier for metering, admin, and event envelopes |
+| `--tenant-id ` | Tenant identifier used by host-aware storage, approvals, and metering |
+| `--storage-root ` | External root for host-managed durable runtime state |
+| `--session-store fileSystem\|sqlite` | Select the embedded session/event storage backend |
-Subcommands include `prompt`, `repl`, `doctor`, `status`, `session`, `models`, `usage`, `cost`, `stats`, `connect`, `hooks`, `skills`, `agents`, `todo`, `share`, `unshare`, `compact`, `serve`, `commands`, `mcp`, `plugins`, `acp`, `bridge`, and `version`.
+Subcommands include `prompt`, `repl`, `doctor`, `status`, `session`, `index`, `memory`, `models`, `usage`, `cost`, `stats`, `connect`, `hooks`, `skills`, `agents`, `todo`, `share`, `unshare`, `compact`, `serve`, `commands`, `mcp`, `plugins`, `tool-packages`, `acp`, `bridge`, and `version`.
## Documentation Map
@@ -205,9 +248,9 @@ Key runtime configuration sections:
|---|---|
| `SharpClaw:Providers:Catalog` | Default provider, model aliases |
| `SharpClaw:Providers:Anthropic` | Anthropic API key, base URL, default model |
-| `SharpClaw:Providers:OpenAiCompatible` | OpenAI-compatible API key, base URL, default model |
+| `SharpClaw:Providers:OpenAiCompatible` | OpenAI-compatible base settings plus local runtime profiles, auth mode, and default embedding model |
| `SharpClaw:Web` | Web search provider name, endpoint template, user agent |
-| `SharpClaw:Telemetry` | Runtime event ring buffer capacity |
+| `SharpClaw:Telemetry` | Runtime event ring buffer capacity plus webhook event export behavior |
Key `sharpclaw.jsonc` capabilities:
@@ -226,8 +269,13 @@ All options are validated at startup via `IValidateOptions` implementations.
- The shared tooling layer is permission-aware across the runtime.
- The current runtime includes multi-turn provider-backed tool execution, session-backed prompt replay, and durable conversation history.
- Agent-driven tool calls flow through the same approval and allowlist enforcement path used by direct tool execution, including caller-aware interactive approval behavior.
+- Workspace indexing, symbol search, and durable memory are available through both CLI commands and built-in tools.
+- ACP now carries editor context, approval round-trips, model catalog queries, workspace search/index actions, and memory actions, which is enough for a real VS Code client over a single transport.
+- OpenAI-compatible local runtime profiles can surface Ollama, llama.cpp, and similar endpoints with profile-aware auth and model discovery.
+- Embedded hosts can opt into trusted-header or OIDC-backed approval identity, tenant-aware usage metering, webhook/SSE event streaming, and admin APIs for provider catalog, index status, search, memory inspection, and tool package management.
+- The CLI mirrors those enterprise surfaces with `usage summary`, `usage detail`, and `tool-packages` commands while preserving the existing workspace-local `usage`, `cost`, and `stats` flows.
- Operational commands support stable JSON output via `--output-format json`, which makes them suitable for scripts, editors, and automation.
-- The embedded server exposes local JSON and SSE endpoints for prompts, sessions, sharing, status, and doctor flows.
+- The embedded server exposes local JSON and SSE endpoints for prompts, sessions, admin control, metering, and event streaming.
## Contributing
@@ -238,6 +286,10 @@ Before opening a PR:
```bash
dotnet build SharpClawCode.sln
dotnet test SharpClawCode.sln
+dotnet build examples/WebApiAgent/WebApiAgent.csproj
+dotnet build examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj
+dotnet build examples/WorkerServiceHost/WorkerServiceHost.csproj
+dotnet build examples/McpToolAgent/McpToolAgent.csproj
```
## License
diff --git a/SharpClawCode.sln b/SharpClawCode.sln
index 48e8b64..41659ce 100644
--- a/SharpClawCode.sln
+++ b/SharpClawCode.sln
@@ -53,6 +53,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpClaw.Code.Acp", "src\S
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpClaw.Code.Mcp.FixtureServer", "tests\SharpClaw.Code.Mcp.FixtureServer\SharpClaw.Code.Mcp.FixtureServer.csproj", "{7F55705B-1E53-4075-AB6F-3BA1BDD2CF85}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpClaw.Code", "src\SharpClaw.Code\SharpClaw.Code.csproj", "{8552440E-B169-4CD9-9B52-4BFFDDADF053}"
+EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "examples", "examples", "{B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "McpToolAgent", "examples\McpToolAgent\McpToolAgent.csproj", "{97EC01C1-A53A-475B-9364-0BD79E9CE272}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApiAgent", "examples\WebApiAgent\WebApiAgent.csproj", "{963C636F-2096-45B1-8101-B8345967F197}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MinimalConsoleAgent", "examples\MinimalConsoleAgent\MinimalConsoleAgent.csproj", "{7BA2E64A-B330-4783-9330-AEF46B91929A}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkerServiceHost", "examples\WorkerServiceHost\WorkerServiceHost.csproj", "{2E8A9F4F-8161-4E49-9F04-533D972C11CB}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -339,6 +351,66 @@ Global
{7F55705B-1E53-4075-AB6F-3BA1BDD2CF85}.Release|x64.Build.0 = Release|Any CPU
{7F55705B-1E53-4075-AB6F-3BA1BDD2CF85}.Release|x86.ActiveCfg = Release|Any CPU
{7F55705B-1E53-4075-AB6F-3BA1BDD2CF85}.Release|x86.Build.0 = Release|Any CPU
+ {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Debug|x64.Build.0 = Debug|Any CPU
+ {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Debug|x86.Build.0 = Debug|Any CPU
+ {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Release|x64.ActiveCfg = Release|Any CPU
+ {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Release|x64.Build.0 = Release|Any CPU
+ {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Release|x86.ActiveCfg = Release|Any CPU
+ {8552440E-B169-4CD9-9B52-4BFFDDADF053}.Release|x86.Build.0 = Release|Any CPU
+ {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Debug|x64.Build.0 = Debug|Any CPU
+ {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Debug|x86.Build.0 = Debug|Any CPU
+ {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Release|Any CPU.Build.0 = Release|Any CPU
+ {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Release|x64.ActiveCfg = Release|Any CPU
+ {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Release|x64.Build.0 = Release|Any CPU
+ {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Release|x86.ActiveCfg = Release|Any CPU
+ {97EC01C1-A53A-475B-9364-0BD79E9CE272}.Release|x86.Build.0 = Release|Any CPU
+ {963C636F-2096-45B1-8101-B8345967F197}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {963C636F-2096-45B1-8101-B8345967F197}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {963C636F-2096-45B1-8101-B8345967F197}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {963C636F-2096-45B1-8101-B8345967F197}.Debug|x64.Build.0 = Debug|Any CPU
+ {963C636F-2096-45B1-8101-B8345967F197}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {963C636F-2096-45B1-8101-B8345967F197}.Debug|x86.Build.0 = Debug|Any CPU
+ {963C636F-2096-45B1-8101-B8345967F197}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {963C636F-2096-45B1-8101-B8345967F197}.Release|Any CPU.Build.0 = Release|Any CPU
+ {963C636F-2096-45B1-8101-B8345967F197}.Release|x64.ActiveCfg = Release|Any CPU
+ {963C636F-2096-45B1-8101-B8345967F197}.Release|x64.Build.0 = Release|Any CPU
+ {963C636F-2096-45B1-8101-B8345967F197}.Release|x86.ActiveCfg = Release|Any CPU
+ {963C636F-2096-45B1-8101-B8345967F197}.Release|x86.Build.0 = Release|Any CPU
+ {7BA2E64A-B330-4783-9330-AEF46B91929A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {7BA2E64A-B330-4783-9330-AEF46B91929A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {7BA2E64A-B330-4783-9330-AEF46B91929A}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {7BA2E64A-B330-4783-9330-AEF46B91929A}.Debug|x64.Build.0 = Debug|Any CPU
+ {7BA2E64A-B330-4783-9330-AEF46B91929A}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {7BA2E64A-B330-4783-9330-AEF46B91929A}.Debug|x86.Build.0 = Debug|Any CPU
+ {7BA2E64A-B330-4783-9330-AEF46B91929A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {7BA2E64A-B330-4783-9330-AEF46B91929A}.Release|Any CPU.Build.0 = Release|Any CPU
+ {7BA2E64A-B330-4783-9330-AEF46B91929A}.Release|x64.ActiveCfg = Release|Any CPU
+ {7BA2E64A-B330-4783-9330-AEF46B91929A}.Release|x64.Build.0 = Release|Any CPU
+ {7BA2E64A-B330-4783-9330-AEF46B91929A}.Release|x86.ActiveCfg = Release|Any CPU
+ {7BA2E64A-B330-4783-9330-AEF46B91929A}.Release|x86.Build.0 = Release|Any CPU
+ {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Debug|x64.Build.0 = Debug|Any CPU
+ {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Debug|x86.Build.0 = Debug|Any CPU
+ {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Release|x64.ActiveCfg = Release|Any CPU
+ {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Release|x64.Build.0 = Release|Any CPU
+ {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Release|x86.ActiveCfg = Release|Any CPU
+ {2E8A9F4F-8161-4E49-9F04-533D972C11CB}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -367,5 +439,10 @@ Global
{5F0ED186-7920-4A49-B0A3-75F84B4215B3} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
{0060F8FF-0714-4C01-936F-719D7E5F124D} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
{7F55705B-1E53-4075-AB6F-3BA1BDD2CF85} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
+ {8552440E-B169-4CD9-9B52-4BFFDDADF053} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
+ {97EC01C1-A53A-475B-9364-0BD79E9CE272} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}
+ {963C636F-2096-45B1-8101-B8345967F197} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}
+ {7BA2E64A-B330-4783-9330-AEF46B91929A} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}
+ {2E8A9F4F-8161-4E49-9F04-533D972C11CB} = {B36A84DF-456D-A817-6EDD-3EC3E7F6E11F}
EndGlobalSection
EndGlobal
diff --git a/docs/acp.md b/docs/acp.md
index 1ce5067..3f248c9 100644
--- a/docs/acp.md
+++ b/docs/acp.md
@@ -17,18 +17,42 @@ Reads **one JSON-RPC request per line** from stdin; writes **one response object
| `initialize` | Returns `protocolVersion`, `agentCapabilities`, and `serverInfo`. |
| `session/new` | Creates a session; updates workspace attachment when applicable. |
| `session/load` | Loads an existing session id. |
-| `session/prompt` | Runs a turn via **`IConversationRuntime`**; may stream **`session/notification`** lines with chunks before the final result. |
+| `session/prompt` | Runs a turn via **`IConversationRuntime`**; accepts `cwd`, `sessionId`, `prompt`, optional `model`, and optional `editorContext`. |
+| `models/list` | Returns the provider catalog, including discovered local runtime profiles and models. |
+| `workspace/index/refresh` | Refreshes the durable workspace knowledge index for `cwd`. |
+| `workspace/search` | Executes hybrid workspace search against indexed files, symbols, and semantic chunks. |
+| `memory/list` | Lists structured project/user memory entries. |
+| `memory/save` | Saves a structured memory entry. |
+| `memory/delete` | Deletes a structured memory entry. |
+| `approval/respond` | Resolves a pending approval request emitted by the ACP host. |
## Capabilities / limits
-The host advertises **`loadSession: true`** and **`promptCapabilities.embeddedContext: true`**; **`image`** and **`audio`** are **`false`**.
+The host advertises:
-**Intentionally unsupported** (errors or omissions vs full vendor ACP): streaming tool execution, interactive permission UI, rich media parts, MCP hot-plug, cancellation reliability guarantees, and non-core extensions. Callers should treat unknown methods as **unsupported** (JSON-RPC error).
+- `loadSession: true`
+- `approvalRequests: true`
+- `models: true`
+- `workspaceSearch: true`
+- `workspaceIndex: true`
+- `memory: true`
+- `promptCapabilities.embeddedContext: true`
+
+`image` and `audio` remain `false`.
+
+Notifications use `session/notification` and currently include:
+
+- streamed assistant text chunks (`sessionUpdate = "agentMessageChunk"`)
+- approval prompts (`sessionUpdate = "approvalRequest"`)
+
+**Intentionally unsupported** (errors or omissions vs full vendor ACP): streaming tool execution details, rich media parts, MCP hot-plug, cancellation reliability guarantees, and non-core extensions. Callers should treat unknown methods as **unsupported** (JSON-RPC error).
## Session attachment
Behavior aligns with **`IWorkspaceSessionAttachmentStore`** and **`RunPromptRequest.SessionId`**: create/load sets attachment; prompts resolve cwd via params when provided.
+When the client advertises approval support during `initialize`, ACP-driven prompts can participate in the same permission and approval flow as interactive CLI callers without opening a second transport.
+
## Implementation
See **`SharpClaw.Code.Acp`** / **`AcpStdioHost`**.
diff --git a/docs/architecture.md b/docs/architecture.md
index 180086c..34cafd1 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -19,16 +19,18 @@ This document matches the **current** solution: `SharpClawCode.sln` with project
| **SharpClaw.Code.Memory / Git / Web / Skills** | Context and auxiliary services composed by runtime/agents as implemented today. |
| **SharpClaw.Code.Agents** | Microsoft Agent Framework bridge, `ProviderBackedAgentKernel`, concrete agents. |
| **SharpClaw.Code.Runtime** | `ConversationRuntime`, `DefaultTurnRunner`, lifecycle/state machine, operational diagnostics DI. |
+| **SharpClaw.Code** | Embeddable runtime SDK: `SharpClawRuntimeHostBuilder`, `SharpClawRuntimeHost`, host-aware runtime entrypoints. |
| **SharpClaw.Code.Commands** | System.CommandLine handlers, REPL host, slash commands, output renderers dispatch. |
| **SharpClaw.Code.Cli** | Entry point (`Program.cs`), `Host` wiring: `AddSharpClawRuntime` + `AddSharpClawCli`. |
-Test projects: **UnitTests**, **IntegrationTests**, **MockProvider**, **ParityHarness**, **Mcp.FixtureServer**.
+Test projects: **UnitTests**, **IntegrationTests**, **MockProvider**, **ParityHarness**, **Mcp.FixtureServer**. Example hosts are included in the solution under `examples/`.
## Composition overview
1. **`CliHostBuilder.BuildHost`** builds `Host.CreateApplicationBuilder`, registers **Runtime** then **CLI**.
-2. **`RuntimeServiceCollectionExtensions.AddSharpClawRuntime`** (with `IConfiguration` when used from CLI) registers in order: Telemetry, Infrastructure, **Providers** (from config), **Mcp**, **Tools**, **Agents**, Memory, Skills, Git, **Sessions** stores, context assembler, **DefaultTurnRunner**, state machine, **operational diagnostics checks**, **`ConversationRuntime`** (as `IConversationRuntime` + `IRuntimeCommandService`), and a minimal **`IHostedService`** (`RuntimeCoordinatorHostedServiceAdapter`).
-3. **`AddSharpClawCli`** registers command handlers, REPL, renderers, `CommandRegistry`.
+2. **`SharpClawRuntimeHostBuilder`** builds the same runtime slice without CLI assumptions for embedded ASP.NET Core, worker-service, or SDK hosts.
+3. **`RuntimeServiceCollectionExtensions.AddSharpClawRuntime`** (with `IConfiguration` when used from CLI) registers in order: Telemetry, Infrastructure, **Providers** (from config), **Mcp**, **Tools**, **Agents**, Memory, Skills, Git, **Sessions** stores, context assembler, **DefaultTurnRunner**, state machine, **operational diagnostics checks**, **`ConversationRuntime`** (as `IConversationRuntime` + `IRuntimeCommandService`), and a minimal **`IHostedService`** (`RuntimeCoordinatorHostedServiceAdapter`).
+4. **`AddSharpClawCli`** registers command handlers, REPL, renderers, `CommandRegistry`.
## Major execution flows
@@ -36,7 +38,7 @@ Test projects: **UnitTests**, **IntegrationTests**, **MockProvider**, **ParityHa
1. **`IRuntimeCommandService.ExecutePromptAsync`** → **`ConversationRuntime.RunPromptAsync`**.
2. Resolves or creates **`ConversationSession`** under the workspace; transitions lifecycle; appends **`RuntimeEvent`**s when persistence is enabled.
-3. **`DefaultTurnRunner.RunAsync`** builds prompt context (`IPromptContextAssembler`), constructs **`AgentRunContext`** (includes **`IToolExecutor`**), calls **`PrimaryCodingAgent.RunAsync`**.
+3. **`DefaultTurnRunner.RunAsync`** builds prompt context (`IPromptContextAssembler`), constructs **`AgentRunContext`** (includes **`IToolExecutor`** and normalized host/tenant context), calls **`PrimaryCodingAgent.RunAsync`**.
4. **`SharpClawAgentBase`** delegates to **`AgentFrameworkBridge.RunAsync`**, which drives **`ProviderBackedAgentKernel`** (streaming `IModelProvider`, auth checks, **`ProviderExecutionException`** on hard failures).
5. Turn completion updates session, checkpoints as implemented in **`ConversationRuntime`**, publishes events via **`IRuntimeEventPublisher`**.
diff --git a/docs/permissions.md b/docs/permissions.md
index 9ba3475..7872a87 100644
--- a/docs/permissions.md
+++ b/docs/permissions.md
@@ -35,11 +35,27 @@ If all rules abstain, **`EvaluateByModeAsync`** applies mode defaults (e.g. **Re
**`ConsoleApprovalService`** prints the tool name, scope, prompt, optional “may be remembered” line, and waits for **`y`/`yes`**.
+### Authenticated approval transports
+
+Embedded hosts can enable approval identity independently from provider auth. The current runtime supports:
+
+- **trusted-header** mode, where an upstream host supplies subject, tenant, role, and scope headers
+- **OIDC** mode, where the embedded/admin HTTP surface validates a bearer token against discovery + JWKS metadata
+
+`ConfiguredApprovalIdentityService` resolves the current `ApprovalPrincipal`, and `AuthenticatedApprovalTransport` approves or denies requests before the console/non-interactive transports are considered.
+
+Two host flags matter:
+
+- `RequireForAdmin`: admin routes must present a valid approval identity
+- `RequireAuthenticatedApprovals`: approval-required operations are denied when no valid approval identity is present, even if the caller is otherwise interactive
+
+Authenticated approvals are tenant-bound. If the runtime host context carries `TenantId`, an approval principal with a different tenant is denied before any remembered approval or console fallback path is used.
+
### Remembered approvals
**`ISessionApprovalMemory`** (**`SessionApprovalMemory`**) stores **approved** decisions in a **process-scoped** dictionary keyed by **`sessionId`** and a composite key (**tool name, scope, source, working directory, originating plugin id/trust**).
-When a rule returns **`RequireApproval`** with **`CanRememberApproval`**, an approved outcome may be **`Store`**d and reused via **`TryGet`**.
+When a rule returns **`RequireApproval`** with **`CanRememberApproval`**, an approved outcome may be **`Store`**d and reused via **`TryGet`**. In embedded-host flows, the remembered approval remains scoped to the current session and tenant context.
## Tool execution context
diff --git a/docs/providers.md b/docs/providers.md
index 6d7a7f7..bf42ad6 100644
--- a/docs/providers.md
+++ b/docs/providers.md
@@ -13,12 +13,20 @@ Registered implementations (see **`ProvidersServiceCollectionExtensions`**):
Both are registered as **`IModelProvider`** singletons; **`ModelProviderResolver`** builds a case-insensitive dictionary by **`ProviderName`**.
+The provider layer also exposes **`IProviderCatalogService`**, which powers the CLI `models` command and ACP `models/list`. It centralizes:
+
+- provider auth status
+- alias/default resolution
+- discovered local runtime profiles
+- model discovery for local runtimes
+
## Resolution and preflight
**`ProviderRequestPreflight`** (`IProviderRequestPreflight`) normalizes **`ProviderRequest`**:
- Applies **`ProviderCatalogOptions.ModelAliases`** (e.g. `"default"` → provider + model id).
- Supports qualified model forms (implementation parses `provider/model`).
+- Also supports local runtime forms such as `ollama/qwen2.5-coder`, which route through the OpenAI-compatible provider with profile metadata attached.
- Fills default **provider name** from **`ProviderCatalogOptions.DefaultProvider`** when missing.
Default catalog (**`ProviderCatalogOptions`**) uses **`DefaultProvider = "openai-compatible"`** if not configured.
@@ -31,14 +39,31 @@ When using **`AddSharpClawRuntime(IConfiguration)`** (CLI host):
|---------|----------------|
| `SharpClaw:Providers:Catalog` | **`ProviderCatalogOptions`** |
| `SharpClaw:Providers:Anthropic` | **`AnthropicProviderOptions`** (`ProviderName` defaults to `"anthropic"`, `BaseUrl`, API key binding as in options class) |
-| `SharpClaw:Providers:OpenAiCompatible` | **`OpenAiCompatibleProviderOptions`** (`ProviderName` defaults to `"openai-compatible"`) |
+| `SharpClaw:Providers:OpenAiCompatible` | **`OpenAiCompatibleProviderOptions`** (`ProviderName` defaults to `"openai-compatible"`, supports auth mode, default embedding model, and named `LocalRuntimes`) |
There is no checked-in **`appsettings.json`** in the repo; add one next to the CLI project or rely on environment variables / user secrets per standard .NET configuration.
+## Local runtimes
+
+`OpenAiCompatibleProviderOptions.LocalRuntimes` supports named profiles for local or self-hosted runtimes such as Ollama and llama.cpp.
+
+Each profile carries:
+
+- runtime kind (`Generic`, `Ollama`, `LlamaCpp`)
+- base URL
+- default chat model
+- optional default embedding model
+- auth mode (`ApiKey`, `Optional`, `None`)
+- capability hints for tool calling and embeddings
+
+At runtime the catalog service probes these profiles and surfaces health plus discovered models. Local runtimes do not assume API-key auth by default.
+
## Auth
**`IAuthFlowService`** / **`AuthFlowService`** answer whether a provider name is authenticated (used by **`ProviderBackedAgentKernel`**). If not authenticated, the kernel may return a **placeholder** completion (see kernel logs) rather than calling the remote API.
+For the OpenAI-compatible provider, auth status now respects provider auth mode plus any configured auth-optional local runtimes.
+
Hard failures use **`ProviderExecutionException`** with **`ProviderFailureKind`**: **`MissingProvider`**, **`AuthenticationUnavailable`**, **`StreamFailed`**.
## Adding a provider
diff --git a/docs/runtime.md b/docs/runtime.md
index f819ddb..90b7f66 100644
--- a/docs/runtime.md
+++ b/docs/runtime.md
@@ -42,7 +42,7 @@ This means the model-visible tool surface and the executor-visible tool surface
## Context assembly
-**`PromptContextAssembler`** pulls workspace/session-aware data (skills registry, todo state, memory hooks, git context as wired today) into the prompt path before the agent runs.
+**`PromptContextAssembler`** pulls workspace/session-aware data (skills registry, todo state, durable project/user memory, workspace index status, and git context) into the prompt path before the agent runs.
It also includes a compact diagnostics summary from **`IWorkspaceDiagnosticsService`**, which currently surfaces configured diagnostics sources and build-derived findings for .NET workspaces.
@@ -55,6 +55,14 @@ When the effective **`PrimaryMode`** is **`Spec`**, the assembler appends a stru
Conversation history is rebuilt from persisted session events and truncated by token budget before being attached to the next provider request. Assistant history prefers the persisted final turn output and only falls back to streamed provider deltas when needed.
+Cross-session memory is sourced from:
+
+- manual project instructions such as `SHARPCLAW.md`
+- structured project memory stored under `.sharpclaw/knowledge/knowledge.db`
+- structured user memory stored under the SharpClaw user root
+
+The runtime injects only compact recall text and index freshness metadata. Detailed retrieval happens through explicit workspace-search tools and ACP/CLI search calls.
+
## Spec workflow
**`ISpecWorkflowService`** handles the post-processing path for **`spec`** mode:
@@ -73,7 +81,7 @@ Each spec-mode prompt creates a fresh folder. If the same slug already exists, t
**`OperationalDiagnosticsCoordinator`** runs injectable **`IOperationalCheck`** implementations:
-- Workspace, configuration, session store, shell, git, provider auth, MCP registry/host, plugin registry.
+- Workspace, configuration, session store, shell, git, provider auth, local runtime catalog health, MCP registry/host, plugin registry.
Used by **`GetStatusAsync`**, **`RunDoctorAsync`**, and **`InspectSessionAsync`** to build **Protocol** reports (`DoctorReport`, `RuntimeStatusReport`, `SessionInspectionReport`).
@@ -90,6 +98,7 @@ The parity layer adds several runtime-owned services:
- **`ISharpClawConfigService`** — loads user/workspace `config.jsonc` + `sharpclaw.jsonc` and merges them by precedence
- **`IAgentCatalogService`** — overlays configured specialist agents on top of built-in agents
- **`IConversationCompactionService`** — creates durable session summaries stored in session metadata
+- compaction also promotes a project-scoped summary memory entry for later session recall
- **`IShareSessionService`** — creates and removes self-hosted share snapshots
- **`IHookDispatcher`** — executes configured hook processes for turn/tool/share/server events and exposes hook inspection/testing
- **`ITodoService`** — persists session and workspace todo items under session metadata and `.sharpclaw/tasks.json`
@@ -112,6 +121,31 @@ These services are intentionally small and runtime-owned rather than separate or
Prompt requests can return JSON or replay the completed runtime event stream as SSE.
+The embedded server also exposes a tenant-aware admin/control surface under `/v1/admin`:
+
+- `GET /v1/admin/providers`
+- `GET /v1/admin/auth/status`
+- `POST /v1/admin/sessions`
+- `POST /v1/admin/sessions/{id}/fork`
+- `GET /v1/admin/index/status`
+- `POST /v1/admin/index/refresh`
+- `POST /v1/admin/search`
+- `GET /v1/admin/memory`
+- `GET /v1/admin/events/recent`
+- `GET /v1/admin/events/stream`
+- `GET /v1/admin/tool-packages`
+- `POST /v1/admin/tool-packages/install`
+- `GET /v1/admin/usage/summary`
+- `GET /v1/admin/usage/detail`
+
+Admin requests reuse the normalized `RuntimeCommandContext.HostContext`, so tenant id, host id, storage root, and selected session store backend remain consistent between CLI, SDK, and HTTP-hosted invocations.
+
+Usage metering is persisted in a dedicated tenant-aware SQLite store under the resolved storage root. Workspace-local `usage`, `cost`, and `stats` continue to read the existing workspace insights model, while the admin and enterprise CLI surfaces query the normalized metering store for tenant/host reporting.
+
+When approval auth is enabled, HTTP/admin requests resolve approval identity through either trusted headers or OIDC bearer tokens before approval-sensitive operations run. The resolved `ApprovalPrincipal` is then applied to authenticated approval transports and tenant-matching checks.
+
+Webhook and SSE event delivery both use the same `RuntimeEventEnvelope` shape, which freezes the external event contract across in-process streams, HTTP event streams, and configured outbound webhook sinks.
+
## Hosted service
**`RuntimeCoordinatorHostedServiceAdapter`** is registered as **`IHostedService`** and currently logs start/stop only (placeholder for future lifecycle coordination).
diff --git a/docs/testing.md b/docs/testing.md
index 2dd1840..e5174c3 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -15,6 +15,15 @@ Run all tests:
dotnet test SharpClawCode.sln
```
+Build the example hosts as part of normal validation:
+
+```bash
+dotnet build examples/WebApiAgent/WebApiAgent.csproj
+dotnet build examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj
+dotnet build examples/WorkerServiceHost/WorkerServiceHost.csproj
+dotnet build examples/McpToolAgent/McpToolAgent.csproj
+```
+
Filter examples:
```bash
@@ -52,4 +61,4 @@ Stable scenario **ids** are listed in **`ParityScenarioIds`** (e.g. `streaming_t
## CI
-Use **`dotnet test`** on the solution; parity tests use temp directories under **`Path.GetTempPath()`** and avoid network.
+CI restores and builds the full solution, explicitly builds every example host project, and then runs `dotnet test` on the solution. Parity tests use temp directories under **`Path.GetTempPath()`** and avoid network.
diff --git a/examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj b/examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj
index 09f2de4..85a15e8 100644
--- a/examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj
+++ b/examples/MinimalConsoleAgent/MinimalConsoleAgent.csproj
@@ -10,7 +10,7 @@
-
+
diff --git a/examples/MinimalConsoleAgent/Program.cs b/examples/MinimalConsoleAgent/Program.cs
index 8d53747..377ea2f 100644
--- a/examples/MinimalConsoleAgent/Program.cs
+++ b/examples/MinimalConsoleAgent/Program.cs
@@ -1,9 +1,6 @@
-using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Hosting;
-using SharpClaw.Code.Protocol.Commands;
+using SharpClaw.Code;
using SharpClaw.Code.Protocol.Enums;
-using SharpClaw.Code.Runtime.Abstractions;
-using SharpClaw.Code.Runtime.Composition;
+using SharpClaw.Code.Protocol.Models;
if (args.Length == 0)
{
@@ -13,32 +10,31 @@
var prompt = string.Join(' ', args);
-var builder = Host.CreateApplicationBuilder(args);
-builder.Services.AddSharpClawRuntime(builder.Configuration);
-
-using var host = builder.Build();
-await host.StartAsync();
-
-var runtime = host.Services.GetRequiredService();
+await using var runtimeHost = new SharpClawRuntimeHostBuilder(args).Build();
+await runtimeHost.StartAsync();
var workspacePath = Directory.GetCurrentDirectory();
-var session = await runtime.CreateSessionAsync(
+var hostContext = new RuntimeHostContext(
+ HostId: "minimal-console-agent",
+ IsEmbeddedHost: true);
+var session = await runtimeHost.CreateSessionAsync(
workspacePath,
PermissionMode.ReadOnly,
OutputFormat.Text,
+ hostContext,
CancellationToken.None);
-var request = new RunPromptRequest(
- Prompt: prompt,
- SessionId: session.Id,
- WorkingDirectory: workspacePath,
- PermissionMode: PermissionMode.ReadOnly,
- OutputFormat: OutputFormat.Text,
- Metadata: null);
-
-var result = await runtime.RunPromptAsync(request, CancellationToken.None);
+var result = await runtimeHost.ExecutePromptAsync(
+ prompt,
+ workspacePath,
+ model: "default",
+ permissionMode: PermissionMode.ReadOnly,
+ outputFormat: OutputFormat.Text,
+ sessionId: session.Id,
+ hostContext: hostContext,
+ cancellationToken: CancellationToken.None);
Console.WriteLine(result.FinalOutput ?? "(no output)");
-await host.StopAsync();
+await runtimeHost.StopAsync();
return 0;
diff --git a/examples/WebApiAgent/Program.cs b/examples/WebApiAgent/Program.cs
index f83023a..9039004 100644
--- a/examples/WebApiAgent/Program.cs
+++ b/examples/WebApiAgent/Program.cs
@@ -1,46 +1,58 @@
-using SharpClaw.Code.Protocol.Commands;
+using Microsoft.Extensions.Hosting;
+using SharpClaw.Code;
using SharpClaw.Code.Protocol.Enums;
-using SharpClaw.Code.Runtime.Abstractions;
-using SharpClaw.Code.Runtime.Composition;
+using SharpClaw.Code.Protocol.Models;
var builder = WebApplication.CreateBuilder(args);
-builder.Services.AddSharpClawRuntime(builder.Configuration);
+builder.Services.AddSingleton(_ => new SharpClawRuntimeHostBuilder(args).Build());
+builder.Services.AddHostedService();
var app = builder.Build();
-app.MapPost("/chat", async (ChatRequest body, IConversationRuntime runtime, CancellationToken ct) =>
+app.MapPost("/chat", async (ChatRequest body, SharpClawRuntimeHost runtimeHost, CancellationToken ct) =>
{
var workspacePath = Directory.GetCurrentDirectory();
+ var hostContext = new RuntimeHostContext(
+ HostId: "web-api-agent",
+ TenantId: body.TenantId,
+ IsEmbeddedHost: true);
- string sessionId;
- if (!string.IsNullOrWhiteSpace(body.SessionId))
+ var sessionId = body.SessionId;
+ if (string.IsNullOrWhiteSpace(sessionId))
{
- sessionId = body.SessionId;
- }
- else
- {
- var session = await runtime.CreateSessionAsync(
+ var session = await runtimeHost.CreateSessionAsync(
workspacePath,
PermissionMode.ReadOnly,
OutputFormat.Text,
+ hostContext,
ct);
sessionId = session.Id;
}
- var request = new RunPromptRequest(
- Prompt: body.Prompt,
- SessionId: sessionId,
- WorkingDirectory: workspacePath,
- PermissionMode: PermissionMode.ReadOnly,
- OutputFormat: OutputFormat.Text,
- Metadata: null);
-
- var result = await runtime.RunPromptAsync(request, ct);
-
- return Results.Ok(new ChatResponse(result.FinalOutput ?? string.Empty, sessionId));
+ var result = await runtimeHost.ExecutePromptAsync(
+ body.Prompt,
+ workspacePath,
+ model: "default",
+ permissionMode: PermissionMode.ReadOnly,
+ outputFormat: OutputFormat.Text,
+ sessionId: sessionId,
+ hostContext: hostContext,
+ cancellationToken: ct);
+
+ return Results.Ok(new ChatResponse(result.FinalOutput ?? string.Empty, sessionId!));
});
app.Run();
-record ChatRequest(string Prompt, string? SessionId);
+sealed class EmbeddedRuntimeLifecycleService(SharpClawRuntimeHost runtimeHost) : IHostedService
+{
+ public Task StartAsync(CancellationToken cancellationToken)
+ => runtimeHost.StartAsync(cancellationToken);
+
+ public Task StopAsync(CancellationToken cancellationToken)
+ => runtimeHost.StopAsync(cancellationToken);
+}
+
+record ChatRequest(string Prompt, string? SessionId, string? TenantId = null);
+
record ChatResponse(string Output, string SessionId);
diff --git a/examples/WebApiAgent/WebApiAgent.csproj b/examples/WebApiAgent/WebApiAgent.csproj
index 243d3a3..369ea85 100644
--- a/examples/WebApiAgent/WebApiAgent.csproj
+++ b/examples/WebApiAgent/WebApiAgent.csproj
@@ -5,7 +5,7 @@
-
+
diff --git a/examples/WorkerServiceHost/EmbeddedRuntimeLifecycleService.cs b/examples/WorkerServiceHost/EmbeddedRuntimeLifecycleService.cs
new file mode 100644
index 0000000..07326ca
--- /dev/null
+++ b/examples/WorkerServiceHost/EmbeddedRuntimeLifecycleService.cs
@@ -0,0 +1,13 @@
+using Microsoft.Extensions.Hosting;
+using SharpClaw.Code;
+
+namespace WorkerServiceHost;
+
+sealed class EmbeddedRuntimeLifecycleService(SharpClawRuntimeHost runtimeHost) : IHostedService
+{
+ public Task StartAsync(CancellationToken cancellationToken)
+ => runtimeHost.StartAsync(cancellationToken);
+
+ public Task StopAsync(CancellationToken cancellationToken)
+ => runtimeHost.StopAsync(cancellationToken);
+}
diff --git a/examples/WorkerServiceHost/Program.cs b/examples/WorkerServiceHost/Program.cs
new file mode 100644
index 0000000..dff998f
--- /dev/null
+++ b/examples/WorkerServiceHost/Program.cs
@@ -0,0 +1,10 @@
+using Microsoft.Extensions.Hosting;
+using SharpClaw.Code;
+using WorkerServiceHost;
+
+var builder = Host.CreateApplicationBuilder(args);
+builder.Services.AddSingleton(_ => new SharpClawRuntimeHostBuilder(args).Build());
+builder.Services.AddHostedService();
+builder.Services.AddHostedService();
+
+await builder.Build().RunAsync();
diff --git a/examples/WorkerServiceHost/PromptWorker.cs b/examples/WorkerServiceHost/PromptWorker.cs
new file mode 100644
index 0000000..0f0ae02
--- /dev/null
+++ b/examples/WorkerServiceHost/PromptWorker.cs
@@ -0,0 +1,64 @@
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using SharpClaw.Code;
+using SharpClaw.Code.Protocol.Enums;
+using SharpClaw.Code.Protocol.Models;
+
+namespace WorkerServiceHost;
+
+sealed class PromptWorker(
+ IConfiguration configuration,
+ ILogger logger,
+ SharpClawRuntimeHost runtimeHost,
+ IHostApplicationLifetime hostApplicationLifetime) : BackgroundService
+{
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ var configuredWorkspacePath = configuration["Worker:WorkspacePath"];
+ var workspacePath = string.IsNullOrWhiteSpace(configuredWorkspacePath)
+ ? Directory.GetCurrentDirectory()
+ : configuredWorkspacePath;
+ var prompt = configuration["Worker:Prompt"] ?? "Summarize the current workspace and highlight any obvious risks.";
+ var configuredModel = configuration["Worker:Model"];
+ var model = string.IsNullOrWhiteSpace(configuredModel) ? "default" : configuredModel;
+ var tenantId = string.IsNullOrWhiteSpace(configuration["Worker:TenantId"])
+ ? null
+ : configuration["Worker:TenantId"];
+ var hostContext = new RuntimeHostContext(
+ HostId: "worker-service-host",
+ TenantId: tenantId,
+ IsEmbeddedHost: true);
+
+ try
+ {
+ var session = await runtimeHost.CreateSessionAsync(
+ workspacePath,
+ PermissionMode.ReadOnly,
+ OutputFormat.Text,
+ hostContext,
+ stoppingToken);
+
+ var result = await runtimeHost.ExecutePromptAsync(
+ prompt,
+ workspacePath,
+ model,
+ PermissionMode.ReadOnly,
+ OutputFormat.Text,
+ sessionId: session.Id,
+ hostContext: hostContext,
+ cancellationToken: stoppingToken);
+
+ logger.LogInformation("Worker prompt completed for session {SessionId}.", session.Id);
+ logger.LogInformation("{Output}", result.FinalOutput ?? "(no output)");
+ }
+ catch (Exception exception)
+ {
+ logger.LogError(exception, "Worker prompt execution failed.");
+ }
+ finally
+ {
+ hostApplicationLifetime.StopApplication();
+ }
+ }
+}
diff --git a/examples/WorkerServiceHost/WorkerServiceHost.csproj b/examples/WorkerServiceHost/WorkerServiceHost.csproj
new file mode 100644
index 0000000..314e287
--- /dev/null
+++ b/examples/WorkerServiceHost/WorkerServiceHost.csproj
@@ -0,0 +1,18 @@
+
+
+
+ net10.0
+ enable
+ enable
+ Worker-service example for hosting SharpClaw Code through the embeddable runtime SDK.
+
+
+
+
+
+
+
+
+
+
+
diff --git a/examples/WorkerServiceHost/appsettings.json b/examples/WorkerServiceHost/appsettings.json
new file mode 100644
index 0000000..047fca9
--- /dev/null
+++ b/examples/WorkerServiceHost/appsettings.json
@@ -0,0 +1,25 @@
+{
+ "SharpClaw": {
+ "Providers": {
+ "Catalog": {
+ "DefaultProvider": "Anthropic"
+ },
+ "Anthropic": {
+ "ApiKey": "",
+ "DefaultModel": "claude-sonnet-4-5"
+ }
+ }
+ },
+ "Worker": {
+ "Prompt": "Summarize the current workspace and highlight any obvious risks.",
+ "WorkspacePath": "",
+ "Model": "default",
+ "TenantId": ""
+ },
+ "Logging": {
+ "LogLevel": {
+ "Default": "Warning",
+ "SharpClaw": "Information"
+ }
+ }
+}
diff --git a/extensions/vscode/README.md b/extensions/vscode/README.md
new file mode 100644
index 0000000..c807f75
--- /dev/null
+++ b/extensions/vscode/README.md
@@ -0,0 +1,29 @@
+# SharpClaw Code VS Code Extension
+
+This extension launches the SharpClaw ACP host as a subprocess and uses it as the single transport for prompts, workspace search, memory management, and approval requests.
+
+Available commands:
+
+- `SharpClaw: Prompt`
+- `SharpClaw: Refresh Index`
+- `SharpClaw: Search Workspace`
+- `SharpClaw: Save Memory`
+- `SharpClaw: List Memory`
+- `SharpClaw: Select Model`
+
+What it currently does:
+
+- sends the active editor file and selection as `editorContext` on prompt requests
+- streams assistant output into the SharpClaw output channel
+- surfaces approval prompts from the ACP host and routes the response back over `approval/respond`
+- refreshes and queries the workspace knowledge index
+- saves and lists structured project/user memory
+- lists models from the provider catalog, including local runtime profiles exposed through the OpenAI-compatible provider
+
+By default the extension runs:
+
+```bash
+dotnet run --project src/SharpClaw.Code.Cli -- acp
+```
+
+Override that command through the `sharpClaw.cliCommand` and `sharpClaw.cliArgs` settings if you want to point the extension at an installed CLI or a different workspace layout.
diff --git a/extensions/vscode/package.json b/extensions/vscode/package.json
new file mode 100644
index 0000000..43068b5
--- /dev/null
+++ b/extensions/vscode/package.json
@@ -0,0 +1,83 @@
+{
+ "name": "sharpclaw-code",
+ "displayName": "SharpClaw Code",
+ "description": "VS Code integration for the SharpClaw ACP runtime.",
+ "version": "0.1.0",
+ "publisher": "clawdotnet",
+ "engines": {
+ "vscode": "^1.100.0"
+ },
+ "categories": [
+ "Other"
+ ],
+ "activationEvents": [
+ "onCommand:sharpClaw.prompt",
+ "onCommand:sharpClaw.refreshIndex",
+ "onCommand:sharpClaw.searchWorkspace",
+ "onCommand:sharpClaw.saveMemory",
+ "onCommand:sharpClaw.listMemory",
+ "onCommand:sharpClaw.selectModel"
+ ],
+ "main": "./out/extension.js",
+ "contributes": {
+ "commands": [
+ {
+ "command": "sharpClaw.prompt",
+ "title": "SharpClaw: Prompt"
+ },
+ {
+ "command": "sharpClaw.refreshIndex",
+ "title": "SharpClaw: Refresh Index"
+ },
+ {
+ "command": "sharpClaw.searchWorkspace",
+ "title": "SharpClaw: Search Workspace"
+ },
+ {
+ "command": "sharpClaw.saveMemory",
+ "title": "SharpClaw: Save Memory"
+ },
+ {
+ "command": "sharpClaw.listMemory",
+ "title": "SharpClaw: List Memory"
+ },
+ {
+ "command": "sharpClaw.selectModel",
+ "title": "SharpClaw: Select Model"
+ }
+ ],
+ "configuration": {
+ "title": "SharpClaw Code",
+ "properties": {
+ "sharpClaw.cliCommand": {
+ "type": "string",
+ "default": "dotnet",
+ "description": "Command used to launch the SharpClaw ACP host."
+ },
+ "sharpClaw.cliArgs": {
+ "type": "array",
+ "default": [
+ "run",
+ "--project",
+ "src/SharpClaw.Code.Cli",
+ "--",
+ "acp"
+ ],
+ "items": {
+ "type": "string"
+ },
+ "description": "Arguments passed to the SharpClaw CLI command."
+ }
+ }
+ }
+ },
+ "scripts": {
+ "compile": "tsc -p ./",
+ "watch": "tsc -watch -p ./"
+ },
+ "devDependencies": {
+ "@types/node": "^22.15.3",
+ "@types/vscode": "^1.100.0",
+ "typescript": "^5.8.3"
+ }
+}
diff --git a/extensions/vscode/src/extension.ts b/extensions/vscode/src/extension.ts
new file mode 100644
index 0000000..25f994b
--- /dev/null
+++ b/extensions/vscode/src/extension.ts
@@ -0,0 +1,321 @@
+import * as readline from "node:readline";
+import { ChildProcessWithoutNullStreams, spawn } from "node:child_process";
+import * as path from "node:path";
+import * as vscode from "vscode";
+
+type JsonRpcResponse = { jsonrpc: string; id?: string | number; result?: unknown; error?: { code: number; message: string } };
+type ProviderCatalogEntry = {
+ providerName: string;
+ defaultModel: string;
+ availableModels?: Array<{ id: string; displayName: string }>;
+ localRuntimeProfiles?: Array<{ name: string; defaultChatModel: string; availableModels?: Array<{ id: string; displayName: string }> }>;
+};
+
+class AcpClient implements vscode.Disposable {
+ private process: ChildProcessWithoutNullStreams | undefined;
+ private initialized = false;
+ private nextId = 1;
+ private readonly pending = new Map();
+
+ constructor(
+ private readonly output: vscode.OutputChannel,
+ private readonly workspaceState: vscode.Memento,
+ private readonly context: vscode.ExtensionContext) {}
+
+ dispose(): void {
+ this.process?.kill();
+ this.process = undefined;
+ this.pending.clear();
+ }
+
+ async call(method: string, params: Record): Promise {
+ await this.ensureStarted();
+ const id = this.nextId++;
+ const payload = JSON.stringify({ jsonrpc: "2.0", id, method, params });
+ const response = await new Promise((resolve, reject) => {
+ this.pending.set(id, { resolve, reject });
+ this.process!.stdin.write(payload + "\n", "utf8");
+ });
+ return response as T;
+ }
+
+ async ensureSession(workspaceFolder: vscode.WorkspaceFolder): Promise {
+ const key = `session:${workspaceFolder.uri.toString()}`;
+ const existing = this.workspaceState.get(key);
+ if (existing) {
+ try {
+ await this.call("session/load", { cwd: workspaceFolder.uri.fsPath, sessionId: existing });
+ return existing;
+ } catch {
+ }
+ }
+
+ const created = await this.call<{ sessionId: string }>("session/new", { cwd: workspaceFolder.uri.fsPath });
+ await this.workspaceState.update(key, created.sessionId);
+ return created.sessionId;
+ }
+
+ private async ensureStarted(): Promise {
+ if (this.process && this.initialized) {
+ return;
+ }
+
+ const config = vscode.workspace.getConfiguration("sharpClaw");
+ const command = config.get("cliCommand", "dotnet");
+ const args = config.get("cliArgs", ["run", "--project", "src/SharpClaw.Code.Cli", "--", "acp"]);
+ const cwd = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? this.context.extensionPath;
+ this.process = spawn(command, args, { cwd, stdio: "pipe" });
+ this.process.stderr.on("data", chunk => this.output.append(chunk.toString()));
+ const rl = readline.createInterface({ input: this.process.stdout });
+ rl.on("line", line => this.handleLine(line));
+ this.process.on("exit", code => {
+ for (const pending of this.pending.values()) {
+ pending.reject(new Error(`SharpClaw ACP exited with code ${code ?? 0}.`));
+ }
+ this.pending.clear();
+ this.initialized = false;
+ this.process = undefined;
+ });
+
+ await this.call("initialize", { clientCapabilities: { approvalRequests: true } });
+ this.initialized = true;
+ }
+
+ private handleLine(line: string): void {
+ if (!line.trim()) {
+ return;
+ }
+
+ const message = JSON.parse(line) as JsonRpcResponse & { method?: string; params?: any };
+ if (message.method === "session/notification") {
+ this.handleNotification(message.params);
+ return;
+ }
+
+ if (typeof message.id !== "number") {
+ return;
+ }
+
+ const pending = this.pending.get(message.id);
+ if (!pending) {
+ return;
+ }
+
+ this.pending.delete(message.id);
+ if (message.error) {
+ pending.reject(new Error(message.error.message));
+ return;
+ }
+
+ pending.resolve(message.result);
+ }
+
+ private async handleNotification(params: any): Promise {
+ const update = params?.update;
+ if (!update) {
+ return;
+ }
+
+ if (update.sessionUpdate === "agentMessageChunk") {
+ const text = update.chunk?.content?.text;
+ if (typeof text === "string" && text.length > 0) {
+ this.output.appendLine(text);
+ this.output.show(true);
+ }
+ return;
+ }
+
+ if (update.sessionUpdate === "approvalRequest") {
+ const approval = update.approval;
+ const choice = await vscode.window.showWarningMessage(
+ approval.prompt ?? `Approval required for ${approval.toolName}.`,
+ { modal: true },
+ approval.canRememberDecision ? "Allow and Remember" : "Allow",
+ "Deny");
+ await this.call("approval/respond", {
+ requestId: approval.requestId,
+ approved: choice === "Allow" || choice === "Allow and Remember",
+ remember: choice === "Allow and Remember"
+ });
+ }
+ }
+}
+
+export function activate(context: vscode.ExtensionContext): void {
+ const output = vscode.window.createOutputChannel("SharpClaw Code");
+ const client = new AcpClient(output, context.workspaceState, context);
+ context.subscriptions.push(client, output);
+
+ context.subscriptions.push(vscode.commands.registerCommand("sharpClaw.prompt", async () => {
+ const folder = requireWorkspaceFolder();
+ const prompt = await vscode.window.showInputBox({ prompt: "Send a prompt to SharpClaw" });
+ if (!prompt) {
+ return;
+ }
+
+ const sessionId = await client.ensureSession(folder);
+ const model = context.workspaceState.get(`model:${folder.uri.toString()}`);
+ const editor = vscode.window.activeTextEditor;
+ const editorContext = editor ? buildEditorContext(editor, sessionId) : undefined;
+ output.show(true);
+ output.appendLine(`> ${prompt}`);
+ await client.call("session/prompt", {
+ cwd: folder.uri.fsPath,
+ sessionId,
+ model,
+ prompt,
+ editorContext
+ });
+ }));
+
+ context.subscriptions.push(vscode.commands.registerCommand("sharpClaw.refreshIndex", async () => {
+ const folder = requireWorkspaceFolder();
+ const result = await client.call<{ indexedFileCount: number }>("workspace/index/refresh", { cwd: folder.uri.fsPath });
+ void vscode.window.showInformationMessage(`SharpClaw indexed ${result.indexedFileCount} file(s).`);
+ }));
+
+ context.subscriptions.push(vscode.commands.registerCommand("sharpClaw.searchWorkspace", async () => {
+ const folder = requireWorkspaceFolder();
+ const query = await vscode.window.showInputBox({ prompt: "Search the indexed workspace" });
+ if (!query) {
+ return;
+ }
+
+ const result = await client.call<{ hits: Array<{ path: string; excerpt: string; startLine?: number }> }>("workspace/search", {
+ cwd: folder.uri.fsPath,
+ query,
+ limit: 20
+ });
+
+ const picked = await vscode.window.showQuickPick(
+ result.hits.map(hit => ({
+ label: hit.path,
+ description: hit.startLine ? `Line ${hit.startLine}` : undefined,
+ detail: hit.excerpt,
+ hit
+ })),
+ { placeHolder: "Workspace search results" });
+ if (!picked) {
+ return;
+ }
+
+ const target = vscode.Uri.file(path.join(folder.uri.fsPath, picked.hit.path));
+ const document = await vscode.workspace.openTextDocument(target);
+ const editor = await vscode.window.showTextDocument(document);
+ if (picked.hit.startLine && picked.hit.startLine > 0) {
+ const line = Math.max(0, picked.hit.startLine - 1);
+ const position = new vscode.Position(line, 0);
+ editor.selection = new vscode.Selection(position, position);
+ editor.revealRange(new vscode.Range(position, position));
+ }
+ }));
+
+ context.subscriptions.push(vscode.commands.registerCommand("sharpClaw.saveMemory", async () => {
+ const folder = requireWorkspaceFolder();
+ const editor = vscode.window.activeTextEditor;
+ const selectionText = editor && !editor.selection.isEmpty ? editor.document.getText(editor.selection) : "";
+ const content = await vscode.window.showInputBox({
+ prompt: "Memory content",
+ value: selectionText
+ });
+ if (!content) {
+ return;
+ }
+
+ const scope = await vscode.window.showQuickPick(["project", "user"], { placeHolder: "Memory scope" });
+ if (!scope) {
+ return;
+ }
+
+ await client.call("memory/save", {
+ cwd: folder.uri.fsPath,
+ sessionId: await client.ensureSession(folder),
+ request: {
+ scope,
+ content,
+ source: editor ? "vscode-selection" : "vscode-manual",
+ relatedFilePath: editor ? vscode.workspace.asRelativePath(editor.document.uri, false) : undefined
+ }
+ });
+ void vscode.window.showInformationMessage(`Saved ${scope} memory.`);
+ }));
+
+ context.subscriptions.push(vscode.commands.registerCommand("sharpClaw.listMemory", async () => {
+ const folder = requireWorkspaceFolder();
+ const result = await client.call>("memory/list", {
+ cwd: folder.uri.fsPath,
+ limit: 30
+ });
+ await vscode.window.showQuickPick(
+ result.map(entry => ({
+ label: entry.id,
+ description: entry.scope,
+ detail: entry.content
+ })),
+ { placeHolder: "Saved SharpClaw memory" });
+ }));
+
+ context.subscriptions.push(vscode.commands.registerCommand("sharpClaw.selectModel", async () => {
+ const folder = requireWorkspaceFolder();
+ const providers = await client.call("models/list", {});
+ const picks = providers.flatMap(provider => {
+ const base = [{
+ label: provider.defaultModel,
+ description: provider.providerName,
+ value: provider.defaultModel
+ }];
+ const profilePicks = (provider.localRuntimeProfiles ?? []).flatMap(profile => {
+ const discovered = profile.availableModels ?? [];
+ if (discovered.length === 0) {
+ return [{
+ label: `${profile.name}/${profile.defaultChatModel}`,
+ description: provider.providerName,
+ value: `${profile.name}/${profile.defaultChatModel}`
+ }];
+ }
+
+ return discovered.map(model => ({
+ label: `${profile.name}/${model.id}`,
+ description: provider.providerName,
+ value: `${profile.name}/${model.id}`
+ }));
+ });
+ return [...base, ...profilePicks];
+ });
+ const selected = await vscode.window.showQuickPick(picks, { placeHolder: "Select the model for ACP prompts" });
+ if (!selected) {
+ return;
+ }
+
+ await context.workspaceState.update(`model:${folder.uri.toString()}`, selected.value);
+ void vscode.window.showInformationMessage(`SharpClaw model set to ${selected.value}.`);
+ }));
+}
+
+export function deactivate(): void {
+}
+
+function requireWorkspaceFolder(): vscode.WorkspaceFolder {
+ const folder = vscode.workspace.workspaceFolders?.[0];
+ if (!folder) {
+ throw new Error("Open a workspace folder before using SharpClaw.");
+ }
+
+ return folder;
+}
+
+function buildEditorContext(editor: vscode.TextEditor, sessionId: string): Record {
+ const selection = editor.selection;
+ return {
+ workspaceRoot: vscode.workspace.getWorkspaceFolder(editor.document.uri)?.uri.fsPath,
+ currentFilePath: editor.document.uri.fsPath,
+ selection: selection.isEmpty
+ ? undefined
+ : {
+ start: editor.document.offsetAt(selection.start),
+ end: editor.document.offsetAt(selection.end),
+ text: editor.document.getText(selection)
+ },
+ sessionId
+ };
+}
diff --git a/extensions/vscode/tsconfig.json b/extensions/vscode/tsconfig.json
new file mode 100644
index 0000000..cc23947
--- /dev/null
+++ b/extensions/vscode/tsconfig.json
@@ -0,0 +1,17 @@
+{
+ "compilerOptions": {
+ "module": "commonjs",
+ "target": "es2022",
+ "lib": [
+ "es2022"
+ ],
+ "outDir": "out",
+ "rootDir": "src",
+ "strict": true,
+ "sourceMap": true,
+ "esModuleInterop": true
+ },
+ "include": [
+ "src/**/*.ts"
+ ]
+}
diff --git a/src/SharpClaw.Code.Acp/AcpApprovalCoordinator.cs b/src/SharpClaw.Code.Acp/AcpApprovalCoordinator.cs
new file mode 100644
index 0000000..f9cb51b
--- /dev/null
+++ b/src/SharpClaw.Code.Acp/AcpApprovalCoordinator.cs
@@ -0,0 +1,125 @@
+using System.Collections.Concurrent;
+using System.Text.Json.Nodes;
+using SharpClaw.Code.Permissions.Models;
+using SharpClaw.Code.Protocol.Models;
+
+namespace SharpClaw.Code.Acp;
+
+///
+/// Coordinates ACP approval notifications and replies for a single host process.
+///
+public sealed class AcpApprovalCoordinator
+{
+ private readonly ConcurrentDictionary pending = new(StringComparer.Ordinal);
+ private Func? notificationSink;
+
+ ///
+ /// Gets a value indicating whether the connected ACP client advertised approval round-trips.
+ ///
+ public bool SupportsApprovals { get; private set; }
+
+ ///
+ /// Updates the active ACP notification sink and approval capability state for the current host run.
+ ///
+ /// Whether the client supports approval callbacks.
+ /// Notification writer used for approval requests.
+ public void Configure(bool supportsApprovals, Func notificationWriter)
+ {
+ SupportsApprovals = supportsApprovals;
+ notificationSink = notificationWriter;
+ }
+
+ ///
+ /// Sends an approval request to the connected ACP client and waits for the response.
+ ///
+ /// Approval request details.
+ /// Permission evaluation context.
+ /// Cancellation token.
+ /// The resolved approval decision.
+ public async Task RequestApprovalAsync(
+ ApprovalRequest request,
+ PermissionEvaluationContext context,
+ CancellationToken cancellationToken)
+ {
+ if (!SupportsApprovals || notificationSink is null)
+ {
+ return new ApprovalDecision(
+ request.Scope,
+ Approved: false,
+ RequestedBy: request.RequestedBy,
+ ResolvedBy: "acp",
+ Reason: "ACP client does not support approval round-trips.",
+ ResolvedAtUtc: DateTimeOffset.UtcNow,
+ ExpiresAtUtc: null,
+ RememberForSession: false);
+ }
+
+ var requestId = $"approval-{Guid.NewGuid():N}";
+ var tcs = new TaskCompletionSource<(bool Approved, bool Remember)>(TaskCreationOptions.RunContinuationsAsynchronously);
+ pending[requestId] = new PendingApproval(context.SessionId, request, tcs);
+
+ using var registration = cancellationToken.Register(() =>
+ {
+ if (pending.TryRemove(requestId, out var removed))
+ {
+ removed.Completion.TrySetCanceled(cancellationToken);
+ }
+ });
+
+ await notificationSink(new JsonObject
+ {
+ ["jsonrpc"] = "2.0",
+ ["method"] = "session/notification",
+ ["params"] = new JsonObject
+ {
+ ["sessionId"] = context.SessionId,
+ ["update"] = new JsonObject
+ {
+ ["sessionUpdate"] = "approvalRequest",
+ ["approval"] = new JsonObject
+ {
+ ["requestId"] = requestId,
+ ["scope"] = request.Scope.ToString(),
+ ["toolName"] = request.ToolName,
+ ["prompt"] = request.Prompt,
+ ["canRememberDecision"] = request.CanRememberDecision,
+ },
+ },
+ },
+ }).ConfigureAwait(false);
+
+ var response = await tcs.Task.ConfigureAwait(false);
+ return new ApprovalDecision(
+ request.Scope,
+ Approved: response.Approved,
+ RequestedBy: request.RequestedBy,
+ ResolvedBy: "acp",
+ Reason: response.Approved ? "Approved via ACP client." : "Denied via ACP client.",
+ ResolvedAtUtc: DateTimeOffset.UtcNow,
+ ExpiresAtUtc: null,
+ RememberForSession: response.Remember);
+ }
+
+ ///
+ /// Resolves a pending ACP approval request.
+ ///
+ /// Approval request id.
+ /// Whether the request was approved.
+ /// Whether the decision should be remembered for the session.
+ /// when a pending request was resolved; otherwise .
+ public bool TryResolve(string requestId, bool approved, bool remember)
+ {
+ if (!pending.TryRemove(requestId, out var pendingApproval))
+ {
+ return false;
+ }
+
+ pendingApproval.Completion.TrySetResult((approved, remember));
+ return true;
+ }
+
+ private sealed record PendingApproval(
+ string SessionId,
+ ApprovalRequest Request,
+ TaskCompletionSource<(bool Approved, bool Remember)> Completion);
+}
diff --git a/src/SharpClaw.Code.Acp/AcpApprovalTransport.cs b/src/SharpClaw.Code.Acp/AcpApprovalTransport.cs
new file mode 100644
index 0000000..b7441a7
--- /dev/null
+++ b/src/SharpClaw.Code.Acp/AcpApprovalTransport.cs
@@ -0,0 +1,19 @@
+using SharpClaw.Code.Permissions.Abstractions;
+using SharpClaw.Code.Permissions.Models;
+using SharpClaw.Code.Protocol.Models;
+
+namespace SharpClaw.Code.Acp;
+
+internal sealed class AcpApprovalTransport(AcpApprovalCoordinator coordinator) : IApprovalTransport
+{
+ public bool CanHandle(PermissionEvaluationContext context)
+ => context.IsInteractive
+ && coordinator.SupportsApprovals
+ && string.Equals(context.SourceName, "acp", StringComparison.OrdinalIgnoreCase);
+
+ public Task RequestApprovalAsync(
+ ApprovalRequest request,
+ PermissionEvaluationContext context,
+ CancellationToken cancellationToken)
+ => coordinator.RequestApprovalAsync(request, context, cancellationToken);
+}
diff --git a/src/SharpClaw.Code.Acp/AcpServiceCollectionExtensions.cs b/src/SharpClaw.Code.Acp/AcpServiceCollectionExtensions.cs
new file mode 100644
index 0000000..83e5ed9
--- /dev/null
+++ b/src/SharpClaw.Code.Acp/AcpServiceCollectionExtensions.cs
@@ -0,0 +1,21 @@
+using Microsoft.Extensions.DependencyInjection;
+using SharpClaw.Code.Permissions.Abstractions;
+
+namespace SharpClaw.Code.Acp;
+
+///
+/// Registers ACP-specific host services.
+///
+public static class AcpServiceCollectionExtensions
+{
+ ///
+ /// Adds ACP host services, including approval round-trip support.
+ ///
+ public static IServiceCollection AddSharpClawAcp(this IServiceCollection services)
+ {
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ return services;
+ }
+}
diff --git a/src/SharpClaw.Code.Acp/AcpStdioHost.cs b/src/SharpClaw.Code.Acp/AcpStdioHost.cs
index 652ee6d..224c8b2 100644
--- a/src/SharpClaw.Code.Acp/AcpStdioHost.cs
+++ b/src/SharpClaw.Code.Acp/AcpStdioHost.cs
@@ -2,8 +2,12 @@
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging;
using SharpClaw.Code.Infrastructure.Abstractions;
+using SharpClaw.Code.Memory.Abstractions;
using SharpClaw.Code.Protocol.Commands;
using SharpClaw.Code.Protocol.Enums;
+using SharpClaw.Code.Protocol.Models;
+using SharpClaw.Code.Protocol.Serialization;
+using SharpClaw.Code.Providers.Abstractions;
using SharpClaw.Code.Runtime.Abstractions;
using SharpClaw.Code.Sessions.Abstractions;
@@ -12,21 +16,32 @@ namespace SharpClaw.Code.Acp;
///
/// Minimal Agent Client Protocol (ACP) JSON-RPC loop over stdio, aligned with common IDE subprocess integrations.
///
-///
-/// Intentionally unsupported (JSON-RPC errors or no-ops): streaming tool execution updates, interactive permission prompts,
-/// image/audio prompt parts, MCP hot-plug, session/cancel reliability, and vendor extensions.
-///
public sealed class AcpStdioHost(
IConversationRuntime conversationRuntime,
IWorkspaceSessionAttachmentStore attachmentStore,
+ IEditorContextBuffer editorContextBuffer,
+ IWorkspaceIndexService workspaceIndexService,
+ IWorkspaceSearchService workspaceSearchService,
+ IPersistentMemoryStore persistentMemoryStore,
+ IProviderCatalogService providerCatalogService,
+ AcpApprovalCoordinator approvalCoordinator,
IPathService pathService,
ILogger logger)
{
+ private readonly SemaphoreSlim writeLock = new(1, 1);
+ private Func? notificationWriter;
+
///
/// Processes newline-delimited JSON-RPC requests until the input stream ends.
///
public async Task RunAsync(TextReader stdin, TextWriter stdout, CancellationToken cancellationToken)
{
+ approvalCoordinator.Configure(
+ supportsApprovals: approvalCoordinator.SupportsApprovals,
+ notificationWriter: payload => WriteJsonLineAsync(stdout, payload, cancellationToken));
+ notificationWriter = payload => WriteJsonLineAsync(stdout, payload, cancellationToken);
+
+ var inFlight = new List();
while (!cancellationToken.IsCancellationRequested)
{
var line = await stdin.ReadLineAsync(cancellationToken).ConfigureAwait(false);
@@ -48,46 +63,52 @@ public async Task RunAsync(TextReader stdin, TextWriter stdout, CancellationToke
catch (JsonException ex)
{
logger.LogWarning(ex, "ACP received non-JSON line.");
- await WriteErrorAsync(stdout, null, -32700, "Parse error.").ConfigureAwait(false);
- await stdout.FlushAsync(cancellationToken).ConfigureAwait(false);
+ await WriteErrorAsync(stdout, null, -32700, "Parse error.", cancellationToken).ConfigureAwait(false);
continue;
}
if (root is not JsonObject requestObject)
{
- await WriteErrorAsync(stdout, null, -32600, "Invalid request.").ConfigureAwait(false);
- await stdout.FlushAsync(cancellationToken).ConfigureAwait(false);
+ await WriteErrorAsync(stdout, null, -32600, "Invalid request.", cancellationToken).ConfigureAwait(false);
continue;
}
- var id = requestObject["id"];
- var method = requestObject["method"]?.GetValue();
- if (!string.Equals(requestObject["jsonrpc"]?.GetValue(), "2.0", StringComparison.Ordinal)
- || string.IsNullOrWhiteSpace(method)
- || id is null)
- {
- await WriteErrorAsync(stdout, id, -32600, "Invalid request.").ConfigureAwait(false);
- await stdout.FlushAsync(cancellationToken).ConfigureAwait(false);
- continue;
- }
+ inFlight.Add(ProcessRequestAsync(requestObject, stdout, cancellationToken));
+ inFlight.RemoveAll(static task => task.IsCompleted);
+ }
- try
- {
- var response = await DispatchAsync(method, requestObject["params"], stdout, cancellationToken).ConfigureAwait(false);
- await WriteResponseAsync(stdout, id, response).ConfigureAwait(false);
- }
- catch (AcpJsonRpcException ex)
- {
- logger.LogWarning(ex, "ACP request failed for method {Method} with JSON-RPC error {Code}.", method, ex.Code);
- await WriteErrorAsync(stdout, id, ex.Code, ex.Message).ConfigureAwait(false);
- }
- catch (Exception ex)
- {
- logger.LogError(ex, "ACP request failed for method {Method}.", method);
- await WriteErrorAsync(stdout, id, -32603, ex.Message).ConfigureAwait(false);
- }
+ if (inFlight.Count > 0)
+ {
+ await Task.WhenAll(inFlight).ConfigureAwait(false);
+ }
+ }
- await stdout.FlushAsync(cancellationToken).ConfigureAwait(false);
+ private async Task ProcessRequestAsync(JsonObject requestObject, TextWriter stdout, CancellationToken cancellationToken)
+ {
+ var id = requestObject["id"];
+ var method = requestObject["method"]?.GetValue();
+ if (!string.Equals(requestObject["jsonrpc"]?.GetValue(), "2.0", StringComparison.Ordinal)
+ || string.IsNullOrWhiteSpace(method)
+ || id is null)
+ {
+ await WriteErrorAsync(stdout, id, -32600, "Invalid request.", cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
+ try
+ {
+ var response = await DispatchAsync(method, requestObject["params"], stdout, cancellationToken).ConfigureAwait(false);
+ await WriteResponseAsync(stdout, id, response, cancellationToken).ConfigureAwait(false);
+ }
+ catch (AcpJsonRpcException ex)
+ {
+ logger.LogWarning(ex, "ACP request failed for method {Method} with JSON-RPC error {Code}.", method, ex.Code);
+ await WriteErrorAsync(stdout, id, ex.Code, ex.Message, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "ACP request failed for method {Method}.", method);
+ await WriteErrorAsync(stdout, id, -32603, ex.Message, cancellationToken).ConfigureAwait(false);
}
}
@@ -98,18 +119,37 @@ public async Task RunAsync(TextReader stdin, TextWriter stdout, CancellationToke
CancellationToken cancellationToken)
=> method switch
{
- "initialize" => await Task.FromResult(BuildInitializeResult()).ConfigureAwait(false),
+ "initialize" => await Task.FromResult(HandleInitialize(parameters)).ConfigureAwait(false),
"session/new" => await HandleSessionNewAsync(parameters, cancellationToken).ConfigureAwait(false),
"session/load" => await HandleSessionLoadAsync(parameters, cancellationToken).ConfigureAwait(false),
"session/prompt" => await HandleSessionPromptAsync(parameters, stdout, cancellationToken).ConfigureAwait(false),
+ "models/list" => await HandleModelsListAsync(cancellationToken).ConfigureAwait(false),
+ "workspace/index/refresh" => await HandleWorkspaceIndexRefreshAsync(parameters, cancellationToken).ConfigureAwait(false),
+ "workspace/search" => await HandleWorkspaceSearchAsync(parameters, cancellationToken).ConfigureAwait(false),
+ "memory/list" => await HandleMemoryListAsync(parameters, cancellationToken).ConfigureAwait(false),
+ "memory/save" => await HandleMemorySaveAsync(parameters, cancellationToken).ConfigureAwait(false),
+ "memory/delete" => await HandleMemoryDeleteAsync(parameters, cancellationToken).ConfigureAwait(false),
+ "approval/respond" => await HandleApprovalRespondAsync(parameters).ConfigureAwait(false),
_ => throw new AcpJsonRpcException(-32601, $"Method '{method}' was not found.")
};
- private static JsonObject BuildInitializeResult()
+ private JsonObject HandleInitialize(JsonNode? parameters)
{
+ var supportsApprovals = parameters?["clientCapabilities"]?["approvalRequests"]?.GetValue() ?? false;
+ approvalCoordinator.Configure(
+ supportsApprovals,
+ payload => notificationWriter is null
+ ? Task.CompletedTask
+ : notificationWriter(payload));
+
var capabilities = new JsonObject
{
["loadSession"] = true,
+ ["approvalRequests"] = true,
+ ["models"] = true,
+ ["workspaceSearch"] = true,
+ ["workspaceIndex"] = true,
+ ["memory"] = true,
["promptCapabilities"] = new JsonObject
{
["image"] = false,
@@ -131,8 +171,7 @@ private static JsonObject BuildInitializeResult()
private async Task HandleSessionNewAsync(JsonNode? parameters, CancellationToken cancellationToken)
{
- var cwd = parameters?["cwd"]?.GetValue() ?? throw new InvalidOperationException("session/new requires cwd.");
- var workspace = pathService.GetFullPath(cwd);
+ var workspace = RequireWorkspace(parameters);
var session = await conversationRuntime
.CreateSessionAsync(workspace, PermissionMode.WorkspaceWrite, OutputFormat.Json, cancellationToken)
.ConfigureAwait(false);
@@ -143,7 +182,9 @@ private async Task HandleSessionNewAsync(JsonNode? parameters, Cance
["models"] = new JsonObject
{
["current"] = "default",
- ["available"] = new JsonArray(),
+ ["available"] = JsonSerializer.SerializeToNode(
+ (await providerCatalogService.ListAsync(cancellationToken).ConfigureAwait(false)).ToList(),
+ ProtocolJsonContext.Default.ListProviderModelCatalogEntry),
},
};
}
@@ -151,8 +192,7 @@ private async Task HandleSessionNewAsync(JsonNode? parameters, Cance
private async Task HandleSessionLoadAsync(JsonNode? parameters, CancellationToken cancellationToken)
{
var sessionId = parameters?["sessionId"]?.GetValue() ?? throw new InvalidOperationException("session/load requires sessionId.");
- var cwd = parameters?["cwd"]?.GetValue() ?? throw new InvalidOperationException("session/load requires cwd.");
- var workspace = pathService.GetFullPath(cwd);
+ var workspace = RequireWorkspace(parameters);
var session = await conversationRuntime
.GetSessionAsync(workspace, sessionId, cancellationToken)
.ConfigureAwait(false);
@@ -177,10 +217,22 @@ private async Task HandleSessionPromptAsync(
CancellationToken cancellationToken)
{
var sessionId = parameters?["sessionId"]?.GetValue() ?? throw new InvalidOperationException("session/prompt requires sessionId.");
- var cwd = parameters?["cwd"]?.GetValue() ?? throw new InvalidOperationException("session/prompt requires cwd.");
- var workspace = pathService.GetFullPath(cwd);
-
+ var workspace = RequireWorkspace(parameters);
var promptText = ExtractPromptText(parameters?["prompt"]);
+ var metadata = new Dictionary(StringComparer.Ordinal)
+ {
+ ["acp"] = "true",
+ };
+ if (parameters?["model"]?.GetValue() is { Length: > 0 } model)
+ {
+ metadata["model"] = model;
+ }
+
+ if (parameters?["editorContext"] is JsonNode editorContextNode)
+ {
+ var editorContext = DeserializeEditorContext(editorContextNode, workspace, sessionId);
+ editorContextBuffer.Publish(editorContext);
+ }
var turn = await conversationRuntime
.RunPromptAsync(
@@ -190,29 +242,31 @@ private async Task HandleSessionPromptAsync(
WorkingDirectory: workspace,
PermissionMode: PermissionMode.WorkspaceWrite,
OutputFormat: OutputFormat.Json,
- Metadata: new Dictionary { ["acp"] = "true" },
- IsInteractive: false),
+ Metadata: metadata,
+ IsInteractive: approvalCoordinator.SupportsApprovals),
cancellationToken)
.ConfigureAwait(false);
- var chunk = new JsonObject
- {
- ["jsonrpc"] = "2.0",
- ["method"] = "session/notification",
- ["params"] = new JsonObject
+ await WriteJsonLineAsync(
+ stdout,
+ new JsonObject
{
- ["sessionId"] = sessionId,
- ["update"] = new JsonObject
+ ["jsonrpc"] = "2.0",
+ ["method"] = "session/notification",
+ ["params"] = new JsonObject
{
- ["sessionUpdate"] = "agentMessageChunk",
- ["chunk"] = new JsonObject
+ ["sessionId"] = sessionId,
+ ["update"] = new JsonObject
{
- ["content"] = new JsonObject { ["type"] = "text", ["text"] = turn.FinalOutput ?? string.Empty },
+ ["sessionUpdate"] = "agentMessageChunk",
+ ["chunk"] = new JsonObject
+ {
+ ["content"] = new JsonObject { ["type"] = "text", ["text"] = turn.FinalOutput ?? string.Empty },
+ },
},
},
},
- };
- await stdout.WriteLineAsync(chunk.ToJsonString()).ConfigureAwait(false);
+ cancellationToken).ConfigureAwait(false);
return new JsonObject
{
@@ -220,6 +274,109 @@ private async Task HandleSessionPromptAsync(
};
}
+ private async Task HandleModelsListAsync(CancellationToken cancellationToken)
+ => JsonSerializer.SerializeToNode(
+ (await providerCatalogService.ListAsync(cancellationToken).ConfigureAwait(false)).ToList(),
+ ProtocolJsonContext.Default.ListProviderModelCatalogEntry);
+
+ private async Task HandleWorkspaceIndexRefreshAsync(JsonNode? parameters, CancellationToken cancellationToken)
+ {
+ var workspace = RequireWorkspace(parameters);
+ var result = await workspaceIndexService.RefreshAsync(workspace, cancellationToken).ConfigureAwait(false);
+ return JsonSerializer.SerializeToNode(result, ProtocolJsonContext.Default.WorkspaceIndexRefreshResult);
+ }
+
+ private async Task HandleWorkspaceSearchAsync(JsonNode? parameters, CancellationToken cancellationToken)
+ {
+ var workspace = RequireWorkspace(parameters);
+ var request = new WorkspaceSearchRequest(
+ Query: parameters?["query"]?.GetValue() ?? string.Empty,
+ Limit: parameters?["limit"]?.GetValue(),
+ IncludeSymbols: parameters?["includeSymbols"]?.GetValue() ?? true,
+ IncludeSemantic: parameters?["includeSemantic"]?.GetValue() ?? true);
+ var result = await workspaceSearchService.SearchAsync(workspace, request, cancellationToken).ConfigureAwait(false);
+ return JsonSerializer.SerializeToNode(result, ProtocolJsonContext.Default.WorkspaceSearchResult);
+ }
+
+ private async Task HandleMemoryListAsync(JsonNode? parameters, CancellationToken cancellationToken)
+ {
+ var workspace = parameters?["cwd"]?.GetValue();
+ var normalizedWorkspace = string.IsNullOrWhiteSpace(workspace) ? null : pathService.GetFullPath(workspace);
+ var scopeText = parameters?["scope"]?.GetValue();
+ var scope = Enum.TryParse(scopeText, true, out var parsedScope) ? (MemoryScope?)parsedScope : null;
+ var rows = await persistentMemoryStore
+ .ListAsync(normalizedWorkspace, scope, parameters?["query"]?.GetValue(), parameters?["limit"]?.GetValue() ?? 20, cancellationToken)
+ .ConfigureAwait(false);
+ return JsonSerializer.SerializeToNode(rows.ToList(), ProtocolJsonContext.Default.ListMemoryEntry);
+ }
+
+ private async Task HandleMemorySaveAsync(JsonNode? parameters, CancellationToken cancellationToken)
+ {
+ var workspace = parameters?["cwd"]?.GetValue();
+ var normalizedWorkspace = string.IsNullOrWhiteSpace(workspace) ? null : pathService.GetFullPath(workspace);
+ var request = parameters?["request"]?.Deserialize(ProtocolJsonContext.Default.MemorySaveRequest)
+ ?? throw new InvalidOperationException("memory/save requires request.");
+ var now = DateTimeOffset.UtcNow;
+ var entry = new MemoryEntry(
+ Id: $"memory-{Guid.NewGuid():N}",
+ Scope: request.Scope,
+ Content: request.Content,
+ Source: request.Source,
+ SourceSessionId: parameters?["sessionId"]?.GetValue(),
+ SourceTurnId: null,
+ Tags: request.Tags ?? [],
+ Confidence: request.Confidence,
+ RelatedFilePath: request.RelatedFilePath,
+ RelatedSymbolName: request.RelatedSymbolName,
+ CreatedAtUtc: now,
+ UpdatedAtUtc: now);
+ var saved = await persistentMemoryStore
+ .SaveAsync(request.Scope == MemoryScope.Project ? normalizedWorkspace : null, entry, cancellationToken)
+ .ConfigureAwait(false);
+ return JsonSerializer.SerializeToNode(saved, ProtocolJsonContext.Default.MemoryEntry);
+ }
+
+ private async Task HandleMemoryDeleteAsync(JsonNode? parameters, CancellationToken cancellationToken)
+ {
+ var workspace = parameters?["cwd"]?.GetValue();
+ var normalizedWorkspace = string.IsNullOrWhiteSpace(workspace) ? null : pathService.GetFullPath(workspace);
+ var scope = Enum.Parse(parameters?["scope"]?.GetValue() ?? MemoryScope.Project.ToString(), true);
+ var id = parameters?["id"]?.GetValue() ?? throw new InvalidOperationException("memory/delete requires id.");
+ var deleted = await persistentMemoryStore
+ .DeleteAsync(scope == MemoryScope.Project ? normalizedWorkspace : null, scope, id, cancellationToken)
+ .ConfigureAwait(false);
+ return new JsonObject { ["deleted"] = deleted };
+ }
+
+ private Task HandleApprovalRespondAsync(JsonNode? parameters)
+ {
+ var requestId = parameters?["requestId"]?.GetValue() ?? throw new InvalidOperationException("approval/respond requires requestId.");
+ var approved = parameters?["approved"]?.GetValue() ?? false;
+ var remember = parameters?["remember"]?.GetValue() ?? false;
+ var resolved = approvalCoordinator.TryResolve(requestId, approved, remember);
+ return Task.FromResult(new JsonObject { ["resolved"] = resolved });
+ }
+
+ private string RequireWorkspace(JsonNode? parameters)
+ {
+ var cwd = parameters?["cwd"]?.GetValue() ?? throw new InvalidOperationException("cwd is required.");
+ return pathService.GetFullPath(cwd);
+ }
+
+ private static EditorContextPayload DeserializeEditorContext(JsonNode node, string workspaceRoot, string sessionId)
+ {
+ if (node.Deserialize(ProtocolJsonContext.Default.EditorContextPayload) is { } payload)
+ {
+ return string.IsNullOrWhiteSpace(payload.SessionId) ? payload with { SessionId = sessionId } : payload;
+ }
+
+ return new EditorContextPayload(
+ WorkspaceRoot: workspaceRoot,
+ CurrentFilePath: node["currentFilePath"]?.GetValue(),
+ Selection: node["selection"]?.Deserialize(ProtocolJsonContext.Default.TextSelectionRange),
+ SessionId: sessionId);
+ }
+
private static string ExtractPromptText(JsonNode? promptNode)
{
if (promptNode is JsonValue v && v.TryGetValue(out var single))
@@ -245,31 +402,45 @@ private static string ExtractPromptText(JsonNode? promptNode)
throw new InvalidOperationException("session/prompt requires a text prompt payload.");
}
- private static async Task WriteResponseAsync(TextWriter stdout, JsonNode id, JsonNode? result)
+ private async Task WriteJsonLineAsync(TextWriter stdout, JsonNode node, CancellationToken cancellationToken)
{
- var response = new JsonObject
+ await writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
{
- ["jsonrpc"] = "2.0",
- ["id"] = id.DeepClone(),
- ["result"] = result ?? new JsonObject(),
- };
- await stdout.WriteLineAsync(response.ToJsonString()).ConfigureAwait(false);
+ await stdout.WriteLineAsync(node.ToJsonString()).ConfigureAwait(false);
+ await stdout.FlushAsync(cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ writeLock.Release();
+ }
}
- private static async Task WriteErrorAsync(TextWriter stdout, JsonNode? id, int code, string message)
- {
- var response = new JsonObject
- {
- ["jsonrpc"] = "2.0",
- ["id"] = id?.DeepClone(),
- ["error"] = new JsonObject
+ private Task WriteResponseAsync(TextWriter stdout, JsonNode id, JsonNode? result, CancellationToken cancellationToken)
+ => WriteJsonLineAsync(
+ stdout,
+ new JsonObject
{
- ["code"] = code,
- ["message"] = message,
+ ["jsonrpc"] = "2.0",
+ ["id"] = id.DeepClone(),
+ ["result"] = result ?? new JsonObject(),
},
- };
- await stdout.WriteLineAsync(response.ToJsonString()).ConfigureAwait(false);
- }
+ cancellationToken);
+
+ private Task WriteErrorAsync(TextWriter stdout, JsonNode? id, int code, string message, CancellationToken cancellationToken)
+ => WriteJsonLineAsync(
+ stdout,
+ new JsonObject
+ {
+ ["jsonrpc"] = "2.0",
+ ["id"] = id?.DeepClone(),
+ ["error"] = new JsonObject
+ {
+ ["code"] = code,
+ ["message"] = message,
+ },
+ },
+ cancellationToken);
private sealed class AcpJsonRpcException(int code, string message) : Exception(message)
{
diff --git a/src/SharpClaw.Code.Acp/SharpClaw.Code.Acp.csproj b/src/SharpClaw.Code.Acp/SharpClaw.Code.Acp.csproj
index 3b86ce5..c583f17 100644
--- a/src/SharpClaw.Code.Acp/SharpClaw.Code.Acp.csproj
+++ b/src/SharpClaw.Code.Acp/SharpClaw.Code.Acp.csproj
@@ -13,6 +13,9 @@
+
+
+
diff --git a/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs b/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs
index c4f8b31..49c9439 100644
--- a/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs
+++ b/src/SharpClaw.Code.Agents/Internal/ProviderBackedAgentKernel.cs
@@ -9,7 +9,6 @@
using SharpClaw.Code.Providers.Models;
using SharpClaw.Code.Protocol.Enums;
using SharpClaw.Code.Telemetry.Diagnostics;
-using SharpClaw.Code.Telemetry.Metrics;
using SharpClaw.Code.Tools.Models;
namespace SharpClaw.Code.Agents.Internal;
@@ -180,7 +179,6 @@ internal async Task ExecuteAsync(
providerSw.Stop();
providerScope.SetCompleted(iterationUsage?.InputTokens, iterationUsage?.OutputTokens);
- SharpClawMeterSource.ProviderDuration.Record(providerSw.Elapsed.TotalMilliseconds);
}
catch (Exception ex)
{
diff --git a/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs b/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs
index 8bb9073..25d23f6 100644
--- a/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs
+++ b/src/SharpClaw.Code.Agents/Services/AgentFrameworkBridge.cs
@@ -46,7 +46,11 @@ public async Task RunAsync(AgentFrameworkRequest request, Cancel
AllowDangerousBypass: false,
IsInteractive: request.Context.IsInteractive,
SourceKind: PermissionRequestSourceKind.Runtime,
- SourceName: null,
+ SourceName: request.Context.Metadata is not null
+ && request.Context.Metadata.TryGetValue("acp", out var acp)
+ && string.Equals(acp, "true", StringComparison.OrdinalIgnoreCase)
+ ? "acp"
+ : null,
TrustedPluginNames: null,
TrustedMcpServerNames: null,
PrimaryMode: request.Context.PrimaryMode,
diff --git a/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs b/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs
index 631b7d7..d54fac9 100644
--- a/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs
+++ b/src/SharpClaw.Code.Cli/Composition/CliServiceCollectionExtensions.cs
@@ -17,6 +17,7 @@ public static class CliServiceCollectionExtensions
/// The updated service collection.
public static IServiceCollection AddSharpClawCli(this IServiceCollection services)
{
+ services.AddSharpClawAcp();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
@@ -27,8 +28,6 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
- services.AddSingleton();
-
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
@@ -38,7 +37,9 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
@@ -49,6 +50,7 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
@@ -62,7 +64,9 @@ public static IServiceCollection AddSharpClawCli(this IServiceCollection service
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/src/SharpClaw.Code.Commands/CliCommandFactory.cs b/src/SharpClaw.Code.Commands/CliCommandFactory.cs
index 6239a93..aaf730c 100644
--- a/src/SharpClaw.Code.Commands/CliCommandFactory.cs
+++ b/src/SharpClaw.Code.Commands/CliCommandFactory.cs
@@ -80,7 +80,7 @@ private async Task AddDiscoveredCustomCommandsAsync(RootCommand rootCommand, Can
try
{
var result = await runtimeCommandService
- .ExecuteCustomCommandAsync(definition.Name, argLine, ToRuntimeContext(ctx), cancellationToken)
+ .ExecuteCustomCommandAsync(definition.Name, argLine, ctx.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
await outputRendererDispatcher.RenderTurnExecutionResultAsync(result, ctx.OutputFormat, cancellationToken).ConfigureAwait(false);
return 0;
@@ -102,14 +102,4 @@ await outputRendererDispatcher.RenderCommandResultAsync(
rootCommand.Subcommands.Add(command);
}
}
-
- private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context)
- => new(
- context.WorkingDirectory,
- context.Model,
- context.PermissionMode,
- context.OutputFormat,
- context.PrimaryMode,
- context.SessionId,
- context.AgentId);
}
diff --git a/src/SharpClaw.Code.Commands/Handlers/CompactCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/CompactCommandHandler.cs
index 83a042a..311feb2 100644
--- a/src/SharpClaw.Code.Commands/Handlers/CompactCommandHandler.cs
+++ b/src/SharpClaw.Code.Commands/Handlers/CompactCommandHandler.cs
@@ -31,7 +31,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions)
{
var context = globalOptions.Resolve(parseResult);
var id = parseResult.GetValue(idOption);
- var result = await runtimeCommandService.CompactSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false);
+ var result = await runtimeCommandService.CompactSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
});
@@ -42,18 +42,8 @@ public Command BuildCommand(GlobalCliOptions globalOptions)
public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken)
{
var id = command.Arguments.Length > 0 ? command.Arguments[0] : null;
- var result = await runtimeCommandService.CompactSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false);
+ var result = await runtimeCommandService.CompactSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
}
-
- private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context)
- => new(
- context.WorkingDirectory,
- context.Model,
- context.PermissionMode,
- context.OutputFormat,
- context.PrimaryMode,
- context.SessionId,
- context.AgentId);
}
diff --git a/src/SharpClaw.Code.Commands/Handlers/DoctorCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/DoctorCommandHandler.cs
index d6ffde5..559a107 100644
--- a/src/SharpClaw.Code.Commands/Handlers/DoctorCommandHandler.cs
+++ b/src/SharpClaw.Code.Commands/Handlers/DoctorCommandHandler.cs
@@ -28,7 +28,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions)
command.SetAction(async (parseResult, cancellationToken) =>
{
var context = globalOptions.Resolve(parseResult);
- var result = await runtimeCommandService.RunDoctorAsync(ToRuntimeContext(context), cancellationToken);
+ var result = await runtimeCommandService.RunDoctorAsync(context.ToRuntimeCommandContext(), cancellationToken);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken);
return result.ExitCode;
});
@@ -39,17 +39,8 @@ public Command BuildCommand(GlobalCliOptions globalOptions)
///
public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken)
{
- var result = await runtimeCommandService.RunDoctorAsync(ToRuntimeContext(context), cancellationToken);
+ var result = await runtimeCommandService.RunDoctorAsync(context.ToRuntimeCommandContext(), cancellationToken);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken);
return result.ExitCode;
}
-
- private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context)
- => new(
- context.WorkingDirectory,
- context.Model,
- context.PermissionMode,
- context.OutputFormat,
- context.PrimaryMode,
- context.SessionId);
}
diff --git a/src/SharpClaw.Code.Commands/Handlers/EditorSlashCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/EditorSlashCommandHandler.cs
index f12b571..2bfa487 100644
--- a/src/SharpClaw.Code.Commands/Handlers/EditorSlashCommandHandler.cs
+++ b/src/SharpClaw.Code.Commands/Handlers/EditorSlashCommandHandler.cs
@@ -36,7 +36,7 @@ await outputRendererDispatcher.RenderCommandResultAsync(
try
{
var result = await runtimeCommandService
- .ExecutePromptAsync(composed.Trim(), ToRuntimeContext(context), cancellationToken)
+ .ExecutePromptAsync(composed.Trim(), context.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
await outputRendererDispatcher.RenderTurnExecutionResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return 0;
@@ -50,13 +50,4 @@ await outputRendererDispatcher.RenderCommandResultAsync(
return 1;
}
}
-
- private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context)
- => new(
- context.WorkingDirectory,
- context.Model,
- context.PermissionMode,
- context.OutputFormat,
- context.PrimaryMode,
- context.SessionId);
}
diff --git a/src/SharpClaw.Code.Commands/Handlers/ExportSlashCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ExportSlashCommandHandler.cs
index a25d8f9..2f4150d 100644
--- a/src/SharpClaw.Code.Commands/Handlers/ExportSlashCommandHandler.cs
+++ b/src/SharpClaw.Code.Commands/Handlers/ExportSlashCommandHandler.cs
@@ -48,7 +48,7 @@ private async Task ExportAsync(
CancellationToken cancellationToken)
{
var result = await runtimeCommandService
- .ExportSessionAsync(sessionId, format, null, ToRuntimeContext(context), cancellationToken)
+ .ExportSessionAsync(sessionId, format, null, context.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
@@ -62,13 +62,4 @@ await outputRendererDispatcher.RenderCommandResultAsync(
cancellationToken).ConfigureAwait(false);
return success ? 0 : 1;
}
-
- private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context)
- => new(
- context.WorkingDirectory,
- context.Model,
- context.PermissionMode,
- context.OutputFormat,
- context.PrimaryMode,
- context.SessionId);
}
diff --git a/src/SharpClaw.Code.Commands/Handlers/IndexCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/IndexCommandHandler.cs
new file mode 100644
index 0000000..01b0f0f
--- /dev/null
+++ b/src/SharpClaw.Code.Commands/Handlers/IndexCommandHandler.cs
@@ -0,0 +1,120 @@
+using System.CommandLine;
+using System.Text.Json;
+using SharpClaw.Code.Commands.Models;
+using SharpClaw.Code.Commands.Options;
+using SharpClaw.Code.Memory.Abstractions;
+using SharpClaw.Code.Protocol.Commands;
+using SharpClaw.Code.Protocol.Models;
+using SharpClaw.Code.Protocol.Serialization;
+
+namespace SharpClaw.Code.Commands;
+
+///
+/// Refreshes and queries the persisted workspace knowledge index.
+///
+public sealed class IndexCommandHandler(
+ IWorkspaceIndexService workspaceIndexService,
+ IWorkspaceSearchService workspaceSearchService,
+ OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler
+{
+ ///
+ public string Name => "index";
+
+ ///
+ public string Description => "Refreshes, inspects, and queries the workspace knowledge index.";
+
+ ///
+ public string CommandName => Name;
+
+ ///
+ public Command BuildCommand(GlobalCliOptions globalOptions)
+ {
+ var command = new Command(Name, Description);
+
+ var refresh = new Command("refresh", "Refreshes the workspace index.");
+ refresh.SetAction((parseResult, cancellationToken) => ExecuteRefreshAsync(globalOptions.Resolve(parseResult), cancellationToken));
+ command.Subcommands.Add(refresh);
+
+ var stats = new Command("stats", "Shows workspace index status.");
+ stats.SetAction((parseResult, cancellationToken) => ExecuteStatsAsync(globalOptions.Resolve(parseResult), cancellationToken));
+ command.Subcommands.Add(stats);
+
+ var query = new Command("query", "Searches the workspace index.");
+ var queryArgument = new Argument("query") { Description = "Search query." };
+ var limitOption = new Option("--limit") { Description = "Maximum number of hits to return." };
+ query.Arguments.Add(queryArgument);
+ query.Options.Add(limitOption);
+ query.SetAction((parseResult, cancellationToken) => ExecuteQueryAsync(
+ globalOptions.Resolve(parseResult),
+ parseResult.GetValue(queryArgument) ?? string.Empty,
+ parseResult.GetValue(limitOption),
+ cancellationToken));
+ command.Subcommands.Add(query);
+
+ command.SetAction((parseResult, cancellationToken) => ExecuteStatsAsync(globalOptions.Resolve(parseResult), cancellationToken));
+ return command;
+ }
+
+ ///
+ public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken)
+ {
+ if (command.Arguments.Length > 0 && string.Equals(command.Arguments[0], "refresh", StringComparison.OrdinalIgnoreCase))
+ {
+ return ExecuteRefreshAsync(context, cancellationToken);
+ }
+
+ if (command.Arguments.Length > 0 && string.Equals(command.Arguments[0], "query", StringComparison.OrdinalIgnoreCase))
+ {
+ var query = string.Join(' ', command.Arguments.Skip(1));
+ return ExecuteQueryAsync(context, query, null, cancellationToken);
+ }
+
+ return ExecuteStatsAsync(context, cancellationToken);
+ }
+
+ private async Task ExecuteRefreshAsync(CommandExecutionContext context, CancellationToken cancellationToken)
+ {
+ var result = await workspaceIndexService.RefreshAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false);
+ return await RenderAsync(context, result, $"Indexed {result.IndexedFileCount} file(s).", cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task ExecuteStatsAsync(CommandExecutionContext context, CancellationToken cancellationToken)
+ {
+ var result = await workspaceIndexService.GetStatusAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false);
+ return await RenderAsync(
+ context,
+ result,
+ result.RefreshedAtUtc is null
+ ? "Workspace index has not been built yet."
+ : $"Workspace index refreshed {result.RefreshedAtUtc:O}.",
+ cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task ExecuteQueryAsync(
+ CommandExecutionContext context,
+ string query,
+ int? limit,
+ CancellationToken cancellationToken)
+ {
+ var result = await workspaceSearchService
+ .SearchAsync(context.WorkingDirectory, new WorkspaceSearchRequest(query, limit), cancellationToken)
+ .ConfigureAwait(false);
+ return await RenderAsync(context, result, $"{result.Hits.Length} workspace hit(s).", cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task RenderAsync(
+ CommandExecutionContext context,
+ TPayload payload,
+ string message,
+ CancellationToken cancellationToken)
+ {
+ var commandResult = new CommandResult(
+ true,
+ 0,
+ context.OutputFormat,
+ message,
+ JsonSerializer.Serialize(payload, ProtocolJsonContext.Default.Options));
+ await outputRendererDispatcher.RenderCommandResultAsync(commandResult, context.OutputFormat, cancellationToken).ConfigureAwait(false);
+ return 0;
+ }
+}
diff --git a/src/SharpClaw.Code.Commands/Handlers/MemoryCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/MemoryCommandHandler.cs
new file mode 100644
index 0000000..554d1f4
--- /dev/null
+++ b/src/SharpClaw.Code.Commands/Handlers/MemoryCommandHandler.cs
@@ -0,0 +1,177 @@
+using System.CommandLine;
+using System.Text.Json;
+using SharpClaw.Code.Commands.Models;
+using SharpClaw.Code.Commands.Options;
+using SharpClaw.Code.Memory.Abstractions;
+using SharpClaw.Code.Protocol.Commands;
+using SharpClaw.Code.Protocol.Models;
+using SharpClaw.Code.Protocol.Serialization;
+
+namespace SharpClaw.Code.Commands;
+
+///
+/// Lists, saves, and deletes structured memory entries.
+///
+public sealed class MemoryCommandHandler(
+ IPersistentMemoryStore persistentMemoryStore,
+ OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler
+{
+ ///
+ public string Name => "memory";
+
+ ///
+ public string Description => "Lists, saves, and deletes durable project and user memory entries.";
+
+ ///
+ public string CommandName => Name;
+
+ ///
+ public Command BuildCommand(GlobalCliOptions globalOptions)
+ {
+ var command = new Command(Name, Description);
+
+ var list = new Command("list", "Lists memory entries.");
+ var scopeOption = new Option("--scope") { Description = "Optional memory scope filter." };
+ var queryOption = new Option("--query") { Description = "Optional free-text filter." };
+ var limitOption = new Option("--limit") { Description = "Maximum number of rows to return." };
+ list.Options.Add(scopeOption);
+ list.Options.Add(queryOption);
+ list.Options.Add(limitOption);
+ list.SetAction((parseResult, cancellationToken) => ExecuteListAsync(
+ globalOptions.Resolve(parseResult),
+ parseResult.GetValue(scopeOption),
+ parseResult.GetValue(queryOption),
+ parseResult.GetValue(limitOption),
+ cancellationToken));
+ command.Subcommands.Add(list);
+
+ var save = new Command("save", "Saves a memory entry.");
+ var saveScope = new Option("--scope") { Description = "Memory scope.", DefaultValueFactory = _ => MemoryScope.Project };
+ var sourceOption = new Option("--source") { Description = "Source label.", DefaultValueFactory = _ => "manual" };
+ var contentArgument = new Argument("content") { Description = "Memory content." };
+ save.Options.Add(saveScope);
+ save.Options.Add(sourceOption);
+ save.Arguments.Add(contentArgument);
+ save.SetAction((parseResult, cancellationToken) => ExecuteSaveAsync(
+ globalOptions.Resolve(parseResult),
+ parseResult.GetValue(saveScope),
+ parseResult.GetValue(sourceOption) ?? "manual",
+ parseResult.GetValue(contentArgument) ?? string.Empty,
+ cancellationToken));
+ command.Subcommands.Add(save);
+
+ var delete = new Command("delete", "Deletes a memory entry.");
+ var deleteScope = new Option("--scope") { Description = "Memory scope.", DefaultValueFactory = _ => MemoryScope.Project };
+ var idArgument = new Argument("id") { Description = "Memory id." };
+ delete.Options.Add(deleteScope);
+ delete.Arguments.Add(idArgument);
+ delete.SetAction((parseResult, cancellationToken) => ExecuteDeleteAsync(
+ globalOptions.Resolve(parseResult),
+ parseResult.GetValue(deleteScope),
+ parseResult.GetValue(idArgument) ?? string.Empty,
+ cancellationToken));
+ command.Subcommands.Add(delete);
+
+ command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), null, null, null, cancellationToken));
+ return command;
+ }
+
+ ///
+ public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken)
+ {
+ if (command.Arguments.Length > 0 && string.Equals(command.Arguments[0], "save", StringComparison.OrdinalIgnoreCase))
+ {
+ var parsedScope = MemoryScope.Project;
+ var hasExplicitScope = command.Arguments.Length > 1 && Enum.TryParse(command.Arguments[1], true, out parsedScope);
+ var scope = hasExplicitScope ? parsedScope : MemoryScope.Project;
+ var content = string.Join(' ', command.Arguments.Skip(hasExplicitScope ? 2 : 1));
+ return ExecuteSaveAsync(context, scope, "manual", content, cancellationToken);
+ }
+
+ if (command.Arguments.Length > 0 && string.Equals(command.Arguments[0], "delete", StringComparison.OrdinalIgnoreCase))
+ {
+ var id = command.Arguments.Length > 1 ? command.Arguments[1] : string.Empty;
+ return ExecuteDeleteAsync(context, MemoryScope.Project, id, cancellationToken);
+ }
+
+ var query = command.Arguments.Length > 1 && string.Equals(command.Arguments[0], "list", StringComparison.OrdinalIgnoreCase)
+ ? string.Join(' ', command.Arguments.Skip(1))
+ : string.Join(' ', command.Arguments);
+ return ExecuteListAsync(context, null, string.IsNullOrWhiteSpace(query) ? null : query, null, cancellationToken);
+ }
+
+ private async Task ExecuteListAsync(
+ CommandExecutionContext context,
+ MemoryScope? scope,
+ string? query,
+ int? limit,
+ CancellationToken cancellationToken)
+ {
+ var rows = await persistentMemoryStore
+ .ListAsync(context.WorkingDirectory, scope, query, Math.Clamp(limit.GetValueOrDefault(20), 1, 100), cancellationToken)
+ .ConfigureAwait(false);
+ return await RenderAsync(context, rows.ToList(), $"{rows.Count} memory entr{(rows.Count == 1 ? "y" : "ies")}.", cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task ExecuteSaveAsync(
+ CommandExecutionContext context,
+ MemoryScope scope,
+ string source,
+ string content,
+ CancellationToken cancellationToken)
+ {
+ var now = DateTimeOffset.UtcNow;
+ var entry = new MemoryEntry(
+ Id: $"memory-{Guid.NewGuid():N}",
+ Scope: scope,
+ Content: content,
+ Source: source,
+ SourceSessionId: context.SessionId,
+ SourceTurnId: null,
+ Tags: [],
+ Confidence: null,
+ RelatedFilePath: null,
+ RelatedSymbolName: null,
+ CreatedAtUtc: now,
+ UpdatedAtUtc: now);
+ var saved = await persistentMemoryStore
+ .SaveAsync(scope == MemoryScope.Project ? context.WorkingDirectory : null, entry, cancellationToken)
+ .ConfigureAwait(false);
+ return await RenderAsync(context, saved, $"Saved {scope.ToString().ToLowerInvariant()} memory {saved.Id}.", cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task ExecuteDeleteAsync(
+ CommandExecutionContext context,
+ MemoryScope scope,
+ string id,
+ CancellationToken cancellationToken)
+ {
+ var deleted = await persistentMemoryStore
+ .DeleteAsync(scope == MemoryScope.Project ? context.WorkingDirectory : null, scope, id, cancellationToken)
+ .ConfigureAwait(false);
+ var result = new CommandResult(
+ deleted,
+ deleted ? 0 : 1,
+ context.OutputFormat,
+ deleted ? $"Deleted memory {id}." : $"Memory {id} was not found.",
+ null);
+ await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
+ return result.ExitCode;
+ }
+
+ private async Task RenderAsync(
+ CommandExecutionContext context,
+ TPayload payload,
+ string message,
+ CancellationToken cancellationToken)
+ {
+ var result = new CommandResult(
+ true,
+ 0,
+ context.OutputFormat,
+ message,
+ JsonSerializer.Serialize(payload, ProtocolJsonContext.Default.Options));
+ await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
+ return 0;
+ }
+}
diff --git a/src/SharpClaw.Code.Commands/Handlers/ModelsCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ModelsCommandHandler.cs
index 5cdc014..25b0d2f 100644
--- a/src/SharpClaw.Code.Commands/Handlers/ModelsCommandHandler.cs
+++ b/src/SharpClaw.Code.Commands/Handlers/ModelsCommandHandler.cs
@@ -1,12 +1,9 @@
using System.CommandLine;
using System.Text.Json;
-using Microsoft.Extensions.Options;
using SharpClaw.Code.Commands.Models;
using SharpClaw.Code.Commands.Options;
using SharpClaw.Code.Providers.Abstractions;
-using SharpClaw.Code.Providers.Configuration;
using SharpClaw.Code.Protocol.Commands;
-using SharpClaw.Code.Protocol.Models;
using SharpClaw.Code.Protocol.Serialization;
namespace SharpClaw.Code.Commands;
@@ -15,11 +12,7 @@ namespace SharpClaw.Code.Commands;
/// Lists the configured provider/model surface available to SharpClaw.
///
public sealed class ModelsCommandHandler(
- IEnumerable modelProviders,
- IAuthFlowService authFlowService,
- IOptions providerCatalogOptions,
- IOptions anthropicOptions,
- IOptions openAiCompatibleOptions,
+ IProviderCatalogService providerCatalogService,
OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler
{
///
@@ -45,39 +38,16 @@ public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionC
private async Task ExecuteAsync(CommandExecutionContext context, CancellationToken cancellationToken)
{
- var entries = new List();
- var aliasesByProvider = providerCatalogOptions.Value.ModelAliases
- .GroupBy(static pair => pair.Value.ProviderName, StringComparer.OrdinalIgnoreCase)
- .ToDictionary(
- static group => group.Key,
- static group => group.Select(pair => pair.Key).OrderBy(static alias => alias, StringComparer.OrdinalIgnoreCase).ToArray(),
- StringComparer.OrdinalIgnoreCase);
-
- foreach (var provider in modelProviders.OrderBy(static provider => provider.ProviderName, StringComparer.OrdinalIgnoreCase))
- {
- var auth = await authFlowService.GetStatusAsync(provider.ProviderName, cancellationToken).ConfigureAwait(false);
- entries.Add(
- new ProviderModelCatalogEntry(
- provider.ProviderName,
- ResolveDefaultModel(provider.ProviderName),
- aliasesByProvider.TryGetValue(provider.ProviderName, out var aliases) ? aliases : [],
- auth));
- }
+ var entries = await providerCatalogService.ListAsync(cancellationToken).ConfigureAwait(false);
+ var payload = entries.ToList();
var result = new CommandResult(
true,
0,
context.OutputFormat,
- $"{entries.Count} provider model surface(s).",
- JsonSerializer.Serialize(entries, ProtocolJsonContext.Default.ListProviderModelCatalogEntry));
+ $"{payload.Count} provider model surface(s).",
+ JsonSerializer.Serialize(payload, ProtocolJsonContext.Default.ListProviderModelCatalogEntry));
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return 0;
}
-
- private string ResolveDefaultModel(string providerName)
- => string.Equals(providerName, anthropicOptions.Value.ProviderName, StringComparison.OrdinalIgnoreCase)
- ? anthropicOptions.Value.DefaultModel
- : string.Equals(providerName, openAiCompatibleOptions.Value.ProviderName, StringComparison.OrdinalIgnoreCase)
- ? openAiCompatibleOptions.Value.DefaultModel
- : "default";
}
diff --git a/src/SharpClaw.Code.Commands/Handlers/PromptCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/PromptCommandHandler.cs
index 024d9c2..3f22474 100644
--- a/src/SharpClaw.Code.Commands/Handlers/PromptCommandHandler.cs
+++ b/src/SharpClaw.Code.Commands/Handlers/PromptCommandHandler.cs
@@ -37,7 +37,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions)
var context = globalOptions.Resolve(parseResult);
try
{
- var result = await runtimeCommandService.ExecutePromptAsync(prompt, ToRuntimeContext(context), cancellationToken);
+ var result = await runtimeCommandService.ExecutePromptAsync(prompt, context.ToRuntimeCommandContext(), cancellationToken);
await outputRendererDispatcher.RenderTurnExecutionResultAsync(result, context.OutputFormat, cancellationToken);
return 0;
}
@@ -53,17 +53,6 @@ await outputRendererDispatcher.RenderCommandResultAsync(
return command;
}
-
- private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context)
- => new(
- context.WorkingDirectory,
- context.Model,
- context.PermissionMode,
- context.OutputFormat,
- context.PrimaryMode,
- context.SessionId,
- context.AgentId);
-
private static CommandResult CreateProviderFailureResult(ProviderExecutionException exception, OutputFormat outputFormat)
=> new(
Succeeded: false,
diff --git a/src/SharpClaw.Code.Commands/Handlers/RedoCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/RedoCommandHandler.cs
index 9c72aab..ed4bce1 100644
--- a/src/SharpClaw.Code.Commands/Handlers/RedoCommandHandler.cs
+++ b/src/SharpClaw.Code.Commands/Handlers/RedoCommandHandler.cs
@@ -31,7 +31,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions)
{
var ctx = globalOptions.Resolve(parseResult);
var sid = parseResult.GetValue(id);
- var result = await runtimeCommandService.RedoAsync(sid, ToRuntimeContext(ctx), cancellationToken).ConfigureAwait(false);
+ var result = await runtimeCommandService.RedoAsync(sid, ctx.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, ctx.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
});
@@ -48,18 +48,9 @@ public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionC
private async Task ExecuteRedoAsync(string? sessionId, CommandExecutionContext context, CancellationToken cancellationToken)
{
var result = await runtimeCommandService
- .RedoAsync(sessionId, ToRuntimeContext(context), cancellationToken)
+ .RedoAsync(sessionId, context.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
}
-
- private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context)
- => new(
- context.WorkingDirectory,
- context.Model,
- context.PermissionMode,
- context.OutputFormat,
- context.PrimaryMode,
- context.SessionId);
}
diff --git a/src/SharpClaw.Code.Commands/Handlers/ServeCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ServeCommandHandler.cs
index 5b17bf3..7308740 100644
--- a/src/SharpClaw.Code.Commands/Handlers/ServeCommandHandler.cs
+++ b/src/SharpClaw.Code.Commands/Handlers/ServeCommandHandler.cs
@@ -40,7 +40,7 @@ await outputRendererDispatcher.RenderCommandResultAsync(
context.OutputFormat,
cancellationToken).ConfigureAwait(false);
await workspaceHttpServer
- .RunAsync(context.WorkingDirectory, host, port, ToRuntimeContext(context), cancellationToken)
+ .RunAsync(context.WorkingDirectory, host, port, context.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
return 0;
});
@@ -66,17 +66,7 @@ await outputRendererDispatcher.RenderCommandResultAsync(
new CommandResult(true, 0, context.OutputFormat, "Starting embedded SharpClaw server. Press Ctrl+C to stop.", null),
context.OutputFormat,
cancellationToken).ConfigureAwait(false);
- await workspaceHttpServer.RunAsync(context.WorkingDirectory, host, port, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false);
+ await workspaceHttpServer.RunAsync(context.WorkingDirectory, host, port, context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false);
return 0;
}
-
- private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context)
- => new(
- context.WorkingDirectory,
- context.Model,
- context.PermissionMode,
- context.OutputFormat,
- context.PrimaryMode,
- context.SessionId,
- context.AgentId);
}
diff --git a/src/SharpClaw.Code.Commands/Handlers/SessionCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/SessionCommandHandler.cs
index a9418ae..e956e1b 100644
--- a/src/SharpClaw.Code.Commands/Handlers/SessionCommandHandler.cs
+++ b/src/SharpClaw.Code.Commands/Handlers/SessionCommandHandler.cs
@@ -39,7 +39,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions)
var context = globalOptions.Resolve(parseResult);
var id = parseResult.GetValue(idOption);
var result = await runtimeCommandService
- .InspectSessionAsync(id, ToRuntimeContext(context), cancellationToken)
+ .InspectSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
@@ -57,7 +57,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions)
var context = globalOptions.Resolve(parseResult);
var id = parseResult.GetValue(forkId);
var result = await runtimeCommandService
- .ForkSessionAsync(id, ToRuntimeContext(context), cancellationToken)
+ .ForkSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
@@ -85,7 +85,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions)
: SessionExportFormat.Markdown;
var path = parseResult.GetValue(outPath);
var result = await runtimeCommandService
- .ExportSessionAsync(sid, fmt, path, ToRuntimeContext(context), cancellationToken)
+ .ExportSessionAsync(sid, fmt, path, context.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
@@ -97,7 +97,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions)
{
var context = globalOptions.Resolve(parseResult);
var result = await runtimeCommandService
- .ListSessionsAsync(ToRuntimeContext(context), cancellationToken)
+ .ListSessionsAsync(context.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
@@ -112,7 +112,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions)
var context = globalOptions.Resolve(parseResult);
var sid = parseResult.GetValue(attachId) ?? throw new InvalidOperationException("--id is required.");
var result = await runtimeCommandService
- .AttachSessionAsync(sid, ToRuntimeContext(context), cancellationToken)
+ .AttachSessionAsync(sid, context.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
@@ -124,7 +124,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions)
{
var context = globalOptions.Resolve(parseResult);
var result = await runtimeCommandService
- .DetachSessionAsync(ToRuntimeContext(context), cancellationToken)
+ .DetachSessionAsync(context.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
@@ -142,7 +142,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions)
var sid = parseResult.GetValue(bundleId);
var outp = parseResult.GetValue(bundleOut);
var result = await runtimeCommandService
- .ExportPortableSessionBundleAsync(sid, outp, ToRuntimeContext(context), cancellationToken)
+ .ExportPortableSessionBundleAsync(sid, outp, context.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
@@ -171,7 +171,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions)
var replace = parseResult.GetValue(importReplace);
var attach = parseResult.GetValue(importAttach);
var result = await runtimeCommandService
- .ImportPortableSessionBundleAsync(from, replace, attach, ToRuntimeContext(context), cancellationToken)
+ .ImportPortableSessionBundleAsync(from, replace, attach, context.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
@@ -255,7 +255,7 @@ public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionC
private async Task ExecuteInspectAsync(string? sessionId, CommandExecutionContext context, CancellationToken cancellationToken)
{
var result = await runtimeCommandService
- .InspectSessionAsync(sessionId, ToRuntimeContext(context), cancellationToken)
+ .InspectSessionAsync(sessionId, context.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
@@ -264,7 +264,7 @@ private async Task ExecuteInspectAsync(string? sessionId, CommandExecutionC
private async Task ExecuteForkAsync(string? sourceId, CommandExecutionContext context, CancellationToken cancellationToken)
{
var result = await runtimeCommandService
- .ForkSessionAsync(sourceId, ToRuntimeContext(context), cancellationToken)
+ .ForkSessionAsync(sourceId, context.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
@@ -273,7 +273,7 @@ private async Task ExecuteForkAsync(string? sourceId, CommandExecutionConte
private async Task ExecuteListAsync(CommandExecutionContext context, CancellationToken cancellationToken)
{
var result = await runtimeCommandService
- .ListSessionsAsync(ToRuntimeContext(context), cancellationToken)
+ .ListSessionsAsync(context.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
@@ -282,7 +282,7 @@ private async Task ExecuteListAsync(CommandExecutionContext context, Cancel
private async Task ExecuteAttachAsync(string sessionId, CommandExecutionContext context, CancellationToken cancellationToken)
{
var result = await runtimeCommandService
- .AttachSessionAsync(sessionId, ToRuntimeContext(context), cancellationToken)
+ .AttachSessionAsync(sessionId, context.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
@@ -291,7 +291,7 @@ private async Task ExecuteAttachAsync(string sessionId, CommandExecutionCon
private async Task ExecuteDetachAsync(CommandExecutionContext context, CancellationToken cancellationToken)
{
var result = await runtimeCommandService
- .DetachSessionAsync(ToRuntimeContext(context), cancellationToken)
+ .DetachSessionAsync(context.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
@@ -304,7 +304,7 @@ private async Task ExecuteBundleAsync(
CancellationToken cancellationToken)
{
var result = await runtimeCommandService
- .ExportPortableSessionBundleAsync(sessionId, outputPath, ToRuntimeContext(context), cancellationToken)
+ .ExportPortableSessionBundleAsync(sessionId, outputPath, context.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
@@ -318,7 +318,7 @@ private async Task ExecuteImportAsync(
CancellationToken cancellationToken)
{
var result = await runtimeCommandService
- .ImportPortableSessionBundleAsync(bundleZipPath, replaceExisting, attachAfterImport, ToRuntimeContext(context), cancellationToken)
+ .ImportPortableSessionBundleAsync(bundleZipPath, replaceExisting, attachAfterImport, context.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
@@ -331,7 +331,7 @@ private async Task ExecuteExportAsync(
CancellationToken cancellationToken)
{
var result = await runtimeCommandService
- .ExportSessionAsync(sessionId, format, null, ToRuntimeContext(context), cancellationToken)
+ .ExportSessionAsync(sessionId, format, null, context.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
@@ -345,13 +345,4 @@ await outputRendererDispatcher.RenderCommandResultAsync(
cancellationToken).ConfigureAwait(false);
return success ? 0 : 1;
}
-
- private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context)
- => new(
- context.WorkingDirectory,
- context.Model,
- context.PermissionMode,
- context.OutputFormat,
- context.PrimaryMode,
- context.SessionId);
}
diff --git a/src/SharpClaw.Code.Commands/Handlers/ShareCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ShareCommandHandler.cs
index 0b8afe0..fdd3a45 100644
--- a/src/SharpClaw.Code.Commands/Handlers/ShareCommandHandler.cs
+++ b/src/SharpClaw.Code.Commands/Handlers/ShareCommandHandler.cs
@@ -31,7 +31,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions)
{
var context = globalOptions.Resolve(parseResult);
var id = parseResult.GetValue(idOption);
- var result = await runtimeCommandService.ShareSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false);
+ var result = await runtimeCommandService.ShareSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
});
@@ -42,18 +42,8 @@ public Command BuildCommand(GlobalCliOptions globalOptions)
public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken)
{
var id = command.Arguments.Length > 0 ? command.Arguments[0] : null;
- var result = await runtimeCommandService.ShareSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false);
+ var result = await runtimeCommandService.ShareSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
}
-
- private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context)
- => new(
- context.WorkingDirectory,
- context.Model,
- context.PermissionMode,
- context.OutputFormat,
- context.PrimaryMode,
- context.SessionId,
- context.AgentId);
}
diff --git a/src/SharpClaw.Code.Commands/Handlers/StatusCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/StatusCommandHandler.cs
index be94b10..0ec69e4 100644
--- a/src/SharpClaw.Code.Commands/Handlers/StatusCommandHandler.cs
+++ b/src/SharpClaw.Code.Commands/Handlers/StatusCommandHandler.cs
@@ -28,7 +28,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions)
command.SetAction(async (parseResult, cancellationToken) =>
{
var context = globalOptions.Resolve(parseResult);
- var result = await runtimeCommandService.GetStatusAsync(ToRuntimeContext(context), cancellationToken);
+ var result = await runtimeCommandService.GetStatusAsync(context.ToRuntimeCommandContext(), cancellationToken);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken);
return result.ExitCode;
});
@@ -39,17 +39,8 @@ public Command BuildCommand(GlobalCliOptions globalOptions)
///
public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken)
{
- var result = await runtimeCommandService.GetStatusAsync(ToRuntimeContext(context), cancellationToken);
+ var result = await runtimeCommandService.GetStatusAsync(context.ToRuntimeCommandContext(), cancellationToken);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken);
return result.ExitCode;
}
-
- private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context)
- => new(
- context.WorkingDirectory,
- context.Model,
- context.PermissionMode,
- context.OutputFormat,
- context.PrimaryMode,
- context.SessionId);
}
diff --git a/src/SharpClaw.Code.Commands/Handlers/ToolPackagesCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/ToolPackagesCommandHandler.cs
new file mode 100644
index 0000000..afa50ce
--- /dev/null
+++ b/src/SharpClaw.Code.Commands/Handlers/ToolPackagesCommandHandler.cs
@@ -0,0 +1,152 @@
+using System.CommandLine;
+using System.Text.Json;
+using SharpClaw.Code.Commands.Options;
+using SharpClaw.Code.Protocol.Commands;
+using SharpClaw.Code.Protocol.Models;
+using SharpClaw.Code.Protocol.Serialization;
+using SharpClaw.Code.Tools.Abstractions;
+
+namespace SharpClaw.Code.Commands;
+
+///
+/// Lists and installs packaged tool bundles for the current workspace.
+///
+public sealed class ToolPackagesCommandHandler(
+ IToolPackageService toolPackageService,
+ OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler
+{
+ ///
+ public string Name => "tool-packages";
+
+ ///
+ public string Description => "Lists and installs packaged third-party tool bundles.";
+
+ ///
+ public Command BuildCommand(GlobalCliOptions globalOptions)
+ {
+ var command = new Command(Name, Description);
+ command.Subcommands.Add(BuildListCommand(globalOptions));
+ command.Subcommands.Add(BuildInstallCommand(globalOptions));
+ command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken));
+ return command;
+ }
+
+ private Command BuildListCommand(GlobalCliOptions globalOptions)
+ {
+ var command = new Command("list", "Lists installed packaged tool bundles.");
+ command.SetAction((parseResult, cancellationToken) => ExecuteListAsync(globalOptions.Resolve(parseResult), cancellationToken));
+ return command;
+ }
+
+ private Command BuildInstallCommand(GlobalCliOptions globalOptions)
+ {
+ var command = new Command("install", "Installs a packaged tool manifest.");
+ var manifestOption = new Option("--manifest")
+ {
+ Required = true,
+ Description = "Path to a serialized ToolPackageManifest JSON file."
+ };
+ var installSourceOption = new Option("--install-source")
+ {
+ Description = "Install-source label recorded with the package.",
+ DefaultValueFactory = _ => "cli",
+ };
+ var sourceReferenceOption = new Option("--source-reference")
+ {
+ Description = "Optional package directory, binary path, or .nupkg source reference."
+ };
+ var packageSourceOption = new Option("--package-source")
+ {
+ Description = "Optional NuGet source feed URL."
+ };
+ var disableOption = new Option("--disable")
+ {
+ Description = "Install the package without enabling its plugin surface."
+ };
+
+ command.Options.Add(manifestOption);
+ command.Options.Add(installSourceOption);
+ command.Options.Add(sourceReferenceOption);
+ command.Options.Add(packageSourceOption);
+ command.Options.Add(disableOption);
+ command.SetAction(async (parseResult, cancellationToken) =>
+ {
+ var context = globalOptions.Resolve(parseResult);
+ var manifestPath = parseResult.GetValue(manifestOption) ?? throw new InvalidOperationException("The --manifest option is required.");
+ var installSource = parseResult.GetValue(installSourceOption) ?? "cli";
+ var sourceReference = parseResult.GetValue(sourceReferenceOption);
+ var packageSource = parseResult.GetValue(packageSourceOption);
+ var disable = parseResult.GetValue(disableOption);
+ return await ExecuteInstallAsync(
+ manifestPath,
+ installSource,
+ sourceReference,
+ packageSource,
+ disable,
+ context,
+ cancellationToken).ConfigureAwait(false);
+ });
+ return command;
+ }
+
+ private async Task ExecuteListAsync(
+ SharpClaw.Code.Commands.Models.CommandExecutionContext context,
+ CancellationToken cancellationToken)
+ {
+ var packages = await toolPackageService.ListInstalledAsync(context.WorkingDirectory, cancellationToken).ConfigureAwait(false);
+ var result = new CommandResult(
+ true,
+ 0,
+ context.OutputFormat,
+ packages.Count == 0 ? "No tool packages are installed for this workspace." : $"{packages.Count} tool package(s).",
+ JsonSerializer.Serialize(packages.ToList(), ProtocolJsonContext.Default.ListInstalledToolPackage));
+ await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
+ return 0;
+ }
+
+ private async Task ExecuteInstallAsync(
+ string manifestPath,
+ string installSource,
+ string? sourceReference,
+ string? packageSource,
+ bool disable,
+ SharpClaw.Code.Commands.Models.CommandExecutionContext context,
+ CancellationToken cancellationToken)
+ {
+ var manifestJson = await File.ReadAllTextAsync(manifestPath, cancellationToken).ConfigureAwait(false);
+ var manifest = JsonSerializer.Deserialize(manifestJson, ProtocolJsonContext.Default.ToolPackageManifest)
+ ?? throw new InvalidOperationException($"Manifest '{manifestPath}' could not be parsed.");
+ var resolvedSourceReference = ResolveSourceReference(manifestPath, sourceReference, manifest.Package.PackageType);
+ var installed = await toolPackageService
+ .InstallAsync(
+ context.WorkingDirectory,
+ new ToolPackageInstallRequest(
+ Manifest: manifest,
+ InstallSource: installSource,
+ EnableAfterInstall: !disable,
+ SourceReference: resolvedSourceReference,
+ PackageSource: packageSource),
+ cancellationToken)
+ .ConfigureAwait(false);
+ var result = new CommandResult(
+ true,
+ 0,
+ context.OutputFormat,
+ $"Installed tool package '{installed.Manifest.Package.PackageId}' ({installed.Manifest.Package.Version}).",
+ JsonSerializer.Serialize(installed, ProtocolJsonContext.Default.InstalledToolPackage));
+ await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
+ return 0;
+ }
+
+ private static string? ResolveSourceReference(string manifestPath, string? sourceReference, string packageType)
+ {
+ if (!string.IsNullOrWhiteSpace(sourceReference))
+ {
+ return sourceReference;
+ }
+
+ return string.Equals(packageType, "local", StringComparison.OrdinalIgnoreCase)
+ ? Path.GetDirectoryName(Path.GetFullPath(manifestPath))
+ : null;
+ }
+}
diff --git a/src/SharpClaw.Code.Commands/Handlers/UndoCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/UndoCommandHandler.cs
index 154f142..ea63703 100644
--- a/src/SharpClaw.Code.Commands/Handlers/UndoCommandHandler.cs
+++ b/src/SharpClaw.Code.Commands/Handlers/UndoCommandHandler.cs
@@ -31,7 +31,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions)
{
var ctx = globalOptions.Resolve(parseResult);
var sid = parseResult.GetValue(id);
- var result = await runtimeCommandService.UndoAsync(sid, ToRuntimeContext(ctx), cancellationToken).ConfigureAwait(false);
+ var result = await runtimeCommandService.UndoAsync(sid, ctx.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, ctx.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
});
@@ -48,18 +48,9 @@ public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionC
private async Task ExecuteUndoAsync(string? sessionId, CommandExecutionContext context, CancellationToken cancellationToken)
{
var result = await runtimeCommandService
- .UndoAsync(sessionId, ToRuntimeContext(context), cancellationToken)
+ .UndoAsync(sessionId, context.ToRuntimeCommandContext(), cancellationToken)
.ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
}
-
- private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context)
- => new(
- context.WorkingDirectory,
- context.Model,
- context.PermissionMode,
- context.OutputFormat,
- context.PrimaryMode,
- context.SessionId);
}
diff --git a/src/SharpClaw.Code.Commands/Handlers/UnshareCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/UnshareCommandHandler.cs
index ec32c45..cc4ae03 100644
--- a/src/SharpClaw.Code.Commands/Handlers/UnshareCommandHandler.cs
+++ b/src/SharpClaw.Code.Commands/Handlers/UnshareCommandHandler.cs
@@ -31,7 +31,7 @@ public Command BuildCommand(GlobalCliOptions globalOptions)
{
var context = globalOptions.Resolve(parseResult);
var id = parseResult.GetValue(idOption);
- var result = await runtimeCommandService.UnshareSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false);
+ var result = await runtimeCommandService.UnshareSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
});
@@ -42,18 +42,8 @@ public Command BuildCommand(GlobalCliOptions globalOptions)
public async Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken)
{
var id = command.Arguments.Length > 0 ? command.Arguments[0] : null;
- var result = await runtimeCommandService.UnshareSessionAsync(id, ToRuntimeContext(context), cancellationToken).ConfigureAwait(false);
+ var result = await runtimeCommandService.UnshareSessionAsync(id, context.ToRuntimeCommandContext(), cancellationToken).ConfigureAwait(false);
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return result.ExitCode;
}
-
- private static RuntimeCommandContext ToRuntimeContext(CommandExecutionContext context)
- => new(
- context.WorkingDirectory,
- context.Model,
- context.PermissionMode,
- context.OutputFormat,
- context.PrimaryMode,
- context.SessionId,
- context.AgentId);
}
diff --git a/src/SharpClaw.Code.Commands/Handlers/UsageCommandHandler.cs b/src/SharpClaw.Code.Commands/Handlers/UsageCommandHandler.cs
index f100794..dc02a4d 100644
--- a/src/SharpClaw.Code.Commands/Handlers/UsageCommandHandler.cs
+++ b/src/SharpClaw.Code.Commands/Handlers/UsageCommandHandler.cs
@@ -6,6 +6,7 @@
using SharpClaw.Code.Protocol.Models;
using SharpClaw.Code.Protocol.Serialization;
using SharpClaw.Code.Runtime.Abstractions;
+using SharpClaw.Code.Telemetry.Abstractions;
namespace SharpClaw.Code.Commands;
@@ -14,6 +15,7 @@ namespace SharpClaw.Code.Commands;
///
public sealed class UsageCommandHandler(
IWorkspaceInsightsService workspaceInsightsService,
+ IUsageMeteringService usageMeteringService,
OutputRendererDispatcher outputRendererDispatcher) : ICommandHandler, ISlashCommandHandler
{
///
@@ -29,13 +31,61 @@ public sealed class UsageCommandHandler(
public Command BuildCommand(GlobalCliOptions globalOptions)
{
var command = new Command(Name, Description);
+ command.Subcommands.Add(BuildSummaryCommand(globalOptions));
+ command.Subcommands.Add(BuildDetailCommand(globalOptions));
command.SetAction((parseResult, cancellationToken) => ExecuteAsync(globalOptions.Resolve(parseResult), cancellationToken));
return command;
}
///
public Task ExecuteAsync(SlashCommandParseResult command, CommandExecutionContext context, CancellationToken cancellationToken)
- => ExecuteAsync(context, cancellationToken);
+ => command.Arguments.Length switch
+ {
+ 0 => ExecuteAsync(context, cancellationToken),
+ _ when string.Equals(command.Arguments[0], "summary", StringComparison.OrdinalIgnoreCase)
+ => ExecuteSummaryAsync(context, null, null, cancellationToken),
+ _ when string.Equals(command.Arguments[0], "detail", StringComparison.OrdinalIgnoreCase)
+ => ExecuteDetailAsync(
+ context,
+ null,
+ null,
+ command.Arguments.Length > 1 && int.TryParse(command.Arguments[1], out var limit) ? limit : null,
+ cancellationToken),
+ _ => ExecuteAsync(context, cancellationToken)
+ };
+
+ private Command BuildSummaryCommand(GlobalCliOptions globalOptions)
+ {
+ var command = new Command("summary", "Shows tenant-aware metering totals for the current workspace.");
+ var fromUtcOption = new Option("--from-utc") { Description = "Inclusive lower-bound timestamp in UTC (ISO-8601)." };
+ var toUtcOption = new Option("--to-utc") { Description = "Exclusive upper-bound timestamp in UTC (ISO-8601)." };
+ command.Options.Add(fromUtcOption);
+ command.Options.Add(toUtcOption);
+ command.SetAction((parseResult, cancellationToken) => ExecuteSummaryAsync(
+ globalOptions.Resolve(parseResult),
+ ParseTimestamp(parseResult.GetValue(fromUtcOption), "--from-utc"),
+ ParseTimestamp(parseResult.GetValue(toUtcOption), "--to-utc"),
+ cancellationToken));
+ return command;
+ }
+
+ private Command BuildDetailCommand(GlobalCliOptions globalOptions)
+ {
+ var command = new Command("detail", "Lists normalized usage metering records for the current workspace.");
+ var fromUtcOption = new Option("--from-utc") { Description = "Inclusive lower-bound timestamp in UTC (ISO-8601)." };
+ var toUtcOption = new Option("--to-utc") { Description = "Exclusive upper-bound timestamp in UTC (ISO-8601)." };
+ var limitOption = new Option("--limit") { Description = "Maximum number of records to return." };
+ command.Options.Add(fromUtcOption);
+ command.Options.Add(toUtcOption);
+ command.Options.Add(limitOption);
+ command.SetAction((parseResult, cancellationToken) => ExecuteDetailAsync(
+ globalOptions.Resolve(parseResult),
+ ParseTimestamp(parseResult.GetValue(fromUtcOption), "--from-utc"),
+ ParseTimestamp(parseResult.GetValue(toUtcOption), "--to-utc"),
+ parseResult.GetValue(limitOption),
+ cancellationToken));
+ return command;
+ }
private async Task ExecuteAsync(CommandExecutionContext context, CancellationToken cancellationToken)
{
@@ -51,4 +101,74 @@ private async Task ExecuteAsync(CommandExecutionContext context, Cancellati
await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
return 0;
}
+
+ private async Task ExecuteSummaryAsync(
+ CommandExecutionContext context,
+ DateTimeOffset? fromUtc,
+ DateTimeOffset? toUtc,
+ CancellationToken cancellationToken)
+ {
+ var report = await usageMeteringService
+ .GetSummaryAsync(context.WorkingDirectory, CreateQuery(context, fromUtc, toUtc), cancellationToken)
+ .ConfigureAwait(false);
+ var result = new CommandResult(
+ true,
+ 0,
+ context.OutputFormat,
+ $"Usage summary: {report.TotalUsage.TotalTokens} tokens, {report.ProviderRequestCount} provider request(s), {report.ToolExecutionCount} tool execution(s), {report.TurnCount} turn(s).",
+ JsonSerializer.Serialize(report, ProtocolJsonContext.Default.UsageMeteringSummaryReport));
+ await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
+ return 0;
+ }
+
+ private async Task ExecuteDetailAsync(
+ CommandExecutionContext context,
+ DateTimeOffset? fromUtc,
+ DateTimeOffset? toUtc,
+ int? limit,
+ CancellationToken cancellationToken)
+ {
+ var report = await usageMeteringService
+ .GetDetailAsync(
+ context.WorkingDirectory,
+ CreateQuery(context, fromUtc, toUtc),
+ Math.Clamp(limit.GetValueOrDefault(50), 1, 500),
+ cancellationToken)
+ .ConfigureAwait(false);
+ var result = new CommandResult(
+ true,
+ 0,
+ context.OutputFormat,
+ $"Usage detail: {report.Records.Count} record(s).",
+ JsonSerializer.Serialize(report, ProtocolJsonContext.Default.UsageMeteringDetailReport));
+ await outputRendererDispatcher.RenderCommandResultAsync(result, context.OutputFormat, cancellationToken).ConfigureAwait(false);
+ return 0;
+ }
+
+ private static UsageMeteringQuery CreateQuery(
+ CommandExecutionContext context,
+ DateTimeOffset? fromUtc,
+ DateTimeOffset? toUtc)
+ => new(
+ FromUtc: fromUtc,
+ ToUtc: toUtc,
+ TenantId: context.HostContext?.TenantId,
+ HostId: context.HostContext?.HostId,
+ WorkspaceRoot: context.WorkingDirectory,
+ SessionId: context.SessionId);
+
+ private static DateTimeOffset? ParseTimestamp(string? value, string optionName)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ return null;
+ }
+
+ if (DateTimeOffset.TryParse(value, out var parsed))
+ {
+ return parsed;
+ }
+
+ throw new InvalidOperationException($"Option '{optionName}' must be a valid UTC timestamp.");
+ }
}
diff --git a/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs b/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs
index 2e00c4f..8b7a39e 100644
--- a/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs
+++ b/src/SharpClaw.Code.Commands/Models/CommandExecutionContext.cs
@@ -1,4 +1,6 @@
using SharpClaw.Code.Protocol.Enums;
+using SharpClaw.Code.Protocol.Models;
+using SharpClaw.Code.Runtime.Abstractions;
namespace SharpClaw.Code.Commands.Models;
@@ -12,6 +14,7 @@ namespace SharpClaw.Code.Commands.Models;
/// Build, plan, or spec workflow from global CLI options.
/// Optional explicit session id for prompts and session-scoped commands.
/// Optional explicit agent id for prompt execution.
+/// Optional embedded-host identity and tenant/storage context.
public sealed record CommandExecutionContext(
string WorkingDirectory,
string? Model,
@@ -19,4 +22,28 @@ public sealed record CommandExecutionContext(
OutputFormat OutputFormat,
PrimaryMode PrimaryMode,
string? SessionId = null,
- string? AgentId = null);
+ string? AgentId = null,
+ RuntimeHostContext? HostContext = null)
+{
+ ///
+ /// Converts the CLI command context into the runtime invocation context.
+ ///
+ /// Whether the current caller can participate in approval prompts.
+ /// Optional primary-mode override.
+ /// Optional agent id override.
+ /// The runtime command context.
+ public RuntimeCommandContext ToRuntimeCommandContext(
+ bool isInteractive = true,
+ PrimaryMode? primaryModeOverride = null,
+ string? agentIdOverride = null)
+ => new(
+ WorkingDirectory,
+ Model,
+ PermissionMode,
+ OutputFormat,
+ primaryModeOverride ?? PrimaryMode,
+ SessionId,
+ agentIdOverride ?? AgentId,
+ isInteractive,
+ HostContext);
+}
diff --git a/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs b/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs
index 4e2e96d..ff84de4 100644
--- a/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs
+++ b/src/SharpClaw.Code.Commands/Options/GlobalCliOptions.cs
@@ -2,6 +2,7 @@
using System.CommandLine.Parsing;
using SharpClaw.Code.Commands.Models;
using SharpClaw.Code.Protocol.Enums;
+using SharpClaw.Code.Protocol.Models;
namespace SharpClaw.Code.Commands.Options;
@@ -59,6 +60,30 @@ public GlobalCliOptions()
Description = "Selects the effective agent id for prompt execution.",
Recursive = true
};
+
+ HostIdOption = new Option("--host-id")
+ {
+ Description = "Sets the embedded host identifier for tenant-aware state and diagnostics.",
+ Recursive = true
+ };
+
+ TenantIdOption = new Option("--tenant-id")
+ {
+ Description = "Sets the tenant identifier for enterprise session, memory, and metering operations.",
+ Recursive = true
+ };
+
+ StorageRootOption = new Option("--storage-root")
+ {
+ Description = "Overrides the external storage root for embedded-host durable state.",
+ Recursive = true
+ };
+
+ SessionStoreOption = new Option("--session-store")
+ {
+ Description = "Selects the embedded session store backend: fileSystem or sqlite.",
+ Recursive = true
+ };
}
///
@@ -96,10 +121,43 @@ public GlobalCliOptions()
///
public Option AgentOption { get; }
+ ///
+ /// Gets the optional embedded host id option.
+ ///
+ public Option HostIdOption { get; }
+
+ ///
+ /// Gets the optional tenant id option.
+ ///
+ public Option TenantIdOption { get; }
+
+ ///
+ /// Gets the optional external storage root option.
+ ///
+ public Option StorageRootOption { get; }
+
+ ///
+ /// Gets the optional embedded session store kind option.
+ ///
+ public Option SessionStoreOption { get; }
+
///
/// Gets all global options.
///
- public IEnumerable