diff --git a/frontend/package.json b/frontend/package.json
index ae01756..3a99587 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -1,41 +1,41 @@
{
- "name": "living-spec-standalone",
- "private": true,
- "version": "0.5.0",
- "type": "module",
- "scripts": {
- "dev": "vite",
- "build": "tsc && vite build",
- "preview": "vite preview",
- "server": "cargo run --manifest-path server/Cargo.toml",
- "test": "vitest run",
- "test:unit": "vitest run",
- "test:e2e": "playwright test",
- "test:coverage": "vitest run --coverage"
- },
- "dependencies": {
- "@types/react-syntax-highlighter": "^15.5.13",
- "react": "^19.1.0",
- "react-dom": "^19.1.0",
- "react-markdown": "^10.1.0",
- "react-syntax-highlighter": "^16.1.0"
- },
- "devDependencies": {
- "@biomejs/biome": "^2.4.2",
- "@playwright/test": "^1.47.2",
- "@testing-library/jest-dom": "^6.0.0",
- "@testing-library/react": "^16.0.0",
- "@testing-library/user-event": "^14.4.3",
- "@types/node": "^25.0.0",
- "@types/react": "^19.1.8",
- "@types/react-dom": "^19.1.6",
- "@vitejs/plugin-react": "^4.6.0",
- "@vitest/coverage-v8": "^2.1.9",
- "jest": "^29.0.0",
- "jsdom": "^28.1.0",
- "ts-jest": "^29.0.0",
- "typescript": "~5.8.3",
- "vite": "^5.4.21",
- "vitest": "^2.1.4"
- }
+ "name": "living-spec-standalone",
+ "private": true,
+ "version": "0.5.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "preview": "vite preview",
+ "server": "cargo run --manifest-path server/Cargo.toml",
+ "test": "vitest run",
+ "test:unit": "vitest run",
+ "test:e2e": "playwright test",
+ "test:coverage": "vitest run --coverage"
+ },
+ "dependencies": {
+ "@types/react-syntax-highlighter": "^15.5.13",
+ "react": "^19.1.0",
+ "react-dom": "^19.1.0",
+ "react-markdown": "^10.1.0",
+ "react-syntax-highlighter": "^16.1.0"
+ },
+ "devDependencies": {
+ "@biomejs/biome": "^2.4.2",
+ "@playwright/test": "^1.47.2",
+ "@testing-library/jest-dom": "^6.0.0",
+ "@testing-library/react": "^16.0.0",
+ "@testing-library/user-event": "^14.4.3",
+ "@types/node": "^25.0.0",
+ "@types/react": "^19.1.8",
+ "@types/react-dom": "^19.1.6",
+ "@vitejs/plugin-react": "^4.6.0",
+ "@vitest/coverage-v8": "^2.1.9",
+ "jest": "^29.0.0",
+ "jsdom": "^28.1.0",
+ "ts-jest": "^29.0.0",
+ "typescript": "~5.8.3",
+ "vite": "^5.4.21",
+ "vitest": "^2.1.4"
+ }
}
diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts
index 3204f58..3f9fe76 100644
--- a/frontend/playwright.config.ts
+++ b/frontend/playwright.config.ts
@@ -1,27 +1,27 @@
-import { defineConfig } from "@playwright/test";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
+import { defineConfig } from "@playwright/test";
const configDir = dirname(fileURLToPath(new URL(import.meta.url)));
const frontendRoot = resolve(configDir, ".");
export default defineConfig({
- testDir: "./tests/e2e",
- fullyParallel: true,
- timeout: 30_000,
- expect: {
- timeout: 5_000,
- },
- use: {
- baseURL: "http://127.0.0.1:41700",
- trace: "on-first-retry",
- },
- webServer: {
- command:
- "pnpm exec vite --config vite.config.ts --host 127.0.0.1 --port 41700 --strictPort",
- url: "http://127.0.0.1:41700/@vite/client",
- reuseExistingServer: true,
- timeout: 120_000,
- cwd: frontendRoot,
- },
+ testDir: "./tests/e2e",
+ fullyParallel: true,
+ timeout: 30_000,
+ expect: {
+ timeout: 5_000,
+ },
+ use: {
+ baseURL: "http://127.0.0.1:41700",
+ trace: "on-first-retry",
+ },
+ webServer: {
+ command:
+ "pnpm exec vite --config vite.config.ts --host 127.0.0.1 --port 41700 --strictPort",
+ url: "http://127.0.0.1:41700/@vite/client",
+ reuseExistingServer: true,
+ timeout: 120_000,
+ cwd: frontendRoot,
+ },
});
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 6d682e9..d698124 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -382,6 +382,14 @@ export const api = {
deleteStory(storyId: string) {
return callMcpTool("delete_story", { story_id: storyId });
},
+ /** Execute a bot slash command without LLM invocation. Returns markdown response text. */
+ botCommand(command: string, args: string, baseUrl?: string) {
+ return requestJson<{ response: string }>(
+ "/bot/command",
+ { method: "POST", body: JSON.stringify({ command, args }) },
+ baseUrl,
+ );
+ },
};
async function callMcpTool(
diff --git a/frontend/src/components/Chat.test.tsx b/frontend/src/components/Chat.test.tsx
index 929f144..4007def 100644
--- a/frontend/src/components/Chat.test.tsx
+++ b/frontend/src/components/Chat.test.tsx
@@ -40,6 +40,7 @@ vi.mock("../api/client", () => {
setAnthropicApiKey: vi.fn(),
readFile: vi.fn(),
listProjectFiles: vi.fn(),
+ botCommand: vi.fn(),
};
class ChatWebSocket {
connect(handlers: WsHandlers) {
@@ -64,6 +65,7 @@ const mockedApi = {
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
readFile: vi.mocked(api.readFile),
listProjectFiles: vi.mocked(api.listProjectFiles),
+ botCommand: vi.mocked(api.botCommand),
};
function setupMocks() {
@@ -76,6 +78,7 @@ function setupMocks() {
mockedApi.listProjectFiles.mockResolvedValue([]);
mockedApi.cancelChat.mockResolvedValue(true);
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
+ mockedApi.botCommand.mockResolvedValue({ response: "Bot response" });
}
describe("Default provider selection (Story 206)", () => {
@@ -1457,3 +1460,204 @@ describe("File reference expansion (Story 269 AC4)", () => {
expect(mockedApi.readFile).not.toHaveBeenCalled();
});
});
+
+describe("Slash command handling (Story 374)", () => {
+ beforeEach(() => {
+ capturedWsHandlers = null;
+ lastSendChatArgs = null;
+ setupMocks();
+ });
+
+ afterEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("AC: /status calls botCommand and displays response", async () => {
+ mockedApi.botCommand.mockResolvedValue({ response: "Pipeline: 3 active" });
+ render();
+ await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
+
+ const input = screen.getByPlaceholderText("Send a message...");
+ await act(async () => {
+ fireEvent.change(input, { target: { value: "/status" } });
+ });
+ await act(async () => {
+ fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
+ });
+
+ await waitFor(() => {
+ expect(mockedApi.botCommand).toHaveBeenCalledWith(
+ "status",
+ "",
+ undefined,
+ );
+ });
+ expect(await screen.findByText("Pipeline: 3 active")).toBeInTheDocument();
+ // Should NOT go to LLM
+ expect(lastSendChatArgs).toBeNull();
+ });
+
+ it("AC: /status passes args to botCommand", async () => {
+ mockedApi.botCommand.mockResolvedValue({ response: "Story 42 details" });
+ render();
+ await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
+
+ const input = screen.getByPlaceholderText("Send a message...");
+ await act(async () => {
+ fireEvent.change(input, { target: { value: "/status 42" } });
+ });
+ await act(async () => {
+ fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
+ });
+
+ await waitFor(() => {
+ expect(mockedApi.botCommand).toHaveBeenCalledWith(
+ "status",
+ "42",
+ undefined,
+ );
+ });
+ });
+
+ it("AC: /start calls botCommand", async () => {
+ mockedApi.botCommand.mockResolvedValue({ response: "Started agent" });
+ render();
+ await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
+
+ const input = screen.getByPlaceholderText("Send a message...");
+ await act(async () => {
+ fireEvent.change(input, { target: { value: "/start 42 opus" } });
+ });
+ await act(async () => {
+ fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
+ });
+
+ await waitFor(() => {
+ expect(mockedApi.botCommand).toHaveBeenCalledWith(
+ "start",
+ "42 opus",
+ undefined,
+ );
+ });
+ expect(await screen.findByText("Started agent")).toBeInTheDocument();
+ });
+
+ it("AC: /git calls botCommand", async () => {
+ mockedApi.botCommand.mockResolvedValue({ response: "On branch main" });
+ render();
+ await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
+
+ const input = screen.getByPlaceholderText("Send a message...");
+ await act(async () => {
+ fireEvent.change(input, { target: { value: "/git" } });
+ });
+ await act(async () => {
+ fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
+ });
+
+ await waitFor(() => {
+ expect(mockedApi.botCommand).toHaveBeenCalledWith("git", "", undefined);
+ });
+ });
+
+ it("AC: /cost calls botCommand", async () => {
+ mockedApi.botCommand.mockResolvedValue({ response: "$1.23 today" });
+ render();
+ await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
+
+ const input = screen.getByPlaceholderText("Send a message...");
+ await act(async () => {
+ fireEvent.change(input, { target: { value: "/cost" } });
+ });
+ await act(async () => {
+ fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
+ });
+
+ await waitFor(() => {
+ expect(mockedApi.botCommand).toHaveBeenCalledWith("cost", "", undefined);
+ });
+ });
+
+ it("AC: /reset clears messages and session without LLM", async () => {
+ render();
+ await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
+
+ // First add a message so there is history to clear
+ act(() => {
+ capturedWsHandlers?.onUpdate([
+ { role: "user", content: "hello" },
+ { role: "assistant", content: "world" },
+ ]);
+ });
+ expect(await screen.findByText("world")).toBeInTheDocument();
+
+ const input = screen.getByPlaceholderText("Send a message...");
+ await act(async () => {
+ fireEvent.change(input, { target: { value: "/reset" } });
+ });
+ await act(async () => {
+ fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
+ });
+
+ // LLM must NOT be invoked
+ expect(lastSendChatArgs).toBeNull();
+ // botCommand must NOT be invoked (reset is frontend-only)
+ expect(mockedApi.botCommand).not.toHaveBeenCalled();
+ // Confirmation message should appear
+ expect(await screen.findByText(/Session reset/)).toBeInTheDocument();
+ });
+
+ it("AC: unrecognised slash command shows error message", async () => {
+ render();
+ await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
+
+ const input = screen.getByPlaceholderText("Send a message...");
+ await act(async () => {
+ fireEvent.change(input, { target: { value: "/foobar" } });
+ });
+ await act(async () => {
+ fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
+ });
+
+ expect(await screen.findByText(/Unknown command/)).toBeInTheDocument();
+ // Should NOT go to LLM
+ expect(lastSendChatArgs).toBeNull();
+ // Should NOT call botCommand
+ expect(mockedApi.botCommand).not.toHaveBeenCalled();
+ });
+
+ it("AC: /help shows help overlay", async () => {
+ render();
+ await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
+
+ const input = screen.getByPlaceholderText("Send a message...");
+ await act(async () => {
+ fireEvent.change(input, { target: { value: "/help" } });
+ });
+ await act(async () => {
+ fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
+ });
+
+ expect(await screen.findByTestId("help-overlay")).toBeInTheDocument();
+ expect(lastSendChatArgs).toBeNull();
+ expect(mockedApi.botCommand).not.toHaveBeenCalled();
+ });
+
+ it("AC: botCommand API error shows error message in chat", async () => {
+ mockedApi.botCommand.mockRejectedValue(new Error("Server error"));
+ render();
+ await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
+
+ const input = screen.getByPlaceholderText("Send a message...");
+ await act(async () => {
+ fireEvent.change(input, { target: { value: "/git" } });
+ });
+ await act(async () => {
+ fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
+ });
+
+ expect(
+ await screen.findByText(/Error running command/),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/components/Chat.tsx b/frontend/src/components/Chat.tsx
index 74f4113..e3b4bfe 100644
--- a/frontend/src/components/Chat.tsx
+++ b/frontend/src/components/Chat.tsx
@@ -612,6 +612,80 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
return;
}
+ // /reset — clear session and message history without LLM
+ if (/^\/reset\s*$/i.test(messageText)) {
+ setMessages([]);
+ setClaudeSessionId(null);
+ setStreamingContent("");
+ setStreamingThinking("");
+ setActivityStatus(null);
+ setMessages([
+ {
+ role: "assistant",
+ content: "Session reset. Starting a fresh conversation.",
+ },
+ ]);
+ return;
+ }
+
+ // Slash commands forwarded to the backend bot command endpoint
+ const slashMatch = messageText.match(/^\/(\S+)(?:\s+([\s\S]*))?$/);
+ if (slashMatch) {
+ const cmd = slashMatch[1].toLowerCase();
+ const args = (slashMatch[2] ?? "").trim();
+
+ // Ignore commands handled elsewhere
+ if (cmd !== "btw") {
+ const knownCommands = new Set([
+ "status",
+ "assign",
+ "start",
+ "show",
+ "move",
+ "delete",
+ "cost",
+ "git",
+ "overview",
+ "rebuild",
+ ]);
+
+ if (knownCommands.has(cmd)) {
+ // Show the slash command in chat as a user message (display only)
+ setMessages((prev: Message[]) => [
+ ...prev,
+ { role: "user", content: messageText },
+ ]);
+ try {
+ const result = await api.botCommand(cmd, args, undefined);
+ setMessages((prev: Message[]) => [
+ ...prev,
+ { role: "assistant", content: result.response },
+ ]);
+ } catch (e) {
+ setMessages((prev: Message[]) => [
+ ...prev,
+ {
+ role: "assistant",
+ content: `**Error running command:** ${e}`,
+ },
+ ]);
+ }
+ return;
+ }
+
+ // Unknown slash command
+ setMessages((prev: Message[]) => [
+ ...prev,
+ { role: "user", content: messageText },
+ {
+ role: "assistant",
+ content: `Unknown command: \`/${cmd}\`. Type \`/help\` to see available commands.`,
+ },
+ ]);
+ return;
+ }
+ }
+
// /btw — answered from context without disrupting main chat
const btwMatch = messageText.match(/^\/btw\s+(.+)/s);
if (btwMatch) {
diff --git a/frontend/src/components/HelpOverlay.tsx b/frontend/src/components/HelpOverlay.tsx
index 0f6a3e2..e542465 100644
--- a/frontend/src/components/HelpOverlay.tsx
+++ b/frontend/src/components/HelpOverlay.tsx
@@ -12,6 +12,57 @@ const SLASH_COMMANDS: SlashCommand[] = [
name: "/help",
description: "Show this list of available slash commands.",
},
+ {
+ name: "/status",
+ description:
+ "Show pipeline status and agent availability. `/status ` shows a story triage dump.",
+ },
+ {
+ name: "/assign ",
+ description: "Pre-assign a model to a story (e.g. `/assign 42 opus`).",
+ },
+ {
+ name: "/start ",
+ description:
+ "Start a coder on a story. Optionally specify a model: `/start opus`.",
+ },
+ {
+ name: "/show ",
+ description: "Display the full text of a work item.",
+ },
+ {
+ name: "/move ",
+ description:
+ "Move a work item to a pipeline stage (backlog, current, qa, merge, done).",
+ },
+ {
+ name: "/delete ",
+ description:
+ "Remove a work item from the pipeline and stop any running agent.",
+ },
+ {
+ name: "/cost",
+ description:
+ "Show token spend: 24h total, top stories, breakdown by agent type, and all-time total.",
+ },
+ {
+ name: "/git",
+ description:
+ "Show git status: branch, uncommitted changes, and ahead/behind remote.",
+ },
+ {
+ name: "/overview ",
+ description: "Show the implementation summary for a merged story.",
+ },
+ {
+ name: "/rebuild",
+ description: "Rebuild the server binary and restart.",
+ },
+ {
+ name: "/reset",
+ description:
+ "Clear the current Claude Code session and start fresh (messages and session ID are cleared locally).",
+ },
{
name: "/btw ",
description:
diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json
index 6d545f5..e8e5246 100644
--- a/frontend/tsconfig.json
+++ b/frontend/tsconfig.json
@@ -1,24 +1,24 @@
{
- "compilerOptions": {
- "target": "ES2020",
- "useDefineForClassFields": true,
- "lib": ["ES2020", "DOM", "DOM.Iterable"],
- "module": "ESNext",
- "skipLibCheck": true,
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
- /* Bundler mode */
- "moduleResolution": "bundler",
- "allowImportingTsExtensions": true,
- "resolveJsonModule": true,
- "isolatedModules": true,
- "noEmit": true,
- "jsx": "react-jsx",
+ /* Bundler mode */
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
- /* Linting */
- "strict": true,
- "noUnusedLocals": true,
- "noUnusedParameters": true,
- "noFallthroughCasesInSwitch": true
- },
- "include": ["src"]
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src"]
}
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index 83d210c..2fa69d2 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -3,49 +3,49 @@ import { defineConfig } from "vite";
// https://vite.dev/config/
export default defineConfig(() => {
- const backendPort = Number(process.env.STORKIT_PORT || "3001");
- return {
- plugins: [react()],
- define: {
- __STORKIT_PORT__: JSON.stringify(String(backendPort)),
- __BUILD_TIME__: JSON.stringify(new Date().toISOString()),
- },
- server: {
- port: backendPort + 2172,
- proxy: {
- "/api": {
- target: `http://127.0.0.1:${String(backendPort)}`,
- timeout: 120000,
- configure: (proxy) => {
- proxy.on("error", (_err) => {
- // Swallow proxy errors (e.g. ECONNREFUSED during backend restart)
- // so the vite dev server doesn't crash.
- });
- },
- },
- "/agents": {
- target: `http://127.0.0.1:${String(backendPort)}`,
- timeout: 120000,
- configure: (proxy) => {
- proxy.on("error", (_err) => {});
- },
- },
- },
- watch: {
- ignored: [
- "**/.story_kit/**",
- "**/target/**",
- "**/.git/**",
- "**/server/**",
- "**/Cargo.*",
- "**/vendor/**",
- "**/node_modules/**",
- ],
- },
- },
- build: {
- outDir: "dist",
- emptyOutDir: true,
- },
- };
+ const backendPort = Number(process.env.STORKIT_PORT || "3001");
+ return {
+ plugins: [react()],
+ define: {
+ __STORKIT_PORT__: JSON.stringify(String(backendPort)),
+ __BUILD_TIME__: JSON.stringify(new Date().toISOString()),
+ },
+ server: {
+ port: backendPort + 2172,
+ proxy: {
+ "/api": {
+ target: `http://127.0.0.1:${String(backendPort)}`,
+ timeout: 120000,
+ configure: (proxy) => {
+ proxy.on("error", (_err) => {
+ // Swallow proxy errors (e.g. ECONNREFUSED during backend restart)
+ // so the vite dev server doesn't crash.
+ });
+ },
+ },
+ "/agents": {
+ target: `http://127.0.0.1:${String(backendPort)}`,
+ timeout: 120000,
+ configure: (proxy) => {
+ proxy.on("error", (_err) => {});
+ },
+ },
+ },
+ watch: {
+ ignored: [
+ "**/.story_kit/**",
+ "**/target/**",
+ "**/.git/**",
+ "**/server/**",
+ "**/Cargo.*",
+ "**/vendor/**",
+ "**/node_modules/**",
+ ],
+ },
+ },
+ build: {
+ outDir: "dist",
+ emptyOutDir: true,
+ },
+ };
});
diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts
index 12c61cb..8c5ad75 100644
--- a/frontend/vitest.config.ts
+++ b/frontend/vitest.config.ts
@@ -2,26 +2,26 @@ import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
- plugins: [react()],
- define: {
- __BUILD_TIME__: JSON.stringify("2026-01-01T00:00:00.000Z"),
- },
- test: {
- environment: "jsdom",
- environmentOptions: {
- jsdom: {
- url: "http://localhost:3000",
- },
- },
- globals: true,
- testTimeout: 10_000,
- setupFiles: ["./src/setupTests.ts"],
- css: true,
- exclude: ["tests/e2e/**", "node_modules/**"],
- coverage: {
- provider: "v8",
- reporter: ["text", "json-summary"],
- reportsDirectory: "./coverage",
- },
- },
+ plugins: [react()],
+ define: {
+ __BUILD_TIME__: JSON.stringify("2026-01-01T00:00:00.000Z"),
+ },
+ test: {
+ environment: "jsdom",
+ environmentOptions: {
+ jsdom: {
+ url: "http://localhost:3000",
+ },
+ },
+ globals: true,
+ testTimeout: 10_000,
+ setupFiles: ["./src/setupTests.ts"],
+ css: true,
+ exclude: ["tests/e2e/**", "node_modules/**"],
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "json-summary"],
+ reportsDirectory: "./coverage",
+ },
+ },
});
diff --git a/server/src/http/bot_command.rs b/server/src/http/bot_command.rs
new file mode 100644
index 0000000..446e841
--- /dev/null
+++ b/server/src/http/bot_command.rs
@@ -0,0 +1,286 @@
+//! Bot command HTTP endpoint.
+//!
+//! `POST /api/bot/command` lets the web UI invoke the same deterministic bot
+//! commands available in Matrix without going through the LLM.
+//!
+//! Synchronous commands (status, assign, git, cost, move, show, overview,
+//! help) are dispatched directly through the matrix command registry.
+//! Asynchronous commands (start, delete, rebuild) are dispatched to their
+//! dedicated async handlers. The `reset` command is handled by the frontend
+//! (it clears local session state and message history) and is not routed here.
+
+use crate::http::context::{AppContext, OpenApiResult};
+use crate::matrix::commands::CommandDispatch;
+use poem::http::StatusCode;
+use poem_openapi::{Object, OpenApi, Tags, payload::Json};
+use serde::{Deserialize, Serialize};
+use std::collections::HashSet;
+use std::sync::{Arc, Mutex};
+
+#[derive(Tags)]
+enum BotCommandTags {
+ BotCommand,
+}
+
+/// Body for `POST /api/bot/command`.
+#[derive(Object, Deserialize)]
+struct BotCommandRequest {
+ /// The command keyword without the leading slash (e.g. `"status"`, `"start"`).
+ command: String,
+ /// Any text after the command keyword, trimmed (may be empty).
+ #[oai(default)]
+ args: String,
+}
+
+/// Response body for `POST /api/bot/command`.
+#[derive(Object, Serialize)]
+struct BotCommandResponse {
+ /// Markdown-formatted response text.
+ response: String,
+}
+
+pub struct BotCommandApi {
+ pub ctx: Arc,
+}
+
+#[OpenApi(tag = "BotCommandTags::BotCommand")]
+impl BotCommandApi {
+ /// Execute a slash command without LLM invocation.
+ ///
+ /// Dispatches to the same handlers used by the Matrix and Slack bots.
+ /// Returns a markdown-formatted response that the frontend can display
+ /// directly in the chat panel.
+ #[oai(path = "/bot/command", method = "post")]
+ async fn run_command(
+ &self,
+ body: Json,
+ ) -> OpenApiResult> {
+ let project_root = self.ctx.state.get_project_root().map_err(|e| {
+ poem::Error::from_string(e, StatusCode::BAD_REQUEST)
+ })?;
+
+ let cmd = body.command.trim().to_ascii_lowercase();
+ let args = body.args.trim();
+ let response = dispatch_command(&cmd, args, &project_root, &self.ctx.agents).await;
+
+ Ok(Json(BotCommandResponse { response }))
+ }
+}
+
+/// Dispatch a command keyword + args to the appropriate handler.
+async fn dispatch_command(
+ cmd: &str,
+ args: &str,
+ project_root: &std::path::Path,
+ agents: &Arc,
+) -> String {
+ match cmd {
+ "start" => dispatch_start(args, project_root, agents).await,
+ "delete" => dispatch_delete(args, project_root, agents).await,
+ "rebuild" => dispatch_rebuild(project_root, agents).await,
+ // All other commands go through the synchronous command registry.
+ _ => dispatch_sync(cmd, args, project_root, agents),
+ }
+}
+
+fn dispatch_sync(
+ cmd: &str,
+ args: &str,
+ project_root: &std::path::Path,
+ agents: &Arc,
+) -> String {
+ let ambient_rooms: Arc>> = Arc::new(Mutex::new(HashSet::new()));
+ // Use a synthetic bot name/id so strip_bot_mention passes through.
+ let bot_name = "__web_ui__";
+ let bot_user_id = "@__web_ui__:localhost";
+ let room_id = "__web_ui__";
+
+ let dispatch = CommandDispatch {
+ bot_name,
+ bot_user_id,
+ project_root,
+ agents,
+ ambient_rooms: &ambient_rooms,
+ room_id,
+ };
+
+ // Build a synthetic message that the registry can parse.
+ let synthetic = if args.is_empty() {
+ format!("{bot_name} {cmd}")
+ } else {
+ format!("{bot_name} {cmd} {args}")
+ };
+
+ match crate::matrix::commands::try_handle_command(&dispatch, &synthetic) {
+ Some(response) => response,
+ None => {
+ // Command exists in the registry but its fallback handler returns None
+ // (start, delete, rebuild, reset, htop — handled elsewhere or in
+ // the frontend). Should not be reached for those since we intercept
+ // them above. For genuinely unknown commands, tell the user.
+ format!("Unknown command: `/{cmd}`. Type `/help` to see available commands.")
+ }
+ }
+}
+
+async fn dispatch_start(
+ args: &str,
+ project_root: &std::path::Path,
+ agents: &Arc,
+) -> String {
+ // args: "" or " "
+ let mut parts = args.splitn(2, char::is_whitespace);
+ let number_str = parts.next().unwrap_or("").trim();
+ let hint_str = parts.next().unwrap_or("").trim();
+
+ if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) {
+ return "Usage: `/start ` or `/start ` (e.g. `/start 42 opus`)"
+ .to_string();
+ }
+
+ let agent_hint = if hint_str.is_empty() {
+ None
+ } else {
+ Some(hint_str)
+ };
+
+ crate::matrix::start::handle_start("web-ui", number_str, agent_hint, project_root, agents)
+ .await
+}
+
+async fn dispatch_delete(
+ args: &str,
+ project_root: &std::path::Path,
+ agents: &Arc,
+) -> String {
+ let number_str = args.trim();
+ if number_str.is_empty() || !number_str.chars().all(|c| c.is_ascii_digit()) {
+ return "Usage: `/delete ` (e.g. `/delete 42`)".to_string();
+ }
+ crate::matrix::delete::handle_delete("web-ui", number_str, project_root, agents).await
+}
+
+async fn dispatch_rebuild(
+ project_root: &std::path::Path,
+ agents: &Arc,
+) -> String {
+ crate::matrix::rebuild::handle_rebuild("web-ui", project_root, agents).await
+}
+
+// ---------------------------------------------------------------------------
+// Tests
+// ---------------------------------------------------------------------------
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use tempfile::TempDir;
+
+ fn test_api(dir: &TempDir) -> BotCommandApi {
+ BotCommandApi {
+ ctx: Arc::new(AppContext::new_test(dir.path().to_path_buf())),
+ }
+ }
+
+ #[tokio::test]
+ async fn help_command_returns_response() {
+ let dir = TempDir::new().unwrap();
+ let api = test_api(&dir);
+ let body = BotCommandRequest {
+ command: "help".to_string(),
+ args: String::new(),
+ };
+ let result = api.run_command(Json(body)).await;
+ assert!(result.is_ok());
+ let resp = result.unwrap().0;
+ assert!(!resp.response.is_empty());
+ }
+
+ #[tokio::test]
+ async fn unknown_command_returns_error_message() {
+ let dir = TempDir::new().unwrap();
+ let api = test_api(&dir);
+ let body = BotCommandRequest {
+ command: "nonexistent_xyz".to_string(),
+ args: String::new(),
+ };
+ let result = api.run_command(Json(body)).await;
+ assert!(result.is_ok());
+ let resp = result.unwrap().0;
+ assert!(
+ resp.response.contains("Unknown command"),
+ "expected 'Unknown command' in: {}",
+ resp.response
+ );
+ }
+
+ #[tokio::test]
+ async fn start_without_number_returns_usage() {
+ let dir = TempDir::new().unwrap();
+ let api = test_api(&dir);
+ let body = BotCommandRequest {
+ command: "start".to_string(),
+ args: String::new(),
+ };
+ let result = api.run_command(Json(body)).await;
+ assert!(result.is_ok());
+ let resp = result.unwrap().0;
+ assert!(
+ resp.response.contains("Usage"),
+ "expected usage hint in: {}",
+ resp.response
+ );
+ }
+
+ #[tokio::test]
+ async fn delete_without_number_returns_usage() {
+ let dir = TempDir::new().unwrap();
+ let api = test_api(&dir);
+ let body = BotCommandRequest {
+ command: "delete".to_string(),
+ args: String::new(),
+ };
+ let result = api.run_command(Json(body)).await;
+ assert!(result.is_ok());
+ let resp = result.unwrap().0;
+ assert!(
+ resp.response.contains("Usage"),
+ "expected usage hint in: {}",
+ resp.response
+ );
+ }
+
+ #[tokio::test]
+ async fn git_command_returns_response() {
+ let dir = TempDir::new().unwrap();
+ // Initialise a bare git repo so the git command has something to query.
+ std::process::Command::new("git")
+ .args(["init"])
+ .current_dir(dir.path())
+ .output()
+ .ok();
+ let api = test_api(&dir);
+ let body = BotCommandRequest {
+ command: "git".to_string(),
+ args: String::new(),
+ };
+ let result = api.run_command(Json(body)).await;
+ assert!(result.is_ok());
+ }
+
+ #[tokio::test]
+ async fn run_command_requires_project_root() {
+ // Create a context with no project root set.
+ let dir = TempDir::new().unwrap();
+ let ctx = AppContext::new_test(dir.path().to_path_buf());
+ // Clear the project root.
+ *ctx.state.project_root.lock().unwrap() = None;
+ let api = BotCommandApi { ctx: Arc::new(ctx) };
+ let body = BotCommandRequest {
+ command: "status".to_string(),
+ args: String::new(),
+ };
+ let result = api.run_command(Json(body)).await;
+ assert!(result.is_err(), "should fail when no project root is set");
+ }
+}
diff --git a/server/src/http/mod.rs b/server/src/http/mod.rs
index 49dba41..63893d4 100644
--- a/server/src/http/mod.rs
+++ b/server/src/http/mod.rs
@@ -2,6 +2,7 @@ pub mod agents;
pub mod agents_sse;
pub mod anthropic;
pub mod assets;
+pub mod bot_command;
pub mod chat;
pub mod context;
pub mod health;
@@ -16,6 +17,7 @@ pub mod ws;
use agents::AgentsApi;
use anthropic::AnthropicApi;
+use bot_command::BotCommandApi;
use chat::ChatApi;
use context::AppContext;
use health::HealthApi;
@@ -113,6 +115,7 @@ type ApiTuple = (
AgentsApi,
SettingsApi,
HealthApi,
+ BotCommandApi,
);
type ApiService = OpenApiService;
@@ -128,6 +131,7 @@ pub fn build_openapi_service(ctx: Arc) -> (ApiService, ApiService) {
AgentsApi { ctx: ctx.clone() },
SettingsApi { ctx: ctx.clone() },
HealthApi,
+ BotCommandApi { ctx: ctx.clone() },
);
let api_service =
@@ -140,8 +144,9 @@ pub fn build_openapi_service(ctx: Arc) -> (ApiService, ApiService) {
IoApi { ctx: ctx.clone() },
ChatApi { ctx: ctx.clone() },
AgentsApi { ctx: ctx.clone() },
- SettingsApi { ctx },
+ SettingsApi { ctx: ctx.clone() },
HealthApi,
+ BotCommandApi { ctx },
);
let docs_service =