summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-01-06 04:08:11 +0000
committersoryu <soryu@soryu.co>2026-01-11 03:01:13 +0000
commit8b17a175c3e7e27b789812eba4e3cd760beadb10 (patch)
tree7864dcaa2fa9db47fdfd4e8bfdb0b1dde832aa33
parentf79c416c58557d2f946aa5332989afdfa8c021cd (diff)
downloadsoryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.tar.gz
soryu-8b17a175c3e7e27b789812eba4e3cd760beadb10.zip
Initial Control system
-rwxr-xr-x.makima/orchestrate.sh206
-rw-r--r--Cargo.lock551
-rw-r--r--Cargo.toml2
-rw-r--r--makima/Cargo.lock3022
-rw-r--r--makima/Cargo.toml12
-rw-r--r--makima/daemon/Cargo.toml48
-rw-r--r--makima/daemon/README.md353
-rw-r--r--makima/daemon/src/cli.rs45
-rw-r--r--makima/daemon/src/config.rs536
-rw-r--r--makima/daemon/src/db/local.rs391
-rw-r--r--makima/daemon/src/db/mod.rs5
-rw-r--r--makima/daemon/src/error.rs75
-rw-r--r--makima/daemon/src/lib.rs21
-rw-r--r--makima/daemon/src/main.rs313
-rw-r--r--makima/daemon/src/process/claude.rs481
-rw-r--r--makima/daemon/src/process/claude_protocol.rs59
-rw-r--r--makima/daemon/src/process/mod.rs10
-rw-r--r--makima/daemon/src/task/manager.rs2248
-rw-r--r--makima/daemon/src/task/mod.rs7
-rw-r--r--makima/daemon/src/task/state.rs161
-rw-r--r--makima/daemon/src/temp.rs224
-rw-r--r--makima/daemon/src/worktree/manager.rs1623
-rw-r--r--makima/daemon/src/worktree/mod.rs11
-rw-r--r--makima/daemon/src/ws/client.rs290
-rw-r--r--makima/daemon/src/ws/mod.rs7
-rw-r--r--makima/daemon/src/ws/protocol.rs511
-rw-r--r--makima/frontend/package-lock.json134
-rw-r--r--makima/frontend/package.json1
-rw-r--r--makima/frontend/pnpm-lock.yaml101
-rw-r--r--makima/frontend/src/components/Masthead.tsx10
-rw-r--r--makima/frontend/src/components/NavStrip.tsx33
-rw-r--r--makima/frontend/src/components/ProtectedRoute.tsx26
-rw-r--r--makima/frontend/src/components/files/BodyRenderer.tsx121
-rw-r--r--makima/frontend/src/components/files/CliInput.tsx58
-rw-r--r--makima/frontend/src/components/files/ElementContextMenu.tsx292
-rw-r--r--makima/frontend/src/components/files/FileDetail.tsx59
-rw-r--r--makima/frontend/src/components/files/FileList.tsx207
-rw-r--r--makima/frontend/src/components/mesh/DirectoryInput.tsx220
-rw-r--r--makima/frontend/src/components/mesh/InlineSubtaskEditor.tsx262
-rw-r--r--makima/frontend/src/components/mesh/MergeConflictResolver.tsx504
-rw-r--r--makima/frontend/src/components/mesh/OverlayDiffViewer.tsx476
-rw-r--r--makima/frontend/src/components/mesh/PRPreview.tsx314
-rw-r--r--makima/frontend/src/components/mesh/SubtaskTree.tsx297
-rw-r--r--makima/frontend/src/components/mesh/TaskDetail.tsx886
-rw-r--r--makima/frontend/src/components/mesh/TaskList.tsx164
-rw-r--r--makima/frontend/src/components/mesh/TaskOutput.tsx281
-rw-r--r--makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx536
-rw-r--r--makima/frontend/src/contexts/AuthContext.tsx160
-rw-r--r--makima/frontend/src/hooks/useMeshChatHistory.ts133
-rw-r--r--makima/frontend/src/hooks/useTaskSubscription.ts333
-rw-r--r--makima/frontend/src/hooks/useTasks.ts130
-rw-r--r--makima/frontend/src/lib/api.ts921
-rw-r--r--makima/frontend/src/lib/supabase.ts26
-rw-r--r--makima/frontend/src/main.tsx71
-rw-r--r--makima/frontend/src/routes/_index.tsx12
-rw-r--r--makima/frontend/src/routes/files.tsx350
-rw-r--r--makima/frontend/src/routes/login.tsx150
-rw-r--r--makima/frontend/src/routes/mesh.tsx634
-rw-r--r--makima/frontend/src/routes/settings.tsx724
-rw-r--r--makima/frontend/tsconfig.tsbuildinfo2
-rw-r--r--makima/migrations/20250102000000_create_mesh_tables.sql83
-rw-r--r--makima/migrations/20250104000000_create_mesh_chat_history.sql34
-rw-r--r--makima/migrations/20250106000000_add_task_depth.sql21
-rw-r--r--makima/migrations/20250107000000_simplify_task_depth.sql18
-rw-r--r--makima/migrations/20250108000000_add_completion_actions.sql13
-rw-r--r--makima/migrations/20250109000000_add_continue_from_task_id.sql11
-rw-r--r--makima/migrations/20250110000000_create_owners_table.sql25
-rw-r--r--makima/migrations/20250110000001_create_users_table.sql27
-rw-r--r--makima/migrations/20250110000002_create_groups_tables.sql53
-rw-r--r--makima/migrations/20250110000003_create_api_keys_table.sql30
-rw-r--r--makima/migrations/20250110000004_create_api_key_events_table.sql20
-rw-r--r--makima/migrations/20250110000005_create_placeholder_owners.sql18
-rw-r--r--makima/migrations/20250110000006_add_owner_foreign_keys.sql30
-rw-r--r--makima/migrations/20250110000007_create_auth_trigger.sql63
-rw-r--r--makima/migrations/20250110000008_remove_owner_defaults.sql46
-rw-r--r--makima/src/db/models.rs589
-rw-r--r--makima/src/db/repository.rs1393
-rw-r--r--makima/src/llm/mesh_tools.rs1080
-rw-r--r--makima/src/llm/mod.rs2
-rw-r--r--makima/src/llm/tools.rs206
-rw-r--r--makima/src/server/auth.rs1238
-rw-r--r--makima/src/server/handlers/api_keys.rs282
-rw-r--r--makima/src/server/handlers/chat.rs115
-rw-r--r--makima/src/server/handlers/files.rs53
-rw-r--r--makima/src/server/handlers/mesh.rs1679
-rw-r--r--makima/src/server/handlers/mesh_chat.rs2088
-rw-r--r--makima/src/server/handlers/mesh_daemon.rs959
-rw-r--r--makima/src/server/handlers/mesh_merge.rs441
-rw-r--r--makima/src/server/handlers/mesh_ws.rs346
-rw-r--r--makima/src/server/handlers/mod.rs7
-rw-r--r--makima/src/server/handlers/users.rs972
-rw-r--r--makima/src/server/mod.rs59
-rw-r--r--makima/src/server/openapi.rs96
-rw-r--r--makima/src/server/state.rs467
94 files changed, 29269 insertions, 3135 deletions
diff --git a/.makima/orchestrate.sh b/.makima/orchestrate.sh
new file mode 100755
index 0000000..15095bd
--- /dev/null
+++ b/.makima/orchestrate.sh
@@ -0,0 +1,206 @@
+#!/bin/bash
+# Makima Orchestrator Helper Script
+# Usage: ./orchestrate.sh <command> [args...]
+
+API_URL="${MAKIMA_API_URL:-http://localhost:8080}"
+API_KEY="${MAKIMA_API_KEY}"
+TASK_ID="${MAKIMA_TASK_ID}"
+
+if [ -z "$API_KEY" ]; then
+ echo "Error: MAKIMA_API_KEY not set" >&2
+ exit 1
+fi
+
+if [ -z "$TASK_ID" ]; then
+ echo "Error: MAKIMA_TASK_ID not set" >&2
+ exit 1
+fi
+
+# Helper function to make API calls and check for errors
+api_call() {
+ local method="$1"
+ local url="$2"
+ local data="$3"
+ local response
+ local http_code
+
+ if [ -n "$data" ]; then
+ response=$(curl -s -w "\n%{http_code}" -X "$method" \
+ -H "X-Makima-Tool-Key: $API_KEY" \
+ -H "Content-Type: application/json" \
+ -d "$data" \
+ "$url")
+ else
+ response=$(curl -s -w "\n%{http_code}" -X "$method" \
+ -H "X-Makima-Tool-Key: $API_KEY" \
+ "$url")
+ fi
+
+ # Extract HTTP code (last line) and body (everything else)
+ http_code=$(echo "$response" | tail -n1)
+ body=$(echo "$response" | sed '$d')
+
+ # Check for curl errors or non-2xx status
+ if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
+ echo "Error: API request failed with HTTP $http_code" >&2
+ echo "URL: $url" >&2
+ echo "Response: $body" >&2
+ echo "$body"
+ return 1
+ fi
+
+ echo "$body"
+ return 0
+}
+
+case "$1" in
+ list)
+ api_call GET "$API_URL/api/v1/mesh/tasks/$TASK_ID/subtasks"
+ ;;
+ create)
+ # Parse arguments: create "name" "plan" [--continue-from <task_id>] [--files "file1,file2"]
+ if [ -z "$2" ] || [ -z "$3" ]; then
+ echo "Usage: $0 create \"<name>\" \"<plan>\" [--continue-from <task_id>] [--files \"file1,file2\"]" >&2
+ exit 1
+ fi
+ NAME="$2"
+ PLAN="$3"
+ CONTINUE_FROM=""
+ COPY_FILES=""
+
+ # Parse optional flags (can be in any order after name and plan)
+ shift 3
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --continue-from)
+ CONTINUE_FROM="$2"
+ shift 2
+ ;;
+ --files)
+ COPY_FILES="$2"
+ shift 2
+ ;;
+ *)
+ echo "Unknown option: $1" >&2
+ exit 1
+ ;;
+ esac
+ done
+
+ # Escape quotes in name and plan for JSON
+ NAME_ESCAPED=$(echo "$NAME" | sed 's/"/\\"/g' | sed 's/\\/\\\\/g')
+ PLAN_ESCAPED=$(echo "$PLAN" | sed 's/"/\\"/g' | sed 's/\\/\\\\/g')
+
+ # Build JSON body
+ JSON_BODY="{\"name\":\"$NAME_ESCAPED\",\"plan\":\"$PLAN_ESCAPED\",\"parentTaskId\":\"$TASK_ID\""
+
+ if [ -n "$CONTINUE_FROM" ]; then
+ echo "Creating subtask: $NAME (continuing from $CONTINUE_FROM)..." >&2
+ JSON_BODY="$JSON_BODY,\"continueFromTaskId\":\"$CONTINUE_FROM\""
+ else
+ echo "Creating subtask: $NAME..." >&2
+ fi
+
+ if [ -n "$COPY_FILES" ]; then
+ # Convert comma-separated file list to JSON array
+ FILES_JSON="["
+ first=true
+ IFS=',' read -ra FILE_ARRAY <<< "$COPY_FILES"
+ for file in "${FILE_ARRAY[@]}"; do
+ file=$(echo "$file" | xargs) # trim whitespace
+ if [ "$first" = true ]; then
+ FILES_JSON="$FILES_JSON\"$file\""
+ first=false
+ else
+ FILES_JSON="$FILES_JSON,\"$file\""
+ fi
+ done
+ FILES_JSON="$FILES_JSON]"
+ JSON_BODY="$JSON_BODY,\"copyFiles\":$FILES_JSON"
+ echo " (copying files: $COPY_FILES)" >&2
+ fi
+
+ JSON_BODY="$JSON_BODY}"
+ api_call POST "$API_URL/api/v1/mesh/tasks" "$JSON_BODY"
+ ;;
+ start)
+ if [ -z "$2" ]; then
+ echo "Usage: $0 start <subtask_id>" >&2
+ exit 1
+ fi
+ echo "Starting subtask $2..." >&2
+ api_call POST "$API_URL/api/v1/mesh/tasks/$2/start"
+ ;;
+ stop)
+ if [ -z "$2" ]; then
+ echo "Usage: $0 stop <subtask_id>" >&2
+ exit 1
+ fi
+ api_call POST "$API_URL/api/v1/mesh/tasks/$2/stop"
+ ;;
+ status)
+ if [ -z "$2" ]; then
+ echo "Usage: $0 status <subtask_id>" >&2
+ exit 1
+ fi
+ api_call GET "$API_URL/api/v1/mesh/tasks/$2"
+ ;;
+ output)
+ if [ -z "$2" ]; then
+ echo "Usage: $0 output <subtask_id>" >&2
+ exit 1
+ fi
+ api_call GET "$API_URL/api/v1/mesh/tasks/$2/output"
+ ;;
+ worktree)
+ if [ -z "$2" ]; then
+ echo "Usage: $0 worktree <subtask_id>" >&2
+ exit 1
+ fi
+ # Get the worktree path from the task's overlayPath field via API
+ TASK_JSON=$(api_call GET "$API_URL/api/v1/mesh/tasks/$2")
+ if [ $? -ne 0 ]; then
+ echo "Error: Failed to get task info" >&2
+ exit 1
+ fi
+ WORKTREE_PATH=$(echo "$TASK_JSON" | grep -o '"overlayPath":"[^"]*"' | cut -d'"' -f4)
+ if [ -z "$WORKTREE_PATH" ]; then
+ echo "Error: Task has no worktree path (may not have started yet)" >&2
+ exit 1
+ fi
+ if [ -d "$WORKTREE_PATH" ]; then
+ echo "$WORKTREE_PATH"
+ else
+ echo "Error: Worktree not found at $WORKTREE_PATH" >&2
+ echo "The worktree may have been cleaned up." >&2
+ exit 1
+ fi
+ ;;
+ done)
+ SUMMARY="${2:-Task completed}"
+ api_call PUT "$API_URL/api/v1/mesh/tasks/$TASK_ID" "{\"status\":\"done\",\"progressSummary\":\"$SUMMARY\"}"
+ ;;
+ *)
+ echo "Makima Orchestrator Helper"
+ echo ""
+ echo "Usage: $0 <command> [args...]"
+ echo ""
+ echo "Subtask Commands:"
+ echo " list List all subtasks and their status"
+ echo " create \"<name>\" \"<plan>\" Create a new subtask"
+ echo " create \"...\" --continue-from ID Create subtask continuing from another task's worktree"
+ echo " create \"...\" --files \"file1,file2\" Copy specific files from parent (orchestrator) worktree"
+ echo " start <subtask_id> Start a subtask"
+ echo " stop <subtask_id> Stop a running subtask"
+ echo " status <subtask_id> Get detailed subtask status"
+ echo " output <subtask_id> Get subtask output history"
+ echo " worktree <subtask_id> Get path to subtask's worktree"
+ echo ""
+ echo "Completion:"
+ echo " done [summary] Mark orchestrator as complete"
+ echo ""
+ echo "Examples:"
+ echo " create \"Fix bug\" \"Fix the null check bug\" --files \"PLAN.md\""
+ echo " create \"Step 2\" \"Continue work\" --continue-from abc123 --files \"shared.rs,types.rs\""
+ ;;
+esac
diff --git a/Cargo.lock b/Cargo.lock
index c1f237b..63ac8ff 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -112,12 +112,29 @@ dependencies = [
]
[[package]]
+name = "arraydeque"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
+
+[[package]]
name = "arrayvec"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
+name = "async-trait"
+version = "0.1.89"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
name = "atoi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -195,6 +212,20 @@ dependencies = [
]
[[package]]
+name = "backoff"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b62ddb9cb1ec0a098ad4bbf9344d0713fa193ae1a80af55febcff2627b6a00c1"
+dependencies = [
+ "futures-core",
+ "getrandom 0.2.16",
+ "instant",
+ "pin-project-lite",
+ "rand 0.8.5",
+ "tokio",
+]
+
+[[package]]
name = "base64"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -202,6 +233,12 @@ checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
[[package]]
name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "base64"
version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
@@ -379,6 +416,25 @@ dependencies = [
]
[[package]]
+name = "config"
+version = "0.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf"
+dependencies = [
+ "async-trait",
+ "convert_case",
+ "json5",
+ "nom",
+ "pathdiff",
+ "ron",
+ "rust-ini",
+ "serde",
+ "serde_json",
+ "toml",
+ "yaml-rust2",
+]
+
+[[package]]
name = "console"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -398,6 +454,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]]
+name = "const-random"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
+dependencies = [
+ "const-random-macro",
+]
+
+[[package]]
+name = "const-random-macro"
+version = "0.1.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
+dependencies = [
+ "getrandom 0.2.16",
+ "once_cell",
+ "tiny-keccak",
+]
+
+[[package]]
+name = "convert_case"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -481,6 +566,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
+[[package]]
name = "crypto-common"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -535,6 +626,20 @@ dependencies = [
]
[[package]]
+name = "dashmap"
+version = "6.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf"
+dependencies = [
+ "cfg-if",
+ "crossbeam-utils",
+ "hashbrown 0.14.5",
+ "lock_api",
+ "once_cell",
+ "parking_lot_core",
+]
+
+[[package]]
name = "data-encoding"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -552,6 +657,15 @@ dependencies = [
]
[[package]]
+name = "deranged"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587"
+dependencies = [
+ "powerfmt",
+]
+
+[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -607,11 +721,32 @@ dependencies = [
[[package]]
name = "dirs"
+version = "5.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
+dependencies = [
+ "dirs-sys 0.4.1",
+]
+
+[[package]]
+name = "dirs"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
dependencies = [
- "dirs-sys",
+ "dirs-sys 0.5.0",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
+dependencies = [
+ "libc",
+ "option-ext",
+ "redox_users 0.4.6",
+ "windows-sys 0.48.0",
]
[[package]]
@@ -622,7 +757,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
dependencies = [
"libc",
"option-ext",
- "redox_users",
+ "redox_users 0.5.2",
"windows-sys 0.61.2",
]
@@ -638,6 +773,15 @@ dependencies = [
]
[[package]]
+name = "dlv-list"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f"
+dependencies = [
+ "const-random",
+]
+
+[[package]]
name = "dotenvy"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -737,6 +881,18 @@ dependencies = [
]
[[package]]
+name = "fallible-iterator"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
+
+[[package]]
+name = "fallible-streaming-iterator"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
+
+[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -935,8 +1091,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
dependencies = [
"cfg-if",
+ "js-sys",
"libc",
"wasi",
+ "wasm-bindgen",
]
[[package]]
@@ -1005,6 +1163,24 @@ checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
[[package]]
name = "hashlink"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7"
+dependencies = [
+ "hashbrown 0.14.5",
+]
+
+[[package]]
+name = "hashlink"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
+dependencies = [
+ "hashbrown 0.14.5",
+]
+
+[[package]]
+name = "hashlink"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
@@ -1036,7 +1212,7 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97"
dependencies = [
- "dirs",
+ "dirs 6.0.0",
"futures",
"http",
"indicatif",
@@ -1088,6 +1264,17 @@ dependencies = [
]
[[package]]
+name = "hostname"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "windows-link",
+]
+
+[[package]]
name = "hound"
version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1383,6 +1570,15 @@ dependencies = [
]
[[package]]
+name = "instant"
+version = "0.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
name = "ipnet"
version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1508,6 +1704,32 @@ dependencies = [
]
[[package]]
+name = "json5"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1"
+dependencies = [
+ "pest",
+ "pest_derive",
+ "serde",
+]
+
+[[package]]
+name = "jsonwebtoken"
+version = "9.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
+dependencies = [
+ "base64 0.22.1",
+ "js-sys",
+ "pem",
+ "ring",
+ "serde",
+ "serde_json",
+ "simple_asn1",
+]
+
+[[package]]
name = "lazy_static"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1545,6 +1767,7 @@ version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
+ "cc",
"pkg-config",
"vcpkg",
]
@@ -1608,22 +1831,28 @@ dependencies = [
"ahash",
"anyhow",
"axum",
+ "base64 0.22.1",
"bytes",
"chrono",
+ "dashmap",
"futures",
+ "hex",
"hf-hub",
"indexmap",
"jaq-core",
"jaq-interpret",
"jaq-parse",
"jaq-std",
+ "jsonwebtoken",
"ndarray",
"once_cell",
"ort",
"parakeet-rs",
+ "rand 0.8.5",
"reqwest",
"serde",
"serde_json",
+ "sha2",
"sqlx",
"symphonia",
"thiserror 2.0.17",
@@ -1638,6 +1867,35 @@ dependencies = [
]
[[package]]
+name = "makima-daemon"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "async-trait",
+ "backoff",
+ "chrono",
+ "clap",
+ "config",
+ "dashmap",
+ "dirs 5.0.1",
+ "futures",
+ "hex",
+ "hostname",
+ "rand 0.9.2",
+ "rusqlite",
+ "serde",
+ "serde_json",
+ "sha2",
+ "shell-escape",
+ "thiserror 2.0.17",
+ "tokio",
+ "tokio-tungstenite 0.24.0",
+ "tracing",
+ "tracing-subscriber",
+ "uuid",
+]
+
+[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1812,6 +2070,16 @@ dependencies = [
]
[[package]]
+name = "num-bigint"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
name = "num-bigint-dig"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1837,6 +2105,12 @@ dependencies = [
]
[[package]]
+name = "num-conv"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
+
+[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1967,6 +2241,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
+name = "ordered-multimap"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79"
+dependencies = [
+ "dlv-list",
+ "hashbrown 0.14.5",
+]
+
+[[package]]
name = "ort"
version = "2.0.0-rc.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2042,6 +2326,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
+name = "pathdiff"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
+
+[[package]]
+name = "pem"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
+dependencies = [
+ "base64 0.22.1",
+ "serde_core",
+]
+
+[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2057,6 +2357,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
+name = "pest"
+version = "2.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c9eb05c21a464ea704b53158d358a31e6425db2f63a1a7312268b05fe2b75f7"
+dependencies = [
+ "memchr",
+ "ucd-trie",
+]
+
+[[package]]
+name = "pest_derive"
+version = "2.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68f9dbced329c441fa79d80472764b1a2c7e57123553b8519b36663a2fb234ed"
+dependencies = [
+ "pest",
+ "pest_generator",
+]
+
+[[package]]
+name = "pest_generator"
+version = "2.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3bb96d5051a78f44f43c8f712d8e810adb0ebf923fc9ed2655a7f66f63ba8ee5"
+dependencies = [
+ "pest",
+ "pest_meta",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pest_meta"
+version = "2.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "602113b5b5e8621770cfd490cfd90b9f84ab29bd2b0e49ad83eb6d186cef2365"
+dependencies = [
+ "pest",
+ "sha2",
+]
+
+[[package]]
name = "pin-project-lite"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2120,6 +2463,12 @@ dependencies = [
]
[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2288,6 +2637,17 @@ dependencies = [
[[package]]
name = "redox_users"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
+dependencies = [
+ "getrandom 0.2.16",
+ "libredox",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "redox_users"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
@@ -2384,6 +2744,18 @@ dependencies = [
]
[[package]]
+name = "ron"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94"
+dependencies = [
+ "base64 0.21.7",
+ "bitflags 2.10.0",
+ "serde",
+ "serde_derive",
+]
+
+[[package]]
name = "rsa"
version = "0.9.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2404,6 +2776,20 @@ dependencies = [
]
[[package]]
+name = "rusqlite"
+version = "0.32.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
+dependencies = [
+ "bitflags 2.10.0",
+ "fallible-iterator",
+ "fallible-streaming-iterator",
+ "hashlink 0.9.1",
+ "libsqlite3-sys",
+ "smallvec 1.15.1",
+]
+
+[[package]]
name = "rust-embed"
version = "8.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2438,6 +2824,16 @@ dependencies = [
]
[[package]]
+name = "rust-ini"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e0698206bcb8882bf2a9ecb4c1e7785db57ff052297085a6efd4fe42302068a"
+dependencies = [
+ "cfg-if",
+ "ordered-multimap",
+]
+
+[[package]]
name = "rustfft"
version = "6.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2613,6 +3009,15 @@ dependencies = [
]
[[package]]
+name = "serde_spanned"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
+dependencies = [
+ "serde",
+]
+
+[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2656,6 +3061,12 @@ dependencies = [
]
[[package]]
+name = "shell-escape"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45bb67a18fa91266cc7807181f62f9178a6873bfad7dc788c42e6430db40184f"
+
+[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2687,6 +3098,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
+name = "simple_asn1"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb"
+dependencies = [
+ "num-bigint",
+ "num-traits",
+ "thiserror 2.0.17",
+ "time",
+]
+
+[[package]]
name = "slab"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2790,7 +3213,7 @@ dependencies = [
"futures-io",
"futures-util",
"hashbrown 0.15.5",
- "hashlink",
+ "hashlink 0.10.0",
"indexmap",
"log",
"memchr",
@@ -3295,6 +3718,46 @@ dependencies = [
]
[[package]]
+name = "time"
+version = "0.3.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d"
+dependencies = [
+ "deranged",
+ "itoa",
+ "num-conv",
+ "powerfmt",
+ "serde",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b"
+
+[[package]]
+name = "time-macros"
+version = "0.2.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tiny-keccak"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
+dependencies = [
+ "crunchy",
+]
+
+[[package]]
name = "tinystr"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3484,6 +3947,47 @@ dependencies = [
]
[[package]]
+name = "toml"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_write",
+ "winnow",
+]
+
+[[package]]
+name = "toml_write"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
+
+[[package]]
name = "tower"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3575,6 +4079,16 @@ dependencies = [
]
[[package]]
+name = "tracing-serde"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
+dependencies = [
+ "serde",
+ "tracing-core",
+]
+
+[[package]]
name = "tracing-subscriber"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3584,12 +4098,15 @@ dependencies = [
"nu-ansi-term",
"once_cell",
"regex-automata",
+ "serde",
+ "serde_json",
"sharded-slab",
"smallvec 1.15.1",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
+ "tracing-serde",
]
[[package]]
@@ -3651,6 +4168,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
[[package]]
+name = "ucd-trie"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
+
+[[package]]
name = "unicase"
version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4376,6 +4899,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
[[package]]
+name = "winnow"
+version = "0.7.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
name = "wit-bindgen"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -4398,6 +4930,17 @@ dependencies = [
]
[[package]]
+name = "yaml-rust2"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8"
+dependencies = [
+ "arraydeque",
+ "encoding_rs",
+ "hashlink 0.8.4",
+]
+
+[[package]]
name = "yoke"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 377d0bb..b8248e4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,3 +1,3 @@
[workspace]
-members = ["makima", "tools/stt-client", "vendor/parakeet-rs"]
+members = ["makima", "makima/daemon", "tools/stt-client", "vendor/parakeet-rs"]
resolver = "2"
diff --git a/makima/Cargo.lock b/makima/Cargo.lock
deleted file mode 100644
index f3fea81..0000000
--- a/makima/Cargo.lock
+++ /dev/null
@@ -1,3022 +0,0 @@
-# This file is automatically @generated by Cargo.
-# It is not intended for manual editing.
-version = 4
-
-[[package]]
-name = "adler2"
-version = "2.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
-
-[[package]]
-name = "ahash"
-version = "0.8.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
-dependencies = [
- "cfg-if",
- "getrandom 0.3.4",
- "once_cell",
- "serde",
- "version_check",
- "zerocopy",
-]
-
-[[package]]
-name = "aho-corasick"
-version = "1.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
-dependencies = [
- "memchr",
-]
-
-[[package]]
-name = "arrayvec"
-version = "0.7.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
-
-[[package]]
-name = "atomic-waker"
-version = "1.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
-
-[[package]]
-name = "autocfg"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
-
-[[package]]
-name = "base64"
-version = "0.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
-
-[[package]]
-name = "base64"
-version = "0.22.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
-
-[[package]]
-name = "base64ct"
-version = "1.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a"
-
-[[package]]
-name = "bitflags"
-version = "1.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
-
-[[package]]
-name = "bitflags"
-version = "2.10.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
-
-[[package]]
-name = "block-buffer"
-version = "0.10.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
-dependencies = [
- "generic-array",
-]
-
-[[package]]
-name = "bumpalo"
-version = "3.19.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
-
-[[package]]
-name = "bytemuck"
-version = "1.24.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
-
-[[package]]
-name = "byteorder"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
-
-[[package]]
-name = "bytes"
-version = "1.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
-
-[[package]]
-name = "castaway"
-version = "0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
-dependencies = [
- "rustversion",
-]
-
-[[package]]
-name = "cc"
-version = "1.2.49"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215"
-dependencies = [
- "find-msvc-tools",
- "shlex",
-]
-
-[[package]]
-name = "cfg-if"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
-
-[[package]]
-name = "compact_str"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a"
-dependencies = [
- "castaway",
- "cfg-if",
- "itoa",
- "rustversion",
- "ryu",
- "serde",
- "static_assertions",
-]
-
-[[package]]
-name = "console"
-version = "0.15.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
-dependencies = [
- "encode_unicode",
- "libc",
- "once_cell",
- "unicode-width",
- "windows-sys 0.59.0",
-]
-
-[[package]]
-name = "core-foundation"
-version = "0.9.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
-dependencies = [
- "core-foundation-sys",
- "libc",
-]
-
-[[package]]
-name = "core-foundation-sys"
-version = "0.8.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
-
-[[package]]
-name = "cpufeatures"
-version = "0.2.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "crc32fast"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
-dependencies = [
- "cfg-if",
-]
-
-[[package]]
-name = "crossbeam-deque"
-version = "0.8.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
-dependencies = [
- "crossbeam-epoch",
- "crossbeam-utils",
-]
-
-[[package]]
-name = "crossbeam-epoch"
-version = "0.9.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
-dependencies = [
- "crossbeam-utils",
-]
-
-[[package]]
-name = "crossbeam-utils"
-version = "0.8.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
-
-[[package]]
-name = "crypto-common"
-version = "0.1.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
-dependencies = [
- "generic-array",
- "typenum",
-]
-
-[[package]]
-name = "darling"
-version = "0.20.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
-dependencies = [
- "darling_core",
- "darling_macro",
-]
-
-[[package]]
-name = "darling_core"
-version = "0.20.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
-dependencies = [
- "fnv",
- "ident_case",
- "proc-macro2",
- "quote",
- "strsim",
- "syn",
-]
-
-[[package]]
-name = "darling_macro"
-version = "0.20.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
-dependencies = [
- "darling_core",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "dary_heap"
-version = "0.3.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "06d2e3287df1c007e74221c49ca10a95d557349e54b3a75dc2fb14712c751f04"
-dependencies = [
- "serde",
-]
-
-[[package]]
-name = "der"
-version = "0.7.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
-dependencies = [
- "pem-rfc7468",
- "zeroize",
-]
-
-[[package]]
-name = "derive_builder"
-version = "0.20.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947"
-dependencies = [
- "derive_builder_macro",
-]
-
-[[package]]
-name = "derive_builder_core"
-version = "0.20.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8"
-dependencies = [
- "darling",
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "derive_builder_macro"
-version = "0.20.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c"
-dependencies = [
- "derive_builder_core",
- "syn",
-]
-
-[[package]]
-name = "digest"
-version = "0.10.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
-dependencies = [
- "block-buffer",
- "crypto-common",
-]
-
-[[package]]
-name = "dirs"
-version = "6.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
-dependencies = [
- "dirs-sys",
-]
-
-[[package]]
-name = "dirs-sys"
-version = "0.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
-dependencies = [
- "libc",
- "option-ext",
- "redox_users",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "displaydoc"
-version = "0.2.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "either"
-version = "1.15.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
-
-[[package]]
-name = "encode_unicode"
-version = "1.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
-
-[[package]]
-name = "encoding_rs"
-version = "0.8.35"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
-dependencies = [
- "cfg-if",
-]
-
-[[package]]
-name = "equivalent"
-version = "1.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
-
-[[package]]
-name = "errno"
-version = "0.3.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
-dependencies = [
- "libc",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "esaxx-rs"
-version = "0.1.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6"
-dependencies = [
- "cc",
-]
-
-[[package]]
-name = "extended"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365"
-
-[[package]]
-name = "eyre"
-version = "0.6.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
-dependencies = [
- "indenter",
- "once_cell",
-]
-
-[[package]]
-name = "fastrand"
-version = "2.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
-
-[[package]]
-name = "filetime"
-version = "0.2.26"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed"
-dependencies = [
- "cfg-if",
- "libc",
- "libredox",
- "windows-sys 0.60.2",
-]
-
-[[package]]
-name = "find-msvc-tools"
-version = "0.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844"
-
-[[package]]
-name = "flate2"
-version = "1.1.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb"
-dependencies = [
- "crc32fast",
- "miniz_oxide",
-]
-
-[[package]]
-name = "fnv"
-version = "1.0.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
-
-[[package]]
-name = "foreign-types"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
-dependencies = [
- "foreign-types-shared",
-]
-
-[[package]]
-name = "foreign-types-shared"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
-
-[[package]]
-name = "form_urlencoded"
-version = "1.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
-dependencies = [
- "percent-encoding",
-]
-
-[[package]]
-name = "futures"
-version = "0.3.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876"
-dependencies = [
- "futures-channel",
- "futures-core",
- "futures-executor",
- "futures-io",
- "futures-sink",
- "futures-task",
- "futures-util",
-]
-
-[[package]]
-name = "futures-channel"
-version = "0.3.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
-dependencies = [
- "futures-core",
- "futures-sink",
-]
-
-[[package]]
-name = "futures-core"
-version = "0.3.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
-
-[[package]]
-name = "futures-executor"
-version = "0.3.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
-dependencies = [
- "futures-core",
- "futures-task",
- "futures-util",
-]
-
-[[package]]
-name = "futures-io"
-version = "0.3.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
-
-[[package]]
-name = "futures-macro"
-version = "0.3.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "futures-sink"
-version = "0.3.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
-
-[[package]]
-name = "futures-task"
-version = "0.3.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
-
-[[package]]
-name = "futures-util"
-version = "0.3.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
-dependencies = [
- "futures-channel",
- "futures-core",
- "futures-io",
- "futures-macro",
- "futures-sink",
- "futures-task",
- "memchr",
- "pin-project-lite",
- "pin-utils",
- "slab",
-]
-
-[[package]]
-name = "generic-array"
-version = "0.14.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
-dependencies = [
- "typenum",
- "version_check",
-]
-
-[[package]]
-name = "getrandom"
-version = "0.2.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
-dependencies = [
- "cfg-if",
- "libc",
- "wasi",
-]
-
-[[package]]
-name = "getrandom"
-version = "0.3.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
-dependencies = [
- "cfg-if",
- "libc",
- "r-efi",
- "wasip2",
-]
-
-[[package]]
-name = "h2"
-version = "0.4.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
-dependencies = [
- "atomic-waker",
- "bytes",
- "fnv",
- "futures-core",
- "futures-sink",
- "http",
- "indexmap",
- "slab",
- "tokio",
- "tokio-util",
- "tracing",
-]
-
-[[package]]
-name = "hashbrown"
-version = "0.16.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
-
-[[package]]
-name = "hermit-abi"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
-
-[[package]]
-name = "hf-hub"
-version = "0.4.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97"
-dependencies = [
- "dirs",
- "futures",
- "http",
- "indicatif",
- "libc",
- "log",
- "native-tls",
- "num_cpus",
- "rand 0.9.2",
- "reqwest",
- "serde",
- "serde_json",
- "thiserror 2.0.17",
- "tokio",
- "ureq 2.12.1",
- "windows-sys 0.60.2",
-]
-
-[[package]]
-name = "hound"
-version = "3.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
-
-[[package]]
-name = "http"
-version = "1.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
-dependencies = [
- "bytes",
- "itoa",
-]
-
-[[package]]
-name = "http-body"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
-dependencies = [
- "bytes",
- "http",
-]
-
-[[package]]
-name = "http-body-util"
-version = "0.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
-dependencies = [
- "bytes",
- "futures-core",
- "http",
- "http-body",
- "pin-project-lite",
-]
-
-[[package]]
-name = "httparse"
-version = "1.10.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
-
-[[package]]
-name = "hyper"
-version = "1.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
-dependencies = [
- "atomic-waker",
- "bytes",
- "futures-channel",
- "futures-core",
- "h2",
- "http",
- "http-body",
- "httparse",
- "itoa",
- "pin-project-lite",
- "pin-utils",
- "smallvec 1.15.1",
- "tokio",
- "want",
-]
-
-[[package]]
-name = "hyper-rustls"
-version = "0.27.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
-dependencies = [
- "http",
- "hyper",
- "hyper-util",
- "rustls",
- "rustls-pki-types",
- "tokio",
- "tokio-rustls",
- "tower-service",
-]
-
-[[package]]
-name = "hyper-tls"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
-dependencies = [
- "bytes",
- "http-body-util",
- "hyper",
- "hyper-util",
- "native-tls",
- "tokio",
- "tokio-native-tls",
- "tower-service",
-]
-
-[[package]]
-name = "hyper-util"
-version = "0.1.19"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
-dependencies = [
- "base64 0.22.1",
- "bytes",
- "futures-channel",
- "futures-core",
- "futures-util",
- "http",
- "http-body",
- "hyper",
- "ipnet",
- "libc",
- "percent-encoding",
- "pin-project-lite",
- "socket2",
- "system-configuration",
- "tokio",
- "tower-service",
- "tracing",
- "windows-registry",
-]
-
-[[package]]
-name = "icu_collections"
-version = "2.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
-dependencies = [
- "displaydoc",
- "potential_utf",
- "yoke",
- "zerofrom",
- "zerovec",
-]
-
-[[package]]
-name = "icu_locale_core"
-version = "2.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
-dependencies = [
- "displaydoc",
- "litemap",
- "tinystr",
- "writeable",
- "zerovec",
-]
-
-[[package]]
-name = "icu_normalizer"
-version = "2.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
-dependencies = [
- "icu_collections",
- "icu_normalizer_data",
- "icu_properties",
- "icu_provider",
- "smallvec 1.15.1",
- "zerovec",
-]
-
-[[package]]
-name = "icu_normalizer_data"
-version = "2.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
-
-[[package]]
-name = "icu_properties"
-version = "2.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
-dependencies = [
- "icu_collections",
- "icu_locale_core",
- "icu_properties_data",
- "icu_provider",
- "zerotrie",
- "zerovec",
-]
-
-[[package]]
-name = "icu_properties_data"
-version = "2.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
-
-[[package]]
-name = "icu_provider"
-version = "2.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
-dependencies = [
- "displaydoc",
- "icu_locale_core",
- "writeable",
- "yoke",
- "zerofrom",
- "zerotrie",
- "zerovec",
-]
-
-[[package]]
-name = "ident_case"
-version = "1.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
-
-[[package]]
-name = "idna"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
-dependencies = [
- "idna_adapter",
- "smallvec 1.15.1",
- "utf8_iter",
-]
-
-[[package]]
-name = "idna_adapter"
-version = "1.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
-dependencies = [
- "icu_normalizer",
- "icu_properties",
-]
-
-[[package]]
-name = "indenter"
-version = "0.3.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5"
-
-[[package]]
-name = "indexmap"
-version = "2.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2"
-dependencies = [
- "equivalent",
- "hashbrown",
-]
-
-[[package]]
-name = "indicatif"
-version = "0.17.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
-dependencies = [
- "console",
- "number_prefix",
- "portable-atomic",
- "unicode-width",
- "web-time",
-]
-
-[[package]]
-name = "ipnet"
-version = "2.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
-
-[[package]]
-name = "iri-string"
-version = "0.7.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397"
-dependencies = [
- "memchr",
- "serde",
-]
-
-[[package]]
-name = "itertools"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57"
-dependencies = [
- "either",
-]
-
-[[package]]
-name = "itertools"
-version = "0.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
-dependencies = [
- "either",
-]
-
-[[package]]
-name = "itertools"
-version = "0.14.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
-dependencies = [
- "either",
-]
-
-[[package]]
-name = "itoa"
-version = "1.0.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
-
-[[package]]
-name = "js-sys"
-version = "0.3.83"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8"
-dependencies = [
- "once_cell",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "lazy_static"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
-
-[[package]]
-name = "libc"
-version = "0.2.178"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091"
-
-[[package]]
-name = "libredox"
-version = "0.1.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb"
-dependencies = [
- "bitflags 2.10.0",
- "libc",
- "redox_syscall",
-]
-
-[[package]]
-name = "linux-raw-sys"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
-
-[[package]]
-name = "litemap"
-version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
-
-[[package]]
-name = "log"
-version = "0.4.29"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
-
-[[package]]
-name = "macro_rules_attribute"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520"
-dependencies = [
- "macro_rules_attribute-proc_macro",
- "paste",
-]
-
-[[package]]
-name = "macro_rules_attribute-proc_macro"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30"
-
-[[package]]
-name = "makima"
-version = "0.1.0"
-dependencies = [
- "hf-hub",
- "ndarray",
- "ort",
- "parakeet-rs",
- "symphonia",
- "tokenizers 0.21.4",
-]
-
-[[package]]
-name = "matrixmultiply"
-version = "0.3.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08"
-dependencies = [
- "autocfg",
- "rawpointer",
-]
-
-[[package]]
-name = "memchr"
-version = "2.7.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
-
-[[package]]
-name = "mime"
-version = "0.3.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
-
-[[package]]
-name = "minimal-lexical"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
-
-[[package]]
-name = "miniz_oxide"
-version = "0.8.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
-dependencies = [
- "adler2",
- "simd-adler32",
-]
-
-[[package]]
-name = "mio"
-version = "1.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
-dependencies = [
- "libc",
- "wasi",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "monostate"
-version = "0.1.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67"
-dependencies = [
- "monostate-impl",
- "serde",
- "serde_core",
-]
-
-[[package]]
-name = "monostate-impl"
-version = "0.1.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "native-tls"
-version = "0.2.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
-dependencies = [
- "libc",
- "log",
- "openssl",
- "openssl-probe",
- "openssl-sys",
- "schannel",
- "security-framework",
- "security-framework-sys",
- "tempfile",
-]
-
-[[package]]
-name = "ndarray"
-version = "0.16.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
-dependencies = [
- "matrixmultiply",
- "num-complex",
- "num-integer",
- "num-traits",
- "portable-atomic",
- "portable-atomic-util",
- "rawpointer",
-]
-
-[[package]]
-name = "nom"
-version = "7.1.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
-dependencies = [
- "memchr",
- "minimal-lexical",
-]
-
-[[package]]
-name = "num-complex"
-version = "0.4.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
-dependencies = [
- "num-traits",
-]
-
-[[package]]
-name = "num-integer"
-version = "0.1.46"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
-dependencies = [
- "num-traits",
-]
-
-[[package]]
-name = "num-traits"
-version = "0.2.19"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
-dependencies = [
- "autocfg",
-]
-
-[[package]]
-name = "num_cpus"
-version = "1.17.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
-dependencies = [
- "hermit-abi",
- "libc",
-]
-
-[[package]]
-name = "number_prefix"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
-
-[[package]]
-name = "once_cell"
-version = "1.21.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
-
-[[package]]
-name = "onig"
-version = "6.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0"
-dependencies = [
- "bitflags 2.10.0",
- "libc",
- "once_cell",
- "onig_sys",
-]
-
-[[package]]
-name = "onig_sys"
-version = "69.9.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc"
-dependencies = [
- "cc",
- "pkg-config",
-]
-
-[[package]]
-name = "openssl"
-version = "0.10.75"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
-dependencies = [
- "bitflags 2.10.0",
- "cfg-if",
- "foreign-types",
- "libc",
- "once_cell",
- "openssl-macros",
- "openssl-sys",
-]
-
-[[package]]
-name = "openssl-macros"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "openssl-probe"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
-
-[[package]]
-name = "openssl-sys"
-version = "0.9.111"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
-dependencies = [
- "cc",
- "libc",
- "pkg-config",
- "vcpkg",
-]
-
-[[package]]
-name = "option-ext"
-version = "0.2.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
-
-[[package]]
-name = "ort"
-version = "2.0.0-rc.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fa7e49bd669d32d7bc2a15ec540a527e7764aec722a45467814005725bcd721"
-dependencies = [
- "ndarray",
- "ort-sys",
- "smallvec 2.0.0-alpha.10",
- "tracing",
-]
-
-[[package]]
-name = "ort-sys"
-version = "2.0.0-rc.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e2aba9f5c7c479925205799216e7e5d07cc1d4fa76ea8058c60a9a30f6a4e890"
-dependencies = [
- "flate2",
- "pkg-config",
- "sha2",
- "tar",
- "ureq 3.1.4",
-]
-
-[[package]]
-name = "parakeet-rs"
-version = "0.2.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "89ac0c8598d3cdd54140cff2440b142d6522de5fdcd574beb5e3ab9b0f191875"
-dependencies = [
- "eyre",
- "hound",
- "ndarray",
- "ort",
- "rustfft",
- "serde",
- "serde_json",
- "tokenizers 0.20.4",
-]
-
-[[package]]
-name = "paste"
-version = "1.0.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
-
-[[package]]
-name = "pem-rfc7468"
-version = "0.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
-dependencies = [
- "base64ct",
-]
-
-[[package]]
-name = "percent-encoding"
-version = "2.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
-
-[[package]]
-name = "pin-project-lite"
-version = "0.2.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
-
-[[package]]
-name = "pin-utils"
-version = "0.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
-
-[[package]]
-name = "pkg-config"
-version = "0.3.32"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
-
-[[package]]
-name = "portable-atomic"
-version = "1.11.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
-
-[[package]]
-name = "portable-atomic-util"
-version = "0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
-dependencies = [
- "portable-atomic",
-]
-
-[[package]]
-name = "potential_utf"
-version = "0.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
-dependencies = [
- "zerovec",
-]
-
-[[package]]
-name = "ppv-lite86"
-version = "0.2.21"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
-dependencies = [
- "zerocopy",
-]
-
-[[package]]
-name = "primal-check"
-version = "0.3.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
-dependencies = [
- "num-integer",
-]
-
-[[package]]
-name = "proc-macro2"
-version = "1.0.103"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
-dependencies = [
- "unicode-ident",
-]
-
-[[package]]
-name = "quote"
-version = "1.0.42"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
-dependencies = [
- "proc-macro2",
-]
-
-[[package]]
-name = "r-efi"
-version = "5.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
-
-[[package]]
-name = "rand"
-version = "0.8.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
-dependencies = [
- "libc",
- "rand_chacha 0.3.1",
- "rand_core 0.6.4",
-]
-
-[[package]]
-name = "rand"
-version = "0.9.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
-dependencies = [
- "rand_chacha 0.9.0",
- "rand_core 0.9.3",
-]
-
-[[package]]
-name = "rand_chacha"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
-dependencies = [
- "ppv-lite86",
- "rand_core 0.6.4",
-]
-
-[[package]]
-name = "rand_chacha"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
-dependencies = [
- "ppv-lite86",
- "rand_core 0.9.3",
-]
-
-[[package]]
-name = "rand_core"
-version = "0.6.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
-dependencies = [
- "getrandom 0.2.16",
-]
-
-[[package]]
-name = "rand_core"
-version = "0.9.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38"
-dependencies = [
- "getrandom 0.3.4",
-]
-
-[[package]]
-name = "rawpointer"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3"
-
-[[package]]
-name = "rayon"
-version = "1.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
-dependencies = [
- "either",
- "rayon-core",
-]
-
-[[package]]
-name = "rayon-cond"
-version = "0.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "059f538b55efd2309c9794130bc149c6a553db90e9d99c2030785c82f0bd7df9"
-dependencies = [
- "either",
- "itertools 0.11.0",
- "rayon",
-]
-
-[[package]]
-name = "rayon-cond"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f"
-dependencies = [
- "either",
- "itertools 0.14.0",
- "rayon",
-]
-
-[[package]]
-name = "rayon-core"
-version = "1.13.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
-dependencies = [
- "crossbeam-deque",
- "crossbeam-utils",
-]
-
-[[package]]
-name = "redox_syscall"
-version = "0.5.18"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
-dependencies = [
- "bitflags 2.10.0",
-]
-
-[[package]]
-name = "redox_users"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
-dependencies = [
- "getrandom 0.2.16",
- "libredox",
- "thiserror 2.0.17",
-]
-
-[[package]]
-name = "regex"
-version = "1.12.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4"
-dependencies = [
- "aho-corasick",
- "memchr",
- "regex-automata",
- "regex-syntax",
-]
-
-[[package]]
-name = "regex-automata"
-version = "0.4.13"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
-dependencies = [
- "aho-corasick",
- "memchr",
- "regex-syntax",
-]
-
-[[package]]
-name = "regex-syntax"
-version = "0.8.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
-
-[[package]]
-name = "reqwest"
-version = "0.12.26"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f"
-dependencies = [
- "base64 0.22.1",
- "bytes",
- "encoding_rs",
- "futures-core",
- "futures-util",
- "h2",
- "http",
- "http-body",
- "http-body-util",
- "hyper",
- "hyper-rustls",
- "hyper-tls",
- "hyper-util",
- "js-sys",
- "log",
- "mime",
- "native-tls",
- "percent-encoding",
- "pin-project-lite",
- "rustls-pki-types",
- "serde",
- "serde_json",
- "serde_urlencoded",
- "sync_wrapper",
- "tokio",
- "tokio-native-tls",
- "tokio-util",
- "tower",
- "tower-http",
- "tower-service",
- "url",
- "wasm-bindgen",
- "wasm-bindgen-futures",
- "wasm-streams",
- "web-sys",
-]
-
-[[package]]
-name = "ring"
-version = "0.17.14"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
-dependencies = [
- "cc",
- "cfg-if",
- "getrandom 0.2.16",
- "libc",
- "untrusted",
- "windows-sys 0.52.0",
-]
-
-[[package]]
-name = "rustfft"
-version = "6.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
-dependencies = [
- "num-complex",
- "num-integer",
- "num-traits",
- "primal-check",
- "strength_reduce",
- "transpose",
-]
-
-[[package]]
-name = "rustix"
-version = "1.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
-dependencies = [
- "bitflags 2.10.0",
- "errno",
- "libc",
- "linux-raw-sys",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "rustls"
-version = "0.23.35"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
-dependencies = [
- "log",
- "once_cell",
- "ring",
- "rustls-pki-types",
- "rustls-webpki",
- "subtle",
- "zeroize",
-]
-
-[[package]]
-name = "rustls-pki-types"
-version = "1.13.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c"
-dependencies = [
- "zeroize",
-]
-
-[[package]]
-name = "rustls-webpki"
-version = "0.103.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
-dependencies = [
- "ring",
- "rustls-pki-types",
- "untrusted",
-]
-
-[[package]]
-name = "rustversion"
-version = "1.0.22"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
-
-[[package]]
-name = "ryu"
-version = "1.0.20"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
-
-[[package]]
-name = "schannel"
-version = "0.1.28"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
-dependencies = [
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "security-framework"
-version = "2.11.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
-dependencies = [
- "bitflags 2.10.0",
- "core-foundation",
- "core-foundation-sys",
- "libc",
- "security-framework-sys",
-]
-
-[[package]]
-name = "security-framework-sys"
-version = "2.15.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
-dependencies = [
- "core-foundation-sys",
- "libc",
-]
-
-[[package]]
-name = "serde"
-version = "1.0.228"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
-dependencies = [
- "serde_core",
- "serde_derive",
-]
-
-[[package]]
-name = "serde_core"
-version = "1.0.228"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
-dependencies = [
- "serde_derive",
-]
-
-[[package]]
-name = "serde_derive"
-version = "1.0.228"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "serde_json"
-version = "1.0.145"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
-dependencies = [
- "itoa",
- "memchr",
- "ryu",
- "serde",
- "serde_core",
-]
-
-[[package]]
-name = "serde_urlencoded"
-version = "0.7.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
-dependencies = [
- "form_urlencoded",
- "itoa",
- "ryu",
- "serde",
-]
-
-[[package]]
-name = "sha2"
-version = "0.10.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
-dependencies = [
- "cfg-if",
- "cpufeatures",
- "digest",
-]
-
-[[package]]
-name = "shlex"
-version = "1.3.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
-
-[[package]]
-name = "simd-adler32"
-version = "0.3.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
-
-[[package]]
-name = "slab"
-version = "0.4.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
-
-[[package]]
-name = "smallvec"
-version = "1.15.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
-
-[[package]]
-name = "smallvec"
-version = "2.0.0-alpha.10"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "51d44cfb396c3caf6fbfd0ab422af02631b69ddd96d2eff0b0f0724f9024051b"
-
-[[package]]
-name = "socket2"
-version = "0.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
-dependencies = [
- "libc",
- "windows-sys 0.60.2",
-]
-
-[[package]]
-name = "socks"
-version = "0.3.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b"
-dependencies = [
- "byteorder",
- "libc",
- "winapi",
-]
-
-[[package]]
-name = "spm_precompiled"
-version = "0.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326"
-dependencies = [
- "base64 0.13.1",
- "nom",
- "serde",
- "unicode-segmentation",
-]
-
-[[package]]
-name = "stable_deref_trait"
-version = "1.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
-
-[[package]]
-name = "static_assertions"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
-
-[[package]]
-name = "strength_reduce"
-version = "0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
-
-[[package]]
-name = "strsim"
-version = "0.11.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
-
-[[package]]
-name = "subtle"
-version = "2.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
-
-[[package]]
-name = "symphonia"
-version = "0.5.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5773a4c030a19d9bfaa090f49746ff35c75dfddfa700df7a5939d5e076a57039"
-dependencies = [
- "lazy_static",
- "symphonia-bundle-flac",
- "symphonia-bundle-mp3",
- "symphonia-codec-aac",
- "symphonia-codec-adpcm",
- "symphonia-codec-pcm",
- "symphonia-codec-vorbis",
- "symphonia-core",
- "symphonia-format-mkv",
- "symphonia-format-ogg",
- "symphonia-format-riff",
- "symphonia-metadata",
-]
-
-[[package]]
-name = "symphonia-bundle-flac"
-version = "0.5.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c91565e180aea25d9b80a910c546802526ffd0072d0b8974e3ebe59b686c9976"
-dependencies = [
- "log",
- "symphonia-core",
- "symphonia-metadata",
- "symphonia-utils-xiph",
-]
-
-[[package]]
-name = "symphonia-bundle-mp3"
-version = "0.5.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4872dd6bb56bf5eac799e3e957aa1981086c3e613b27e0ac23b176054f7c57ed"
-dependencies = [
- "lazy_static",
- "log",
- "symphonia-core",
- "symphonia-metadata",
-]
-
-[[package]]
-name = "symphonia-codec-aac"
-version = "0.5.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4c263845aa86881416849c1729a54c7f55164f8b96111dba59de46849e73a790"
-dependencies = [
- "lazy_static",
- "log",
- "symphonia-core",
-]
-
-[[package]]
-name = "symphonia-codec-adpcm"
-version = "0.5.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2dddc50e2bbea4cfe027441eece77c46b9f319748605ab8f3443350129ddd07f"
-dependencies = [
- "log",
- "symphonia-core",
-]
-
-[[package]]
-name = "symphonia-codec-pcm"
-version = "0.5.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e89d716c01541ad3ebe7c91ce4c8d38a7cf266a3f7b2f090b108fb0cb031d95"
-dependencies = [
- "log",
- "symphonia-core",
-]
-
-[[package]]
-name = "symphonia-codec-vorbis"
-version = "0.5.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f025837c309cd69ffef572750b4a2257b59552c5399a5e49707cc5b1b85d1c73"
-dependencies = [
- "log",
- "symphonia-core",
- "symphonia-utils-xiph",
-]
-
-[[package]]
-name = "symphonia-core"
-version = "0.5.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ea00cc4f79b7f6bb7ff87eddc065a1066f3a43fe1875979056672c9ef948c2af"
-dependencies = [
- "arrayvec",
- "bitflags 1.3.2",
- "bytemuck",
- "lazy_static",
- "log",
-]
-
-[[package]]
-name = "symphonia-format-mkv"
-version = "0.5.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "122d786d2c43a49beb6f397551b4a050d8229eaa54c7ddf9ee4b98899b8742d0"
-dependencies = [
- "lazy_static",
- "log",
- "symphonia-core",
- "symphonia-metadata",
- "symphonia-utils-xiph",
-]
-
-[[package]]
-name = "symphonia-format-ogg"
-version = "0.5.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2b4955c67c1ed3aa8ae8428d04ca8397fbef6a19b2b051e73b5da8b1435639cb"
-dependencies = [
- "log",
- "symphonia-core",
- "symphonia-metadata",
- "symphonia-utils-xiph",
-]
-
-[[package]]
-name = "symphonia-format-riff"
-version = "0.5.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c2d7c3df0e7d94efb68401d81906eae73c02b40d5ec1a141962c592d0f11a96f"
-dependencies = [
- "extended",
- "log",
- "symphonia-core",
- "symphonia-metadata",
-]
-
-[[package]]
-name = "symphonia-metadata"
-version = "0.5.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "36306ff42b9ffe6e5afc99d49e121e0bd62fe79b9db7b9681d48e29fa19e6b16"
-dependencies = [
- "encoding_rs",
- "lazy_static",
- "log",
- "symphonia-core",
-]
-
-[[package]]
-name = "symphonia-utils-xiph"
-version = "0.5.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee27c85ab799a338446b68eec77abf42e1a6f1bb490656e121c6e27bfbab9f16"
-dependencies = [
- "symphonia-core",
- "symphonia-metadata",
-]
-
-[[package]]
-name = "syn"
-version = "2.0.111"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87"
-dependencies = [
- "proc-macro2",
- "quote",
- "unicode-ident",
-]
-
-[[package]]
-name = "sync_wrapper"
-version = "1.0.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
-dependencies = [
- "futures-core",
-]
-
-[[package]]
-name = "synstructure"
-version = "0.13.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "system-configuration"
-version = "0.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
-dependencies = [
- "bitflags 2.10.0",
- "core-foundation",
- "system-configuration-sys",
-]
-
-[[package]]
-name = "system-configuration-sys"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
-dependencies = [
- "core-foundation-sys",
- "libc",
-]
-
-[[package]]
-name = "tar"
-version = "0.4.44"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
-dependencies = [
- "filetime",
- "libc",
- "xattr",
-]
-
-[[package]]
-name = "tempfile"
-version = "3.23.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
-dependencies = [
- "fastrand",
- "getrandom 0.3.4",
- "once_cell",
- "rustix",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "thiserror"
-version = "1.0.69"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
-dependencies = [
- "thiserror-impl 1.0.69",
-]
-
-[[package]]
-name = "thiserror"
-version = "2.0.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
-dependencies = [
- "thiserror-impl 2.0.17",
-]
-
-[[package]]
-name = "thiserror-impl"
-version = "1.0.69"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "thiserror-impl"
-version = "2.0.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "tinystr"
-version = "0.8.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
-dependencies = [
- "displaydoc",
- "zerovec",
-]
-
-[[package]]
-name = "tokenizers"
-version = "0.20.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3b08cc37428a476fc9e20ac850132a513a2e1ce32b6a31addf2b74fa7033b905"
-dependencies = [
- "aho-corasick",
- "derive_builder",
- "esaxx-rs",
- "getrandom 0.2.16",
- "indicatif",
- "itertools 0.12.1",
- "lazy_static",
- "log",
- "macro_rules_attribute",
- "monostate",
- "onig",
- "paste",
- "rand 0.8.5",
- "rayon",
- "rayon-cond 0.3.0",
- "regex",
- "regex-syntax",
- "serde",
- "serde_json",
- "spm_precompiled",
- "thiserror 1.0.69",
- "unicode-normalization-alignments",
- "unicode-segmentation",
- "unicode_categories",
-]
-
-[[package]]
-name = "tokenizers"
-version = "0.21.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a620b996116a59e184c2fa2dfd8251ea34a36d0a514758c6f966386bd2e03476"
-dependencies = [
- "ahash",
- "aho-corasick",
- "compact_str",
- "dary_heap",
- "derive_builder",
- "esaxx-rs",
- "getrandom 0.3.4",
- "indicatif",
- "itertools 0.14.0",
- "log",
- "macro_rules_attribute",
- "monostate",
- "onig",
- "paste",
- "rand 0.9.2",
- "rayon",
- "rayon-cond 0.4.0",
- "regex",
- "regex-syntax",
- "serde",
- "serde_json",
- "spm_precompiled",
- "thiserror 2.0.17",
- "unicode-normalization-alignments",
- "unicode-segmentation",
- "unicode_categories",
-]
-
-[[package]]
-name = "tokio"
-version = "1.48.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408"
-dependencies = [
- "bytes",
- "libc",
- "mio",
- "pin-project-lite",
- "socket2",
- "tokio-macros",
- "windows-sys 0.61.2",
-]
-
-[[package]]
-name = "tokio-macros"
-version = "2.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "tokio-native-tls"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
-dependencies = [
- "native-tls",
- "tokio",
-]
-
-[[package]]
-name = "tokio-rustls"
-version = "0.26.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
-dependencies = [
- "rustls",
- "tokio",
-]
-
-[[package]]
-name = "tokio-util"
-version = "0.7.17"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594"
-dependencies = [
- "bytes",
- "futures-core",
- "futures-sink",
- "pin-project-lite",
- "tokio",
-]
-
-[[package]]
-name = "tower"
-version = "0.5.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
-dependencies = [
- "futures-core",
- "futures-util",
- "pin-project-lite",
- "sync_wrapper",
- "tokio",
- "tower-layer",
- "tower-service",
-]
-
-[[package]]
-name = "tower-http"
-version = "0.6.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
-dependencies = [
- "bitflags 2.10.0",
- "bytes",
- "futures-util",
- "http",
- "http-body",
- "iri-string",
- "pin-project-lite",
- "tower",
- "tower-layer",
- "tower-service",
-]
-
-[[package]]
-name = "tower-layer"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
-
-[[package]]
-name = "tower-service"
-version = "0.3.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
-
-[[package]]
-name = "tracing"
-version = "0.1.43"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647"
-dependencies = [
- "pin-project-lite",
- "tracing-core",
-]
-
-[[package]]
-name = "tracing-core"
-version = "0.1.35"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c"
-dependencies = [
- "once_cell",
-]
-
-[[package]]
-name = "transpose"
-version = "0.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
-dependencies = [
- "num-integer",
- "strength_reduce",
-]
-
-[[package]]
-name = "try-lock"
-version = "0.2.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
-
-[[package]]
-name = "typenum"
-version = "1.19.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
-
-[[package]]
-name = "unicode-ident"
-version = "1.0.22"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
-
-[[package]]
-name = "unicode-normalization-alignments"
-version = "0.1.12"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de"
-dependencies = [
- "smallvec 1.15.1",
-]
-
-[[package]]
-name = "unicode-segmentation"
-version = "1.12.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
-
-[[package]]
-name = "unicode-width"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
-
-[[package]]
-name = "unicode_categories"
-version = "0.1.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e"
-
-[[package]]
-name = "untrusted"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
-
-[[package]]
-name = "ureq"
-version = "2.12.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d"
-dependencies = [
- "base64 0.22.1",
- "flate2",
- "log",
- "native-tls",
- "once_cell",
- "rustls",
- "rustls-pki-types",
- "serde",
- "serde_json",
- "socks",
- "url",
- "webpki-roots 0.26.11",
-]
-
-[[package]]
-name = "ureq"
-version = "3.1.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a"
-dependencies = [
- "base64 0.22.1",
- "der",
- "log",
- "native-tls",
- "percent-encoding",
- "rustls-pki-types",
- "socks",
- "ureq-proto",
- "utf-8",
- "webpki-root-certs",
-]
-
-[[package]]
-name = "ureq-proto"
-version = "0.5.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f"
-dependencies = [
- "base64 0.22.1",
- "http",
- "httparse",
- "log",
-]
-
-[[package]]
-name = "url"
-version = "2.5.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b"
-dependencies = [
- "form_urlencoded",
- "idna",
- "percent-encoding",
- "serde",
-]
-
-[[package]]
-name = "utf-8"
-version = "0.7.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
-
-[[package]]
-name = "utf8_iter"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
-
-[[package]]
-name = "vcpkg"
-version = "0.2.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
-
-[[package]]
-name = "version_check"
-version = "0.9.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
-
-[[package]]
-name = "want"
-version = "0.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
-dependencies = [
- "try-lock",
-]
-
-[[package]]
-name = "wasi"
-version = "0.11.1+wasi-snapshot-preview1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
-
-[[package]]
-name = "wasip2"
-version = "1.0.1+wasi-0.2.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
-dependencies = [
- "wit-bindgen",
-]
-
-[[package]]
-name = "wasm-bindgen"
-version = "0.2.106"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
-dependencies = [
- "cfg-if",
- "once_cell",
- "rustversion",
- "wasm-bindgen-macro",
- "wasm-bindgen-shared",
-]
-
-[[package]]
-name = "wasm-bindgen-futures"
-version = "0.4.56"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c"
-dependencies = [
- "cfg-if",
- "js-sys",
- "once_cell",
- "wasm-bindgen",
- "web-sys",
-]
-
-[[package]]
-name = "wasm-bindgen-macro"
-version = "0.2.106"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
-dependencies = [
- "quote",
- "wasm-bindgen-macro-support",
-]
-
-[[package]]
-name = "wasm-bindgen-macro-support"
-version = "0.2.106"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
-dependencies = [
- "bumpalo",
- "proc-macro2",
- "quote",
- "syn",
- "wasm-bindgen-shared",
-]
-
-[[package]]
-name = "wasm-bindgen-shared"
-version = "0.2.106"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
-dependencies = [
- "unicode-ident",
-]
-
-[[package]]
-name = "wasm-streams"
-version = "0.4.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
-dependencies = [
- "futures-util",
- "js-sys",
- "wasm-bindgen",
- "wasm-bindgen-futures",
- "web-sys",
-]
-
-[[package]]
-name = "web-sys"
-version = "0.3.83"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac"
-dependencies = [
- "js-sys",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "web-time"
-version = "1.1.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
-dependencies = [
- "js-sys",
- "wasm-bindgen",
-]
-
-[[package]]
-name = "webpki-root-certs"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ee3e3b5f5e80bc89f30ce8d0343bf4e5f12341c51f3e26cbeecbc7c85443e85b"
-dependencies = [
- "rustls-pki-types",
-]
-
-[[package]]
-name = "webpki-roots"
-version = "0.26.11"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
-dependencies = [
- "webpki-roots 1.0.4",
-]
-
-[[package]]
-name = "webpki-roots"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e"
-dependencies = [
- "rustls-pki-types",
-]
-
-[[package]]
-name = "winapi"
-version = "0.3.9"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
-dependencies = [
- "winapi-i686-pc-windows-gnu",
- "winapi-x86_64-pc-windows-gnu",
-]
-
-[[package]]
-name = "winapi-i686-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
-
-[[package]]
-name = "winapi-x86_64-pc-windows-gnu"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
-
-[[package]]
-name = "windows-link"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
-
-[[package]]
-name = "windows-registry"
-version = "0.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
-dependencies = [
- "windows-link",
- "windows-result",
- "windows-strings",
-]
-
-[[package]]
-name = "windows-result"
-version = "0.4.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
-dependencies = [
- "windows-link",
-]
-
-[[package]]
-name = "windows-strings"
-version = "0.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
-dependencies = [
- "windows-link",
-]
-
-[[package]]
-name = "windows-sys"
-version = "0.52.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
-dependencies = [
- "windows-targets 0.52.6",
-]
-
-[[package]]
-name = "windows-sys"
-version = "0.59.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
-dependencies = [
- "windows-targets 0.52.6",
-]
-
-[[package]]
-name = "windows-sys"
-version = "0.60.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
-dependencies = [
- "windows-targets 0.53.5",
-]
-
-[[package]]
-name = "windows-sys"
-version = "0.61.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
-dependencies = [
- "windows-link",
-]
-
-[[package]]
-name = "windows-targets"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
-dependencies = [
- "windows_aarch64_gnullvm 0.52.6",
- "windows_aarch64_msvc 0.52.6",
- "windows_i686_gnu 0.52.6",
- "windows_i686_gnullvm 0.52.6",
- "windows_i686_msvc 0.52.6",
- "windows_x86_64_gnu 0.52.6",
- "windows_x86_64_gnullvm 0.52.6",
- "windows_x86_64_msvc 0.52.6",
-]
-
-[[package]]
-name = "windows-targets"
-version = "0.53.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
-dependencies = [
- "windows-link",
- "windows_aarch64_gnullvm 0.53.1",
- "windows_aarch64_msvc 0.53.1",
- "windows_i686_gnu 0.53.1",
- "windows_i686_gnullvm 0.53.1",
- "windows_i686_msvc 0.53.1",
- "windows_x86_64_gnu 0.53.1",
- "windows_x86_64_gnullvm 0.53.1",
- "windows_x86_64_msvc 0.53.1",
-]
-
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
-
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
-
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
-
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
-
-[[package]]
-name = "windows_i686_gnu"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
-
-[[package]]
-name = "windows_i686_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
-
-[[package]]
-name = "windows_i686_gnullvm"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
-
-[[package]]
-name = "windows_i686_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
-
-[[package]]
-name = "windows_i686_msvc"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
-
-[[package]]
-name = "windows_i686_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
-
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
-
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
-
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
-
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
-
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.52.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
-
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
-
-[[package]]
-name = "wit-bindgen"
-version = "0.46.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
-
-[[package]]
-name = "writeable"
-version = "0.6.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
-
-[[package]]
-name = "xattr"
-version = "1.6.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
-dependencies = [
- "libc",
- "rustix",
-]
-
-[[package]]
-name = "yoke"
-version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
-dependencies = [
- "stable_deref_trait",
- "yoke-derive",
- "zerofrom",
-]
-
-[[package]]
-name = "yoke-derive"
-version = "0.8.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
- "synstructure",
-]
-
-[[package]]
-name = "zerocopy"
-version = "0.8.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3"
-dependencies = [
- "zerocopy-derive",
-]
-
-[[package]]
-name = "zerocopy-derive"
-version = "0.8.31"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
-
-[[package]]
-name = "zerofrom"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
-dependencies = [
- "zerofrom-derive",
-]
-
-[[package]]
-name = "zerofrom-derive"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
- "synstructure",
-]
-
-[[package]]
-name = "zeroize"
-version = "1.8.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
-
-[[package]]
-name = "zerotrie"
-version = "0.2.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
-dependencies = [
- "displaydoc",
- "yoke",
- "zerofrom",
-]
-
-[[package]]
-name = "zerovec"
-version = "0.11.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
-dependencies = [
- "yoke",
- "zerofrom",
- "zerovec-derive",
-]
-
-[[package]]
-name = "zerovec-derive"
-version = "0.11.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
-dependencies = [
- "proc-macro2",
- "quote",
- "syn",
-]
diff --git a/makima/Cargo.toml b/makima/Cargo.toml
index 4bf629f..5d8c44e 100644
--- a/makima/Cargo.toml
+++ b/makima/Cargo.toml
@@ -37,6 +37,9 @@ utoipa-swagger-ui = { version = "9", features = ["axum"] }
thiserror = "2.0"
anyhow = "1.0"
+# Concurrent data structures
+dashmap = "6.0"
+
# Database
sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json"] }
chrono = { version = "0.4", features = ["serde"] }
@@ -47,6 +50,15 @@ reqwest = { version = "0.12", features = ["json"] }
# Lazy statics
once_cell = "1.19"
+# Cryptographic hashing for API keys
+sha2 = "0.10"
+rand = { version = "0.8", features = ["std", "std_rng"] }
+base64 = "0.22"
+hex = "0.4"
+
+# JWT authentication
+jsonwebtoken = "9"
+
# JQ for JSON transformation
jaq-interpret = "1.5"
jaq-parse = "1.0"
diff --git a/makima/daemon/Cargo.toml b/makima/daemon/Cargo.toml
new file mode 100644
index 0000000..02ecbb3
--- /dev/null
+++ b/makima/daemon/Cargo.toml
@@ -0,0 +1,48 @@
+[package]
+name = "makima-daemon"
+version = "0.1.0"
+edition = "2024"
+
+[[bin]]
+name = "makima-daemon"
+path = "src/main.rs"
+
+[dependencies]
+# Async runtime
+tokio = { version = "1.0", features = ["full", "signal", "process"] }
+futures = "0.3"
+async-trait = "0.1"
+
+# WebSocket client
+tokio-tungstenite = { version = "0.24", features = ["native-tls"] }
+
+# Serialization
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+
+# Configuration
+config = "0.14"
+clap = { version = "4.4", features = ["derive", "env"] }
+
+# Database (local state)
+rusqlite = { version = "0.32", features = ["bundled"] }
+
+# Error handling
+thiserror = "2.0"
+anyhow = "1.0"
+
+# Logging
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
+
+# Utilities
+uuid = { version = "1.0", features = ["v4", "serde"] }
+chrono = { version = "0.4", features = ["serde"] }
+dashmap = "6.0"
+backoff = { version = "0.4", features = ["tokio"] }
+hostname = "0.4"
+sha2 = "0.10"
+hex = "0.4"
+shell-escape = "0.1"
+dirs = "5.0"
+rand = "0.9"
diff --git a/makima/daemon/README.md b/makima/daemon/README.md
new file mode 100644
index 0000000..7c577c5
--- /dev/null
+++ b/makima/daemon/README.md
@@ -0,0 +1,353 @@
+# Makima Daemon
+
+The Makima daemon connects to the Makima server and executes tasks using Claude Code in isolated git worktrees.
+
+## Installation
+
+```bash
+cd makima/daemon
+cargo build --release
+```
+
+The binary will be at `target/release/makima-daemon`.
+
+## Quick Start
+
+```bash
+# Set required environment variables
+export MAKIMA_API_KEY="your-api-key"
+export MAKIMA_DAEMON_SERVER_URL="ws://localhost:8080"
+
+# Run the daemon
+makima-daemon
+```
+
+## Configuration
+
+Configuration is loaded from multiple sources in order of precedence (highest first):
+
+1. CLI arguments
+2. Environment variables
+3. `./makima-daemon.toml` (current directory)
+4. `~/.config/makima-daemon/config.toml` (user config)
+5. `/etc/makima-daemon/config.toml` (system config, Linux only)
+
+### Environment Variables
+
+| Variable | Description |
+|----------|-------------|
+| `MAKIMA_API_KEY` | API key for authentication (preferred) |
+| `MAKIMA_DAEMON_SERVER_URL` | WebSocket URL of the Makima server |
+| `MAKIMA_DAEMON_SERVER_APIKEY` | Alternative to MAKIMA_API_KEY |
+| `MAKIMA_DAEMON_PROCESS_MAXCONCURRENTTASKS` | Max concurrent tasks |
+
+### CLI Arguments
+
+```
+makima-daemon [OPTIONS]
+
+Options:
+ -c, --config <PATH> Path to config file
+ -s, --server-url <URL> WebSocket URL of makima server
+ -k, --api-key <KEY> API key for authentication
+ -m, --max-tasks <N> Maximum concurrent tasks
+ -l, --log-level <LEVEL> Log level (trace, debug, info, warn, error)
+ --repos-dir <PATH> Directory for cloned repositories
+ --worktrees-dir <PATH> Directory for worktrees
+ -h, --help Print help
+ -V, --version Print version
+```
+
+---
+
+## Configuration File Reference
+
+Below is a complete configuration file with all options and their defaults.
+
+```toml
+# =============================================================================
+# Server Connection
+# =============================================================================
+[server]
+# WebSocket URL of the Makima server (required)
+url = "ws://localhost:8080"
+
+# API key for authentication (required)
+# Can also be set via MAKIMA_API_KEY environment variable
+api_key = "your-api-key"
+
+# Heartbeat interval in seconds (default: 30)
+heartbeat_interval_secs = 30
+
+# Reconnect interval after connection loss in seconds (default: 5)
+reconnect_interval_secs = 5
+
+# Maximum reconnect attempts before giving up (default: 0 = infinite)
+max_reconnect_attempts = 0
+
+# =============================================================================
+# Worktree Settings
+# =============================================================================
+[worktree]
+# Base directory for task worktrees (default: ~/.makima/worktrees)
+base_dir = "/home/user/.makima/worktrees"
+
+# Directory for cached repository clones (default: ~/.makima/repos)
+repos_dir = "/home/user/.makima/repos"
+
+# Branch prefix for task branches (default: "makima/task-")
+branch_prefix = "makima/task-"
+
+# Clean up old worktrees on daemon start (default: false)
+cleanup_on_start = false
+
+# Default target repository for pushing completed branches
+# Used when task.target_repo_path is not set
+default_target_repo = "/home/user/projects/my-repo"
+
+# =============================================================================
+# Process Settings (Claude Code)
+# =============================================================================
+[process]
+# Path or command for Claude Code CLI (default: "claude")
+claude_command = "claude"
+
+# Additional arguments to pass to Claude Code (after default arguments)
+# Default arguments are: --output-format=stream-json --input-format=stream-json
+# --verbose --dangerously-skip-permissions
+claude_args = ["--model", "opus"]
+
+# Arguments to pass before default arguments (for overriding defaults)
+claude_pre_args = []
+
+# Enable Claude's permission system (default: false)
+# When true, removes --dangerously-skip-permissions flag
+enable_permissions = false
+
+# Disable verbose output (default: false)
+# When true, removes --verbose flag
+disable_verbose = false
+
+# Maximum concurrent tasks (default: 4)
+max_concurrent_tasks = 4
+
+# Default timeout for tasks in seconds (default: 0 = no timeout)
+default_timeout_secs = 0
+
+# Additional environment variables to pass to Claude Code
+[process.env_vars]
+ANTHROPIC_API_KEY = "sk-ant-..."
+CUSTOM_VAR = "value"
+
+# =============================================================================
+# Repository Auto-Clone
+# =============================================================================
+[repos]
+# Directory to clone repositories into (default: ~/.makima/home)
+home_dir = "/home/user/.makima/home"
+
+# List of repositories to auto-clone on startup
+# Repositories that already exist are skipped
+
+# Simple format - just URLs
+auto_clone = [
+ "https://github.com/user/repo1.git",
+ "https://github.com/user/repo2.git",
+]
+
+# Shorthand format supported:
+# github:user/repo -> https://github.com/user/repo.git
+# gitlab:user/repo -> https://gitlab.com/user/repo.git
+auto_clone = [
+ "github:anthropics/claude-code",
+ "gitlab:company/project",
+]
+
+# Detailed format with options (use [[repos.auto_clone]] for each entry)
+[[repos.auto_clone]]
+url = "github:user/repo"
+name = "custom-directory-name" # Optional: override directory name
+branch = "develop" # Optional: checkout specific branch
+shallow = true # Optional: shallow clone (--depth 1)
+
+[[repos.auto_clone]]
+url = "https://github.com/org/large-repo.git"
+shallow = true # Faster clone for large repos
+
+# =============================================================================
+# Local Database
+# =============================================================================
+[local_db]
+# Path to local SQLite database (default: ~/.makima/daemon.db)
+path = "/home/user/.makima/daemon.db"
+
+# =============================================================================
+# Logging
+# =============================================================================
+[logging]
+# Log level: trace, debug, info, warn, error (default: "info")
+level = "info"
+
+# Log format: "pretty" or "json" (default: "pretty")
+format = "pretty"
+```
+
+---
+
+## Examples
+
+### Minimal Configuration
+
+```toml
+[server]
+url = "ws://localhost:8080"
+api_key = "your-api-key"
+```
+
+### Production Configuration
+
+```toml
+[server]
+url = "wss://api.makima.example.com/daemon"
+api_key = "prod-api-key"
+heartbeat_interval_secs = 30
+reconnect_interval_secs = 10
+max_reconnect_attempts = 0
+
+[worktree]
+base_dir = "/var/lib/makima/worktrees"
+repos_dir = "/var/lib/makima/repos"
+cleanup_on_start = true
+
+[process]
+max_concurrent_tasks = 8
+default_timeout_secs = 3600 # 1 hour timeout
+
+[logging]
+level = "info"
+format = "json"
+```
+
+### Development with Custom Claude
+
+```toml
+[server]
+url = "ws://localhost:8080"
+api_key = "dev-key"
+
+[process]
+# Use a specific claude binary
+claude_command = "/usr/local/bin/claude-dev"
+
+# Add custom arguments
+claude_args = ["--model", "sonnet", "--max-turns", "50"]
+
+# Enable permission prompts for testing
+enable_permissions = true
+
+[process.env_vars]
+ANTHROPIC_API_KEY = "sk-ant-dev-..."
+DEBUG = "1"
+
+[logging]
+level = "debug"
+```
+
+### Auto-Clone Team Repositories
+
+```toml
+[server]
+url = "ws://localhost:8080"
+api_key = "team-key"
+
+[repos]
+home_dir = "/home/dev/.makima/projects"
+
+# Clone all team repos on startup
+[[repos.auto_clone]]
+url = "github:myteam/frontend"
+branch = "main"
+
+[[repos.auto_clone]]
+url = "github:myteam/backend"
+branch = "main"
+
+[[repos.auto_clone]]
+url = "github:myteam/shared-libs"
+shallow = true # Only need latest commit
+```
+
+---
+
+## Directory Structure
+
+After running, the daemon creates the following directories:
+
+```
+~/.makima/
+├── daemon.db # Local state database
+├── worktrees/ # Task worktrees (temporary)
+│ └── task-abc123/ # Individual task worktree
+├── repos/ # Cached repository clones
+│ └── repo-name/ # Bare clone for worktree creation
+└── home/ # Auto-cloned repositories
+ └── my-repo/ # Full repository clone
+```
+
+---
+
+## Troubleshooting
+
+### Connection Issues
+
+```bash
+# Check server connectivity
+curl -I http://localhost:8080/health
+
+# Run with debug logging
+makima-daemon --log-level debug
+```
+
+### Claude Code Not Found
+
+```bash
+# Verify claude is installed and in PATH
+which claude
+claude --version
+
+# Or specify full path in config
+[process]
+claude_command = "/full/path/to/claude"
+```
+
+### Permission Errors
+
+If Claude Code requires permissions, either:
+
+1. Use `--dangerously-skip-permissions` (default behavior)
+2. Set `enable_permissions = true` and handle permission prompts
+3. Pre-configure Claude Code permissions in `~/.claude/`
+
+### Task Timeouts
+
+Set an appropriate timeout for long-running tasks:
+
+```toml
+[process]
+default_timeout_secs = 7200 # 2 hours
+```
+
+---
+
+## Environment Variable Reference
+
+All configuration options can be set via environment variables using the pattern:
+`MAKIMA_DAEMON_<SECTION>_<KEY>`
+
+Examples:
+- `MAKIMA_DAEMON_SERVER_URL` -> `server.url`
+- `MAKIMA_DAEMON_PROCESS_MAXCONCURRENTTASKS` -> `process.max_concurrent_tasks`
+- `MAKIMA_DAEMON_LOGGING_LEVEL` -> `logging.level`
+
+Special case:
+- `MAKIMA_API_KEY` -> `server.api_key` (preferred method)
diff --git a/makima/daemon/src/cli.rs b/makima/daemon/src/cli.rs
new file mode 100644
index 0000000..ca84017
--- /dev/null
+++ b/makima/daemon/src/cli.rs
@@ -0,0 +1,45 @@
+//! Command-line argument parsing for makima-daemon.
+
+use clap::Parser;
+use std::path::PathBuf;
+
+/// Makima daemon for managing Claude Code instances in isolated worktrees.
+#[derive(Parser, Debug)]
+#[command(name = "makima-daemon")]
+#[command(version, about, long_about = None)]
+pub struct Cli {
+ /// Path to custom config file
+ #[arg(short, long)]
+ pub config: Option<PathBuf>,
+
+ /// Directory where repositories are cloned
+ #[arg(long, env = "MAKIMA_DAEMON_REPOS_DIR")]
+ pub repos_dir: Option<PathBuf>,
+
+ /// Directory where worktrees are created
+ #[arg(long, env = "MAKIMA_DAEMON_WORKTREES_DIR")]
+ pub worktrees_dir: Option<PathBuf>,
+
+ /// WebSocket server URL to connect to
+ #[arg(long, env = "MAKIMA_DAEMON_SERVER_URL")]
+ pub server_url: Option<String>,
+
+ /// API key for server authentication
+ #[arg(long, env = "MAKIMA_DAEMON_SERVER_APIKEY")]
+ pub api_key: Option<String>,
+
+ /// Maximum number of concurrent tasks
+ #[arg(long)]
+ pub max_tasks: Option<u32>,
+
+ /// Log level (trace, debug, info, warn, error)
+ #[arg(short, long, default_value = "info")]
+ pub log_level: String,
+}
+
+impl Cli {
+ /// Parse command-line arguments
+ pub fn parse_args() -> Self {
+ Self::parse()
+ }
+}
diff --git a/makima/daemon/src/config.rs b/makima/daemon/src/config.rs
new file mode 100644
index 0000000..28c7fea
--- /dev/null
+++ b/makima/daemon/src/config.rs
@@ -0,0 +1,536 @@
+//! Configuration management for the makima daemon.
+
+use config::{Config, Environment, File};
+use serde::Deserialize;
+use std::collections::HashMap;
+use std::path::PathBuf;
+
+/// Root daemon configuration.
+#[derive(Debug, Clone, Deserialize)]
+pub struct DaemonConfig {
+ /// Server connection settings.
+ pub server: ServerConfig,
+
+ /// Worktree settings.
+ #[serde(default)]
+ pub worktree: WorktreeConfig,
+
+ /// Process settings.
+ #[serde(default)]
+ pub process: ProcessConfig,
+
+ /// Local database settings.
+ #[serde(default)]
+ pub local_db: LocalDbConfig,
+
+ /// Logging settings.
+ #[serde(default)]
+ pub logging: LoggingConfig,
+
+ /// Repositories to auto-clone on startup.
+ #[serde(default)]
+ pub repos: ReposConfig,
+}
+
+/// Server connection configuration.
+#[derive(Debug, Clone, Deserialize)]
+pub struct ServerConfig {
+ /// WebSocket URL of makima server (e.g., ws://localhost:8080 or wss://makima.example.com).
+ #[serde(default)]
+ pub url: String,
+
+ /// API key for authentication.
+ #[serde(default, alias = "apikey")]
+ pub api_key: String,
+
+ /// Heartbeat interval in seconds.
+ #[serde(default = "default_heartbeat_interval", alias = "heartbeatintervalsecs")]
+ pub heartbeat_interval_secs: u64,
+
+ /// Reconnect interval in seconds after connection loss.
+ #[serde(default = "default_reconnect_interval", alias = "reconnectintervalsecs")]
+ pub reconnect_interval_secs: u64,
+
+ /// Maximum reconnect attempts before giving up (0 = infinite).
+ #[serde(default, alias = "maxreconnectattempts")]
+ pub max_reconnect_attempts: u32,
+}
+
+fn default_heartbeat_interval() -> u64 {
+ 30
+}
+
+fn default_reconnect_interval() -> u64 {
+ 5
+}
+
+/// Worktree configuration for task isolation.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(default)]
+pub struct WorktreeConfig {
+ /// Base directory for worktrees (~/.makima/worktrees).
+ #[serde(default = "default_worktree_base_dir", alias = "basedir")]
+ pub base_dir: PathBuf,
+
+ /// Base directory for cloned repositories (~/.makima/repos).
+ #[serde(default = "default_repos_base_dir", alias = "reposdir")]
+ pub repos_dir: PathBuf,
+
+ /// Branch prefix for task branches.
+ #[serde(default = "default_branch_prefix", alias = "branchprefix")]
+ pub branch_prefix: String,
+
+ /// Clean up worktrees on daemon start.
+ #[serde(default, alias = "cleanuponstart")]
+ pub cleanup_on_start: bool,
+
+ /// Default target repository path for pushing completed branches.
+ /// Used when task.target_repo_path is not set.
+ #[serde(default, alias = "defaulttargetrepo")]
+ pub default_target_repo: Option<PathBuf>,
+}
+
+fn default_worktree_base_dir() -> PathBuf {
+ dirs::home_dir()
+ .unwrap_or_else(|| PathBuf::from("."))
+ .join(".makima")
+ .join("worktrees")
+}
+
+fn default_repos_base_dir() -> PathBuf {
+ dirs::home_dir()
+ .unwrap_or_else(|| PathBuf::from("."))
+ .join(".makima")
+ .join("repos")
+}
+
+fn default_branch_prefix() -> String {
+ "makima/task-".to_string()
+}
+
+impl Default for WorktreeConfig {
+ fn default() -> Self {
+ Self {
+ base_dir: default_worktree_base_dir(),
+ repos_dir: default_repos_base_dir(),
+ branch_prefix: default_branch_prefix(),
+ cleanup_on_start: false,
+ default_target_repo: None,
+ }
+ }
+}
+
+/// Process configuration for Claude Code subprocess execution.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(default)]
+pub struct ProcessConfig {
+ /// Path or command for Claude Code CLI.
+ #[serde(default = "default_claude_command", alias = "claudecommand")]
+ pub claude_command: String,
+
+ /// Additional arguments to pass to Claude Code.
+ /// These are added after the default arguments.
+ #[serde(default, alias = "claudeargs")]
+ pub claude_args: Vec<String>,
+
+ /// Arguments to pass before the default arguments.
+ /// Useful for overriding defaults.
+ #[serde(default, alias = "claudepreargs")]
+ pub claude_pre_args: Vec<String>,
+
+ /// Skip the --dangerously-skip-permissions flag (default: false).
+ /// Set to true if you want to use Claude's permission system.
+ #[serde(default, alias = "enablepermissions")]
+ pub enable_permissions: bool,
+
+ /// Skip the --verbose flag (default: false).
+ #[serde(default, alias = "disableverbose")]
+ pub disable_verbose: bool,
+
+ /// Maximum concurrent tasks.
+ #[serde(default = "default_max_tasks", alias = "maxconcurrenttasks")]
+ pub max_concurrent_tasks: u32,
+
+ /// Default timeout for tasks in seconds (0 = no timeout).
+ #[serde(default, alias = "defaulttimeoutsecs")]
+ pub default_timeout_secs: u64,
+
+ /// Additional environment variables to pass to Claude Code.
+ #[serde(default, alias = "envvars")]
+ pub env_vars: HashMap<String, String>,
+}
+
+fn default_claude_command() -> String {
+ "claude".to_string()
+}
+
+fn default_max_tasks() -> u32 {
+ 4
+}
+
+impl Default for ProcessConfig {
+ fn default() -> Self {
+ Self {
+ claude_command: default_claude_command(),
+ claude_args: Vec::new(),
+ claude_pre_args: Vec::new(),
+ enable_permissions: false,
+ disable_verbose: false,
+ max_concurrent_tasks: default_max_tasks(),
+ default_timeout_secs: 0,
+ env_vars: HashMap::new(),
+ }
+ }
+}
+
+/// Local database configuration.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(default)]
+pub struct LocalDbConfig {
+ /// Path to local SQLite database.
+ #[serde(default = "default_db_path")]
+ pub path: PathBuf,
+}
+
+impl Default for LocalDbConfig {
+ fn default() -> Self {
+ Self {
+ path: default_db_path(),
+ }
+ }
+}
+
+fn default_db_path() -> PathBuf {
+ dirs::home_dir()
+ .unwrap_or_else(|| PathBuf::from("."))
+ .join(".makima")
+ .join("daemon.db")
+}
+
+/// Logging configuration.
+#[derive(Debug, Clone, Deserialize, Default)]
+pub struct LoggingConfig {
+ /// Log level: "trace", "debug", "info", "warn", "error".
+ #[serde(default = "default_log_level")]
+ pub level: String,
+
+ /// Log format: "pretty" or "json".
+ #[serde(default = "default_log_format")]
+ pub format: String,
+}
+
+fn default_log_level() -> String {
+ "info".to_string()
+}
+
+fn default_log_format() -> String {
+ "pretty".to_string()
+}
+
+/// Repository auto-clone configuration.
+#[derive(Debug, Clone, Deserialize, Default)]
+pub struct ReposConfig {
+ /// Directory to clone repositories into (default: ~/.makima/home).
+ #[serde(default = "default_home_dir")]
+ pub home_dir: PathBuf,
+
+ /// List of repositories to auto-clone on startup.
+ /// Each entry can be a URL (e.g., "https://github.com/user/repo.git")
+ /// or a shorthand (e.g., "github:user/repo").
+ #[serde(default, alias = "autoclone")]
+ pub auto_clone: Vec<RepoEntry>,
+}
+
+/// A repository entry for auto-cloning.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(untagged)]
+pub enum RepoEntry {
+ /// Simple URL string.
+ Url(String),
+ /// Detailed configuration.
+ Config {
+ /// Repository URL.
+ url: String,
+ /// Custom directory name (defaults to repo name from URL).
+ #[serde(default)]
+ name: Option<String>,
+ /// Branch to checkout after cloning (defaults to default branch).
+ #[serde(default)]
+ branch: Option<String>,
+ /// Whether to do a shallow clone (default: false).
+ #[serde(default)]
+ shallow: bool,
+ },
+}
+
+impl RepoEntry {
+ /// Get the URL for this repo entry.
+ pub fn url(&self) -> &str {
+ match self {
+ RepoEntry::Url(url) => url,
+ RepoEntry::Config { url, .. } => url,
+ }
+ }
+
+ /// Get the custom name, if any.
+ pub fn name(&self) -> Option<&str> {
+ match self {
+ RepoEntry::Url(_) => None,
+ RepoEntry::Config { name, .. } => name.as_deref(),
+ }
+ }
+
+ /// Get the branch to checkout, if any.
+ pub fn branch(&self) -> Option<&str> {
+ match self {
+ RepoEntry::Url(_) => None,
+ RepoEntry::Config { branch, .. } => branch.as_deref(),
+ }
+ }
+
+ /// Whether to do a shallow clone.
+ pub fn shallow(&self) -> bool {
+ match self {
+ RepoEntry::Url(_) => false,
+ RepoEntry::Config { shallow, .. } => *shallow,
+ }
+ }
+
+ /// Get the directory name to use (either custom name or derived from URL).
+ pub fn dir_name(&self) -> Option<String> {
+ if let Some(name) = self.name() {
+ return Some(name.to_string());
+ }
+
+ // Derive from URL
+ let url = self.url();
+
+ // Handle shorthand formats
+ let url = if url.starts_with("github:") {
+ url.strip_prefix("github:").unwrap_or(url)
+ } else if url.starts_with("gitlab:") {
+ url.strip_prefix("gitlab:").unwrap_or(url)
+ } else {
+ url
+ };
+
+ // Extract repo name from URL
+ url.trim_end_matches('/')
+ .trim_end_matches(".git")
+ .rsplit('/')
+ .next()
+ .map(|s| s.to_string())
+ }
+
+ /// Expand the URL (e.g., convert shorthand to full URL).
+ pub fn expanded_url(&self) -> String {
+ let url = self.url();
+
+ if url.starts_with("github:") {
+ format!("https://github.com/{}.git", url.strip_prefix("github:").unwrap_or(""))
+ } else if url.starts_with("gitlab:") {
+ format!("https://gitlab.com/{}.git", url.strip_prefix("gitlab:").unwrap_or(""))
+ } else {
+ url.to_string()
+ }
+ }
+}
+
+fn default_home_dir() -> PathBuf {
+ dirs::home_dir()
+ .unwrap_or_else(|| PathBuf::from("."))
+ .join(".makima")
+ .join("home")
+}
+
+impl DaemonConfig {
+ /// Load configuration from files and environment variables.
+ ///
+ /// Configuration sources (in order of precedence):
+ /// 1. Environment variables (MAKIMA_API_KEY, MAKIMA_DAEMON_SERVER_URL, etc.)
+ /// 2. ./makima-daemon.toml (current directory)
+ /// 3. ~/.config/makima-daemon/config.toml
+ /// 4. /etc/makima-daemon/config.toml (Linux only)
+ ///
+ /// Environment variable examples:
+ /// - MAKIMA_API_KEY=your-api-key (preferred)
+ /// - MAKIMA_DAEMON_SERVER_URL=ws://localhost:8080
+ /// - MAKIMA_DAEMON_PROCESS_MAXCONCURRENTTASKS=4
+ pub fn load() -> Result<Self, config::ConfigError> {
+ Self::load_from_path(None)
+ }
+
+ /// Load configuration from a specific path plus standard sources.
+ fn load_from_path(config_path: Option<&std::path::Path>) -> Result<Self, config::ConfigError> {
+ let mut builder = Config::builder();
+
+ // System-wide config (Linux only)
+ #[cfg(target_os = "linux")]
+ {
+ builder = builder.add_source(
+ File::with_name("/etc/makima-daemon/config").required(false),
+ );
+ }
+
+ // User config
+ if let Some(config_dir) = dirs::config_dir() {
+ let user_config = config_dir.join("makima-daemon").join("config");
+ builder = builder.add_source(
+ File::with_name(user_config.to_str().unwrap_or("")).required(false),
+ );
+ }
+
+ // Local config
+ builder = builder.add_source(File::with_name("makima-daemon").required(false));
+
+ // Custom config file (if provided)
+ if let Some(path) = config_path {
+ builder = builder.add_source(
+ File::with_name(path.to_str().unwrap_or("")).required(true),
+ );
+ }
+
+ // Environment variables with underscore separator for nesting
+ // e.g., MAKIMA_DAEMON_SERVER_URL -> server.url
+ // MAKIMA_DAEMON_SERVER_APIKEY -> server.api_key
+ builder = builder.add_source(
+ Environment::with_prefix("MAKIMA_DAEMON")
+ .separator("_")
+ .try_parsing(true),
+ );
+
+ let config = builder.build()?;
+ let mut config: DaemonConfig = config.try_deserialize()?;
+
+ // Check for MAKIMA_API_KEY environment variable (preferred over MAKIMA_DAEMON_SERVER_APIKEY)
+ if let Ok(api_key) = std::env::var("MAKIMA_API_KEY") {
+ config.server.api_key = api_key;
+ }
+
+ // Validate required fields (don't validate here - let load_with_cli do final validation)
+ Ok(config)
+ }
+
+ /// Validate that required configuration fields are set.
+ pub fn validate(&self) -> Result<(), config::ConfigError> {
+ if self.server.url.is_empty() {
+ return Err(config::ConfigError::Message(
+ "server.url is required. Set via config file, MAKIMA_DAEMON_SERVER_URL, or --server-url".to_string()
+ ));
+ }
+ if self.server.api_key.is_empty() {
+ return Err(config::ConfigError::Message(
+ "API key is required. Set via MAKIMA_API_KEY, config file, or --api-key".to_string()
+ ));
+ }
+ Ok(())
+ }
+
+ /// Load configuration with CLI argument overrides.
+ ///
+ /// Configuration sources (in order of precedence, highest first):
+ /// 1. CLI arguments
+ /// 2. Environment variables
+ /// 3. Custom config file (if --config specified)
+ /// 4. ./makima-daemon.toml (current directory)
+ /// 5. ~/.config/makima-daemon/config.toml
+ /// 6. /etc/makima-daemon/config.toml (Linux only)
+ /// 7. Default values
+ pub fn load_with_cli(cli: &crate::cli::Cli) -> Result<Self, config::ConfigError> {
+ // Load base config (with optional custom config file)
+ let mut config = Self::load_from_path(cli.config.as_deref())?;
+
+ // Apply CLI overrides (highest priority)
+ if let Some(ref repos_dir) = cli.repos_dir {
+ config.worktree.repos_dir = repos_dir.clone();
+ }
+ if let Some(ref worktrees_dir) = cli.worktrees_dir {
+ config.worktree.base_dir = worktrees_dir.clone();
+ }
+ if let Some(ref server_url) = cli.server_url {
+ config.server.url = server_url.clone();
+ }
+ if let Some(ref api_key) = cli.api_key {
+ config.server.api_key = api_key.clone();
+ }
+ if let Some(max_tasks) = cli.max_tasks {
+ config.process.max_concurrent_tasks = max_tasks;
+ }
+ // Log level is always set (has default)
+ config.logging.level = cli.log_level.clone();
+
+ // Validate required fields after all sources are merged
+ config.validate()?;
+
+ Ok(config)
+ }
+
+ /// Create a minimal config for testing.
+ #[cfg(test)]
+ pub fn test_config() -> Self {
+ Self {
+ server: ServerConfig {
+ url: "ws://localhost:8080".to_string(),
+ api_key: "test-key".to_string(),
+ heartbeat_interval_secs: 30,
+ reconnect_interval_secs: 5,
+ max_reconnect_attempts: 0,
+ },
+ worktree: WorktreeConfig {
+ base_dir: PathBuf::from("/tmp/makima-daemon-test/worktrees"),
+ repos_dir: PathBuf::from("/tmp/makima-daemon-test/repos"),
+ branch_prefix: "makima/task-".to_string(),
+ cleanup_on_start: true,
+ default_target_repo: None,
+ },
+ process: ProcessConfig {
+ claude_command: "claude".to_string(),
+ claude_args: Vec::new(),
+ claude_pre_args: Vec::new(),
+ enable_permissions: false,
+ disable_verbose: false,
+ max_concurrent_tasks: 2,
+ default_timeout_secs: 0,
+ env_vars: HashMap::new(),
+ },
+ local_db: LocalDbConfig {
+ path: PathBuf::from("/tmp/makima-daemon-test/state.db"),
+ },
+ logging: LoggingConfig::default(),
+ repos: ReposConfig::default(),
+ }
+ }
+}
+
+/// Helper module for dirs crate (minimal subset).
+mod dirs {
+ use std::path::PathBuf;
+
+ pub fn home_dir() -> Option<PathBuf> {
+ std::env::var("HOME").ok().map(PathBuf::from)
+ }
+
+ pub fn config_dir() -> Option<PathBuf> {
+ #[cfg(target_os = "macos")]
+ {
+ std::env::var("HOME")
+ .ok()
+ .map(|h| PathBuf::from(h).join("Library").join("Application Support"))
+ }
+ #[cfg(target_os = "linux")]
+ {
+ std::env::var("XDG_CONFIG_HOME")
+ .ok()
+ .map(PathBuf::from)
+ .or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".config")))
+ }
+ #[cfg(target_os = "windows")]
+ {
+ std::env::var("APPDATA").ok().map(PathBuf::from)
+ }
+ #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
+ {
+ None
+ }
+ }
+}
diff --git a/makima/daemon/src/db/local.rs b/makima/daemon/src/db/local.rs
new file mode 100644
index 0000000..5adbf98
--- /dev/null
+++ b/makima/daemon/src/db/local.rs
@@ -0,0 +1,391 @@
+//! Local SQLite database for crash recovery and state persistence.
+
+use std::path::Path;
+
+use chrono::{DateTime, Utc};
+use rusqlite::{params, Connection, Result as SqliteResult};
+use uuid::Uuid;
+
+use crate::task::TaskState;
+
+/// Local task record for persistence.
+#[derive(Debug, Clone)]
+pub struct LocalTask {
+ pub id: Uuid,
+ pub server_task_id: Uuid,
+ pub state: TaskState,
+ pub container_id: Option<String>,
+ pub overlay_path: Option<String>,
+ pub repo_url: Option<String>,
+ pub base_branch: Option<String>,
+ pub plan: String,
+ pub created_at: DateTime<Utc>,
+ pub started_at: Option<DateTime<Utc>>,
+ pub completed_at: Option<DateTime<Utc>>,
+ pub error_message: Option<String>,
+}
+
+/// Buffered output for reliable delivery.
+#[derive(Debug, Clone)]
+pub struct BufferedOutput {
+ pub id: i64,
+ pub task_id: Uuid,
+ pub output: String,
+ pub is_partial: bool,
+ pub timestamp: DateTime<Utc>,
+}
+
+/// Local database for daemon state persistence.
+pub struct LocalDb {
+ conn: Connection,
+}
+
+impl LocalDb {
+ /// Open or create the local database.
+ pub fn open(path: &Path) -> SqliteResult<Self> {
+ // Create parent directory if needed
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent).ok();
+ }
+
+ let conn = Connection::open(path)?;
+
+ // Initialize schema
+ conn.execute_batch(Self::schema())?;
+
+ Ok(Self { conn })
+ }
+
+ /// Open an in-memory database (for testing).
+ #[cfg(test)]
+ pub fn open_memory() -> SqliteResult<Self> {
+ let conn = Connection::open_in_memory()?;
+ conn.execute_batch(Self::schema())?;
+ Ok(Self { conn })
+ }
+
+ /// Database schema.
+ fn schema() -> &'static str {
+ r#"
+ -- Local task state for crash recovery
+ CREATE TABLE IF NOT EXISTS tasks (
+ id TEXT PRIMARY KEY,
+ server_task_id TEXT NOT NULL,
+ state TEXT NOT NULL,
+ container_id TEXT,
+ overlay_path TEXT,
+ repo_url TEXT,
+ base_branch TEXT,
+ plan TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ started_at TEXT,
+ completed_at TEXT,
+ error_message TEXT
+ );
+
+ -- Buffered output for reliable delivery
+ CREATE TABLE IF NOT EXISTS output_buffer (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ task_id TEXT NOT NULL,
+ output TEXT NOT NULL,
+ is_partial INTEGER NOT NULL,
+ timestamp TEXT NOT NULL,
+ sent INTEGER NOT NULL DEFAULT 0
+ );
+
+ -- Daemon state key-value store
+ CREATE TABLE IF NOT EXISTS daemon_state (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ updated_at TEXT NOT NULL
+ );
+
+ -- Indexes
+ CREATE INDEX IF NOT EXISTS idx_tasks_state ON tasks(state);
+ CREATE INDEX IF NOT EXISTS idx_output_buffer_sent ON output_buffer(sent, id);
+ CREATE INDEX IF NOT EXISTS idx_output_buffer_task ON output_buffer(task_id);
+ "#
+ }
+
+ /// Save a task.
+ pub fn save_task(&self, task: &LocalTask) -> SqliteResult<()> {
+ self.conn.execute(
+ r#"
+ INSERT OR REPLACE INTO tasks
+ (id, server_task_id, state, container_id, overlay_path, repo_url, base_branch, plan, created_at, started_at, completed_at, error_message)
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
+ "#,
+ params![
+ task.id.to_string(),
+ task.server_task_id.to_string(),
+ task.state.as_str(),
+ task.container_id,
+ task.overlay_path,
+ task.repo_url,
+ task.base_branch,
+ task.plan,
+ task.created_at.to_rfc3339(),
+ task.started_at.map(|t| t.to_rfc3339()),
+ task.completed_at.map(|t| t.to_rfc3339()),
+ task.error_message,
+ ],
+ )?;
+ Ok(())
+ }
+
+ /// Get a task by ID.
+ pub fn get_task(&self, id: Uuid) -> SqliteResult<Option<LocalTask>> {
+ let mut stmt = self.conn.prepare(
+ "SELECT id, server_task_id, state, container_id, overlay_path, repo_url, base_branch, plan, created_at, started_at, completed_at, error_message FROM tasks WHERE id = ?1",
+ )?;
+
+ let mut rows = stmt.query(params![id.to_string()])?;
+
+ if let Some(row) = rows.next()? {
+ Ok(Some(Self::task_from_row(row)?))
+ } else {
+ Ok(None)
+ }
+ }
+
+ /// Get all running/active tasks (for recovery).
+ pub fn get_active_tasks(&self) -> SqliteResult<Vec<LocalTask>> {
+ let mut stmt = self.conn.prepare(
+ r#"
+ SELECT id, server_task_id, state, container_id, overlay_path, repo_url, base_branch, plan, created_at, started_at, completed_at, error_message
+ FROM tasks
+ WHERE state IN ('initializing', 'starting', 'running', 'paused', 'blocked')
+ "#,
+ )?;
+
+ let rows = stmt.query_map([], |row| Self::task_from_row(row))?;
+
+ rows.collect()
+ }
+
+ /// Delete a task.
+ pub fn delete_task(&self, id: Uuid) -> SqliteResult<()> {
+ self.conn.execute(
+ "DELETE FROM tasks WHERE id = ?1",
+ params![id.to_string()],
+ )?;
+ Ok(())
+ }
+
+ /// Update task state.
+ pub fn update_task_state(&self, id: Uuid, state: TaskState) -> SqliteResult<()> {
+ self.conn.execute(
+ "UPDATE tasks SET state = ?2 WHERE id = ?1",
+ params![id.to_string(), state.as_str()],
+ )?;
+ Ok(())
+ }
+
+ /// Buffer output for reliable delivery.
+ pub fn buffer_output(&self, task_id: Uuid, output: &str, is_partial: bool) -> SqliteResult<i64> {
+ self.conn.execute(
+ r#"
+ INSERT INTO output_buffer (task_id, output, is_partial, timestamp, sent)
+ VALUES (?1, ?2, ?3, datetime('now'), 0)
+ "#,
+ params![task_id.to_string(), output, is_partial as i32],
+ )?;
+ Ok(self.conn.last_insert_rowid())
+ }
+
+ /// Get unsent outputs.
+ pub fn get_unsent_outputs(&self, limit: i64) -> SqliteResult<Vec<BufferedOutput>> {
+ let mut stmt = self.conn.prepare(
+ r#"
+ SELECT id, task_id, output, is_partial, timestamp
+ FROM output_buffer
+ WHERE sent = 0
+ ORDER BY id
+ LIMIT ?1
+ "#,
+ )?;
+
+ let rows = stmt.query_map(params![limit], |row| {
+ let id: i64 = row.get(0)?;
+ let task_id_str: String = row.get(1)?;
+ let task_id = Uuid::parse_str(&task_id_str).unwrap_or_default();
+ let output: String = row.get(2)?;
+ let is_partial: i32 = row.get(3)?;
+ let timestamp_str: String = row.get(4)?;
+ let timestamp = DateTime::parse_from_rfc3339(&timestamp_str)
+ .map(|dt| dt.with_timezone(&Utc))
+ .unwrap_or_else(|_| Utc::now());
+
+ Ok(BufferedOutput {
+ id,
+ task_id,
+ output,
+ is_partial: is_partial != 0,
+ timestamp,
+ })
+ })?;
+
+ rows.collect()
+ }
+
+ /// Mark outputs as sent.
+ pub fn mark_outputs_sent(&self, ids: &[i64]) -> SqliteResult<()> {
+ if ids.is_empty() {
+ return Ok(());
+ }
+
+ let placeholders: Vec<&str> = ids.iter().map(|_| "?").collect();
+ let sql = format!(
+ "UPDATE output_buffer SET sent = 1 WHERE id IN ({})",
+ placeholders.join(",")
+ );
+
+ let params: Vec<rusqlite::types::Value> = ids
+ .iter()
+ .map(|id| rusqlite::types::Value::Integer(*id))
+ .collect();
+
+ self.conn.execute(&sql, rusqlite::params_from_iter(params))?;
+ Ok(())
+ }
+
+ /// Clean up old sent outputs.
+ pub fn cleanup_sent_outputs(&self, older_than_hours: i64) -> SqliteResult<usize> {
+ let result = self.conn.execute(
+ r#"
+ DELETE FROM output_buffer
+ WHERE sent = 1 AND timestamp < datetime('now', ?1 || ' hours')
+ "#,
+ params![format!("-{}", older_than_hours)],
+ )?;
+ Ok(result)
+ }
+
+ /// Get daemon state value.
+ pub fn get_state(&self, key: &str) -> SqliteResult<Option<String>> {
+ let mut stmt = self.conn.prepare(
+ "SELECT value FROM daemon_state WHERE key = ?1",
+ )?;
+
+ let mut rows = stmt.query(params![key])?;
+
+ if let Some(row) = rows.next()? {
+ let value: String = row.get(0)?;
+ Ok(Some(value))
+ } else {
+ Ok(None)
+ }
+ }
+
+ /// Set daemon state value.
+ pub fn set_state(&self, key: &str, value: &str) -> SqliteResult<()> {
+ self.conn.execute(
+ r#"
+ INSERT OR REPLACE INTO daemon_state (key, value, updated_at)
+ VALUES (?1, ?2, datetime('now'))
+ "#,
+ params![key, value],
+ )?;
+ Ok(())
+ }
+
+ /// Parse a task from a database row.
+ fn task_from_row(row: &rusqlite::Row) -> SqliteResult<LocalTask> {
+ let id_str: String = row.get(0)?;
+ let server_task_id_str: String = row.get(1)?;
+ let state_str: String = row.get(2)?;
+ let container_id: Option<String> = row.get(3)?;
+ let overlay_path: Option<String> = row.get(4)?;
+ let repo_url: Option<String> = row.get(5)?;
+ let base_branch: Option<String> = row.get(6)?;
+ let plan: String = row.get(7)?;
+ let created_at_str: String = row.get(8)?;
+ let started_at_str: Option<String> = row.get(9)?;
+ let completed_at_str: Option<String> = row.get(10)?;
+ let error_message: Option<String> = row.get(11)?;
+
+ let id = Uuid::parse_str(&id_str).unwrap_or_default();
+ let server_task_id = Uuid::parse_str(&server_task_id_str).unwrap_or_default();
+ let state = TaskState::from_str(&state_str).unwrap_or_default();
+ let created_at = DateTime::parse_from_rfc3339(&created_at_str)
+ .map(|dt| dt.with_timezone(&Utc))
+ .unwrap_or_else(|_| Utc::now());
+ let started_at = started_at_str
+ .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
+ .map(|dt| dt.with_timezone(&Utc));
+ let completed_at = completed_at_str
+ .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
+ .map(|dt| dt.with_timezone(&Utc));
+
+ Ok(LocalTask {
+ id,
+ server_task_id,
+ state,
+ container_id,
+ overlay_path,
+ repo_url,
+ base_branch,
+ plan,
+ created_at,
+ started_at,
+ completed_at,
+ error_message,
+ })
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_open_memory() {
+ let db = LocalDb::open_memory().unwrap();
+ assert!(db.get_active_tasks().unwrap().is_empty());
+ }
+
+ #[test]
+ fn test_save_and_get_task() {
+ let db = LocalDb::open_memory().unwrap();
+
+ let task = LocalTask {
+ id: Uuid::new_v4(),
+ server_task_id: Uuid::new_v4(),
+ state: TaskState::Running,
+ container_id: Some("abc123".to_string()),
+ overlay_path: Some("/tmp/overlay".to_string()),
+ repo_url: Some("https://github.com/test/repo".to_string()),
+ base_branch: Some("main".to_string()),
+ plan: "Build the feature".to_string(),
+ created_at: Utc::now(),
+ started_at: Some(Utc::now()),
+ completed_at: None,
+ error_message: None,
+ };
+
+ db.save_task(&task).unwrap();
+
+ let loaded = db.get_task(task.id).unwrap().unwrap();
+ assert_eq!(loaded.id, task.id);
+ assert_eq!(loaded.state, TaskState::Running);
+ assert_eq!(loaded.plan, "Build the feature");
+ }
+
+ #[test]
+ fn test_output_buffer() {
+ let db = LocalDb::open_memory().unwrap();
+ let task_id = Uuid::new_v4();
+
+ db.buffer_output(task_id, "line 1", false).unwrap();
+ db.buffer_output(task_id, "line 2", false).unwrap();
+
+ let unsent = db.get_unsent_outputs(10).unwrap();
+ assert_eq!(unsent.len(), 2);
+
+ let ids: Vec<i64> = unsent.iter().map(|o| o.id).collect();
+ db.mark_outputs_sent(&ids).unwrap();
+
+ let unsent = db.get_unsent_outputs(10).unwrap();
+ assert!(unsent.is_empty());
+ }
+}
diff --git a/makima/daemon/src/db/mod.rs b/makima/daemon/src/db/mod.rs
new file mode 100644
index 0000000..2c6e0f3
--- /dev/null
+++ b/makima/daemon/src/db/mod.rs
@@ -0,0 +1,5 @@
+//! Local database for daemon state persistence.
+
+pub mod local;
+
+pub use local::{BufferedOutput, LocalDb, LocalTask};
diff --git a/makima/daemon/src/error.rs b/makima/daemon/src/error.rs
new file mode 100644
index 0000000..00e5140
--- /dev/null
+++ b/makima/daemon/src/error.rs
@@ -0,0 +1,75 @@
+//! Error types for the makima daemon.
+
+use thiserror::Error;
+use uuid::Uuid;
+
+/// Top-level daemon error type.
+#[derive(Error, Debug)]
+pub enum DaemonError {
+ #[error("WebSocket error: {0}")]
+ WebSocket(#[from] tokio_tungstenite::tungstenite::Error),
+
+ #[error("Worktree error: {0}")]
+ Worktree(#[from] crate::worktree::WorktreeError),
+
+ #[error("Process error: {0}")]
+ Process(#[from] crate::process::ClaudeProcessError),
+
+ #[error("Task error: {0}")]
+ Task(#[from] TaskError),
+
+ #[error("Configuration error: {0}")]
+ Config(#[from] config::ConfigError),
+
+ #[error("Database error: {0}")]
+ Database(#[from] rusqlite::Error),
+
+ #[error("IO error: {0}")]
+ Io(#[from] std::io::Error),
+
+ #[error("JSON error: {0}")]
+ Json(#[from] serde_json::Error),
+
+ #[error("Authentication failed: {0}")]
+ AuthFailed(String),
+
+ #[error("Connection lost")]
+ ConnectionLost,
+
+ #[error("Server error: {code} - {message}")]
+ ServerError { code: String, message: String },
+}
+
+/// Task management errors.
+#[derive(Error, Debug)]
+pub enum TaskError {
+ #[error("Task not found: {0}")]
+ NotFound(Uuid),
+
+ #[error("Invalid state transition from {from} to {to}")]
+ InvalidStateTransition { from: String, to: String },
+
+ #[error("Concurrency limit reached")]
+ ConcurrencyLimit,
+
+ #[error("Task already exists: {0}")]
+ AlreadyExists(Uuid),
+
+ #[error("Task not running: {0}")]
+ NotRunning(Uuid),
+
+ #[error("Failed to send message to task: {0}")]
+ MessageFailed(String),
+
+ #[error("Task setup failed: {0}")]
+ SetupFailed(String),
+
+ #[error("Task execution failed: {0}")]
+ ExecutionFailed(String),
+}
+
+/// Result type alias for daemon operations.
+pub type Result<T> = std::result::Result<T, DaemonError>;
+
+/// Result type alias for task operations.
+pub type TaskResult<T> = std::result::Result<T, TaskError>;
diff --git a/makima/daemon/src/lib.rs b/makima/daemon/src/lib.rs
new file mode 100644
index 0000000..9555681
--- /dev/null
+++ b/makima/daemon/src/lib.rs
@@ -0,0 +1,21 @@
+//! Makima Daemon - Git worktree orchestration for Claude Code.
+//!
+//! This daemon runs on worker machines and:
+//! - Connects to the makima server via WebSocket
+//! - Creates git worktrees for task isolation
+//! - Runs Claude Code CLI as subprocesses in worktrees
+//! - Streams JSON output back to server
+
+pub mod cli;
+pub mod config;
+pub mod db;
+pub mod error;
+pub mod process;
+pub mod task;
+pub mod temp;
+pub mod worktree;
+pub mod ws;
+
+pub use cli::Cli;
+pub use config::DaemonConfig;
+pub use error::{DaemonError, Result};
diff --git a/makima/daemon/src/main.rs b/makima/daemon/src/main.rs
new file mode 100644
index 0000000..e4ca5d4
--- /dev/null
+++ b/makima/daemon/src/main.rs
@@ -0,0 +1,313 @@
+//! Makima Daemon - Git worktree orchestration for Claude Code.
+
+use std::sync::Arc;
+
+use std::path::Path;
+
+use clap::Parser;
+use makima_daemon::cli::Cli;
+use makima_daemon::config::{DaemonConfig, RepoEntry};
+use makima_daemon::db::LocalDb;
+use makima_daemon::error::DaemonError;
+use makima_daemon::task::{TaskConfig, TaskManager};
+use makima_daemon::ws::{DaemonCommand, WsClient};
+use tokio::process::Command;
+use tokio::sync::mpsc;
+use tracing_subscriber::{fmt, prelude::*, EnvFilter};
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+ eprintln!("=== Makima Daemon Starting ===");
+
+ // Parse command-line arguments
+ let cli = Cli::parse();
+
+ // Load configuration with CLI overrides
+ eprintln!("[1/5] Loading configuration...");
+ let config = match DaemonConfig::load_with_cli(&cli) {
+ Ok(cfg) => {
+ eprintln!(" Config loaded: server={}", cfg.server.url);
+ cfg
+ }
+ Err(e) => {
+ eprintln!("Failed to load configuration: {}", e);
+ eprintln!();
+ eprintln!("Use CLI flags:");
+ eprintln!(" makima-daemon --server-url ws://localhost:8080 --api-key your-api-key");
+ eprintln!();
+ eprintln!("Or set environment variables:");
+ eprintln!(" MAKIMA_DAEMON_SERVER_URL=ws://localhost:8080");
+ eprintln!(" MAKIMA_API_KEY=your-api-key");
+ eprintln!();
+ eprintln!("Or create a config file: makima-daemon.toml");
+ eprintln!();
+ eprintln!("Run 'makima-daemon --help' for all options.");
+ std::process::exit(1);
+ }
+ };
+
+ // Initialize logging
+ init_logging(&config.logging.level, &config.logging.format);
+ eprintln!("[2/5] Logging initialized");
+
+ // Initialize local database
+ eprintln!("[3/5] Opening local database: {}", config.local_db.path.display());
+ let _local_db = LocalDb::open(&config.local_db.path)?;
+ eprintln!(" Database opened");
+
+ // Initialize worktree directories
+ eprintln!("[4/5] Setting up directories...");
+ tokio::fs::create_dir_all(&config.worktree.base_dir).await?;
+ tokio::fs::create_dir_all(&config.worktree.repos_dir).await?;
+ tokio::fs::create_dir_all(&config.repos.home_dir).await?;
+ eprintln!(" Worktree base: {}", config.worktree.base_dir.display());
+ eprintln!(" Repos cache: {}", config.worktree.repos_dir.display());
+ eprintln!(" Home dir: {}", config.repos.home_dir.display());
+
+ // Auto-clone repositories if configured
+ if !config.repos.auto_clone.is_empty() {
+ eprintln!(" Auto-cloning {} repositories...", config.repos.auto_clone.len());
+ for repo_entry in &config.repos.auto_clone {
+ if let Err(e) = auto_clone_repo(repo_entry, &config.repos.home_dir).await {
+ eprintln!(" WARNING: Failed to clone {}: {}", repo_entry.url(), e);
+ }
+ }
+ }
+
+ // Create channels for communication
+ let (command_tx, mut command_rx) = mpsc::channel::<DaemonCommand>(64);
+
+ // Get machine info
+ let machine_id = get_machine_id();
+ let hostname = get_hostname();
+ eprintln!(" Machine ID: {}", machine_id);
+ eprintln!(" Hostname: {}", hostname);
+
+ // Create WebSocket client
+ eprintln!("[5/5] Connecting to server: {}", config.server.url);
+ let mut ws_client = WsClient::new(
+ config.server.clone(),
+ machine_id,
+ hostname,
+ config.process.max_concurrent_tasks as i32,
+ command_tx,
+ );
+
+ // Get sender for task manager
+ let ws_tx = ws_client.sender();
+
+ // Create task configuration
+ let task_config = TaskConfig {
+ max_concurrent_tasks: config.process.max_concurrent_tasks,
+ worktree_base_dir: config.worktree.base_dir.clone(),
+ env_vars: config.process.env_vars.clone(),
+ claude_command: config.process.claude_command.clone(),
+ claude_args: config.process.claude_args.clone(),
+ claude_pre_args: config.process.claude_pre_args.clone(),
+ enable_permissions: config.process.enable_permissions,
+ disable_verbose: config.process.disable_verbose,
+ };
+
+ // Create task manager
+ let task_manager = Arc::new(TaskManager::new(task_config, ws_tx.clone()));
+
+ // Spawn command handler
+ let task_manager_clone = task_manager.clone();
+ tokio::spawn(async move {
+ tracing::info!("Command handler started, waiting for commands...");
+ while let Some(command) = command_rx.recv().await {
+ tracing::info!("Received command from channel: {:?}", command);
+ if let Err(e) = task_manager_clone.handle_command(command).await {
+ tracing::error!("Failed to handle command: {}", e);
+ }
+ }
+ tracing::info!("Command handler stopped");
+ });
+
+ // Handle shutdown signals
+ let shutdown_signal = async {
+ tokio::signal::ctrl_c()
+ .await
+ .expect("Failed to install Ctrl+C handler");
+ eprintln!("\nReceived shutdown signal");
+ };
+
+ eprintln!("=== Daemon running (Ctrl+C to stop) ===");
+
+ // Run WebSocket client with shutdown handling
+ tokio::select! {
+ result = ws_client.run() => {
+ match result {
+ Ok(()) => eprintln!("WebSocket client exited cleanly"),
+ Err(DaemonError::AuthFailed(msg)) => {
+ eprintln!("ERROR: Authentication failed: {}", msg);
+ std::process::exit(1);
+ }
+ Err(e) => {
+ eprintln!("ERROR: WebSocket client error: {}", e);
+ std::process::exit(1);
+ }
+ }
+ }
+ _ = shutdown_signal => {
+ eprintln!("Shutting down...");
+ }
+ }
+
+ // Cleanup
+ tracing::info!("Daemon stopped");
+
+ Ok(())
+}
+
+fn init_logging(level: &str, format: &str) {
+ let filter = EnvFilter::try_from_default_env()
+ .or_else(|_| EnvFilter::try_new(level))
+ .unwrap_or_else(|_| EnvFilter::new("info"));
+
+ let subscriber = tracing_subscriber::registry().with(filter);
+
+ if format == "json" {
+ subscriber.with(fmt::layer().json()).init();
+ } else {
+ subscriber.with(fmt::layer()).init();
+ }
+}
+
+fn get_machine_id() -> String {
+ // Try to read machine-id from standard locations
+ #[cfg(target_os = "linux")]
+ {
+ if let Ok(id) = std::fs::read_to_string("/etc/machine-id") {
+ return id.trim().to_string();
+ }
+ if let Ok(id) = std::fs::read_to_string("/var/lib/dbus/machine-id") {
+ return id.trim().to_string();
+ }
+ }
+
+ #[cfg(target_os = "macos")]
+ {
+ // Use IOPlatformSerialNumber
+ if let Ok(output) = std::process::Command::new("ioreg")
+ .args(["-rd1", "-c", "IOPlatformExpertDevice"])
+ .output()
+ {
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ for line in stdout.lines() {
+ if line.contains("IOPlatformUUID") {
+ if let Some(uuid) = line.split('"').nth(3) {
+ return uuid.to_string();
+ }
+ }
+ }
+ }
+ }
+
+ // Fallback: generate a random ID and persist it
+ let state_dir = dirs_next::data_local_dir()
+ .unwrap_or_else(|| std::path::PathBuf::from("."))
+ .join("makima-daemon");
+ let machine_id_file = state_dir.join("machine-id");
+
+ if let Ok(id) = std::fs::read_to_string(&machine_id_file) {
+ return id.trim().to_string();
+ }
+
+ // Generate new ID
+ let new_id = uuid::Uuid::new_v4().to_string();
+ std::fs::create_dir_all(&state_dir).ok();
+ std::fs::write(&machine_id_file, &new_id).ok();
+ new_id
+}
+
+fn get_hostname() -> String {
+ hostname::get()
+ .map(|h| h.to_string_lossy().to_string())
+ .unwrap_or_else(|_| "unknown".to_string())
+}
+
+/// Auto-clone a repository to the home directory if it doesn't exist.
+async fn auto_clone_repo(
+ repo_entry: &RepoEntry,
+ home_dir: &Path,
+) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
+ let dir_name = repo_entry
+ .dir_name()
+ .ok_or("Could not determine directory name from URL")?;
+ let target_dir = home_dir.join(&dir_name);
+
+ // Check if already cloned
+ if target_dir.exists() {
+ eprintln!(" [skip] {} (already exists)", dir_name);
+ return Ok(());
+ }
+
+ let url = repo_entry.expanded_url();
+ eprintln!(" [clone] {} -> {}", url, target_dir.display());
+
+ // Build git clone command
+ let mut args = vec!["clone".to_string()];
+
+ // Add shallow clone if requested
+ if repo_entry.shallow() {
+ args.push("--depth".to_string());
+ args.push("1".to_string());
+ }
+
+ // Add branch if specified
+ if let Some(branch) = repo_entry.branch() {
+ args.push("--branch".to_string());
+ args.push(branch.to_string());
+ }
+
+ args.push(url.clone());
+ args.push(target_dir.to_string_lossy().to_string());
+
+ // Run git clone
+ let output = Command::new("git")
+ .args(&args)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(format!("git clone failed: {}", stderr).into());
+ }
+
+ eprintln!(" [done] {}", dir_name);
+ Ok(())
+}
+
+/// dirs_next minimal replacement
+mod dirs_next {
+ use std::path::PathBuf;
+
+ pub fn data_local_dir() -> Option<PathBuf> {
+ #[cfg(target_os = "macos")]
+ {
+ std::env::var("HOME")
+ .ok()
+ .map(|h| PathBuf::from(h).join("Library").join("Application Support"))
+ }
+ #[cfg(target_os = "linux")]
+ {
+ std::env::var("XDG_DATA_HOME")
+ .ok()
+ .map(PathBuf::from)
+ .or_else(|| {
+ std::env::var("HOME")
+ .ok()
+ .map(|h| PathBuf::from(h).join(".local").join("share"))
+ })
+ }
+ #[cfg(target_os = "windows")]
+ {
+ std::env::var("LOCALAPPDATA").ok().map(PathBuf::from)
+ }
+ #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
+ {
+ None
+ }
+ }
+}
diff --git a/makima/daemon/src/process/claude.rs b/makima/daemon/src/process/claude.rs
new file mode 100644
index 0000000..e06ee09
--- /dev/null
+++ b/makima/daemon/src/process/claude.rs
@@ -0,0 +1,481 @@
+//! Claude Code process management.
+
+use std::collections::HashMap;
+use std::path::Path;
+use std::process::Stdio;
+use std::sync::Arc;
+
+use futures::Stream;
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
+use tokio::process::{Child, ChildStdin, Command};
+use tokio::sync::{mpsc, Mutex};
+
+use super::claude_protocol::ClaudeInputMessage;
+
+/// Errors that can occur during Claude process management.
+#[derive(Debug, thiserror::Error)]
+pub enum ClaudeProcessError {
+ #[error("Failed to spawn Claude process: {0}")]
+ SpawnFailed(#[from] std::io::Error),
+
+ #[error("Claude command not found: {0}")]
+ CommandNotFound(String),
+
+ #[error("Process already exited")]
+ AlreadyExited,
+
+ #[error("Failed to read output: {0}")]
+ OutputRead(String),
+}
+
+/// A line of output from Claude Code.
+#[derive(Debug, Clone)]
+pub struct OutputLine {
+ /// The raw content of the line.
+ pub content: String,
+ /// Whether this is from stdout (true) or stderr (false).
+ pub is_stdout: bool,
+ /// Parsed JSON type if available (e.g., "system", "assistant", "result").
+ pub json_type: Option<String>,
+}
+
+impl OutputLine {
+ /// Create a new stdout output line.
+ pub fn stdout(content: String) -> Self {
+ let json_type = extract_json_type(&content);
+ Self {
+ content,
+ is_stdout: true,
+ json_type,
+ }
+ }
+
+ /// Create a new stderr output line.
+ pub fn stderr(content: String) -> Self {
+ Self {
+ content,
+ is_stdout: false,
+ json_type: None,
+ }
+ }
+}
+
+/// Extract the "type" field from a JSON line if present.
+fn extract_json_type(line: &str) -> Option<String> {
+ // Quick check for JSON
+ if !line.starts_with('{') {
+ return None;
+ }
+
+ // Try to parse and extract type
+ if let Ok(json) = serde_json::from_str::<serde_json::Value>(line) {
+ json.get("type")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string())
+ } else {
+ None
+ }
+}
+
+/// Handle to a running Claude Code process.
+pub struct ClaudeProcess {
+ /// The child process.
+ child: Child,
+ /// Receiver for output lines.
+ output_rx: mpsc::Receiver<OutputLine>,
+ /// Stdin handle for sending input to the process (thread-safe).
+ stdin: Arc<Mutex<Option<ChildStdin>>>,
+}
+
+impl ClaudeProcess {
+ /// Wait for the process to exit and return the exit code.
+ pub async fn wait(&mut self) -> Result<i64, ClaudeProcessError> {
+ let status = self.child.wait().await?;
+ Ok(status.code().unwrap_or(-1) as i64)
+ }
+
+ /// Check if the process has exited.
+ pub fn try_wait(&mut self) -> Result<Option<i64>, ClaudeProcessError> {
+ match self.child.try_wait()? {
+ Some(status) => Ok(Some(status.code().unwrap_or(-1) as i64)),
+ None => Ok(None),
+ }
+ }
+
+ /// Kill the process.
+ pub async fn kill(&mut self) -> Result<(), ClaudeProcessError> {
+ self.child.kill().await?;
+ Ok(())
+ }
+
+ /// Get the next output line, if available.
+ pub async fn next_output(&mut self) -> Option<OutputLine> {
+ self.output_rx.recv().await
+ }
+
+ /// Send a raw message to the process via stdin.
+ ///
+ /// This can be used to provide input when Claude Code is waiting for user input.
+ pub async fn send_input(&self, message: &str) -> Result<(), ClaudeProcessError> {
+ let mut stdin_guard = self.stdin.lock().await;
+ if let Some(ref mut stdin) = *stdin_guard {
+ stdin
+ .write_all(message.as_bytes())
+ .await
+ .map_err(|e| ClaudeProcessError::OutputRead(format!("Failed to write to stdin: {}", e)))?;
+ stdin
+ .write_all(b"\n")
+ .await
+ .map_err(|e| ClaudeProcessError::OutputRead(format!("Failed to write newline: {}", e)))?;
+ stdin
+ .flush()
+ .await
+ .map_err(|e| ClaudeProcessError::OutputRead(format!("Failed to flush stdin: {}", e)))?;
+ Ok(())
+ } else {
+ Err(ClaudeProcessError::OutputRead("Stdin not available".to_string()))
+ }
+ }
+
+ /// Send a user message to the process via stdin using JSON protocol.
+ ///
+ /// This is the preferred method when using `--input-format=stream-json`.
+ /// The message is serialized as JSON and sent as a single line.
+ pub async fn send_user_message(&self, content: &str) -> Result<(), ClaudeProcessError> {
+ let message = ClaudeInputMessage::user(content);
+ let json_line = message.to_json_line().map_err(|e| {
+ ClaudeProcessError::OutputRead(format!("Failed to serialize message: {}", e))
+ })?;
+
+ tracing::debug!(content_len = content.len(), "Sending user message to Claude process");
+
+ let mut stdin_guard = self.stdin.lock().await;
+ if let Some(ref mut stdin) = *stdin_guard {
+ stdin
+ .write_all(json_line.as_bytes())
+ .await
+ .map_err(|e| ClaudeProcessError::OutputRead(format!("Failed to write to stdin: {}", e)))?;
+ stdin
+ .flush()
+ .await
+ .map_err(|e| ClaudeProcessError::OutputRead(format!("Failed to flush stdin: {}", e)))?;
+ Ok(())
+ } else {
+ Err(ClaudeProcessError::OutputRead("Stdin not available".to_string()))
+ }
+ }
+
+ /// Get a clone of the stdin handle for external use.
+ pub fn stdin_handle(&self) -> Arc<Mutex<Option<ChildStdin>>> {
+ Arc::clone(&self.stdin)
+ }
+
+ /// Close stdin, signaling EOF to the process.
+ pub async fn close_stdin(&self) -> Result<(), ClaudeProcessError> {
+ let mut stdin_guard = self.stdin.lock().await;
+ if let Some(mut stdin) = stdin_guard.take() {
+ let _ = stdin.shutdown().await;
+ }
+ Ok(())
+ }
+
+ /// Convert to a stream of output lines.
+ pub fn into_stream(self) -> impl Stream<Item = OutputLine> {
+ futures::stream::unfold(self.output_rx, |mut rx| async move {
+ rx.recv().await.map(|line| (line, rx))
+ })
+ }
+}
+
+/// Manages Claude Code process spawning.
+pub struct ProcessManager {
+ /// Path to the claude command.
+ claude_command: String,
+ /// Additional arguments to pass to Claude Code (after defaults).
+ claude_args: Vec<String>,
+ /// Arguments to pass before defaults.
+ claude_pre_args: Vec<String>,
+ /// Whether to enable Claude's permission system (skip --dangerously-skip-permissions).
+ enable_permissions: bool,
+ /// Whether to disable verbose output.
+ disable_verbose: bool,
+ /// Default environment variables to pass.
+ default_env: HashMap<String, String>,
+}
+
+impl Default for ProcessManager {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl ProcessManager {
+ /// Create a new ProcessManager with default settings.
+ pub fn new() -> Self {
+ Self {
+ claude_command: "claude".to_string(),
+ claude_args: Vec::new(),
+ claude_pre_args: Vec::new(),
+ enable_permissions: false,
+ disable_verbose: false,
+ default_env: HashMap::new(),
+ }
+ }
+
+ /// Create a ProcessManager with a custom claude command path.
+ pub fn with_command(command: String) -> Self {
+ Self {
+ claude_command: command,
+ claude_args: Vec::new(),
+ claude_pre_args: Vec::new(),
+ enable_permissions: false,
+ disable_verbose: false,
+ default_env: HashMap::new(),
+ }
+ }
+
+ /// Set additional arguments to pass after default arguments.
+ pub fn with_args(mut self, args: Vec<String>) -> Self {
+ self.claude_args = args;
+ self
+ }
+
+ /// Set arguments to pass before default arguments.
+ pub fn with_pre_args(mut self, args: Vec<String>) -> Self {
+ self.claude_pre_args = args;
+ self
+ }
+
+ /// Enable Claude's permission system (don't pass --dangerously-skip-permissions).
+ pub fn with_permissions_enabled(mut self, enabled: bool) -> Self {
+ self.enable_permissions = enabled;
+ self
+ }
+
+ /// Disable verbose output.
+ pub fn with_verbose_disabled(mut self, disabled: bool) -> Self {
+ self.disable_verbose = disabled;
+ self
+ }
+
+ /// Add default environment variables.
+ pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
+ self.default_env = env;
+ self
+ }
+
+ /// Spawn a Claude Code process to execute a plan.
+ ///
+ /// The process runs in the specified working directory with stream-json output format.
+ pub async fn spawn(
+ &self,
+ working_dir: &Path,
+ plan: &str,
+ extra_env: Option<HashMap<String, String>>,
+ ) -> Result<ClaudeProcess, ClaudeProcessError> {
+ tracing::info!(
+ working_dir = %working_dir.display(),
+ plan_len = plan.len(),
+ plan_preview = %if plan.len() > 200 { &plan[..200] } else { plan },
+ "Spawning Claude Code process"
+ );
+
+ // Verify working directory exists
+ if !working_dir.exists() {
+ tracing::error!(working_dir = %working_dir.display(), "Working directory does not exist!");
+ return Err(ClaudeProcessError::SpawnFailed(std::io::Error::new(
+ std::io::ErrorKind::NotFound,
+ format!("Working directory does not exist: {}", working_dir.display()),
+ )));
+ }
+
+ // Build environment
+ let mut env = self.default_env.clone();
+ if let Some(extra) = extra_env {
+ env.extend(extra);
+ }
+
+ // Build arguments list
+ let mut args = Vec::new();
+
+ // Pre-args (before defaults)
+ args.extend(self.claude_pre_args.clone());
+
+ // Required arguments for stream-json protocol
+ args.push("--output-format=stream-json".to_string());
+ args.push("--input-format=stream-json".to_string());
+
+ // Optional default arguments
+ if !self.disable_verbose {
+ args.push("--verbose".to_string());
+ }
+ if !self.enable_permissions {
+ args.push("--dangerously-skip-permissions".to_string());
+ }
+
+ // Additional user-configured arguments
+ args.extend(self.claude_args.clone());
+
+ tracing::debug!(args = ?args, "Claude command arguments");
+
+ // Spawn the process
+ let mut child = Command::new(&self.claude_command)
+ .args(&args)
+ .current_dir(working_dir)
+ .envs(env)
+ .stdin(Stdio::piped())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped())
+ .kill_on_drop(true)
+ .spawn()
+ .map_err(|e| {
+ if e.kind() == std::io::ErrorKind::NotFound {
+ ClaudeProcessError::CommandNotFound(self.claude_command.clone())
+ } else {
+ ClaudeProcessError::SpawnFailed(e)
+ }
+ })?;
+
+ // Create output channel
+ let (tx, rx) = mpsc::channel(1000);
+
+ // Take stdout, stderr, and stdin
+ // With --input-format=stream-json, we keep stdin open for sending messages
+ let stdin = child.stdin.take();
+ let stdin = Arc::new(Mutex::new(stdin));
+
+ let stdout = child.stdout.take().expect("stdout should be piped");
+ let stderr = child.stderr.take().expect("stderr should be piped");
+
+ // Spawn task to read stdout
+ let tx_stdout = tx.clone();
+ tokio::spawn(async move {
+ use tokio::io::AsyncReadExt;
+ let mut reader = BufReader::new(stdout);
+ let mut buffer = vec![0u8; 4096];
+ let mut line_buffer = String::new();
+
+ loop {
+ // Try to read with a timeout to detect if we're stuck
+ match tokio::time::timeout(
+ tokio::time::Duration::from_secs(5),
+ reader.read(&mut buffer)
+ ).await {
+ Ok(Ok(0)) => {
+ // EOF
+ tracing::debug!("Claude stdout EOF");
+ // Send any remaining content
+ if !line_buffer.is_empty() {
+ let _ = tx_stdout.send(OutputLine::stdout(line_buffer)).await;
+ }
+ break;
+ }
+ Ok(Ok(n)) => {
+ let chunk = String::from_utf8_lossy(&buffer[..n]);
+ tracing::debug!(bytes = n, chunk_preview = %if chunk.len() > 100 { &chunk[..100] } else { &chunk }, "Got stdout chunk from Claude");
+
+ // Accumulate into line buffer and emit complete lines
+ line_buffer.push_str(&chunk);
+ while let Some(newline_pos) = line_buffer.find('\n') {
+ let line = line_buffer[..newline_pos].to_string();
+ line_buffer = line_buffer[newline_pos + 1..].to_string();
+ if tx_stdout.send(OutputLine::stdout(line)).await.is_err() {
+ return;
+ }
+ }
+ }
+ Ok(Err(e)) => {
+ tracing::error!(error = %e, "Error reading Claude stdout");
+ break;
+ }
+ Err(_) => {
+ // Timeout - no data for 5 seconds
+ tracing::warn!("No stdout data from Claude for 5 seconds");
+ }
+ }
+ }
+ tracing::debug!("Claude stdout reader task ended");
+ });
+
+ // Spawn task to read stderr
+ let tx_stderr = tx;
+ tokio::spawn(async move {
+ let reader = BufReader::new(stderr);
+ let mut lines = reader.lines();
+ while let Ok(Some(line)) = lines.next_line().await {
+ tracing::debug!(line = %line, "Claude stderr");
+ if tx_stderr.send(OutputLine::stderr(line)).await.is_err() {
+ break;
+ }
+ }
+ tracing::debug!("Claude stderr reader task ended");
+ });
+
+ tracing::info!("Claude Code process spawned successfully");
+
+ let process = ClaudeProcess {
+ child,
+ output_rx: rx,
+ stdin,
+ };
+
+ // Send the initial plan as the first user message
+ tracing::info!(plan_len = plan.len(), "Sending initial plan to Claude via stdin");
+ process.send_user_message(plan).await?;
+
+ Ok(process)
+ }
+
+ /// Check if the claude command is available.
+ pub async fn check_claude_available(&self) -> Result<String, ClaudeProcessError> {
+ let output = Command::new(&self.claude_command)
+ .arg("--version")
+ .output()
+ .await
+ .map_err(|e| {
+ if e.kind() == std::io::ErrorKind::NotFound {
+ ClaudeProcessError::CommandNotFound(self.claude_command.clone())
+ } else {
+ ClaudeProcessError::SpawnFailed(e)
+ }
+ })?;
+
+ if output.status.success() {
+ Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
+ } else {
+ Err(ClaudeProcessError::CommandNotFound(
+ self.claude_command.clone(),
+ ))
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_extract_json_type() {
+ assert_eq!(
+ extract_json_type(r#"{"type":"system","subtype":"init"}"#),
+ Some("system".to_string())
+ );
+ assert_eq!(
+ extract_json_type(r#"{"type":"assistant","message":{}}"#),
+ Some("assistant".to_string())
+ );
+ assert_eq!(extract_json_type("not json"), None);
+ assert_eq!(extract_json_type(r#"{"no_type": true}"#), None);
+ }
+
+ #[test]
+ fn test_output_line_creation() {
+ let line = OutputLine::stdout(r#"{"type":"result"}"#.to_string());
+ assert!(line.is_stdout);
+ assert_eq!(line.json_type, Some("result".to_string()));
+
+ let line = OutputLine::stderr("error message".to_string());
+ assert!(!line.is_stdout);
+ assert_eq!(line.json_type, None);
+ }
+}
diff --git a/makima/daemon/src/process/claude_protocol.rs b/makima/daemon/src/process/claude_protocol.rs
new file mode 100644
index 0000000..930152b
--- /dev/null
+++ b/makima/daemon/src/process/claude_protocol.rs
@@ -0,0 +1,59 @@
+//! Claude Code JSON protocol types for stdin communication.
+//!
+//! When using `--input-format=stream-json`, Claude Code expects
+//! newline-delimited JSON messages on stdin.
+
+use serde::Serialize;
+
+/// Message sent to Claude Code via stdin.
+///
+/// Format based on Claude Code's stream-json input protocol.
+#[derive(Debug, Clone, Serialize)]
+#[serde(tag = "type", rename_all = "snake_case")]
+pub enum ClaudeInputMessage {
+ /// A user message to send to Claude.
+ User { message: UserMessage },
+}
+
+/// The inner user message structure.
+#[derive(Debug, Clone, Serialize)]
+pub struct UserMessage {
+ /// Always "user" for user messages.
+ pub role: String,
+ /// The message content.
+ pub content: String,
+}
+
+impl ClaudeInputMessage {
+ /// Create a new user message.
+ pub fn user(content: impl Into<String>) -> Self {
+ Self::User {
+ message: UserMessage {
+ role: "user".to_string(),
+ content: content.into(),
+ },
+ }
+ }
+
+ /// Serialize to a JSON string with trailing newline (NDJSON format).
+ pub fn to_json_line(&self) -> Result<String, serde_json::Error> {
+ let mut json = serde_json::to_string(self)?;
+ json.push('\n');
+ Ok(json)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_user_message_serialization() {
+ let msg = ClaudeInputMessage::user("Hello, Claude!");
+ let json = msg.to_json_line().unwrap();
+
+ // Should produce: {"type":"user","message":{"role":"user","content":"Hello, Claude!"}}\n
+ assert!(json.starts_with(r#"{"type":"user","message":{"role":"user","content":"Hello, Claude!"}}"#));
+ assert!(json.ends_with('\n'));
+ }
+}
diff --git a/makima/daemon/src/process/mod.rs b/makima/daemon/src/process/mod.rs
new file mode 100644
index 0000000..814a3c5
--- /dev/null
+++ b/makima/daemon/src/process/mod.rs
@@ -0,0 +1,10 @@
+//! Process management for Claude Code subprocess execution.
+//!
+//! Spawns and manages Claude Code processes in worktree directories,
+//! streaming JSON output back to the daemon.
+
+mod claude;
+mod claude_protocol;
+
+pub use claude::{ClaudeProcess, ClaudeProcessError, OutputLine, ProcessManager};
+pub use claude_protocol::ClaudeInputMessage;
diff --git a/makima/daemon/src/task/manager.rs b/makima/daemon/src/task/manager.rs
new file mode 100644
index 0000000..4979ce7
--- /dev/null
+++ b/makima/daemon/src/task/manager.rs
@@ -0,0 +1,2248 @@
+//! Task lifecycle manager using git worktrees and Claude Code subprocesses.
+
+use std::collections::HashMap;
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::time::Instant;
+
+use rand::Rng;
+use tokio::io::AsyncWriteExt;
+use tokio::sync::{mpsc, RwLock, Semaphore};
+use uuid::Uuid;
+
+use std::collections::HashSet;
+
+use super::state::TaskState;
+use crate::error::{DaemonError, TaskError, TaskResult};
+use crate::process::{ClaudeInputMessage, ProcessManager};
+use crate::temp::TempManager;
+use crate::worktree::{is_new_repo_request, ConflictResolution, WorktreeInfo, WorktreeManager};
+use crate::ws::{BranchInfo, DaemonCommand, DaemonMessage};
+
+/// Generate a secure random API key for orchestrator tool access.
+fn generate_tool_key() -> String {
+ let mut rng = rand::rng();
+ let bytes: [u8; 32] = rng.random();
+ hex::encode(bytes)
+}
+
+/// System prompt for regular (non-orchestrator) subtasks.
+/// This ensures subtasks work only within their isolated worktree directory.
+const SUBTASK_SYSTEM_PROMPT: &str = r#"You are working in an isolated worktree directory that contains a snapshot of the codebase.
+
+## IMPORTANT: Directory Restrictions
+
+**You MUST only work within the current working directory (your worktree).**
+
+- DO NOT use `cd` to navigate to directories outside your worktree
+- DO NOT use absolute paths that point outside your worktree (e.g., don't write to ~/some/path, /tmp, or the original repository)
+- DO NOT modify files in parent directories or sibling directories
+- All your file operations should be relative to the current directory
+
+Your working directory is your sandboxed workspace. When you complete your task, your changes will be reviewed and integrated by the orchestrator.
+
+**Why?** Your worktree is isolated so that:
+1. Your changes don't affect other running tasks
+2. Changes can be reviewed before integration
+3. Multiple tasks can work on the codebase in parallel without conflicts
+
+---
+
+"#;
+
+/// The orchestrator system prompt that tells Claude how to use the helper script.
+const ORCHESTRATOR_SYSTEM_PROMPT: &str = r#"You are an orchestrator task. Your job is to coordinate subtasks and integrate their work, NOT to write code directly.
+
+## FIRST STEP
+
+Start by checking if you have existing subtasks:
+
+```bash
+# List all subtasks to see what work needs to be done
+./.makima/orchestrate.sh list
+```
+
+If subtasks exist, start them. If you need additional subtasks or no subtasks exist yet, you can create them.
+
+---
+
+## Creating Subtasks
+
+You can create new subtasks to break down work:
+
+```bash
+# Create a new subtask with a name and plan
+./.makima/orchestrate.sh create "Subtask Name" "Detailed plan for what the subtask should do..."
+
+# The command returns the new subtask ID - use it to start the subtask
+./.makima/orchestrate.sh start <new_subtask_id>
+```
+
+Create subtasks when you need to:
+- Break down complex work into smaller pieces
+- Run multiple tasks in parallel on different parts of the codebase
+- Delegate specific implementation work
+
+## Task Continuation (Sequential Dependencies)
+
+When subtasks need to build on each other's work (e.g., Task B depends on Task A's changes), use `--continue-from`:
+
+```bash
+# Create Task B that continues from Task A's worktree
+./.makima/orchestrate.sh create "Task B" "Build on Task A's work..." --continue-from <task_a_id>
+```
+
+This copies all files from Task A's worktree into Task B's worktree, so Task B starts with Task A's changes.
+
+**When to use continuation:**
+- Sequential work: Task B needs Task A's output files
+- Staged implementation: Building features incrementally
+- Fix-and-extend: One task fixes issues, another adds features on top
+
+**When NOT to use continuation:**
+- Parallel tasks working on different files
+- Independent subtasks that can be merged separately
+
+**Important for merging:** When tasks continue from each other, only merge the FINAL task in the chain. Earlier tasks' changes are already included in later tasks' worktrees.
+
+## Sharing Files with Subtasks
+
+Use `--files` to copy specific files from your orchestrator worktree to subtasks. This is useful for sharing plans, configs, or data files:
+
+```bash
+# Create subtask with specific files copied from orchestrator
+./.makima/orchestrate.sh create "Implement Feature" "Follow the plan in PLAN.md" --files "PLAN.md"
+
+# Copy multiple files (comma-separated)
+./.makima/orchestrate.sh create "API Work" "Use the spec..." --files "PLAN.md,api-spec.yaml,types.ts"
+
+# Combine with --continue-from to share files AND continue from another task
+./.makima/orchestrate.sh create "Step 2" "Continue..." --continue-from <task_a_id> --files "requirements.md"
+```
+
+**Use cases for --files:**
+- Share a PLAN.md with detailed implementation steps
+- Distribute configuration or spec files
+- Pass generated data or intermediate results
+
+## How Subtasks Work
+
+Each subtask runs in its own **worktree** - a separate directory with a copy of the codebase. When subtasks complete:
+- Their work remains in the worktree files (NOT committed to git)
+- **Subtasks do NOT auto-merge** - YOU must integrate their work into your worktree
+- You can view and copy files from subtask worktrees using their paths
+- The worktree path is returned when you get subtask status
+
+**IMPORTANT:** Subtasks never create PRs or merge to the target repository. Only the orchestrator (you) can trigger completion actions like PR creation or merging after integrating all subtask work.
+
+## Subtask Commands
+```bash
+# List all subtasks and their current status
+./.makima/orchestrate.sh list
+
+# Create a new subtask (returns the subtask ID)
+./.makima/orchestrate.sh create "Name" "Plan/description"
+
+# Create a subtask that continues from another task's worktree
+./.makima/orchestrate.sh create "Name" "Plan" --continue-from <other_task_id>
+
+# Create a subtask with specific files copied from orchestrator worktree
+./.makima/orchestrate.sh create "Name" "Plan" --files "file1.md,file2.yaml"
+
+# Start a specific subtask (it will run in its own Claude instance)
+./.makima/orchestrate.sh start <subtask_id>
+
+# Stop a running subtask
+./.makima/orchestrate.sh stop <subtask_id>
+
+# Get detailed status of a subtask (includes worktree_path when available)
+./.makima/orchestrate.sh status <subtask_id>
+
+# Get the output/logs of a subtask
+./.makima/orchestrate.sh output <subtask_id>
+
+# Get the worktree path for a subtask
+./.makima/orchestrate.sh worktree <subtask_id>
+```
+
+## Integrating Subtask Work
+
+When subtasks complete, their changes exist as files in their worktree directories:
+- Files are NOT committed to git branches
+- You must copy/integrate files from subtask worktrees into your worktree
+- Use standard file operations (cp, cat, etc.) to review and integrate changes
+
+### Handling Continuation Chains
+
+**CRITICAL:** When subtasks use `--continue-from`, they form a chain where each task includes all changes from previous tasks. You must ONLY integrate the FINAL task in each chain.
+
+Example chain: Task A → Task B (continues from A) → Task C (continues from B)
+- Task C's worktree contains ALL changes from A, B, and C
+- You should ONLY integrate Task C's worktree
+- DO NOT integrate Task A or Task B separately (their changes are already in C)
+
+**How to track continuation chains:**
+1. When you create tasks with `--continue-from`, note which task continues from which
+2. Build a mental model: Independent tasks (no continuation) + Continuation chains
+3. For each chain, only integrate the LAST task in the chain
+
+**Example with mixed independent and chained tasks:**
+```
+Independent tasks (integrate all):
+- Task X: API endpoints
+- Task Y: Database models
+
+Continuation chain (integrate ONLY the last one):
+- Task A: Core feature → Task B: Tests (continues from A) → Task C: Docs (continues from B)
+ Only integrate Task C!
+```
+
+### Integration Examples
+
+For independent subtasks (no continuation):
+```bash
+# Get the worktree path for a completed subtask
+SUBTASK_PATH=$(./.makima/orchestrate.sh worktree <subtask_id>)
+
+# View what files were changed
+ls -la "$SUBTASK_PATH"
+diff -r . "$SUBTASK_PATH" --exclude=.git --exclude=.makima
+
+# Copy specific files from subtask
+cp "$SUBTASK_PATH/src/new_file.rs" ./src/
+cp "$SUBTASK_PATH/src/modified_file.rs" ./src/
+
+# Or use diff/patch for more control
+diff -u ./src/file.rs "$SUBTASK_PATH/src/file.rs" > changes.patch
+patch -p0 < changes.patch
+```
+
+For continuation chains (only integrate the final task):
+```bash
+# If you have: Task A → Task B → Task C (each continues from previous)
+# ONLY get and integrate Task C's worktree - it has everything!
+
+FINAL_TASK_PATH=$(./.makima/orchestrate.sh worktree <task_c_id>)
+
+# Copy all changes from the final task
+rsync -av --exclude='.git' --exclude='.makima' "$FINAL_TASK_PATH/" ./
+```
+
+## Completion
+```bash
+# Mark yourself as complete after integrating all subtask work
+./.makima/orchestrate.sh done "Summary of what was accomplished"
+```
+
+## Workflow
+1. **List existing subtasks**: Run `list` to see current subtasks
+2. **Create subtasks if needed**: Use `create` to add new subtasks for the work
+ - For independent parallel work: create without `--continue-from`
+ - For sequential dependencies: use `--continue-from <previous_task_id>`
+ - Track which tasks continue from which (continuation chains)
+3. **Start subtasks**: Run `start` for each subtask
+4. **Monitor progress**: Check status and output as subtasks run
+5. **Integrate work**: When subtasks complete:
+ - For independent tasks: integrate each one's worktree
+ - For continuation chains: ONLY integrate the FINAL task (it has all changes)
+ - Get worktree path with `worktree <subtask_id>`
+ - Copy or merge files into your worktree
+6. **Complete**: Call `done` once all work is integrated
+
+## Important Notes
+- Subtask files are in worktrees, NOT committed git branches
+- **Subtasks do NOT auto-merge or create PRs** - you must integrate their work
+- You can read files from subtask worktrees using their paths
+- Use standard file tools (cp, diff, cat, rsync) to integrate changes
+- You should NOT edit files directly - that's what subtasks are for
+- DO NOT DO THE SUBTASKS' WORK! Your only job is to coordinate, not implement.
+- When you call `done`, YOUR worktree may be used for the final PR/merge
+"#;
+
+/// Content of the helper bash script that orchestrators use to call the API.
+const ORCHESTRATE_SCRIPT: &str = r#"#!/bin/bash
+# Makima Orchestrator Helper Script
+# Usage: ./orchestrate.sh <command> [args...]
+
+API_URL="${MAKIMA_API_URL:-http://localhost:8080}"
+API_KEY="${MAKIMA_API_KEY}"
+TASK_ID="${MAKIMA_TASK_ID}"
+
+if [ -z "$API_KEY" ]; then
+ echo "Error: MAKIMA_API_KEY not set" >&2
+ exit 1
+fi
+
+if [ -z "$TASK_ID" ]; then
+ echo "Error: MAKIMA_TASK_ID not set" >&2
+ exit 1
+fi
+
+# Helper function to make API calls and check for errors
+api_call() {
+ local method="$1"
+ local url="$2"
+ local data="$3"
+ local response
+ local http_code
+
+ if [ -n "$data" ]; then
+ response=$(curl -s -w "\n%{http_code}" -X "$method" \
+ -H "X-Makima-Tool-Key: $API_KEY" \
+ -H "Content-Type: application/json" \
+ -d "$data" \
+ "$url")
+ else
+ response=$(curl -s -w "\n%{http_code}" -X "$method" \
+ -H "X-Makima-Tool-Key: $API_KEY" \
+ "$url")
+ fi
+
+ # Extract HTTP code (last line) and body (everything else)
+ http_code=$(echo "$response" | tail -n1)
+ body=$(echo "$response" | sed '$d')
+
+ # Check for curl errors or non-2xx status
+ if [ "$http_code" -lt 200 ] || [ "$http_code" -ge 300 ]; then
+ echo "Error: API request failed with HTTP $http_code" >&2
+ echo "URL: $url" >&2
+ echo "Response: $body" >&2
+ echo "$body"
+ return 1
+ fi
+
+ echo "$body"
+ return 0
+}
+
+case "$1" in
+ list)
+ api_call GET "$API_URL/api/v1/mesh/tasks/$TASK_ID/subtasks"
+ ;;
+ create)
+ # Parse arguments: create "name" "plan" [--continue-from <task_id>] [--files "file1,file2"]
+ if [ -z "$2" ] || [ -z "$3" ]; then
+ echo "Usage: $0 create \"<name>\" \"<plan>\" [--continue-from <task_id>] [--files \"file1,file2\"]" >&2
+ exit 1
+ fi
+ NAME="$2"
+ PLAN="$3"
+ CONTINUE_FROM=""
+ COPY_FILES=""
+
+ # Parse optional flags (can be in any order after name and plan)
+ shift 3
+ while [ $# -gt 0 ]; do
+ case "$1" in
+ --continue-from)
+ CONTINUE_FROM="$2"
+ shift 2
+ ;;
+ --files)
+ COPY_FILES="$2"
+ shift 2
+ ;;
+ *)
+ echo "Unknown option: $1" >&2
+ exit 1
+ ;;
+ esac
+ done
+
+ # Escape quotes in name and plan for JSON
+ NAME_ESCAPED=$(echo "$NAME" | sed 's/"/\\"/g' | sed 's/\\/\\\\/g')
+ PLAN_ESCAPED=$(echo "$PLAN" | sed 's/"/\\"/g' | sed 's/\\/\\\\/g')
+
+ # Build JSON body
+ JSON_BODY="{\"name\":\"$NAME_ESCAPED\",\"plan\":\"$PLAN_ESCAPED\",\"parentTaskId\":\"$TASK_ID\""
+
+ if [ -n "$CONTINUE_FROM" ]; then
+ echo "Creating subtask: $NAME (continuing from $CONTINUE_FROM)..." >&2
+ JSON_BODY="$JSON_BODY,\"continueFromTaskId\":\"$CONTINUE_FROM\""
+ else
+ echo "Creating subtask: $NAME..." >&2
+ fi
+
+ if [ -n "$COPY_FILES" ]; then
+ # Convert comma-separated file list to JSON array
+ FILES_JSON="["
+ first=true
+ IFS=',' read -ra FILE_ARRAY <<< "$COPY_FILES"
+ for file in "${FILE_ARRAY[@]}"; do
+ file=$(echo "$file" | xargs) # trim whitespace
+ if [ "$first" = true ]; then
+ FILES_JSON="$FILES_JSON\"$file\""
+ first=false
+ else
+ FILES_JSON="$FILES_JSON,\"$file\""
+ fi
+ done
+ FILES_JSON="$FILES_JSON]"
+ JSON_BODY="$JSON_BODY,\"copyFiles\":$FILES_JSON"
+ echo " (copying files: $COPY_FILES)" >&2
+ fi
+
+ JSON_BODY="$JSON_BODY}"
+ api_call POST "$API_URL/api/v1/mesh/tasks" "$JSON_BODY"
+ ;;
+ start)
+ if [ -z "$2" ]; then
+ echo "Usage: $0 start <subtask_id>" >&2
+ exit 1
+ fi
+ echo "Starting subtask $2..." >&2
+ api_call POST "$API_URL/api/v1/mesh/tasks/$2/start"
+ ;;
+ stop)
+ if [ -z "$2" ]; then
+ echo "Usage: $0 stop <subtask_id>" >&2
+ exit 1
+ fi
+ api_call POST "$API_URL/api/v1/mesh/tasks/$2/stop"
+ ;;
+ status)
+ if [ -z "$2" ]; then
+ echo "Usage: $0 status <subtask_id>" >&2
+ exit 1
+ fi
+ api_call GET "$API_URL/api/v1/mesh/tasks/$2"
+ ;;
+ output)
+ if [ -z "$2" ]; then
+ echo "Usage: $0 output <subtask_id>" >&2
+ exit 1
+ fi
+ api_call GET "$API_URL/api/v1/mesh/tasks/$2/output"
+ ;;
+ worktree)
+ if [ -z "$2" ]; then
+ echo "Usage: $0 worktree <subtask_id>" >&2
+ exit 1
+ fi
+ # Get the worktree path from the task's overlayPath field via API
+ TASK_JSON=$(api_call GET "$API_URL/api/v1/mesh/tasks/$2")
+ if [ $? -ne 0 ]; then
+ echo "Error: Failed to get task info" >&2
+ exit 1
+ fi
+ WORKTREE_PATH=$(echo "$TASK_JSON" | grep -o '"overlayPath":"[^"]*"' | cut -d'"' -f4)
+ if [ -z "$WORKTREE_PATH" ]; then
+ echo "Error: Task has no worktree path (may not have started yet)" >&2
+ exit 1
+ fi
+ if [ -d "$WORKTREE_PATH" ]; then
+ echo "$WORKTREE_PATH"
+ else
+ echo "Error: Worktree not found at $WORKTREE_PATH" >&2
+ echo "The worktree may have been cleaned up." >&2
+ exit 1
+ fi
+ ;;
+ done)
+ SUMMARY="${2:-Task completed}"
+ api_call PUT "$API_URL/api/v1/mesh/tasks/$TASK_ID" "{\"status\":\"done\",\"progressSummary\":\"$SUMMARY\"}"
+ ;;
+ *)
+ echo "Makima Orchestrator Helper"
+ echo ""
+ echo "Usage: $0 <command> [args...]"
+ echo ""
+ echo "Subtask Commands:"
+ echo " list List all subtasks and their status"
+ echo " create \"<name>\" \"<plan>\" Create a new subtask"
+ echo " create \"...\" --continue-from ID Create subtask continuing from another task's worktree"
+ echo " create \"...\" --files \"file1,file2\" Copy specific files from parent (orchestrator) worktree"
+ echo " start <subtask_id> Start a subtask"
+ echo " stop <subtask_id> Stop a running subtask"
+ echo " status <subtask_id> Get detailed subtask status"
+ echo " output <subtask_id> Get subtask output history"
+ echo " worktree <subtask_id> Get path to subtask's worktree"
+ echo ""
+ echo "Completion:"
+ echo " done [summary] Mark orchestrator as complete"
+ echo ""
+ echo "Examples:"
+ echo " create \"Fix bug\" \"Fix the null check bug\" --files \"PLAN.md\""
+ echo " create \"Step 2\" \"Continue work\" --continue-from abc123 --files \"shared.rs,types.rs\""
+ ;;
+esac
+"#;
+
+/// Tracks merge state for an orchestrator task.
+#[derive(Default)]
+struct MergeTracker {
+ /// Subtask branches that have been successfully merged.
+ merged_subtasks: HashSet<Uuid>,
+ /// Subtask branches that were explicitly skipped (with reason).
+ skipped_subtasks: HashMap<Uuid, String>,
+}
+
+/// Managed task information.
+pub struct ManagedTask {
+ /// Task ID.
+ pub id: Uuid,
+ /// Current state.
+ pub state: TaskState,
+ /// Worktree info if created.
+ pub worktree: Option<WorktreeInfo>,
+ /// Task plan.
+ pub plan: String,
+ /// Repository URL or path.
+ pub repo_source: Option<String>,
+ /// Base branch.
+ pub base_branch: Option<String>,
+ /// Target branch to merge into.
+ pub target_branch: Option<String>,
+ /// Parent task ID if this is a subtask.
+ pub parent_task_id: Option<Uuid>,
+ /// Depth in task hierarchy (0=top-level, 1=subtask, 2=sub-subtask).
+ pub depth: i32,
+ /// Whether this task runs as an orchestrator (coordinates subtasks).
+ pub is_orchestrator: bool,
+ /// Path to target repository for completion actions.
+ pub target_repo_path: Option<String>,
+ /// Completion action: "none", "branch", "merge", "pr".
+ pub completion_action: Option<String>,
+ /// Task ID to continue from (copy worktree from this task).
+ pub continue_from_task_id: Option<Uuid>,
+ /// Files to copy from parent task's worktree.
+ pub copy_files: Option<Vec<String>>,
+ /// Time task was created.
+ pub created_at: Instant,
+ /// Time task started running.
+ pub started_at: Option<Instant>,
+ /// Time task completed.
+ pub completed_at: Option<Instant>,
+ /// Error message if failed.
+ pub error: Option<String>,
+}
+
+/// Configuration for task execution.
+#[derive(Clone)]
+pub struct TaskConfig {
+ /// Maximum concurrent tasks.
+ pub max_concurrent_tasks: u32,
+ /// Base directory for worktrees.
+ pub worktree_base_dir: PathBuf,
+ /// Environment variables to pass to Claude.
+ pub env_vars: HashMap<String, String>,
+ /// Claude command path.
+ pub claude_command: String,
+ /// Additional arguments to pass to Claude Code.
+ pub claude_args: Vec<String>,
+ /// Arguments to pass before defaults.
+ pub claude_pre_args: Vec<String>,
+ /// Enable Claude's permission system.
+ pub enable_permissions: bool,
+ /// Disable verbose output.
+ pub disable_verbose: bool,
+}
+
+impl Default for TaskConfig {
+ fn default() -> Self {
+ Self {
+ max_concurrent_tasks: 4,
+ worktree_base_dir: WorktreeManager::default_base_dir(),
+ env_vars: HashMap::new(),
+ claude_command: "claude".to_string(),
+ claude_args: Vec::new(),
+ claude_pre_args: Vec::new(),
+ enable_permissions: false,
+ disable_verbose: false,
+ }
+ }
+}
+
+/// Task manager for handling task lifecycle.
+pub struct TaskManager {
+ /// Worktree manager.
+ worktree_manager: Arc<WorktreeManager>,
+ /// Process manager.
+ process_manager: Arc<ProcessManager>,
+ /// Temp directory manager.
+ temp_manager: Arc<TempManager>,
+ /// Task configuration.
+ #[allow(dead_code)]
+ config: TaskConfig,
+ /// Active tasks.
+ tasks: Arc<RwLock<HashMap<Uuid, ManagedTask>>>,
+ /// Channel to send messages to server.
+ ws_tx: mpsc::Sender<DaemonMessage>,
+ /// Semaphore for limiting concurrent tasks.
+ semaphore: Arc<Semaphore>,
+ /// Channels for sending input to running tasks.
+ /// Each sender allows sending messages to the stdin of a running Claude process.
+ task_inputs: Arc<RwLock<HashMap<Uuid, mpsc::Sender<String>>>>,
+ /// Tracks merge state per orchestrator task (for completion gate).
+ merge_trackers: Arc<RwLock<HashMap<Uuid, MergeTracker>>>,
+}
+
+impl TaskManager {
+ /// Create a new task manager.
+ pub fn new(config: TaskConfig, ws_tx: mpsc::Sender<DaemonMessage>) -> Self {
+ let max_concurrent = config.max_concurrent_tasks as usize;
+ let worktree_manager = Arc::new(WorktreeManager::new(config.worktree_base_dir.clone()));
+ let process_manager = Arc::new(
+ ProcessManager::with_command(config.claude_command.clone())
+ .with_args(config.claude_args.clone())
+ .with_pre_args(config.claude_pre_args.clone())
+ .with_permissions_enabled(config.enable_permissions)
+ .with_verbose_disabled(config.disable_verbose)
+ .with_env(config.env_vars.clone()),
+ );
+ let temp_manager = Arc::new(TempManager::new());
+
+ Self {
+ worktree_manager,
+ process_manager,
+ temp_manager,
+ config,
+ tasks: Arc::new(RwLock::new(HashMap::new())),
+ ws_tx,
+ semaphore: Arc::new(Semaphore::new(max_concurrent)),
+ task_inputs: Arc::new(RwLock::new(HashMap::new())),
+ merge_trackers: Arc::new(RwLock::new(HashMap::new())),
+ }
+ }
+
+ /// Handle a command from the server.
+ pub async fn handle_command(&self, command: DaemonCommand) -> Result<(), DaemonError> {
+ tracing::info!("Received command from server: {:?}", command);
+
+ match command {
+ DaemonCommand::SpawnTask {
+ task_id,
+ task_name,
+ plan,
+ repo_url,
+ base_branch,
+ target_branch,
+ parent_task_id,
+ depth,
+ is_orchestrator,
+ target_repo_path,
+ completion_action,
+ continue_from_task_id,
+ copy_files,
+ } => {
+ tracing::info!(
+ task_id = %task_id,
+ task_name = %task_name,
+ repo_url = ?repo_url,
+ base_branch = ?base_branch,
+ target_branch = ?target_branch,
+ parent_task_id = ?parent_task_id,
+ depth = depth,
+ is_orchestrator = is_orchestrator,
+ target_repo_path = ?target_repo_path,
+ completion_action = ?completion_action,
+ continue_from_task_id = ?continue_from_task_id,
+ copy_files = ?copy_files,
+ plan_len = plan.len(),
+ "Spawning new task"
+ );
+ self.spawn_task(
+ task_id, task_name, plan, repo_url, base_branch, target_branch,
+ parent_task_id, depth, is_orchestrator,
+ target_repo_path, completion_action, continue_from_task_id,
+ copy_files
+ ).await?;
+ }
+ DaemonCommand::PauseTask { task_id } => {
+ tracing::info!(task_id = %task_id, "Pause not supported for subprocess tasks");
+ // Subprocesses don't support pause, just log and ignore
+ }
+ DaemonCommand::ResumeTask { task_id } => {
+ tracing::info!(task_id = %task_id, "Resume not supported for subprocess tasks");
+ // Subprocesses don't support resume, just log and ignore
+ }
+ DaemonCommand::InterruptTask { task_id, graceful: _ } => {
+ tracing::info!(task_id = %task_id, "Interrupting task");
+ self.interrupt_task(task_id).await?;
+ }
+ DaemonCommand::SendMessage { task_id, message } => {
+ tracing::info!(task_id = %task_id, message_len = message.len(), "Sending message to task");
+ // Send message to the task's stdin via the input channel
+ let inputs = self.task_inputs.read().await;
+ if let Some(sender) = inputs.get(&task_id) {
+ if let Err(e) = sender.send(message).await {
+ tracing::warn!(task_id = %task_id, error = %e, "Failed to send message to task input channel");
+ } else {
+ tracing::info!(task_id = %task_id, "Message sent to task successfully");
+ }
+ } else {
+ tracing::warn!(task_id = %task_id, "No input channel for task (task may not be running)");
+ }
+ }
+ DaemonCommand::InjectSiblingContext { task_id, .. } => {
+ tracing::debug!(task_id = %task_id, "Sibling context injection not supported for subprocess tasks");
+ }
+ DaemonCommand::Authenticated { daemon_id } => {
+ tracing::debug!(daemon_id = %daemon_id, "Authenticated command (handled by WS client)");
+ }
+ DaemonCommand::Error { code, message } => {
+ tracing::warn!(code = %code, message = %message, "Error command from server");
+ }
+
+ // =========================================================================
+ // Merge Commands
+ // =========================================================================
+
+ DaemonCommand::ListBranches { task_id } => {
+ tracing::info!(task_id = %task_id, "Listing task branches");
+ self.handle_list_branches(task_id).await?;
+ }
+ DaemonCommand::MergeStart { task_id, source_branch } => {
+ tracing::info!(task_id = %task_id, source_branch = %source_branch, "Starting merge");
+ self.handle_merge_start(task_id, source_branch).await?;
+ }
+ DaemonCommand::MergeStatus { task_id } => {
+ tracing::info!(task_id = %task_id, "Getting merge status");
+ self.handle_merge_status(task_id).await?;
+ }
+ DaemonCommand::MergeResolve { task_id, file, strategy } => {
+ tracing::info!(task_id = %task_id, file = %file, strategy = %strategy, "Resolving conflict");
+ self.handle_merge_resolve(task_id, file, strategy).await?;
+ }
+ DaemonCommand::MergeCommit { task_id, message } => {
+ tracing::info!(task_id = %task_id, "Committing merge");
+ self.handle_merge_commit(task_id, message).await?;
+ }
+ DaemonCommand::MergeAbort { task_id } => {
+ tracing::info!(task_id = %task_id, "Aborting merge");
+ self.handle_merge_abort(task_id).await?;
+ }
+ DaemonCommand::MergeSkip { task_id, subtask_id, reason } => {
+ tracing::info!(task_id = %task_id, subtask_id = %subtask_id, reason = %reason, "Skipping subtask merge");
+ self.handle_merge_skip(task_id, subtask_id, reason).await?;
+ }
+ DaemonCommand::CheckMergeComplete { task_id } => {
+ tracing::info!(task_id = %task_id, "Checking merge completion");
+ self.handle_check_merge_complete(task_id).await?;
+ }
+
+ // =========================================================================
+ // Completion Action Commands
+ // =========================================================================
+
+ DaemonCommand::RetryCompletionAction {
+ task_id,
+ task_name,
+ action,
+ target_repo_path,
+ target_branch,
+ } => {
+ tracing::info!(
+ task_id = %task_id,
+ task_name = %task_name,
+ action = %action,
+ target_repo_path = %target_repo_path,
+ target_branch = ?target_branch,
+ "Retrying completion action"
+ );
+ self.handle_retry_completion_action(task_id, task_name, action, target_repo_path, target_branch).await?;
+ }
+
+ DaemonCommand::CloneWorktree { task_id, target_dir } => {
+ tracing::info!(
+ task_id = %task_id,
+ target_dir = %target_dir,
+ "Cloning worktree to target directory"
+ );
+ self.handle_clone_worktree(task_id, target_dir).await?;
+ }
+
+ DaemonCommand::CheckTargetExists { task_id, target_dir } => {
+ tracing::debug!(
+ task_id = %task_id,
+ target_dir = %target_dir,
+ "Checking if target directory exists"
+ );
+ self.handle_check_target_exists(task_id, target_dir).await?;
+ }
+ }
+ Ok(())
+ }
+
+ /// Spawn a new task.
+ #[allow(clippy::too_many_arguments)]
+ pub async fn spawn_task(
+ &self,
+ task_id: Uuid,
+ task_name: String,
+ plan: String,
+ repo_url: Option<String>,
+ base_branch: Option<String>,
+ target_branch: Option<String>,
+ parent_task_id: Option<Uuid>,
+ depth: i32,
+ is_orchestrator: bool,
+ target_repo_path: Option<String>,
+ completion_action: Option<String>,
+ continue_from_task_id: Option<Uuid>,
+ copy_files: Option<Vec<String>>,
+ ) -> TaskResult<()> {
+ tracing::info!(task_id = %task_id, is_orchestrator = is_orchestrator, depth = depth, "=== SPAWN_TASK START ===");
+
+ // Check if task already exists - allow re-spawning if in terminal state
+ {
+ let mut tasks = self.tasks.write().await;
+ if let Some(existing) = tasks.get(&task_id) {
+ if existing.state.is_terminal() {
+ // Task exists but is in terminal state (completed, failed, interrupted)
+ // Remove it so we can re-spawn
+ tracing::info!(task_id = %task_id, old_state = ?existing.state, "Removing terminated task to allow re-spawn");
+ tasks.remove(&task_id);
+ } else {
+ // Task is still active, reject
+ tracing::warn!(task_id = %task_id, state = ?existing.state, "Task already exists and is active, rejecting spawn");
+ return Err(TaskError::AlreadyExists(task_id));
+ }
+ }
+ }
+
+ // Acquire semaphore permit
+ tracing::info!(task_id = %task_id, "Acquiring concurrency permit...");
+ let permit = self
+ .semaphore
+ .clone()
+ .try_acquire_owned()
+ .map_err(|_| {
+ tracing::warn!(task_id = %task_id, "Concurrency limit reached, cannot spawn task");
+ TaskError::ConcurrencyLimit
+ })?;
+ tracing::info!(task_id = %task_id, "Concurrency permit acquired");
+
+ // Create task entry
+ tracing::info!(task_id = %task_id, "Creating task entry in state: Initializing");
+ let task = ManagedTask {
+ id: task_id,
+ state: TaskState::Initializing,
+ worktree: None,
+ plan: plan.clone(),
+ repo_source: repo_url.clone(),
+ base_branch: base_branch.clone(),
+ target_branch: target_branch.clone(),
+ parent_task_id,
+ depth,
+ is_orchestrator,
+ target_repo_path: target_repo_path.clone(),
+ completion_action: completion_action.clone(),
+ continue_from_task_id,
+ copy_files: copy_files.clone(),
+ created_at: Instant::now(),
+ started_at: None,
+ completed_at: None,
+ error: None,
+ };
+
+ self.tasks.write().await.insert(task_id, task);
+ tracing::info!(task_id = %task_id, "Task entry created and stored");
+
+ // Notify server of status change
+ tracing::info!(task_id = %task_id, "Notifying server: pending -> initializing");
+ self.send_status_change(task_id, "pending", "initializing").await;
+
+ // Spawn task in background
+ tracing::info!(task_id = %task_id, "Spawning background task runner");
+ let inner = self.clone_inner();
+ tokio::spawn(async move {
+ let _permit = permit; // Hold permit until done
+ tracing::info!(task_id = %task_id, "Background task runner started");
+
+ if let Err(e) = inner.run_task(
+ task_id, task_name, plan, repo_url, base_branch, target_branch,
+ is_orchestrator, target_repo_path, completion_action,
+ continue_from_task_id, copy_files
+ ).await {
+ tracing::error!(task_id = %task_id, error = %e, "Task execution failed");
+ inner.mark_failed(task_id, &e.to_string()).await;
+ }
+ tracing::info!(task_id = %task_id, "Background task runner completed");
+ });
+
+ tracing::info!(task_id = %task_id, "=== SPAWN_TASK END (task running in background) ===");
+ Ok(())
+ }
+
+ /// Clone inner state for spawned tasks.
+ fn clone_inner(&self) -> TaskManagerInner {
+ TaskManagerInner {
+ worktree_manager: self.worktree_manager.clone(),
+ process_manager: self.process_manager.clone(),
+ temp_manager: self.temp_manager.clone(),
+ tasks: self.tasks.clone(),
+ ws_tx: self.ws_tx.clone(),
+ task_inputs: self.task_inputs.clone(),
+ }
+ }
+
+ /// Interrupt a task.
+ pub async fn interrupt_task(&self, task_id: Uuid) -> TaskResult<()> {
+ let mut tasks = self.tasks.write().await;
+ let task = tasks.get_mut(&task_id).ok_or(TaskError::NotFound(task_id))?;
+
+ if task.state.is_terminal() {
+ return Ok(()); // Already done
+ }
+
+ let old_state = task.state;
+ task.state = TaskState::Interrupted;
+ task.completed_at = Some(Instant::now());
+
+ // Notify server
+ drop(tasks);
+ self.send_status_change(task_id, old_state.as_str(), "interrupted").await;
+
+ // Note: The process will be killed when the ClaudeProcess is dropped
+ // Worktrees are kept until explicitly deleted
+
+ Ok(())
+ }
+
+ /// Get list of active task IDs.
+ pub async fn active_task_ids(&self) -> Vec<Uuid> {
+ self.tasks
+ .read()
+ .await
+ .iter()
+ .filter(|(_, t)| t.state.is_active())
+ .map(|(id, _)| *id)
+ .collect()
+ }
+
+ /// Get task state.
+ pub async fn get_task_state(&self, task_id: Uuid) -> Option<TaskState> {
+ self.tasks.read().await.get(&task_id).map(|t| t.state)
+ }
+
+ /// Send status change notification to server.
+ async fn send_status_change(&self, task_id: Uuid, old_status: &str, new_status: &str) {
+ let msg = DaemonMessage::task_status_change(task_id, old_status, new_status);
+ let _ = self.ws_tx.send(msg).await;
+ }
+
+ // =========================================================================
+ // Merge Handler Methods
+ // =========================================================================
+
+ /// Get worktree path for a task, or return error if not found.
+ /// First checks in-memory tasks, then scans the worktrees directory.
+ async fn get_task_worktree_path(&self, task_id: Uuid) -> Result<std::path::PathBuf, DaemonError> {
+ // First try to get from in-memory tasks
+ {
+ let tasks = self.tasks.read().await;
+ if let Some(task) = tasks.get(&task_id) {
+ if let Some(ref worktree) = task.worktree {
+ return Ok(worktree.path.clone());
+ }
+ }
+ }
+
+ // Task not in memory - scan worktrees directory for matching task ID
+ let short_id = &task_id.to_string()[..8];
+ let worktrees_dir = self.worktree_manager.base_dir();
+
+ if let Ok(mut entries) = tokio::fs::read_dir(worktrees_dir).await {
+ while let Ok(Some(entry)) = entries.next_entry().await {
+ let name = entry.file_name();
+ let name_str = name.to_string_lossy();
+ if name_str.starts_with(short_id) {
+ let path = entry.path();
+ // Verify it's a valid git directory
+ if path.join(".git").exists() {
+ tracing::info!(
+ task_id = %task_id,
+ worktree_path = %path.display(),
+ "Found worktree by scanning directory"
+ );
+ return Ok(path);
+ }
+ }
+ }
+ }
+
+ Err(DaemonError::Task(TaskError::SetupFailed(
+ format!("No worktree found for task {}. The worktree may have been cleaned up.", task_id)
+ )))
+ }
+
+ /// Handle ListBranches command.
+ async fn handle_list_branches(&self, task_id: Uuid) -> Result<(), DaemonError> {
+ let worktree_path = self.get_task_worktree_path(task_id).await?;
+
+ match self.worktree_manager.list_task_branches(&worktree_path).await {
+ Ok(branches) => {
+ let branch_infos: Vec<BranchInfo> = branches
+ .into_iter()
+ .map(|b| BranchInfo {
+ name: b.name,
+ task_id: b.task_id,
+ is_merged: b.is_merged,
+ last_commit: b.last_commit,
+ last_commit_message: b.last_commit_message,
+ })
+ .collect();
+
+ let msg = DaemonMessage::BranchList {
+ task_id,
+ branches: branch_infos,
+ };
+ let _ = self.ws_tx.send(msg).await;
+ }
+ Err(e) => {
+ tracing::error!(task_id = %task_id, error = %e, "Failed to list branches");
+ let msg = DaemonMessage::MergeResult {
+ task_id,
+ success: false,
+ message: e.to_string(),
+ commit_sha: None,
+ conflicts: None,
+ };
+ let _ = self.ws_tx.send(msg).await;
+ }
+ }
+ Ok(())
+ }
+
+ /// Handle MergeStart command.
+ async fn handle_merge_start(&self, task_id: Uuid, source_branch: String) -> Result<(), DaemonError> {
+ let worktree_path = self.get_task_worktree_path(task_id).await?;
+
+ match self.worktree_manager.merge_branch(&worktree_path, &source_branch).await {
+ Ok(None) => {
+ // Merge succeeded without conflicts
+ let msg = DaemonMessage::MergeResult {
+ task_id,
+ success: true,
+ message: "Merge completed without conflicts".to_string(),
+ commit_sha: None,
+ conflicts: None,
+ };
+ let _ = self.ws_tx.send(msg).await;
+ }
+ Ok(Some(conflicts)) => {
+ // Merge has conflicts
+ let msg = DaemonMessage::MergeResult {
+ task_id,
+ success: false,
+ message: format!("Merge has {} conflicts", conflicts.len()),
+ commit_sha: None,
+ conflicts: Some(conflicts),
+ };
+ let _ = self.ws_tx.send(msg).await;
+ }
+ Err(e) => {
+ let msg = DaemonMessage::MergeResult {
+ task_id,
+ success: false,
+ message: e.to_string(),
+ commit_sha: None,
+ conflicts: None,
+ };
+ let _ = self.ws_tx.send(msg).await;
+ }
+ }
+ Ok(())
+ }
+
+ /// Handle MergeStatus command.
+ async fn handle_merge_status(&self, task_id: Uuid) -> Result<(), DaemonError> {
+ let worktree_path = self.get_task_worktree_path(task_id).await?;
+
+ match self.worktree_manager.get_merge_state(&worktree_path).await {
+ Ok(state) => {
+ let msg = DaemonMessage::MergeStatusResponse {
+ task_id,
+ in_progress: state.in_progress,
+ source_branch: if state.in_progress { Some(state.source_branch) } else { None },
+ conflicted_files: state.conflicted_files,
+ };
+ let _ = self.ws_tx.send(msg).await;
+ }
+ Err(e) => {
+ tracing::error!(task_id = %task_id, error = %e, "Failed to get merge status");
+ let msg = DaemonMessage::MergeStatusResponse {
+ task_id,
+ in_progress: false,
+ source_branch: None,
+ conflicted_files: vec![],
+ };
+ let _ = self.ws_tx.send(msg).await;
+ }
+ }
+ Ok(())
+ }
+
+ /// Handle MergeResolve command.
+ async fn handle_merge_resolve(&self, task_id: Uuid, file: String, strategy: String) -> Result<(), DaemonError> {
+ let worktree_path = self.get_task_worktree_path(task_id).await?;
+
+ let resolution = match strategy.to_lowercase().as_str() {
+ "ours" => ConflictResolution::Ours,
+ "theirs" => ConflictResolution::Theirs,
+ _ => {
+ let msg = DaemonMessage::MergeResult {
+ task_id,
+ success: false,
+ message: format!("Invalid strategy '{}', must be 'ours' or 'theirs'", strategy),
+ commit_sha: None,
+ conflicts: None,
+ };
+ let _ = self.ws_tx.send(msg).await;
+ return Ok(());
+ }
+ };
+
+ match self.worktree_manager.resolve_conflict(&worktree_path, &file, resolution).await {
+ Ok(()) => {
+ let msg = DaemonMessage::MergeResult {
+ task_id,
+ success: true,
+ message: format!("Resolved conflict in {}", file),
+ commit_sha: None,
+ conflicts: None,
+ };
+ let _ = self.ws_tx.send(msg).await;
+ }
+ Err(e) => {
+ let msg = DaemonMessage::MergeResult {
+ task_id,
+ success: false,
+ message: e.to_string(),
+ commit_sha: None,
+ conflicts: None,
+ };
+ let _ = self.ws_tx.send(msg).await;
+ }
+ }
+ Ok(())
+ }
+
+ /// Handle MergeCommit command.
+ async fn handle_merge_commit(&self, task_id: Uuid, message: String) -> Result<(), DaemonError> {
+ let worktree_path = self.get_task_worktree_path(task_id).await?;
+
+ match self.worktree_manager.commit_merge(&worktree_path, &message).await {
+ Ok(commit_sha) => {
+ // Track this merge as completed (extract subtask ID from branch if possible)
+ // For now, we'll track it when MergeSkip is called or based on branch names
+ let msg = DaemonMessage::MergeResult {
+ task_id,
+ success: true,
+ message: "Merge committed successfully".to_string(),
+ commit_sha: Some(commit_sha),
+ conflicts: None,
+ };
+ let _ = self.ws_tx.send(msg).await;
+ }
+ Err(e) => {
+ let msg = DaemonMessage::MergeResult {
+ task_id,
+ success: false,
+ message: e.to_string(),
+ commit_sha: None,
+ conflicts: None,
+ };
+ let _ = self.ws_tx.send(msg).await;
+ }
+ }
+ Ok(())
+ }
+
+ /// Handle MergeAbort command.
+ async fn handle_merge_abort(&self, task_id: Uuid) -> Result<(), DaemonError> {
+ let worktree_path = self.get_task_worktree_path(task_id).await?;
+
+ match self.worktree_manager.abort_merge(&worktree_path).await {
+ Ok(()) => {
+ let msg = DaemonMessage::MergeResult {
+ task_id,
+ success: true,
+ message: "Merge aborted".to_string(),
+ commit_sha: None,
+ conflicts: None,
+ };
+ let _ = self.ws_tx.send(msg).await;
+ }
+ Err(e) => {
+ let msg = DaemonMessage::MergeResult {
+ task_id,
+ success: false,
+ message: e.to_string(),
+ commit_sha: None,
+ conflicts: None,
+ };
+ let _ = self.ws_tx.send(msg).await;
+ }
+ }
+ Ok(())
+ }
+
+ /// Handle MergeSkip command.
+ async fn handle_merge_skip(&self, task_id: Uuid, subtask_id: Uuid, reason: String) -> Result<(), DaemonError> {
+ // Record that this subtask was skipped
+ {
+ let mut trackers = self.merge_trackers.write().await;
+ let tracker = trackers.entry(task_id).or_insert_with(MergeTracker::default);
+ tracker.skipped_subtasks.insert(subtask_id, reason.clone());
+ }
+
+ let msg = DaemonMessage::MergeResult {
+ task_id,
+ success: true,
+ message: format!("Subtask {} skipped: {}", subtask_id, reason),
+ commit_sha: None,
+ conflicts: None,
+ };
+ let _ = self.ws_tx.send(msg).await;
+ Ok(())
+ }
+
+ /// Handle CheckMergeComplete command.
+ async fn handle_check_merge_complete(&self, task_id: Uuid) -> Result<(), DaemonError> {
+ let worktree_path = self.get_task_worktree_path(task_id).await?;
+
+ // Get all task branches
+ let branches = match self.worktree_manager.list_task_branches(&worktree_path).await {
+ Ok(b) => b,
+ Err(e) => {
+ let msg = DaemonMessage::MergeCompleteCheck {
+ task_id,
+ can_complete: false,
+ unmerged_branches: vec![format!("Error listing branches: {}", e)],
+ merged_count: 0,
+ skipped_count: 0,
+ };
+ let _ = self.ws_tx.send(msg).await;
+ return Ok(());
+ }
+ };
+
+ // Get tracker state
+ let trackers = self.merge_trackers.read().await;
+ let empty_merged: HashSet<Uuid> = HashSet::new();
+ let empty_skipped: HashMap<Uuid, String> = HashMap::new();
+ let tracker = trackers.get(&task_id);
+ let merged_set = tracker.map(|t| &t.merged_subtasks).unwrap_or(&empty_merged);
+ let skipped_set = tracker.map(|t| &t.skipped_subtasks).unwrap_or(&empty_skipped);
+
+ let mut merged_count = 0u32;
+ let mut skipped_count = 0u32;
+ let mut unmerged_branches = Vec::new();
+
+ for branch in &branches {
+ if branch.is_merged {
+ merged_count += 1;
+ } else if let Some(subtask_id) = branch.task_id {
+ if merged_set.contains(&subtask_id) {
+ merged_count += 1;
+ } else if skipped_set.contains_key(&subtask_id) {
+ skipped_count += 1;
+ } else {
+ unmerged_branches.push(branch.name.clone());
+ }
+ } else {
+ // Branch without task ID - check if it's merged
+ unmerged_branches.push(branch.name.clone());
+ }
+ }
+
+ let can_complete = unmerged_branches.is_empty();
+
+ let msg = DaemonMessage::MergeCompleteCheck {
+ task_id,
+ can_complete,
+ unmerged_branches,
+ merged_count,
+ skipped_count,
+ };
+ let _ = self.ws_tx.send(msg).await;
+ Ok(())
+ }
+
+ /// Mark a subtask as merged in the tracker.
+ #[allow(dead_code)]
+ pub async fn mark_subtask_merged(&self, orchestrator_task_id: Uuid, subtask_id: Uuid) {
+ let mut trackers = self.merge_trackers.write().await;
+ let tracker = trackers.entry(orchestrator_task_id).or_insert_with(MergeTracker::default);
+ tracker.merged_subtasks.insert(subtask_id);
+ }
+
+ // =========================================================================
+ // Completion Action Handler Methods
+ // =========================================================================
+
+ /// Handle RetryCompletionAction command.
+ async fn handle_retry_completion_action(
+ &self,
+ task_id: Uuid,
+ task_name: String,
+ action: String,
+ target_repo_path: String,
+ target_branch: Option<String>,
+ ) -> Result<(), DaemonError> {
+ // Get the task's worktree path
+ let worktree_path = self.get_task_worktree_path(task_id).await?;
+
+ // Execute the completion action
+ let inner = self.clone_inner();
+ let result = inner.execute_completion_action(
+ task_id,
+ &task_name,
+ &worktree_path,
+ &action,
+ Some(target_repo_path.as_str()),
+ target_branch.as_deref(),
+ ).await;
+
+ // Send result back to server
+ let msg = match result {
+ Ok(pr_url) => DaemonMessage::CompletionActionResult {
+ task_id,
+ success: true,
+ message: match action.as_str() {
+ "branch" => format!("Branch pushed to {}", target_repo_path),
+ "merge" => format!("Merged into {}", target_branch.as_deref().unwrap_or("main")),
+ "pr" => format!("Pull request created"),
+ _ => format!("Completion action '{}' executed", action),
+ },
+ pr_url,
+ },
+ Err(e) => DaemonMessage::CompletionActionResult {
+ task_id,
+ success: false,
+ message: e,
+ pr_url: None,
+ },
+ };
+ let _ = self.ws_tx.send(msg).await;
+ Ok(())
+ }
+
+ /// Handle CloneWorktree command.
+ async fn handle_clone_worktree(
+ &self,
+ task_id: Uuid,
+ target_dir: String,
+ ) -> Result<(), DaemonError> {
+ // Get the task's worktree path
+ let worktree_path = self.get_task_worktree_path(task_id).await?;
+
+ // Expand tilde in target path
+ let target_path = crate::worktree::expand_tilde(&target_dir);
+
+ // Clone the worktree to target directory
+ let result = self.worktree_manager.clone_worktree_to_directory(
+ &worktree_path,
+ &target_path,
+ ).await;
+
+ // Send result back to server
+ let msg = match result {
+ Ok(message) => DaemonMessage::CloneWorktreeResult {
+ task_id,
+ success: true,
+ message,
+ target_dir: Some(target_path.to_string_lossy().to_string()),
+ },
+ Err(e) => DaemonMessage::CloneWorktreeResult {
+ task_id,
+ success: false,
+ message: e.to_string(),
+ target_dir: None,
+ },
+ };
+ let _ = self.ws_tx.send(msg).await;
+ Ok(())
+ }
+
+ /// Handle CheckTargetExists command.
+ async fn handle_check_target_exists(
+ &self,
+ task_id: Uuid,
+ target_dir: String,
+ ) -> Result<(), DaemonError> {
+ // Expand tilde in target path
+ let target_path = crate::worktree::expand_tilde(&target_dir);
+
+ // Check if target exists
+ let exists = self.worktree_manager.target_directory_exists(&target_path).await;
+
+ // Send result back to server
+ let msg = DaemonMessage::CheckTargetExistsResult {
+ task_id,
+ exists,
+ target_dir: target_path.to_string_lossy().to_string(),
+ };
+ let _ = self.ws_tx.send(msg).await;
+ Ok(())
+ }
+}
+
+/// Inner state for spawned tasks (cloneable).
+struct TaskManagerInner {
+ worktree_manager: Arc<WorktreeManager>,
+ process_manager: Arc<ProcessManager>,
+ temp_manager: Arc<TempManager>,
+ tasks: Arc<RwLock<HashMap<Uuid, ManagedTask>>>,
+ ws_tx: mpsc::Sender<DaemonMessage>,
+ task_inputs: Arc<RwLock<HashMap<Uuid, mpsc::Sender<String>>>>,
+}
+
+impl TaskManagerInner {
+ /// Run a task to completion.
+ #[allow(clippy::too_many_arguments)]
+ async fn run_task(
+ &self,
+ task_id: Uuid,
+ task_name: String,
+ plan: String,
+ repo_source: Option<String>,
+ base_branch: Option<String>,
+ target_branch: Option<String>,
+ is_orchestrator: bool,
+ target_repo_path: Option<String>,
+ completion_action: Option<String>,
+ continue_from_task_id: Option<Uuid>,
+ copy_files: Option<Vec<String>>,
+ ) -> Result<(), DaemonError> {
+ tracing::info!(task_id = %task_id, is_orchestrator = is_orchestrator, "=== RUN_TASK START ===");
+
+ // Determine working directory
+ let working_dir = if let Some(ref source) = repo_source {
+ if is_new_repo_request(source) {
+ // Explicit new repo request: new:// or new://project-name
+ tracing::info!(
+ task_id = %task_id,
+ source = %source,
+ "Creating new git repository"
+ );
+
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("Initializing new git repository...\n"),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+
+ let worktree_info = self.worktree_manager
+ .init_new_repo(task_id, source)
+ .await
+ .map_err(|e| DaemonError::Task(TaskError::SetupFailed(e.to_string())))?;
+
+ tracing::info!(
+ task_id = %task_id,
+ path = %worktree_info.path.display(),
+ "New repository created"
+ );
+
+ // Store worktree info
+ {
+ let mut tasks = self.tasks.write().await;
+ if let Some(task) = tasks.get_mut(&task_id) {
+ task.worktree = Some(worktree_info.clone());
+ }
+ }
+
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("Repository ready at {}\n", worktree_info.path.display()),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+
+ worktree_info.path
+ } else {
+ // Send progress message
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("Setting up worktree from {}...\n", source),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+
+ // Ensure source repo exists (clone if URL, verify if path)
+ let source_repo = self.worktree_manager.ensure_repo(source).await
+ .map_err(|e| DaemonError::Task(TaskError::SetupFailed(e.to_string())))?;
+
+ // Detect or use provided base branch
+ let branch = if let Some(ref b) = base_branch {
+ b.clone()
+ } else {
+ self.worktree_manager.detect_default_branch(&source_repo).await
+ .map_err(|e| DaemonError::Task(TaskError::SetupFailed(e.to_string())))?
+ };
+
+ tracing::info!(
+ task_id = %task_id,
+ source = %source,
+ branch = %branch,
+ continue_from_task_id = ?continue_from_task_id,
+ "Setting up worktree"
+ );
+
+ // Create worktree - either from scratch or copying from another task
+ let task_name = format!("task-{}", &task_id.to_string()[..8]);
+ let worktree_info = if let Some(from_task_id) = continue_from_task_id {
+ // Find the source task's worktree path
+ let source_worktree = self.find_worktree_for_task(from_task_id).await
+ .map_err(|e| DaemonError::Task(TaskError::SetupFailed(
+ format!("Cannot continue from task {}: {}", from_task_id, e)
+ )))?;
+
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("Continuing from task {} worktree...\n", &from_task_id.to_string()[..8]),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+
+ // Create worktree by copying from source task
+ self.worktree_manager
+ .create_worktree_from_task(&source_worktree, task_id, &task_name)
+ .await
+ .map_err(|e| DaemonError::Task(TaskError::SetupFailed(e.to_string())))?
+ } else {
+ // Create fresh worktree from repo
+ self.worktree_manager
+ .create_worktree(&source_repo, task_id, &task_name, &branch)
+ .await
+ .map_err(|e| DaemonError::Task(TaskError::SetupFailed(e.to_string())))?
+ };
+
+ tracing::info!(
+ task_id = %task_id,
+ worktree_path = %worktree_info.path.display(),
+ branch = %worktree_info.branch,
+ continued_from = ?continue_from_task_id,
+ "Worktree created"
+ );
+
+ // Store worktree info
+ {
+ let mut tasks = self.tasks.write().await;
+ if let Some(task) = tasks.get_mut(&task_id) {
+ task.worktree = Some(worktree_info.clone());
+ }
+ }
+
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("Worktree ready at {}\n", worktree_info.path.display()),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+
+ worktree_info.path
+ }
+ } else {
+ // No repo specified - use managed temp directory in ~/.makima/temp/
+ tracing::info!(task_id = %task_id, "Creating managed temp directory (no repo)");
+
+ let msg = DaemonMessage::task_output(
+ task_id,
+ "Creating temporary working directory...\n".to_string(),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+
+ let temp_dir = self.temp_manager.create_task_dir(task_id).await?;
+
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("Working directory ready at {}\n", temp_dir.display()),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+
+ temp_dir
+ };
+
+ // Copy files from parent task's worktree if specified
+ if let Some(ref files) = copy_files {
+ if !files.is_empty() {
+ // Get the parent task ID to find its worktree
+ let parent_task_id = {
+ let tasks = self.tasks.read().await;
+ tasks.get(&task_id).and_then(|t| t.parent_task_id)
+ };
+
+ if let Some(parent_id) = parent_task_id {
+ match self.find_worktree_for_task(parent_id).await {
+ Ok(parent_worktree) => {
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("Copying {} files from orchestrator...\n", files.len()),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+
+ for file_path in files {
+ let source = parent_worktree.join(file_path);
+ let dest = working_dir.join(file_path);
+
+ // Create parent directories if needed
+ if let Some(parent) = dest.parent() {
+ if let Err(e) = tokio::fs::create_dir_all(parent).await {
+ tracing::warn!(
+ task_id = %task_id,
+ file = %file_path,
+ error = %e,
+ "Failed to create parent directory for file"
+ );
+ continue;
+ }
+ }
+
+ // Copy the file
+ match tokio::fs::copy(&source, &dest).await {
+ Ok(_) => {
+ tracing::info!(
+ task_id = %task_id,
+ source = %source.display(),
+ dest = %dest.display(),
+ "Copied file from orchestrator"
+ );
+ }
+ Err(e) => {
+ tracing::warn!(
+ task_id = %task_id,
+ source = %source.display(),
+ dest = %dest.display(),
+ error = %e,
+ "Failed to copy file from orchestrator"
+ );
+ // Notify but don't fail - the file might be optional
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("Warning: Could not copy {}: {}\n", file_path, e),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+ }
+ }
+ }
+
+ let msg = DaemonMessage::task_output(
+ task_id,
+ "Files copied from orchestrator.\n".to_string(),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+ }
+ Err(e) => {
+ tracing::warn!(
+ task_id = %task_id,
+ parent_id = %parent_id,
+ error = %e,
+ "Could not find parent task worktree for file copying"
+ );
+ }
+ }
+ } else {
+ tracing::warn!(
+ task_id = %task_id,
+ "copy_files specified but no parent_task_id"
+ );
+ }
+ }
+ }
+
+ // Update state to Starting
+ tracing::info!(task_id = %task_id, "Updating state: Initializing -> Starting");
+ self.update_state(task_id, TaskState::Starting).await;
+ self.send_status_change(task_id, "initializing", "starting").await;
+
+ // Check Claude is available
+ match self.process_manager.check_claude_available().await {
+ Ok(version) => {
+ tracing::info!(task_id = %task_id, version = %version, "Claude Code available");
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("Claude Code {} ready\n", version),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+ }
+ Err(e) => {
+ let err_msg = format!("Claude Code not available: {}", e);
+ tracing::error!(task_id = %task_id, error = %err_msg);
+ return Err(DaemonError::Task(TaskError::SetupFailed(err_msg)));
+ }
+ }
+
+ // Set up orchestrator mode if needed
+ let (extra_env, full_plan) = if is_orchestrator {
+ tracing::info!(task_id = %task_id, working_dir = %working_dir.display(), "Setting up orchestrator mode");
+
+ let msg = DaemonMessage::task_output(
+ task_id,
+ "Setting up orchestrator environment...\n".to_string(),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+
+ // Generate tool key for API access
+ let tool_key = generate_tool_key();
+ tracing::info!(task_id = %task_id, tool_key_len = tool_key.len(), "Generated tool key for orchestrator");
+
+ // Register tool key with server
+ let register_msg = DaemonMessage::register_tool_key(task_id, tool_key.clone());
+ if self.ws_tx.send(register_msg).await.is_err() {
+ tracing::warn!(task_id = %task_id, "Failed to register tool key");
+ } else {
+ tracing::info!(task_id = %task_id, "Tool key registration message sent to server");
+ }
+
+ // Create .makima directory and helper script
+ let makima_dir = working_dir.join(".makima");
+ if let Err(e) = tokio::fs::create_dir_all(&makima_dir).await {
+ tracing::warn!(task_id = %task_id, makima_dir = %makima_dir.display(), "Failed to create .makima directory: {}", e);
+ } else {
+ tracing::info!(task_id = %task_id, makima_dir = %makima_dir.display(), "Created .makima directory");
+ }
+
+ let script_path = makima_dir.join("orchestrate.sh");
+ if let Err(e) = tokio::fs::write(&script_path, ORCHESTRATE_SCRIPT).await {
+ tracing::warn!(task_id = %task_id, script_path = %script_path.display(), "Failed to write orchestrate.sh: {}", e);
+ } else {
+ tracing::info!(task_id = %task_id, script_path = %script_path.display(), script_size = ORCHESTRATE_SCRIPT.len(), "Wrote orchestrate.sh");
+ // Make script executable
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ if let Err(e) = std::fs::set_permissions(&script_path, std::fs::Permissions::from_mode(0o755)) {
+ tracing::warn!(task_id = %task_id, "Failed to set script permissions: {}", e);
+ } else {
+ tracing::info!(task_id = %task_id, "Set orchestrate.sh executable (0o755)");
+ }
+ }
+ }
+
+ // Set up environment variables
+ let mut env = HashMap::new();
+ // TODO: Make API URL configurable
+ env.insert("MAKIMA_API_URL".to_string(), "http://localhost:8080".to_string());
+ env.insert("MAKIMA_API_KEY".to_string(), tool_key.clone());
+ env.insert("MAKIMA_TASK_ID".to_string(), task_id.to_string());
+
+ tracing::info!(
+ task_id = %task_id,
+ api_url = "http://localhost:8080",
+ tool_key_preview = &tool_key[..8.min(tool_key.len())],
+ "Set orchestrator environment variables"
+ );
+
+ // Prepend orchestrator instructions to the plan
+ let orchestrator_plan = format!(
+ "{}\n\n---\n\nYour task:\n{}",
+ ORCHESTRATOR_SYSTEM_PROMPT,
+ plan
+ );
+
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("Orchestrator environment ready (script at {})\n", script_path.display()),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+
+ (Some(env), orchestrator_plan)
+ } else {
+ tracing::info!(task_id = %task_id, "Running as regular subtask (not orchestrator)");
+ // Prepend subtask instructions to ensure worktree isolation
+ let subtask_plan = format!(
+ "{}\nYour task:\n{}",
+ SUBTASK_SYSTEM_PROMPT,
+ plan
+ );
+ (None, subtask_plan)
+ };
+
+ // Spawn Claude process
+ let plan_bytes = full_plan.len();
+ let plan_chars = full_plan.chars().count();
+ // Rough token estimate: ~4 chars per token for English
+ let estimated_tokens = plan_chars / 4;
+
+ tracing::info!(
+ task_id = %task_id,
+ working_dir = %working_dir.display(),
+ is_orchestrator = is_orchestrator,
+ plan_bytes = plan_bytes,
+ plan_chars = plan_chars,
+ estimated_tokens = estimated_tokens,
+ "Spawning Claude process"
+ );
+
+ // Warn if plan is very large (Claude's context is typically 100k-200k tokens)
+ if estimated_tokens > 50_000 {
+ tracing::warn!(task_id = %task_id, estimated_tokens = estimated_tokens, "Plan is very large - may hit context limits!");
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("Warning: Plan is very large (~{} tokens). This may cause issues.\n", estimated_tokens),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+ }
+
+ let msg = DaemonMessage::task_output(
+ task_id,
+ if is_orchestrator {
+ format!("Starting Claude Code (orchestrator mode, ~{} tokens)...\n", estimated_tokens)
+ } else {
+ format!("Starting Claude Code (~{} tokens)...\n", estimated_tokens)
+ },
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+
+ tracing::debug!(task_id = %task_id, "Calling process_manager.spawn()...");
+ let mut process = self.process_manager
+ .spawn(&working_dir, &full_plan, extra_env)
+ .await
+ .map_err(|e| {
+ tracing::error!(task_id = %task_id, error = %e, "Failed to spawn Claude process");
+ DaemonError::Task(TaskError::SetupFailed(e.to_string()))
+ })?;
+ tracing::info!(task_id = %task_id, "Claude process spawned successfully");
+
+ // Set up input channel for this task so we can send messages to its stdin
+ tracing::debug!(task_id = %task_id, "Setting up input channel...");
+ let (input_tx, mut input_rx) = mpsc::channel::<String>(100);
+ tracing::debug!(task_id = %task_id, "Acquiring task_inputs write lock...");
+ self.task_inputs.write().await.insert(task_id, input_tx);
+ tracing::debug!(task_id = %task_id, "Input channel registered");
+
+ // Get stdin handle for input forwarding and completion signaling
+ let stdin_handle = process.stdin_handle();
+ let stdin_handle_for_completion = stdin_handle.clone();
+
+ tracing::info!(task_id = %task_id, "Setting up stdin forwarder for task input (JSON protocol)");
+ tokio::spawn(async move {
+ tracing::info!(task_id = %task_id, "Stdin forwarder task started, waiting for messages...");
+ while let Some(msg) = input_rx.recv().await {
+ tracing::info!(task_id = %task_id, msg_len = msg.len(), msg_preview = %if msg.len() > 50 { &msg[..50] } else { &msg }, "Received message from input channel");
+
+ // Format as JSON user message for stream-json input protocol
+ let json_msg = ClaudeInputMessage::user(&msg);
+ let json_line = match json_msg.to_json_line() {
+ Ok(line) => line,
+ Err(e) => {
+ tracing::error!(task_id = %task_id, error = %e, "Failed to serialize input message");
+ continue;
+ }
+ };
+
+ tracing::debug!(task_id = %task_id, json_line = %json_line.trim(), "Formatted JSON line for stdin");
+
+ let mut stdin_guard = stdin_handle.lock().await;
+ if let Some(ref mut stdin) = *stdin_guard {
+ tracing::debug!(task_id = %task_id, "Acquired stdin lock, writing...");
+ if stdin.write_all(json_line.as_bytes()).await.is_err() {
+ tracing::warn!(task_id = %task_id, "Failed to write to stdin, breaking");
+ break;
+ }
+ if stdin.flush().await.is_err() {
+ tracing::warn!(task_id = %task_id, "Failed to flush stdin, breaking");
+ break;
+ }
+ tracing::info!(task_id = %task_id, json_len = json_line.len(), "Successfully wrote user message to Claude stdin");
+ } else {
+ tracing::warn!(task_id = %task_id, "Stdin is None (already closed), cannot send message");
+ break;
+ }
+ }
+ tracing::info!(task_id = %task_id, "Stdin forwarder task ended (channel closed or stdin unavailable)");
+ });
+
+ // Update state to Running
+ {
+ tracing::debug!(task_id = %task_id, "Acquiring tasks write lock for Running state update");
+ let mut tasks = self.tasks.write().await;
+ if let Some(task) = tasks.get_mut(&task_id) {
+ task.state = TaskState::Running;
+ task.started_at = Some(Instant::now());
+ }
+ tracing::debug!(task_id = %task_id, "Released tasks write lock");
+ }
+ tracing::info!(task_id = %task_id, "Updating state: Starting -> Running");
+ self.send_status_change(task_id, "starting", "running").await;
+ tracing::debug!(task_id = %task_id, "Sent status change notification");
+
+ // Stream output with startup timeout check
+ tracing::info!(task_id = %task_id, "Starting output stream - waiting for Claude output...");
+ tracing::debug!(task_id = %task_id, "Output will be forwarded via WebSocket to server");
+ let ws_tx = self.ws_tx.clone();
+
+ let mut output_count = 0u64;
+ let mut output_bytes = 0usize;
+ let startup_timeout = tokio::time::Duration::from_secs(30);
+ let mut startup_check = tokio::time::interval(tokio::time::Duration::from_secs(5));
+ startup_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+ let startup_deadline = tokio::time::Instant::now() + startup_timeout;
+
+ loop {
+ tokio::select! {
+ maybe_line = process.next_output() => {
+ match maybe_line {
+ Some(line) => {
+ output_count += 1;
+ output_bytes += line.content.len();
+
+ if output_count == 1 {
+ tracing::info!(task_id = %task_id, "Received first output line from Claude");
+ }
+ if output_count % 100 == 0 {
+ tracing::debug!(task_id = %task_id, output_count = output_count, output_bytes = output_bytes, "Output progress");
+ }
+
+ // Log output details for debugging
+ tracing::trace!(
+ task_id = %task_id,
+ line_num = output_count,
+ content_len = line.content.len(),
+ is_stdout = line.is_stdout,
+ json_type = ?line.json_type,
+ "Forwarding output to WebSocket"
+ );
+
+ // Check if this is a "result" message indicating task completion
+ // With --input-format=stream-json, Claude waits for more input after completion
+ // We close stdin to signal EOF and let the process exit
+ if line.json_type.as_deref() == Some("result") {
+ tracing::info!(task_id = %task_id, "Received result message, closing stdin to signal completion");
+ let mut stdin_guard = stdin_handle_for_completion.lock().await;
+ if let Some(mut stdin) = stdin_guard.take() {
+ let _ = stdin.shutdown().await;
+ }
+ }
+
+ let msg = DaemonMessage::task_output(task_id, line.content, false);
+ if ws_tx.send(msg).await.is_err() {
+ tracing::warn!(task_id = %task_id, "Failed to send output, channel closed");
+ break;
+ }
+ }
+ None => {
+ tracing::info!(task_id = %task_id, output_count = output_count, output_bytes = output_bytes, "Output stream ended");
+ break;
+ }
+ }
+ }
+ _ = startup_check.tick(), if output_count == 0 => {
+ // Check if process is still alive
+ match process.try_wait() {
+ Ok(Some(exit_code)) => {
+ tracing::error!(task_id = %task_id, exit_code = exit_code, "Claude process exited before producing output!");
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("Error: Claude process exited unexpectedly with code {}\n", exit_code),
+ false,
+ );
+ let _ = ws_tx.send(msg).await;
+ break;
+ }
+ Ok(None) => {
+ // Still running but no output
+ if tokio::time::Instant::now() > startup_deadline {
+ tracing::warn!(task_id = %task_id, "Claude process not producing output after 30s - may be stuck");
+ let msg = DaemonMessage::task_output(
+ task_id,
+ "Warning: Claude Code is taking longer than expected to start. It may be waiting for authentication or network access.\n".to_string(),
+ false,
+ );
+ let _ = ws_tx.send(msg).await;
+ } else {
+ tracing::debug!(task_id = %task_id, "Claude process still running, waiting for output...");
+ }
+ }
+ Err(e) => {
+ tracing::error!(task_id = %task_id, error = %e, "Failed to check Claude process status");
+ }
+ }
+ }
+ }
+ }
+
+ // Wait for process to exit
+ let exit_code = process.wait().await.unwrap_or(-1);
+
+ // Clean up input channel for this task
+ self.task_inputs.write().await.remove(&task_id);
+ tracing::debug!(task_id = %task_id, "Removed task input channel");
+
+ // Update state based on exit code
+ let success = exit_code == 0;
+ let new_state = if success {
+ TaskState::Completed
+ } else {
+ TaskState::Failed
+ };
+
+ tracing::info!(
+ task_id = %task_id,
+ exit_code = exit_code,
+ success = success,
+ new_state = ?new_state,
+ "Claude process exited, updating task state"
+ );
+
+ {
+ let mut tasks = self.tasks.write().await;
+ if let Some(task) = tasks.get_mut(&task_id) {
+ task.state = new_state;
+ task.completed_at = Some(Instant::now());
+ if !success {
+ task.error = Some(format!("Process exited with code {}", exit_code));
+ }
+ }
+ }
+
+ // Execute completion action if task succeeded
+ let completion_result = if success {
+ if let Some(ref action) = completion_action {
+ if action != "none" {
+ self.execute_completion_action(
+ task_id,
+ &task_name,
+ &working_dir,
+ action,
+ target_repo_path.as_deref(),
+ target_branch.as_deref(),
+ ).await
+ } else {
+ Ok(None)
+ }
+ } else {
+ Ok(None)
+ }
+ } else {
+ Ok(None)
+ };
+
+ // Log completion action result
+ match &completion_result {
+ Ok(Some(pr_url)) => {
+ tracing::info!(task_id = %task_id, pr_url = %pr_url, "Completion action created PR");
+ }
+ Ok(None) => {}
+ Err(e) => {
+ tracing::warn!(task_id = %task_id, error = %e, "Completion action failed (task still marked as done)");
+ }
+ }
+
+ // Notify server
+ let error = if success {
+ None
+ } else {
+ Some(format!("Exit code: {}", exit_code))
+ };
+ tracing::info!(task_id = %task_id, success = success, "Notifying server of task completion");
+ let msg = DaemonMessage::task_complete(task_id, success, error);
+ let _ = self.ws_tx.send(msg).await;
+
+ // Note: Worktrees are kept until explicitly deleted (per user preference)
+ // This allows inspection, PR creation, etc.
+
+ tracing::info!(task_id = %task_id, "=== RUN_TASK END ===");
+ Ok(())
+ }
+
+ /// Execute the completion action for a task.
+ async fn execute_completion_action(
+ &self,
+ task_id: Uuid,
+ task_name: &str,
+ worktree_path: &std::path::Path,
+ action: &str,
+ target_repo_path: Option<&str>,
+ target_branch: Option<&str>,
+ ) -> Result<Option<String>, String> {
+ let target_repo = match target_repo_path {
+ Some(path) => crate::worktree::expand_tilde(path),
+ None => {
+ tracing::warn!(task_id = %task_id, "No target_repo_path configured, skipping completion action");
+ return Ok(None);
+ }
+ };
+
+ if !target_repo.exists() {
+ return Err(format!("Target repo not found: {} (expanded from {:?})", target_repo.display(), target_repo_path));
+ }
+
+ // Get the branch name: makima/{task-name-with-dashes}-{short-id}
+ let branch_name = format!(
+ "makima/{}-{}",
+ crate::worktree::sanitize_name(task_name),
+ crate::worktree::short_uuid(task_id)
+ );
+
+ // Determine target branch - use provided value or detect default branch of target repo
+ let target_branch = match target_branch {
+ Some(branch) => branch.to_string(),
+ None => {
+ // Detect default branch (main, master, develop, etc.)
+ self.worktree_manager
+ .detect_default_branch(&target_repo)
+ .await
+ .unwrap_or_else(|_| "master".to_string())
+ }
+ };
+
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("Executing completion action: {}...\n", action),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+
+ match action {
+ "branch" => {
+ // Just push the branch to target repo
+ self.worktree_manager
+ .push_to_target_repo(worktree_path, &target_repo, &branch_name, task_name)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("Branch '{}' pushed to {}\n", branch_name, target_repo.display()),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+ Ok(None)
+ }
+ "merge" => {
+ // Push and merge into target branch
+ let commit_sha = self.worktree_manager
+ .merge_to_target(worktree_path, &target_repo, &branch_name, &target_branch, task_name)
+ .await
+ .map_err(|e| e.to_string())?;
+
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("Branch merged into {} (commit: {})\n", target_branch, commit_sha),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+ Ok(None)
+ }
+ "pr" => {
+ // Push and create PR
+ let title = task_name.to_string();
+ let body = format!(
+ "Automated PR from makima task.\n\nTask ID: `{}`",
+ task_id
+ );
+ let pr_url = self.worktree_manager
+ .create_pull_request(
+ worktree_path,
+ &target_repo,
+ &branch_name,
+ &target_branch,
+ &title,
+ &body,
+ )
+ .await
+ .map_err(|e| e.to_string())?;
+
+ let msg = DaemonMessage::task_output(
+ task_id,
+ format!("Pull request created: {}\n", pr_url),
+ false,
+ );
+ let _ = self.ws_tx.send(msg).await;
+ Ok(Some(pr_url))
+ }
+ _ => {
+ tracing::warn!(task_id = %task_id, action = %action, "Unknown completion action");
+ Ok(None)
+ }
+ }
+ }
+
+ /// Find worktree path for a task ID.
+ /// First checks in-memory tasks, then scans the worktrees directory.
+ async fn find_worktree_for_task(&self, task_id: Uuid) -> Result<PathBuf, String> {
+ // First try to get from in-memory tasks
+ {
+ let tasks = self.tasks.read().await;
+ if let Some(task) = tasks.get(&task_id) {
+ if let Some(ref worktree) = task.worktree {
+ return Ok(worktree.path.clone());
+ }
+ }
+ }
+
+ // Task not in memory - scan worktrees directory for matching task ID
+ let short_id = &task_id.to_string()[..8];
+ let worktrees_dir = self.worktree_manager.base_dir();
+
+ if let Ok(mut entries) = tokio::fs::read_dir(worktrees_dir).await {
+ while let Ok(Some(entry)) = entries.next_entry().await {
+ let name = entry.file_name();
+ let name_str = name.to_string_lossy();
+ if name_str.starts_with(short_id) {
+ let path = entry.path();
+ // Verify it's a valid git directory
+ if path.join(".git").exists() {
+ tracing::info!(
+ task_id = %task_id,
+ worktree_path = %path.display(),
+ "Found worktree by scanning directory"
+ );
+ return Ok(path);
+ }
+ }
+ }
+ }
+
+ Err(format!(
+ "No worktree found for task {}. The worktree may have been cleaned up.",
+ task_id
+ ))
+ }
+
+ async fn update_state(&self, task_id: Uuid, state: TaskState) {
+ let mut tasks = self.tasks.write().await;
+ if let Some(task) = tasks.get_mut(&task_id) {
+ task.state = state;
+ }
+ }
+
+ async fn send_status_change(&self, task_id: Uuid, old_status: &str, new_status: &str) {
+ let msg = DaemonMessage::task_status_change(task_id, old_status, new_status);
+ let _ = self.ws_tx.send(msg).await;
+ }
+
+ /// Mark task as failed.
+ async fn mark_failed(&self, task_id: Uuid, error: &str) {
+ {
+ let mut tasks = self.tasks.write().await;
+ if let Some(task) = tasks.get_mut(&task_id) {
+ task.state = TaskState::Failed;
+ task.error = Some(error.to_string());
+ task.completed_at = Some(Instant::now());
+ }
+ }
+
+ // Notify server
+ let msg = DaemonMessage::task_complete(task_id, false, Some(error.to_string()));
+ let _ = self.ws_tx.send(msg).await;
+ }
+}
+
+impl Clone for TaskManagerInner {
+ fn clone(&self) -> Self {
+ Self {
+ worktree_manager: self.worktree_manager.clone(),
+ process_manager: self.process_manager.clone(),
+ temp_manager: self.temp_manager.clone(),
+ tasks: self.tasks.clone(),
+ ws_tx: self.ws_tx.clone(),
+ task_inputs: self.task_inputs.clone(),
+ }
+ }
+}
diff --git a/makima/daemon/src/task/mod.rs b/makima/daemon/src/task/mod.rs
new file mode 100644
index 0000000..29c261e
--- /dev/null
+++ b/makima/daemon/src/task/mod.rs
@@ -0,0 +1,7 @@
+//! Task management and execution.
+
+pub mod manager;
+pub mod state;
+
+pub use manager::{ManagedTask, TaskConfig, TaskManager};
+pub use state::TaskState;
diff --git a/makima/daemon/src/task/state.rs b/makima/daemon/src/task/state.rs
new file mode 100644
index 0000000..fe73de1
--- /dev/null
+++ b/makima/daemon/src/task/state.rs
@@ -0,0 +1,161 @@
+//! Task state machine.
+
+use std::fmt;
+
+/// Task execution state.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub enum TaskState {
+ /// Task received, preparing overlay.
+ Initializing,
+ /// Overlay ready, starting container.
+ Starting,
+ /// Container running.
+ Running,
+ /// Container paused.
+ Paused,
+ /// Waiting for sibling or resource.
+ Blocked,
+ /// Task completed successfully.
+ Completed,
+ /// Task failed with error.
+ Failed,
+ /// Task interrupted by user.
+ Interrupted,
+}
+
+impl TaskState {
+ /// Check if a state transition is valid.
+ pub fn can_transition_to(&self, target: TaskState) -> bool {
+ use TaskState::*;
+
+ matches!(
+ (self, target),
+ // From Initializing
+ (Initializing, Starting)
+ | (Initializing, Failed)
+ | (Initializing, Interrupted)
+ // From Starting
+ | (Starting, Running)
+ | (Starting, Failed)
+ | (Starting, Interrupted)
+ // From Running
+ | (Running, Paused)
+ | (Running, Blocked)
+ | (Running, Completed)
+ | (Running, Failed)
+ | (Running, Interrupted)
+ // From Paused
+ | (Paused, Running)
+ | (Paused, Interrupted)
+ | (Paused, Failed)
+ // From Blocked
+ | (Blocked, Running)
+ | (Blocked, Failed)
+ | (Blocked, Interrupted)
+ )
+ }
+
+ /// Check if this state is terminal (no more transitions possible).
+ pub fn is_terminal(&self) -> bool {
+ matches!(
+ self,
+ TaskState::Completed | TaskState::Failed | TaskState::Interrupted
+ )
+ }
+
+ /// Check if the task is currently active (running or paused).
+ pub fn is_active(&self) -> bool {
+ matches!(
+ self,
+ TaskState::Initializing
+ | TaskState::Starting
+ | TaskState::Running
+ | TaskState::Paused
+ | TaskState::Blocked
+ )
+ }
+
+ /// Check if the task is running.
+ pub fn is_running(&self) -> bool {
+ matches!(self, TaskState::Running)
+ }
+
+ /// Convert to string for protocol messages.
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ TaskState::Initializing => "initializing",
+ TaskState::Starting => "starting",
+ TaskState::Running => "running",
+ TaskState::Paused => "paused",
+ TaskState::Blocked => "blocked",
+ TaskState::Completed => "done",
+ TaskState::Failed => "failed",
+ TaskState::Interrupted => "interrupted",
+ }
+ }
+
+ /// Parse from string.
+ pub fn from_str(s: &str) -> Option<Self> {
+ match s.to_lowercase().as_str() {
+ "initializing" => Some(TaskState::Initializing),
+ "starting" => Some(TaskState::Starting),
+ "running" => Some(TaskState::Running),
+ "paused" => Some(TaskState::Paused),
+ "blocked" => Some(TaskState::Blocked),
+ "done" | "completed" => Some(TaskState::Completed),
+ "failed" => Some(TaskState::Failed),
+ "interrupted" => Some(TaskState::Interrupted),
+ _ => None,
+ }
+ }
+}
+
+impl fmt::Display for TaskState {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(f, "{}", self.as_str())
+ }
+}
+
+impl Default for TaskState {
+ fn default() -> Self {
+ TaskState::Initializing
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_valid_transitions() {
+ use TaskState::*;
+
+ // Valid transitions
+ assert!(Initializing.can_transition_to(Starting));
+ assert!(Starting.can_transition_to(Running));
+ assert!(Running.can_transition_to(Completed));
+ assert!(Running.can_transition_to(Paused));
+ assert!(Paused.can_transition_to(Running));
+
+ // Invalid transitions
+ assert!(!Completed.can_transition_to(Running));
+ assert!(!Failed.can_transition_to(Running));
+ assert!(!Running.can_transition_to(Initializing));
+ }
+
+ #[test]
+ fn test_terminal_states() {
+ assert!(TaskState::Completed.is_terminal());
+ assert!(TaskState::Failed.is_terminal());
+ assert!(TaskState::Interrupted.is_terminal());
+ assert!(!TaskState::Running.is_terminal());
+ assert!(!TaskState::Paused.is_terminal());
+ }
+
+ #[test]
+ fn test_parse() {
+ assert_eq!(TaskState::from_str("running"), Some(TaskState::Running));
+ assert_eq!(TaskState::from_str("done"), Some(TaskState::Completed));
+ assert_eq!(TaskState::from_str("invalid"), None);
+ }
+}
diff --git a/makima/daemon/src/temp.rs b/makima/daemon/src/temp.rs
new file mode 100644
index 0000000..015b21b
--- /dev/null
+++ b/makima/daemon/src/temp.rs
@@ -0,0 +1,224 @@
+//! Managed temporary directory for tasks without repositories.
+//!
+//! Tasks that don't have a repository URL and aren't subtasks (which inherit
+//! from parent) use a managed temp directory in ~/.makima/temp/. The directory
+//! is automatically cleaned up when it exceeds a size limit.
+
+use std::path::PathBuf;
+
+use tokio::fs;
+use uuid::Uuid;
+
+/// Maximum size of the temp directory before cleanup (5GB).
+const MAX_TEMP_SIZE_BYTES: u64 = 5 * 1024 * 1024 * 1024;
+
+/// Manages temporary directories for tasks without repositories.
+pub struct TempManager {
+ /// Base directory for temp task directories (~/.makima/temp/).
+ temp_dir: PathBuf,
+}
+
+impl TempManager {
+ /// Create a new TempManager.
+ pub fn new() -> Self {
+ let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
+ Self {
+ temp_dir: home.join(".makima").join("temp"),
+ }
+ }
+
+ /// Create a new TempManager with a custom base directory.
+ #[allow(dead_code)]
+ pub fn with_base_dir(base_dir: PathBuf) -> Self {
+ Self { temp_dir: base_dir }
+ }
+
+ /// Get the base temp directory path.
+ pub fn temp_dir(&self) -> &PathBuf {
+ &self.temp_dir
+ }
+
+ /// Create a temp directory for a task.
+ ///
+ /// This creates a directory at ~/.makima/temp/task-{id}/ and triggers
+ /// cleanup if the total size exceeds the limit.
+ pub async fn create_task_dir(&self, task_id: Uuid) -> Result<PathBuf, std::io::Error> {
+ // Ensure base directory exists
+ fs::create_dir_all(&self.temp_dir).await?;
+
+ // Check size and cleanup if needed
+ if let Err(e) = self.cleanup_if_needed().await {
+ tracing::warn!("Temp directory cleanup failed: {}", e);
+ // Continue anyway, cleanup is best-effort
+ }
+
+ // Create task-specific directory
+ let task_dir = self.temp_dir.join(format!("task-{}", task_id));
+ fs::create_dir_all(&task_dir).await?;
+
+ tracing::info!(
+ task_id = %task_id,
+ path = %task_dir.display(),
+ "Created temp directory for task"
+ );
+
+ Ok(task_dir)
+ }
+
+ /// Calculate total size of temp directory recursively.
+ async fn get_total_size(&self) -> Result<u64, std::io::Error> {
+ if !self.temp_dir.exists() {
+ return Ok(0);
+ }
+
+ let mut total = 0u64;
+ let mut stack = vec![self.temp_dir.clone()];
+
+ while let Some(dir) = stack.pop() {
+ let mut entries = match fs::read_dir(&dir).await {
+ Ok(e) => e,
+ Err(_) => continue,
+ };
+
+ while let Ok(Some(entry)) = entries.next_entry().await {
+ let metadata = match entry.metadata().await {
+ Ok(m) => m,
+ Err(_) => continue,
+ };
+
+ if metadata.is_dir() {
+ stack.push(entry.path());
+ } else {
+ total += metadata.len();
+ }
+ }
+ }
+
+ Ok(total)
+ }
+
+ /// Remove oldest directories if total size exceeds limit.
+ async fn cleanup_if_needed(&self) -> Result<(), std::io::Error> {
+ let size = self.get_total_size().await?;
+ if size <= MAX_TEMP_SIZE_BYTES {
+ return Ok(());
+ }
+
+ tracing::info!(
+ current_size_mb = size / 1024 / 1024,
+ limit_mb = MAX_TEMP_SIZE_BYTES / 1024 / 1024,
+ "Temp directory exceeds size limit, starting cleanup"
+ );
+
+ // Get all task dirs with modification times
+ let mut dirs: Vec<(PathBuf, std::time::SystemTime, u64)> = vec![];
+ let mut entries = fs::read_dir(&self.temp_dir).await?;
+
+ while let Ok(Some(entry)) = entries.next_entry().await {
+ let path = entry.path();
+ if !path.is_dir() {
+ continue;
+ }
+
+ let metadata = match entry.metadata().await {
+ Ok(m) => m,
+ Err(_) => continue,
+ };
+
+ let modified = metadata.modified().unwrap_or(std::time::UNIX_EPOCH);
+ let dir_size = self.get_dir_size(&path).await.unwrap_or(0);
+ dirs.push((path, modified, dir_size));
+ }
+
+ // Sort by oldest first
+ dirs.sort_by(|a, b| a.1.cmp(&b.1));
+
+ // Remove oldest until under limit
+ let mut current_size = size;
+ for (path, _, dir_size) in dirs {
+ if current_size <= MAX_TEMP_SIZE_BYTES {
+ break;
+ }
+
+ tracing::info!(
+ path = %path.display(),
+ size_mb = dir_size / 1024 / 1024,
+ "Removing old temp directory"
+ );
+
+ if let Err(e) = fs::remove_dir_all(&path).await {
+ tracing::warn!(path = %path.display(), error = %e, "Failed to remove temp directory");
+ continue;
+ }
+
+ current_size = current_size.saturating_sub(dir_size);
+ }
+
+ tracing::info!(
+ new_size_mb = current_size / 1024 / 1024,
+ "Temp directory cleanup complete"
+ );
+
+ Ok(())
+ }
+
+ /// Calculate size of a directory recursively.
+ async fn get_dir_size(&self, path: &PathBuf) -> Result<u64, std::io::Error> {
+ let mut total = 0u64;
+ let mut stack = vec![path.clone()];
+
+ while let Some(dir) = stack.pop() {
+ let mut entries = match fs::read_dir(&dir).await {
+ Ok(e) => e,
+ Err(_) => continue,
+ };
+
+ while let Ok(Some(entry)) = entries.next_entry().await {
+ let metadata = match entry.metadata().await {
+ Ok(m) => m,
+ Err(_) => continue,
+ };
+
+ if metadata.is_dir() {
+ stack.push(entry.path());
+ } else {
+ total += metadata.len();
+ }
+ }
+ }
+
+ Ok(total)
+ }
+
+ /// Remove a specific task's temp directory.
+ #[allow(dead_code)]
+ pub async fn remove_task_dir(&self, task_id: Uuid) -> Result<(), std::io::Error> {
+ let task_dir = self.temp_dir.join(format!("task-{}", task_id));
+ if task_dir.exists() {
+ fs::remove_dir_all(&task_dir).await?;
+ tracing::info!(
+ task_id = %task_id,
+ path = %task_dir.display(),
+ "Removed temp directory for task"
+ );
+ }
+ Ok(())
+ }
+}
+
+impl Default for TempManager {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_temp_manager_default_dir() {
+ let manager = TempManager::new();
+ assert!(manager.temp_dir().ends_with(".makima/temp"));
+ }
+}
diff --git a/makima/daemon/src/worktree/manager.rs b/makima/daemon/src/worktree/manager.rs
new file mode 100644
index 0000000..266b970
--- /dev/null
+++ b/makima/daemon/src/worktree/manager.rs
@@ -0,0 +1,1623 @@
+//! Worktree manager implementation.
+
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+use std::sync::LazyLock;
+
+use tokio::process::Command;
+use tokio::sync::Mutex;
+use uuid::Uuid;
+
+/// Errors that can occur during worktree operations.
+#[derive(Debug, thiserror::Error)]
+pub enum WorktreeError {
+ #[error("Git command failed: {0}")]
+ GitCommand(String),
+
+ #[error("Repository not found: {0}")]
+ RepoNotFound(String),
+
+ #[error("Failed to create directory: {0}")]
+ CreateDir(#[from] std::io::Error),
+
+ #[error("Invalid repository path: {0}")]
+ InvalidPath(String),
+
+ #[error("Worktree already exists: {0}")]
+ AlreadyExists(String),
+
+ #[error("Clone failed: {0}")]
+ CloneFailed(String),
+
+ #[error("Merge in progress")]
+ MergeInProgress,
+
+ #[error("No merge in progress")]
+ NoMergeInProgress,
+
+ #[error("Merge has conflicts: {0}")]
+ MergeConflicts(String),
+}
+
+/// Strategy for resolving a merge conflict.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ConflictResolution {
+ /// Use our version (the branch being merged into).
+ Ours,
+ /// Use their version (the branch being merged).
+ Theirs,
+}
+
+/// State of an in-progress merge.
+#[derive(Debug, Clone)]
+pub struct MergeState {
+ /// The branch being merged.
+ pub source_branch: String,
+ /// Files with unresolved conflicts.
+ pub conflicted_files: Vec<String>,
+ /// Whether a merge is currently in progress.
+ pub in_progress: bool,
+}
+
+/// Information about a task branch.
+#[derive(Debug, Clone)]
+pub struct TaskBranchInfo {
+ /// Full branch name.
+ pub name: String,
+ /// Task ID extracted from branch name (if parseable).
+ pub task_id: Option<Uuid>,
+ /// Whether this branch has been merged into the current branch.
+ pub is_merged: bool,
+ /// Short SHA of the last commit.
+ pub last_commit: String,
+ /// Subject line of the last commit.
+ pub last_commit_message: String,
+}
+
+/// Information about a created worktree.
+#[derive(Debug, Clone)]
+pub struct WorktreeInfo {
+ /// Path to the worktree directory.
+ pub path: PathBuf,
+ /// Git branch name for this worktree.
+ pub branch: String,
+ /// Source repository path.
+ pub source_repo: PathBuf,
+}
+
+/// Manages git worktrees for task isolation.
+pub struct WorktreeManager {
+ /// Base directory for all worktrees (~/.makima/worktrees).
+ base_dir: PathBuf,
+ /// Base directory for cloned repos (~/.makima/repos).
+ repos_dir: PathBuf,
+ /// Branch prefix for task branches.
+ branch_prefix: String,
+}
+
+/// Per-worktree locks to prevent concurrent creation issues.
+static WORKTREE_LOCKS: LazyLock<Mutex<HashMap<String, std::sync::Arc<tokio::sync::Mutex<()>>>>> =
+ LazyLock::new(|| Mutex::new(HashMap::new()));
+
+impl WorktreeManager {
+ /// Create a new WorktreeManager with the given base directory.
+ pub fn new(base_dir: PathBuf) -> Self {
+ let repos_dir = base_dir.parent()
+ .map(|p| p.join("repos"))
+ .unwrap_or_else(|| base_dir.join("repos"));
+
+ Self {
+ base_dir,
+ repos_dir,
+ branch_prefix: "makima/task-".to_string(),
+ }
+ }
+
+ /// Get the default worktree base directory (~/.makima/worktrees).
+ pub fn default_base_dir() -> PathBuf {
+ dirs::home_dir()
+ .unwrap_or_else(|| PathBuf::from("."))
+ .join(".makima")
+ .join("worktrees")
+ }
+
+ /// Get the base directory for worktrees.
+ pub fn base_dir(&self) -> &Path {
+ &self.base_dir
+ }
+
+ /// Detect the default branch of a repository.
+ /// Tries to find HEAD's target, falling back to common branch names.
+ pub async fn detect_default_branch(&self, repo_path: &Path) -> Result<String, WorktreeError> {
+ // Try to get the branch that HEAD points to
+ let output = Command::new("git")
+ .args(["symbolic-ref", "refs/remotes/origin/HEAD", "--short"])
+ .current_dir(repo_path)
+ .output()
+ .await?;
+
+ if output.status.success() {
+ let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ // Remove "origin/" prefix if present
+ let branch = branch.strip_prefix("origin/").unwrap_or(&branch).to_string();
+ if !branch.is_empty() {
+ return Ok(branch);
+ }
+ }
+
+ // Try common branch names
+ for branch in ["main", "master", "develop", "trunk"] {
+ let output = Command::new("git")
+ .args(["rev-parse", "--verify", &format!("refs/heads/{}", branch)])
+ .current_dir(repo_path)
+ .output()
+ .await?;
+
+ if output.status.success() {
+ return Ok(branch.to_string());
+ }
+ }
+
+ // Fall back to getting the current branch
+ let output = Command::new("git")
+ .args(["rev-parse", "--abbrev-ref", "HEAD"])
+ .current_dir(repo_path)
+ .output()
+ .await?;
+
+ if output.status.success() {
+ let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ if !branch.is_empty() && branch != "HEAD" {
+ return Ok(branch);
+ }
+ }
+
+ Err(WorktreeError::GitCommand(
+ "Could not detect default branch".to_string(),
+ ))
+ }
+
+ /// Ensure the source repository exists locally and is up-to-date.
+ /// If repo_source is a URL, clone it. If it's a path, verify it exists.
+ /// For both cases, fetch latest changes from remote if available.
+ pub async fn ensure_repo(&self, repo_source: &str) -> Result<PathBuf, WorktreeError> {
+ // Check if it's a URL (simple heuristic)
+ if repo_source.starts_with("http://")
+ || repo_source.starts_with("https://")
+ || repo_source.starts_with("git@")
+ || repo_source.starts_with("ssh://")
+ {
+ self.clone_or_fetch_repo(repo_source).await
+ } else {
+ // Treat as local path - expand tilde if present
+ let path = expand_tilde(repo_source);
+ if !path.exists() {
+ return Err(WorktreeError::RepoNotFound(repo_source.to_string()));
+ }
+ // Verify it's a git repo
+ let git_dir = path.join(".git");
+ if !git_dir.exists() {
+ return Err(WorktreeError::InvalidPath(format!(
+ "{} is not a git repository",
+ repo_source
+ )));
+ }
+
+ // Fetch latest changes from remote if configured
+ tracing::info!("Fetching latest changes for local repo: {}", repo_source);
+ let output = Command::new("git")
+ .args(["fetch", "--all", "--prune"])
+ .current_dir(&path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ // Don't fail - repo might not have a remote configured
+ tracing::debug!("Git fetch for local repo (may not have remote): {}", stderr);
+ } else {
+ tracing::info!("Fetched latest changes for {}", repo_source);
+ }
+
+ Ok(path)
+ }
+ }
+
+ /// Clone a repository or fetch if already cloned.
+ async fn clone_or_fetch_repo(&self, url: &str) -> Result<PathBuf, WorktreeError> {
+ // Extract repo name from URL
+ let repo_name = extract_repo_name(url);
+ let repo_path = self.repos_dir.join(&repo_name);
+
+ // Create repos directory if needed
+ tokio::fs::create_dir_all(&self.repos_dir).await?;
+
+ if repo_path.exists() {
+ // Fetch latest changes
+ tracing::info!("Fetching updates for existing repo: {}", repo_name);
+ let output = Command::new("git")
+ .args(["fetch", "--all", "--prune"])
+ .current_dir(&repo_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ tracing::warn!("Git fetch warning: {}", stderr);
+ // Don't fail on fetch errors, repo might still be usable
+ }
+ } else {
+ // Clone the repository
+ tracing::info!("Cloning repository: {} -> {}", url, repo_path.display());
+ let output = Command::new("git")
+ .args(["clone", "--bare", url])
+ .arg(&repo_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::CloneFailed(stderr.to_string()));
+ }
+ }
+
+ Ok(repo_path)
+ }
+
+ /// Create a worktree for a task.
+ ///
+ /// This creates a unique directory with a git worktree checked out to a new branch.
+ pub async fn create_worktree(
+ &self,
+ source_repo: &Path,
+ task_id: Uuid,
+ task_name: &str,
+ base_branch: &str,
+ ) -> Result<WorktreeInfo, WorktreeError> {
+ // Generate unique directory name and branch
+ let dir_name = format!("{}-{}", short_uuid(task_id), sanitize_name(task_name));
+ let worktree_path = self.base_dir.join(&dir_name);
+ // Branch name: makima/{task-name-with-dashes}-{short-id}
+ let branch_name = format!("{}{}-{}", self.branch_prefix, sanitize_name(task_name), short_uuid(task_id));
+
+ // Acquire lock for this worktree path
+ let lock = {
+ let mut locks = WORKTREE_LOCKS.lock().await;
+ locks
+ .entry(worktree_path.to_string_lossy().to_string())
+ .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(())))
+ .clone()
+ };
+ let _guard = lock.lock().await;
+
+ // Check if worktree already exists - reuse it if so
+ if worktree_path.exists() {
+ tracing::info!(
+ task_id = %task_id,
+ worktree_path = %worktree_path.display(),
+ "Worktree already exists, reusing"
+ );
+
+ // Verify it's a valid git directory
+ let git_dir = worktree_path.join(".git");
+ if git_dir.exists() {
+ // Get the current branch name
+ let output = Command::new("git")
+ .args(["rev-parse", "--abbrev-ref", "HEAD"])
+ .current_dir(&worktree_path)
+ .output()
+ .await?;
+
+ let current_branch = if output.status.success() {
+ String::from_utf8_lossy(&output.stdout).trim().to_string()
+ } else {
+ branch_name.clone()
+ };
+
+ return Ok(WorktreeInfo {
+ path: worktree_path,
+ branch: current_branch,
+ source_repo: source_repo.to_path_buf(),
+ });
+ } else {
+ // Directory exists but isn't a git worktree - remove and recreate
+ tracing::warn!(
+ task_id = %task_id,
+ worktree_path = %worktree_path.display(),
+ "Directory exists but is not a git worktree, removing"
+ );
+ tokio::fs::remove_dir_all(&worktree_path).await?;
+ }
+ }
+
+ // Create base directory
+ tokio::fs::create_dir_all(&self.base_dir).await?;
+
+ tracing::info!(
+ task_id = %task_id,
+ worktree_path = %worktree_path.display(),
+ branch = %branch_name,
+ base_branch = %base_branch,
+ "Creating worktree from local branch"
+ );
+
+ // Create the worktree with a new branch based on the local base_branch
+ let output = Command::new("git")
+ .args([
+ "worktree",
+ "add",
+ "-b",
+ &branch_name,
+ ])
+ .arg(&worktree_path)
+ .arg(base_branch)
+ .current_dir(source_repo)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to create worktree: {}",
+ stderr
+ )));
+ }
+
+ tracing::info!(
+ task_id = %task_id,
+ worktree_path = %worktree_path.display(),
+ "Worktree created successfully"
+ );
+
+ Ok(WorktreeInfo {
+ path: worktree_path,
+ branch: branch_name,
+ source_repo: source_repo.to_path_buf(),
+ })
+ }
+
+ /// Create a worktree for a task by copying from another task's worktree.
+ ///
+ /// This allows sequential subtasks where one continues from another's work,
+ /// including uncommitted changes.
+ pub async fn create_worktree_from_task(
+ &self,
+ source_worktree: &Path,
+ task_id: Uuid,
+ task_name: &str,
+ ) -> Result<WorktreeInfo, WorktreeError> {
+ // Verify source worktree exists
+ if !source_worktree.exists() {
+ return Err(WorktreeError::RepoNotFound(format!(
+ "Source worktree not found: {}",
+ source_worktree.display()
+ )));
+ }
+
+ // Get the source repo from the source worktree
+ let source_repo = self.get_worktree_source(source_worktree).await?;
+
+ // Get the base branch from source worktree's current HEAD
+ let output = Command::new("git")
+ .args(["rev-parse", "HEAD"])
+ .current_dir(source_worktree)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ return Err(WorktreeError::GitCommand(
+ "Failed to get source worktree HEAD".to_string(),
+ ));
+ }
+ let source_commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
+
+ // Generate unique directory name and branch for new worktree
+ let dir_name = format!("{}-{}", short_uuid(task_id), sanitize_name(task_name));
+ let worktree_path = self.base_dir.join(&dir_name);
+ let branch_name = format!("{}{}", self.branch_prefix, task_id);
+
+ // Acquire lock for this worktree path
+ let lock = {
+ let mut locks = WORKTREE_LOCKS.lock().await;
+ locks
+ .entry(worktree_path.to_string_lossy().to_string())
+ .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(())))
+ .clone()
+ };
+ let _guard = lock.lock().await;
+
+ // Remove existing worktree if present
+ if worktree_path.exists() {
+ tracing::info!(
+ task_id = %task_id,
+ worktree_path = %worktree_path.display(),
+ "Removing existing worktree before creating from source"
+ );
+ tokio::fs::remove_dir_all(&worktree_path).await?;
+ }
+
+ // Create base directory
+ tokio::fs::create_dir_all(&self.base_dir).await?;
+
+ tracing::info!(
+ task_id = %task_id,
+ source_worktree = %source_worktree.display(),
+ worktree_path = %worktree_path.display(),
+ branch = %branch_name,
+ source_commit = %source_commit,
+ "Creating worktree from source task"
+ );
+
+ // Create a new worktree based on the source commit
+ let output = Command::new("git")
+ .args([
+ "worktree",
+ "add",
+ "-b",
+ &branch_name,
+ ])
+ .arg(&worktree_path)
+ .arg(&source_commit)
+ .current_dir(&source_repo)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to create worktree: {}",
+ stderr
+ )));
+ }
+
+ // Now copy uncommitted changes from source worktree
+ // Use rsync to copy all files except .git
+ let output = Command::new("rsync")
+ .args([
+ "-a",
+ "--exclude", ".git",
+ "--exclude", ".makima",
+ &format!("{}/", source_worktree.display()),
+ &format!("{}/", worktree_path.display()),
+ ])
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ tracing::warn!(
+ task_id = %task_id,
+ "rsync warning (continuing anyway): {}",
+ stderr
+ );
+ }
+
+ tracing::info!(
+ task_id = %task_id,
+ worktree_path = %worktree_path.display(),
+ "Worktree created from source task successfully"
+ );
+
+ Ok(WorktreeInfo {
+ path: worktree_path,
+ branch: branch_name,
+ source_repo: source_repo.to_path_buf(),
+ })
+ }
+
+ /// Remove a worktree and optionally its branch.
+ pub async fn remove_worktree(
+ &self,
+ worktree_path: &Path,
+ delete_branch: bool,
+ ) -> Result<(), WorktreeError> {
+ if !worktree_path.exists() {
+ return Ok(()); // Already gone
+ }
+
+ // Get the branch name before removing
+ let branch_name = if delete_branch {
+ self.get_worktree_branch(worktree_path).await.ok()
+ } else {
+ None
+ };
+
+ // Find the source repo from worktree
+ let source_repo = self.get_worktree_source(worktree_path).await?;
+
+ tracing::info!(
+ worktree_path = %worktree_path.display(),
+ delete_branch = delete_branch,
+ "Removing worktree"
+ );
+
+ // Remove the worktree
+ let output = Command::new("git")
+ .args(["worktree", "remove", "--force"])
+ .arg(worktree_path)
+ .current_dir(&source_repo)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ // Try force removal of directory if git worktree remove fails
+ if worktree_path.exists() {
+ tokio::fs::remove_dir_all(worktree_path).await?;
+ }
+ tracing::warn!("Git worktree remove warning: {}", stderr);
+ }
+
+ // Prune worktree references
+ let _ = Command::new("git")
+ .args(["worktree", "prune"])
+ .current_dir(&source_repo)
+ .output()
+ .await;
+
+ // Delete the branch if requested
+ if let Some(branch) = branch_name {
+ let output = Command::new("git")
+ .args(["branch", "-D", &branch])
+ .current_dir(&source_repo)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ tracing::warn!("Failed to delete branch {}: {}", branch, stderr);
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Get the branch name of a worktree.
+ async fn get_worktree_branch(&self, worktree_path: &Path) -> Result<String, WorktreeError> {
+ let output = Command::new("git")
+ .args(["rev-parse", "--abbrev-ref", "HEAD"])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to get branch: {}",
+ stderr
+ )));
+ }
+
+ Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
+ }
+
+ /// Get the source repository path for a worktree.
+ async fn get_worktree_source(&self, worktree_path: &Path) -> Result<PathBuf, WorktreeError> {
+ // Read the .git file in the worktree which contains the path to the main repo
+ let git_file = worktree_path.join(".git");
+
+ if git_file.is_file() {
+ let content = tokio::fs::read_to_string(&git_file).await?;
+ // Format: "gitdir: /path/to/repo/.git/worktrees/name"
+ if let Some(gitdir) = content.strip_prefix("gitdir: ") {
+ let gitdir = gitdir.trim();
+ // Navigate from worktrees/name back to the main repo
+ let path = PathBuf::from(gitdir);
+ if let Some(worktrees_dir) = path.parent() {
+ if let Some(git_dir) = worktrees_dir.parent() {
+ if let Some(repo_dir) = git_dir.parent() {
+ return Ok(repo_dir.to_path_buf());
+ }
+ }
+ }
+ }
+ }
+
+ // Fallback: try to find it in our repos directory
+ Err(WorktreeError::InvalidPath(format!(
+ "Could not determine source repo for worktree: {}",
+ worktree_path.display()
+ )))
+ }
+
+ /// List all worktrees in the base directory.
+ pub async fn list_worktrees(&self) -> Result<Vec<PathBuf>, WorktreeError> {
+ let mut worktrees = Vec::new();
+
+ if !self.base_dir.exists() {
+ return Ok(worktrees);
+ }
+
+ let mut entries = tokio::fs::read_dir(&self.base_dir).await?;
+ while let Some(entry) = entries.next_entry().await? {
+ let path = entry.path();
+ if path.is_dir() && path.join(".git").exists() {
+ worktrees.push(path);
+ }
+ }
+
+ Ok(worktrees)
+ }
+
+ /// Initialize a new git repository for a task.
+ ///
+ /// This creates a fresh git repo (not a worktree) for tasks that don't need
+ /// an existing codebase. Use this when `repository_url` is `new://` or `new://project-name`.
+ pub async fn init_new_repo(
+ &self,
+ task_id: Uuid,
+ repo_source: &str,
+ ) -> Result<WorktreeInfo, WorktreeError> {
+ let project_name = extract_new_repo_name(repo_source);
+ let dir_name = match project_name {
+ Some(name) => format!("{}-{}", short_uuid(task_id), sanitize_name(name)),
+ None => format!("{}-new", short_uuid(task_id)),
+ };
+ let repo_path = self.repos_dir.join(&dir_name);
+
+ tracing::info!(
+ task_id = %task_id,
+ path = %repo_path.display(),
+ project_name = ?project_name,
+ "Initializing new git repository"
+ );
+
+ // Create directory
+ tokio::fs::create_dir_all(&repo_path).await?;
+
+ // git init
+ let output = Command::new("git")
+ .args(["init"])
+ .current_dir(&repo_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to init repository: {}",
+ stderr
+ )));
+ }
+
+ // Configure git user (needed for commits)
+ let _ = Command::new("git")
+ .args(["config", "user.email", "makima@localhost"])
+ .current_dir(&repo_path)
+ .output()
+ .await;
+ let _ = Command::new("git")
+ .args(["config", "user.name", "Makima"])
+ .current_dir(&repo_path)
+ .output()
+ .await;
+
+ // Initial commit (required for worktrees to work later if needed)
+ let output = Command::new("git")
+ .args(["commit", "--allow-empty", "-m", "Initial commit"])
+ .current_dir(&repo_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to create initial commit: {}",
+ stderr
+ )));
+ }
+
+ tracing::info!(
+ task_id = %task_id,
+ path = %repo_path.display(),
+ "New git repository initialized"
+ );
+
+ Ok(WorktreeInfo {
+ path: repo_path.clone(),
+ branch: "main".to_string(),
+ source_repo: repo_path,
+ })
+ }
+
+ // ========== Merge Operations ==========
+
+ /// List all task branches in a repository.
+ ///
+ /// Returns branches matching the pattern `makima/task-*`.
+ pub async fn list_task_branches(
+ &self,
+ repo_path: &Path,
+ ) -> Result<Vec<TaskBranchInfo>, WorktreeError> {
+ // Get all branches matching our prefix
+ let output = Command::new("git")
+ .args([
+ "branch",
+ "--list",
+ &format!("{}*", self.branch_prefix),
+ "--format=%(refname:short)|%(objectname:short)|%(subject)",
+ ])
+ .current_dir(repo_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to list branches: {}",
+ stderr
+ )));
+ }
+
+ // Get list of merged branches
+ let merged_output = Command::new("git")
+ .args(["branch", "--merged", "HEAD", "--format=%(refname:short)"])
+ .current_dir(repo_path)
+ .output()
+ .await?;
+
+ let merged_branches: std::collections::HashSet<String> = if merged_output.status.success() {
+ String::from_utf8_lossy(&merged_output.stdout)
+ .lines()
+ .map(|s| s.trim().to_string())
+ .collect()
+ } else {
+ std::collections::HashSet::new()
+ };
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let mut branches = Vec::new();
+
+ for line in stdout.lines() {
+ let parts: Vec<&str> = line.split('|').collect();
+ if parts.len() >= 3 {
+ let name = parts[0].trim().to_string();
+ let last_commit = parts[1].trim().to_string();
+ let last_commit_message = parts[2].trim().to_string();
+
+ // Try to extract task ID from branch name
+ let task_id = name
+ .strip_prefix(&self.branch_prefix)
+ .and_then(|s| Uuid::parse_str(s).ok());
+
+ let is_merged = merged_branches.contains(&name);
+
+ branches.push(TaskBranchInfo {
+ name,
+ task_id,
+ is_merged,
+ last_commit,
+ last_commit_message,
+ });
+ }
+ }
+
+ Ok(branches)
+ }
+
+ /// Start a merge of a branch into the current worktree.
+ ///
+ /// Uses `--no-commit` to allow conflict resolution before committing.
+ /// Returns Ok(None) if merge succeeds without conflicts, or Ok(Some(files))
+ /// with the list of conflicted files.
+ pub async fn merge_branch(
+ &self,
+ worktree_path: &Path,
+ source_branch: &str,
+ ) -> Result<Option<Vec<String>>, WorktreeError> {
+ // Check if there's already a merge in progress
+ if self.is_merge_in_progress(worktree_path).await? {
+ return Err(WorktreeError::MergeInProgress);
+ }
+
+ tracing::info!(
+ worktree = %worktree_path.display(),
+ source_branch = %source_branch,
+ "Starting merge"
+ );
+
+ // Attempt the merge with --no-commit --no-ff
+ let output = Command::new("git")
+ .args(["merge", "--no-commit", "--no-ff", source_branch])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if output.status.success() {
+ tracing::info!("Merge completed without conflicts");
+ return Ok(None);
+ }
+
+ // Check if there are conflicts
+ let conflicts = self.get_conflicted_files(worktree_path).await?;
+ if !conflicts.is_empty() {
+ tracing::info!(
+ conflicts = ?conflicts,
+ "Merge has conflicts"
+ );
+ return Ok(Some(conflicts));
+ }
+
+ // Other error
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ Err(WorktreeError::GitCommand(format!(
+ "Merge failed: {}",
+ stderr
+ )))
+ }
+
+ /// Check if a merge is currently in progress.
+ pub async fn is_merge_in_progress(&self, worktree_path: &Path) -> Result<bool, WorktreeError> {
+ // Check for MERGE_HEAD file
+ let merge_head = worktree_path.join(".git").join("MERGE_HEAD");
+ if merge_head.exists() {
+ return Ok(true);
+ }
+
+ // Also check in .git file (for worktrees)
+ let git_file = worktree_path.join(".git");
+ if git_file.is_file() {
+ if let Ok(content) = tokio::fs::read_to_string(&git_file).await {
+ if let Some(gitdir) = content.strip_prefix("gitdir: ") {
+ let gitdir = PathBuf::from(gitdir.trim());
+ let merge_head = gitdir.join("MERGE_HEAD");
+ if merge_head.exists() {
+ return Ok(true);
+ }
+ }
+ }
+ }
+
+ Ok(false)
+ }
+
+ /// Get the list of files with unresolved conflicts.
+ pub async fn get_conflicted_files(
+ &self,
+ worktree_path: &Path,
+ ) -> Result<Vec<String>, WorktreeError> {
+ let output = Command::new("git")
+ .args(["diff", "--name-only", "--diff-filter=U"])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ // No conflicts or not in merge state
+ return Ok(Vec::new());
+ }
+
+ let stdout = String::from_utf8_lossy(&output.stdout);
+ let files: Vec<String> = stdout
+ .lines()
+ .map(|s| s.trim().to_string())
+ .filter(|s| !s.is_empty())
+ .collect();
+
+ Ok(files)
+ }
+
+ /// Get the current merge state.
+ pub async fn get_merge_state(
+ &self,
+ worktree_path: &Path,
+ ) -> Result<MergeState, WorktreeError> {
+ let in_progress = self.is_merge_in_progress(worktree_path).await?;
+
+ if !in_progress {
+ return Ok(MergeState {
+ source_branch: String::new(),
+ conflicted_files: Vec::new(),
+ in_progress: false,
+ });
+ }
+
+ // Get the branch being merged from MERGE_HEAD
+ let source_branch = self.get_merge_source_branch(worktree_path).await?;
+ let conflicted_files = self.get_conflicted_files(worktree_path).await?;
+
+ Ok(MergeState {
+ source_branch,
+ conflicted_files,
+ in_progress: true,
+ })
+ }
+
+ /// Get the branch name being merged (from MERGE_HEAD).
+ async fn get_merge_source_branch(&self, worktree_path: &Path) -> Result<String, WorktreeError> {
+ // Get MERGE_HEAD commit
+ let output = Command::new("git")
+ .args(["rev-parse", "MERGE_HEAD"])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ return Ok("unknown".to_string());
+ }
+
+ let commit = String::from_utf8_lossy(&output.stdout).trim().to_string();
+
+ // Try to find branch name for this commit
+ let output = Command::new("git")
+ .args(["name-rev", "--name-only", &commit])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if output.status.success() {
+ let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ // Clean up the name (remove ~N suffixes, etc.)
+ let name = name.split('~').next().unwrap_or(&name);
+ let name = name.split('^').next().unwrap_or(name);
+ return Ok(name.to_string());
+ }
+
+ Ok(commit[..8.min(commit.len())].to_string())
+ }
+
+ /// Resolve a conflict in a specific file.
+ pub async fn resolve_conflict(
+ &self,
+ worktree_path: &Path,
+ file_path: &str,
+ resolution: ConflictResolution,
+ ) -> Result<(), WorktreeError> {
+ if !self.is_merge_in_progress(worktree_path).await? {
+ return Err(WorktreeError::NoMergeInProgress);
+ }
+
+ let strategy = match resolution {
+ ConflictResolution::Ours => "--ours",
+ ConflictResolution::Theirs => "--theirs",
+ };
+
+ tracing::info!(
+ worktree = %worktree_path.display(),
+ file = %file_path,
+ strategy = %strategy,
+ "Resolving conflict"
+ );
+
+ // Checkout the chosen version
+ let output = Command::new("git")
+ .args(["checkout", strategy, "--", file_path])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to resolve conflict: {}",
+ stderr
+ )));
+ }
+
+ // Stage the resolved file
+ let output = Command::new("git")
+ .args(["add", file_path])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to stage resolved file: {}",
+ stderr
+ )));
+ }
+
+ Ok(())
+ }
+
+ /// Abort the current merge.
+ pub async fn abort_merge(&self, worktree_path: &Path) -> Result<(), WorktreeError> {
+ if !self.is_merge_in_progress(worktree_path).await? {
+ return Err(WorktreeError::NoMergeInProgress);
+ }
+
+ tracing::info!(
+ worktree = %worktree_path.display(),
+ "Aborting merge"
+ );
+
+ let output = Command::new("git")
+ .args(["merge", "--abort"])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to abort merge: {}",
+ stderr
+ )));
+ }
+
+ Ok(())
+ }
+
+ /// Commit the current merge.
+ pub async fn commit_merge(
+ &self,
+ worktree_path: &Path,
+ message: &str,
+ ) -> Result<String, WorktreeError> {
+ // Check for remaining conflicts
+ let conflicts = self.get_conflicted_files(worktree_path).await?;
+ if !conflicts.is_empty() {
+ return Err(WorktreeError::MergeConflicts(conflicts.join(", ")));
+ }
+
+ tracing::info!(
+ worktree = %worktree_path.display(),
+ message = %message,
+ "Committing merge"
+ );
+
+ let output = Command::new("git")
+ .args(["commit", "-m", message])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to commit merge: {}",
+ stderr
+ )));
+ }
+
+ // Get the new commit SHA
+ let output = Command::new("git")
+ .args(["rev-parse", "HEAD"])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if output.status.success() {
+ let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
+ return Ok(sha);
+ }
+
+ Ok("unknown".to_string())
+ }
+
+ // ========== Completion Action Operations ==========
+
+ /// Push task branch from worktree to an external target repository.
+ ///
+ /// This stages and commits any uncommitted changes, then pushes to the target repo.
+ pub async fn push_to_target_repo(
+ &self,
+ worktree_path: &Path,
+ target_repo: &Path,
+ branch_name: &str,
+ task_name: &str,
+ ) -> Result<(), WorktreeError> {
+ tracing::info!(
+ worktree = %worktree_path.display(),
+ target_repo = %target_repo.display(),
+ branch = %branch_name,
+ "Pushing branch to target repository"
+ );
+
+ // First, stage all changes (including new files)
+ let output = Command::new("git")
+ .args(["add", "-A"])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to stage changes: {}",
+ stderr
+ )));
+ }
+
+ // Check if there are staged changes to commit
+ let output = Command::new("git")
+ .args(["diff", "--cached", "--quiet"])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ // Exit code 1 means there are staged changes
+ if !output.status.success() {
+ tracing::info!("Committing staged changes before push");
+
+ let commit_message = format!("feat: {}", task_name);
+ let output = Command::new("git")
+ .args(["commit", "-m", &commit_message])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to commit changes: {}",
+ stderr
+ )));
+ }
+ }
+
+ // Ensure there are commits to push
+ let output = Command::new("git")
+ .args(["log", "--oneline", "-1"])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ return Err(WorktreeError::GitCommand(
+ "No commits in worktree".to_string(),
+ ));
+ }
+
+ // Add target repo as a remote in the worktree (if not already)
+ let remote_name = "target";
+ let target_path_str = target_repo.to_string_lossy();
+
+ // Remove existing remote if any (ignore errors)
+ let _ = Command::new("git")
+ .args(["remote", "remove", remote_name])
+ .current_dir(worktree_path)
+ .output()
+ .await;
+
+ // Add the target as a remote
+ let output = Command::new("git")
+ .args(["remote", "add", remote_name, &target_path_str])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to add remote: {}",
+ stderr
+ )));
+ }
+
+ // Push the branch to the target
+ let output = Command::new("git")
+ .args(["push", "-u", remote_name, &format!("HEAD:{}", branch_name)])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to push to target: {}",
+ stderr
+ )));
+ }
+
+ tracing::info!(
+ branch = %branch_name,
+ target_repo = %target_repo.display(),
+ "Branch pushed successfully"
+ );
+
+ // Detach HEAD in the worktree to release the branch
+ // This allows the branch to be checked out in the target repo
+ let output = Command::new("git")
+ .args(["checkout", "--detach", "HEAD"])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ // Non-fatal: log but don't fail the push
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ tracing::warn!(
+ "Failed to detach HEAD in worktree (branch may not be checkable in target): {}",
+ stderr
+ );
+ } else {
+ tracing::info!("Detached HEAD in worktree to release branch");
+ }
+
+ Ok(())
+ }
+
+ /// Merge a branch into the target branch in the target repository.
+ ///
+ /// This pushes the branch first (if needed), then performs a merge in the target repo.
+ pub async fn merge_to_target(
+ &self,
+ worktree_path: &Path,
+ target_repo: &Path,
+ source_branch: &str,
+ target_branch: &str,
+ task_name: &str,
+ ) -> Result<String, WorktreeError> {
+ tracing::info!(
+ worktree = %worktree_path.display(),
+ target_repo = %target_repo.display(),
+ source_branch = %source_branch,
+ target_branch = %target_branch,
+ "Merging branch to target"
+ );
+
+ // First, push the branch to target repo
+ self.push_to_target_repo(worktree_path, target_repo, source_branch, task_name)
+ .await?;
+
+ // In target repo, checkout the target branch
+ let output = Command::new("git")
+ .args(["checkout", target_branch])
+ .current_dir(target_repo)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to checkout target branch: {}",
+ stderr
+ )));
+ }
+
+ // Pull latest changes first
+ let _ = Command::new("git")
+ .args(["pull", "--ff-only"])
+ .current_dir(target_repo)
+ .output()
+ .await;
+
+ // Merge the source branch
+ let merge_message = format!("feat: {}", task_name);
+ let output = Command::new("git")
+ .args(["merge", "--no-ff", source_branch, "-m", &merge_message])
+ .current_dir(target_repo)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+
+ // Check if it's a conflict
+ let conflicts = self.get_conflicted_files(target_repo).await?;
+ if !conflicts.is_empty() {
+ // Abort the merge
+ let _ = Command::new("git")
+ .args(["merge", "--abort"])
+ .current_dir(target_repo)
+ .output()
+ .await;
+
+ return Err(WorktreeError::MergeConflicts(format!(
+ "Merge conflicts in: {}. Consider creating a PR instead.",
+ conflicts.join(", ")
+ )));
+ }
+
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to merge: {}",
+ stderr
+ )));
+ }
+
+ // Get the merge commit SHA
+ let output = Command::new("git")
+ .args(["rev-parse", "HEAD"])
+ .current_dir(target_repo)
+ .output()
+ .await?;
+
+ let commit_sha = if output.status.success() {
+ String::from_utf8_lossy(&output.stdout).trim().to_string()
+ } else {
+ "unknown".to_string()
+ };
+
+ tracing::info!(
+ commit_sha = %commit_sha,
+ "Merge completed successfully"
+ );
+
+ Ok(commit_sha)
+ }
+
+ /// Create a GitHub pull request using the gh CLI.
+ ///
+ /// This pushes the branch first, then creates a PR.
+ pub async fn create_pull_request(
+ &self,
+ worktree_path: &Path,
+ target_repo: &Path,
+ source_branch: &str,
+ target_branch: &str,
+ title: &str,
+ body: &str,
+ ) -> Result<String, WorktreeError> {
+ tracing::info!(
+ worktree = %worktree_path.display(),
+ target_repo = %target_repo.display(),
+ source_branch = %source_branch,
+ target_branch = %target_branch,
+ title = %title,
+ "Creating pull request"
+ );
+
+ // First, push the branch to the target repo's remote
+ // For PRs, we need to push to origin (the GitHub remote)
+
+ // Get the worktree's current branch
+ let output = Command::new("git")
+ .args(["rev-parse", "--abbrev-ref", "HEAD"])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ let current_branch = if output.status.success() {
+ String::from_utf8_lossy(&output.stdout).trim().to_string()
+ } else {
+ source_branch.to_string()
+ };
+
+ // Push to the target repo's origin
+ // First, check if target_repo has an origin remote
+ let output = Command::new("git")
+ .args(["remote", "get-url", "origin"])
+ .current_dir(target_repo)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ return Err(WorktreeError::GitCommand(
+ "Target repository has no origin remote configured".to_string(),
+ ));
+ }
+
+ let origin_url = String::from_utf8_lossy(&output.stdout).trim().to_string();
+
+ // Push the branch from worktree to the remote
+ // First add the remote to worktree
+ let _ = Command::new("git")
+ .args(["remote", "remove", "pr-origin"])
+ .current_dir(worktree_path)
+ .output()
+ .await;
+
+ let output = Command::new("git")
+ .args(["remote", "add", "pr-origin", &origin_url])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to add remote: {}",
+ stderr
+ )));
+ }
+
+ // Push to the remote
+ let output = Command::new("git")
+ .args(["push", "-u", "pr-origin", &format!("{}:{}", current_branch, source_branch)])
+ .current_dir(worktree_path)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to push branch: {}",
+ stderr
+ )));
+ }
+
+ // Create PR using gh CLI in the target repo
+ let output = Command::new("gh")
+ .args([
+ "pr",
+ "create",
+ "--title", title,
+ "--body", body,
+ "--head", source_branch,
+ "--base", target_branch,
+ ])
+ .current_dir(target_repo)
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::GitCommand(format!(
+ "Failed to create PR: {}",
+ stderr
+ )));
+ }
+
+ // The gh CLI outputs the PR URL
+ let pr_url = String::from_utf8_lossy(&output.stdout).trim().to_string();
+
+ tracing::info!(
+ pr_url = %pr_url,
+ "Pull request created successfully"
+ );
+
+ Ok(pr_url)
+ }
+
+ /// Clone/copy the worktree contents to a target directory.
+ ///
+ /// This creates a new git repository at the target path with the same contents
+ /// as the worktree. Returns (success, message).
+ pub async fn clone_worktree_to_directory(
+ &self,
+ worktree_path: &Path,
+ target_dir: &Path,
+ ) -> Result<String, WorktreeError> {
+ tracing::info!(
+ worktree = %worktree_path.display(),
+ target = %target_dir.display(),
+ "Cloning worktree to target directory"
+ );
+
+ // Check if target directory already exists
+ if target_dir.exists() {
+ return Err(WorktreeError::AlreadyExists(format!(
+ "Target directory already exists: {}",
+ target_dir.display()
+ )));
+ }
+
+ // Get parent directory to ensure it exists
+ if let Some(parent) = target_dir.parent() {
+ if !parent.exists() {
+ tokio::fs::create_dir_all(parent).await?;
+ }
+ }
+
+ // Use git clone --local to efficiently copy the repository
+ // This is more efficient than cp -r for git repos
+ let output = Command::new("git")
+ .args([
+ "clone",
+ "--local",
+ "--no-hardlinks",
+ &worktree_path.to_string_lossy(),
+ &target_dir.to_string_lossy(),
+ ])
+ .output()
+ .await?;
+
+ if !output.status.success() {
+ let stderr = String::from_utf8_lossy(&output.stderr);
+ return Err(WorktreeError::CloneFailed(format!(
+ "Failed to clone worktree: {}",
+ stderr
+ )));
+ }
+
+ // Remove the 'origin' remote that points back to the worktree
+ let _ = Command::new("git")
+ .args(["remote", "remove", "origin"])
+ .current_dir(target_dir)
+ .output()
+ .await;
+
+ tracing::info!(
+ target = %target_dir.display(),
+ "Worktree cloned successfully"
+ );
+
+ Ok(format!("Cloned to {}", target_dir.display()))
+ }
+
+ /// Check if a target directory exists.
+ pub async fn target_directory_exists(&self, target_dir: &Path) -> bool {
+ target_dir.exists()
+ }
+}
+
+/// Check if repo_source is a "new repo" request.
+///
+/// Accepts `new://` or `new://project-name` to create a fresh git repository.
+pub fn is_new_repo_request(source: &str) -> bool {
+ source == "new" || source == "new://" || source.starts_with("new://")
+}
+
+/// Extract optional project name from new:// URL.
+fn extract_new_repo_name(source: &str) -> Option<&str> {
+ source.strip_prefix("new://").filter(|s| !s.is_empty())
+}
+
+/// Extract repository name from URL.
+fn extract_repo_name(url: &str) -> String {
+ // Handle various URL formats:
+ // https://github.com/user/repo.git -> repo
+ // git@github.com:user/repo.git -> repo
+ // https://github.com/user/repo -> repo
+
+ let url = url.trim_end_matches('/');
+ let url = url.trim_end_matches(".git");
+
+ url.rsplit('/')
+ .next()
+ .or_else(|| url.rsplit(':').next())
+ .unwrap_or("repo")
+ .to_string()
+}
+
+/// Create a short UUID string for directory naming.
+pub fn short_uuid(id: Uuid) -> String {
+ id.to_string()[..8].to_string()
+}
+
+/// Expand tilde (~) in path to home directory.
+pub fn expand_tilde(path: &str) -> PathBuf {
+ if let Some(rest) = path.strip_prefix("~/") {
+ if let Some(home) = dirs::home_dir() {
+ return home.join(rest);
+ }
+ } else if path == "~" {
+ if let Some(home) = dirs::home_dir() {
+ return home;
+ }
+ }
+ PathBuf::from(path)
+}
+
+/// Sanitize a name for use in directory/branch names.
+pub fn sanitize_name(name: &str) -> String {
+ name.chars()
+ .map(|c| {
+ if c.is_alphanumeric() || c == '-' || c == '_' {
+ c.to_ascii_lowercase()
+ } else {
+ '-'
+ }
+ })
+ .collect::<String>()
+ .chars()
+ .take(50) // Limit length
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_extract_repo_name() {
+ assert_eq!(
+ extract_repo_name("https://github.com/user/repo.git"),
+ "repo"
+ );
+ assert_eq!(
+ extract_repo_name("https://github.com/user/repo"),
+ "repo"
+ );
+ assert_eq!(
+ extract_repo_name("git@github.com:user/repo.git"),
+ "repo"
+ );
+ }
+
+ #[test]
+ fn test_sanitize_name() {
+ assert_eq!(sanitize_name("Hello World!"), "hello-world-");
+ assert_eq!(sanitize_name("test_name-123"), "test_name-123");
+ assert_eq!(sanitize_name("A".repeat(100).as_str()).len(), 50);
+ }
+
+ #[test]
+ fn test_short_uuid() {
+ let id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap();
+ assert_eq!(short_uuid(id), "550e8400");
+ }
+}
diff --git a/makima/daemon/src/worktree/mod.rs b/makima/daemon/src/worktree/mod.rs
new file mode 100644
index 0000000..eb9f031
--- /dev/null
+++ b/makima/daemon/src/worktree/mod.rs
@@ -0,0 +1,11 @@
+//! Git worktree management for task isolation.
+//!
+//! Each task gets a unique git worktree with its own branch,
+//! providing isolation without the overhead of Docker containers.
+
+mod manager;
+
+pub use manager::{
+ expand_tilde, is_new_repo_request, sanitize_name, short_uuid, ConflictResolution, MergeState,
+ TaskBranchInfo, WorktreeError, WorktreeInfo, WorktreeManager,
+};
diff --git a/makima/daemon/src/ws/client.rs b/makima/daemon/src/ws/client.rs
new file mode 100644
index 0000000..ba1263f
--- /dev/null
+++ b/makima/daemon/src/ws/client.rs
@@ -0,0 +1,290 @@
+//! WebSocket client for connecting to the makima server.
+
+use std::sync::Arc;
+use std::time::Duration;
+
+use backoff::backoff::Backoff;
+use backoff::ExponentialBackoff;
+use futures::{SinkExt, StreamExt};
+use tokio::sync::{mpsc, RwLock};
+use tokio_tungstenite::{connect_async, tungstenite::{client::IntoClientRequest, Message}};
+use uuid::Uuid;
+
+use super::protocol::{DaemonCommand, DaemonMessage};
+use crate::config::ServerConfig;
+use crate::error::{DaemonError, Result};
+
+/// WebSocket client state.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ConnectionState {
+ /// Not connected to server.
+ Disconnected,
+ /// Currently connecting.
+ Connecting,
+ /// Connected and authenticated.
+ Connected,
+ /// Connection failed, will retry.
+ Reconnecting,
+ /// Permanently failed (e.g., auth failure).
+ Failed,
+}
+
+/// WebSocket client for daemon-server communication.
+pub struct WsClient {
+ config: ServerConfig,
+ machine_id: String,
+ hostname: String,
+ max_concurrent_tasks: i32,
+ state: Arc<RwLock<ConnectionState>>,
+ daemon_id: Arc<RwLock<Option<Uuid>>>,
+ /// Channel to receive messages to send to server.
+ outgoing_rx: mpsc::Receiver<DaemonMessage>,
+ /// Sender for outgoing messages (clone this to send messages).
+ outgoing_tx: mpsc::Sender<DaemonMessage>,
+ /// Channel to send received commands to the task manager.
+ incoming_tx: mpsc::Sender<DaemonCommand>,
+}
+
+impl WsClient {
+ /// Create a new WebSocket client.
+ pub fn new(
+ config: ServerConfig,
+ machine_id: String,
+ hostname: String,
+ max_concurrent_tasks: i32,
+ incoming_tx: mpsc::Sender<DaemonCommand>,
+ ) -> Self {
+ let (outgoing_tx, outgoing_rx) = mpsc::channel(256);
+
+ Self {
+ config,
+ machine_id,
+ hostname,
+ max_concurrent_tasks,
+ state: Arc::new(RwLock::new(ConnectionState::Disconnected)),
+ daemon_id: Arc::new(RwLock::new(None)),
+ outgoing_rx,
+ outgoing_tx,
+ incoming_tx,
+ }
+ }
+
+ /// Get a sender for outgoing messages.
+ pub fn sender(&self) -> mpsc::Sender<DaemonMessage> {
+ self.outgoing_tx.clone()
+ }
+
+ /// Get current connection state.
+ pub async fn state(&self) -> ConnectionState {
+ *self.state.read().await
+ }
+
+ /// Get daemon ID if authenticated.
+ pub async fn daemon_id(&self) -> Option<Uuid> {
+ *self.daemon_id.read().await
+ }
+
+ /// Run the WebSocket client with automatic reconnection.
+ pub async fn run(&mut self) -> Result<()> {
+ let mut backoff = ExponentialBackoff {
+ initial_interval: Duration::from_secs(self.config.reconnect_interval_secs),
+ max_interval: Duration::from_secs(60),
+ max_elapsed_time: if self.config.max_reconnect_attempts > 0 {
+ Some(Duration::from_secs(
+ self.config.reconnect_interval_secs * self.config.max_reconnect_attempts as u64 * 10,
+ ))
+ } else {
+ None // Infinite retries
+ },
+ ..Default::default()
+ };
+
+ loop {
+ *self.state.write().await = ConnectionState::Connecting;
+ tracing::info!("Connecting to server: {}", self.config.url);
+
+ match self.connect_and_run().await {
+ Ok(()) => {
+ // Clean shutdown
+ tracing::info!("WebSocket connection closed cleanly");
+ break;
+ }
+ Err(DaemonError::AuthFailed(msg)) => {
+ tracing::error!("Authentication failed: {}", msg);
+ *self.state.write().await = ConnectionState::Failed;
+ return Err(DaemonError::AuthFailed(msg));
+ }
+ Err(e) => {
+ tracing::warn!("Connection error: {}", e);
+ *self.state.write().await = ConnectionState::Reconnecting;
+
+ if let Some(delay) = backoff.next_backoff() {
+ tracing::info!("Reconnecting in {:?}...", delay);
+ tokio::time::sleep(delay).await;
+ } else {
+ tracing::error!("Max reconnection attempts reached");
+ *self.state.write().await = ConnectionState::Failed;
+ return Err(DaemonError::ConnectionLost);
+ }
+ }
+ }
+ }
+
+ Ok(())
+ }
+
+ /// Connect to server and run the message loop.
+ async fn connect_and_run(&mut self) -> Result<()> {
+ // Build WebSocket URL
+ let ws_url = format!("{}/api/v1/mesh/daemons/connect", self.config.url);
+ tracing::debug!("Connecting to WebSocket: {}", ws_url);
+
+ // Build request with API key header
+ let mut request = ws_url.into_client_request()?;
+ request.headers_mut().insert(
+ "x-makima-api-key",
+ self.config.api_key.parse().map_err(|_| {
+ DaemonError::AuthFailed("Invalid API key format".into())
+ })?,
+ );
+
+ // Connect with API key in headers
+ let (ws_stream, _response) = connect_async(request).await?;
+ let (mut write, mut read) = ws_stream.split();
+
+ // Send daemon info after connection (server authenticated us via header)
+ let info_msg = DaemonMessage::authenticate(
+ &self.config.api_key,
+ &self.machine_id,
+ &self.hostname,
+ self.max_concurrent_tasks,
+ );
+ let info_json = serde_json::to_string(&info_msg)?;
+ write.send(Message::Text(info_json)).await?;
+
+ // Wait for authentication response
+ let auth_response = read
+ .next()
+ .await
+ .ok_or(DaemonError::ConnectionLost)??;
+
+ let auth_text = match auth_response {
+ Message::Text(text) => text,
+ Message::Close(_) => return Err(DaemonError::ConnectionLost),
+ _ => return Err(DaemonError::AuthFailed("Unexpected response type".into())),
+ };
+
+ let command: DaemonCommand = serde_json::from_str(&auth_text)?;
+ match command {
+ DaemonCommand::Authenticated { daemon_id } => {
+ tracing::info!("Authenticated with daemon ID: {}", daemon_id);
+ *self.daemon_id.write().await = Some(daemon_id);
+ *self.state.write().await = ConnectionState::Connected;
+
+ // Send daemon directories info to server
+ let working_directory = std::env::current_dir()
+ .map(|p| p.to_string_lossy().to_string())
+ .unwrap_or_else(|_| ".".to_string());
+ let home_directory = dirs::home_dir()
+ .map(|h| h.join(".makima").join("home"))
+ .unwrap_or_else(|| std::path::PathBuf::from("~/.makima/home"));
+ // Create home directory if it doesn't exist
+ if let Err(e) = std::fs::create_dir_all(&home_directory) {
+ tracing::warn!("Failed to create home directory {:?}: {}", home_directory, e);
+ }
+ let home_directory_str = home_directory.to_string_lossy().to_string();
+ let worktrees_directory = dirs::home_dir()
+ .map(|h| h.join(".makima").join("worktrees").to_string_lossy().to_string())
+ .unwrap_or_else(|| "~/.makima/worktrees".to_string());
+
+ let dirs_msg = DaemonMessage::DaemonDirectories {
+ working_directory,
+ home_directory: home_directory_str,
+ worktrees_directory,
+ };
+ let dirs_json = serde_json::to_string(&dirs_msg)?;
+ write.send(Message::Text(dirs_json)).await?;
+ tracing::info!("Sent daemon directories info to server");
+ }
+ DaemonCommand::Error { code, message } => {
+ return Err(DaemonError::AuthFailed(format!("{}: {}", code, message)));
+ }
+ _ => {
+ return Err(DaemonError::AuthFailed(
+ "Unexpected response to authentication".into(),
+ ));
+ }
+ }
+
+ // Start main message loop
+ let heartbeat_interval = Duration::from_secs(self.config.heartbeat_interval_secs);
+ let mut heartbeat_timer = tokio::time::interval(heartbeat_interval);
+
+ loop {
+ tokio::select! {
+ // Handle incoming server commands
+ msg = read.next() => {
+ match msg {
+ Some(Ok(Message::Text(text))) => {
+ tracing::info!("Received WebSocket message: {} bytes", text.len());
+ match serde_json::from_str::<DaemonCommand>(&text) {
+ Ok(command) => {
+ tracing::info!("Parsed command: {:?}", command);
+ tracing::info!("Sending command to task manager channel...");
+ if self.incoming_tx.send(command).await.is_err() {
+ tracing::warn!("Command receiver dropped, shutting down");
+ break;
+ }
+ tracing::info!("Command sent to task manager successfully");
+ }
+ Err(e) => {
+ tracing::warn!("Failed to parse server message: {}", e);
+ tracing::debug!("Raw message: {}", text);
+ }
+ }
+ }
+ Some(Ok(Message::Ping(data))) => {
+ write.send(Message::Pong(data)).await?;
+ }
+ Some(Ok(Message::Close(_))) | None => {
+ tracing::info!("Server closed connection");
+ return Err(DaemonError::ConnectionLost);
+ }
+ Some(Err(e)) => {
+ tracing::warn!("WebSocket error: {}", e);
+ return Err(e.into());
+ }
+ _ => {}
+ }
+ }
+
+ // Handle outgoing messages
+ msg = self.outgoing_rx.recv() => {
+ match msg {
+ Some(message) => {
+ let json = serde_json::to_string(&message)?;
+ tracing::trace!("Sending message: {}", json);
+ write.send(Message::Text(json)).await?;
+ }
+ None => {
+ // Sender dropped, shutdown
+ tracing::info!("Outgoing channel closed, shutting down");
+ break;
+ }
+ }
+ }
+
+ // Send heartbeat
+ _ = heartbeat_timer.tick() => {
+ // Get active task IDs from task manager
+ // For now, send empty list - will be connected to task manager
+ let heartbeat = DaemonMessage::heartbeat(vec![]);
+ let json = serde_json::to_string(&heartbeat)?;
+ write.send(Message::Text(json)).await?;
+ }
+ }
+ }
+
+ Ok(())
+ }
+}
diff --git a/makima/daemon/src/ws/mod.rs b/makima/daemon/src/ws/mod.rs
new file mode 100644
index 0000000..5a0e9d1
--- /dev/null
+++ b/makima/daemon/src/ws/mod.rs
@@ -0,0 +1,7 @@
+//! WebSocket client and protocol types for daemon-server communication.
+
+pub mod client;
+pub mod protocol;
+
+pub use client::{ConnectionState, WsClient};
+pub use protocol::{BranchInfo, DaemonCommand, DaemonMessage};
diff --git a/makima/daemon/src/ws/protocol.rs b/makima/daemon/src/ws/protocol.rs
new file mode 100644
index 0000000..7c2ad6d
--- /dev/null
+++ b/makima/daemon/src/ws/protocol.rs
@@ -0,0 +1,511 @@
+//! Protocol types for daemon-server communication.
+//!
+//! These types mirror the server's protocol exactly for compatibility.
+
+use serde::{Deserialize, Serialize};
+use uuid::Uuid;
+
+/// Message from daemon to server.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "camelCase")]
+pub enum DaemonMessage {
+ /// Authentication request (first message required).
+ Authenticate {
+ #[serde(rename = "apiKey")]
+ api_key: String,
+ #[serde(rename = "machineId")]
+ machine_id: String,
+ hostname: String,
+ #[serde(rename = "maxConcurrentTasks")]
+ max_concurrent_tasks: i32,
+ },
+
+ /// Periodic heartbeat with current status.
+ Heartbeat {
+ #[serde(rename = "activeTasks")]
+ active_tasks: Vec<Uuid>,
+ },
+
+ /// Task output streaming (stdout/stderr from Claude Code).
+ TaskOutput {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ output: String,
+ #[serde(rename = "isPartial")]
+ is_partial: bool,
+ },
+
+ /// Task status change notification.
+ TaskStatusChange {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ #[serde(rename = "oldStatus")]
+ old_status: String,
+ #[serde(rename = "newStatus")]
+ new_status: String,
+ },
+
+ /// Task progress update with summary.
+ TaskProgress {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ summary: String,
+ },
+
+ /// Task completion notification.
+ TaskComplete {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ success: bool,
+ error: Option<String>,
+ },
+
+ /// Register a tool key for orchestrator API access.
+ RegisterToolKey {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ /// The API key for this orchestrator to use when calling mesh endpoints.
+ key: String,
+ },
+
+ /// Revoke a tool key when task completes.
+ RevokeToolKey {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+
+ // =========================================================================
+ // Merge Response Messages (sent by daemon after processing merge commands)
+ // =========================================================================
+
+ /// Response to ListBranches command.
+ BranchList {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ branches: Vec<BranchInfo>,
+ },
+
+ /// Response to MergeStatus command.
+ MergeStatusResponse {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ #[serde(rename = "inProgress")]
+ in_progress: bool,
+ #[serde(rename = "sourceBranch")]
+ source_branch: Option<String>,
+ #[serde(rename = "conflictedFiles")]
+ conflicted_files: Vec<String>,
+ },
+
+ /// Response to merge operations (MergeStart, MergeResolve, MergeCommit, MergeAbort).
+ MergeResult {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ success: bool,
+ message: String,
+ #[serde(rename = "commitSha")]
+ commit_sha: Option<String>,
+ /// Present only when conflicts occurred.
+ conflicts: Option<Vec<String>>,
+ },
+
+ /// Response to CheckMergeComplete command.
+ MergeCompleteCheck {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ #[serde(rename = "canComplete")]
+ can_complete: bool,
+ #[serde(rename = "unmergedBranches")]
+ unmerged_branches: Vec<String>,
+ #[serde(rename = "mergedCount")]
+ merged_count: u32,
+ #[serde(rename = "skippedCount")]
+ skipped_count: u32,
+ },
+
+ // =========================================================================
+ // Completion Action Response Messages
+ // =========================================================================
+
+ /// Response to RetryCompletionAction command.
+ CompletionActionResult {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ success: bool,
+ message: String,
+ /// PR URL if action was "pr" and successful.
+ #[serde(rename = "prUrl")]
+ pr_url: Option<String>,
+ },
+
+ /// Report daemon's available directories for task output.
+ DaemonDirectories {
+ /// Current working directory of the daemon.
+ #[serde(rename = "workingDirectory")]
+ working_directory: String,
+ /// Path to ~/.makima/home directory (for cloning completed work).
+ #[serde(rename = "homeDirectory")]
+ home_directory: String,
+ /// Path to worktrees directory (~/.makima/worktrees).
+ #[serde(rename = "worktreesDirectory")]
+ worktrees_directory: String,
+ },
+
+ /// Response to CloneWorktree command.
+ CloneWorktreeResult {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ success: bool,
+ message: String,
+ /// The path where the worktree was cloned.
+ #[serde(rename = "targetDir")]
+ target_dir: Option<String>,
+ },
+
+ /// Response to CheckTargetExists command.
+ CheckTargetExistsResult {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ /// Whether the target directory exists.
+ exists: bool,
+ /// The path that was checked.
+ #[serde(rename = "targetDir")]
+ target_dir: String,
+ },
+}
+
+/// Information about a branch (used in BranchList message).
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct BranchInfo {
+ /// Full branch name.
+ pub name: String,
+ /// Task ID extracted from branch name (if parseable).
+ #[serde(rename = "taskId")]
+ pub task_id: Option<Uuid>,
+ /// Whether this branch has been merged.
+ #[serde(rename = "isMerged")]
+ pub is_merged: bool,
+ /// Short SHA of the last commit.
+ #[serde(rename = "lastCommit")]
+ pub last_commit: String,
+ /// Subject line of the last commit.
+ #[serde(rename = "lastCommitMessage")]
+ pub last_commit_message: String,
+}
+
+/// Command from server to daemon.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "type", rename_all = "camelCase")]
+pub enum DaemonCommand {
+ /// Confirm successful authentication.
+ Authenticated {
+ #[serde(rename = "daemonId")]
+ daemon_id: Uuid,
+ },
+
+ /// Spawn a new task in a container.
+ SpawnTask {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ /// Human-readable task name (used for commit messages).
+ #[serde(rename = "taskName")]
+ task_name: String,
+ plan: String,
+ #[serde(rename = "repoUrl")]
+ repo_url: Option<String>,
+ #[serde(rename = "baseBranch")]
+ base_branch: Option<String>,
+ /// Target branch to merge into (used for completion actions).
+ #[serde(rename = "targetBranch")]
+ target_branch: Option<String>,
+ /// Parent task ID if this is a subtask.
+ #[serde(rename = "parentTaskId")]
+ parent_task_id: Option<Uuid>,
+ /// Depth in task hierarchy (0=top-level, 1=subtask, 2=sub-subtask).
+ depth: i32,
+ /// Whether this task should run as an orchestrator (true if depth==0 and has subtasks).
+ #[serde(rename = "isOrchestrator")]
+ is_orchestrator: bool,
+ /// Path to user's local repository (outside ~/.makima) for completion actions.
+ #[serde(rename = "targetRepoPath")]
+ target_repo_path: Option<String>,
+ /// Action on completion: "none", "branch", "merge", "pr".
+ #[serde(rename = "completionAction")]
+ completion_action: Option<String>,
+ /// Task ID to continue from (copy worktree from this task).
+ #[serde(rename = "continueFromTaskId")]
+ continue_from_task_id: Option<Uuid>,
+ /// Files to copy from parent task's worktree.
+ #[serde(rename = "copyFiles")]
+ copy_files: Option<Vec<String>>,
+ },
+
+ /// Pause a running task.
+ PauseTask {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+
+ /// Resume a paused task.
+ ResumeTask {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+
+ /// Interrupt a task (gracefully or forced).
+ InterruptTask {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ graceful: bool,
+ },
+
+ /// Send a message to a running task.
+ SendMessage {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ message: String,
+ },
+
+ /// Inject context about sibling task progress.
+ InjectSiblingContext {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ #[serde(rename = "siblingTaskId")]
+ sibling_task_id: Uuid,
+ #[serde(rename = "siblingName")]
+ sibling_name: String,
+ #[serde(rename = "siblingStatus")]
+ sibling_status: String,
+ #[serde(rename = "progressSummary")]
+ progress_summary: Option<String>,
+ #[serde(rename = "changedFiles")]
+ changed_files: Vec<String>,
+ },
+
+ // =========================================================================
+ // Merge Commands (for orchestrators to merge subtask branches)
+ // =========================================================================
+
+ /// List all subtask branches for a task.
+ ListBranches {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+
+ /// Start merging a subtask branch.
+ MergeStart {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ #[serde(rename = "sourceBranch")]
+ source_branch: String,
+ },
+
+ /// Get current merge status.
+ MergeStatus {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+
+ /// Resolve a merge conflict.
+ MergeResolve {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ file: String,
+ /// "ours" or "theirs"
+ strategy: String,
+ },
+
+ /// Commit the current merge.
+ MergeCommit {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ message: String,
+ },
+
+ /// Abort the current merge.
+ MergeAbort {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+
+ /// Skip merging a subtask branch (mark as intentionally not merged).
+ MergeSkip {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ #[serde(rename = "subtaskId")]
+ subtask_id: Uuid,
+ reason: String,
+ },
+
+ /// Check if all subtask branches have been merged or skipped (completion gate).
+ CheckMergeComplete {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+
+ // =========================================================================
+ // Completion Action Commands
+ // =========================================================================
+
+ /// Retry a completion action for a completed task.
+ RetryCompletionAction {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ /// Human-readable task name (used for commit messages).
+ #[serde(rename = "taskName")]
+ task_name: String,
+ /// The action to execute: "branch", "merge", or "pr".
+ action: String,
+ /// Path to the target repository.
+ #[serde(rename = "targetRepoPath")]
+ target_repo_path: String,
+ /// Target branch to merge into (for merge/pr actions).
+ #[serde(rename = "targetBranch")]
+ target_branch: Option<String>,
+ },
+
+ /// Clone worktree to a target directory.
+ CloneWorktree {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ /// Path to the target directory.
+ #[serde(rename = "targetDir")]
+ target_dir: String,
+ },
+
+ /// Check if a target directory exists.
+ CheckTargetExists {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ /// Path to check.
+ #[serde(rename = "targetDir")]
+ target_dir: String,
+ },
+
+ /// Error response.
+ Error {
+ code: String,
+ message: String,
+ },
+}
+
+impl DaemonMessage {
+ /// Create an authentication message.
+ pub fn authenticate(
+ api_key: &str,
+ machine_id: &str,
+ hostname: &str,
+ max_concurrent_tasks: i32,
+ ) -> Self {
+ Self::Authenticate {
+ api_key: api_key.to_string(),
+ machine_id: machine_id.to_string(),
+ hostname: hostname.to_string(),
+ max_concurrent_tasks,
+ }
+ }
+
+ /// Create a heartbeat message.
+ pub fn heartbeat(active_tasks: Vec<Uuid>) -> Self {
+ Self::Heartbeat { active_tasks }
+ }
+
+ /// Create a task output message.
+ pub fn task_output(task_id: Uuid, output: String, is_partial: bool) -> Self {
+ Self::TaskOutput {
+ task_id,
+ output,
+ is_partial,
+ }
+ }
+
+ /// Create a task status change message.
+ pub fn task_status_change(task_id: Uuid, old_status: &str, new_status: &str) -> Self {
+ Self::TaskStatusChange {
+ task_id,
+ old_status: old_status.to_string(),
+ new_status: new_status.to_string(),
+ }
+ }
+
+ /// Create a task progress message.
+ pub fn task_progress(task_id: Uuid, summary: String) -> Self {
+ Self::TaskProgress { task_id, summary }
+ }
+
+ /// Create a task complete message.
+ pub fn task_complete(task_id: Uuid, success: bool, error: Option<String>) -> Self {
+ Self::TaskComplete {
+ task_id,
+ success,
+ error,
+ }
+ }
+
+ /// Create a register tool key message.
+ pub fn register_tool_key(task_id: Uuid, key: String) -> Self {
+ Self::RegisterToolKey { task_id, key }
+ }
+
+ /// Create a revoke tool key message.
+ pub fn revoke_tool_key(task_id: Uuid) -> Self {
+ Self::RevokeToolKey { task_id }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_daemon_message_serialization() {
+ let msg = DaemonMessage::authenticate("key123", "machine-abc", "worker-1", 4);
+ let json = serde_json::to_string(&msg).unwrap();
+ assert!(json.contains("\"type\":\"authenticate\""));
+ assert!(json.contains("\"apiKey\":\"key123\""));
+ assert!(json.contains("\"machineId\":\"machine-abc\""));
+ }
+
+ #[test]
+ fn test_daemon_command_deserialization() {
+ let json = r#"{"type":"spawnTask","taskId":"550e8400-e29b-41d4-a716-446655440000","plan":"Build the feature","repoUrl":"https://github.com/test/repo","baseBranch":"main","parentTaskId":null,"depth":0,"isOrchestrator":false}"#;
+ let cmd: DaemonCommand = serde_json::from_str(json).unwrap();
+ match cmd {
+ DaemonCommand::SpawnTask {
+ plan,
+ repo_url,
+ base_branch,
+ parent_task_id,
+ depth,
+ is_orchestrator,
+ ..
+ } => {
+ assert_eq!(plan, "Build the feature");
+ assert_eq!(repo_url, Some("https://github.com/test/repo".to_string()));
+ assert_eq!(base_branch, Some("main".to_string()));
+ assert_eq!(parent_task_id, None);
+ assert_eq!(depth, 0);
+ assert!(!is_orchestrator);
+ }
+ _ => panic!("Expected SpawnTask"),
+ }
+ }
+
+ #[test]
+ fn test_orchestrator_spawn_deserialization() {
+ let json = r#"{"type":"spawnTask","taskId":"550e8400-e29b-41d4-a716-446655440000","plan":"Coordinate subtasks","repoUrl":"https://github.com/test/repo","baseBranch":"main","parentTaskId":null,"depth":0,"isOrchestrator":true}"#;
+ let cmd: DaemonCommand = serde_json::from_str(json).unwrap();
+ match cmd {
+ DaemonCommand::SpawnTask {
+ is_orchestrator,
+ depth,
+ ..
+ } => {
+ assert!(is_orchestrator);
+ assert_eq!(depth, 0);
+ }
+ _ => panic!("Expected SpawnTask"),
+ }
+ }
+}
diff --git a/makima/frontend/package-lock.json b/makima/frontend/package-lock.json
index e305d2a..88297c2 100644
--- a/makima/frontend/package-lock.json
+++ b/makima/frontend/package-lock.json
@@ -8,6 +8,7 @@
"name": "makima-frontend",
"version": "0.1.0",
"dependencies": {
+ "@supabase/supabase-js": "^2.90.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.1.0",
@@ -1406,6 +1407,80 @@
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
},
+ "node_modules/@supabase/auth-js": {
+ "version": "2.90.1",
+ "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.90.1.tgz",
+ "integrity": "sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng==",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/functions-js": {
+ "version": "2.90.1",
+ "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.90.1.tgz",
+ "integrity": "sha512-x9mV9dF1Lam9qL3zlpP6mSM5C9iqMPtF5B/tU1Jj/F0ufX5mjDf9ghVBaErVxmrQJRL4+iMKWKY2GnODkpS8tw==",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/postgrest-js": {
+ "version": "2.90.1",
+ "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.90.1.tgz",
+ "integrity": "sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ==",
+ "dependencies": {
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/realtime-js": {
+ "version": "2.90.1",
+ "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.90.1.tgz",
+ "integrity": "sha512-PWbnEMkcQRuor8jhObp4+Snufkq8C6fBp+MchVp2qBPY1NXk/c3Iv3YyiFYVzo0Dzuw4nAlT4+ahuPggy4r32w==",
+ "dependencies": {
+ "@types/phoenix": "^1.6.6",
+ "@types/ws": "^8.18.1",
+ "tslib": "2.8.1",
+ "ws": "^8.18.2"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/storage-js": {
+ "version": "2.90.1",
+ "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.90.1.tgz",
+ "integrity": "sha512-GHY+Ps/K/RBfRj7kwx+iVf2HIdqOS43rM2iDOIDpapyUnGA9CCBFzFV/XvfzznGykd//z2dkGZhlZZprsVFqGg==",
+ "dependencies": {
+ "iceberg-js": "^0.8.1",
+ "tslib": "2.8.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
+ "node_modules/@supabase/supabase-js": {
+ "version": "2.90.1",
+ "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.90.1.tgz",
+ "integrity": "sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w==",
+ "dependencies": {
+ "@supabase/auth-js": "2.90.1",
+ "@supabase/functions-js": "2.90.1",
+ "@supabase/postgrest-js": "2.90.1",
+ "@supabase/realtime-js": "2.90.1",
+ "@supabase/storage-js": "2.90.1"
+ },
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/@tailwindcss/node": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz",
@@ -1764,6 +1839,19 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true
},
+ "node_modules/@types/node": {
+ "version": "25.0.5",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.5.tgz",
+ "integrity": "sha512-FuLxeLuSVOqHPxSN1fkcD8DLU21gAP7nCKqGRJ/FglbCUBs0NYN6TpHcdmyLeh8C0KwGIaZQJSv+OYG+KZz+Gw==",
+ "dependencies": {
+ "undici-types": "~7.16.0"
+ }
+ },
+ "node_modules/@types/phoenix": {
+ "version": "1.6.7",
+ "resolved": "https://registry.npmjs.org/@types/phoenix/-/phoenix-1.6.7.tgz",
+ "integrity": "sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q=="
+ },
"node_modules/@types/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
@@ -1787,6 +1875,14 @@
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
},
+ "node_modules/@types/ws": {
+ "version": "8.18.1",
+ "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
+ "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
"node_modules/@vitejs/plugin-react": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
@@ -2277,6 +2373,14 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true
},
+ "node_modules/iceberg-js": {
+ "version": "0.8.1",
+ "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
+ "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
+ "engines": {
+ "node": ">=20.0.0"
+ }
+ },
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
@@ -3002,6 +3106,11 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
+ },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -3015,6 +3124,11 @@
"node": ">=14.17"
}
},
+ "node_modules/undici-types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
+ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -3190,6 +3304,26 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"dev": true
},
+ "node_modules/ws": {
+ "version": "8.19.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
+ "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/makima/frontend/package.json b/makima/frontend/package.json
index ef99c3d..9293f65 100644
--- a/makima/frontend/package.json
+++ b/makima/frontend/package.json
@@ -11,6 +11,7 @@
"preview": "vite preview"
},
"dependencies": {
+ "@supabase/supabase-js": "^2.90.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router": "^7.1.0",
diff --git a/makima/frontend/pnpm-lock.yaml b/makima/frontend/pnpm-lock.yaml
index afb6e93..3044ad7 100644
--- a/makima/frontend/pnpm-lock.yaml
+++ b/makima/frontend/pnpm-lock.yaml
@@ -5,6 +5,9 @@ settings:
excludeLinksFromLockfile: false
dependencies:
+ '@supabase/supabase-js':
+ specifier: ^2.90.1
+ version: 2.90.1
react:
specifier: ^19.0.0
version: 19.2.3
@@ -938,6 +941,62 @@ packages:
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
dev: false
+ /@supabase/auth-js@2.90.1:
+ resolution: {integrity: sha512-vxb66dgo6h3yyPbR06735Ps+dK3hj0JwS8w9fdQPVZQmocSTlKUW5MfxSy99mN0XqCCuLMQ3jCEiIIUU23e9ng==}
+ engines: {node: '>=20.0.0'}
+ dependencies:
+ tslib: 2.8.1
+ dev: false
+
+ /@supabase/functions-js@2.90.1:
+ resolution: {integrity: sha512-x9mV9dF1Lam9qL3zlpP6mSM5C9iqMPtF5B/tU1Jj/F0ufX5mjDf9ghVBaErVxmrQJRL4+iMKWKY2GnODkpS8tw==}
+ engines: {node: '>=20.0.0'}
+ dependencies:
+ tslib: 2.8.1
+ dev: false
+
+ /@supabase/postgrest-js@2.90.1:
+ resolution: {integrity: sha512-jh6vqzaYzoFn3raaC0hcFt9h+Bt+uxNRBSdc7PfToQeRGk7PDPoweHsbdiPWREtDVTGKfu+PyPW9e2jbK+BCgQ==}
+ engines: {node: '>=20.0.0'}
+ dependencies:
+ tslib: 2.8.1
+ dev: false
+
+ /@supabase/realtime-js@2.90.1:
+ resolution: {integrity: sha512-PWbnEMkcQRuor8jhObp4+Snufkq8C6fBp+MchVp2qBPY1NXk/c3Iv3YyiFYVzo0Dzuw4nAlT4+ahuPggy4r32w==}
+ engines: {node: '>=20.0.0'}
+ dependencies:
+ '@types/phoenix': 1.6.7
+ '@types/ws': 8.18.1
+ tslib: 2.8.1
+ ws: 8.19.0
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+ dev: false
+
+ /@supabase/storage-js@2.90.1:
+ resolution: {integrity: sha512-GHY+Ps/K/RBfRj7kwx+iVf2HIdqOS43rM2iDOIDpapyUnGA9CCBFzFV/XvfzznGykd//z2dkGZhlZZprsVFqGg==}
+ engines: {node: '>=20.0.0'}
+ dependencies:
+ iceberg-js: 0.8.1
+ tslib: 2.8.1
+ dev: false
+
+ /@supabase/supabase-js@2.90.1:
+ resolution: {integrity: sha512-U8KaKGLUgTIFHtwEW1dgw1gK7XrdpvvYo7nzzqPx721GqPe8WZbAiLh/hmyKLGBYQ/mmQNr20vU9tWSDZpii3w==}
+ engines: {node: '>=20.0.0'}
+ dependencies:
+ '@supabase/auth-js': 2.90.1
+ '@supabase/functions-js': 2.90.1
+ '@supabase/postgrest-js': 2.90.1
+ '@supabase/realtime-js': 2.90.1
+ '@supabase/storage-js': 2.90.1
+ transitivePeerDependencies:
+ - bufferutil
+ - utf-8-validate
+ dev: false
+
/@tailwindcss/node@4.1.18:
resolution: {integrity: sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==}
dependencies:
@@ -1168,6 +1227,16 @@ packages:
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
dev: true
+ /@types/node@25.0.5:
+ resolution: {integrity: sha512-FuLxeLuSVOqHPxSN1fkcD8DLU21gAP7nCKqGRJ/FglbCUBs0NYN6TpHcdmyLeh8C0KwGIaZQJSv+OYG+KZz+Gw==}
+ dependencies:
+ undici-types: 7.16.0
+ dev: false
+
+ /@types/phoenix@1.6.7:
+ resolution: {integrity: sha512-oN9ive//QSBkf19rfDv45M7eZPi0eEXylht2OLEXicu5b4KoQ1OzXIw+xDSGWxSxe1JmepRR/ZH283vsu518/Q==}
+ dev: false
+
/@types/react-dom@19.2.3(@types/react@19.2.7):
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
peerDependencies:
@@ -1185,6 +1254,12 @@ packages:
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
dev: false
+ /@types/ws@8.18.1:
+ resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
+ dependencies:
+ '@types/node': 25.0.5
+ dev: false
+
/@vitejs/plugin-react@4.7.0(vite@6.4.1):
resolution: {integrity: sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -1482,6 +1557,11 @@ packages:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
dev: true
+ /iceberg-js@0.8.1:
+ resolution: {integrity: sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==}
+ engines: {node: '>=20.0.0'}
+ dev: false
+
/immer@10.2.0:
resolution: {integrity: sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==}
dev: false
@@ -1904,12 +1984,20 @@ packages:
picomatch: 4.0.3
dev: true
+ /tslib@2.8.1:
+ resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+ dev: false
+
/typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
hasBin: true
dev: true
+ /undici-types@7.16.0:
+ resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
+ dev: false
+
/update-browserslist-db@1.2.3(browserslist@4.28.1):
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
hasBin: true
@@ -2034,6 +2122,19 @@ packages:
fsevents: 2.3.3
dev: true
+ /ws@8.19.0:
+ resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
+ engines: {node: '>=10.0.0'}
+ peerDependencies:
+ bufferutil: ^4.0.1
+ utf-8-validate: '>=5.0.2'
+ peerDependenciesMeta:
+ bufferutil:
+ optional: true
+ utf-8-validate:
+ optional: true
+ dev: false
+
/yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
dev: true
diff --git a/makima/frontend/src/components/Masthead.tsx b/makima/frontend/src/components/Masthead.tsx
index 803e45a..afe385e 100644
--- a/makima/frontend/src/components/Masthead.tsx
+++ b/makima/frontend/src/components/Masthead.tsx
@@ -18,7 +18,7 @@ export function Masthead({ showTicker = false, showNav = true }: MastheadProps)
makima.jp
</h1>
<small className="block text-[#dbe7ff] text-xs tracking-wide">
- Listening System
+ Control System
</small>
</div>
</Link>
@@ -29,10 +29,10 @@ export function Masthead({ showTicker = false, showNav = true }: MastheadProps)
<div className="absolute inset-y-0 left-0 w-3 bg-gradient-to-b from-[rgba(231,237,247,0.5)] to-transparent" />
<div className="absolute inset-y-0 right-0 w-3 bg-gradient-to-b from-[rgba(231,237,247,0.5)] to-transparent rotate-180" />
<span className="ticker-content">
- /// MAKIMA LISTENING SYSTEM // MESH LATTICE FOR CONTESTED DOMAINS ///
- TRANSPORT: WEBSOCKET /// ENCODING: PCM32F /// STATUS: ONLINE ///
- MAKIMA.JP /// MAKIMA LISTENING SYSTEM // MESH LATTICE FOR CONTESTED DOMAINS
- /// TRANSPORT: WEBSOCKET /// ENCODING: PCM32F /// STATUS: ONLINE ///
+ /// MAKIMA CONTROL SYSTEM // MESH ORCHESTRATION PLATFORM ///
+ TRANSPORT: WEBSOCKET /// DAEMONS: ACTIVE /// STATUS: ONLINE ///
+ MAKIMA.JP /// MAKIMA CONTROL SYSTEM // MESH ORCHESTRATION PLATFORM
+ /// TRANSPORT: WEBSOCKET /// DAEMONS: ACTIVE /// STATUS: ONLINE ///
MAKIMA.JP ///
</span>
</div>
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx
index 4e90d4d..806f0c5 100644
--- a/makima/frontend/src/components/NavStrip.tsx
+++ b/makima/frontend/src/components/NavStrip.tsx
@@ -1,3 +1,4 @@
+import { useAuth } from "../contexts/AuthContext";
import { RewriteLink } from "./RewriteLink";
interface NavLink {
@@ -10,12 +11,17 @@ interface NavLink {
const NAV_LINKS: NavLink[] = [
{ label: "Listen", href: "/listen" },
{ label: "Files", href: "/files" },
- { label: "Mesh", href: "/mesh", disabled: true },
- { label: "Register", href: "/register", disabled: true },
- { label: "Login", href: "/login", disabled: true },
+ { label: "Mesh", href: "/mesh" },
];
export function NavStrip() {
+ const { isAuthenticated, isAuthConfigured, signOut, user } = useAuth();
+
+ const handleSignOut = async () => {
+ await signOut();
+ window.location.href = "/login";
+ };
+
return (
<nav
className="flex items-center gap-2.5 px-3 py-2.5 border-t border-b border-dashed border-[rgba(117,170,252,0.35)] bg-[#0c1729] font-mono uppercase tracking-wide text-[11px]"
@@ -24,7 +30,7 @@ export function NavStrip() {
<span className="text-[#9bc3ff] pr-2.5 border-r border-[rgba(117,170,252,0.35)]">
NAV//
</span>
- <div className="flex flex-wrap gap-2 items-center">
+ <div className="flex flex-wrap gap-2 items-center flex-1">
{NAV_LINKS.map((link) => (
<RewriteLink
key={link.label}
@@ -36,6 +42,25 @@ export function NavStrip() {
</RewriteLink>
))}
</div>
+ <div className="flex items-center gap-2 pl-2.5 border-l border-[rgba(117,170,252,0.35)]">
+ {isAuthenticated && isAuthConfigured ? (
+ <>
+ {user?.email && (
+ <span className="text-[#9bc3ff] opacity-60">{user.email}</span>
+ )}
+ <RewriteLink to="/settings">Settings</RewriteLink>
+ <button
+ type="button"
+ onClick={handleSignOut}
+ className="bg-transparent border-none text-[#9bc3ff] hover:text-white transition-colors cursor-pointer uppercase text-[11px] font-mono tracking-wide p-0"
+ >
+ Logout
+ </button>
+ </>
+ ) : (
+ <RewriteLink to="/login">Login</RewriteLink>
+ )}
+ </div>
</nav>
);
}
diff --git a/makima/frontend/src/components/ProtectedRoute.tsx b/makima/frontend/src/components/ProtectedRoute.tsx
new file mode 100644
index 0000000..32ac592
--- /dev/null
+++ b/makima/frontend/src/components/ProtectedRoute.tsx
@@ -0,0 +1,26 @@
+import { Navigate } from "react-router";
+import { useAuth } from "../contexts/AuthContext";
+
+interface ProtectedRouteProps {
+ children: React.ReactNode;
+}
+
+export function ProtectedRoute({ children }: ProtectedRouteProps) {
+ const { isAuthenticated, isLoading, isAuthConfigured } = useAuth();
+
+ // Show loading state while checking auth
+ if (isLoading) {
+ return (
+ <div className="min-h-screen bg-black text-white flex items-center justify-center">
+ <div className="text-zinc-400">Loading...</div>
+ </div>
+ );
+ }
+
+ // If auth is configured but user is not authenticated, redirect to login
+ if (isAuthConfigured && !isAuthenticated) {
+ return <Navigate to="/login" replace />;
+ }
+
+ return <>{children}</>;
+}
diff --git a/makima/frontend/src/components/files/BodyRenderer.tsx b/makima/frontend/src/components/files/BodyRenderer.tsx
index 867fc4c..cf99fde 100644
--- a/makima/frontend/src/components/files/BodyRenderer.tsx
+++ b/makima/frontend/src/components/files/BodyRenderer.tsx
@@ -1,6 +1,7 @@
import { useState, useRef, useEffect } from "react";
import type { BodyElement } from "../../lib/api";
import { ChartRenderer } from "../charts/ChartRenderer";
+import { ElementContextMenu } from "./ElementContextMenu";
interface BodyRendererProps {
elements: BodyElement[];
@@ -10,11 +11,54 @@ interface BodyRendererProps {
onEditingChange?: (isEditing: boolean) => void;
hasPendingRemoteUpdate?: boolean;
onOverwrite?: () => void;
+ onFocusElement?: (index: number) => void;
+ onDeleteElement?: (index: number) => void;
+ onDuplicateElement?: (index: number) => void;
+ onConvertElement?: (index: number, toType: string) => void;
+ onGenerateFromElement?: (index: number, action: string) => void;
+ onCreateTaskFromElement?: (index: number, selectedText?: string) => void;
}
-export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder, onEditingChange, hasPendingRemoteUpdate, onOverwrite }: BodyRendererProps) {
+export function BodyRenderer({
+ elements,
+ isEditing = false,
+ onUpdate,
+ onReorder,
+ onEditingChange,
+ hasPendingRemoteUpdate,
+ onOverwrite,
+ onFocusElement,
+ onDeleteElement,
+ onDuplicateElement,
+ onConvertElement,
+ onGenerateFromElement,
+ onCreateTaskFromElement,
+}: BodyRendererProps) {
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
+ const [contextMenu, setContextMenu] = useState<{
+ x: number;
+ y: number;
+ elementIndex: number;
+ selectedText?: string;
+ } | null>(null);
+
+ const handleContextMenu = (index: number) => (e: React.MouseEvent) => {
+ e.preventDefault();
+ // Get any selected text
+ const selection = window.getSelection();
+ const selectedText = selection?.toString().trim() || undefined;
+ setContextMenu({
+ x: e.clientX,
+ y: e.clientY,
+ elementIndex: index,
+ selectedText,
+ });
+ };
+
+ const closeContextMenu = () => {
+ setContextMenu(null);
+ };
if (elements.length === 0) {
return (
@@ -73,6 +117,7 @@ export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder,
onDragOver={handleDragOver(index)}
onDragLeave={handleDragLeave}
onDrop={handleDrop(index)}
+ onContextMenu={handleContextMenu(index)}
>
{/* Drag handle - only show in edit mode */}
{isEditing && onReorder && (
@@ -109,6 +154,24 @@ export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder,
</div>
</div>
))}
+
+ {/* Context Menu */}
+ {contextMenu && (
+ <ElementContextMenu
+ x={contextMenu.x}
+ y={contextMenu.y}
+ element={elements[contextMenu.elementIndex]}
+ elementIndex={contextMenu.elementIndex}
+ selectedText={contextMenu.selectedText}
+ onClose={closeContextMenu}
+ onFocus={(index) => onFocusElement?.(index)}
+ onDelete={(index) => onDeleteElement?.(index)}
+ onDuplicate={(index) => onDuplicateElement?.(index)}
+ onConvert={(index, toType) => onConvertElement?.(index, toType)}
+ onGenerate={(index, action) => onGenerateFromElement?.(index, action)}
+ onCreateTask={(index, selectedText) => onCreateTaskFromElement?.(index, selectedText)}
+ />
+ )}
</div>
);
}
@@ -156,6 +219,20 @@ function BodyElementRenderer({
onOverwrite={onOverwrite}
/>
);
+ case "code":
+ return (
+ <CodeElement
+ language={element.language}
+ content={element.content}
+ />
+ );
+ case "list":
+ return (
+ <ListElement
+ ordered={element.ordered}
+ items={element.items}
+ />
+ );
case "chart":
return (
<ChartElement
@@ -502,3 +579,45 @@ function ImageElement({
</figure>
);
}
+
+function CodeElement({
+ language,
+ content,
+}: {
+ language?: string;
+ content: string;
+}) {
+ return (
+ <div className="relative">
+ {language && (
+ <div className="absolute top-0 right-0 px-2 py-0.5 font-mono text-[10px] text-[#555] bg-[#1a1a1a] border-b border-l border-[#333]">
+ {language}
+ </div>
+ )}
+ <pre className="bg-[#0d0d0d] border border-[#333] p-4 overflow-x-auto">
+ <code className="font-mono text-sm text-[#9bc3ff] whitespace-pre">
+ {content}
+ </code>
+ </pre>
+ </div>
+ );
+}
+
+function ListElement({
+ ordered,
+ items,
+}: {
+ ordered: boolean;
+ items: string[];
+}) {
+ const ListTag = ordered ? "ol" : "ul";
+ return (
+ <ListTag className={`font-mono text-sm text-white/80 leading-relaxed pl-6 space-y-1 ${ordered ? "list-decimal" : "list-disc"}`}>
+ {items.map((item, index) => (
+ <li key={index} className="pl-1">
+ {item}
+ </li>
+ ))}
+ </ListTag>
+ );
+}
diff --git a/makima/frontend/src/components/files/CliInput.tsx b/makima/frontend/src/components/files/CliInput.tsx
index ff2b0a4..47e7616 100644
--- a/makima/frontend/src/components/files/CliInput.tsx
+++ b/makima/frontend/src/components/files/CliInput.tsx
@@ -8,10 +8,15 @@ import {
type UserAnswer,
} from "../../lib/api";
import { SimpleMarkdown } from "../SimpleMarkdown";
+import type { FocusedElement } from "./FileDetail";
interface CliInputProps {
fileId: string;
onUpdate: (body: BodyElement[], summary: string | null) => void;
+ focusedElement?: FocusedElement | null;
+ onClearFocus?: () => void;
+ suggestedPrompt?: string | null;
+ onClearSuggestedPrompt?: () => void;
}
interface Message {
@@ -28,7 +33,7 @@ const MODEL_OPTIONS: { value: LlmModel; label: string }[] = [
{ value: "groq", label: "Groq Kimi" },
];
-export function CliInput({ fileId, onUpdate }: CliInputProps) {
+export function CliInput({ fileId, onUpdate, focusedElement, onClearFocus, suggestedPrompt, onClearSuggestedPrompt }: CliInputProps) {
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [messages, setMessages] = useState<Message[]>([]);
@@ -53,6 +58,21 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) {
}
}, [messages]);
+ // Auto-focus input when an element is focused
+ useEffect(() => {
+ if (focusedElement && inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, [focusedElement]);
+
+ // Handle suggested prompt from generate actions
+ useEffect(() => {
+ if (suggestedPrompt) {
+ setInput(suggestedPrompt);
+ onClearSuggestedPrompt?.();
+ }
+ }, [suggestedPrompt, onClearSuggestedPrompt]);
+
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
@@ -73,7 +93,13 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) {
try {
// Send request with conversation history for context
- const response = await chatWithFile(fileId, userMessage, model, conversationHistory);
+ const response = await chatWithFile(
+ fileId,
+ userMessage,
+ model,
+ conversationHistory,
+ focusedElement?.index
+ );
// Add assistant response
const assistantMsgId = (Date.now() + 1).toString();
@@ -128,7 +154,7 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) {
inputRef.current?.focus();
}
},
- [input, loading, fileId, model, onUpdate, conversationHistory]
+ [input, loading, fileId, model, onUpdate, conversationHistory, focusedElement]
);
// Handle option selection for a question
@@ -206,7 +232,13 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) {
try {
// Send answers as the next message
- const response = await chatWithFile(fileId, answerText, model, conversationHistory);
+ const response = await chatWithFile(
+ fileId,
+ answerText,
+ model,
+ conversationHistory,
+ focusedElement?.index
+ );
// Add assistant response
const assistantMsgId = (Date.now() + 1).toString();
@@ -258,7 +290,7 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) {
} finally {
setLoading(false);
}
- }, [pendingQuestions, userAnswers, customInputs, loading, fileId, model, conversationHistory, onUpdate]);
+ }, [pendingQuestions, userAnswers, customInputs, loading, fileId, model, conversationHistory, onUpdate, focusedElement]);
// Cancel answering questions
const handleCancelQuestions = useCallback(() => {
@@ -397,6 +429,22 @@ export function CliInput({ fileId, onUpdate }: CliInputProps) {
</option>
))}
</select>
+
+ {/* Focus Badge */}
+ {focusedElement && (
+ <button
+ type="button"
+ onClick={onClearFocus}
+ className="flex items-center gap-1 px-2 py-0.5 font-mono text-[10px] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.3)] text-[#9bc3ff] hover:border-[#75aafc] transition-colors group"
+ title="Click to clear focus"
+ >
+ <span className="text-[#75aafc]">{focusedElement.type}</span>
+ <span className="text-[#555]">:</span>
+ <span>{focusedElement.index}</span>
+ <span className="text-[#555] group-hover:text-red-400 ml-1">&times;</span>
+ </button>
+ )}
+
<span className="text-[#9bc3ff] font-mono text-sm">&gt;</span>
<input
ref={inputRef}
diff --git a/makima/frontend/src/components/files/ElementContextMenu.tsx b/makima/frontend/src/components/files/ElementContextMenu.tsx
new file mode 100644
index 0000000..dcb430c
--- /dev/null
+++ b/makima/frontend/src/components/files/ElementContextMenu.tsx
@@ -0,0 +1,292 @@
+import { useEffect, useRef, useState } from "react";
+import type { BodyElement } from "../../lib/api";
+
+interface ElementContextMenuProps {
+ x: number;
+ y: number;
+ element: BodyElement;
+ elementIndex: number;
+ selectedText?: string;
+ onClose: () => void;
+ onFocus: (index: number) => void;
+ onDelete: (index: number) => void;
+ onDuplicate: (index: number) => void;
+ onConvert: (index: number, toType: string) => void;
+ onGenerate: (index: number, action: string) => void;
+ onCreateTask: (index: number, selectedText?: string) => void;
+}
+
+export function ElementContextMenu({
+ x,
+ y,
+ element,
+ elementIndex,
+ selectedText,
+ onClose,
+ onFocus,
+ onDelete,
+ onDuplicate,
+ onConvert,
+ onGenerate,
+ onCreateTask,
+}: ElementContextMenuProps) {
+ const menuRef = useRef<HTMLDivElement>(null);
+ const [activeSubmenu, setActiveSubmenu] = useState<"generate" | "convert" | null>(null);
+
+ // Close on click outside
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
+ onClose();
+ }
+ };
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === "Escape") {
+ onClose();
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ document.addEventListener("keydown", handleKeyDown);
+
+ return () => {
+ document.removeEventListener("mousedown", handleClickOutside);
+ document.removeEventListener("keydown", handleKeyDown);
+ };
+ }, [onClose]);
+
+ // Adjust position if menu would overflow viewport
+ useEffect(() => {
+ if (menuRef.current) {
+ const rect = menuRef.current.getBoundingClientRect();
+ const viewportWidth = window.innerWidth;
+ const viewportHeight = window.innerHeight;
+
+ if (rect.right > viewportWidth) {
+ menuRef.current.style.left = `${x - rect.width}px`;
+ }
+ if (rect.bottom > viewportHeight) {
+ menuRef.current.style.top = `${y - rect.height}px`;
+ }
+ }
+ }, [x, y]);
+
+ const getElementTypeLabel = () => {
+ switch (element.type) {
+ case "heading":
+ return `Heading ${element.level}`;
+ case "paragraph":
+ return "Paragraph";
+ case "code":
+ return element.language ? `Code (${element.language})` : "Code";
+ case "list":
+ return element.ordered ? "Ordered List" : "Bullet List";
+ case "chart":
+ return `Chart (${element.chartType})`;
+ case "image":
+ return "Image";
+ default:
+ return "Element";
+ }
+ };
+
+ const menuItemClass =
+ "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center gap-2";
+ const submenuTriggerClass =
+ "w-full px-3 py-1.5 text-left text-xs font-mono text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)] flex items-center justify-between";
+ const dividerClass = "border-t border-[rgba(117,170,252,0.2)] my-1";
+
+ return (
+ <div
+ ref={menuRef}
+ className="fixed z-50 bg-[#0a0a0a] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[180px]"
+ style={{ left: x, top: y }}
+ >
+ {/* Header showing element type */}
+ <div className="px-3 py-1.5 text-[10px] font-mono text-[#555] uppercase border-b border-[rgba(117,170,252,0.2)]">
+ {getElementTypeLabel()} [{elementIndex}]
+ </div>
+
+ {/* Focus action */}
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onFocus(elementIndex);
+ onClose();
+ }}
+ >
+ <span className="text-[#75aafc]">&gt;</span>
+ Focus on this element
+ </button>
+
+ {/* Create task action */}
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onCreateTask(elementIndex, selectedText);
+ onClose();
+ }}
+ >
+ <span className="text-[#75aafc]">@</span>
+ {selectedText ? "Create task from selection" : "Create task from this"}
+ </button>
+
+ <div className={dividerClass} />
+
+ {/* Generate submenu */}
+ <div
+ className="relative"
+ onMouseEnter={() => setActiveSubmenu("generate")}
+ onMouseLeave={() => setActiveSubmenu(null)}
+ >
+ <button className={submenuTriggerClass}>
+ <span className="flex items-center gap-2">
+ <span className="text-[#75aafc]">+</span>
+ Generate from this
+ </span>
+ <span className="text-[#555]">&rsaquo;</span>
+ </button>
+
+ {activeSubmenu === "generate" && (
+ <div className="absolute left-full top-0 bg-[#0a0a0a] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[160px]">
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onGenerate(elementIndex, "elaborate");
+ onClose();
+ }}
+ >
+ Elaborate/Expand
+ </button>
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onGenerate(elementIndex, "summarize");
+ onClose();
+ }}
+ >
+ Summarize
+ </button>
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onGenerate(elementIndex, "extract_actions");
+ onClose();
+ }}
+ >
+ Extract action items
+ </button>
+ </div>
+ )}
+ </div>
+
+ {/* Convert submenu */}
+ <div
+ className="relative"
+ onMouseEnter={() => setActiveSubmenu("convert")}
+ onMouseLeave={() => setActiveSubmenu(null)}
+ >
+ <button className={submenuTriggerClass}>
+ <span className="flex items-center gap-2">
+ <span className="text-[#75aafc]">~</span>
+ Convert to...
+ </span>
+ <span className="text-[#555]">&rsaquo;</span>
+ </button>
+
+ {activeSubmenu === "convert" && (
+ <div className="absolute left-full top-0 bg-[#0a0a0a] border border-[rgba(117,170,252,0.3)] shadow-lg min-w-[140px]">
+ {element.type !== "paragraph" && (
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onConvert(elementIndex, "paragraph");
+ onClose();
+ }}
+ >
+ Paragraph
+ </button>
+ )}
+ {element.type !== "list" && (
+ <>
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onConvert(elementIndex, "list_unordered");
+ onClose();
+ }}
+ >
+ Bullet List
+ </button>
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onConvert(elementIndex, "list_ordered");
+ onClose();
+ }}
+ >
+ Numbered List
+ </button>
+ </>
+ )}
+ {element.type !== "code" && (
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onConvert(elementIndex, "code");
+ onClose();
+ }}
+ >
+ Code Block
+ </button>
+ )}
+ {element.type !== "heading" && (
+ <>
+ <div className={dividerClass} />
+ {[1, 2, 3, 4, 5, 6].map((level) => (
+ <button
+ key={level}
+ className={menuItemClass}
+ onClick={() => {
+ onConvert(elementIndex, `heading_${level}`);
+ onClose();
+ }}
+ >
+ Heading {level}
+ </button>
+ ))}
+ </>
+ )}
+ </div>
+ )}
+ </div>
+
+ <div className={dividerClass} />
+
+ {/* Duplicate */}
+ <button
+ className={menuItemClass}
+ onClick={() => {
+ onDuplicate(elementIndex);
+ onClose();
+ }}
+ >
+ <span className="text-[#75aafc]">++</span>
+ Duplicate
+ </button>
+
+ {/* Delete */}
+ <button
+ className={`${menuItemClass} text-red-400 hover:bg-red-400/10`}
+ onClick={() => {
+ onDelete(elementIndex);
+ onClose();
+ }}
+ >
+ <span className="text-red-400">x</span>
+ Delete
+ </button>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/files/FileDetail.tsx b/makima/frontend/src/components/files/FileDetail.tsx
index c7b716a..60458e9 100644
--- a/makima/frontend/src/components/files/FileDetail.tsx
+++ b/makima/frontend/src/components/files/FileDetail.tsx
@@ -3,6 +3,12 @@ import type { FileDetail as FileDetailType, FileVersionSummary, FileVersion } fr
import { BodyRenderer } from "./BodyRenderer";
import { VersionHistoryDropdown } from "./VersionHistoryDropdown";
+export interface FocusedElement {
+ index: number;
+ type: string;
+ preview: string;
+}
+
interface FileDetailProps {
file: FileDetailType;
loading: boolean;
@@ -11,9 +17,17 @@ interface FileDetailProps {
onDelete: (id: string) => void;
onBodyElementUpdate?: (index: number, element: import("../../lib/api").BodyElement) => void;
onBodyReorder?: (fromIndex: number, toIndex: number) => void;
+ onBodyElementDelete?: (index: number) => void;
+ onBodyElementDuplicate?: (index: number) => void;
onEditingChange?: (isEditing: boolean) => void;
hasPendingRemoteUpdate?: boolean;
onOverwrite?: () => void;
+ // Focus element props
+ focusedElement?: FocusedElement | null;
+ onFocusElement?: (element: FocusedElement | null) => void;
+ onGenerateFromElement?: (index: number, action: string) => void;
+ onConvertElement?: (index: number, toType: string) => void;
+ onCreateTaskFromElement?: (index: number, selectedText?: string) => void;
// Version history props
versions?: FileVersionSummary[];
versionsLoading?: boolean;
@@ -33,9 +47,16 @@ export function FileDetail({
onDelete,
onBodyElementUpdate,
onBodyReorder,
+ onBodyElementDelete,
+ onBodyElementDuplicate,
onEditingChange,
hasPendingRemoteUpdate,
onOverwrite,
+ focusedElement: _focusedElement,
+ onFocusElement,
+ onGenerateFromElement,
+ onConvertElement,
+ onCreateTaskFromElement,
versions = [],
versionsLoading = false,
selectedVersion = null,
@@ -50,6 +71,38 @@ export function FileDetail({
const [description, setDescription] = useState(file.description || "");
const [transcriptExpanded, setTranscriptExpanded] = useState(false);
+ // Helper to get element preview text
+ const getElementPreview = (index: number): string => {
+ const element = file.body[index];
+ if (!element) return "";
+ switch (element.type) {
+ case "heading":
+ case "paragraph":
+ return element.text.slice(0, 50) + (element.text.length > 50 ? "..." : "");
+ case "code":
+ return element.content.slice(0, 50) + (element.content.length > 50 ? "..." : "");
+ case "list":
+ return element.items[0]?.slice(0, 40) + (element.items.length > 1 ? ` (+${element.items.length - 1} more)` : "");
+ case "chart":
+ return element.title || `${element.chartType} chart`;
+ case "image":
+ return element.alt || element.caption || "Image";
+ default:
+ return "Element";
+ }
+ };
+
+ // Handler for focus action from context menu
+ const handleFocusElement = (index: number) => {
+ const element = file.body[index];
+ if (!element || !onFocusElement) return;
+ onFocusElement({
+ index,
+ type: element.type,
+ preview: getElementPreview(index),
+ });
+ };
+
// Update local state when file changes
useEffect(() => {
setName(file.name);
@@ -192,6 +245,12 @@ export function FileDetail({
onEditingChange={onEditingChange}
hasPendingRemoteUpdate={hasPendingRemoteUpdate}
onOverwrite={onOverwrite}
+ onFocusElement={handleFocusElement}
+ onDeleteElement={onBodyElementDelete}
+ onDuplicateElement={onBodyElementDuplicate}
+ onConvertElement={onConvertElement}
+ onGenerateFromElement={onGenerateFromElement}
+ onCreateTaskFromElement={onCreateTaskFromElement}
/>
</div>
diff --git a/makima/frontend/src/components/files/FileList.tsx b/makima/frontend/src/components/files/FileList.tsx
index a859aa1..c537846 100644
--- a/makima/frontend/src/components/files/FileList.tsx
+++ b/makima/frontend/src/components/files/FileList.tsx
@@ -1,4 +1,5 @@
-import type { FileSummary } from "../../lib/api";
+import { useRef } from "react";
+import type { FileSummary, BodyElement } from "../../lib/api";
interface FileListProps {
files: FileSummary[];
@@ -6,6 +7,154 @@ interface FileListProps {
onSelect: (id: string) => void;
onDelete: (id: string) => void;
onCreate: () => void;
+ onUploadMarkdown?: (name: string, body: BodyElement[]) => void;
+}
+
+/**
+ * Parse markdown text into BodyElements.
+ * Converts image embeds to links instead of images.
+ */
+function parseMarkdown(markdown: string): BodyElement[] {
+ const elements: BodyElement[] = [];
+ const lines = markdown.split('\n');
+ let currentParagraph: string[] = [];
+ let inCodeBlock = false;
+ let codeBlockLanguage: string | undefined;
+ let codeBlockContent: string[] = [];
+ let currentList: { ordered: boolean; items: string[] } | null = null;
+
+ const flushParagraph = () => {
+ if (currentParagraph.length > 0) {
+ const text = currentParagraph.join('\n').trim();
+ if (text) {
+ elements.push({ type: "paragraph", text });
+ }
+ currentParagraph = [];
+ }
+ };
+
+ const flushCodeBlock = () => {
+ if (codeBlockContent.length > 0 || inCodeBlock) {
+ elements.push({
+ type: "code",
+ language: codeBlockLanguage || undefined,
+ content: codeBlockContent.join('\n'),
+ });
+ codeBlockContent = [];
+ codeBlockLanguage = undefined;
+ }
+ };
+
+ const flushList = () => {
+ if (currentList && currentList.items.length > 0) {
+ elements.push({
+ type: "list",
+ ordered: currentList.ordered,
+ items: currentList.items,
+ });
+ currentList = null;
+ }
+ };
+
+ // Convert image syntax ![alt](url) to link syntax [alt](url) or [image](url)
+ const convertImagesToLinks = (text: string): string => {
+ return text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (_, alt, url) => {
+ const linkText = alt || 'image';
+ return `[${linkText}](${url})`;
+ });
+ };
+
+ for (const rawLine of lines) {
+ // Check for code block fence (``` or ~~~)
+ const codeFenceMatch = rawLine.match(/^(`{3,}|~{3,})(\w*)?$/);
+ if (codeFenceMatch) {
+ if (!inCodeBlock) {
+ // Starting a code block
+ flushParagraph();
+ flushList();
+ inCodeBlock = true;
+ codeBlockLanguage = codeFenceMatch[2] || undefined;
+ codeBlockContent = [];
+ } else {
+ // Ending a code block
+ flushCodeBlock();
+ inCodeBlock = false;
+ }
+ continue;
+ }
+
+ // If inside a code block, add line as-is
+ if (inCodeBlock) {
+ codeBlockContent.push(rawLine);
+ continue;
+ }
+
+ // Convert images to links in all lines
+ const line = convertImagesToLinks(rawLine);
+
+ // Check for headings
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
+ if (headingMatch) {
+ flushParagraph();
+ flushList();
+ const level = headingMatch[1].length;
+ const text = headingMatch[2].trim();
+ elements.push({ type: "heading", level, text });
+ continue;
+ }
+
+ // Check for unordered list items (-, *, +)
+ const unorderedMatch = line.match(/^[\s]*[-*+]\s+(.+)$/);
+ if (unorderedMatch) {
+ flushParagraph();
+ const itemText = unorderedMatch[1].trim();
+ if (currentList && currentList.ordered) {
+ // Switch from ordered to unordered
+ flushList();
+ }
+ if (!currentList) {
+ currentList = { ordered: false, items: [] };
+ }
+ currentList.items.push(itemText);
+ continue;
+ }
+
+ // Check for ordered list items (1. 2. etc)
+ const orderedMatch = line.match(/^[\s]*\d+\.\s+(.+)$/);
+ if (orderedMatch) {
+ flushParagraph();
+ const itemText = orderedMatch[1].trim();
+ if (currentList && !currentList.ordered) {
+ // Switch from unordered to ordered
+ flushList();
+ }
+ if (!currentList) {
+ currentList = { ordered: true, items: [] };
+ }
+ currentList.items.push(itemText);
+ continue;
+ }
+
+ // Empty line - flush everything
+ if (line.trim() === '') {
+ flushParagraph();
+ flushList();
+ continue;
+ }
+
+ // Regular text - flush list first, then add to paragraph
+ flushList();
+ currentParagraph.push(line);
+ }
+
+ // Flush any remaining content
+ if (inCodeBlock) {
+ flushCodeBlock();
+ }
+ flushParagraph();
+ flushList();
+
+ return elements;
}
function formatDuration(seconds: number | null): string {
@@ -32,7 +181,32 @@ export function FileList({
onSelect,
onDelete,
onCreate,
+ onUploadMarkdown,
}: FileListProps) {
+ const fileInputRef = useRef<HTMLInputElement>(null);
+
+ const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const file = event.target.files?.[0];
+ if (!file || !onUploadMarkdown) return;
+
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ const content = e.target?.result as string;
+ if (content) {
+ const body = parseMarkdown(content);
+ // Use filename without extension as the name
+ const name = file.name.replace(/\.md$/i, '') || 'Imported Document';
+ onUploadMarkdown(name, body);
+ }
+ };
+ reader.readAsText(file);
+
+ // Reset input so the same file can be uploaded again
+ if (fileInputRef.current) {
+ fileInputRef.current.value = '';
+ }
+ };
+
if (loading) {
return (
<div className="panel h-full flex items-center justify-center">
@@ -47,12 +221,31 @@ export function FileList({
<div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
FILES//
</div>
- <button
- onClick={onCreate}
- className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors uppercase"
- >
- + New
- </button>
+ <div className="flex items-center gap-2">
+ {onUploadMarkdown && (
+ <>
+ <input
+ ref={fileInputRef}
+ type="file"
+ accept=".md,.markdown,text/markdown"
+ onChange={handleFileUpload}
+ className="hidden"
+ />
+ <button
+ onClick={() => fileInputRef.current?.click()}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors uppercase"
+ >
+ Upload .md
+ </button>
+ </>
+ )}
+ <button
+ onClick={onCreate}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors uppercase"
+ >
+ + New
+ </button>
+ </div>
</div>
<div className="flex-1 overflow-y-auto">
diff --git a/makima/frontend/src/components/mesh/DirectoryInput.tsx b/makima/frontend/src/components/mesh/DirectoryInput.tsx
new file mode 100644
index 0000000..e2e331e
--- /dev/null
+++ b/makima/frontend/src/components/mesh/DirectoryInput.tsx
@@ -0,0 +1,220 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+import type { DaemonDirectory } from "../../lib/api";
+
+interface DirectoryInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ suggestions: DaemonDirectory[];
+ placeholder?: string;
+ /** Repository URL to extract repo name for home directory suggestions */
+ repoUrl?: string | null;
+ className?: string;
+ disabled?: boolean;
+}
+
+/** Extract repository name from URL */
+function extractRepoName(url: string | null | undefined): string | null {
+ if (!url) return null;
+
+ // Handle various URL formats:
+ // https://github.com/user/repo.git -> repo
+ // https://github.com/user/repo -> repo
+ // git@github.com:user/repo.git -> repo
+ // /path/to/local/repo -> repo
+
+ let name = url;
+
+ // Remove trailing .git
+ if (name.endsWith(".git")) {
+ name = name.slice(0, -4);
+ }
+
+ // Remove trailing slash
+ if (name.endsWith("/")) {
+ name = name.slice(0, -1);
+ }
+
+ // Get the last path segment
+ const lastSlash = name.lastIndexOf("/");
+ if (lastSlash !== -1) {
+ name = name.slice(lastSlash + 1);
+ }
+
+ // Handle git@host:user/repo format
+ const colonIndex = name.lastIndexOf(":");
+ if (colonIndex !== -1) {
+ const afterColon = name.slice(colonIndex + 1);
+ const slashIndex = afterColon.lastIndexOf("/");
+ if (slashIndex !== -1) {
+ name = afterColon.slice(slashIndex + 1);
+ } else {
+ name = afterColon;
+ }
+ }
+
+ return name || null;
+}
+
+export function DirectoryInput({
+ value,
+ onChange,
+ suggestions,
+ placeholder = "/path/to/directory",
+ repoUrl,
+ className = "",
+ disabled = false,
+}: DirectoryInputProps) {
+ const [showSuggestions, setShowSuggestions] = useState(false);
+ const [highlightedIndex, setHighlightedIndex] = useState(-1);
+ const inputRef = useRef<HTMLInputElement>(null);
+ const dropdownRef = useRef<HTMLDivElement>(null);
+
+ // Extract repo name for home directory suggestions
+ const repoName = extractRepoName(repoUrl);
+
+ // Process suggestions to add repo name to home directory
+ const processedSuggestions = suggestions.map((dir) => {
+ if (dir.directoryType === "home" && repoName) {
+ return {
+ ...dir,
+ path: `${dir.path}/${repoName}`,
+ label: `${dir.label} (${repoName})`,
+ };
+ }
+ return dir;
+ });
+
+ // Filter suggestions based on current input
+ const filteredSuggestions = processedSuggestions.filter((dir) => {
+ if (!value) return true;
+ const lowerValue = value.toLowerCase();
+ return (
+ dir.path.toLowerCase().includes(lowerValue) ||
+ dir.label.toLowerCase().includes(lowerValue)
+ );
+ });
+
+ // Handle clicking outside to close dropdown
+ useEffect(() => {
+ const handleClickOutside = (e: MouseEvent) => {
+ if (
+ dropdownRef.current &&
+ !dropdownRef.current.contains(e.target as Node) &&
+ inputRef.current &&
+ !inputRef.current.contains(e.target as Node)
+ ) {
+ setShowSuggestions(false);
+ }
+ };
+
+ document.addEventListener("mousedown", handleClickOutside);
+ return () => document.removeEventListener("mousedown", handleClickOutside);
+ }, []);
+
+ const handleFocus = useCallback(() => {
+ setShowSuggestions(true);
+ setHighlightedIndex(-1);
+ }, []);
+
+ const handleBlur = useCallback(() => {
+ // Delay hiding to allow click on suggestion
+ setTimeout(() => {
+ setShowSuggestions(false);
+ }, 150);
+ }, []);
+
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent) => {
+ if (!showSuggestions || filteredSuggestions.length === 0) return;
+
+ switch (e.key) {
+ case "ArrowDown":
+ e.preventDefault();
+ setHighlightedIndex((prev) =>
+ prev < filteredSuggestions.length - 1 ? prev + 1 : 0
+ );
+ break;
+ case "ArrowUp":
+ e.preventDefault();
+ setHighlightedIndex((prev) =>
+ prev > 0 ? prev - 1 : filteredSuggestions.length - 1
+ );
+ break;
+ case "Enter":
+ e.preventDefault();
+ if (highlightedIndex >= 0 && highlightedIndex < filteredSuggestions.length) {
+ onChange(filteredSuggestions[highlightedIndex].path);
+ setShowSuggestions(false);
+ }
+ break;
+ case "Escape":
+ setShowSuggestions(false);
+ break;
+ }
+ },
+ [showSuggestions, filteredSuggestions, highlightedIndex, onChange]
+ );
+
+ const handleSelectSuggestion = useCallback(
+ (path: string) => {
+ onChange(path);
+ setShowSuggestions(false);
+ inputRef.current?.focus();
+ },
+ [onChange]
+ );
+
+ return (
+ <div className={`relative ${className}`}>
+ <input
+ ref={inputRef}
+ type="text"
+ value={value}
+ onChange={(e) => onChange(e.target.value)}
+ onFocus={handleFocus}
+ onBlur={handleBlur}
+ onKeyDown={handleKeyDown}
+ placeholder={placeholder}
+ disabled={disabled}
+ className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3] disabled:opacity-50"
+ />
+
+ {/* Suggestions dropdown */}
+ {showSuggestions && filteredSuggestions.length > 0 && (
+ <div
+ ref={dropdownRef}
+ className="absolute z-50 left-0 right-0 top-full mt-1 bg-[#0a1525] border border-[rgba(117,170,252,0.35)] shadow-lg max-h-48 overflow-auto"
+ >
+ {filteredSuggestions.map((dir, index) => (
+ <button
+ key={`${dir.directoryType}-${index}`}
+ type="button"
+ onClick={() => handleSelectSuggestion(dir.path)}
+ onMouseEnter={() => setHighlightedIndex(index)}
+ className={`w-full text-left px-3 py-2 font-mono text-xs transition-colors ${
+ index === highlightedIndex
+ ? "bg-[rgba(117,170,252,0.2)] text-[#dbe7ff]"
+ : "text-[#9bc3ff] hover:bg-[rgba(117,170,252,0.1)]"
+ }`}
+ >
+ <div className="flex items-center justify-between gap-2">
+ <div className="flex items-center gap-2">
+ <span className="text-[#75aafc]">{dir.label}</span>
+ {dir.exists === true && (
+ <span className="text-[#f0ad4e] text-[10px]" title="Directory already exists">
+ (exists)
+ </span>
+ )}
+ </div>
+ {dir.hostname && (
+ <span className="text-[#555] text-[10px]">({dir.hostname})</span>
+ )}
+ </div>
+ <div className="text-[10px] text-[#555] truncate">{dir.path}</div>
+ </button>
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/InlineSubtaskEditor.tsx b/makima/frontend/src/components/mesh/InlineSubtaskEditor.tsx
new file mode 100644
index 0000000..3621b08
--- /dev/null
+++ b/makima/frontend/src/components/mesh/InlineSubtaskEditor.tsx
@@ -0,0 +1,262 @@
+import { useState, useCallback, useEffect } from "react";
+import type { TaskWithSubtasks, TaskStatus } from "../../lib/api";
+import { getTask, updateTask } from "../../lib/api";
+
+interface InlineSubtaskEditorProps {
+ subtaskId: string;
+ onClose: () => void;
+ onUpdated: () => void;
+ onNavigate?: (taskId: string) => void;
+}
+
+function getStatusColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "text-[#9bc3ff]";
+ case "running":
+ return "text-green-400";
+ case "paused":
+ return "text-yellow-400";
+ case "blocked":
+ return "text-orange-400";
+ case "done":
+ return "text-emerald-400";
+ case "failed":
+ return "text-red-400";
+ case "merged":
+ return "text-purple-400";
+ default:
+ return "text-[#9bc3ff]";
+ }
+}
+
+function getStatusBgColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "bg-[rgba(117,170,252,0.1)]";
+ case "running":
+ return "bg-green-400/10";
+ case "paused":
+ return "bg-yellow-400/10";
+ case "blocked":
+ return "bg-orange-400/10";
+ case "done":
+ return "bg-emerald-400/10";
+ case "failed":
+ return "bg-red-400/10";
+ case "merged":
+ return "bg-purple-400/10";
+ default:
+ return "bg-[rgba(117,170,252,0.1)]";
+ }
+}
+
+export function InlineSubtaskEditor({
+ subtaskId,
+ onClose,
+ onUpdated,
+ onNavigate,
+}: InlineSubtaskEditorProps) {
+ const [subtask, setSubtask] = useState<TaskWithSubtasks | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [isEditing, setIsEditing] = useState(false);
+ const [editName, setEditName] = useState("");
+ const [editDescription, setEditDescription] = useState("");
+ const [editPlan, setEditPlan] = useState("");
+
+ // Load subtask details
+ useEffect(() => {
+ setLoading(true);
+ getTask(subtaskId)
+ .then((task) => {
+ setSubtask(task);
+ setEditName(task.name);
+ setEditDescription(task.description || "");
+ setEditPlan(task.plan);
+ })
+ .catch((err) => {
+ console.error("Failed to load subtask:", err);
+ })
+ .finally(() => {
+ setLoading(false);
+ });
+ }, [subtaskId]);
+
+ const handleSave = useCallback(async () => {
+ if (!subtask || saving) return;
+ setSaving(true);
+ try {
+ await updateTask(subtaskId, {
+ name: editName,
+ description: editDescription || undefined,
+ plan: editPlan,
+ version: subtask.version,
+ });
+ // Refresh subtask
+ const updated = await getTask(subtaskId);
+ setSubtask(updated);
+ setIsEditing(false);
+ onUpdated();
+ } catch (err) {
+ console.error("Failed to save subtask:", err);
+ } finally {
+ setSaving(false);
+ }
+ }, [subtask, subtaskId, editName, editDescription, editPlan, saving, onUpdated]);
+
+ const handleCancel = useCallback(() => {
+ if (subtask) {
+ setEditName(subtask.name);
+ setEditDescription(subtask.description || "");
+ setEditPlan(subtask.plan);
+ }
+ setIsEditing(false);
+ }, [subtask]);
+
+ if (loading) {
+ return (
+ <div className="p-4 bg-[rgba(0,0,0,0.2)] border-l-2 border-[#3f6fb3]">
+ <div className="font-mono text-xs text-[#75aafc]">Loading subtask...</div>
+ </div>
+ );
+ }
+
+ if (!subtask) {
+ return (
+ <div className="p-4 bg-[rgba(0,0,0,0.2)] border-l-2 border-red-400">
+ <div className="font-mono text-xs text-red-400">Failed to load subtask</div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="bg-[rgba(0,0,0,0.2)] border-l-2 border-[#3f6fb3]">
+ {/* Header */}
+ <div className="flex items-center justify-between p-3 border-b border-[rgba(117,170,252,0.15)]">
+ <div className="flex items-center gap-2">
+ <button
+ onClick={onClose}
+ className="font-mono text-[10px] text-[#555] hover:text-[#75aafc]"
+ >
+ [close]
+ </button>
+ <span
+ className={`px-1.5 py-0.5 font-mono text-[9px] uppercase ${getStatusColor(
+ subtask.status as TaskStatus
+ )} ${getStatusBgColor(subtask.status as TaskStatus)} border border-current/20`}
+ >
+ {subtask.status}
+ </span>
+ {onNavigate && (
+ <button
+ onClick={() => onNavigate(subtaskId)}
+ className="font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff]"
+ >
+ [open full view]
+ </button>
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ {isEditing ? (
+ <>
+ <button
+ onClick={handleCancel}
+ disabled={saving}
+ className="px-2 py-0.5 font-mono text-[10px] text-[#555] hover:text-[#9bc3ff] disabled:opacity-50"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleSave}
+ disabled={saving}
+ className="px-2 py-0.5 font-mono text-[10px] text-green-400 border border-green-400/30 hover:border-green-400/50 disabled:opacity-50"
+ >
+ {saving ? "..." : "Save"}
+ </button>
+ </>
+ ) : (
+ <button
+ onClick={() => setIsEditing(true)}
+ className="px-2 py-0.5 font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff]"
+ >
+ Edit
+ </button>
+ )}
+ </div>
+ </div>
+
+ {/* Content */}
+ <div className="p-3 space-y-3">
+ {/* Name */}
+ {isEditing ? (
+ <input
+ type="text"
+ value={editName}
+ onChange={(e) => setEditName(e.target.value)}
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-2 py-1 outline-none focus:border-[#3f6fb3]"
+ placeholder="Subtask name"
+ />
+ ) : (
+ <div className="font-mono text-sm text-[#dbe7ff]">{subtask.name}</div>
+ )}
+
+ {/* Description */}
+ {isEditing ? (
+ <textarea
+ value={editDescription}
+ onChange={(e) => setEditDescription(e.target.value)}
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs px-2 py-1 outline-none focus:border-[#3f6fb3] min-h-[40px] resize-y"
+ placeholder="Description (optional)"
+ />
+ ) : subtask.description ? (
+ <div className="font-mono text-xs text-[#75aafc]">{subtask.description}</div>
+ ) : null}
+
+ {/* Plan */}
+ <div className="space-y-1">
+ <div className="font-mono text-[10px] text-[#555] uppercase">Plan</div>
+ {isEditing ? (
+ <textarea
+ value={editPlan}
+ onChange={(e) => setEditPlan(e.target.value)}
+ className="w-full bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-xs px-2 py-1 outline-none focus:border-[#3f6fb3] min-h-[100px] resize-y"
+ placeholder="Plan/instructions..."
+ />
+ ) : (
+ <pre className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-2 font-mono text-xs text-[#9bc3ff] whitespace-pre-wrap max-h-[150px] overflow-y-auto">
+ {subtask.plan}
+ </pre>
+ )}
+ </div>
+
+ {/* Progress/Error */}
+ {subtask.progressSummary && (
+ <div className="font-mono text-[10px] text-[#75aafc]">
+ <span className="text-[#555]">Progress:</span> {subtask.progressSummary}
+ </div>
+ )}
+ {subtask.errorMessage && (
+ <div className="font-mono text-[10px] text-red-400">
+ <span className="text-red-400/50">Error:</span> {subtask.errorMessage}
+ </div>
+ )}
+
+ {/* Nested subtasks indicator */}
+ {subtask.subtasks.length > 0 && (
+ <div className="font-mono text-[10px] text-[#555]">
+ Has {subtask.subtasks.length} subtask{subtask.subtasks.length > 1 ? "s" : ""}
+ {onNavigate && (
+ <button
+ onClick={() => onNavigate(subtaskId)}
+ className="ml-2 text-[#75aafc] hover:text-[#9bc3ff]"
+ >
+ [view all]
+ </button>
+ )}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/MergeConflictResolver.tsx b/makima/frontend/src/components/mesh/MergeConflictResolver.tsx
new file mode 100644
index 0000000..4479705
--- /dev/null
+++ b/makima/frontend/src/components/mesh/MergeConflictResolver.tsx
@@ -0,0 +1,504 @@
+import { useState, useMemo, useCallback } from "react";
+
+interface ConflictHunk {
+ id: string;
+ filePath: string;
+ startLine: number;
+ endLine: number;
+ ours: string[]; // Changes from current branch
+ theirs: string[]; // Changes from incoming branch
+ base?: string[]; // Original content (if 3-way merge)
+ resolved?: "ours" | "theirs" | "both" | "custom";
+ customResolution?: string[];
+}
+
+interface ConflictFile {
+ path: string;
+ hunks: ConflictHunk[];
+ resolved: boolean;
+}
+
+interface MergeConflictResolverProps {
+ conflicts: ConflictFile[];
+ sourceBranch: string;
+ targetBranch: string;
+ loading?: boolean;
+ onResolve: (resolutions: Map<string, ConflictHunk[]>) => Promise<void>;
+ onAbort: () => void;
+ onAskLLM?: (hunk: ConflictHunk) => Promise<string[]>;
+}
+
+type ResolutionChoice = "ours" | "theirs" | "both" | "custom";
+
+function ConflictHunkView({
+ hunk,
+ sourceBranch,
+ targetBranch,
+ onResolve,
+ onAskLLM,
+}: {
+ hunk: ConflictHunk;
+ sourceBranch: string;
+ targetBranch: string;
+ onResolve: (resolution: ResolutionChoice, customLines?: string[]) => void;
+ onAskLLM?: () => Promise<void>;
+}) {
+ const [showCustomEditor, setShowCustomEditor] = useState(false);
+ const [customText, setCustomText] = useState(
+ hunk.customResolution?.join("\n") || [...hunk.ours, ...hunk.theirs].join("\n")
+ );
+ const [askingLLM, setAskingLLM] = useState(false);
+
+ const handleAskLLM = async () => {
+ if (!onAskLLM || askingLLM) return;
+ setAskingLLM(true);
+ try {
+ await onAskLLM();
+ } finally {
+ setAskingLLM(false);
+ }
+ };
+
+ const handleCustomSave = () => {
+ const lines = customText.split("\n");
+ onResolve("custom", lines);
+ setShowCustomEditor(false);
+ };
+
+ const isResolved = hunk.resolved !== undefined;
+
+ return (
+ <div
+ className={`border ${
+ isResolved
+ ? "border-green-400/30 bg-green-400/5"
+ : "border-yellow-400/30 bg-yellow-400/5"
+ } mb-3`}
+ >
+ {/* Hunk header */}
+ <div className="flex items-center justify-between px-3 py-2 border-b border-[rgba(117,170,252,0.2)]">
+ <div className="font-mono text-xs text-[#75aafc]">
+ Lines {hunk.startLine}-{hunk.endLine}
+ {isResolved && (
+ <span className="ml-2 text-green-400">
+ (Resolved: {hunk.resolved})
+ </span>
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ {onAskLLM && (
+ <button
+ onClick={handleAskLLM}
+ disabled={askingLLM}
+ className="px-2 py-1 font-mono text-[10px] text-purple-400 border border-purple-400/30 hover:border-purple-400/50 disabled:opacity-50 transition-colors"
+ >
+ {askingLLM ? "..." : "Ask LLM"}
+ </button>
+ )}
+ <button
+ onClick={() => setShowCustomEditor(!showCustomEditor)}
+ className="px-2 py-1 font-mono text-[10px] text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ Edit
+ </button>
+ </div>
+ </div>
+
+ {/* Conflict content */}
+ {!showCustomEditor ? (
+ <div className="grid grid-cols-2 divide-x divide-[rgba(117,170,252,0.2)]">
+ {/* Ours (current branch) */}
+ <div className="p-2">
+ <div className="flex items-center justify-between mb-2">
+ <span className="font-mono text-[10px] text-[#9bc3ff] uppercase">
+ {targetBranch} (ours)
+ </span>
+ <button
+ onClick={() => onResolve("ours")}
+ className={`px-2 py-0.5 font-mono text-[9px] border transition-colors ${
+ hunk.resolved === "ours"
+ ? "text-green-400 border-green-400/50 bg-green-400/10"
+ : "text-[#75aafc] border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3]"
+ }`}
+ >
+ Use This
+ </button>
+ </div>
+ <pre className="font-mono text-xs text-red-400 bg-red-400/5 p-2 overflow-x-auto">
+ {hunk.ours.map((line, i) => (
+ <div key={i}>
+ <span className="text-[#555] select-none mr-2">-</span>
+ {line}
+ </div>
+ ))}
+ </pre>
+ </div>
+
+ {/* Theirs (incoming branch) */}
+ <div className="p-2">
+ <div className="flex items-center justify-between mb-2">
+ <span className="font-mono text-[10px] text-[#9bc3ff] uppercase">
+ {sourceBranch} (theirs)
+ </span>
+ <button
+ onClick={() => onResolve("theirs")}
+ className={`px-2 py-0.5 font-mono text-[9px] border transition-colors ${
+ hunk.resolved === "theirs"
+ ? "text-green-400 border-green-400/50 bg-green-400/10"
+ : "text-[#75aafc] border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3]"
+ }`}
+ >
+ Use This
+ </button>
+ </div>
+ <pre className="font-mono text-xs text-green-400 bg-green-400/5 p-2 overflow-x-auto">
+ {hunk.theirs.map((line, i) => (
+ <div key={i}>
+ <span className="text-[#555] select-none mr-2">+</span>
+ {line}
+ </div>
+ ))}
+ </pre>
+ </div>
+ </div>
+ ) : (
+ /* Custom editor */
+ <div className="p-2">
+ <div className="flex items-center justify-between mb-2">
+ <span className="font-mono text-[10px] text-[#9bc3ff] uppercase">
+ Custom Resolution
+ </span>
+ <div className="flex items-center gap-2">
+ <button
+ onClick={() => setShowCustomEditor(false)}
+ className="px-2 py-0.5 font-mono text-[9px] text-[#555] hover:text-[#9bc3ff]"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleCustomSave}
+ className="px-2 py-0.5 font-mono text-[9px] text-green-400 border border-green-400/30 hover:border-green-400/50"
+ >
+ Apply
+ </button>
+ </div>
+ </div>
+ <textarea
+ value={customText}
+ onChange={(e) => setCustomText(e.target.value)}
+ className="w-full bg-[rgba(0,0,0,0.3)] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-xs p-2 outline-none focus:border-[#3f6fb3] min-h-[100px] resize-y"
+ />
+ </div>
+ )}
+
+ {/* Both option */}
+ <div className="px-3 py-2 border-t border-[rgba(117,170,252,0.1)] flex justify-center">
+ <button
+ onClick={() => onResolve("both")}
+ className={`px-3 py-1 font-mono text-[10px] border transition-colors ${
+ hunk.resolved === "both"
+ ? "text-green-400 border-green-400/50 bg-green-400/10"
+ : "text-[#75aafc] border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3]"
+ }`}
+ >
+ Keep Both
+ </button>
+ </div>
+ </div>
+ );
+}
+
+function ConflictFileView({
+ file,
+ sourceBranch,
+ targetBranch,
+ onResolveHunk,
+ onAskLLM,
+}: {
+ file: ConflictFile;
+ sourceBranch: string;
+ targetBranch: string;
+ onResolveHunk: (hunkId: string, resolution: ResolutionChoice, customLines?: string[]) => void;
+ onAskLLM?: (hunk: ConflictHunk) => Promise<string[]>;
+}) {
+ const [expanded, setExpanded] = useState(true);
+ const resolvedCount = file.hunks.filter((h) => h.resolved !== undefined).length;
+
+ return (
+ <div className="border border-[rgba(117,170,252,0.2)] mb-3">
+ {/* File header */}
+ <button
+ onClick={() => setExpanded(!expanded)}
+ className="w-full flex items-center gap-2 px-3 py-2 bg-[rgba(0,0,0,0.2)] hover:bg-[rgba(0,0,0,0.3)] text-left"
+ >
+ <span className="font-mono text-[10px] text-[#555]">
+ {expanded ? "▼" : "▶"}
+ </span>
+ <span
+ className={`px-1.5 py-0.5 font-mono text-[9px] ${
+ file.resolved
+ ? "text-green-400 bg-green-400/10"
+ : "text-yellow-400 bg-yellow-400/10"
+ }`}
+ >
+ {file.resolved ? "RESOLVED" : "CONFLICT"}
+ </span>
+ <span className="font-mono text-sm text-[#dbe7ff] flex-1 truncate">
+ {file.path}
+ </span>
+ <span className="font-mono text-[10px] text-[#555]">
+ {resolvedCount}/{file.hunks.length} hunks
+ </span>
+ </button>
+
+ {/* Hunks */}
+ {expanded && (
+ <div className="p-3">
+ {file.hunks.map((hunk) => (
+ <ConflictHunkView
+ key={hunk.id}
+ hunk={hunk}
+ sourceBranch={sourceBranch}
+ targetBranch={targetBranch}
+ onResolve={(resolution, customLines) =>
+ onResolveHunk(hunk.id, resolution, customLines)
+ }
+ onAskLLM={
+ onAskLLM
+ ? async () => {
+ const resolution = await onAskLLM(hunk);
+ onResolveHunk(hunk.id, "custom", resolution);
+ }
+ : undefined
+ }
+ />
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
+
+export function MergeConflictResolver({
+ conflicts: initialConflicts,
+ sourceBranch,
+ targetBranch,
+ loading = false,
+ onResolve,
+ onAbort,
+ onAskLLM,
+}: MergeConflictResolverProps) {
+ const [conflicts, setConflicts] = useState<ConflictFile[]>(initialConflicts);
+ const [resolving, setResolving] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ // Calculate resolution stats
+ const stats = useMemo(() => {
+ const totalHunks = conflicts.reduce((sum, f) => sum + f.hunks.length, 0);
+ const resolvedHunks = conflicts.reduce(
+ (sum, f) => sum + f.hunks.filter((h) => h.resolved !== undefined).length,
+ 0
+ );
+ const resolvedFiles = conflicts.filter((f) => f.resolved).length;
+ return {
+ totalFiles: conflicts.length,
+ resolvedFiles,
+ totalHunks,
+ resolvedHunks,
+ allResolved: resolvedHunks === totalHunks,
+ };
+ }, [conflicts]);
+
+ // Handle resolving a single hunk
+ const handleResolveHunk = useCallback(
+ (filePath: string, hunkId: string, resolution: ResolutionChoice, customLines?: string[]) => {
+ setConflicts((prev) =>
+ prev.map((file) => {
+ if (file.path !== filePath) return file;
+
+ const updatedHunks = file.hunks.map((hunk) => {
+ if (hunk.id !== hunkId) return hunk;
+ return {
+ ...hunk,
+ resolved: resolution,
+ customResolution: customLines,
+ };
+ });
+
+ const allHunksResolved = updatedHunks.every((h) => h.resolved !== undefined);
+
+ return {
+ ...file,
+ hunks: updatedHunks,
+ resolved: allHunksResolved,
+ };
+ })
+ );
+ },
+ []
+ );
+
+ // Resolve all hunks in a file with same choice
+ const handleResolveFileAll = useCallback(
+ (filePath: string, resolution: ResolutionChoice) => {
+ setConflicts((prev) =>
+ prev.map((file) => {
+ if (file.path !== filePath) return file;
+
+ const updatedHunks = file.hunks.map((hunk) => ({
+ ...hunk,
+ resolved: resolution,
+ customResolution:
+ resolution === "both"
+ ? [...hunk.ours, ...hunk.theirs]
+ : resolution === "ours"
+ ? hunk.ours
+ : resolution === "theirs"
+ ? hunk.theirs
+ : undefined,
+ }));
+
+ return {
+ ...file,
+ hunks: updatedHunks,
+ resolved: true,
+ };
+ })
+ );
+ },
+ []
+ );
+
+ // Apply all resolutions
+ const handleApplyResolutions = async () => {
+ if (!stats.allResolved || resolving) return;
+
+ setResolving(true);
+ setError(null);
+
+ try {
+ const resolutionMap = new Map<string, ConflictHunk[]>();
+ conflicts.forEach((file) => {
+ resolutionMap.set(file.path, file.hunks);
+ });
+ await onResolve(resolutionMap);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to apply resolutions");
+ } finally {
+ setResolving(false);
+ }
+ };
+
+ if (loading) {
+ return (
+ <div className="panel p-4">
+ <div className="flex items-center justify-center h-32">
+ <div className="font-mono text-sm text-[#75aafc]">
+ Analyzing conflicts...
+ </div>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="panel flex flex-col max-h-[80vh] overflow-hidden">
+ {/* Header */}
+ <div className="flex items-center justify-between p-4 border-b border-[rgba(117,170,252,0.2)] shrink-0">
+ <div className="flex items-center gap-3">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Merge Conflicts
+ </div>
+ <span className="px-2 py-0.5 font-mono text-[10px] text-yellow-400 bg-yellow-400/10 border border-yellow-400/20">
+ {sourceBranch} → {targetBranch}
+ </span>
+ </div>
+ <button
+ onClick={onAbort}
+ className="font-mono text-xs text-red-400 hover:text-red-300"
+ >
+ Abort Merge
+ </button>
+ </div>
+
+ {/* Progress */}
+ <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)] shrink-0">
+ <div className="flex items-center justify-between mb-2">
+ <span className="font-mono text-[10px] text-[#75aafc]">
+ {stats.resolvedFiles}/{stats.totalFiles} files resolved
+ </span>
+ <span className="font-mono text-[10px] text-[#75aafc]">
+ {stats.resolvedHunks}/{stats.totalHunks} conflicts resolved
+ </span>
+ </div>
+ <div className="h-1.5 bg-[rgba(117,170,252,0.1)] rounded-full overflow-hidden">
+ <div
+ className="h-full bg-green-400 transition-all"
+ style={{
+ width: `${(stats.resolvedHunks / stats.totalHunks) * 100}%`,
+ }}
+ />
+ </div>
+ </div>
+
+ {/* Error */}
+ {error && (
+ <div className="mx-4 mt-3 bg-red-400/10 border border-red-400/30 p-3 font-mono text-xs text-red-400 shrink-0">
+ {error}
+ </div>
+ )}
+
+ {/* Conflict files */}
+ <div className="flex-1 overflow-y-auto p-4">
+ {conflicts.map((file) => (
+ <ConflictFileView
+ key={file.path}
+ file={file}
+ sourceBranch={sourceBranch}
+ targetBranch={targetBranch}
+ onResolveHunk={(hunkId, resolution, customLines) =>
+ handleResolveHunk(file.path, hunkId, resolution, customLines)
+ }
+ onAskLLM={onAskLLM}
+ />
+ ))}
+ </div>
+
+ {/* Footer actions */}
+ <div className="flex items-center justify-between p-4 border-t border-[rgba(117,170,252,0.2)] shrink-0">
+ <div className="flex items-center gap-2">
+ <button
+ onClick={() =>
+ conflicts.forEach((f) => handleResolveFileAll(f.path, "ours"))
+ }
+ className="px-3 py-1.5 font-mono text-[10px] text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ Accept All Ours
+ </button>
+ <button
+ onClick={() =>
+ conflicts.forEach((f) => handleResolveFileAll(f.path, "theirs"))
+ }
+ className="px-3 py-1.5 font-mono text-[10px] text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ Accept All Theirs
+ </button>
+ </div>
+ <button
+ onClick={handleApplyResolutions}
+ disabled={!stats.allResolved || resolving}
+ className="px-4 py-2 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {resolving
+ ? "Applying..."
+ : stats.allResolved
+ ? "Complete Merge"
+ : `Resolve ${stats.totalHunks - stats.resolvedHunks} Conflicts`}
+ </button>
+ </div>
+ </div>
+ );
+}
+
+// Export types for use in other components
+export type { ConflictHunk, ConflictFile, ResolutionChoice };
diff --git a/makima/frontend/src/components/mesh/OverlayDiffViewer.tsx b/makima/frontend/src/components/mesh/OverlayDiffViewer.tsx
new file mode 100644
index 0000000..74059a0
--- /dev/null
+++ b/makima/frontend/src/components/mesh/OverlayDiffViewer.tsx
@@ -0,0 +1,476 @@
+import { useState, useMemo } from "react";
+
+interface DiffLine {
+ type: "add" | "remove" | "context" | "header" | "hunk";
+ content: string;
+ oldLineNumber?: number;
+ newLineNumber?: number;
+}
+
+interface DiffFile {
+ path: string;
+ status: "added" | "modified" | "deleted" | "renamed";
+ oldPath?: string; // For renames
+ additions: number;
+ deletions: number;
+ lines: DiffLine[];
+}
+
+interface OverlayDiffViewerProps {
+ diff: string;
+ changedFiles?: string[];
+ loading?: boolean;
+ error?: string;
+ onClose?: () => void;
+ title?: string;
+}
+
+function parseDiff(diffText: string): DiffFile[] {
+ if (!diffText.trim()) return [];
+
+ const files: DiffFile[] = [];
+ const lines = diffText.split("\n");
+
+ let currentFile: DiffFile | null = null;
+ let oldLineNum = 0;
+ let newLineNum = 0;
+
+ for (let i = 0; i < lines.length; i++) {
+ const line = lines[i];
+
+ // File header (diff --git a/path b/path)
+ if (line.startsWith("diff --git")) {
+ if (currentFile) {
+ files.push(currentFile);
+ }
+
+ // Extract paths
+ const match = line.match(/diff --git a\/(.+) b\/(.+)/);
+ const oldPath = match?.[1] || "";
+ const newPath = match?.[2] || oldPath;
+
+ currentFile = {
+ path: newPath,
+ oldPath: oldPath !== newPath ? oldPath : undefined,
+ status: "modified",
+ additions: 0,
+ deletions: 0,
+ lines: [],
+ };
+ continue;
+ }
+
+ if (!currentFile) continue;
+
+ // New file indicator
+ if (line.startsWith("new file mode")) {
+ currentFile.status = "added";
+ continue;
+ }
+
+ // Deleted file indicator
+ if (line.startsWith("deleted file mode")) {
+ currentFile.status = "deleted";
+ continue;
+ }
+
+ // Rename indicator
+ if (line.startsWith("rename from") || line.startsWith("rename to")) {
+ currentFile.status = "renamed";
+ continue;
+ }
+
+ // Hunk header (@@ -1,3 +1,4 @@)
+ if (line.startsWith("@@")) {
+ const hunkMatch = line.match(/@@ -(\d+),?\d* \+(\d+),?\d* @@/);
+ if (hunkMatch) {
+ oldLineNum = parseInt(hunkMatch[1], 10);
+ newLineNum = parseInt(hunkMatch[2], 10);
+ }
+ currentFile.lines.push({
+ type: "hunk",
+ content: line,
+ });
+ continue;
+ }
+
+ // Skip other headers (---, +++, index, etc.)
+ if (
+ line.startsWith("---") ||
+ line.startsWith("+++") ||
+ line.startsWith("index ") ||
+ line.startsWith("Binary files")
+ ) {
+ currentFile.lines.push({
+ type: "header",
+ content: line,
+ });
+ continue;
+ }
+
+ // Diff content
+ if (line.startsWith("+")) {
+ currentFile.additions++;
+ currentFile.lines.push({
+ type: "add",
+ content: line.substring(1),
+ newLineNumber: newLineNum++,
+ });
+ } else if (line.startsWith("-")) {
+ currentFile.deletions++;
+ currentFile.lines.push({
+ type: "remove",
+ content: line.substring(1),
+ oldLineNumber: oldLineNum++,
+ });
+ } else if (line.startsWith(" ") || line === "") {
+ currentFile.lines.push({
+ type: "context",
+ content: line.substring(1) || "",
+ oldLineNumber: oldLineNum++,
+ newLineNumber: newLineNum++,
+ });
+ }
+ }
+
+ if (currentFile) {
+ files.push(currentFile);
+ }
+
+ return files;
+}
+
+function DiffFileView({ file, collapsed, onToggle }: { file: DiffFile; collapsed: boolean; onToggle: () => void }) {
+ const statusColors: Record<DiffFile["status"], string> = {
+ added: "text-green-400 bg-green-400/10",
+ modified: "text-yellow-400 bg-yellow-400/10",
+ deleted: "text-red-400 bg-red-400/10",
+ renamed: "text-purple-400 bg-purple-400/10",
+ };
+
+ const statusLabels: Record<DiffFile["status"], string> = {
+ added: "A",
+ modified: "M",
+ deleted: "D",
+ renamed: "R",
+ };
+
+ return (
+ <div className="border border-[rgba(117,170,252,0.2)] mb-2">
+ {/* File header */}
+ <button
+ onClick={onToggle}
+ className="w-full flex items-center gap-2 px-3 py-2 bg-[rgba(0,0,0,0.2)] hover:bg-[rgba(0,0,0,0.3)] transition-colors text-left"
+ >
+ <span className="font-mono text-[10px] text-[#555]">
+ {collapsed ? "▶" : "▼"}
+ </span>
+ <span
+ className={`px-1.5 py-0.5 font-mono text-[9px] font-bold ${statusColors[file.status]}`}
+ >
+ {statusLabels[file.status]}
+ </span>
+ <span className="font-mono text-sm text-[#dbe7ff] flex-1 truncate">
+ {file.oldPath ? (
+ <>
+ <span className="text-[#555]">{file.oldPath}</span>
+ <span className="text-[#75aafc] mx-1">→</span>
+ {file.path}
+ </>
+ ) : (
+ file.path
+ )}
+ </span>
+ <span className="font-mono text-[10px]">
+ {file.additions > 0 && (
+ <span className="text-green-400 mr-2">+{file.additions}</span>
+ )}
+ {file.deletions > 0 && (
+ <span className="text-red-400">-{file.deletions}</span>
+ )}
+ </span>
+ </button>
+
+ {/* File content */}
+ {!collapsed && (
+ <div className="overflow-x-auto">
+ <table className="w-full font-mono text-xs">
+ <tbody>
+ {file.lines.map((line, i) => {
+ if (line.type === "header" || line.type === "hunk") {
+ return (
+ <tr
+ key={i}
+ className="bg-[rgba(117,170,252,0.05)]"
+ >
+ <td
+ colSpan={3}
+ className="px-2 py-0.5 text-[#75aafc] select-none"
+ >
+ {line.content}
+ </td>
+ </tr>
+ );
+ }
+
+ const bgColor =
+ line.type === "add"
+ ? "bg-green-400/10"
+ : line.type === "remove"
+ ? "bg-red-400/10"
+ : "";
+
+ const textColor =
+ line.type === "add"
+ ? "text-green-400"
+ : line.type === "remove"
+ ? "text-red-400"
+ : "text-[#9bc3ff]";
+
+ const prefix =
+ line.type === "add"
+ ? "+"
+ : line.type === "remove"
+ ? "-"
+ : " ";
+
+ return (
+ <tr key={i} className={bgColor}>
+ {/* Old line number */}
+ <td className="w-10 px-2 py-0 text-right text-[#555] select-none border-r border-[rgba(117,170,252,0.1)]">
+ {line.type !== "add" ? line.oldLineNumber : ""}
+ </td>
+ {/* New line number */}
+ <td className="w-10 px-2 py-0 text-right text-[#555] select-none border-r border-[rgba(117,170,252,0.1)]">
+ {line.type !== "remove" ? line.newLineNumber : ""}
+ </td>
+ {/* Content */}
+ <td className={`px-2 py-0 whitespace-pre ${textColor}`}>
+ <span className="select-none mr-1 text-[#555]">
+ {prefix}
+ </span>
+ {line.content}
+ </td>
+ </tr>
+ );
+ })}
+ </tbody>
+ </table>
+ </div>
+ )}
+ </div>
+ );
+}
+
+export function OverlayDiffViewer({
+ diff,
+ changedFiles,
+ loading,
+ error,
+ onClose,
+ title = "Overlay Changes",
+}: OverlayDiffViewerProps) {
+ const [collapsedFiles, setCollapsedFiles] = useState<Set<string>>(new Set());
+ const [showFullDiff, setShowFullDiff] = useState(true);
+
+ const parsedFiles = useMemo(() => parseDiff(diff), [diff]);
+
+ const toggleFile = (path: string) => {
+ setCollapsedFiles((prev) => {
+ const next = new Set(prev);
+ if (next.has(path)) {
+ next.delete(path);
+ } else {
+ next.add(path);
+ }
+ return next;
+ });
+ };
+
+ const expandAll = () => setCollapsedFiles(new Set());
+ const collapseAll = () => setCollapsedFiles(new Set(parsedFiles.map((f) => f.path)));
+
+ // Calculate totals
+ const totals = useMemo(() => {
+ return parsedFiles.reduce(
+ (acc, file) => ({
+ additions: acc.additions + file.additions,
+ deletions: acc.deletions + file.deletions,
+ files: acc.files + 1,
+ }),
+ { additions: 0, deletions: 0, files: 0 }
+ );
+ }, [parsedFiles]);
+
+ if (loading) {
+ return (
+ <div className="panel p-4">
+ <div className="flex items-center justify-center h-32">
+ <div className="font-mono text-sm text-[#75aafc]">
+ Loading diff...
+ </div>
+ </div>
+ </div>
+ );
+ }
+
+ if (error) {
+ return (
+ <div className="panel p-4">
+ <div className="flex items-center justify-between mb-3">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ {title}
+ </div>
+ {onClose && (
+ <button
+ onClick={onClose}
+ className="font-mono text-xs text-[#555] hover:text-[#9bc3ff]"
+ >
+ Close
+ </button>
+ )}
+ </div>
+ <div className="bg-red-400/10 border border-red-400/30 p-3 font-mono text-sm text-red-400">
+ {error}
+ </div>
+ </div>
+ );
+ }
+
+ if (!diff.trim() && (!changedFiles || changedFiles.length === 0)) {
+ return (
+ <div className="panel p-4">
+ <div className="flex items-center justify-between mb-3">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ {title}
+ </div>
+ {onClose && (
+ <button
+ onClick={onClose}
+ className="font-mono text-xs text-[#555] hover:text-[#9bc3ff]"
+ >
+ Close
+ </button>
+ )}
+ </div>
+ <div className="text-center py-8 font-mono text-sm text-[#555]">
+ No changes detected in overlay
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="panel flex flex-col max-h-[600px]">
+ {/* Header */}
+ <div className="flex items-center justify-between p-3 border-b border-[rgba(117,170,252,0.2)] shrink-0">
+ <div className="flex items-center gap-4">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ {title}
+ </div>
+ <div className="font-mono text-[10px] text-[#75aafc]">
+ {totals.files} file{totals.files !== 1 ? "s" : ""} changed
+ {totals.additions > 0 && (
+ <span className="text-green-400 ml-2">+{totals.additions}</span>
+ )}
+ {totals.deletions > 0 && (
+ <span className="text-red-400 ml-2">-{totals.deletions}</span>
+ )}
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ <button
+ onClick={expandAll}
+ className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff]"
+ >
+ Expand All
+ </button>
+ <button
+ onClick={collapseAll}
+ className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff]"
+ >
+ Collapse All
+ </button>
+ <button
+ onClick={() => setShowFullDiff(!showFullDiff)}
+ className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff]"
+ >
+ {showFullDiff ? "File List" : "Full Diff"}
+ </button>
+ {onClose && (
+ <button
+ onClick={onClose}
+ className="font-mono text-xs text-[#555] hover:text-[#9bc3ff] ml-2"
+ >
+ Close
+ </button>
+ )}
+ </div>
+ </div>
+
+ {/* Content */}
+ <div className="flex-1 overflow-y-auto p-3">
+ {showFullDiff ? (
+ // Full diff view
+ parsedFiles.length > 0 ? (
+ parsedFiles.map((file) => (
+ <DiffFileView
+ key={file.path}
+ file={file}
+ collapsed={collapsedFiles.has(file.path)}
+ onToggle={() => toggleFile(file.path)}
+ />
+ ))
+ ) : (
+ // Fallback to raw diff
+ <pre className="font-mono text-xs text-[#9bc3ff] whitespace-pre-wrap">
+ {diff}
+ </pre>
+ )
+ ) : (
+ // File list view
+ <div className="space-y-1">
+ {(changedFiles || parsedFiles.map((f) => f.path)).map((path) => {
+ const file = parsedFiles.find((f) => f.path === path);
+ return (
+ <div
+ key={path}
+ className="flex items-center gap-2 px-2 py-1 hover:bg-[rgba(117,170,252,0.05)]"
+ >
+ {file && (
+ <span
+ className={`px-1 py-0.5 font-mono text-[9px] ${
+ file.status === "added"
+ ? "text-green-400 bg-green-400/10"
+ : file.status === "deleted"
+ ? "text-red-400 bg-red-400/10"
+ : file.status === "renamed"
+ ? "text-purple-400 bg-purple-400/10"
+ : "text-yellow-400 bg-yellow-400/10"
+ }`}
+ >
+ {file.status.charAt(0).toUpperCase()}
+ </span>
+ )}
+ <span className="font-mono text-sm text-[#dbe7ff] flex-1 truncate">
+ {path}
+ </span>
+ {file && (
+ <span className="font-mono text-[10px]">
+ {file.additions > 0 && (
+ <span className="text-green-400 mr-1">+{file.additions}</span>
+ )}
+ {file.deletions > 0 && (
+ <span className="text-red-400">-{file.deletions}</span>
+ )}
+ </span>
+ )}
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/PRPreview.tsx b/makima/frontend/src/components/mesh/PRPreview.tsx
new file mode 100644
index 0000000..fc202b0
--- /dev/null
+++ b/makima/frontend/src/components/mesh/PRPreview.tsx
@@ -0,0 +1,314 @@
+import { useState, useMemo } from "react";
+import type { TaskWithSubtasks, TaskSummary } from "../../lib/api";
+import { OverlayDiffViewer } from "./OverlayDiffViewer";
+
+interface PRPreviewProps {
+ task: TaskWithSubtasks;
+ diff?: string;
+ changedFiles?: string[];
+ loading?: boolean;
+ onCreatePR?: (title: string, body: string, draft: boolean) => Promise<void>;
+ onAutoMerge?: () => Promise<void>;
+ onClose: () => void;
+}
+
+interface PRFormData {
+ title: string;
+ body: string;
+ isDraft: boolean;
+}
+
+function generatePRTitle(task: TaskWithSubtasks): string {
+ // Generate a PR title based on the task name
+ const prefix = task.parentTaskId ? "feat" : "feat";
+ return `${prefix}: ${task.name}`;
+}
+
+function generatePRBody(task: TaskWithSubtasks, changedFiles?: string[]): string {
+ const sections: string[] = [];
+
+ // Summary
+ sections.push("## Summary\n");
+ if (task.description) {
+ sections.push(task.description + "\n");
+ } else {
+ sections.push("_Add a brief description of the changes..._\n");
+ }
+
+ // Plan/Implementation details
+ sections.push("\n## Implementation\n");
+ if (task.plan) {
+ // Truncate if too long
+ const planPreview = task.plan.length > 500
+ ? task.plan.substring(0, 500) + "..."
+ : task.plan;
+ sections.push("```\n" + planPreview + "\n```\n");
+ }
+
+ // Subtasks summary
+ if (task.subtasks.length > 0) {
+ sections.push("\n## Subtasks\n");
+ task.subtasks.forEach((subtask: TaskSummary) => {
+ const emoji = subtask.status === "done" || subtask.status === "merged" ? "+" :
+ subtask.status === "running" ? "~" : "-";
+ sections.push(`- [${emoji === "+" ? "x" : " "}] ${subtask.name} (${subtask.status})\n`);
+ });
+ }
+
+ // Changed files
+ if (changedFiles && changedFiles.length > 0) {
+ sections.push("\n## Changed Files\n");
+ changedFiles.slice(0, 20).forEach((file) => {
+ sections.push(`- \`${file}\`\n`);
+ });
+ if (changedFiles.length > 20) {
+ sections.push(`\n_...and ${changedFiles.length - 20} more files_\n`);
+ }
+ }
+
+ // Test plan
+ sections.push("\n## Test Plan\n");
+ sections.push("- [ ] Manual testing completed\n");
+ sections.push("- [ ] Unit tests added/updated\n");
+ sections.push("- [ ] Integration tests passing\n");
+
+ // Footer
+ sections.push("\n---\n");
+ sections.push("_Generated by makima mesh orchestrator_\n");
+
+ return sections.join("");
+}
+
+export function PRPreview({
+ task,
+ diff = "",
+ changedFiles = [],
+ loading = false,
+ onCreatePR,
+ onAutoMerge,
+ onClose,
+}: PRPreviewProps) {
+ const [showDiff, setShowDiff] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const [formData, setFormData] = useState<PRFormData>(() => ({
+ title: generatePRTitle(task),
+ body: generatePRBody(task, changedFiles),
+ isDraft: false,
+ }));
+
+ const handleCreatePR = async () => {
+ if (!onCreatePR || creating) return;
+
+ setCreating(true);
+ setError(null);
+
+ try {
+ await onCreatePR(formData.title, formData.body, formData.isDraft);
+ onClose();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to create PR");
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ const handleAutoMerge = async () => {
+ if (!onAutoMerge || creating) return;
+
+ if (!confirm("Are you sure you want to auto-merge this task directly to the target branch?")) {
+ return;
+ }
+
+ setCreating(true);
+ setError(null);
+
+ try {
+ await onAutoMerge();
+ onClose();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to auto-merge");
+ } finally {
+ setCreating(false);
+ }
+ };
+
+ // Calculate stats
+ const stats = useMemo(() => {
+ const completedSubtasks = task.subtasks.filter(
+ (s) => s.status === "done" || s.status === "merged"
+ ).length;
+ return {
+ filesChanged: changedFiles.length,
+ subtasksCompleted: completedSubtasks,
+ subtasksTotal: task.subtasks.length,
+ isReady: completedSubtasks === task.subtasks.length || task.subtasks.length === 0,
+ };
+ }, [task.subtasks, changedFiles]);
+
+ return (
+ <div className="panel flex flex-col max-h-[80vh] overflow-hidden">
+ {/* Header */}
+ <div className="flex items-center justify-between p-4 border-b border-[rgba(117,170,252,0.2)] shrink-0">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Create Pull Request
+ </div>
+ <button
+ onClick={onClose}
+ className="font-mono text-xs text-[#555] hover:text-[#9bc3ff]"
+ >
+ Cancel
+ </button>
+ </div>
+
+ {/* Content */}
+ <div className="flex-1 overflow-y-auto p-4 space-y-4">
+ {/* Status badges */}
+ <div className="flex flex-wrap gap-2">
+ <span className="px-2 py-0.5 font-mono text-[10px] text-[#75aafc] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.2)]">
+ {task.baseBranch || "main"} → {task.targetBranch || task.baseBranch || "main"}
+ </span>
+ <span className="px-2 py-0.5 font-mono text-[10px] text-[#9bc3ff] bg-[rgba(117,170,252,0.1)] border border-[rgba(117,170,252,0.2)]">
+ {stats.filesChanged} files changed
+ </span>
+ {task.subtasks.length > 0 && (
+ <span
+ className={`px-2 py-0.5 font-mono text-[10px] border ${
+ stats.isReady
+ ? "text-green-400 bg-green-400/10 border-green-400/20"
+ : "text-yellow-400 bg-yellow-400/10 border-yellow-400/20"
+ }`}
+ >
+ {stats.subtasksCompleted}/{stats.subtasksTotal} subtasks complete
+ </span>
+ )}
+ </div>
+
+ {/* Warning if subtasks not complete */}
+ {!stats.isReady && (
+ <div className="bg-yellow-400/10 border border-yellow-400/30 p-3 font-mono text-xs text-yellow-400">
+ Some subtasks are not yet complete. Consider waiting before creating the PR.
+ </div>
+ )}
+
+ {/* Error message */}
+ {error && (
+ <div className="bg-red-400/10 border border-red-400/30 p-3 font-mono text-xs text-red-400">
+ {error}
+ </div>
+ )}
+
+ {/* PR Title */}
+ <div className="space-y-2">
+ <label className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Title
+ </label>
+ <input
+ type="text"
+ value={formData.title}
+ onChange={(e) => setFormData({ ...formData, title: e.target.value })}
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]"
+ placeholder="PR title"
+ disabled={creating}
+ />
+ </div>
+
+ {/* PR Body */}
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <label className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Description
+ </label>
+ <button
+ onClick={() => setFormData({
+ ...formData,
+ body: generatePRBody(task, changedFiles),
+ })}
+ className="font-mono text-[10px] text-[#555] hover:text-[#9bc3ff]"
+ >
+ Regenerate
+ </button>
+ </div>
+ <textarea
+ value={formData.body}
+ onChange={(e) => setFormData({ ...formData, body: e.target.value })}
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-xs px-3 py-2 outline-none focus:border-[#3f6fb3] min-h-[200px] resize-y"
+ placeholder="PR description (markdown)"
+ disabled={creating}
+ />
+ </div>
+
+ {/* Options */}
+ <div className="flex items-center gap-4">
+ <label className="flex items-center gap-2 cursor-pointer">
+ <input
+ type="checkbox"
+ checked={formData.isDraft}
+ onChange={(e) => setFormData({ ...formData, isDraft: e.target.checked })}
+ className="w-4 h-4 accent-[#75aafc]"
+ disabled={creating}
+ />
+ <span className="font-mono text-xs text-[#9bc3ff]">Create as draft</span>
+ </label>
+ </div>
+
+ {/* Diff preview toggle */}
+ <div className="border-t border-[rgba(117,170,252,0.2)] pt-4">
+ <button
+ onClick={() => setShowDiff(!showDiff)}
+ className="flex items-center gap-2 font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff]"
+ >
+ <span>{showDiff ? "▼" : "▶"}</span>
+ <span>
+ {showDiff ? "Hide" : "Show"} diff preview ({stats.filesChanged} files)
+ </span>
+ </button>
+ </div>
+
+ {/* Inline diff viewer */}
+ {showDiff && (
+ <div className="border border-[rgba(117,170,252,0.2)]">
+ <OverlayDiffViewer
+ diff={diff}
+ changedFiles={changedFiles}
+ loading={loading}
+ title="Changes to be merged"
+ />
+ </div>
+ )}
+ </div>
+
+ {/* Footer actions */}
+ <div className="flex items-center justify-between p-4 border-t border-[rgba(117,170,252,0.2)] shrink-0">
+ <div className="font-mono text-[10px] text-[#555]">
+ {task.repositoryUrl && (
+ <span className="truncate max-w-[200px] inline-block align-middle">
+ {task.repositoryUrl}
+ </span>
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ {task.mergeMode === "auto" && onAutoMerge && (
+ <button
+ onClick={handleAutoMerge}
+ disabled={creating || !stats.isReady}
+ className="px-4 py-2 font-mono text-xs text-yellow-400 border border-yellow-400/30 hover:border-yellow-400/50 hover:bg-yellow-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {creating ? "..." : "Auto-Merge"}
+ </button>
+ )}
+ {onCreatePR && (
+ <button
+ onClick={handleCreatePR}
+ disabled={creating || !formData.title.trim()}
+ className="px-4 py-2 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {creating ? "Creating..." : formData.isDraft ? "Create Draft PR" : "Create PR"}
+ </button>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/SubtaskTree.tsx b/makima/frontend/src/components/mesh/SubtaskTree.tsx
new file mode 100644
index 0000000..176b7a7
--- /dev/null
+++ b/makima/frontend/src/components/mesh/SubtaskTree.tsx
@@ -0,0 +1,297 @@
+import { useState, useCallback } from "react";
+import type { TaskSummary, TaskStatus } from "../../lib/api";
+
+interface SubtaskTreeProps {
+ subtasks: TaskSummary[];
+ onSelect: (taskId: string) => void;
+ depth?: number;
+ loading?: boolean;
+ fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>;
+}
+
+interface TreeNodeProps {
+ task: TaskSummary;
+ onSelect: (taskId: string) => void;
+ depth: number;
+ fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>;
+}
+
+function getStatusColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "text-[#9bc3ff]";
+ case "running":
+ return "text-green-400";
+ case "paused":
+ return "text-yellow-400";
+ case "blocked":
+ return "text-orange-400";
+ case "done":
+ return "text-emerald-400";
+ case "failed":
+ return "text-red-400";
+ case "merged":
+ return "text-purple-400";
+ default:
+ return "text-[#9bc3ff]";
+ }
+}
+
+function getStatusIcon(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "○";
+ case "running":
+ return "◉";
+ case "paused":
+ return "◎";
+ case "blocked":
+ return "◈";
+ case "done":
+ return "●";
+ case "failed":
+ return "✕";
+ case "merged":
+ return "◆";
+ default:
+ return "○";
+ }
+}
+
+function TreeNode({ task, onSelect, depth, fetchSubtasks }: TreeNodeProps) {
+ const [expanded, setExpanded] = useState(false);
+ const [children, setChildren] = useState<TaskSummary[] | null>(null);
+ const [loadingChildren, setLoadingChildren] = useState(false);
+
+ const hasSubtasks = task.subtaskCount > 0;
+
+ const handleToggle = useCallback(async () => {
+ if (!hasSubtasks) return;
+
+ if (expanded) {
+ setExpanded(false);
+ } else {
+ if (!children && fetchSubtasks) {
+ setLoadingChildren(true);
+ try {
+ const subtasks = await fetchSubtasks(task.id);
+ setChildren(subtasks);
+ } catch (err) {
+ console.error("Failed to fetch subtasks:", err);
+ } finally {
+ setLoadingChildren(false);
+ }
+ }
+ setExpanded(true);
+ }
+ }, [expanded, children, hasSubtasks, task.id, fetchSubtasks]);
+
+ const indent = depth * 16;
+
+ return (
+ <div className="select-none">
+ <div
+ className="flex items-center gap-2 py-1.5 px-2 hover:bg-[rgba(117,170,252,0.05)] transition-colors cursor-pointer group"
+ style={{ paddingLeft: `${indent + 8}px` }}
+ >
+ {/* Expand/Collapse button */}
+ <button
+ onClick={handleToggle}
+ className={`w-4 h-4 flex items-center justify-center font-mono text-[10px] ${
+ hasSubtasks
+ ? "text-[#75aafc] hover:text-[#9bc3ff]"
+ : "text-transparent cursor-default"
+ }`}
+ disabled={!hasSubtasks}
+ >
+ {loadingChildren ? (
+ <span className="animate-spin">⌛</span>
+ ) : hasSubtasks ? (
+ expanded ? "▼" : "▶"
+ ) : (
+ ""
+ )}
+ </button>
+
+ {/* Status icon */}
+ <span
+ className={`font-mono text-xs ${getStatusColor(task.status)}`}
+ title={task.status}
+ >
+ {getStatusIcon(task.status)}
+ </span>
+
+ {/* Task name - clickable */}
+ <button
+ onClick={() => onSelect(task.id)}
+ className="flex-1 text-left font-mono text-sm text-[#dbe7ff] hover:text-white transition-colors truncate"
+ >
+ {task.name}
+ </button>
+
+ {/* Subtask count badge */}
+ {hasSubtasks && (
+ <span className="font-mono text-[9px] text-[#555] group-hover:text-[#75aafc]">
+ {task.subtaskCount} sub
+ </span>
+ )}
+
+ {/* Priority indicator */}
+ {task.priority > 0 && (
+ <span className="font-mono text-[9px] text-orange-400">
+ P{task.priority}
+ </span>
+ )}
+ </div>
+
+ {/* Children */}
+ {expanded && children && children.length > 0 && (
+ <div className="border-l border-[rgba(117,170,252,0.15)]" style={{ marginLeft: `${indent + 16}px` }}>
+ {children.map((child) => (
+ <TreeNode
+ key={child.id}
+ task={child}
+ onSelect={onSelect}
+ depth={depth + 1}
+ fetchSubtasks={fetchSubtasks}
+ />
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
+
+export function SubtaskTree({
+ subtasks,
+ onSelect,
+ depth = 0,
+ loading = false,
+ fetchSubtasks,
+}: SubtaskTreeProps) {
+ if (loading) {
+ return (
+ <div className="p-4 text-center font-mono text-xs text-[#555]">
+ Loading subtasks...
+ </div>
+ );
+ }
+
+ if (subtasks.length === 0) {
+ return (
+ <div className="p-4 text-center font-mono text-xs text-[#555]">
+ No subtasks
+ </div>
+ );
+ }
+
+ return (
+ <div className="divide-y divide-[rgba(117,170,252,0.1)]">
+ {subtasks.map((task) => (
+ <TreeNode
+ key={task.id}
+ task={task}
+ onSelect={onSelect}
+ depth={depth}
+ fetchSubtasks={fetchSubtasks}
+ />
+ ))}
+ </div>
+ );
+}
+
+// Aggregated status summary for a task tree
+export interface TaskTreeStats {
+ total: number;
+ pending: number;
+ running: number;
+ paused: number;
+ blocked: number;
+ done: number;
+ failed: number;
+ merged: number;
+}
+
+export function calculateTreeStats(subtasks: TaskSummary[]): TaskTreeStats {
+ const stats: TaskTreeStats = {
+ total: subtasks.length,
+ pending: 0,
+ running: 0,
+ paused: 0,
+ blocked: 0,
+ done: 0,
+ failed: 0,
+ merged: 0,
+ };
+
+ for (const task of subtasks) {
+ switch (task.status) {
+ case "pending":
+ stats.pending++;
+ break;
+ case "running":
+ stats.running++;
+ break;
+ case "paused":
+ stats.paused++;
+ break;
+ case "blocked":
+ stats.blocked++;
+ break;
+ case "done":
+ stats.done++;
+ break;
+ case "failed":
+ stats.failed++;
+ break;
+ case "merged":
+ stats.merged++;
+ break;
+ }
+ }
+
+ return stats;
+}
+
+// Visual summary bar
+export function SubtaskProgressBar({ stats }: { stats: TaskTreeStats }) {
+ if (stats.total === 0) return null;
+
+ const segments = [
+ { count: stats.merged, color: "bg-purple-400", label: "Merged" },
+ { count: stats.done, color: "bg-emerald-400", label: "Done" },
+ { count: stats.running, color: "bg-green-400", label: "Running" },
+ { count: stats.paused, color: "bg-yellow-400", label: "Paused" },
+ { count: stats.blocked, color: "bg-orange-400", label: "Blocked" },
+ { count: stats.pending, color: "bg-[#75aafc]", label: "Pending" },
+ { count: stats.failed, color: "bg-red-400", label: "Failed" },
+ ].filter((s) => s.count > 0);
+
+ return (
+ <div className="space-y-1">
+ {/* Progress bar */}
+ <div className="h-2 flex overflow-hidden rounded-sm">
+ {segments.map((segment, i) => (
+ <div
+ key={i}
+ className={`${segment.color} transition-all`}
+ style={{ width: `${(segment.count / stats.total) * 100}%` }}
+ title={`${segment.label}: ${segment.count}`}
+ />
+ ))}
+ </div>
+
+ {/* Legend */}
+ <div className="flex flex-wrap gap-3 font-mono text-[9px]">
+ {segments.map((segment, i) => (
+ <div key={i} className="flex items-center gap-1">
+ <div className={`w-2 h-2 ${segment.color} rounded-sm`} />
+ <span className="text-[#75aafc]">
+ {segment.label}: {segment.count}
+ </span>
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/TaskDetail.tsx b/makima/frontend/src/components/mesh/TaskDetail.tsx
new file mode 100644
index 0000000..be4fb80
--- /dev/null
+++ b/makima/frontend/src/components/mesh/TaskDetail.tsx
@@ -0,0 +1,886 @@
+import { useState, useCallback, useMemo, useEffect } from "react";
+import type { TaskWithSubtasks, TaskStatus, TaskSummary, CompletionAction, DaemonDirectory } from "../../lib/api";
+import { retryCompletionAction, getDaemonDirectories, cloneWorktree } from "../../lib/api";
+import { SubtaskTree, SubtaskProgressBar, calculateTreeStats } from "./SubtaskTree";
+import { OverlayDiffViewer } from "./OverlayDiffViewer";
+import { PRPreview } from "./PRPreview";
+import { InlineSubtaskEditor } from "./InlineSubtaskEditor";
+import { DirectoryInput } from "./DirectoryInput";
+
+interface TaskDetailProps {
+ task: TaskWithSubtasks;
+ loading: boolean;
+ onBack: () => void;
+ onSave: (taskId: string, name: string, description: string, plan: string, targetRepoPath?: string, completionAction?: CompletionAction) => void;
+ onDelete: (taskId: string) => void;
+ onStart: (taskId: string) => void;
+ onStop: (taskId: string) => void;
+ onRestart: (taskId: string) => void;
+ onContinue: (taskId: string) => void;
+ onSelectSubtask: (taskId: string) => void;
+ onCreateSubtask: () => void;
+ /** Toggle viewing a subtask's output (for running subtasks) */
+ onToggleSubtaskOutput?: (subtaskId: string, subtaskName: string) => void;
+ /** Which subtask's output is currently being viewed */
+ viewingSubtaskId?: string | null;
+ // Optional advanced features
+ overlayDiff?: string;
+ changedFiles?: string[];
+ onRequestDiff?: () => void;
+ onCreatePR?: (title: string, body: string, draft: boolean) => Promise<void>;
+ onAutoMerge?: () => Promise<void>;
+ fetchSubtasks?: (taskId: string) => Promise<TaskSummary[]>;
+}
+
+function formatDate(dateStr: string): string {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+function getStatusColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "text-[#9bc3ff]";
+ case "initializing":
+ case "starting":
+ return "text-cyan-400";
+ case "running":
+ return "text-green-400";
+ case "paused":
+ return "text-yellow-400";
+ case "blocked":
+ return "text-orange-400";
+ case "done":
+ return "text-emerald-400";
+ case "failed":
+ return "text-red-400";
+ case "merged":
+ return "text-purple-400";
+ default:
+ return "text-[#9bc3ff]";
+ }
+}
+
+function getStatusBgColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "bg-[rgba(117,170,252,0.1)]";
+ case "initializing":
+ case "starting":
+ return "bg-cyan-400/10";
+ case "running":
+ return "bg-green-400/10";
+ case "paused":
+ return "bg-yellow-400/10";
+ case "blocked":
+ return "bg-orange-400/10";
+ case "done":
+ return "bg-emerald-400/10";
+ case "failed":
+ return "bg-red-400/10";
+ case "merged":
+ return "bg-purple-400/10";
+ default:
+ return "bg-[rgba(117,170,252,0.1)]";
+ }
+}
+
+export function TaskDetail({
+ task,
+ loading,
+ onBack,
+ onSave,
+ onDelete,
+ onStart,
+ onStop,
+ onRestart,
+ onContinue,
+ onSelectSubtask,
+ onCreateSubtask,
+ onToggleSubtaskOutput,
+ viewingSubtaskId,
+ overlayDiff,
+ changedFiles,
+ onRequestDiff,
+ onCreatePR,
+ onAutoMerge,
+ fetchSubtasks,
+}: TaskDetailProps) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [editName, setEditName] = useState(task.name);
+ const [editDescription, setEditDescription] = useState(task.description || "");
+ const [editPlan, setEditPlan] = useState(task.plan);
+ const [editTargetRepoPath, setEditTargetRepoPath] = useState(task.targetRepoPath || "");
+ const [editCompletionAction, setEditCompletionAction] = useState<CompletionAction>(
+ (task.completionAction as CompletionAction) || "none"
+ );
+ const [showDiff, setShowDiff] = useState(false);
+ const [showPRPreview, setShowPRPreview] = useState(false);
+ const [useTreeView, setUseTreeView] = useState(false);
+ // Track which subtask is expanded for inline editing
+ const [expandedSubtaskId, setExpandedSubtaskId] = useState<string | null>(null);
+ // Track interrupt dropdown state
+ const [showInterruptMenu, setShowInterruptMenu] = useState(false);
+ // Track retry completion action state
+ const [isRetryingCompletion, setIsRetryingCompletion] = useState(false);
+ const [retryError, setRetryError] = useState<string | null>(null);
+ // Suggested directories from daemon
+ const [suggestedDirectories, setSuggestedDirectories] = useState<DaemonDirectory[]>([]);
+ // Track clone worktree state
+ const [isCloning, setIsCloning] = useState(false);
+ const [cloneError, setCloneError] = useState<string | null>(null);
+ const [cloneTargetDir, setCloneTargetDir] = useState("");
+
+ // Check if task is running
+ const isTaskRunning = task.status === "running" || task.status === "initializing" || task.status === "starting";
+ // Check if task is in a terminal state (can be continued/reopened)
+ const isTaskTerminal = task.status === "done" || task.status === "failed" || task.status === "merged";
+
+ // Calculate subtask statistics
+ const subtaskStats = useMemo(
+ () => calculateTreeStats(task.subtasks),
+ [task.subtasks]
+ );
+
+ // Check if task can create PR
+ const canCreatePR = useMemo(() => {
+ return (
+ (task.status === "done" || task.status === "merged") &&
+ task.repositoryUrl &&
+ (onCreatePR || onAutoMerge)
+ );
+ }, [task.status, task.repositoryUrl, onCreatePR, onAutoMerge]);
+
+ // Check if task can retry completion action
+ const canRetryCompletion = useMemo(() => {
+ return (
+ (task.status === "done" || task.status === "failed" || task.status === "merged") &&
+ task.completionAction &&
+ task.completionAction !== "none" &&
+ task.targetRepoPath
+ // Note: overlayPath may be null in server DB even if worktree exists on daemon
+ // The daemon will scan for the worktree by task ID
+ );
+ }, [task.status, task.completionAction, task.targetRepoPath]);
+
+ // Handler for retrying completion action
+ const handleRetryCompletion = useCallback(async () => {
+ setIsRetryingCompletion(true);
+ setRetryError(null);
+ try {
+ await retryCompletionAction(task.id);
+ // Success - the result will be shown in task output
+ } catch (e) {
+ setRetryError(e instanceof Error ? e.message : "Failed to retry completion action");
+ } finally {
+ setIsRetryingCompletion(false);
+ }
+ }, [task.id]);
+
+ // Check if task can clone worktree
+ const canCloneWorktree = useMemo(() => {
+ return (
+ (task.status === "done" || task.status === "failed" || task.status === "merged")
+ );
+ }, [task.status]);
+
+ // Handler for cloning worktree
+ const handleCloneWorktree = useCallback(async () => {
+ if (!cloneTargetDir.trim()) {
+ setCloneError("Please enter a target directory");
+ return;
+ }
+ setIsCloning(true);
+ setCloneError(null);
+ try {
+ await cloneWorktree(task.id, cloneTargetDir);
+ // Success - the result will be shown in task output
+ setCloneTargetDir(""); // Clear input on success
+ } catch (e) {
+ setCloneError(e instanceof Error ? e.message : "Failed to clone worktree");
+ } finally {
+ setIsCloning(false);
+ }
+ }, [task.id, cloneTargetDir]);
+
+ // Fetch suggested directories when entering edit mode or when clone section is visible
+ useEffect(() => {
+ if (isEditing || canCloneWorktree) {
+ getDaemonDirectories()
+ .then((res) => setSuggestedDirectories(res.directories))
+ .catch(() => setSuggestedDirectories([]));
+ }
+ }, [isEditing, canCloneWorktree]);
+
+ const handleSave = useCallback(() => {
+ onSave(
+ task.id,
+ editName,
+ editDescription,
+ editPlan,
+ editTargetRepoPath || undefined,
+ editCompletionAction
+ );
+ setIsEditing(false);
+ }, [task.id, editName, editDescription, editPlan, editTargetRepoPath, editCompletionAction, onSave]);
+
+ const handleCancel = useCallback(() => {
+ setEditName(task.name);
+ setEditDescription(task.description || "");
+ setEditPlan(task.plan);
+ setEditTargetRepoPath(task.targetRepoPath || "");
+ setEditCompletionAction((task.completionAction as CompletionAction) || "none");
+ setIsEditing(false);
+ }, [task]);
+
+ // Toggle subtask expansion for inline editing
+ const handleSubtaskToggle = useCallback((subtaskId: string) => {
+ setExpandedSubtaskId((prev) => (prev === subtaskId ? null : subtaskId));
+ }, []);
+
+ // Handle subtask click - toggle output view for any task status
+ const handleSubtaskClick = useCallback(
+ (subtask: TaskSummary) => {
+ if (onToggleSubtaskOutput) {
+ // Toggle viewing this subtask's output (works for any status)
+ onToggleSubtaskOutput(subtask.id, subtask.name);
+ } else {
+ // Fallback to expand/collapse if output viewing not available
+ handleSubtaskToggle(subtask.id);
+ }
+ },
+ [onToggleSubtaskOutput, handleSubtaskToggle]
+ );
+
+ // Called when inline subtask editor saves changes
+ const handleSubtaskUpdated = useCallback(() => {
+ // Re-fetch the parent task to refresh subtask list
+ // This will trigger from the parent component when task updates
+ }, []);
+
+ if (loading) {
+ return (
+ <div className="panel h-full flex items-center justify-center">
+ <div className="font-mono text-[#9bc3ff] text-sm">Loading task...</div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="panel h-full flex flex-col overflow-hidden">
+ {/* Header */}
+ <div className="flex items-center justify-between p-4 pb-2 border-b border-dashed border-[rgba(117,170,252,0.35)] shrink-0">
+ <div className="flex items-center gap-3">
+ <button
+ onClick={onBack}
+ className="font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
+ >
+ &lt; Back
+ </button>
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ TASK//
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ {isEditing ? (
+ <>
+ <button
+ onClick={handleCancel}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleSave}
+ className="px-3 py-1 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 transition-colors uppercase"
+ >
+ Save
+ </button>
+ </>
+ ) : (
+ <>
+ {(task.status === "pending" || task.status === "failed") && (
+ <button
+ onClick={() => onStart(task.id)}
+ className="px-3 py-1 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 transition-colors uppercase"
+ >
+ Start
+ </button>
+ )}
+ {isTaskRunning && (
+ <div className="relative">
+ <button
+ onClick={() => setShowInterruptMenu(!showInterruptMenu)}
+ className="px-3 py-1 font-mono text-xs text-orange-400 border border-orange-400/30 hover:border-orange-400/50 hover:bg-orange-400/10 transition-colors uppercase flex items-center gap-1"
+ >
+ <span className="w-1.5 h-1.5 bg-orange-400 rounded-full animate-pulse" />
+ Interrupt
+ </button>
+ {showInterruptMenu && (
+ <>
+ {/* Backdrop to close menu on click outside */}
+ <div
+ className="fixed inset-0 z-40"
+ onClick={() => setShowInterruptMenu(false)}
+ />
+ <div className="absolute right-0 top-full mt-1 z-50 bg-[#0a1525] border border-[rgba(117,170,252,0.35)] shadow-lg">
+ <button
+ onClick={() => {
+ onRestart(task.id);
+ setShowInterruptMenu(false);
+ }}
+ className="block w-full px-4 py-2 font-mono text-xs text-left text-yellow-400 hover:bg-yellow-400/10 transition-colors whitespace-nowrap"
+ >
+ Restart Task
+ </button>
+ <button
+ onClick={() => {
+ onStop(task.id);
+ setShowInterruptMenu(false);
+ }}
+ className="block w-full px-4 py-2 font-mono text-xs text-left text-red-400 hover:bg-red-400/10 transition-colors whitespace-nowrap"
+ >
+ Cancel Task
+ </button>
+ </div>
+ </>
+ )}
+ </div>
+ )}
+ {isTaskTerminal && (
+ <button
+ onClick={() => onContinue(task.id)}
+ className="px-3 py-1 font-mono text-xs text-cyan-400 border border-cyan-400/30 hover:border-cyan-400/50 hover:bg-cyan-400/10 transition-colors uppercase flex items-center gap-1"
+ >
+ <span className="w-1.5 h-1.5 bg-cyan-400 rounded-full" />
+ Continue
+ </button>
+ )}
+ <button
+ onClick={() => setIsEditing(true)}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ Edit
+ </button>
+ <button
+ onClick={() => onDelete(task.id)}
+ className="px-3 py-1 font-mono text-xs text-red-400 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase"
+ >
+ Delete
+ </button>
+ </>
+ )}
+ </div>
+ </div>
+
+ {/* Content */}
+ <div className="flex-1 overflow-y-auto p-4 space-y-4">
+ {/* Task Info */}
+ <div className="space-y-3">
+ {isEditing ? (
+ <>
+ <input
+ type="text"
+ value={editName}
+ onChange={(e) => setEditName(e.target.value)}
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-lg px-3 py-2 outline-none focus:border-[#3f6fb3]"
+ placeholder="Task name"
+ />
+ <textarea
+ value={editDescription}
+ onChange={(e) => setEditDescription(e.target.value)}
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3] min-h-[60px] resize-y"
+ placeholder="Description (optional)"
+ />
+ </>
+ ) : (
+ <>
+ <h2 className="font-mono text-lg text-[#dbe7ff]">{task.name}</h2>
+ {task.description && (
+ <p className="font-mono text-sm text-[#9bc3ff]">{task.description}</p>
+ )}
+ </>
+ )}
+
+ {/* Status badges */}
+ <div className="flex flex-wrap gap-2">
+ <span
+ className={`px-2 py-0.5 font-mono text-xs uppercase ${getStatusColor(
+ task.status as TaskStatus
+ )} ${getStatusBgColor(task.status as TaskStatus)} border border-current/20`}
+ >
+ {task.status}
+ </span>
+ {/* Orchestrator badge for depth 0 tasks with subtasks */}
+ {task.depth === 0 && task.subtasks.length > 0 && (
+ <span className="px-2 py-0.5 font-mono text-xs text-purple-400 bg-purple-400/10 border border-purple-400/20">
+ Orchestrator
+ </span>
+ )}
+ {/* Depth indicator for subtasks */}
+ {task.depth > 0 && (
+ <span className="px-2 py-0.5 font-mono text-xs text-cyan-400 bg-cyan-400/10 border border-cyan-400/20">
+ Depth: {task.depth}
+ </span>
+ )}
+ {task.priority > 0 && (
+ <span className="px-2 py-0.5 font-mono text-xs text-orange-400 bg-orange-400/10 border border-orange-400/20">
+ Priority: {task.priority}
+ </span>
+ )}
+ {task.mergeMode && (
+ <span className="px-2 py-0.5 font-mono text-xs text-purple-400 bg-purple-400/10 border border-purple-400/20">
+ Merge: {task.mergeMode}
+ </span>
+ )}
+ </div>
+
+ {/* Metadata */}
+ <div className="flex flex-wrap gap-4 font-mono text-[10px] text-[#75aafc]">
+ <span>Created: {formatDate(task.createdAt)}</span>
+ {task.startedAt && <span>Started: {formatDate(task.startedAt)}</span>}
+ {task.completedAt && <span>Completed: {formatDate(task.completedAt)}</span>}
+ <span>Version: {task.version}</span>
+ </div>
+ </div>
+
+ {/* Plan */}
+ <div className="space-y-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Plan
+ </div>
+ {isEditing ? (
+ <textarea
+ value={editPlan}
+ onChange={(e) => setEditPlan(e.target.value)}
+ className="w-full bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3] min-h-[200px] resize-y"
+ placeholder="Enter the plan/instructions for this task..."
+ />
+ ) : (
+ <pre className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 font-mono text-sm text-[#dbe7ff] whitespace-pre-wrap overflow-x-auto">
+ {task.plan}
+ </pre>
+ )}
+ </div>
+
+ {/* Progress Summary */}
+ {task.progressSummary && (
+ <div className="space-y-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Progress
+ </div>
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 font-mono text-sm text-[#9bc3ff]">
+ {task.progressSummary}
+ </div>
+ </div>
+ )}
+
+ {/* Last Output */}
+ {task.lastOutput && (
+ <div className="space-y-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Last Output
+ </div>
+ <pre className="bg-[rgba(0,0,0,0.3)] border border-[rgba(117,170,252,0.15)] p-3 font-mono text-xs text-[#75aafc] whitespace-pre-wrap overflow-x-auto max-h-[200px] overflow-y-auto">
+ {task.lastOutput}
+ </pre>
+ </div>
+ )}
+
+ {/* Error Message */}
+ {task.errorMessage && (
+ <div className="space-y-2">
+ <div className="font-mono text-xs text-red-400 tracking-wide uppercase">
+ Error
+ </div>
+ <div className="bg-red-400/5 border border-red-400/30 p-3 font-mono text-sm text-red-400">
+ {task.errorMessage}
+ </div>
+ </div>
+ )}
+
+ {/* Repository Info */}
+ {(task.repositoryUrl || task.baseBranch || task.targetBranch) && (
+ <div className="space-y-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Repository
+ </div>
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-1">
+ {task.repositoryUrl && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">URL:</span> {task.repositoryUrl}
+ </div>
+ )}
+ {task.baseBranch && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Base:</span> {task.baseBranch}
+ </div>
+ )}
+ {task.targetBranch && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Target:</span> {task.targetBranch}
+ </div>
+ )}
+ {task.prUrl && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">PR:</span>{" "}
+ <a
+ href={task.prUrl}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="text-[#9bc3ff] hover:underline"
+ >
+ {task.prUrl}
+ </a>
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+
+ {/* Completion Action Settings */}
+ <div className="space-y-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Completion Actions
+ </div>
+ {isEditing ? (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-3">
+ <div className="space-y-1">
+ <label className="font-mono text-xs text-[#555]">Action on Completion</label>
+ <select
+ value={editCompletionAction}
+ onChange={(e) => setEditCompletionAction(e.target.value as CompletionAction)}
+ className="w-full bg-[#0a1525] border border-[rgba(117,170,252,0.25)] text-[#dbe7ff] font-mono text-sm px-3 py-2 outline-none focus:border-[#3f6fb3]"
+ >
+ <option value="none">None (keep in worktree)</option>
+ <option value="branch">Create branch in target repo</option>
+ <option value="merge">Auto-merge to target branch</option>
+ <option value="pr">Create Pull Request</option>
+ </select>
+ </div>
+ {editCompletionAction !== "none" && (
+ <div className="space-y-1">
+ <label className="font-mono text-xs text-[#555]">Target Repository Path</label>
+ <DirectoryInput
+ value={editTargetRepoPath}
+ onChange={setEditTargetRepoPath}
+ suggestions={suggestedDirectories}
+ placeholder="/path/to/your/local/repo"
+ repoUrl={task.repositoryUrl}
+ />
+ <p className="font-mono text-[10px] text-[#555]">
+ Path to your local repository where the branch will be pushed/merged.
+ </p>
+ </div>
+ )}
+ </div>
+ ) : (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-1">
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Action:</span>{" "}
+ {task.completionAction === "none" || !task.completionAction
+ ? "None (keep in worktree)"
+ : task.completionAction === "branch"
+ ? "Create branch in target repo"
+ : task.completionAction === "merge"
+ ? "Auto-merge to target branch"
+ : task.completionAction === "pr"
+ ? "Create Pull Request"
+ : task.completionAction}
+ </div>
+ {task.targetRepoPath && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Target Repo:</span> {task.targetRepoPath}
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+
+ {/* Metadata Info */}
+ {(task.daemonId || task.containerId || task.overlayPath) && (
+ <div className="space-y-2">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Metadata
+ </div>
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-1">
+ {task.daemonId && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Daemon:</span> {task.daemonId}
+ </div>
+ )}
+ {task.containerId && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Container:</span> {task.containerId}
+ </div>
+ )}
+ {task.overlayPath && (
+ <div className="font-mono text-xs text-[#75aafc]">
+ <span className="text-[#555]">Overlay:</span> {task.overlayPath}
+ </div>
+ )}
+ </div>
+ </div>
+ )}
+
+ {/* Subtasks */}
+ <div className="space-y-2">
+ <div className="flex items-center justify-between">
+ <div className="flex items-center gap-3">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Subtasks ({task.subtasks.length})
+ </div>
+ {task.subtasks.length > 0 && (
+ <button
+ onClick={() => setUseTreeView(!useTreeView)}
+ className="font-mono text-[9px] text-[#555] hover:text-[#75aafc]"
+ >
+ {useTreeView ? "List" : "Tree"}
+ </button>
+ )}
+ </div>
+ {/* Disable adding subtasks at max depth (2 = sub-subtask, cannot have children) */}
+ {task.depth < 2 ? (
+ <button
+ onClick={onCreateSubtask}
+ className="px-2 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ + Add Subtask
+ </button>
+ ) : (
+ <span className="px-2 py-1 font-mono text-[10px] text-[#555] border border-[#333]" title="Maximum depth reached">
+ Max depth
+ </span>
+ )}
+ </div>
+
+ {/* Progress bar for subtasks */}
+ {task.subtasks.length > 0 && (
+ <SubtaskProgressBar stats={subtaskStats} />
+ )}
+
+ {task.subtasks.length === 0 ? (
+ <div className="text-[#555] font-mono text-xs py-4 text-center">
+ No subtasks yet
+ </div>
+ ) : useTreeView ? (
+ <div className="border border-[rgba(117,170,252,0.15)]">
+ <SubtaskTree
+ subtasks={task.subtasks}
+ onSelect={onSelectSubtask}
+ fetchSubtasks={fetchSubtasks}
+ />
+ </div>
+ ) : (
+ <div className="divide-y divide-[rgba(117,170,252,0.15)] border border-[rgba(117,170,252,0.15)]">
+ {task.subtasks.map((subtask: TaskSummary) => {
+ const isRunning = subtask.status === "running" || subtask.status === "initializing" || subtask.status === "starting";
+ const isViewingOutput = viewingSubtaskId === subtask.id;
+ const isExpanded = expandedSubtaskId === subtask.id;
+
+ // Different highlight colors: green for running, subtle blue for others
+ const outputHighlightBg = isRunning ? "bg-green-400/10" : "bg-[rgba(117,170,252,0.08)]";
+ const outputHighlightBorder = isRunning ? "border-l-green-400" : "border-l-[#75aafc]";
+ const outputLabelColor = isRunning ? "text-green-400" : "text-[#75aafc]";
+
+ return (
+ <div key={subtask.id}>
+ {/* Subtask header - clickable to view output */}
+ <div
+ className={`w-full p-3 text-left hover:bg-[rgba(117,170,252,0.05)] transition-colors cursor-pointer ${
+ isExpanded && !isViewingOutput ? "bg-[rgba(117,170,252,0.08)]" : ""
+ } ${isViewingOutput ? `${outputHighlightBg} border-l-2 ${outputHighlightBorder}` : ""}`}
+ onClick={() => handleSubtaskClick(subtask)}
+ >
+ <div className="flex items-center gap-2 mb-1">
+ <span className="text-[#555] text-xs">
+ {isViewingOutput ? "[*]" : (isExpanded ? "[-]" : "[+]")}
+ </span>
+ <span className="font-mono text-sm text-[#dbe7ff]">
+ {subtask.name}
+ </span>
+ <span
+ className={`px-1.5 py-0.5 font-mono text-[9px] uppercase ${getStatusColor(
+ subtask.status
+ )} ${getStatusBgColor(subtask.status)} border border-current/20`}
+ >
+ {subtask.status}
+ </span>
+ {subtask.subtaskCount > 0 && (
+ <span className="font-mono text-[9px] text-[#555]">
+ +{subtask.subtaskCount}
+ </span>
+ )}
+ {isViewingOutput && (
+ <span className={`font-mono text-[9px] ${outputLabelColor} ml-auto`}>
+ {isRunning ? "viewing live output" : "viewing output"}
+ </span>
+ )}
+ {/* Expand/edit button - always available */}
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
+ handleSubtaskToggle(subtask.id);
+ }}
+ className={`ml-auto px-1.5 py-0.5 font-mono text-[10px] text-[#75aafc] hover:text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors ${
+ isViewingOutput ? "ml-2" : ""
+ }`}
+ title="Expand details"
+ >
+ {isExpanded ? "-" : "+"}
+ </button>
+ </div>
+ {subtask.progressSummary && !isExpanded && !isViewingOutput && (
+ <p className="font-mono text-xs text-[#75aafc] line-clamp-1 pl-6">
+ {subtask.progressSummary}
+ </p>
+ )}
+ </div>
+ {/* Inline subtask editor */}
+ {isExpanded && (
+ <InlineSubtaskEditor
+ subtaskId={subtask.id}
+ onClose={() => setExpandedSubtaskId(null)}
+ onUpdated={handleSubtaskUpdated}
+ onNavigate={onSelectSubtask}
+ />
+ )}
+ </div>
+ );
+ })}
+ </div>
+ )}
+ </div>
+
+ {/* Action buttons for completed tasks */}
+ {(task.status === "done" || task.status === "merged" || task.status === "failed") && (
+ <div className="space-y-2 pt-4 border-t border-[rgba(117,170,252,0.2)]">
+ <div className="flex flex-wrap gap-2">
+ {onRequestDiff && (
+ <button
+ onClick={() => {
+ onRequestDiff();
+ setShowDiff(true);
+ }}
+ className="px-3 py-1.5 font-mono text-xs text-[#75aafc] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors"
+ >
+ View Diff
+ </button>
+ )}
+ {canCreatePR && (
+ <button
+ onClick={() => setShowPRPreview(true)}
+ className="px-3 py-1.5 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 transition-colors"
+ >
+ Create PR
+ </button>
+ )}
+ {/* Retry completion action button */}
+ {canRetryCompletion && (
+ <button
+ onClick={handleRetryCompletion}
+ disabled={isRetryingCompletion}
+ className="px-3 py-1.5 font-mono text-xs text-cyan-400 border border-cyan-400/30 hover:border-cyan-400/50 hover:bg-cyan-400/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {isRetryingCompletion
+ ? "Retrying..."
+ : task.completionAction === "branch"
+ ? "Push Branch"
+ : task.completionAction === "merge"
+ ? "Merge to Target"
+ : task.completionAction === "pr"
+ ? "Create PR"
+ : "Run Completion Action"}
+ </button>
+ )}
+ {/* Show hint if completion action needs configuration */}
+ {!canRetryCompletion && (
+ <span className="px-3 py-1.5 font-mono text-xs text-[#555] italic">
+ {!task.completionAction || task.completionAction === "none"
+ ? "Set completion action to enable"
+ : !task.targetRepoPath
+ ? "Set target repo path to enable"
+ : ""}
+ </span>
+ )}
+ </div>
+ {/* Retry error message */}
+ {retryError && (
+ <div className="font-mono text-xs text-red-400 bg-red-400/10 px-3 py-2 border border-red-400/30">
+ {retryError}
+ </div>
+ )}
+ </div>
+ )}
+
+ {/* Clone Worktree Section */}
+ {canCloneWorktree && (
+ <div className="bg-[rgba(0,0,0,0.2)] border border-[rgba(117,170,252,0.15)] p-3 space-y-2">
+ <div className="font-mono text-xs text-[#555]">Clone Worktree to Directory</div>
+ <div className="flex gap-2 items-start">
+ <DirectoryInput
+ value={cloneTargetDir}
+ onChange={setCloneTargetDir}
+ suggestions={suggestedDirectories}
+ placeholder="/path/to/clone"
+ repoUrl={task.repositoryUrl}
+ className="flex-1"
+ />
+ <button
+ onClick={handleCloneWorktree}
+ disabled={isCloning || !cloneTargetDir.trim()}
+ className="px-3 py-2 font-mono text-xs text-purple-400 border border-purple-400/30 hover:border-purple-400/50 hover:bg-purple-400/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
+ >
+ {isCloning ? "Cloning..." : "Clone"}
+ </button>
+ </div>
+ <p className="font-mono text-[10px] text-[#555]">
+ Clone the worktree (git repo) to a new directory. Useful for moving completed work outside ~/.makima.
+ </p>
+ {cloneError && (
+ <div className="font-mono text-xs text-red-400 bg-red-400/10 px-3 py-2 border border-red-400/30">
+ {cloneError}
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+
+ {/* Overlay Diff Modal */}
+ {showDiff && overlayDiff !== undefined && (
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
+ <div className="max-w-4xl w-full max-h-[80vh]">
+ <OverlayDiffViewer
+ diff={overlayDiff}
+ changedFiles={changedFiles}
+ onClose={() => setShowDiff(false)}
+ title={`Changes in ${task.name}`}
+ />
+ </div>
+ </div>
+ )}
+
+ {/* PR Preview Modal */}
+ {showPRPreview && (
+ <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
+ <div className="max-w-3xl w-full">
+ <PRPreview
+ task={task}
+ diff={overlayDiff}
+ changedFiles={changedFiles}
+ onCreatePR={onCreatePR}
+ onAutoMerge={task.mergeMode === "auto" ? onAutoMerge : undefined}
+ onClose={() => setShowPRPreview(false)}
+ />
+ </div>
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/TaskList.tsx b/makima/frontend/src/components/mesh/TaskList.tsx
new file mode 100644
index 0000000..a37e564
--- /dev/null
+++ b/makima/frontend/src/components/mesh/TaskList.tsx
@@ -0,0 +1,164 @@
+import type { TaskSummary, TaskStatus } from "../../lib/api";
+
+interface TaskListProps {
+ tasks: TaskSummary[];
+ loading: boolean;
+ onSelect: (id: string) => void;
+ onDelete: (id: string) => void;
+ onCreate: () => void;
+}
+
+function formatDate(dateStr: string): string {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+function getStatusColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "text-[#9bc3ff]";
+ case "running":
+ return "text-green-400";
+ case "paused":
+ return "text-yellow-400";
+ case "blocked":
+ return "text-orange-400";
+ case "done":
+ return "text-emerald-400";
+ case "failed":
+ return "text-red-400";
+ case "merged":
+ return "text-purple-400";
+ default:
+ return "text-[#9bc3ff]";
+ }
+}
+
+function getStatusBgColor(status: TaskStatus): string {
+ switch (status) {
+ case "pending":
+ return "bg-[rgba(117,170,252,0.1)]";
+ case "running":
+ return "bg-green-400/10";
+ case "paused":
+ return "bg-yellow-400/10";
+ case "blocked":
+ return "bg-orange-400/10";
+ case "done":
+ return "bg-emerald-400/10";
+ case "failed":
+ return "bg-red-400/10";
+ case "merged":
+ return "bg-purple-400/10";
+ default:
+ return "bg-[rgba(117,170,252,0.1)]";
+ }
+}
+
+export function TaskList({
+ tasks,
+ loading,
+ onSelect,
+ onDelete,
+ onCreate,
+}: TaskListProps) {
+ if (loading) {
+ return (
+ <div className="panel h-full flex items-center justify-center">
+ <div className="font-mono text-[#9bc3ff] text-sm">Loading tasks...</div>
+ </div>
+ );
+ }
+
+ // Separate root tasks (no parent) from subtasks
+ const rootTasks = tasks.filter((t) => !t.parentTaskId);
+
+ return (
+ <div className="panel h-full flex flex-col">
+ <div className="flex items-center justify-between p-4 pb-2 border-b border-dashed border-[rgba(117,170,252,0.35)]">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ MESH//TASKS
+ </div>
+ <button
+ onClick={onCreate}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors uppercase"
+ >
+ + New Task
+ </button>
+ </div>
+
+ <div className="flex-1 overflow-y-auto">
+ {rootTasks.length === 0 ? (
+ <div className="text-center text-[#9bc3ff] text-sm font-mono opacity-60 py-8">
+ No tasks yet. Create one to start orchestrating Claude Code instances.
+ </div>
+ ) : (
+ <div className="divide-y divide-[rgba(117,170,252,0.15)]">
+ {rootTasks.map((task) => (
+ <div
+ key={task.id}
+ className="p-4 hover:bg-[rgba(117,170,252,0.05)] transition-colors"
+ >
+ <div className="flex items-start justify-between gap-4">
+ <button
+ onClick={() => onSelect(task.id)}
+ className="flex-1 text-left"
+ >
+ <div className="flex items-center gap-2 mb-1">
+ <h3 className="font-mono text-sm text-[#dbe7ff]">
+ {task.name}
+ </h3>
+ <span
+ className={`px-2 py-0.5 font-mono text-[10px] uppercase ${getStatusColor(
+ task.status
+ )} ${getStatusBgColor(task.status)} border border-current/20`}
+ >
+ {task.status}
+ </span>
+ {task.depth === 0 && task.subtaskCount > 0 && (
+ <span className="px-2 py-0.5 font-mono text-[10px] text-purple-400 bg-purple-400/10 border border-purple-400/20">
+ Orchestrator
+ </span>
+ )}
+ {task.priority > 0 && (
+ <span className="px-2 py-0.5 font-mono text-[10px] text-orange-400 bg-orange-400/10 border border-orange-400/20">
+ P{task.priority}
+ </span>
+ )}
+ </div>
+ {task.progressSummary && (
+ <p className="font-mono text-xs text-[#9bc3ff] mb-2 line-clamp-2">
+ {task.progressSummary}
+ </p>
+ )}
+ <div className="flex gap-4 font-mono text-[10px] text-[#75aafc]">
+ {task.subtaskCount > 0 && (
+ <span>{task.subtaskCount} subtasks</span>
+ )}
+ <span>{formatDate(task.createdAt)}</span>
+ </div>
+ </button>
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
+ onDelete(task.id);
+ }}
+ className="px-2 py-1 font-mono text-[10px] text-red-400 hover:bg-red-400/10 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase"
+ >
+ Delete
+ </button>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/mesh/TaskOutput.tsx b/makima/frontend/src/components/mesh/TaskOutput.tsx
new file mode 100644
index 0000000..10de225
--- /dev/null
+++ b/makima/frontend/src/components/mesh/TaskOutput.tsx
@@ -0,0 +1,281 @@
+import { useRef, useEffect, useState, useCallback } from "react";
+import { SimpleMarkdown } from "../SimpleMarkdown";
+import type { TaskOutputEvent } from "../../hooks/useTaskSubscription";
+import { sendTaskMessage } from "../../lib/api";
+
+interface TaskOutputProps {
+ /** Array of parsed output events from the backend */
+ entries: TaskOutputEvent[];
+ isStreaming: boolean;
+ /** Name of subtask whose output is being viewed (null = parent task) */
+ viewingSubtaskName?: string | null;
+ /** Callback to return to parent task output */
+ onClearSubtaskView?: () => void;
+ onClear?: () => void;
+ /** Task ID for sending input (if provided, shows input bar when streaming) */
+ taskId?: string | null;
+ /** Callback when user sends input (to show it immediately in output) */
+ onUserInput?: (message: string) => void;
+}
+
+export function TaskOutput({ entries, isStreaming, viewingSubtaskName, onClearSubtaskView, onClear, taskId, onUserInput }: TaskOutputProps) {
+ const containerRef = useRef<HTMLDivElement>(null);
+ const [autoScroll, setAutoScroll] = useState(true);
+ const [inputValue, setInputValue] = useState("");
+ const [sendingInput, setSendingInput] = useState(false);
+ const [inputError, setInputError] = useState<string | null>(null);
+ const inputRef = useRef<HTMLInputElement>(null);
+
+ // Handle scroll to check if user has scrolled up
+ const handleScroll = useCallback(() => {
+ if (!containerRef.current) return;
+ const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
+ const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
+ setAutoScroll(isAtBottom);
+ }, []);
+
+ // Auto-scroll when entries change
+ useEffect(() => {
+ if (autoScroll && containerRef.current) {
+ containerRef.current.scrollTop = containerRef.current.scrollHeight;
+ }
+ }, [entries, autoScroll]);
+
+ // Handle sending input to the task
+ const handleSendInput = useCallback(async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!taskId || !inputValue.trim() || sendingInput) return;
+
+ const message = inputValue.trim();
+ setSendingInput(true);
+ setInputError(null);
+
+ // Show user input immediately in the output window
+ onUserInput?.(message);
+
+ try {
+ await sendTaskMessage(taskId, message);
+ setInputValue("");
+ inputRef.current?.focus();
+ } catch (err) {
+ setInputError(err instanceof Error ? err.message : "Failed to send input");
+ } finally {
+ setSendingInput(false);
+ }
+ }, [taskId, inputValue, sendingInput, onUserInput]);
+
+ // Show input bar when task is running and has a valid taskId
+ const showInputBar = isStreaming && taskId;
+
+ return (
+ <div className="flex flex-col h-full">
+ {/* Header */}
+ <div className="flex items-center justify-between px-3 py-2 border-b border-dashed border-[rgba(117,170,252,0.35)] shrink-0">
+ <div className="flex items-center gap-2">
+ {viewingSubtaskName ? (
+ <>
+ <button
+ onClick={onClearSubtaskView}
+ className="font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
+ >
+ &lt;
+ </button>
+ <span className="font-mono text-xs text-green-400 tracking-wide uppercase">
+ Subtask: {viewingSubtaskName}
+ </span>
+ </>
+ ) : (
+ <span className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ Output
+ </span>
+ )}
+ {isStreaming && (
+ <span className="flex items-center gap-1.5 px-2 py-0.5 bg-green-400/10 border border-green-400/30 text-green-400 font-mono text-[10px] uppercase">
+ <span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" />
+ Live
+ </span>
+ )}
+ </div>
+ <div className="flex items-center gap-2">
+ {!autoScroll && (
+ <button
+ onClick={() => {
+ setAutoScroll(true);
+ if (containerRef.current) {
+ containerRef.current.scrollTop =
+ containerRef.current.scrollHeight;
+ }
+ }}
+ className="px-2 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ Resume Scroll
+ </button>
+ )}
+ {onClear && entries.length > 0 && (
+ <button
+ onClick={onClear}
+ className="px-2 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ Clear
+ </button>
+ )}
+ </div>
+ </div>
+
+ {/* Output area */}
+ <div
+ ref={containerRef}
+ onScroll={handleScroll}
+ className="flex-1 overflow-auto bg-[#0a0f18] p-3 font-mono text-xs min-h-0"
+ >
+ {entries.length === 0 ? (
+ <div className="text-[#555] italic">
+ {isStreaming ? "Waiting for output..." : "No output yet"}
+ </div>
+ ) : (
+ <div className="space-y-3">
+ {entries.map((entry, idx) => (
+ <OutputEntryRenderer key={idx} entry={entry} />
+ ))}
+ {isStreaming && (
+ <span className="inline-block w-2 h-4 bg-[#9bc3ff] animate-pulse" />
+ )}
+ </div>
+ )}
+ </div>
+
+ {/* Input bar for sending messages to running tasks */}
+ {showInputBar && (
+ <div className="shrink-0 border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]">
+ {inputError && (
+ <div className="px-3 py-1 bg-red-900/20 text-red-400 text-xs font-mono">
+ {inputError}
+ </div>
+ )}
+ <form onSubmit={handleSendInput} className="flex items-center gap-2 px-3 py-2">
+ <span className="text-green-400 font-mono text-sm">&gt;</span>
+ <input
+ ref={inputRef}
+ type="text"
+ value={inputValue}
+ onChange={(e) => setInputValue(e.target.value)}
+ placeholder={sendingInput ? "Sending..." : "Send input to Claude..."}
+ disabled={sendingInput}
+ className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]"
+ />
+ <button
+ type="submit"
+ disabled={sendingInput || !inputValue.trim()}
+ className="px-3 py-1 font-mono text-xs text-green-400 border border-green-400/30 hover:border-green-400/50 hover:bg-green-400/10 disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {sendingInput ? "..." : "Send"}
+ </button>
+ </form>
+ </div>
+ )}
+ </div>
+ );
+}
+
+function OutputEntryRenderer({ entry }: { entry: TaskOutputEvent }) {
+ const [expanded, setExpanded] = useState(false);
+
+ switch (entry.messageType) {
+ case "user_input":
+ return (
+ <div className="pl-2 border-l-2 border-cyan-400/50">
+ <div className="flex items-center gap-2">
+ <span className="text-cyan-400 text-[10px] uppercase tracking-wide">You:</span>
+ </div>
+ <div className="text-cyan-300 mt-1">{entry.content}</div>
+ </div>
+ );
+
+ case "system":
+ return (
+ <div className="text-[#555] text-[10px] uppercase tracking-wide">
+ {entry.content}
+ </div>
+ );
+
+ case "assistant":
+ return (
+ <div className="pl-2 border-l-2 border-[#3f6fb3]">
+ <SimpleMarkdown content={entry.content} className="text-[#9bc3ff]" />
+ </div>
+ );
+
+ case "tool_use":
+ return (
+ <div className="space-y-1">
+ <div className="flex items-center gap-2">
+ <span className="text-yellow-500">*</span>
+ <span className="text-[#75aafc]">{entry.toolName || "unknown"}</span>
+ {entry.toolInput && Object.keys(entry.toolInput).length > 0 && (
+ <button
+ onClick={() => setExpanded(!expanded)}
+ className="text-[#555] hover:text-[#9bc3ff] text-[10px]"
+ >
+ {expanded ? "[-]" : "[+]"}
+ </button>
+ )}
+ </div>
+ {expanded && entry.toolInput && (
+ <pre className="ml-4 text-[10px] text-[#555] bg-[#0a1525] p-2 overflow-x-auto">
+ {JSON.stringify(entry.toolInput, null, 2)}
+ </pre>
+ )}
+ </div>
+ );
+
+ case "tool_result":
+ if (!entry.content) return null;
+ return (
+ <div className="ml-4 text-[10px]">
+ <span className={entry.isError ? "text-red-400" : "text-green-500"}>
+ {entry.isError ? "x" : "+"}
+ </span>{" "}
+ <span className="text-[#555]">
+ {entry.content.split("\n")[0]}
+ {entry.content.includes("\n") && "..."}
+ </span>
+ </div>
+ );
+
+ case "result":
+ return (
+ <div className="border-t border-[rgba(117,170,252,0.2)] pt-2 mt-2">
+ <div className="text-green-500 font-semibold mb-1">Result:</div>
+ <SimpleMarkdown content={entry.content} className="text-[#9bc3ff]" />
+ {(entry.costUsd !== undefined || entry.durationMs !== undefined) && (
+ <div className="text-[10px] text-[#555] mt-2">
+ {entry.durationMs !== undefined && (
+ <span>Duration: {(entry.durationMs / 1000).toFixed(1)}s</span>
+ )}
+ {entry.costUsd !== undefined && entry.durationMs !== undefined && " | "}
+ {entry.costUsd !== undefined && (
+ <span>Cost: ${entry.costUsd.toFixed(4)}</span>
+ )}
+ </div>
+ )}
+ </div>
+ );
+
+ case "error":
+ return (
+ <div className="text-red-400 pl-2 border-l-2 border-red-400/50">
+ {entry.content}
+ </div>
+ );
+
+ case "raw":
+ return (
+ <div className="text-[#555] text-[10px]">
+ {entry.content}
+ </div>
+ );
+
+ default:
+ return null;
+ }
+}
diff --git a/makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx b/makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx
new file mode 100644
index 0000000..5caa3c4
--- /dev/null
+++ b/makima/frontend/src/components/mesh/UnifiedMeshChatInput.tsx
@@ -0,0 +1,536 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+import {
+ type LlmModel,
+ type UserQuestion,
+ type UserAnswer,
+ type MeshChatContext,
+} from "../../lib/api";
+import { useMeshChatHistory } from "../../hooks/useMeshChatHistory";
+import { SimpleMarkdown } from "../SimpleMarkdown";
+
+interface UnifiedMeshChatInputProps {
+ context: MeshChatContext;
+ onUpdate?: () => void;
+}
+
+const MODEL_OPTIONS: { value: LlmModel; label: string }[] = [
+ { value: "claude-opus", label: "Claude Opus" },
+ { value: "claude-sonnet", label: "Claude Sonnet" },
+ { value: "groq", label: "Groq Kimi" },
+];
+
+const DEFAULT_MODEL: LlmModel = "claude-opus";
+
+// LocalStorage keys
+const STORAGE_KEY_MODEL = "makima-mesh-chat-model";
+const STORAGE_KEY_CMD_HISTORY = "makima-mesh-chat-cmd-history";
+const MAX_CMD_HISTORY = 100;
+
+function loadModel(): LlmModel {
+ try {
+ const modelStr = localStorage.getItem(STORAGE_KEY_MODEL);
+ return (modelStr as LlmModel) || DEFAULT_MODEL;
+ } catch {
+ return DEFAULT_MODEL;
+ }
+}
+
+function saveModel(model: LlmModel): void {
+ try {
+ localStorage.setItem(STORAGE_KEY_MODEL, model);
+ } catch {
+ // Ignore storage errors
+ }
+}
+
+function loadCommandHistory(): string[] {
+ try {
+ const historyJson = localStorage.getItem(STORAGE_KEY_CMD_HISTORY);
+ return historyJson ? JSON.parse(historyJson) : [];
+ } catch {
+ return [];
+ }
+}
+
+function saveCommandHistory(history: string[]): void {
+ try {
+ localStorage.setItem(
+ STORAGE_KEY_CMD_HISTORY,
+ JSON.stringify(history.slice(-MAX_CMD_HISTORY))
+ );
+ } catch {
+ // Ignore storage errors
+ }
+}
+
+function getPlaceholder(context: MeshChatContext): string {
+ switch (context.type) {
+ case "mesh":
+ return "Create task, list tasks, check status...";
+ case "task":
+ return "Create subtask, run task, check status...";
+ case "subtask":
+ return "Update plan, check siblings, merge...";
+ default:
+ return "Ask anything...";
+ }
+}
+
+function getContextLabel(context: MeshChatContext): string {
+ switch (context.type) {
+ case "mesh":
+ return "mesh";
+ case "task":
+ return `task:${context.taskId?.slice(0, 8)}`;
+ case "subtask":
+ return `subtask:${context.taskId?.slice(0, 8)}`;
+ default:
+ return "chat";
+ }
+}
+
+export function UnifiedMeshChatInput({
+ context,
+ onUpdate,
+}: UnifiedMeshChatInputProps) {
+ const {
+ messages,
+ loading: historyLoading,
+ error: historyError,
+ sending,
+ clearHistory,
+ sendMessage,
+ } = useMeshChatHistory();
+
+ const [input, setInput] = useState("");
+ const [expanded, setExpanded] = useState(false);
+ const [model, setModel] = useState<LlmModel>(DEFAULT_MODEL);
+
+ // Pending questions state
+ const [pendingQuestions, setPendingQuestions] = useState<
+ UserQuestion[] | null
+ >(null);
+ const [userAnswers, setUserAnswers] = useState<Map<string, string[]>>(
+ new Map()
+ );
+ const [customInputs, setCustomInputs] = useState<Map<string, string>>(
+ new Map()
+ );
+
+ // Command history for arrow key navigation
+ const [commandHistory, setCommandHistory] = useState<string[]>([]);
+ const [historyIndex, setHistoryIndex] = useState(-1);
+ const [savedInput, setSavedInput] = useState("");
+
+ const inputRef = useRef<HTMLInputElement>(null);
+ const messagesRef = useRef<HTMLDivElement>(null);
+
+ // Load model preference on mount
+ useEffect(() => {
+ setModel(loadModel());
+ setCommandHistory(loadCommandHistory());
+ }, []);
+
+ // Expand when messages exist
+ useEffect(() => {
+ if (messages.length > 0) {
+ setExpanded(true);
+ }
+ }, [messages.length]);
+
+ // Auto-scroll to bottom when messages change
+ useEffect(() => {
+ if (messagesRef.current) {
+ messagesRef.current.scrollTop = messagesRef.current.scrollHeight;
+ }
+ }, [messages]);
+
+ // Handle model change
+ const handleModelChange = useCallback((newModel: LlmModel) => {
+ setModel(newModel);
+ saveModel(newModel);
+ }, []);
+
+ // Handle keyboard navigation for command history
+ const handleKeyDown = useCallback(
+ (e: React.KeyboardEvent<HTMLInputElement>) => {
+ if (e.key === "ArrowUp") {
+ e.preventDefault();
+ if (commandHistory.length === 0) return;
+
+ if (historyIndex === -1) {
+ setSavedInput(input);
+ setHistoryIndex(commandHistory.length - 1);
+ setInput(commandHistory[commandHistory.length - 1]);
+ } else if (historyIndex > 0) {
+ setHistoryIndex(historyIndex - 1);
+ setInput(commandHistory[historyIndex - 1]);
+ }
+ } else if (e.key === "ArrowDown") {
+ e.preventDefault();
+ if (historyIndex === -1) return;
+
+ if (historyIndex < commandHistory.length - 1) {
+ setHistoryIndex(historyIndex + 1);
+ setInput(commandHistory[historyIndex + 1]);
+ } else {
+ setHistoryIndex(-1);
+ setInput(savedInput);
+ }
+ }
+ },
+ [commandHistory, historyIndex, input, savedInput]
+ );
+
+ const handleSubmit = useCallback(
+ async (e: React.FormEvent) => {
+ e.preventDefault();
+ if (!input.trim() || sending) return;
+
+ const userMessage = input.trim();
+
+ // Update command history
+ const newHistory =
+ commandHistory[commandHistory.length - 1] !== userMessage
+ ? [...commandHistory, userMessage]
+ : commandHistory;
+ setCommandHistory(newHistory);
+ saveCommandHistory(newHistory);
+
+ // Reset navigation state
+ setHistoryIndex(-1);
+ setSavedInput("");
+
+ setInput("");
+ setExpanded(true);
+
+ // Send message via hook (uses DB-persisted history)
+ const response = await sendMessage(userMessage, context, model);
+
+ if (response) {
+ // Handle pending questions
+ if (response.pendingQuestions?.length) {
+ setPendingQuestions(response.pendingQuestions);
+ const initialAnswers = new Map<string, string[]>();
+ response.pendingQuestions.forEach((q) => {
+ initialAnswers.set(q.id, []);
+ });
+ setUserAnswers(initialAnswers);
+ setCustomInputs(new Map());
+ }
+
+ // Notify parent that something may have been updated
+ // Always refresh when tool calls were made (state may have changed)
+ if (response.toolCalls && response.toolCalls.length > 0) {
+ onUpdate?.();
+ }
+ }
+
+ inputRef.current?.focus();
+ },
+ [input, sending, context, model, sendMessage, onUpdate, commandHistory]
+ );
+
+ // Handle option selection for a question
+ const handleOptionToggle = useCallback(
+ (questionId: string, option: string, allowMultiple: boolean) => {
+ setUserAnswers((prev) => {
+ const newMap = new Map(prev);
+ const currentAnswers = newMap.get(questionId) || [];
+
+ if (allowMultiple) {
+ if (currentAnswers.includes(option)) {
+ newMap.set(
+ questionId,
+ currentAnswers.filter((a) => a !== option)
+ );
+ } else {
+ newMap.set(questionId, [...currentAnswers, option]);
+ }
+ } else {
+ newMap.set(questionId, [option]);
+ }
+
+ return newMap;
+ });
+ },
+ []
+ );
+
+ // Handle custom input change
+ const handleCustomInputChange = useCallback(
+ (questionId: string, value: string) => {
+ setCustomInputs((prev) => {
+ const newMap = new Map(prev);
+ newMap.set(questionId, value);
+ return newMap;
+ });
+ },
+ []
+ );
+
+ // Submit answers to questions
+ const handleSubmitAnswers = useCallback(async () => {
+ if (!pendingQuestions || sending) return;
+
+ // Build answers array
+ const answers: UserAnswer[] = pendingQuestions.map((q) => {
+ const selectedOptions = userAnswers.get(q.id) || [];
+ const customInput = customInputs.get(q.id)?.trim();
+ const finalAnswers = customInput
+ ? [...selectedOptions, customInput]
+ : selectedOptions;
+
+ return {
+ id: q.id,
+ answers: finalAnswers,
+ };
+ });
+
+ // Format answers as a message
+ const answerText = answers
+ .map((a) => {
+ const question = pendingQuestions.find((q) => q.id === a.id);
+ return `${question?.question || a.id}: ${a.answers.join(", ")}`;
+ })
+ .join("\n");
+
+ // Clear pending questions
+ setPendingQuestions(null);
+ setUserAnswers(new Map());
+ setCustomInputs(new Map());
+
+ // Send answers as the next message
+ const response = await sendMessage(answerText, context, model);
+
+ if (response) {
+ // Handle more pending questions
+ if (response.pendingQuestions?.length) {
+ setPendingQuestions(response.pendingQuestions);
+ const initialAnswers = new Map<string, string[]>();
+ response.pendingQuestions.forEach((q) => {
+ initialAnswers.set(q.id, []);
+ });
+ setUserAnswers(initialAnswers);
+ setCustomInputs(new Map());
+ }
+
+ // Notify parent that something may have been updated
+ if (response.toolCalls && response.toolCalls.length > 0) {
+ onUpdate?.();
+ }
+ }
+ }, [
+ pendingQuestions,
+ userAnswers,
+ customInputs,
+ sending,
+ context,
+ model,
+ sendMessage,
+ onUpdate,
+ ]);
+
+ // Cancel answering questions
+ const handleCancelQuestions = useCallback(() => {
+ setPendingQuestions(null);
+ setUserAnswers(new Map());
+ setCustomInputs(new Map());
+ }, []);
+
+ const handleClearHistory = useCallback(async () => {
+ await clearHistory();
+ setPendingQuestions(null);
+ setUserAnswers(new Map());
+ setCustomInputs(new Map());
+ }, [clearHistory]);
+
+ const loading = sending || historyLoading;
+
+ return (
+ <div className="border-t border-[rgba(117,170,252,0.35)] bg-[#0d1b2d]">
+ {/* Error Display */}
+ {historyError && (
+ <div className="px-3 py-2 bg-red-900/20 text-red-400 text-xs font-mono">
+ {historyError}
+ </div>
+ )}
+
+ {/* Messages Panel (expandable) */}
+ {expanded && messages.length > 0 && (
+ <div
+ ref={messagesRef}
+ className="max-h-48 overflow-y-auto p-3 space-y-2 border-b border-[rgba(117,170,252,0.2)]"
+ >
+ {messages.map((msg) => (
+ <div key={msg.id} className="font-mono text-xs">
+ {msg.role === "user" && (
+ <div className="flex gap-2">
+ <span className="text-[#9bc3ff]">&gt;</span>
+ <span className="text-white/80 whitespace-pre-wrap">
+ {msg.content}
+ </span>
+ {msg.contextType !== "mesh" && (
+ <span className="text-[#555] text-[10px]">
+ [{msg.contextType}]
+ </span>
+ )}
+ </div>
+ )}
+ {msg.role === "assistant" && (
+ <div className="pl-4 space-y-1">
+ <SimpleMarkdown
+ content={msg.content}
+ className="text-[#75aafc]"
+ />
+ {msg.toolCalls && msg.toolCalls.length > 0 && (
+ <div className="text-[#555] text-[10px] space-y-0.5">
+ {msg.toolCalls.map((tc, i) => (
+ <div key={i}>
+ <span
+ className={
+ tc.result.success
+ ? "text-green-500"
+ : "text-red-400"
+ }
+ >
+ {tc.result.success ? "+" : "x"}
+ </span>{" "}
+ {tc.name}: {tc.result.message}
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ )}
+ {msg.role === "error" && (
+ <div className="pl-4 text-red-400">{msg.content}</div>
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+
+ {/* Pending Questions UI */}
+ {pendingQuestions && pendingQuestions.length > 0 && (
+ <div className="p-3 border-b border-[rgba(117,170,252,0.2)] space-y-3">
+ <div className="text-[#9bc3ff] font-mono text-xs uppercase tracking-wide">
+ Questions from AI
+ </div>
+ {pendingQuestions.map((q) => (
+ <div key={q.id} className="space-y-2">
+ <div className="text-white/90 font-mono text-sm">
+ {q.question}
+ </div>
+ <div className="flex flex-wrap gap-2">
+ {q.options.map((option) => {
+ const isSelected = (userAnswers.get(q.id) || []).includes(
+ option
+ );
+ return (
+ <button
+ key={option}
+ type="button"
+ onClick={() =>
+ handleOptionToggle(q.id, option, q.allowMultiple)
+ }
+ className={`px-2 py-1 font-mono text-xs border transition-colors ${
+ isSelected
+ ? "bg-[#3f6fb3] border-[#75aafc] text-white"
+ : "bg-transparent border-[rgba(117,170,252,0.25)] text-[#9bc3ff] hover:border-[#3f6fb3]"
+ }`}
+ >
+ {q.allowMultiple && (
+ <span className="mr-1">{isSelected ? "+" : "-"}</span>
+ )}
+ {option}
+ </button>
+ );
+ })}
+ </div>
+ {q.allowCustom && (
+ <input
+ type="text"
+ value={customInputs.get(q.id) || ""}
+ onChange={(e) => handleCustomInputChange(q.id, e.target.value)}
+ placeholder="Or type a custom answer..."
+ className="w-full bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-xs px-2 py-1 outline-none focus:border-[#3f6fb3] placeholder-[#555]"
+ />
+ )}
+ </div>
+ ))}
+ <div className="flex gap-2 pt-2">
+ <button
+ type="button"
+ onClick={handleSubmitAnswers}
+ disabled={loading}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {loading ? "..." : "Submit Answers"}
+ </button>
+ <button
+ type="button"
+ onClick={handleCancelQuestions}
+ disabled={loading}
+ className="px-3 py-1 font-mono text-xs text-[#555] hover:text-[#9bc3ff] transition-colors"
+ >
+ Cancel
+ </button>
+ </div>
+ </div>
+ )}
+
+ {/* Input Bar */}
+ <form onSubmit={handleSubmit} className="flex items-center gap-2 p-3">
+ <select
+ value={model}
+ onChange={(e) => handleModelChange(e.target.value as LlmModel)}
+ disabled={loading || !!pendingQuestions}
+ className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs px-2 py-1 rounded-none outline-none focus:border-[#3f6fb3] disabled:opacity-50"
+ >
+ {MODEL_OPTIONS.map((opt) => (
+ <option key={opt.value} value={opt.value}>
+ {opt.label}
+ </option>
+ ))}
+ </select>
+ <span className="text-[#555] font-mono text-[10px]">
+ [{getContextLabel(context)}]
+ </span>
+ <span className="text-[#9bc3ff] font-mono text-sm">&gt;</span>
+ <input
+ ref={inputRef}
+ type="text"
+ value={input}
+ onChange={(e) => setInput(e.target.value)}
+ onKeyDown={handleKeyDown}
+ placeholder={
+ loading
+ ? "Processing..."
+ : pendingQuestions
+ ? "Answer questions above first..."
+ : getPlaceholder(context)
+ }
+ disabled={loading || !!pendingQuestions}
+ className="flex-1 bg-transparent border-none outline-none font-mono text-sm text-white placeholder-[#555]"
+ />
+ {messages.length > 0 && (
+ <button
+ type="button"
+ onClick={handleClearHistory}
+ className="text-[#555] hover:text-[#9bc3ff] font-mono text-xs transition-colors"
+ >
+ clear
+ </button>
+ )}
+ <button
+ type="submit"
+ disabled={loading || !input.trim() || !!pendingQuestions}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] disabled:opacity-50 disabled:cursor-not-allowed transition-colors uppercase"
+ >
+ {loading ? "..." : "Send"}
+ </button>
+ </form>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/contexts/AuthContext.tsx b/makima/frontend/src/contexts/AuthContext.tsx
new file mode 100644
index 0000000..ce2724b
--- /dev/null
+++ b/makima/frontend/src/contexts/AuthContext.tsx
@@ -0,0 +1,160 @@
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useState,
+ useCallback,
+ type ReactNode,
+} from "react";
+import { supabase, isAuthConfigured, type Session, type User } from "../lib/supabase";
+
+interface AuthState {
+ user: User | null;
+ session: Session | null;
+ isLoading: boolean;
+ isAuthenticated: boolean;
+ isAuthConfigured: boolean;
+}
+
+interface AuthContextValue extends AuthState {
+ /** Get the current access token for API calls */
+ getAccessToken: () => string | null;
+ /** Sign in with email and password */
+ signIn: (email: string, password: string) => Promise<{ error: Error | null }>;
+ /** Sign up with email and password */
+ signUp: (email: string, password: string) => Promise<{ error: Error | null }>;
+ /** Sign out */
+ signOut: () => Promise<void>;
+ /** Sign in with OAuth provider */
+ signInWithOAuth: (provider: "github" | "google") => Promise<{ error: Error | null }>;
+}
+
+const AuthContext = createContext<AuthContextValue | null>(null);
+
+export function AuthProvider({ children }: { children: ReactNode }) {
+ const [state, setState] = useState<AuthState>({
+ user: null,
+ session: null,
+ isLoading: true,
+ isAuthenticated: false,
+ isAuthConfigured: isAuthConfigured(),
+ });
+
+ // Initialize auth state
+ useEffect(() => {
+ if (!supabase) {
+ // Auth not configured - allow unauthenticated access
+ setState((prev) => ({
+ ...prev,
+ isLoading: false,
+ isAuthenticated: true, // Allow access when auth is not configured
+ }));
+ return;
+ }
+
+ // Get initial session
+ supabase.auth.getSession().then(({ data: { session } }) => {
+ setState({
+ user: session?.user ?? null,
+ session,
+ isLoading: false,
+ isAuthenticated: !!session,
+ isAuthConfigured: true,
+ });
+ });
+
+ // Listen for auth changes
+ const {
+ data: { subscription },
+ } = supabase.auth.onAuthStateChange((_event, session) => {
+ setState((prev) => ({
+ ...prev,
+ user: session?.user ?? null,
+ session,
+ isAuthenticated: !!session,
+ }));
+ });
+
+ return () => subscription.unsubscribe();
+ }, []);
+
+ const getAccessToken = useCallback((): string | null => {
+ return state.session?.access_token ?? null;
+ }, [state.session]);
+
+ const signIn = useCallback(
+ async (email: string, password: string): Promise<{ error: Error | null }> => {
+ if (!supabase) {
+ return { error: new Error("Auth not configured") };
+ }
+ const { error } = await supabase.auth.signInWithPassword({ email, password });
+ return { error: error ? new Error(error.message) : null };
+ },
+ []
+ );
+
+ const signUp = useCallback(
+ async (email: string, password: string): Promise<{ error: Error | null }> => {
+ if (!supabase) {
+ return { error: new Error("Auth not configured") };
+ }
+ const { error } = await supabase.auth.signUp({ email, password });
+ return { error: error ? new Error(error.message) : null };
+ },
+ []
+ );
+
+ const signOut = useCallback(async () => {
+ // Always clear local state first
+ setState((prev) => ({
+ ...prev,
+ user: null,
+ session: null,
+ isAuthenticated: false,
+ }));
+
+ // Clear Supabase storage directly in case signOut API fails
+ const storageKey = `sb-${import.meta.env.VITE_SUPABASE_URL?.split('//')[1]?.split('.')[0]}-auth-token`;
+ localStorage.removeItem(storageKey);
+
+ // Try to call signOut API (may fail if token is invalid, that's OK)
+ if (supabase) {
+ await supabase.auth.signOut({ scope: 'local' }).catch(() => {});
+ }
+ }, []);
+
+ const signInWithOAuth = useCallback(
+ async (provider: "github" | "google"): Promise<{ error: Error | null }> => {
+ if (!supabase) {
+ return { error: new Error("Auth not configured") };
+ }
+ const { error } = await supabase.auth.signInWithOAuth({
+ provider,
+ options: {
+ redirectTo: window.location.origin,
+ },
+ });
+ return { error: error ? new Error(error.message) : null };
+ },
+ []
+ );
+
+ const value: AuthContextValue = {
+ ...state,
+ getAccessToken,
+ signIn,
+ signUp,
+ signOut,
+ signInWithOAuth,
+ };
+
+ return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+}
+
+export function useAuth(): AuthContextValue {
+ const context = useContext(AuthContext);
+ if (!context) {
+ throw new Error("useAuth must be used within an AuthProvider");
+ }
+ return context;
+}
diff --git a/makima/frontend/src/hooks/useMeshChatHistory.ts b/makima/frontend/src/hooks/useMeshChatHistory.ts
new file mode 100644
index 0000000..82c576d
--- /dev/null
+++ b/makima/frontend/src/hooks/useMeshChatHistory.ts
@@ -0,0 +1,133 @@
+import { useState, useCallback, useEffect } from "react";
+import {
+ getMeshChatHistory,
+ clearMeshChatHistory,
+ chatWithMeshContext,
+ type MeshChatMessageRecord,
+ type MeshChatContext,
+ type MeshChatResponse,
+ type LlmModel,
+} from "../lib/api";
+
+export interface MeshChatState {
+ conversationId: string | null;
+ messages: MeshChatMessageRecord[];
+ loading: boolean;
+ error: string | null;
+ sending: boolean;
+}
+
+export function useMeshChatHistory() {
+ const [state, setState] = useState<MeshChatState>({
+ conversationId: null,
+ messages: [],
+ loading: true,
+ error: null,
+ sending: false,
+ });
+
+ const fetchHistory = useCallback(async () => {
+ setState((prev) => ({ ...prev, loading: true, error: null }));
+ try {
+ const response = await getMeshChatHistory();
+ setState((prev) => ({
+ ...prev,
+ conversationId: response.conversationId,
+ messages: response.messages,
+ loading: false,
+ }));
+ } catch (e) {
+ setState((prev) => ({
+ ...prev,
+ error: e instanceof Error ? e.message : "Failed to fetch chat history",
+ loading: false,
+ }));
+ }
+ }, []);
+
+ const clearHistory = useCallback(async (): Promise<boolean> => {
+ setState((prev) => ({ ...prev, loading: true, error: null }));
+ try {
+ const response = await clearMeshChatHistory();
+ setState({
+ conversationId: response.conversationId,
+ messages: [],
+ loading: false,
+ error: null,
+ sending: false,
+ });
+ return true;
+ } catch (e) {
+ setState((prev) => ({
+ ...prev,
+ error: e instanceof Error ? e.message : "Failed to clear chat history",
+ loading: false,
+ }));
+ return false;
+ }
+ }, []);
+
+ const sendMessage = useCallback(
+ async (
+ message: string,
+ context: MeshChatContext,
+ model?: LlmModel
+ ): Promise<MeshChatResponse | null> => {
+ setState((prev) => ({ ...prev, sending: true, error: null }));
+
+ // Optimistically add user message (will be refetched after response)
+ const tempUserMessage: MeshChatMessageRecord = {
+ id: `temp-${Date.now()}`,
+ conversationId: state.conversationId || "",
+ role: "user",
+ content: message,
+ contextType: context.type,
+ contextTaskId: context.taskId || null,
+ toolCalls: null,
+ pendingQuestions: null,
+ createdAt: new Date().toISOString(),
+ };
+
+ setState((prev) => ({
+ ...prev,
+ messages: [...prev.messages, tempUserMessage],
+ }));
+
+ try {
+ const response = await chatWithMeshContext(message, context, model);
+
+ // Refetch to get the actual saved messages (with proper IDs)
+ await fetchHistory();
+
+ setState((prev) => ({ ...prev, sending: false }));
+ return response;
+ } catch (e) {
+ // Remove optimistic message on error
+ setState((prev) => ({
+ ...prev,
+ messages: prev.messages.filter((m) => m.id !== tempUserMessage.id),
+ error: e instanceof Error ? e.message : "Failed to send message",
+ sending: false,
+ }));
+ return null;
+ }
+ },
+ [state.conversationId, fetchHistory]
+ );
+
+ // Initial fetch on mount
+ useEffect(() => {
+ fetchHistory();
+ }, [fetchHistory]);
+
+ return {
+ conversationId: state.conversationId,
+ messages: state.messages,
+ loading: state.loading,
+ error: state.error,
+ sending: state.sending,
+ fetchHistory,
+ clearHistory,
+ sendMessage,
+ };
+}
diff --git a/makima/frontend/src/hooks/useTaskSubscription.ts b/makima/frontend/src/hooks/useTaskSubscription.ts
new file mode 100644
index 0000000..9316c3a
--- /dev/null
+++ b/makima/frontend/src/hooks/useTaskSubscription.ts
@@ -0,0 +1,333 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+import { TASK_SUBSCRIBE_ENDPOINT } from "../lib/api";
+
+export interface TaskUpdateEvent {
+ taskId: string;
+ version: number;
+ status: string;
+ updatedFields: string[];
+ updatedBy: "user" | "daemon" | "system";
+}
+
+export interface TaskOutputEvent {
+ taskId: string;
+ /** Message type: "assistant", "tool_use", "tool_result", "result", "system", "error", "raw" */
+ messageType: string;
+ /** Main text content */
+ content: string;
+ /** Tool name if tool_use message */
+ toolName?: string;
+ /** Tool input JSON if tool_use message */
+ toolInput?: Record<string, unknown>;
+ /** Whether tool result was an error */
+ isError?: boolean;
+ /** Cost in USD if result message */
+ costUsd?: number;
+ /** Duration in ms if result message */
+ durationMs?: number;
+ isPartial: boolean;
+}
+
+interface UseTaskSubscriptionOptions {
+ taskId: string | null;
+ subscribeAll?: boolean;
+ subscribeOutput?: boolean;
+ /** Task ID to subscribe output for (defaults to taskId if not specified) */
+ outputTaskId?: string;
+ onUpdate?: (event: TaskUpdateEvent) => void;
+ onOutput?: (event: TaskOutputEvent) => void;
+ onError?: (error: string) => void;
+}
+
+export function useTaskSubscription(options: UseTaskSubscriptionOptions) {
+ const {
+ taskId,
+ subscribeAll = false,
+ subscribeOutput = false,
+ outputTaskId,
+ onUpdate,
+ onOutput,
+ onError,
+ } = options;
+
+ // The task ID to use for output subscription (defaults to taskId)
+ const effectiveOutputTaskId = outputTaskId || taskId;
+
+ const [connected, setConnected] = useState(false);
+ const wsRef = useRef<WebSocket | null>(null);
+ const reconnectTimeoutRef = useRef<number | null>(null);
+ const subscribedTaskRef = useRef<string | null>(null);
+ const subscribedAllRef = useRef(false);
+ const subscribedOutputRef = useRef<string | null>(null);
+
+ // Store callbacks in refs to avoid re-connecting when callbacks change
+ const callbacksRef = useRef({ onUpdate, onOutput, onError });
+ useEffect(() => {
+ callbacksRef.current = { onUpdate, onOutput, onError };
+ }, [onUpdate, onOutput, onError]);
+
+ const connect = useCallback(() => {
+ // Prevent multiple connections - check for OPEN or CONNECTING states
+ const currentState = wsRef.current?.readyState;
+ if (currentState === WebSocket.OPEN || currentState === WebSocket.CONNECTING) {
+ return;
+ }
+
+ // Close any existing connection that's in CLOSING state
+ if (wsRef.current && currentState === WebSocket.CLOSING) {
+ wsRef.current = null;
+ }
+
+ try {
+ const ws = new WebSocket(TASK_SUBSCRIBE_ENDPOINT);
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ setConnected(true);
+ // Re-subscribe if we had subscriptions
+ if (subscribedAllRef.current) {
+ ws.send(JSON.stringify({ type: "subscribeAll" }));
+ }
+ if (subscribedTaskRef.current) {
+ ws.send(
+ JSON.stringify({
+ type: "subscribe",
+ taskId: subscribedTaskRef.current,
+ })
+ );
+ }
+ if (subscribedOutputRef.current) {
+ ws.send(
+ JSON.stringify({
+ type: "subscribeOutput",
+ taskId: subscribedOutputRef.current,
+ })
+ );
+ }
+ };
+
+ ws.onmessage = (event) => {
+ try {
+ const message = JSON.parse(event.data);
+
+ switch (message.type) {
+ case "taskUpdated":
+ callbacksRef.current.onUpdate?.({
+ taskId: message.taskId,
+ version: message.version,
+ status: message.status,
+ updatedFields: message.updatedFields,
+ updatedBy: message.updatedBy,
+ });
+ break;
+ case "taskOutput":
+ callbacksRef.current.onOutput?.({
+ taskId: message.taskId,
+ messageType: message.messageType,
+ content: message.content,
+ toolName: message.toolName,
+ toolInput: message.toolInput,
+ isError: message.isError,
+ costUsd: message.costUsd,
+ durationMs: message.durationMs,
+ isPartial: message.isPartial,
+ });
+ break;
+ case "error":
+ callbacksRef.current.onError?.(message.message);
+ break;
+ // Acknowledgement messages - could add callbacks if needed
+ case "subscribed":
+ case "unsubscribed":
+ case "subscribedAll":
+ case "unsubscribedAll":
+ case "outputSubscribed":
+ case "outputUnsubscribed":
+ break;
+ }
+ } catch (e) {
+ console.error("Failed to parse task subscription message:", e);
+ }
+ };
+
+ ws.onerror = () => {
+ callbacksRef.current.onError?.("WebSocket connection error");
+ };
+
+ ws.onclose = () => {
+ setConnected(false);
+ wsRef.current = null;
+
+ // Attempt reconnection after 3 seconds if we still have a subscription
+ if (
+ subscribedTaskRef.current ||
+ subscribedAllRef.current ||
+ subscribedOutputRef.current
+ ) {
+ reconnectTimeoutRef.current = window.setTimeout(() => {
+ connect();
+ }, 3000);
+ }
+ };
+ } catch (e) {
+ callbacksRef.current.onError?.(
+ e instanceof Error ? e.message : "Failed to connect"
+ );
+ }
+ }, []);
+
+ const subscribeToTask = useCallback(
+ (id: string) => {
+ subscribedTaskRef.current = id;
+
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(
+ JSON.stringify({
+ type: "subscribe",
+ taskId: id,
+ })
+ );
+ } else {
+ connect();
+ }
+ },
+ [connect]
+ );
+
+ const unsubscribeFromTask = useCallback(() => {
+ if (
+ subscribedTaskRef.current &&
+ wsRef.current?.readyState === WebSocket.OPEN
+ ) {
+ wsRef.current.send(
+ JSON.stringify({
+ type: "unsubscribe",
+ taskId: subscribedTaskRef.current,
+ })
+ );
+ }
+ subscribedTaskRef.current = null;
+ }, []);
+
+ const subscribeToAll = useCallback(() => {
+ subscribedAllRef.current = true;
+
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(JSON.stringify({ type: "subscribeAll" }));
+ } else {
+ connect();
+ }
+ }, [connect]);
+
+ const unsubscribeFromAll = useCallback(() => {
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(JSON.stringify({ type: "unsubscribeAll" }));
+ }
+ subscribedAllRef.current = false;
+ }, []);
+
+ const subscribeToOutput = useCallback(
+ (id: string) => {
+ // First unsubscribe from any previous output subscription
+ if (subscribedOutputRef.current && subscribedOutputRef.current !== id) {
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(
+ JSON.stringify({
+ type: "unsubscribeOutput",
+ taskId: subscribedOutputRef.current,
+ })
+ );
+ }
+ }
+
+ subscribedOutputRef.current = id;
+
+ if (wsRef.current?.readyState === WebSocket.OPEN) {
+ wsRef.current.send(
+ JSON.stringify({
+ type: "subscribeOutput",
+ taskId: id,
+ })
+ );
+ } else {
+ connect();
+ }
+ },
+ [connect]
+ );
+
+ const unsubscribeFromOutput = useCallback(() => {
+ if (
+ subscribedOutputRef.current &&
+ wsRef.current?.readyState === WebSocket.OPEN
+ ) {
+ wsRef.current.send(
+ JSON.stringify({
+ type: "unsubscribeOutput",
+ taskId: subscribedOutputRef.current,
+ })
+ );
+ }
+ subscribedOutputRef.current = null;
+ }, []);
+
+ // Auto-subscribe based on options
+ useEffect(() => {
+ if (subscribeAll) {
+ subscribeToAll();
+ } else if (taskId) {
+ subscribeToTask(taskId);
+ } else {
+ unsubscribeFromTask();
+ unsubscribeFromAll();
+ }
+
+ return () => {
+ unsubscribeFromTask();
+ unsubscribeFromAll();
+ };
+ }, [
+ taskId,
+ subscribeAll,
+ subscribeToTask,
+ unsubscribeFromTask,
+ subscribeToAll,
+ unsubscribeFromAll,
+ ]);
+
+ // Handle output subscription separately
+ // Uses effectiveOutputTaskId which may be different from taskId when viewing subtask output
+ useEffect(() => {
+ if (subscribeOutput && effectiveOutputTaskId) {
+ subscribeToOutput(effectiveOutputTaskId);
+ } else {
+ unsubscribeFromOutput();
+ }
+
+ return () => {
+ unsubscribeFromOutput();
+ };
+ }, [effectiveOutputTaskId, subscribeOutput, subscribeToOutput, unsubscribeFromOutput]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current);
+ }
+ if (wsRef.current) {
+ wsRef.current.close();
+ }
+ };
+ }, []);
+
+ return {
+ connected,
+ subscribeToTask,
+ unsubscribeFromTask,
+ subscribeToAll,
+ unsubscribeFromAll,
+ subscribeToOutput,
+ unsubscribeFromOutput,
+ };
+}
diff --git a/makima/frontend/src/hooks/useTasks.ts b/makima/frontend/src/hooks/useTasks.ts
new file mode 100644
index 0000000..6e6c992
--- /dev/null
+++ b/makima/frontend/src/hooks/useTasks.ts
@@ -0,0 +1,130 @@
+import { useState, useCallback, useEffect } from "react";
+import {
+ listTasks,
+ getTask,
+ createTask,
+ updateTask,
+ deleteTask,
+ VersionConflictError,
+ type TaskSummary,
+ type TaskWithSubtasks,
+ type CreateTaskRequest,
+ type UpdateTaskRequest,
+} from "../lib/api";
+
+export interface ConflictState {
+ hasConflict: boolean;
+ expectedVersion: number;
+ actualVersion: number;
+}
+
+export function useTasks() {
+ const [tasks, setTasks] = useState<TaskSummary[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [conflict, setConflict] = useState<ConflictState | null>(null);
+
+ const fetchTasks = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await listTasks();
+ setTasks(response.tasks);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to fetch tasks");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const fetchTask = useCallback(
+ async (id: string): Promise<TaskWithSubtasks | null> => {
+ setError(null);
+ try {
+ return await getTask(id);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to fetch task");
+ return null;
+ }
+ },
+ []
+ );
+
+ const saveTask = useCallback(
+ async (data: CreateTaskRequest): Promise<TaskWithSubtasks | null> => {
+ setError(null);
+ try {
+ const task = await createTask(data);
+ await fetchTasks(); // Refresh list
+ // Return as TaskWithSubtasks
+ return { ...task, subtasks: [] };
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to save task");
+ return null;
+ }
+ },
+ [fetchTasks]
+ );
+
+ const editTask = useCallback(
+ async (id: string, data: UpdateTaskRequest): Promise<TaskWithSubtasks | null> => {
+ setError(null);
+ setConflict(null);
+ try {
+ await updateTask(id, data);
+ await fetchTasks(); // Refresh list
+ // Re-fetch to get subtasks
+ return await getTask(id);
+ } catch (e) {
+ if (e instanceof VersionConflictError) {
+ setConflict({
+ hasConflict: true,
+ expectedVersion: e.expectedVersion,
+ actualVersion: e.actualVersion,
+ });
+ return null;
+ }
+ setError(e instanceof Error ? e.message : "Failed to update task");
+ return null;
+ }
+ },
+ [fetchTasks]
+ );
+
+ const clearConflict = useCallback(() => {
+ setConflict(null);
+ }, []);
+
+ const removeTask = useCallback(
+ async (id: string): Promise<boolean> => {
+ setError(null);
+ try {
+ await deleteTask(id);
+ await fetchTasks(); // Refresh list
+ return true;
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to delete task");
+ return false;
+ }
+ },
+ [fetchTasks]
+ );
+
+ // Initial fetch
+ useEffect(() => {
+ fetchTasks();
+ }, [fetchTasks]);
+
+ return {
+ tasks,
+ loading,
+ error,
+ conflict,
+ clearConflict,
+ fetchTasks,
+ fetchTask,
+ saveTask,
+ editTask,
+ removeTask,
+ };
+}
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index 2657a95..a11f15e 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -1,3 +1,5 @@
+import { supabase } from "./supabase";
+
const API_CONFIG = {
local: {
http: "http://localhost:8080",
@@ -33,8 +35,72 @@ const env = detectEnvironment();
export const API_BASE = API_CONFIG[env].http;
export const WS_BASE = API_CONFIG[env].ws;
+
+// =============================================================================
+// Authentication helpers
+// =============================================================================
+
+/** Storage key for API key */
+const API_KEY_STORAGE_KEY = "makima_api_key";
+
+/** Get stored API key from localStorage */
+export function getStoredApiKey(): string | null {
+ if (typeof window === "undefined") return null;
+ return localStorage.getItem(API_KEY_STORAGE_KEY);
+}
+
+/** Store API key in localStorage */
+export function setStoredApiKey(key: string): void {
+ if (typeof window === "undefined") return;
+ localStorage.setItem(API_KEY_STORAGE_KEY, key);
+}
+
+/** Remove stored API key */
+export function clearStoredApiKey(): void {
+ if (typeof window === "undefined") return;
+ localStorage.removeItem(API_KEY_STORAGE_KEY);
+}
+
+/** Get auth headers for API requests */
+async function getAuthHeaders(): Promise<HeadersInit> {
+ const headers: HeadersInit = {
+ "Content-Type": "application/json",
+ };
+
+ // Try Supabase session first
+ if (supabase) {
+ const { data: { session } } = await supabase.auth.getSession();
+ if (session?.access_token) {
+ headers["Authorization"] = `Bearer ${session.access_token}`;
+ return headers;
+ }
+ }
+
+ // Fall back to API key if available
+ const apiKey = getStoredApiKey();
+ if (apiKey) {
+ headers["X-Makima-API-Key"] = apiKey;
+ }
+
+ return headers;
+}
+
+/** Fetch with authentication headers */
+async function authFetch(url: string, options: RequestInit = {}): Promise<Response> {
+ const authHeaders = await getAuthHeaders();
+ const mergedHeaders = {
+ ...authHeaders,
+ ...options.headers,
+ };
+
+ return fetch(url, {
+ ...options,
+ headers: mergedHeaders,
+ });
+}
export const LISTEN_ENDPOINT = `${WS_BASE}/api/v1/listen`;
export const FILE_SUBSCRIBE_ENDPOINT = `${WS_BASE}/api/v1/files/subscribe`;
+export const TASK_SUBSCRIBE_ENDPOINT = `${WS_BASE}/api/v1/mesh/tasks/subscribe`;
export function getEnvironment(): Environment {
return env;
@@ -57,6 +123,8 @@ export type ChartType = "line" | "bar" | "pie" | "area";
export type BodyElement =
| { type: "heading"; level: number; text: string }
| { type: "paragraph"; text: string }
+ | { type: "code"; language?: string; content: string }
+ | { type: "list"; ordered: boolean; items: string[] }
| {
type: "chart";
chartType: ChartType;
@@ -145,6 +213,7 @@ export interface ChatRequest {
message: string;
model?: LlmModel;
history?: ChatMessage[];
+ focusedElementIndex?: number;
}
export interface ToolCallInfo {
@@ -179,7 +248,7 @@ export interface ChatResponse {
// File API functions
export async function listFiles(): Promise<FileListResponse> {
- const res = await fetch(`${API_BASE}/api/v1/files`);
+ const res = await authFetch(`${API_BASE}/api/v1/files`);
if (!res.ok) {
throw new Error(`Failed to list files: ${res.statusText}`);
}
@@ -187,7 +256,7 @@ export async function listFiles(): Promise<FileListResponse> {
}
export async function getFile(id: string): Promise<FileDetail> {
- const res = await fetch(`${API_BASE}/api/v1/files/${id}`);
+ const res = await authFetch(`${API_BASE}/api/v1/files/${id}`);
if (!res.ok) {
throw new Error(`Failed to get file: ${res.statusText}`);
}
@@ -195,9 +264,8 @@ export async function getFile(id: string): Promise<FileDetail> {
}
export async function createFile(data: CreateFileRequest): Promise<FileDetail> {
- const res = await fetch(`${API_BASE}/api/v1/files`, {
+ const res = await authFetch(`${API_BASE}/api/v1/files`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) {
@@ -210,9 +278,8 @@ export async function updateFile(
id: string,
data: UpdateFileRequest
): Promise<FileDetail> {
- const res = await fetch(`${API_BASE}/api/v1/files/${id}`, {
+ const res = await authFetch(`${API_BASE}/api/v1/files/${id}`, {
method: "PUT",
- headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
@@ -228,7 +295,7 @@ export async function updateFile(
}
export async function deleteFile(id: string): Promise<void> {
- const res = await fetch(`${API_BASE}/api/v1/files/${id}`, {
+ const res = await authFetch(`${API_BASE}/api/v1/files/${id}`, {
method: "DELETE",
});
if (!res.ok) {
@@ -241,7 +308,8 @@ export async function chatWithFile(
id: string,
message: string,
model?: LlmModel,
- history?: ChatMessage[]
+ history?: ChatMessage[],
+ focusedElementIndex?: number
): Promise<ChatResponse> {
const body: ChatRequest = { message };
if (model) {
@@ -250,9 +318,11 @@ export async function chatWithFile(
if (history && history.length > 0) {
body.history = history;
}
- const res = await fetch(`${API_BASE}/api/v1/files/${id}/chat`, {
+ if (focusedElementIndex !== undefined) {
+ body.focusedElementIndex = focusedElementIndex;
+ }
+ const res = await authFetch(`${API_BASE}/api/v1/files/${id}/chat`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
@@ -294,7 +364,7 @@ export interface RestoreVersionRequest {
// Version history API functions
export async function listFileVersions(fileId: string): Promise<FileVersionListResponse> {
- const res = await fetch(`${API_BASE}/api/v1/files/${fileId}/versions`);
+ const res = await authFetch(`${API_BASE}/api/v1/files/${fileId}/versions`);
if (!res.ok) {
throw new Error(`Failed to list versions: ${res.statusText}`);
}
@@ -302,7 +372,7 @@ export async function listFileVersions(fileId: string): Promise<FileVersionListR
}
export async function getFileVersion(fileId: string, version: number): Promise<FileVersion> {
- const res = await fetch(`${API_BASE}/api/v1/files/${fileId}/versions/${version}`);
+ const res = await authFetch(`${API_BASE}/api/v1/files/${fileId}/versions/${version}`);
if (!res.ok) {
throw new Error(`Failed to get version: ${res.statusText}`);
}
@@ -314,9 +384,8 @@ export async function restoreFileVersion(
targetVersion: number,
currentVersion: number
): Promise<FileDetail> {
- const res = await fetch(`${API_BASE}/api/v1/files/${fileId}/versions/restore`, {
+ const res = await authFetch(`${API_BASE}/api/v1/files/${fileId}/versions/restore`, {
method: "POST",
- headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetVersion, currentVersion }),
});
@@ -396,3 +465,827 @@ export type LlmVersionToolResult =
| { name: "read_version"; result: ReadVersionToolOutput }
| { name: "list_versions"; result: ListVersionsToolOutput }
| { name: "restore_version"; result: RestoreVersionToolOutput };
+
+// =============================================================================
+// Mesh/Task Types for Claude Code Orchestration
+// =============================================================================
+
+export type TaskStatus =
+ | "pending"
+ | "initializing"
+ | "starting"
+ | "running"
+ | "paused"
+ | "blocked"
+ | "done"
+ | "failed"
+ | "merged";
+
+export type MergeMode = "pr" | "auto" | "manual";
+
+/** Action to perform when a task completes successfully */
+export type CompletionAction = "none" | "branch" | "merge" | "pr";
+
+export type DaemonStatus = "connected" | "disconnected" | "unhealthy";
+
+export interface TaskSummary {
+ id: string;
+ parentTaskId: string | null;
+ depth: number;
+ name: string;
+ status: TaskStatus;
+ priority: number;
+ progressSummary: string | null;
+ subtaskCount: number;
+ version: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface Task {
+ id: string;
+ ownerId: string;
+ parentTaskId: string | null;
+ depth: number;
+ name: string;
+ description: string | null;
+ status: TaskStatus;
+ priority: number;
+ plan: string;
+
+ // Daemon/container info
+ daemonId: string | null;
+ containerId: string | null;
+ overlayPath: string | null;
+
+ // Repository info
+ repositoryUrl: string | null;
+ baseBranch: string | null;
+ targetBranch: string | null;
+
+ // Merge settings
+ mergeMode: MergeMode | null;
+ prUrl: string | null;
+
+ // Completion action settings
+ /** Path to user's local repository for completion actions */
+ targetRepoPath: string | null;
+ /** Action on completion: "none", "branch", "merge", "pr" */
+ completionAction: CompletionAction | null;
+
+ // Progress tracking
+ progressSummary: string | null;
+ lastOutput: string | null;
+ errorMessage: string | null;
+
+ // Timestamps
+ startedAt: string | null;
+ completedAt: string | null;
+ version: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface TaskWithSubtasks extends Task {
+ subtasks: TaskSummary[];
+}
+
+export interface TaskListResponse {
+ tasks: TaskSummary[];
+ total: number;
+}
+
+export interface CreateTaskRequest {
+ name: string;
+ description?: string;
+ plan: string;
+ parentTaskId?: string;
+ priority?: number;
+ repositoryUrl?: string;
+ baseBranch?: string;
+ targetBranch?: string;
+ mergeMode?: MergeMode;
+ /** Path to user's local repository for completion actions */
+ targetRepoPath?: string;
+ /** Action on completion: "none", "branch", "merge", "pr" */
+ completionAction?: CompletionAction;
+}
+
+export interface UpdateTaskRequest {
+ name?: string;
+ description?: string;
+ plan?: string;
+ status?: TaskStatus;
+ priority?: number;
+ progressSummary?: string;
+ lastOutput?: string;
+ errorMessage?: string;
+ mergeMode?: MergeMode;
+ prUrl?: string;
+ /** Path to user's local repository for completion actions */
+ targetRepoPath?: string;
+ /** Action on completion: "none", "branch", "merge", "pr" */
+ completionAction?: CompletionAction;
+ version?: number;
+}
+
+export interface TaskEvent {
+ id: string;
+ taskId: string;
+ eventType: string;
+ previousStatus: string | null;
+ newStatus: string | null;
+ eventData: Record<string, unknown> | null;
+ createdAt: string;
+}
+
+export interface TaskEventListResponse {
+ events: TaskEvent[];
+ total: number;
+}
+
+export interface Daemon {
+ id: string;
+ ownerId: string;
+ connectionId: string;
+ hostname: string | null;
+ machineId: string | null;
+ maxConcurrentTasks: number;
+ currentTaskCount: number;
+ status: DaemonStatus;
+ lastHeartbeatAt: string;
+ connectedAt: string;
+ disconnectedAt: string | null;
+}
+
+export interface DaemonListResponse {
+ daemons: Daemon[];
+ total: number;
+}
+
+// Mesh API functions
+export async function listTasks(): Promise<TaskListResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks`);
+ if (!res.ok) {
+ throw new Error(`Failed to list tasks: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function getTask(id: string): Promise<TaskWithSubtasks> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${id}`);
+ if (!res.ok) {
+ throw new Error(`Failed to get task: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function createTask(data: CreateTaskRequest): Promise<Task> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks`, {
+ method: "POST",
+ body: JSON.stringify(data),
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to create task: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function updateTask(
+ id: string,
+ data: UpdateTaskRequest
+): Promise<Task> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${id}`, {
+ method: "PUT",
+ body: JSON.stringify(data),
+ });
+
+ if (res.status === 409) {
+ const conflict = (await res.json()) as ConflictErrorResponse;
+ throw new VersionConflictError(conflict);
+ }
+
+ if (!res.ok) {
+ throw new Error(`Failed to update task: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function deleteTask(id: string): Promise<void> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${id}`, {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to delete task: ${res.statusText}`);
+ }
+}
+
+export async function startTask(id: string): Promise<Task> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${id}/start`, {
+ method: "POST",
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to start task: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function stopTask(id: string): Promise<Task> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${id}/stop`, {
+ method: "POST",
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to stop task: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+export interface SendMessageResponse {
+ success: boolean;
+ taskId: string;
+ messageLength: number;
+}
+
+/**
+ * Send a message to a running task's stdin.
+ * This can be used to provide input to Claude Code when it's waiting for user input,
+ * or to inject context/instructions into a running task.
+ */
+export async function sendTaskMessage(
+ taskId: string,
+ message: string
+): Promise<SendMessageResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/message`, {
+ method: "POST",
+ body: JSON.stringify({ message }),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to send message: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+export interface RetryCompletionResponse {
+ success: boolean;
+ taskId: string;
+ action: string;
+ targetRepoPath: string;
+ message: string;
+}
+
+/**
+ * Retry completion action for a completed task.
+ * This allows retrying a completion action (push branch, merge, create PR)
+ * after filling in the target_repo_path if it wasn't set when the task completed.
+ */
+export async function retryCompletionAction(
+ taskId: string
+): Promise<RetryCompletionResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/retry-completion`, {
+ method: "POST",
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to retry completion action: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+/** A suggested directory from a connected daemon */
+export interface DaemonDirectory {
+ /** Path to the directory */
+ path: string;
+ /** Display label for the directory */
+ label: string;
+ /** Type of directory: "working", "makima", "worktrees" */
+ directoryType: string;
+ /** Daemon hostname this directory is from */
+ hostname: string | null;
+ /** Whether the directory already exists (for validation) */
+ exists?: boolean;
+}
+
+export interface DaemonDirectoriesResponse {
+ directories: DaemonDirectory[];
+}
+
+/**
+ * Get suggested directories from connected daemons.
+ * These can be used as target_repo_path suggestions for completion actions.
+ */
+export async function getDaemonDirectories(): Promise<DaemonDirectoriesResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/daemons/directories`);
+ if (!res.ok) {
+ throw new Error(`Failed to get daemon directories: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/** Request to clone a worktree */
+export interface CloneWorktreeRequest {
+ targetDir: string;
+}
+
+/** Response from clone worktree */
+export interface CloneWorktreeResponse {
+ status: string;
+ taskId: string;
+ targetDir: string;
+}
+
+/**
+ * Clone a task's worktree to a target directory.
+ */
+export async function cloneWorktree(
+ taskId: string,
+ targetDir: string
+): Promise<CloneWorktreeResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/clone`, {
+ method: "POST",
+ body: JSON.stringify({ targetDir }),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to clone worktree: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+/** Request to check if target exists */
+export interface CheckTargetExistsRequest {
+ targetDir: string;
+}
+
+/** Response from check target exists */
+export interface CheckTargetExistsResponse {
+ status: string;
+ taskId: string;
+ targetDir: string;
+}
+
+/**
+ * Check if a target directory exists.
+ */
+export async function checkTargetExists(
+ taskId: string,
+ targetDir: string
+): Promise<CheckTargetExistsResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/check-target`, {
+ method: "POST",
+ body: JSON.stringify({ targetDir }),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to check target: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function listSubtasks(taskId: string): Promise<TaskListResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/subtasks`);
+ if (!res.ok) {
+ throw new Error(`Failed to list subtasks: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function listTaskEvents(
+ taskId: string
+): Promise<TaskEventListResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/events`);
+ if (!res.ok) {
+ throw new Error(`Failed to list task events: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/** A single output entry from a Claude Code task */
+export interface TaskOutputEntry {
+ id: string;
+ taskId: string;
+ /** Message type: "assistant", "tool_use", "tool_result", "result", "system", "error", "raw" */
+ messageType: string;
+ /** Main text content */
+ content: string;
+ /** Tool name if tool_use message */
+ toolName?: string;
+ /** Tool input JSON if tool_use message */
+ toolInput?: Record<string, unknown>;
+ /** Whether tool result was an error */
+ isError?: boolean;
+ /** Cost in USD if result message */
+ costUsd?: number;
+ /** Duration in ms if result message */
+ durationMs?: number;
+ /** Timestamp when this output was recorded */
+ createdAt: string;
+}
+
+/** Response from the task output endpoint */
+export interface TaskOutputResponse {
+ entries: TaskOutputEntry[];
+ total: number;
+ taskId: string;
+}
+
+/**
+ * Get task output history.
+ * Retrieves all recorded output from a task's Claude Code process.
+ * Use this to fetch missed output when subscribing late or reconnecting.
+ */
+export async function getTaskOutput(
+ taskId: string
+): Promise<TaskOutputResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/output`);
+ if (!res.ok) {
+ throw new Error(`Failed to get task output: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function listDaemons(): Promise<DaemonListResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/daemons`);
+ if (!res.ok) {
+ throw new Error(`Failed to list daemons: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function getDaemon(id: string): Promise<Daemon> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/daemons/${id}`);
+ if (!res.ok) {
+ throw new Error(`Failed to get daemon: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+// =============================================================================
+// Mesh Chat Types for Task Orchestration
+// =============================================================================
+
+export interface MeshChatMessage {
+ role: "user" | "assistant";
+ content: string;
+}
+
+export interface MeshChatRequest {
+ message: string;
+ model?: LlmModel;
+ history?: MeshChatMessage[];
+}
+
+export interface MeshToolCallInfo {
+ name: string;
+ result: {
+ success: boolean;
+ message: string;
+ };
+}
+
+export interface MeshChatResponse {
+ response: string;
+ toolCalls: MeshToolCallInfo[];
+ pendingQuestions?: UserQuestion[];
+}
+
+// Mesh Chat API functions
+
+// Top-level mesh chat (no specific task context)
+export async function chatWithMesh(
+ message: string,
+ model?: LlmModel,
+ history?: MeshChatMessage[]
+): Promise<MeshChatResponse> {
+ const body: MeshChatRequest = { message };
+ if (model) {
+ body.model = model;
+ }
+ if (history && history.length > 0) {
+ body.history = history;
+ }
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/chat`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Mesh chat failed: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+// Task-scoped mesh chat
+export async function chatWithTask(
+ taskId: string,
+ message: string,
+ model?: LlmModel,
+ history?: MeshChatMessage[]
+): Promise<MeshChatResponse> {
+ const body: MeshChatRequest = { message };
+ if (model) {
+ body.model = model;
+ }
+ if (history && history.length > 0) {
+ body.history = history;
+ }
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/tasks/${taskId}/chat`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Mesh chat failed: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+// =============================================================================
+// Mesh Chat History Types
+// =============================================================================
+
+export type MeshChatContextType = "mesh" | "task" | "subtask";
+
+export interface MeshChatContext {
+ type: MeshChatContextType;
+ taskId?: string;
+ parentTaskId?: string;
+}
+
+export interface MeshChatMessageRecord {
+ id: string;
+ conversationId: string;
+ role: "user" | "assistant" | "error";
+ content: string;
+ contextType: MeshChatContextType;
+ contextTaskId: string | null;
+ toolCalls: MeshToolCallInfo[] | null;
+ pendingQuestions: UserQuestion[] | null;
+ createdAt: string;
+}
+
+export interface MeshChatHistoryResponse {
+ conversationId: string;
+ messages: MeshChatMessageRecord[];
+}
+
+export interface MeshChatWithContextRequest {
+ message: string;
+ model?: LlmModel;
+ contextType?: MeshChatContextType;
+ contextTaskId?: string;
+}
+
+// =============================================================================
+// Mesh Chat History API Functions
+// =============================================================================
+
+/**
+ * Get the current chat history from the database
+ */
+export async function getMeshChatHistory(): Promise<MeshChatHistoryResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/chat/history`);
+ if (!res.ok) {
+ throw new Error(`Failed to get chat history: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Clear chat history (archives current conversation, starts new one)
+ */
+export async function clearMeshChatHistory(): Promise<{ success: boolean; conversationId: string }> {
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/chat/history`, {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to clear chat history: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Chat with mesh using context (new approach with DB history)
+ */
+export async function chatWithMeshContext(
+ message: string,
+ context: MeshChatContext,
+ model?: LlmModel
+): Promise<MeshChatResponse> {
+ const body: MeshChatWithContextRequest = {
+ message,
+ contextType: context.type,
+ };
+
+ if (model) {
+ body.model = model;
+ }
+
+ // Set contextTaskId based on context type
+ if (context.type === "task" && context.taskId) {
+ body.contextTaskId = context.taskId;
+ } else if (context.type === "subtask" && context.taskId) {
+ body.contextTaskId = context.taskId;
+ }
+
+ // Use top-level endpoint (it now loads history from DB)
+ const res = await authFetch(`${API_BASE}/api/v1/mesh/chat`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Mesh chat failed: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+// =============================================================================
+// API Key Management
+// =============================================================================
+
+export interface ApiKeyInfo {
+ id: string;
+ prefix: string;
+ name: string | null;
+ lastUsedAt: string | null;
+ createdAt: string;
+}
+
+export interface CreateApiKeyResponse {
+ id: string;
+ key: string;
+ prefix: string;
+ name: string | null;
+ createdAt: string;
+}
+
+export interface RefreshApiKeyResponse {
+ id: string;
+ key: string;
+ prefix: string;
+ name: string | null;
+ createdAt: string;
+ previousKeyRevoked: boolean;
+}
+
+export interface RevokeApiKeyResponse {
+ message: string;
+ revokedKeyPrefix: string;
+}
+
+/**
+ * Get information about the current active API key.
+ */
+export async function getApiKey(): Promise<ApiKeyInfo | null> {
+ const res = await authFetch(`${API_BASE}/api/v1/auth/api-keys`);
+ if (res.status === 404) {
+ return null;
+ }
+ if (!res.ok) {
+ throw new Error(`Failed to get API key: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Create a new API key.
+ */
+export async function createApiKey(name?: string): Promise<CreateApiKeyResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/auth/api-keys`, {
+ method: "POST",
+ body: JSON.stringify({ name }),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to create API key: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Refresh (rotate) the current API key.
+ */
+export async function refreshApiKey(name?: string): Promise<RefreshApiKeyResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/auth/api-keys/refresh`, {
+ method: "POST",
+ body: JSON.stringify({ name }),
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to refresh API key: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+/**
+ * Revoke the current API key.
+ */
+export async function revokeApiKey(): Promise<RevokeApiKeyResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/auth/api-keys`, {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ const errorText = await res.text();
+ throw new Error(`Failed to revoke API key: ${errorText || res.statusText}`);
+ }
+ return res.json();
+}
+
+// =============================================================================
+// User Account Management
+// =============================================================================
+
+export interface ChangePasswordRequest {
+ currentPassword: string;
+ newPassword: string;
+}
+
+export interface ChangePasswordResponse {
+ success: boolean;
+ message: string;
+}
+
+export interface ChangeEmailRequest {
+ password: string;
+ newEmail: string;
+}
+
+export interface ChangeEmailResponse {
+ success: boolean;
+ message: string;
+ verificationSent: boolean;
+}
+
+export interface DeleteAccountRequest {
+ password: string;
+ confirmation: string;
+}
+
+export interface DeleteAccountResponse {
+ success: boolean;
+ message: string;
+}
+
+/**
+ * Change the current user's password.
+ * Requires current password verification.
+ */
+export async function changePassword(
+ currentPassword: string,
+ newPassword: string
+): Promise<ChangePasswordResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/users/me/password`, {
+ method: "PUT",
+ body: JSON.stringify({ currentPassword, newPassword }),
+ });
+ if (!res.ok) {
+ const errorData = await res.json().catch(() => null);
+ const errorMessage = errorData?.message || res.statusText;
+ throw new Error(errorMessage);
+ }
+ return res.json();
+}
+
+/**
+ * Change the current user's email address.
+ * Requires password verification.
+ */
+export async function changeEmail(
+ password: string,
+ newEmail: string
+): Promise<ChangeEmailResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/users/me/email`, {
+ method: "PUT",
+ body: JSON.stringify({ password, newEmail }),
+ });
+ if (!res.ok) {
+ const errorData = await res.json().catch(() => null);
+ const errorMessage = errorData?.message || res.statusText;
+ throw new Error(errorMessage);
+ }
+ return res.json();
+}
+
+/**
+ * Delete the current user's account.
+ * Requires password verification and email confirmation.
+ */
+export async function deleteAccount(
+ password: string,
+ confirmation: string
+): Promise<DeleteAccountResponse> {
+ const res = await authFetch(`${API_BASE}/api/v1/users/me`, {
+ method: "DELETE",
+ body: JSON.stringify({ password, confirmation }),
+ });
+ if (!res.ok) {
+ const errorData = await res.json().catch(() => null);
+ const errorMessage = errorData?.message || res.statusText;
+ throw new Error(errorMessage);
+ }
+ return res.json();
+}
diff --git a/makima/frontend/src/lib/supabase.ts b/makima/frontend/src/lib/supabase.ts
new file mode 100644
index 0000000..eedff10
--- /dev/null
+++ b/makima/frontend/src/lib/supabase.ts
@@ -0,0 +1,26 @@
+import { createClient, SupabaseClient, Session, User } from "@supabase/supabase-js";
+
+// Supabase configuration from environment variables
+const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL as string | undefined;
+const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY as string | undefined;
+
+// Only create client if configuration is available
+let supabaseClient: SupabaseClient | null = null;
+
+if (SUPABASE_URL && SUPABASE_ANON_KEY) {
+ supabaseClient = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
+ auth: {
+ autoRefreshToken: true,
+ persistSession: true,
+ detectSessionInUrl: true,
+ },
+ });
+}
+
+export const supabase = supabaseClient;
+
+export function isAuthConfigured(): boolean {
+ return supabaseClient !== null;
+}
+
+export type { Session, User };
diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx
index 874ab1a..d4ca13a 100644
--- a/makima/frontend/src/main.tsx
+++ b/makima/frontend/src/main.tsx
@@ -2,21 +2,74 @@ import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { BrowserRouter, Routes, Route } from "react-router";
import "./index.css";
+import { AuthProvider } from "./contexts/AuthContext";
import { GridOverlay } from "./components/GridOverlay";
+import { ProtectedRoute } from "./components/ProtectedRoute";
import HomePage from "./routes/_index";
import ListenPage from "./routes/listen";
import FilesPage from "./routes/files";
+import MeshPage from "./routes/mesh";
+import LoginPage from "./routes/login";
+import SettingsPage from "./routes/settings";
createRoot(document.getElementById("root")!).render(
<StrictMode>
- <BrowserRouter>
- <GridOverlay />
- <Routes>
- <Route path="/" element={<HomePage />} />
- <Route path="/listen" element={<ListenPage />} />
- <Route path="/files" element={<FilesPage />} />
- <Route path="/files/:id" element={<FilesPage />} />
- </Routes>
- </BrowserRouter>
+ <AuthProvider>
+ <BrowserRouter>
+ <GridOverlay />
+ <Routes>
+ <Route path="/" element={<HomePage />} />
+ <Route path="/login" element={<LoginPage />} />
+ <Route
+ path="/listen"
+ element={
+ <ProtectedRoute>
+ <ListenPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
+ path="/files"
+ element={
+ <ProtectedRoute>
+ <FilesPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
+ path="/files/:id"
+ element={
+ <ProtectedRoute>
+ <FilesPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
+ path="/mesh"
+ element={
+ <ProtectedRoute>
+ <MeshPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
+ path="/mesh/:id"
+ element={
+ <ProtectedRoute>
+ <MeshPage />
+ </ProtectedRoute>
+ }
+ />
+ <Route
+ path="/settings"
+ element={
+ <ProtectedRoute>
+ <SettingsPage />
+ </ProtectedRoute>
+ }
+ />
+ </Routes>
+ </BrowserRouter>
+ </AuthProvider>
</StrictMode>
);
diff --git a/makima/frontend/src/routes/_index.tsx b/makima/frontend/src/routes/_index.tsx
index 4c3c2c0..7084c2e 100644
--- a/makima/frontend/src/routes/_index.tsx
+++ b/makima/frontend/src/routes/_index.tsx
@@ -13,18 +13,18 @@ export default function HomePage() {
</div>
<span className="inline-block px-2 py-1 border border-[#3f6fb3] bg-[#0f1c2f] text-[#9bc3ff] font-mono text-xs tracking-wide uppercase mb-3">
- Listening System
+ Control System
</span>
<h2 className="m-0 mb-3 text-xl text-[#f0f5ff] tracking-wide">
- Mesh Listening Lattice
+ Mesh Orchestration Platform
</h2>
<p className="my-2 text-[#e4edff]">
- Makima is a mesh listening lattice for contested domains, delivering
- live audio surveillance, detection, and analysis in one persistent layer.
+ Makima is a control system for orchestrating distributed daemon meshes,
+ coordinating concurrent execution across distinct domains.
</p>
<p className="my-2 text-[#e4edff]">
- Dynamic telemetry for detection, orchestration, and mission-critical
- decisions. Real-time transcription with speaker diarization.
+ Unified command interface for spawning, monitoring, and directing
+ worker daemons. Real-time task coordination with overlay management.
</p>
</section>
</main>
diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx
index 0d870f7..0645b85 100644
--- a/makima/frontend/src/routes/files.tsx
+++ b/makima/frontend/src/routes/files.tsx
@@ -2,7 +2,7 @@ import { useState, useCallback, useEffect, useRef } from "react";
import { useParams, useNavigate } from "react-router";
import { Masthead } from "../components/Masthead";
import { FileList } from "../components/files/FileList";
-import { FileDetail } from "../components/files/FileDetail";
+import { FileDetail, type FocusedElement } from "../components/files/FileDetail";
import { CliInput } from "../components/files/CliInput";
import { ConflictNotification } from "../components/files/ConflictNotification";
import { UpdateNotification } from "../components/files/UpdateNotification";
@@ -12,7 +12,8 @@ import {
useFileSubscription,
type FileUpdateEvent,
} from "../hooks/useFileSubscription";
-import type { FileDetail as FileDetailType, BodyElement } from "../lib/api";
+import type { FileDetail as FileDetailType, BodyElement, Task } from "../lib/api";
+import { createTask } from "../lib/api";
export default function FilesPage() {
const { id } = useParams<{ id: string }>();
@@ -23,6 +24,9 @@ export default function FilesPage() {
const [creating, setCreating] = useState(false);
const [remoteUpdate, setRemoteUpdate] = useState<FileUpdateEvent | null>(null);
const [remoteFileData, setRemoteFileData] = useState<FileDetailType | null>(null);
+ const [focusedElement, setFocusedElement] = useState<FocusedElement | null>(null);
+ const [suggestedPrompt, setSuggestedPrompt] = useState<string | null>(null);
+ const [createdTask, setCreatedTask] = useState<Task | null>(null);
const pendingUpdateRef = useRef(false);
// Track the last version we sent to detect our own updates
const lastSentVersionRef = useRef<number | null>(null);
@@ -85,6 +89,7 @@ export default function FilesPage() {
currentVersionRef.current = null;
setRemoteUpdate(null);
setRemoteFileData(null);
+ setFocusedElement(null);
fetchFile(id).then((detail) => {
if (detail) {
currentVersionRef.current = detail.version;
@@ -285,6 +290,276 @@ export default function FilesPage() {
[fileDetail, id, editFile, updateHasLocalChanges]
);
+ // Element action handlers for context menu
+ const handleBodyElementDelete = useCallback(
+ async (index: number) => {
+ if (fileDetail && id) {
+ const newBody = fileDetail.body.filter((_, i) => i !== index);
+
+ // Update local state immediately
+ setFileDetail({
+ ...fileDetail,
+ body: newBody,
+ });
+ updateHasLocalChanges(true);
+
+ // Clear focus if deleting focused element
+ if (focusedElement?.index === index) {
+ setFocusedElement(null);
+ } else if (focusedElement && focusedElement.index > index) {
+ // Adjust focus index if deleting an element before it
+ setFocusedElement({
+ ...focusedElement,
+ index: focusedElement.index - 1,
+ });
+ }
+
+ // Save to backend
+ pendingUpdateRef.current = true;
+ lastSentVersionRef.current = fileDetail.version;
+ try {
+ const result = await editFile(id, { body: newBody, version: fileDetail.version });
+ if (result) {
+ lastSavedVersionRef.current = result.version;
+ currentVersionRef.current = result.version;
+ setFileDetail(result);
+ updateHasLocalChanges(false);
+ }
+ } finally {
+ pendingUpdateRef.current = false;
+ lastSentVersionRef.current = null;
+ }
+ }
+ },
+ [fileDetail, id, editFile, updateHasLocalChanges, focusedElement]
+ );
+
+ const handleBodyElementDuplicate = useCallback(
+ async (index: number) => {
+ if (fileDetail && id) {
+ const elementToDuplicate = fileDetail.body[index];
+ if (!elementToDuplicate) return;
+
+ const newBody = [...fileDetail.body];
+ // Insert duplicate after the original
+ newBody.splice(index + 1, 0, { ...elementToDuplicate });
+
+ // Update local state immediately
+ setFileDetail({
+ ...fileDetail,
+ body: newBody,
+ });
+ updateHasLocalChanges(true);
+
+ // Adjust focus index if duplicating before focused element
+ if (focusedElement && focusedElement.index > index) {
+ setFocusedElement({
+ ...focusedElement,
+ index: focusedElement.index + 1,
+ });
+ }
+
+ // Save to backend
+ pendingUpdateRef.current = true;
+ lastSentVersionRef.current = fileDetail.version;
+ try {
+ const result = await editFile(id, { body: newBody, version: fileDetail.version });
+ if (result) {
+ lastSavedVersionRef.current = result.version;
+ currentVersionRef.current = result.version;
+ setFileDetail(result);
+ updateHasLocalChanges(false);
+ }
+ } finally {
+ pendingUpdateRef.current = false;
+ lastSentVersionRef.current = null;
+ }
+ }
+ },
+ [fileDetail, id, editFile, updateHasLocalChanges, focusedElement]
+ );
+
+ const handleFocusElement = useCallback((element: FocusedElement | null) => {
+ setFocusedElement(element);
+ }, []);
+
+ const handleClearFocus = useCallback(() => {
+ setFocusedElement(null);
+ }, []);
+
+ // Convert element to a different type
+ const handleConvertElement = useCallback(
+ async (index: number, toType: string) => {
+ if (!fileDetail || !id) return;
+
+ const element = fileDetail.body[index];
+ if (!element) return;
+
+ // Extract text content from current element
+ let textContent = "";
+ switch (element.type) {
+ case "heading":
+ case "paragraph":
+ textContent = element.text;
+ break;
+ case "code":
+ textContent = element.content;
+ break;
+ case "list":
+ textContent = element.items.join("\n");
+ break;
+ default:
+ return; // Can't convert charts/images
+ }
+
+ // Create new element based on target type
+ let newElement: BodyElement;
+ if (toType === "paragraph") {
+ newElement = { type: "paragraph", text: textContent };
+ } else if (toType === "list_unordered") {
+ const items = textContent.split("\n").filter(line => line.trim());
+ newElement = { type: "list", ordered: false, items };
+ } else if (toType === "list_ordered") {
+ const items = textContent.split("\n").filter(line => line.trim());
+ newElement = { type: "list", ordered: true, items };
+ } else if (toType === "code") {
+ newElement = { type: "code", content: textContent };
+ } else if (toType.startsWith("heading_")) {
+ const level = parseInt(toType.replace("heading_", ""), 10) as 1 | 2 | 3 | 4 | 5 | 6;
+ newElement = { type: "heading", level, text: textContent };
+ } else {
+ return; // Unknown type
+ }
+
+ const newBody = [...fileDetail.body];
+ newBody[index] = newElement;
+
+ // Update local state
+ setFileDetail({ ...fileDetail, body: newBody });
+ updateHasLocalChanges(true);
+
+ // Update focus if this element was focused
+ if (focusedElement?.index === index) {
+ setFocusedElement({
+ index,
+ type: newElement.type,
+ preview: textContent.slice(0, 50) + (textContent.length > 50 ? "..." : ""),
+ });
+ }
+
+ // Save to backend
+ pendingUpdateRef.current = true;
+ lastSentVersionRef.current = fileDetail.version;
+ try {
+ const result = await editFile(id, { body: newBody, version: fileDetail.version });
+ if (result) {
+ lastSavedVersionRef.current = result.version;
+ currentVersionRef.current = result.version;
+ setFileDetail(result);
+ updateHasLocalChanges(false);
+ }
+ } finally {
+ pendingUpdateRef.current = false;
+ lastSentVersionRef.current = null;
+ }
+ },
+ [fileDetail, id, editFile, updateHasLocalChanges, focusedElement]
+ );
+
+ // Generate from element - focus on it and pre-fill a prompt
+ const handleGenerateFromElement = useCallback(
+ (index: number, action: string) => {
+ if (!fileDetail) return;
+
+ const element = fileDetail.body[index];
+ if (!element) return;
+
+ // Get preview text
+ let preview = "";
+ switch (element.type) {
+ case "heading":
+ case "paragraph":
+ preview = element.text.slice(0, 50);
+ break;
+ case "code":
+ preview = element.content.slice(0, 50);
+ break;
+ case "list":
+ preview = element.items[0]?.slice(0, 40) || "";
+ break;
+ default:
+ preview = "Element";
+ }
+
+ // Focus on the element
+ setFocusedElement({
+ index,
+ type: element.type,
+ preview: preview + (preview.length >= 50 ? "..." : ""),
+ });
+
+ // Set suggested prompt based on action
+ let prompt = "";
+ switch (action) {
+ case "elaborate":
+ prompt = "Elaborate and expand on this content";
+ break;
+ case "summarize":
+ prompt = "Summarize this content";
+ break;
+ case "extract_actions":
+ prompt = "Extract action items from this content";
+ break;
+ }
+ setSuggestedPrompt(prompt);
+ },
+ [fileDetail]
+ );
+
+ // Create a mesh task from an element
+ const handleCreateTaskFromElement = useCallback(
+ async (index: number, selectedText?: string) => {
+ if (!fileDetail) return;
+
+ const element = fileDetail.body[index];
+ if (!element) return;
+
+ // Get the content to use as task plan
+ let content = selectedText || "";
+ if (!content) {
+ switch (element.type) {
+ case "heading":
+ case "paragraph":
+ content = element.text;
+ break;
+ case "code":
+ content = element.content;
+ break;
+ case "list":
+ content = element.items.join("\n");
+ break;
+ default:
+ content = "Task from file element";
+ }
+ }
+
+ // Create a task name from the content
+ const name = content.slice(0, 60) + (content.length > 60 ? "..." : "");
+
+ try {
+ const task = await createTask({
+ name,
+ plan: content,
+ description: `Created from ${fileDetail.name}`,
+ });
+ setCreatedTask(task);
+ } catch (err) {
+ console.error("Failed to create task:", err);
+ }
+ },
+ [fileDetail]
+ );
+
const handleCreate = useCallback(async () => {
if (creating) return;
setCreating(true);
@@ -301,6 +576,28 @@ export default function FilesPage() {
}
}, [creating, saveFile, navigate]);
+ const handleUploadMarkdown = useCallback(async (name: string, body: BodyElement[]) => {
+ if (creating) return;
+ setCreating(true);
+ try {
+ const newFile = await saveFile({
+ name,
+ transcript: [],
+ });
+ if (newFile) {
+ // Update with the parsed body
+ const updated = await editFile(newFile.id, { body, version: newFile.version });
+ if (updated) {
+ navigate(`/files/${updated.id}`);
+ } else {
+ navigate(`/files/${newFile.id}`);
+ }
+ }
+ } finally {
+ setCreating(false);
+ }
+ }, [creating, saveFile, editFile, navigate]);
+
// Conflict resolution handlers
const handleConflictReload = useCallback(async () => {
if (id) {
@@ -381,9 +678,16 @@ export default function FilesPage() {
onDelete={handleDelete}
onBodyElementUpdate={handleBodyElementUpdate}
onBodyReorder={handleBodyReorder}
+ onBodyElementDelete={handleBodyElementDelete}
+ onBodyElementDuplicate={handleBodyElementDuplicate}
+ onConvertElement={handleConvertElement}
+ onGenerateFromElement={handleGenerateFromElement}
+ onCreateTaskFromElement={handleCreateTaskFromElement}
onEditingChange={updateIsActivelyEditing}
hasPendingRemoteUpdate={!!remoteUpdate}
onOverwrite={handleRemoteUpdateDismiss}
+ focusedElement={focusedElement}
+ onFocusElement={handleFocusElement}
versions={versions}
versionsLoading={versionsLoading}
selectedVersion={selectedVersion}
@@ -395,7 +699,14 @@ export default function FilesPage() {
/>
</div>
<div className="shrink-0">
- <CliInput fileId={id} onUpdate={handleBodyUpdate} />
+ <CliInput
+ fileId={id}
+ onUpdate={handleBodyUpdate}
+ focusedElement={focusedElement}
+ onClearFocus={handleClearFocus}
+ suggestedPrompt={suggestedPrompt}
+ onClearSuggestedPrompt={() => setSuggestedPrompt(null)}
+ />
</div>
</div>
) : id && detailLoading ? (
@@ -409,6 +720,7 @@ export default function FilesPage() {
onSelect={handleSelectFile}
onDelete={handleDelete}
onCreate={handleCreate}
+ onUploadMarkdown={handleUploadMarkdown}
/>
)}
</main>
@@ -432,6 +744,38 @@ export default function FilesPage() {
onDismiss={handleRemoteUpdateDismiss}
/>
)}
+
+ {/* Task created notification */}
+ {createdTask && (
+ <div className="fixed bottom-4 right-4 z-50 bg-[#0a1628] border border-[rgba(117,170,252,0.3)] p-4 shadow-lg max-w-sm">
+ <div className="flex items-start gap-3">
+ <span className="text-[#75aafc] text-lg">@</span>
+ <div className="flex-1">
+ <p className="font-mono text-xs text-[#9bc3ff] mb-1">Task created</p>
+ <p className="font-mono text-sm text-white truncate mb-3">
+ {createdTask.name}
+ </p>
+ <div className="flex gap-2">
+ <button
+ onClick={() => {
+ navigate(`/mesh/${createdTask.id}`);
+ setCreatedTask(null);
+ }}
+ className="px-3 py-1 font-mono text-xs text-[#0a1628] bg-[#75aafc] hover:bg-[#9bc3ff] transition-colors"
+ >
+ Go to task
+ </button>
+ <button
+ onClick={() => setCreatedTask(null)}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.3)] hover:border-[#75aafc] transition-colors"
+ >
+ Dismiss
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+ )}
</div>
);
}
diff --git a/makima/frontend/src/routes/login.tsx b/makima/frontend/src/routes/login.tsx
new file mode 100644
index 0000000..63b3af3
--- /dev/null
+++ b/makima/frontend/src/routes/login.tsx
@@ -0,0 +1,150 @@
+import { useState, type FormEvent } from "react";
+import { useNavigate } from "react-router";
+import { useAuth } from "../contexts/AuthContext";
+import { Masthead } from "../components/Masthead";
+
+type AuthMode = "signin" | "signup";
+
+export default function LoginPage() {
+ const navigate = useNavigate();
+ const { signIn, signUp, isAuthConfigured, isAuthenticated } = useAuth();
+
+ const [mode, setMode] = useState<AuthMode>("signin");
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState<string | null>(null);
+ const [loading, setLoading] = useState(false);
+ const [message, setMessage] = useState<string | null>(null);
+
+ // Redirect if already authenticated
+ if (isAuthenticated && isAuthConfigured) {
+ navigate("/mesh");
+ return null;
+ }
+
+ const handleEmailAuth = async (e: FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ setMessage(null);
+ setLoading(true);
+
+ try {
+ if (mode === "signin") {
+ const { error } = await signIn(email, password);
+ if (error) {
+ setError(error.message);
+ } else {
+ navigate("/mesh");
+ }
+ } else if (mode === "signup") {
+ const { error } = await signUp(email, password);
+ if (error) {
+ setError(error.message);
+ } else {
+ setMessage("Check your email for a confirmation link.");
+ }
+ }
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ // If auth is not configured, show a message
+ if (!isAuthConfigured) {
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col">
+ <Masthead />
+ <main className="flex-1 flex items-center justify-center p-4">
+ <div className="w-full max-w-md text-center">
+ <h1 className="text-2xl font-bold mb-4">Authentication Required</h1>
+ <p className="text-zinc-400 mb-4">
+ Authentication is not configured. Please configure Supabase authentication to use this application.
+ </p>
+ <p className="text-zinc-500 text-sm">
+ For API access, use an API key in request headers instead.
+ </p>
+ </div>
+ </main>
+ </div>
+ );
+ }
+
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col">
+ <Masthead />
+ <main className="flex-1 flex items-center justify-center p-4">
+ <div className="w-full max-w-md">
+ <div className="text-center mb-8">
+ <h1 className="text-2xl font-bold mb-2">Sign In</h1>
+ <p className="text-zinc-400">
+ {mode === "signin" && "Sign in to your account"}
+ {mode === "signup" && "Create a new account"}
+ </p>
+ </div>
+
+ {/* Mode switcher */}
+ <div className="flex border-b border-zinc-800 mb-6">
+ <button
+ onClick={() => setMode("signin")}
+ className={`flex-1 py-2 text-sm transition-colors ${
+ mode === "signin"
+ ? "text-white border-b-2 border-white"
+ : "text-zinc-500 hover:text-zinc-300"
+ }`}
+ >
+ Sign In
+ </button>
+ <button
+ onClick={() => setMode("signup")}
+ className={`flex-1 py-2 text-sm transition-colors ${
+ mode === "signup"
+ ? "text-white border-b-2 border-white"
+ : "text-zinc-500 hover:text-zinc-300"
+ }`}
+ >
+ Sign Up
+ </button>
+ </div>
+
+ {/* Email/password form */}
+ <form onSubmit={handleEmailAuth} className="space-y-4">
+ <div>
+ <label className="block text-sm text-zinc-400 mb-1">Email</label>
+ <input
+ type="email"
+ value={email}
+ onChange={(e) => setEmail(e.target.value)}
+ placeholder="you@example.com"
+ className="w-full px-3 py-2 bg-zinc-900 border border-zinc-800 rounded text-white placeholder-zinc-600 focus:outline-none focus:border-zinc-600"
+ required
+ />
+ </div>
+ <div>
+ <label className="block text-sm text-zinc-400 mb-1">Password</label>
+ <input
+ type="password"
+ value={password}
+ onChange={(e) => setPassword(e.target.value)}
+ placeholder="********"
+ className="w-full px-3 py-2 bg-zinc-900 border border-zinc-800 rounded text-white placeholder-zinc-600 focus:outline-none focus:border-zinc-600"
+ required
+ minLength={6}
+ />
+ </div>
+
+ {error && <div className="text-red-400 text-sm">{error}</div>}
+ {message && <div className="text-green-400 text-sm">{message}</div>}
+
+ <button
+ type="submit"
+ disabled={loading}
+ className="w-full py-2 bg-white text-black rounded font-medium hover:bg-zinc-200 transition-colors disabled:opacity-50"
+ >
+ {loading ? "Loading..." : mode === "signin" ? "Sign In" : "Sign Up"}
+ </button>
+ </form>
+ </div>
+ </main>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/routes/mesh.tsx b/makima/frontend/src/routes/mesh.tsx
new file mode 100644
index 0000000..852ce58
--- /dev/null
+++ b/makima/frontend/src/routes/mesh.tsx
@@ -0,0 +1,634 @@
+import { useState, useCallback, useEffect, useRef, useMemo, type MouseEvent } from "react";
+import { useParams, useNavigate } from "react-router";
+import { Masthead } from "../components/Masthead";
+import { TaskList } from "../components/mesh/TaskList";
+import { TaskDetail } from "../components/mesh/TaskDetail";
+import { TaskOutput } from "../components/mesh/TaskOutput";
+import { UnifiedMeshChatInput } from "../components/mesh/UnifiedMeshChatInput";
+import { useTasks } from "../hooks/useTasks";
+import { useTaskSubscription, type TaskUpdateEvent, type TaskOutputEvent } from "../hooks/useTaskSubscription";
+import type { TaskWithSubtasks, MeshChatContext } from "../lib/api";
+import { startTask as startTaskApi, stopTask as stopTaskApi, getTaskOutput } from "../lib/api";
+
+// View modes for the task detail page
+type ViewMode = "split" | "task" | "output";
+
+// Minimum panel widths (in pixels)
+const MIN_TASK_WIDTH = 300;
+const MIN_OUTPUT_WIDTH = 200;
+
+// TODO: Store task output in database for resuming from any device.
+// Currently only persisted in localStorage which is device-specific.
+
+// LocalStorage key prefix for task output
+const STORAGE_KEY_PREFIX_OUTPUT = "makima-task-output-";
+
+// Load persisted output from localStorage with deduplication
+function loadPersistedOutput(taskId: string): TaskOutputEvent[] {
+ try {
+ const stored = localStorage.getItem(STORAGE_KEY_PREFIX_OUTPUT + taskId);
+ if (!stored) return [];
+ const entries = JSON.parse(stored) as TaskOutputEvent[];
+
+ // Deduplicate consecutive identical entries (cleanup from previous bug)
+ const deduplicated: TaskOutputEvent[] = [];
+ for (const entry of entries) {
+ const last = deduplicated[deduplicated.length - 1];
+ if (
+ !last ||
+ last.messageType !== entry.messageType ||
+ last.content !== entry.content ||
+ last.toolName !== entry.toolName
+ ) {
+ deduplicated.push(entry);
+ }
+ }
+
+ // Save cleaned up version if we removed duplicates
+ if (deduplicated.length !== entries.length) {
+ savePersistedOutput(taskId, deduplicated);
+ }
+
+ return deduplicated;
+ } catch {
+ return [];
+ }
+}
+
+// Save output to localStorage
+function savePersistedOutput(taskId: string, entries: TaskOutputEvent[]): void {
+ try {
+ localStorage.setItem(STORAGE_KEY_PREFIX_OUTPUT + taskId, JSON.stringify(entries));
+ } catch {
+ // Ignore storage errors
+ }
+}
+
+// Clear output from localStorage
+function clearPersistedOutput(taskId: string): void {
+ try {
+ localStorage.removeItem(STORAGE_KEY_PREFIX_OUTPUT + taskId);
+ } catch {
+ // Ignore storage errors
+ }
+}
+
+export default function MeshPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const { tasks, loading, error, conflict, clearConflict, fetchTask, fetchTasks, editTask, removeTask, saveTask } = useTasks();
+ const [taskDetail, setTaskDetail] = useState<TaskWithSubtasks | null>(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+ const [creating, setCreating] = useState(false);
+ const [taskOutputEntries, setTaskOutputEntries] = useState<TaskOutputEvent[]>([]);
+ const [isStreaming, setIsStreaming] = useState(false);
+ // Track which subtask's output we're viewing (null = parent task)
+ const [viewingSubtaskId, setViewingSubtaskId] = useState<string | null>(null);
+ const [viewingSubtaskName, setViewingSubtaskName] = useState<string | null>(null);
+ // View mode for the split panel layout
+ const [viewMode, setViewMode] = useState<ViewMode>("split");
+ // Width of the task panel as a percentage (0-100)
+ const [taskPanelPercent, setTaskPanelPercent] = useState(66.67);
+ // Track resizing state
+ const [isResizing, setIsResizing] = useState(false);
+ const containerRef = useRef<HTMLDivElement>(null);
+ // Track which task we've loaded output for to avoid stale saves
+ const loadedTaskIdRef = useRef<string | null>(null);
+
+ // Handle task update events from WebSocket
+ const handleTaskUpdate = useCallback(async (event: TaskUpdateEvent) => {
+ // Refresh task list if we're viewing the list
+ if (!id) {
+ fetchTasks();
+ return;
+ }
+
+ // Check if this update is for the current task or one of its subtasks
+ const isCurrentTask = event.taskId === id;
+ const isSubtask = taskDetail?.subtasks.some((st) => st.id === event.taskId);
+
+ // Refresh task detail if the update is for current task or any subtask
+ // This ensures subtask status changes (e.g., when orchestrator starts them) are reflected
+ if (isCurrentTask || isSubtask) {
+ const updated = await fetchTask(id);
+ if (updated) {
+ setTaskDetail(updated);
+ }
+ }
+
+ // Update streaming state based on status for current task
+ if (isCurrentTask) {
+ setIsStreaming(event.status === "running");
+ }
+ }, [id, fetchTask, fetchTasks, taskDetail?.subtasks]);
+
+ // The task ID whose output we're currently viewing
+ const activeOutputTaskId = viewingSubtaskId || id;
+
+ // Handle task output events from WebSocket
+ const handleTaskOutput = useCallback((event: TaskOutputEvent) => {
+ // Only process output for the task we're currently viewing
+ if (event.taskId === activeOutputTaskId) {
+ setTaskOutputEntries((prev) => {
+ // Deduplicate by checking if last entry is identical
+ // This prevents duplicates from React StrictMode or WebSocket reconnects
+ const lastEntry = prev[prev.length - 1];
+ if (
+ lastEntry &&
+ lastEntry.messageType === event.messageType &&
+ lastEntry.content === event.content &&
+ lastEntry.toolName === event.toolName
+ ) {
+ return prev; // Skip duplicate
+ }
+ const newEntries = [...prev, event];
+ // Persist to localStorage
+ savePersistedOutput(event.taskId, newEntries);
+ return newEntries;
+ });
+ }
+ }, [activeOutputTaskId]);
+
+ // Handle user input sent to task - show immediately in output
+ const handleUserInput = useCallback((message: string) => {
+ if (!activeOutputTaskId) return;
+ const userEntry: TaskOutputEvent = {
+ taskId: activeOutputTaskId,
+ messageType: "user_input",
+ content: message,
+ isPartial: false,
+ };
+ setTaskOutputEntries((prev) => {
+ const newEntries = [...prev, userEntry];
+ savePersistedOutput(activeOutputTaskId, newEntries);
+ return newEntries;
+ });
+ }, [activeOutputTaskId]);
+
+ // Subscribe to task updates and output
+ // When viewing a subtask's output, subscribe to that instead of the parent
+ // Always subscribe to all updates so we see subtask status changes
+ const { connected } = useTaskSubscription({
+ taskId: id || null,
+ subscribeAll: true, // Always subscribe to all - needed to see subtask updates
+ subscribeOutput: !!activeOutputTaskId, // Subscribe to output when viewing a task
+ outputTaskId: activeOutputTaskId || undefined, // Which task's output to subscribe to
+ onUpdate: handleTaskUpdate,
+ onOutput: handleTaskOutput,
+ });
+
+ // Load persisted output when task or viewed subtask changes
+ useEffect(() => {
+ if (activeOutputTaskId) {
+ // First load from localStorage (instant, for local cache)
+ const persisted = loadPersistedOutput(activeOutputTaskId);
+ setTaskOutputEntries(persisted);
+ loadedTaskIdRef.current = activeOutputTaskId;
+
+ // Then fetch from API to get any output we missed
+ // (e.g., subtask was running before we started viewing it)
+ getTaskOutput(activeOutputTaskId)
+ .then((response) => {
+ if (response.entries.length > 0) {
+ setTaskOutputEntries((prev) => {
+ // API returns all historical entries in chronological order
+ const apiEntries = response.entries.map(entry => ({
+ taskId: entry.taskId,
+ messageType: entry.messageType,
+ content: entry.content,
+ toolName: entry.toolName,
+ toolInput: entry.toolInput,
+ isError: entry.isError,
+ costUsd: entry.costUsd,
+ durationMs: entry.durationMs,
+ isPartial: false,
+ }));
+
+ // If localStorage is empty, just use API data
+ if (prev.length === 0) {
+ savePersistedOutput(activeOutputTaskId, apiEntries);
+ return apiEntries;
+ }
+
+ // localStorage has user_input entries in correct positions - trust its order
+ // Only append API entries that we don't already have locally
+ const localKeys = new Set(prev.map(e => `${e.messageType}:${e.content}`));
+ const newFromApi = apiEntries.filter(e => !localKeys.has(`${e.messageType}:${e.content}`));
+
+ // Keep local order (has user_input in correct spots), append new API data
+ const merged = [...prev, ...newFromApi];
+ savePersistedOutput(activeOutputTaskId, merged);
+ return merged;
+ });
+ }
+ })
+ .catch((err) => {
+ console.error("Failed to fetch task output:", err);
+ });
+ } else {
+ setTaskOutputEntries([]);
+ loadedTaskIdRef.current = null;
+ }
+ setIsStreaming(false);
+ }, [activeOutputTaskId]);
+
+ // Reset subtask view when navigating to a different parent task
+ useEffect(() => {
+ setViewingSubtaskId(null);
+ setViewingSubtaskName(null);
+ }, [id]);
+
+ // Toggle viewing a subtask's output (for running subtasks)
+ const handleToggleSubtaskOutput = useCallback(
+ (subtaskId: string, subtaskName: string) => {
+ if (viewingSubtaskId === subtaskId) {
+ // Already viewing this subtask, switch back to parent
+ setViewingSubtaskId(null);
+ setViewingSubtaskName(null);
+ } else {
+ // Switch to viewing this subtask's output
+ setViewingSubtaskId(subtaskId);
+ setViewingSubtaskName(subtaskName);
+ }
+ },
+ [viewingSubtaskId]
+ );
+
+ // Load task detail when URL has an id
+ useEffect(() => {
+ if (id) {
+ setDetailLoading(true);
+ fetchTask(id).then((detail) => {
+ setTaskDetail(detail);
+ setDetailLoading(false);
+ });
+ } else {
+ setTaskDetail(null);
+ }
+ }, [id, fetchTask]);
+
+ const handleSelectTask = useCallback(
+ (taskId: string) => {
+ navigate(`/mesh/${taskId}`);
+ },
+ [navigate]
+ );
+
+ const handleBack = useCallback(() => {
+ // If viewing a subtask, go back to parent
+ if (taskDetail?.parentTaskId) {
+ navigate(`/mesh/${taskDetail.parentTaskId}`);
+ } else {
+ navigate("/mesh");
+ }
+ }, [navigate, taskDetail]);
+
+ const handleDelete = useCallback(
+ async (taskId: string) => {
+ if (confirm("Are you sure you want to delete this task?")) {
+ const success = await removeTask(taskId);
+ if (success && id === taskId) {
+ // If deleting current task, go back
+ if (taskDetail?.parentTaskId) {
+ navigate(`/mesh/${taskDetail.parentTaskId}`);
+ } else {
+ navigate("/mesh");
+ }
+ }
+ }
+ },
+ [removeTask, id, taskDetail, navigate]
+ );
+
+ const handleStart = useCallback(
+ async (taskId: string) => {
+ try {
+ const updated = await startTaskApi(taskId);
+ setTaskDetail((prev) => prev ? { ...prev, ...updated } : prev);
+ } catch (e) {
+ console.error("Failed to start task:", e);
+ alert(e instanceof Error ? e.message : "Failed to start task");
+ }
+ },
+ []
+ );
+
+ const handleStop = useCallback(
+ async (taskId: string) => {
+ try {
+ const updated = await stopTaskApi(taskId);
+ setTaskDetail((prev) => prev ? { ...prev, ...updated } : prev);
+ } catch (e) {
+ console.error("Failed to stop task:", e);
+ alert(e instanceof Error ? e.message : "Failed to stop task");
+ }
+ },
+ []
+ );
+
+ const handleRestart = useCallback(
+ async (taskId: string) => {
+ try {
+ // First stop the task
+ await stopTaskApi(taskId);
+ // Then start it again
+ const updated = await startTaskApi(taskId);
+ setTaskDetail((prev) => prev ? { ...prev, ...updated } : prev);
+ } catch (e) {
+ console.error("Failed to restart task:", e);
+ alert(e instanceof Error ? e.message : "Failed to restart task");
+ }
+ },
+ []
+ );
+
+ const handleContinue = useCallback(
+ async (taskId: string) => {
+ try {
+ // Start the task again from terminal state
+ const updated = await startTaskApi(taskId);
+ setTaskDetail((prev) => prev ? { ...prev, ...updated } : prev);
+ } catch (e) {
+ console.error("Failed to continue task:", e);
+ alert(e instanceof Error ? e.message : "Failed to continue task");
+ }
+ },
+ []
+ );
+
+ const handleSave = useCallback(
+ async (taskId: string, name: string, description: string, plan: string, targetRepoPath?: string, completionAction?: string) => {
+ if (!taskDetail) return;
+ const result = await editTask(taskId, {
+ name,
+ description: description || undefined,
+ plan,
+ targetRepoPath: targetRepoPath || undefined,
+ completionAction: completionAction as import("../lib/api").CompletionAction | undefined,
+ version: taskDetail.version,
+ });
+ if (result) {
+ setTaskDetail(result);
+ }
+ },
+ [editTask, taskDetail]
+ );
+
+ const handleCreate = useCallback(async () => {
+ if (creating) return;
+ setCreating(true);
+ try {
+ const newTask = await saveTask({
+ name: `Task ${new Date().toLocaleDateString()}`,
+ plan: "# Plan\n\nDescribe what this task should accomplish...",
+ });
+ if (newTask) {
+ navigate(`/mesh/${newTask.id}`);
+ }
+ } finally {
+ setCreating(false);
+ }
+ }, [creating, saveTask, navigate]);
+
+ const handleCreateSubtask = useCallback(async () => {
+ if (!taskDetail || creating) return;
+ setCreating(true);
+ try {
+ const newTask = await saveTask({
+ name: `Subtask of ${taskDetail.name}`,
+ plan: "# Plan\n\nDescribe what this subtask should accomplish...",
+ parentTaskId: taskDetail.id,
+ });
+ if (newTask) {
+ // Refresh current task to show new subtask
+ const refreshed = await fetchTask(taskDetail.id);
+ if (refreshed) {
+ setTaskDetail(refreshed);
+ }
+ }
+ } finally {
+ setCreating(false);
+ }
+ }, [creating, saveTask, taskDetail, fetchTask]);
+
+ // Callback when task is updated via CLI
+ const handleTaskUpdatedFromCli = useCallback(async () => {
+ if (id) {
+ const updated = await fetchTask(id);
+ if (updated) {
+ setTaskDetail(updated);
+ }
+ }
+ // Also refresh the task list
+ fetchTasks();
+ }, [id, fetchTask, fetchTasks]);
+
+ // Calculate chat context based on current view
+ const chatContext: MeshChatContext = useMemo(() => {
+ if (!id) {
+ return { type: "mesh" };
+ }
+ if (taskDetail?.parentTaskId) {
+ return { type: "subtask", taskId: id, parentTaskId: taskDetail.parentTaskId };
+ }
+ return { type: "task", taskId: id };
+ }, [id, taskDetail?.parentTaskId]);
+
+ // Handle resizing of the split panel
+ const handleResizeStart = useCallback((e: MouseEvent) => {
+ e.preventDefault();
+ setIsResizing(true);
+ }, []);
+
+ useEffect(() => {
+ if (!isResizing) return;
+
+ const handleMouseMove = (e: globalThis.MouseEvent) => {
+ if (!containerRef.current) return;
+ const containerRect = containerRef.current.getBoundingClientRect();
+ const containerWidth = containerRect.width;
+ const mouseX = e.clientX - containerRect.left;
+
+ // Calculate percentage, respecting minimum widths
+ const minTaskPercent = (MIN_TASK_WIDTH / containerWidth) * 100;
+ const maxTaskPercent = ((containerWidth - MIN_OUTPUT_WIDTH) / containerWidth) * 100;
+ const newPercent = Math.max(minTaskPercent, Math.min(maxTaskPercent, (mouseX / containerWidth) * 100));
+
+ setTaskPanelPercent(newPercent);
+ };
+
+ const handleMouseUp = () => {
+ setIsResizing(false);
+ };
+
+ document.addEventListener("mousemove", handleMouseMove);
+ document.addEventListener("mouseup", handleMouseUp);
+
+ return () => {
+ document.removeEventListener("mousemove", handleMouseMove);
+ document.removeEventListener("mouseup", handleMouseUp);
+ };
+ }, [isResizing]);
+
+ // Cycle through view modes
+ const cycleViewMode = useCallback(() => {
+ setViewMode((current) => {
+ if (current === "split") return "task";
+ if (current === "task") return "output";
+ return "split";
+ });
+ }, []);
+
+ // Get label for current view mode
+ const getViewModeLabel = (mode: ViewMode): string => {
+ switch (mode) {
+ case "split": return "Split";
+ case "task": return "Task";
+ case "output": return "Output";
+ }
+ };
+
+ return (
+ <div className="relative z-10 h-screen flex flex-col overflow-hidden">
+ <Masthead showTicker={false} showNav />
+
+ <main className="flex-1 p-4 md:p-6 min-h-0 overflow-hidden flex flex-col">
+ {error && (
+ <div className="mb-4 p-3 border border-red-400/50 bg-red-400/10 text-red-400 font-mono text-sm shrink-0">
+ {error}
+ </div>
+ )}
+
+ {conflict?.hasConflict && (
+ <div className="mb-4 p-3 border border-yellow-400/50 bg-yellow-400/10 text-yellow-400 font-mono text-sm shrink-0">
+ <p>Version conflict detected. Please reload and try again.</p>
+ <button
+ onClick={clearConflict}
+ className="mt-2 px-3 py-1 border border-yellow-400/30 hover:border-yellow-400/50 text-xs uppercase"
+ >
+ Dismiss
+ </button>
+ </div>
+ )}
+
+ {/* Main content area - conditional based on route */}
+ <div className="flex-1 flex flex-col min-h-0 overflow-hidden gap-4">
+ {id && taskDetail ? (
+ <>
+ {/* Header with connection status and view toggle */}
+ <div className="flex items-center justify-between shrink-0">
+ <div className="flex items-center gap-2">
+ <span
+ className={`w-2 h-2 rounded-full ${
+ connected ? "bg-green-400" : "bg-yellow-400 animate-pulse"
+ }`}
+ />
+ <span className="font-mono text-[10px] text-[#75aafc] uppercase">
+ {connected ? "Connected" : "Connecting..."}
+ </span>
+ </div>
+ {/* View mode toggle */}
+ <button
+ onClick={cycleViewMode}
+ className="px-3 py-1 font-mono text-[10px] text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ View: {getViewModeLabel(viewMode)}
+ </button>
+ </div>
+
+ {/* Split panel layout */}
+ <div
+ ref={containerRef}
+ className={`flex-1 flex min-h-0 overflow-hidden ${isResizing ? "select-none" : ""}`}
+ >
+ {/* Task detail panel */}
+ {(viewMode === "split" || viewMode === "task") && (
+ <div
+ className="min-h-0 overflow-hidden"
+ style={{
+ width: viewMode === "split" ? `${taskPanelPercent}%` : "100%",
+ flexShrink: 0,
+ }}
+ >
+ <TaskDetail
+ task={taskDetail}
+ loading={detailLoading}
+ onBack={handleBack}
+ onSave={handleSave}
+ onDelete={handleDelete}
+ onStart={handleStart}
+ onStop={handleStop}
+ onRestart={handleRestart}
+ onContinue={handleContinue}
+ onSelectSubtask={handleSelectTask}
+ onCreateSubtask={handleCreateSubtask}
+ onToggleSubtaskOutput={handleToggleSubtaskOutput}
+ viewingSubtaskId={viewingSubtaskId}
+ />
+ </div>
+ )}
+
+ {/* Resizable divider */}
+ {viewMode === "split" && (
+ <div
+ className="w-1 shrink-0 cursor-col-resize bg-[rgba(117,170,252,0.15)] hover:bg-[rgba(117,170,252,0.35)] transition-colors group flex items-center justify-center"
+ onMouseDown={handleResizeStart}
+ >
+ <div className="w-0.5 h-8 bg-[rgba(117,170,252,0.3)] group-hover:bg-[rgba(117,170,252,0.5)] rounded-full" />
+ </div>
+ )}
+
+ {/* Output panel */}
+ {(viewMode === "split" || viewMode === "output") && (
+ <div
+ className="panel min-h-0 overflow-hidden flex-1"
+ >
+ <TaskOutput
+ entries={taskOutputEntries}
+ isStreaming={isStreaming || taskDetail.status === "running"}
+ viewingSubtaskName={viewingSubtaskName}
+ onClearSubtaskView={viewingSubtaskId ? () => {
+ setViewingSubtaskId(null);
+ setViewingSubtaskName(null);
+ } : undefined}
+ onClear={() => {
+ setTaskOutputEntries([]);
+ if (activeOutputTaskId) {
+ clearPersistedOutput(activeOutputTaskId);
+ }
+ }}
+ taskId={activeOutputTaskId}
+ onUserInput={handleUserInput}
+ />
+ </div>
+ )}
+ </div>
+ </>
+ ) : id && detailLoading ? (
+ <div className="panel flex-1 flex items-center justify-center">
+ <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div>
+ </div>
+ ) : (
+ <div className="flex-1 min-h-0 overflow-hidden">
+ <TaskList
+ tasks={tasks}
+ loading={loading || creating}
+ onSelect={handleSelectTask}
+ onDelete={handleDelete}
+ onCreate={handleCreate}
+ />
+ </div>
+ )}
+
+ {/* Mesh Chat Input - always rendered to persist state across navigation */}
+ <div className="shrink-0">
+ <UnifiedMeshChatInput
+ context={chatContext}
+ onUpdate={id ? handleTaskUpdatedFromCli : fetchTasks}
+ />
+ </div>
+ </div>
+ </main>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/routes/settings.tsx b/makima/frontend/src/routes/settings.tsx
new file mode 100644
index 0000000..6d56e67
--- /dev/null
+++ b/makima/frontend/src/routes/settings.tsx
@@ -0,0 +1,724 @@
+import { useState, useEffect, type FormEvent } from "react";
+import { useAuth } from "../contexts/AuthContext";
+import { useNavigate } from "react-router";
+import { Masthead } from "../components/Masthead";
+import {
+ getApiKey,
+ createApiKey,
+ refreshApiKey,
+ revokeApiKey,
+ changePassword,
+ changeEmail,
+ deleteAccount,
+ type ApiKeyInfo,
+ type CreateApiKeyResponse,
+} from "../lib/api";
+
+// =============================================================================
+// Password Strength Indicator
+// =============================================================================
+
+interface PasswordStrength {
+ score: number;
+ label: string;
+ color: string;
+ requirements: { met: boolean; text: string }[];
+}
+
+function getPasswordStrength(password: string): PasswordStrength {
+ const requirements = [
+ { met: password.length >= 6, text: "At least 6 characters" },
+ ];
+
+ const score = requirements.filter((r) => r.met).length;
+
+ const label = score === 1 ? "Valid" : "Too short";
+ const color = score === 1 ? "bg-green-500" : "bg-red-500";
+
+ return { score, label, color, requirements };
+}
+
+// =============================================================================
+// Confirmation Dialog Component
+// =============================================================================
+
+interface ConfirmDialogProps {
+ isOpen: boolean;
+ title: string;
+ message: string;
+ confirmText: string;
+ confirmButtonClass?: string;
+ requireInput?: string;
+ inputPlaceholder?: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+}
+
+function ConfirmDialog({
+ isOpen,
+ title,
+ message,
+ confirmText,
+ confirmButtonClass = "bg-red-900/50 border-red-700 hover:bg-red-800/50",
+ requireInput,
+ inputPlaceholder,
+ onConfirm,
+ onCancel,
+}: ConfirmDialogProps) {
+ const [inputValue, setInputValue] = useState("");
+
+ useEffect(() => {
+ if (!isOpen) {
+ setInputValue("");
+ }
+ }, [isOpen]);
+
+ if (!isOpen) return null;
+
+ const canConfirm = !requireInput || inputValue === requireInput;
+
+ return (
+ <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50">
+ <div className="bg-[#0d1b2d] border border-[rgba(117,170,252,0.35)] p-6 max-w-md w-full mx-4">
+ <h3 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff] mb-3">{title}</h3>
+ <p className="text-[#75aafc] text-xs font-mono mb-4">{message}</p>
+ {requireInput && (
+ <div className="mb-4">
+ <label className="block text-xs font-mono text-[#8899aa] mb-2">
+ Type <span className="text-[#9bc3ff]">{requireInput}</span> to confirm:
+ </label>
+ <input
+ type="text"
+ value={inputValue}
+ onChange={(e) => setInputValue(e.target.value)}
+ placeholder={inputPlaceholder}
+ className="w-full px-3 py-2 bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-sm placeholder-[#556677] focus:outline-none focus:border-[#3f6fb3]"
+ />
+ </div>
+ )}
+ <div className="flex gap-3 justify-end">
+ <button
+ onClick={onCancel}
+ className="px-4 py-2 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] transition-colors"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={onConfirm}
+ disabled={!canConfirm}
+ className={`px-4 py-2 border font-mono text-xs uppercase tracking-wide transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${confirmButtonClass}`}
+ >
+ {confirmText}
+ </button>
+ </div>
+ </div>
+ </div>
+ );
+}
+
+// =============================================================================
+// Section Header Component
+// =============================================================================
+
+function SectionHeader({ children }: { children: React.ReactNode }) {
+ return (
+ <h2 className="text-[11px] font-mono uppercase tracking-wide text-[#8899aa] mb-3 pb-2 border-b border-[rgba(117,170,252,0.15)]">
+ {children}
+ </h2>
+ );
+}
+
+// =============================================================================
+// Form Input Component
+// =============================================================================
+
+function FormInput({
+ label,
+ type = "text",
+ value,
+ onChange,
+ placeholder,
+ required,
+ disabled,
+}: {
+ label: string;
+ type?: string;
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ required?: boolean;
+ disabled?: boolean;
+}) {
+ return (
+ <div>
+ <label className="block text-[10px] font-mono uppercase tracking-wide text-[#7788aa] mb-1">
+ {label}
+ </label>
+ <input
+ type={type}
+ value={value}
+ onChange={(e) => onChange(e.target.value)}
+ placeholder={placeholder}
+ required={required}
+ disabled={disabled}
+ className="w-full px-3 py-2 bg-transparent border border-[rgba(117,170,252,0.25)] text-white font-mono text-sm placeholder-[#556677] focus:outline-none focus:border-[#3f6fb3] disabled:opacity-50"
+ />
+ </div>
+ );
+}
+
+// =============================================================================
+// Alert Components
+// =============================================================================
+
+function ErrorAlert({ children }: { children: React.ReactNode }) {
+ return (
+ <div className="border border-red-700/50 bg-red-900/20 text-red-400 px-3 py-2 mb-4 font-mono text-xs">
+ {children}
+ </div>
+ );
+}
+
+function SuccessAlert({ children }: { children: React.ReactNode }) {
+ return (
+ <div className="border border-green-700/50 bg-green-900/20 text-green-400 px-3 py-2 mb-4 font-mono text-xs">
+ {children}
+ </div>
+ );
+}
+
+// =============================================================================
+// Button Components
+// =============================================================================
+
+function PrimaryButton({
+ children,
+ onClick,
+ disabled,
+ type = "button",
+}: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ disabled?: boolean;
+ type?: "button" | "submit";
+}) {
+ return (
+ <button
+ type={type}
+ onClick={onClick}
+ disabled={disabled}
+ className="px-4 py-2 bg-[#3f6fb3] border border-[#75aafc] text-white font-mono text-xs uppercase tracking-wide hover:bg-[#4a7fc3] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {children}
+ </button>
+ );
+}
+
+function SecondaryButton({
+ children,
+ onClick,
+ disabled,
+}: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ disabled?: boolean;
+}) {
+ return (
+ <button
+ type="button"
+ onClick={onClick}
+ disabled={disabled}
+ className="px-4 py-2 border border-[rgba(117,170,252,0.25)] text-[#9bc3ff] font-mono text-xs uppercase tracking-wide hover:border-[#3f6fb3] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {children}
+ </button>
+ );
+}
+
+function DangerButton({
+ children,
+ onClick,
+ disabled,
+}: {
+ children: React.ReactNode;
+ onClick?: () => void;
+ disabled?: boolean;
+}) {
+ return (
+ <button
+ type="button"
+ onClick={onClick}
+ disabled={disabled}
+ className="px-4 py-2 bg-red-900/30 border border-red-700/50 text-red-400 font-mono text-xs uppercase tracking-wide hover:bg-red-800/30 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
+ >
+ {children}
+ </button>
+ );
+}
+
+// =============================================================================
+// Main Settings Page
+// =============================================================================
+
+export default function SettingsPage() {
+ const { user, isAuthConfigured, signOut } = useAuth();
+ const navigate = useNavigate();
+
+ // API Key state
+ const [apiKeyInfo, setApiKeyInfo] = useState<ApiKeyInfo | null>(null);
+ const [newKey, setNewKey] = useState<string | null>(null);
+ const [loading, setLoading] = useState(true);
+ const [actionLoading, setActionLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [copied, setCopied] = useState(false);
+
+ // Password change state
+ const [passwordForm, setPasswordForm] = useState({
+ currentPassword: "",
+ newPassword: "",
+ confirmPassword: "",
+ });
+ const [passwordLoading, setPasswordLoading] = useState(false);
+ const [passwordError, setPasswordError] = useState<string | null>(null);
+ const [passwordSuccess, setPasswordSuccess] = useState<string | null>(null);
+
+ // Email change state
+ const [emailForm, setEmailForm] = useState({
+ password: "",
+ newEmail: "",
+ });
+ const [emailLoading, setEmailLoading] = useState(false);
+ const [emailError, setEmailError] = useState<string | null>(null);
+ const [emailSuccess, setEmailSuccess] = useState<string | null>(null);
+
+ // Account deletion state
+ const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
+ const [deletePassword, setDeletePassword] = useState("");
+ const [deleteLoading, setDeleteLoading] = useState(false);
+ const [deleteError, setDeleteError] = useState<string | null>(null);
+
+ useEffect(() => {
+ loadApiKey();
+ }, []);
+
+ const loadApiKey = async () => {
+ try {
+ setLoading(true);
+ setError(null);
+ const key = await getApiKey();
+ setApiKeyInfo(key);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to load API key");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleCreate = async () => {
+ try {
+ setActionLoading(true);
+ setError(null);
+ setNewKey(null);
+ const response: CreateApiKeyResponse = await createApiKey("Web UI");
+ setNewKey(response.key);
+ setApiKeyInfo({
+ id: response.id,
+ prefix: response.prefix,
+ name: response.name,
+ lastUsedAt: null,
+ createdAt: response.createdAt,
+ });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to create API key");
+ } finally {
+ setActionLoading(false);
+ }
+ };
+
+ const handleRefresh = async () => {
+ try {
+ setActionLoading(true);
+ setError(null);
+ setNewKey(null);
+ const response = await refreshApiKey("Web UI (Refreshed)");
+ setNewKey(response.key);
+ setApiKeyInfo({
+ id: response.id,
+ prefix: response.prefix,
+ name: response.name,
+ lastUsedAt: null,
+ createdAt: response.createdAt,
+ });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to refresh API key");
+ } finally {
+ setActionLoading(false);
+ }
+ };
+
+ const handleRevoke = async () => {
+ if (!confirm("Are you sure you want to revoke this API key? Any applications using it will stop working.")) {
+ return;
+ }
+ try {
+ setActionLoading(true);
+ setError(null);
+ setNewKey(null);
+ await revokeApiKey();
+ setApiKeyInfo(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to revoke API key");
+ } finally {
+ setActionLoading(false);
+ }
+ };
+
+ const copyToClipboard = async () => {
+ if (!newKey) return;
+ try {
+ await navigator.clipboard.writeText(newKey);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (err) {
+ console.error("Failed to copy:", err);
+ }
+ };
+
+ // Password change handlers
+ const handlePasswordChange = async (e: FormEvent) => {
+ e.preventDefault();
+ setPasswordError(null);
+ setPasswordSuccess(null);
+
+ if (passwordForm.newPassword !== passwordForm.confirmPassword) {
+ setPasswordError("New passwords do not match");
+ return;
+ }
+
+ const strength = getPasswordStrength(passwordForm.newPassword);
+ if (strength.score < 1) {
+ setPasswordError("Password must be at least 6 characters");
+ return;
+ }
+
+ try {
+ setPasswordLoading(true);
+ await changePassword(passwordForm.currentPassword, passwordForm.newPassword);
+ setPasswordSuccess("Password changed successfully. Please sign in with your new password.");
+ setPasswordForm({ currentPassword: "", newPassword: "", confirmPassword: "" });
+ setTimeout(async () => {
+ await signOut();
+ navigate("/login");
+ }, 1500);
+ } catch (err) {
+ setPasswordError(err instanceof Error ? err.message : "Failed to change password");
+ } finally {
+ setPasswordLoading(false);
+ }
+ };
+
+ // Email change handlers
+ const handleEmailChange = async (e: FormEvent) => {
+ e.preventDefault();
+ setEmailError(null);
+ setEmailSuccess(null);
+
+ if (!emailForm.newEmail.includes("@")) {
+ setEmailError("Please enter a valid email address");
+ return;
+ }
+
+ try {
+ setEmailLoading(true);
+ await changeEmail(emailForm.password, emailForm.newEmail);
+ setEmailSuccess("Email changed successfully");
+ setEmailForm({ password: "", newEmail: "" });
+ } catch (err) {
+ setEmailError(err instanceof Error ? err.message : "Failed to change email");
+ } finally {
+ setEmailLoading(false);
+ }
+ };
+
+ // Account deletion handlers
+ const DELETE_CONFIRMATION = "DELETE MY ACCOUNT";
+
+ const handleDeleteAccount = async () => {
+ try {
+ setDeleteLoading(true);
+ setDeleteError(null);
+ await deleteAccount(deletePassword, DELETE_CONFIRMATION);
+ await signOut();
+ navigate("/login");
+ } catch (err) {
+ setDeleteError(err instanceof Error ? err.message : "Failed to delete account");
+ setDeleteLoading(false);
+ }
+ };
+
+ const passwordStrength = getPasswordStrength(passwordForm.newPassword);
+
+ return (
+ <div className="relative z-10 min-h-screen flex flex-col bg-[#0a1628]">
+ <Masthead showNav />
+
+ <main className="flex-1 max-w-4xl mx-auto p-6 w-full">
+ {/* Page Header */}
+ <div className="mb-8">
+ <h1 className="text-sm font-mono uppercase tracking-wide text-[#9bc3ff]">Settings</h1>
+ <div className="h-px bg-[rgba(117,170,252,0.35)] mt-2" />
+ </div>
+
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
+ {/* Left Column */}
+ <div className="space-y-6">
+ {/* Account Info */}
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <SectionHeader>Account</SectionHeader>
+ {isAuthConfigured && user ? (
+ <div className="space-y-2 font-mono text-xs">
+ <div className="flex justify-between">
+ <span className="text-[#7788aa]">Email</span>
+ <span className="text-[#9bc3ff]">{user.email}</span>
+ </div>
+ <div className="flex justify-between">
+ <span className="text-[#7788aa]">User ID</span>
+ <span className="text-[#75aafc] text-[10px]">{user.id}</span>
+ </div>
+ </div>
+ ) : (
+ <p className="text-[#7788aa] font-mono text-xs">
+ {isAuthConfigured
+ ? "Not signed in"
+ : "Authentication not configured (API key mode)"}
+ </p>
+ )}
+ </section>
+
+ {/* API Key Section */}
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <SectionHeader>API Key</SectionHeader>
+ <p className="text-[#7788aa] font-mono text-[10px] mb-4">
+ Authenticate daemon and CLI tools. One active key at a time.
+ </p>
+
+ {error && <ErrorAlert>{error}</ErrorAlert>}
+
+ {newKey && (
+ <div className="border border-green-700/50 bg-green-900/20 p-3 mb-4">
+ <p className="text-green-400 font-mono text-[10px] mb-2">
+ Key created. Copy now - won't be shown again.
+ </p>
+ <div className="flex items-center gap-2">
+ <code className="flex-1 bg-black/50 px-2 py-1 text-[10px] font-mono text-green-400 break-all">
+ {newKey}
+ </code>
+ <button
+ onClick={copyToClipboard}
+ className="px-2 py-1 bg-green-900/50 border border-green-700/50 text-green-400 font-mono text-[10px] uppercase hover:bg-green-800/50 transition-colors"
+ >
+ {copied ? "Copied" : "Copy"}
+ </button>
+ </div>
+ </div>
+ )}
+
+ {loading ? (
+ <p className="text-[#7788aa] font-mono text-xs">Loading...</p>
+ ) : apiKeyInfo ? (
+ <div className="space-y-3">
+ <div className="font-mono text-xs">
+ <div className="flex justify-between mb-1">
+ <span className="text-[#7788aa]">Prefix</span>
+ <code className="text-[#75aafc]">{apiKeyInfo.prefix}...</code>
+ </div>
+ <div className="flex justify-between mb-1">
+ <span className="text-[#7788aa]">Created</span>
+ <span className="text-[#9bc3ff]">
+ {new Date(apiKeyInfo.createdAt).toLocaleDateString()}
+ </span>
+ </div>
+ {apiKeyInfo.lastUsedAt && (
+ <div className="flex justify-between">
+ <span className="text-[#7788aa]">Last used</span>
+ <span className="text-[#9bc3ff]">
+ {new Date(apiKeyInfo.lastUsedAt).toLocaleDateString()}
+ </span>
+ </div>
+ )}
+ </div>
+ <div className="flex gap-2 pt-2">
+ <SecondaryButton onClick={handleRefresh} disabled={actionLoading}>
+ {actionLoading ? "..." : "Rotate"}
+ </SecondaryButton>
+ <DangerButton onClick={handleRevoke} disabled={actionLoading}>
+ {actionLoading ? "..." : "Revoke"}
+ </DangerButton>
+ </div>
+ </div>
+ ) : (
+ <div>
+ <p className="text-[#7788aa] font-mono text-xs mb-3">No API key configured.</p>
+ <PrimaryButton onClick={handleCreate} disabled={actionLoading}>
+ {actionLoading ? "Creating..." : "Create API Key"}
+ </PrimaryButton>
+ </div>
+ )}
+ </section>
+
+ {/* Daemon Setup */}
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <SectionHeader>Daemon Setup</SectionHeader>
+ <p className="text-[#7788aa] font-mono text-[10px] mb-3">
+ Set your API key as an environment variable:
+ </p>
+ <code className="block bg-black/50 px-3 py-2 text-[11px] font-mono text-green-400 mb-3">
+ export MAKIMA_API_KEY="your-key"
+ </code>
+ <p className="text-[#7788aa] font-mono text-[10px]">
+ Then run: <code className="text-green-400">makima-daemon</code>
+ </p>
+ </section>
+ </div>
+
+ {/* Right Column */}
+ <div className="space-y-6">
+ {/* Password Change */}
+ {isAuthConfigured && user && (
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <SectionHeader>Change Password</SectionHeader>
+ {passwordError && <ErrorAlert>{passwordError}</ErrorAlert>}
+ {passwordSuccess && <SuccessAlert>{passwordSuccess}</SuccessAlert>}
+ <form onSubmit={handlePasswordChange} className="space-y-3">
+ <FormInput
+ label="Current Password"
+ type="password"
+ value={passwordForm.currentPassword}
+ onChange={(v) => setPasswordForm({ ...passwordForm, currentPassword: v })}
+ required
+ />
+ <FormInput
+ label="New Password"
+ type="password"
+ value={passwordForm.newPassword}
+ onChange={(v) => setPasswordForm({ ...passwordForm, newPassword: v })}
+ required
+ />
+ {passwordForm.newPassword && (
+ <div className="space-y-1">
+ <div className="flex items-center gap-2">
+ <div className="flex-1 h-1 bg-[#1a2a3a]">
+ <div
+ className={`h-full transition-all ${passwordStrength.color}`}
+ style={{ width: `${passwordStrength.score * 100}%` }}
+ />
+ </div>
+ <span className="text-[10px] font-mono text-[#9bc3ff]">
+ {passwordStrength.label}
+ </span>
+ </div>
+ </div>
+ )}
+ <FormInput
+ label="Confirm Password"
+ type="password"
+ value={passwordForm.confirmPassword}
+ onChange={(v) => setPasswordForm({ ...passwordForm, confirmPassword: v })}
+ required
+ />
+ {passwordForm.confirmPassword &&
+ passwordForm.newPassword !== passwordForm.confirmPassword && (
+ <p className="text-red-400 font-mono text-[10px]">Passwords do not match</p>
+ )}
+ <div className="pt-2">
+ <PrimaryButton
+ type="submit"
+ disabled={passwordLoading || passwordStrength.score < 1}
+ >
+ {passwordLoading ? "Changing..." : "Change Password"}
+ </PrimaryButton>
+ </div>
+ </form>
+ </section>
+ )}
+
+ {/* Email Change */}
+ {isAuthConfigured && user && (
+ <section className="border border-[rgba(117,170,252,0.25)] bg-[#0d1b2d] p-4">
+ <SectionHeader>Change Email</SectionHeader>
+ {emailError && <ErrorAlert>{emailError}</ErrorAlert>}
+ {emailSuccess && <SuccessAlert>{emailSuccess}</SuccessAlert>}
+ <form onSubmit={handleEmailChange} className="space-y-3">
+ <FormInput
+ label="New Email"
+ type="email"
+ value={emailForm.newEmail}
+ onChange={(v) => setEmailForm({ ...emailForm, newEmail: v })}
+ placeholder="new@example.com"
+ required
+ />
+ <FormInput
+ label="Password (to confirm)"
+ type="password"
+ value={emailForm.password}
+ onChange={(v) => setEmailForm({ ...emailForm, password: v })}
+ required
+ />
+ <div className="pt-2">
+ <PrimaryButton type="submit" disabled={emailLoading}>
+ {emailLoading ? "Changing..." : "Change Email"}
+ </PrimaryButton>
+ </div>
+ </form>
+ </section>
+ )}
+
+ {/* Danger Zone */}
+ {isAuthConfigured && user && (
+ <section className="border border-red-900/50 bg-[#0d1b2d] p-4">
+ <h2 className="text-[11px] font-mono uppercase tracking-wide text-red-400 mb-3 pb-2 border-b border-red-900/30">
+ Danger Zone
+ </h2>
+ <p className="text-[#7788aa] font-mono text-[10px] mb-3">
+ Permanently delete your account and all data. This cannot be undone.
+ </p>
+ {deleteError && <ErrorAlert>{deleteError}</ErrorAlert>}
+ <div className="space-y-3">
+ <FormInput
+ label="Password"
+ type="password"
+ value={deletePassword}
+ onChange={setDeletePassword}
+ placeholder="Enter password to continue"
+ />
+ <DangerButton
+ onClick={() => setDeleteDialogOpen(true)}
+ disabled={!deletePassword || deleteLoading}
+ >
+ {deleteLoading ? "Deleting..." : "Delete Account"}
+ </DangerButton>
+ </div>
+ </section>
+ )}
+ </div>
+ </div>
+ </main>
+
+ {/* Delete Confirmation Dialog */}
+ <ConfirmDialog
+ isOpen={deleteDialogOpen}
+ title="Delete Account"
+ message="This will permanently delete your account and all your data. This action cannot be undone."
+ confirmText="Delete"
+ confirmButtonClass="bg-red-900/50 border border-red-700 text-red-400 hover:bg-red-800/50"
+ requireInput={DELETE_CONFIRMATION}
+ inputPlaceholder={`Type "${DELETE_CONFIRMATION}" to confirm`}
+ onConfirm={() => {
+ setDeleteDialogOpen(false);
+ handleDeleteAccount();
+ }}
+ onCancel={() => setDeleteDialogOpen(false)}
+ />
+ </div>
+ );
+}
diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo
index 10234a8..3d441ed 100644
--- a/makima/frontend/tsconfig.tsbuildinfo
+++ b/makima/frontend/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/charts/chartrenderer.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemicrophone.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/routes/_index.tsx","./src/routes/files.tsx","./src/routes/listen.tsx","./src/types/messages.ts"],"version":"5.9.3"} \ No newline at end of file
+{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/protectedroute.tsx","./src/components/rewritelink.tsx","./src/components/simplemarkdown.tsx","./src/components/charts/chartrenderer.tsx","./src/components/files/bodyrenderer.tsx","./src/components/files/cliinput.tsx","./src/components/files/conflictnotification.tsx","./src/components/files/elementcontextmenu.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/files/updatenotification.tsx","./src/components/files/versionhistorydropdown.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/components/mesh/directoryinput.tsx","./src/components/mesh/inlinesubtaskeditor.tsx","./src/components/mesh/mergeconflictresolver.tsx","./src/components/mesh/overlaydiffviewer.tsx","./src/components/mesh/prpreview.tsx","./src/components/mesh/subtasktree.tsx","./src/components/mesh/taskdetail.tsx","./src/components/mesh/tasklist.tsx","./src/components/mesh/taskoutput.tsx","./src/components/mesh/unifiedmeshchatinput.tsx","./src/contexts/authcontext.tsx","./src/hooks/usefilesubscription.ts","./src/hooks/usefiles.ts","./src/hooks/usemeshchathistory.ts","./src/hooks/usemicrophone.ts","./src/hooks/usetasksubscription.ts","./src/hooks/usetasks.ts","./src/hooks/usetextscramble.ts","./src/hooks/useversionhistory.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/lib/supabase.ts","./src/routes/_index.tsx","./src/routes/files.tsx","./src/routes/listen.tsx","./src/routes/login.tsx","./src/routes/mesh.tsx","./src/routes/settings.tsx","./src/types/messages.ts"],"version":"5.9.3"} \ No newline at end of file
diff --git a/makima/migrations/20250102000000_create_mesh_tables.sql b/makima/migrations/20250102000000_create_mesh_tables.sql
new file mode 100644
index 0000000..5b01d57
--- /dev/null
+++ b/makima/migrations/20250102000000_create_mesh_tables.sql
@@ -0,0 +1,83 @@
+-- Create tasks table for orchestrating Claude Code instances
+CREATE TABLE IF NOT EXISTS tasks (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ owner_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000002',
+ parent_task_id UUID REFERENCES tasks(id) ON DELETE CASCADE,
+ name VARCHAR(255) NOT NULL,
+ description TEXT,
+ status VARCHAR(32) NOT NULL DEFAULT 'pending', -- pending/running/paused/blocked/done/failed/merged
+ priority INTEGER NOT NULL DEFAULT 0,
+ plan TEXT NOT NULL,
+
+ -- Daemon/container info
+ daemon_id UUID,
+ container_id VARCHAR(255), -- Docker/Podman container ID
+ overlay_path VARCHAR(512), -- Path to overlay upper layer on daemon
+
+ -- Repository info
+ repository_url VARCHAR(512),
+ base_branch VARCHAR(255), -- Branch overlay is based on
+ target_branch VARCHAR(255), -- Branch to merge into when complete
+
+ -- Merge settings
+ merge_mode VARCHAR(32) DEFAULT 'pr', -- 'pr' (create PR), 'auto' (auto-merge), 'manual'
+ pr_url VARCHAR(512), -- URL if PR created
+
+ -- Progress tracking
+ progress_summary TEXT,
+ last_output TEXT,
+ error_message TEXT,
+
+ -- Timestamps
+ started_at TIMESTAMPTZ,
+ completed_at TIMESTAMPTZ,
+ version INTEGER NOT NULL DEFAULT 1,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- Create indexes for tasks
+CREATE INDEX idx_tasks_owner_id ON tasks(owner_id);
+CREATE INDEX idx_tasks_parent_id ON tasks(parent_task_id);
+CREATE INDEX idx_tasks_status ON tasks(status);
+CREATE INDEX idx_tasks_daemon_id ON tasks(daemon_id);
+CREATE INDEX idx_tasks_created_at ON tasks(created_at DESC);
+
+-- Create trigger to auto-update updated_at (reuse existing function)
+CREATE TRIGGER update_tasks_updated_at
+ BEFORE UPDATE ON tasks
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+-- Create task events log for audit and state changes
+CREATE TABLE IF NOT EXISTS task_events (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
+ event_type VARCHAR(64) NOT NULL, -- 'status_change', 'output', 'progress', 'sibling_broadcast', etc.
+ previous_status VARCHAR(32),
+ new_status VARCHAR(32),
+ event_data JSONB, -- Flexible event payload
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_task_events_task_id ON task_events(task_id);
+CREATE INDEX idx_task_events_created_at ON task_events(created_at DESC);
+
+-- Create connected daemons registry
+CREATE TABLE IF NOT EXISTS daemons (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ owner_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000002',
+ connection_id VARCHAR(255) NOT NULL UNIQUE, -- WebSocket session ID
+ hostname VARCHAR(255),
+ machine_id VARCHAR(255), -- For identifying the machine
+ max_concurrent_tasks INTEGER NOT NULL DEFAULT 1,
+ current_task_count INTEGER NOT NULL DEFAULT 0,
+ status VARCHAR(32) NOT NULL DEFAULT 'connected', -- connected, disconnected, unhealthy
+ last_heartbeat_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ connected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ disconnected_at TIMESTAMPTZ
+);
+
+CREATE INDEX idx_daemons_owner_id ON daemons(owner_id);
+CREATE INDEX idx_daemons_status ON daemons(status);
+CREATE INDEX idx_daemons_connection_id ON daemons(connection_id);
diff --git a/makima/migrations/20250104000000_create_mesh_chat_history.sql b/makima/migrations/20250104000000_create_mesh_chat_history.sql
new file mode 100644
index 0000000..2617855
--- /dev/null
+++ b/makima/migrations/20250104000000_create_mesh_chat_history.sql
@@ -0,0 +1,34 @@
+-- Create mesh_chat_conversations table for storing global conversation threads
+CREATE TABLE IF NOT EXISTS mesh_chat_conversations (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ owner_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000002',
+ name VARCHAR(255),
+ is_active BOOLEAN NOT NULL DEFAULT true,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_mesh_chat_conversations_owner ON mesh_chat_conversations(owner_id);
+CREATE INDEX idx_mesh_chat_conversations_active ON mesh_chat_conversations(is_active);
+
+CREATE TRIGGER update_mesh_chat_conversations_updated_at
+ BEFORE UPDATE ON mesh_chat_conversations
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+-- Create mesh_chat_messages table for individual messages
+CREATE TABLE IF NOT EXISTS mesh_chat_messages (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ conversation_id UUID NOT NULL REFERENCES mesh_chat_conversations(id) ON DELETE CASCADE,
+ role VARCHAR(16) NOT NULL CHECK (role IN ('user', 'assistant', 'error')),
+ content TEXT NOT NULL,
+ context_type VARCHAR(32) NOT NULL DEFAULT 'mesh', -- mesh/task/subtask
+ context_task_id UUID REFERENCES tasks(id) ON DELETE SET NULL,
+ tool_calls JSONB,
+ pending_questions JSONB,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX idx_mesh_chat_messages_conversation ON mesh_chat_messages(conversation_id);
+CREATE INDEX idx_mesh_chat_messages_created ON mesh_chat_messages(created_at);
+CREATE INDEX idx_mesh_chat_messages_context_task ON mesh_chat_messages(context_task_id);
diff --git a/makima/migrations/20250106000000_add_task_depth.sql b/makima/migrations/20250106000000_add_task_depth.sql
new file mode 100644
index 0000000..e0fdd03
--- /dev/null
+++ b/makima/migrations/20250106000000_add_task_depth.sql
@@ -0,0 +1,21 @@
+-- Add depth column for task hierarchy (0=top-level, 1=subtask, 2=sub-subtask)
+-- Max depth is 2 (3 levels total)
+
+ALTER TABLE tasks ADD COLUMN depth INTEGER NOT NULL DEFAULT 0;
+ALTER TABLE tasks ADD CONSTRAINT tasks_depth_check CHECK (depth >= 0 AND depth < 3);
+
+-- Backfill existing tasks based on parent chain
+WITH RECURSIVE task_depth AS (
+ SELECT id, parent_task_id, 0 as calculated_depth
+ FROM tasks
+ WHERE parent_task_id IS NULL
+ UNION ALL
+ SELECT t.id, t.parent_task_id, td.calculated_depth + 1
+ FROM tasks t
+ JOIN task_depth td ON t.parent_task_id = td.id
+)
+UPDATE tasks SET depth = task_depth.calculated_depth
+FROM task_depth WHERE tasks.id = task_depth.id;
+
+-- Index for depth queries
+CREATE INDEX idx_tasks_depth ON tasks(depth);
diff --git a/makima/migrations/20250107000000_simplify_task_depth.sql b/makima/migrations/20250107000000_simplify_task_depth.sql
new file mode 100644
index 0000000..73e4ba0
--- /dev/null
+++ b/makima/migrations/20250107000000_simplify_task_depth.sql
@@ -0,0 +1,18 @@
+-- Simplify task hierarchy to only 2 levels: orchestrator (depth 0) and subtasks (depth 1)
+-- Subtasks cannot have their own children
+
+-- First, check for any existing depth-2 tasks and fail if found
+DO $$
+BEGIN
+ IF EXISTS (SELECT 1 FROM tasks WHERE depth >= 2) THEN
+ RAISE EXCEPTION 'Cannot migrate: tasks with depth >= 2 exist. Please flatten or delete these tasks first.';
+ END IF;
+END $$;
+
+-- Drop the old constraint
+ALTER TABLE tasks DROP CONSTRAINT IF EXISTS tasks_depth_check;
+
+-- Add new stricter constraint (max depth 1)
+ALTER TABLE tasks ADD CONSTRAINT tasks_depth_check CHECK (depth >= 0 AND depth < 2);
+
+COMMENT ON COLUMN tasks.depth IS 'Task hierarchy depth: 0=orchestrator (top-level), 1=subtask. Maximum depth is 1.';
diff --git a/makima/migrations/20250108000000_add_completion_actions.sql b/makima/migrations/20250108000000_add_completion_actions.sql
new file mode 100644
index 0000000..ba025cf
--- /dev/null
+++ b/makima/migrations/20250108000000_add_completion_actions.sql
@@ -0,0 +1,13 @@
+-- Add completion action fields to tasks table
+-- These control what happens when a task completes successfully
+
+-- Path to user's local repository (outside ~/.makima)
+-- Overrides daemon's default_target_repo if set
+ALTER TABLE tasks ADD COLUMN target_repo_path VARCHAR(512);
+
+-- Action to perform on completion: "none", "branch", "merge", or "pr"
+-- - branch: Push task branch to target repo (default)
+-- - merge: Auto-merge branch into target_branch in target repo
+-- - pr: Create GitHub pull request
+-- - none: Keep work in worktree only
+ALTER TABLE tasks ADD COLUMN completion_action VARCHAR(32) DEFAULT 'branch';
diff --git a/makima/migrations/20250109000000_add_continue_from_task_id.sql b/makima/migrations/20250109000000_add_continue_from_task_id.sql
new file mode 100644
index 0000000..50bf8ca
--- /dev/null
+++ b/makima/migrations/20250109000000_add_continue_from_task_id.sql
@@ -0,0 +1,11 @@
+-- Add continue_from_task_id column for task continuation
+-- This allows subtasks to start from another task's worktree
+
+ALTER TABLE tasks ADD COLUMN continue_from_task_id UUID REFERENCES tasks(id) ON DELETE SET NULL;
+
+-- Add index for efficient lookup
+CREATE INDEX idx_tasks_continue_from ON tasks(continue_from_task_id) WHERE continue_from_task_id IS NOT NULL;
+
+-- Add copy_files column for copying specific files from parent worktree
+-- This is a JSON array of file paths relative to the worktree root
+ALTER TABLE tasks ADD COLUMN copy_files JSONB DEFAULT NULL;
diff --git a/makima/migrations/20250110000000_create_owners_table.sql b/makima/migrations/20250110000000_create_owners_table.sql
new file mode 100644
index 0000000..7b0d696
--- /dev/null
+++ b/makima/migrations/20250110000000_create_owners_table.sql
@@ -0,0 +1,25 @@
+-- Create owners table for multi-tenant support
+-- Owners are the logical entities that own resources (files, tasks, daemons)
+-- Users belong to owners via groups
+
+CREATE TABLE IF NOT EXISTS owners (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ name VARCHAR(255) NOT NULL,
+ -- 'personal' for individual users, 'organization' for teams
+ owner_type VARCHAR(32) NOT NULL DEFAULT 'personal',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ CONSTRAINT valid_owner_type CHECK (owner_type IN ('personal', 'organization'))
+);
+
+CREATE INDEX idx_owners_owner_type ON owners(owner_type);
+
+-- Trigger to update updated_at timestamp (reuse existing function)
+CREATE TRIGGER update_owners_updated_at
+ BEFORE UPDATE ON owners
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+COMMENT ON TABLE owners IS 'Logical entities that own resources. Users access owners via group membership.';
+COMMENT ON COLUMN owners.owner_type IS 'Type of owner: personal (single user) or organization (team)';
diff --git a/makima/migrations/20250110000001_create_users_table.sql b/makima/migrations/20250110000001_create_users_table.sql
new file mode 100644
index 0000000..cb82807
--- /dev/null
+++ b/makima/migrations/20250110000001_create_users_table.sql
@@ -0,0 +1,27 @@
+-- Create users table as local mirror of Supabase auth.users
+-- This allows faster queries and storing additional user metadata
+
+CREATE TABLE IF NOT EXISTS users (
+ -- Matches Supabase auth.users(id)
+ id UUID PRIMARY KEY,
+ email VARCHAR(255) NOT NULL,
+ display_name VARCHAR(255),
+ avatar_url TEXT,
+ -- User's personal owner (created on signup)
+ default_owner_id UUID REFERENCES owners(id),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE UNIQUE INDEX idx_users_email ON users(email);
+CREATE INDEX idx_users_default_owner_id ON users(default_owner_id);
+
+-- Trigger to update updated_at timestamp (reuse existing function)
+CREATE TRIGGER update_users_updated_at
+ BEFORE UPDATE ON users
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+COMMENT ON TABLE users IS 'Local mirror of Supabase auth.users with additional metadata';
+COMMENT ON COLUMN users.id IS 'Matches Supabase auth.users(id) - set by trigger on signup';
+COMMENT ON COLUMN users.default_owner_id IS 'User personal owner, created automatically on signup';
diff --git a/makima/migrations/20250110000002_create_groups_tables.sql b/makima/migrations/20250110000002_create_groups_tables.sql
new file mode 100644
index 0000000..88e00a1
--- /dev/null
+++ b/makima/migrations/20250110000002_create_groups_tables.sql
@@ -0,0 +1,53 @@
+-- Create groups table for organizing users under an owner
+CREATE TABLE IF NOT EXISTS groups (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ owner_id UUID NOT NULL REFERENCES owners(id) ON DELETE CASCADE,
+ name VARCHAR(255) NOT NULL,
+ description TEXT,
+ -- True for the auto-created personal group on signup
+ is_default BOOLEAN NOT NULL DEFAULT false,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ -- Each owner can only have one group with a given name
+ UNIQUE(owner_id, name)
+);
+
+CREATE INDEX idx_groups_owner_id ON groups(owner_id);
+CREATE INDEX idx_groups_is_default ON groups(is_default) WHERE is_default = true;
+
+CREATE TRIGGER update_groups_updated_at
+ BEFORE UPDATE ON groups
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+-- Create group_members junction table
+CREATE TABLE IF NOT EXISTS group_members (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
+ -- References Supabase auth.users(id) via our users table
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ -- Role within the group
+ role VARCHAR(32) NOT NULL DEFAULT 'member',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ -- Each user can only be in a group once
+ UNIQUE(group_id, user_id),
+
+ CONSTRAINT valid_role CHECK (role IN ('owner', 'admin', 'member'))
+);
+
+CREATE INDEX idx_group_members_group_id ON group_members(group_id);
+CREATE INDEX idx_group_members_user_id ON group_members(user_id);
+CREATE INDEX idx_group_members_role ON group_members(role);
+
+CREATE TRIGGER update_group_members_updated_at
+ BEFORE UPDATE ON group_members
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+COMMENT ON TABLE groups IS 'Groups organize users under an owner for access control';
+COMMENT ON COLUMN groups.is_default IS 'True for the auto-created personal group on signup';
+COMMENT ON TABLE group_members IS 'Junction table for user membership in groups';
+COMMENT ON COLUMN group_members.role IS 'Role in group: owner (full control), admin (manage members), member (access only)';
diff --git a/makima/migrations/20250110000003_create_api_keys_table.sql b/makima/migrations/20250110000003_create_api_keys_table.sql
new file mode 100644
index 0000000..33832e8
--- /dev/null
+++ b/makima/migrations/20250110000003_create_api_keys_table.sql
@@ -0,0 +1,30 @@
+-- Create API keys table for programmatic access (daemons, CLI, integrations)
+CREATE TABLE IF NOT EXISTS api_keys (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ -- SHA-256 hash of the key (never store plain key)
+ key_hash VARCHAR(64) NOT NULL,
+ -- First 8 chars of key for display/identification (e.g., "mk_abc123")
+ key_prefix VARCHAR(16) NOT NULL,
+ -- User-provided label for the key
+ name VARCHAR(255),
+ -- Tracking
+ last_used_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ -- Null if active, timestamp if revoked
+ revoked_at TIMESTAMPTZ
+);
+
+-- Index for key lookup (only active keys)
+CREATE INDEX idx_api_keys_key_hash ON api_keys(key_hash) WHERE revoked_at IS NULL;
+CREATE INDEX idx_api_keys_user_id ON api_keys(user_id);
+CREATE INDEX idx_api_keys_key_prefix ON api_keys(key_prefix);
+
+-- Partial unique index: only one active key per user
+CREATE UNIQUE INDEX idx_api_keys_one_active_per_user
+ ON api_keys(user_id) WHERE revoked_at IS NULL;
+
+COMMENT ON TABLE api_keys IS 'API keys for programmatic access. Max one active key per user.';
+COMMENT ON COLUMN api_keys.key_hash IS 'SHA-256 hash of the full API key';
+COMMENT ON COLUMN api_keys.key_prefix IS 'First 8 chars of key for identification (e.g., "mk_abc123")';
+COMMENT ON COLUMN api_keys.revoked_at IS 'NULL if active, timestamp when revoked';
diff --git a/makima/migrations/20250110000004_create_api_key_events_table.sql b/makima/migrations/20250110000004_create_api_key_events_table.sql
new file mode 100644
index 0000000..cb07bb3
--- /dev/null
+++ b/makima/migrations/20250110000004_create_api_key_events_table.sql
@@ -0,0 +1,20 @@
+-- Audit log for API key operations
+CREATE TABLE IF NOT EXISTS api_key_events (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ api_key_id UUID NOT NULL REFERENCES api_keys(id) ON DELETE CASCADE,
+ event_type VARCHAR(32) NOT NULL,
+ -- Request metadata
+ ip_address INET,
+ user_agent TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+
+ CONSTRAINT valid_event_type CHECK (
+ event_type IN ('created', 'used', 'revoked', 'refreshed')
+ )
+);
+
+CREATE INDEX idx_api_key_events_api_key_id ON api_key_events(api_key_id);
+CREATE INDEX idx_api_key_events_created_at ON api_key_events(created_at DESC);
+CREATE INDEX idx_api_key_events_event_type ON api_key_events(event_type);
+
+COMMENT ON TABLE api_key_events IS 'Audit log for API key lifecycle events';
diff --git a/makima/migrations/20250110000005_create_placeholder_owners.sql b/makima/migrations/20250110000005_create_placeholder_owners.sql
new file mode 100644
index 0000000..05c946e
--- /dev/null
+++ b/makima/migrations/20250110000005_create_placeholder_owners.sql
@@ -0,0 +1,18 @@
+-- Create placeholder owners for existing data with hardcoded owner_ids
+-- These will be migrated to real owners when users sign up
+
+-- Note: The existing codebase uses the same UUID (00000000-0000-0000-0000-000000000002)
+-- for both files, tasks, and daemons based on the existing migrations.
+
+-- Placeholder owner for existing legacy data (used in files, tasks, daemons)
+INSERT INTO owners (id, name, owner_type)
+VALUES ('00000000-0000-0000-0000-000000000002', 'Legacy Owner', 'personal')
+ON CONFLICT (id) DO NOTHING;
+
+-- Additional placeholder in case files migration used different ID
+-- (keeping both for backward compatibility)
+INSERT INTO owners (id, name, owner_type)
+VALUES ('00000000-0000-0000-0000-000000000001', 'Legacy Files Owner', 'personal')
+ON CONFLICT (id) DO NOTHING;
+
+COMMENT ON TABLE owners IS 'NOTE: IDs 00000000-0000-0000-0000-00000000000[12] are legacy placeholders';
diff --git a/makima/migrations/20250110000006_add_owner_foreign_keys.sql b/makima/migrations/20250110000006_add_owner_foreign_keys.sql
new file mode 100644
index 0000000..c4fb53a
--- /dev/null
+++ b/makima/migrations/20250110000006_add_owner_foreign_keys.sql
@@ -0,0 +1,30 @@
+-- Add foreign key constraints to existing tables
+-- This links all data to the owners table
+-- NOTE: This migration depends on placeholder owners existing (migration 005)
+
+-- Files table
+ALTER TABLE files
+ ADD CONSTRAINT fk_files_owner
+ FOREIGN KEY (owner_id) REFERENCES owners(id);
+
+-- Tasks table
+ALTER TABLE tasks
+ ADD CONSTRAINT fk_tasks_owner
+ FOREIGN KEY (owner_id) REFERENCES owners(id);
+
+-- Daemons table
+ALTER TABLE daemons
+ ADD CONSTRAINT fk_daemons_owner
+ FOREIGN KEY (owner_id) REFERENCES owners(id);
+
+-- Mesh chat conversations table
+ALTER TABLE mesh_chat_conversations
+ ADD CONSTRAINT fk_mesh_chat_conversations_owner
+ FOREIGN KEY (owner_id) REFERENCES owners(id);
+
+-- Verify indexes exist (they should from original migrations)
+-- If not, add them for query performance:
+CREATE INDEX IF NOT EXISTS idx_files_owner_id ON files(owner_id);
+CREATE INDEX IF NOT EXISTS idx_tasks_owner_id ON tasks(owner_id);
+CREATE INDEX IF NOT EXISTS idx_daemons_owner_id ON daemons(owner_id);
+CREATE INDEX IF NOT EXISTS idx_mesh_chat_conversations_owner_id ON mesh_chat_conversations(owner_id);
diff --git a/makima/migrations/20250110000007_create_auth_trigger.sql b/makima/migrations/20250110000007_create_auth_trigger.sql
new file mode 100644
index 0000000..3b3fec9
--- /dev/null
+++ b/makima/migrations/20250110000007_create_auth_trigger.sql
@@ -0,0 +1,63 @@
+-- Function to handle new user signup from Supabase Auth
+-- This runs when a user is inserted into auth.users
+
+CREATE OR REPLACE FUNCTION public.handle_new_user()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+SECURITY DEFINER -- Run with elevated privileges
+SET search_path = public
+AS $$
+DECLARE
+ new_owner_id UUID;
+ new_group_id UUID;
+ user_name TEXT;
+BEGIN
+ -- Extract display name from metadata, fallback to email
+ user_name := COALESCE(
+ NEW.raw_user_meta_data->>'full_name',
+ NEW.raw_user_meta_data->>'name',
+ split_part(NEW.email, '@', 1)
+ );
+
+ -- Create personal owner for this user
+ INSERT INTO public.owners (name, owner_type)
+ VALUES (user_name, 'personal')
+ RETURNING id INTO new_owner_id;
+
+ -- Create local user record
+ INSERT INTO public.users (id, email, display_name, default_owner_id)
+ VALUES (
+ NEW.id,
+ NEW.email,
+ user_name,
+ new_owner_id
+ );
+
+ -- Create default group for personal owner
+ INSERT INTO public.groups (owner_id, name, is_default)
+ VALUES (new_owner_id, 'Personal', true)
+ RETURNING id INTO new_group_id;
+
+ -- Add user as owner of their personal group
+ INSERT INTO public.group_members (group_id, user_id, role)
+ VALUES (new_group_id, NEW.id, 'owner');
+
+ RETURN NEW;
+EXCEPTION WHEN OTHERS THEN
+ -- Log error but don't fail the signup
+ RAISE WARNING 'handle_new_user failed for %: %', NEW.id, SQLERRM;
+ RETURN NEW;
+END;
+$$;
+
+-- NOTE: The trigger on auth.users must be created in the Supabase dashboard
+-- or via supabase CLI because we can't directly access auth schema in migrations.
+--
+-- Run this SQL in Supabase SQL Editor to create the trigger:
+--
+-- CREATE TRIGGER on_auth_user_created
+-- AFTER INSERT ON auth.users
+-- FOR EACH ROW
+-- EXECUTE FUNCTION public.handle_new_user();
+
+COMMENT ON FUNCTION handle_new_user IS 'Creates owner, user, group, and membership when a new user signs up via Supabase Auth';
diff --git a/makima/migrations/20250110000008_remove_owner_defaults.sql b/makima/migrations/20250110000008_remove_owner_defaults.sql
new file mode 100644
index 0000000..307f077
--- /dev/null
+++ b/makima/migrations/20250110000008_remove_owner_defaults.sql
@@ -0,0 +1,46 @@
+-- Remove default owner_id values from tables
+-- This should be done after application code is updated to always provide owner_id
+
+-- IMPORTANT: Only apply this migration after updating all code to provide owner_id
+-- Otherwise, inserts will fail
+
+-- Remove default values (they are no longer needed since code provides owner_id)
+ALTER TABLE files
+ ALTER COLUMN owner_id DROP DEFAULT;
+
+ALTER TABLE tasks
+ ALTER COLUMN owner_id DROP DEFAULT;
+
+ALTER TABLE daemons
+ ALTER COLUMN owner_id DROP DEFAULT;
+
+ALTER TABLE mesh_chat_conversations
+ ALTER COLUMN owner_id DROP DEFAULT;
+
+-- Make owner_id NOT NULL explicit (should already be, but ensure)
+-- Note: These columns were created with NOT NULL, so this is a no-op validation
+-- The SET NOT NULL will succeed if all existing rows have non-null values
+
+-- Files already has NOT NULL in original migration (20241222000000)
+-- Tasks already has NOT NULL in original migration (20250102000000)
+-- Daemons already has NOT NULL in original migration (20250102000000)
+-- Mesh chat conversations already has NOT NULL in original migration (20250104000000)
+
+-- However, we explicitly restate it for clarity and documentation
+ALTER TABLE files
+ ALTER COLUMN owner_id SET NOT NULL;
+
+ALTER TABLE tasks
+ ALTER COLUMN owner_id SET NOT NULL;
+
+ALTER TABLE daemons
+ ALTER COLUMN owner_id SET NOT NULL;
+
+ALTER TABLE mesh_chat_conversations
+ ALTER COLUMN owner_id SET NOT NULL;
+
+-- Add comments documenting the constraint
+COMMENT ON COLUMN files.owner_id IS 'Owner ID is required - no default value, must be provided by application';
+COMMENT ON COLUMN tasks.owner_id IS 'Owner ID is required - no default value, must be provided by application';
+COMMENT ON COLUMN daemons.owner_id IS 'Owner ID is required - no default value, must be provided by application';
+COMMENT ON COLUMN mesh_chat_conversations.owner_id IS 'Owner ID is required - no default value, must be provided by application';
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs
index 617e590..5064b97 100644
--- a/makima/src/db/models.rs
+++ b/makima/src/db/models.rs
@@ -36,6 +36,16 @@ pub enum BodyElement {
Heading { level: u8, text: String },
/// Paragraph text
Paragraph { text: String },
+ /// Code block with optional language
+ Code {
+ language: Option<String>,
+ content: String,
+ },
+ /// List (ordered or unordered)
+ List {
+ ordered: bool,
+ items: Vec<String>,
+ },
/// Chart visualization
Chart {
#[serde(rename = "chartType")]
@@ -245,3 +255,582 @@ pub struct RestoreVersionRequest {
/// The current version (for optimistic locking)
pub current_version: i32,
}
+
+// =============================================================================
+// Mesh/Task Types
+// =============================================================================
+
+/// Task status for orchestrating Claude Code instances
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "lowercase")]
+pub enum TaskStatus {
+ Pending,
+ Running,
+ Paused,
+ Blocked,
+ Done,
+ Failed,
+ Merged,
+}
+
+impl std::fmt::Display for TaskStatus {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ TaskStatus::Pending => write!(f, "pending"),
+ TaskStatus::Running => write!(f, "running"),
+ TaskStatus::Paused => write!(f, "paused"),
+ TaskStatus::Blocked => write!(f, "blocked"),
+ TaskStatus::Done => write!(f, "done"),
+ TaskStatus::Failed => write!(f, "failed"),
+ TaskStatus::Merged => write!(f, "merged"),
+ }
+ }
+}
+
+impl std::str::FromStr for TaskStatus {
+ type Err = String;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s.to_lowercase().as_str() {
+ "pending" => Ok(TaskStatus::Pending),
+ "running" => Ok(TaskStatus::Running),
+ "paused" => Ok(TaskStatus::Paused),
+ "blocked" => Ok(TaskStatus::Blocked),
+ "done" => Ok(TaskStatus::Done),
+ "failed" => Ok(TaskStatus::Failed),
+ "merged" => Ok(TaskStatus::Merged),
+ _ => Err(format!("Unknown task status: {}", s)),
+ }
+ }
+}
+
+/// Merge mode for task completion
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "lowercase")]
+pub enum MergeMode {
+ /// Create a PR for review
+ Pr,
+ /// Auto-merge to target branch
+ Auto,
+ /// Manual merge by user
+ Manual,
+}
+
+impl std::fmt::Display for MergeMode {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ MergeMode::Pr => write!(f, "pr"),
+ MergeMode::Auto => write!(f, "auto"),
+ MergeMode::Manual => write!(f, "manual"),
+ }
+ }
+}
+
+impl std::str::FromStr for MergeMode {
+ type Err = String;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s.to_lowercase().as_str() {
+ "pr" => Ok(MergeMode::Pr),
+ "auto" => Ok(MergeMode::Auto),
+ "manual" => Ok(MergeMode::Manual),
+ _ => Err(format!("Unknown merge mode: {}", s)),
+ }
+ }
+}
+
+/// Task record from the database
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct Task {
+ pub id: Uuid,
+ pub owner_id: Uuid,
+ pub parent_task_id: Option<Uuid>,
+ /// Depth in task hierarchy: 0=orchestrator (top-level), 1=subtask (max)
+ pub depth: i32,
+ pub name: String,
+ pub description: Option<String>,
+ pub status: String,
+ pub priority: i32,
+ pub plan: String,
+
+ // Daemon/container info
+ pub daemon_id: Option<Uuid>,
+ pub container_id: Option<String>,
+ pub overlay_path: Option<String>,
+
+ // Repository info
+ pub repository_url: Option<String>,
+ pub base_branch: Option<String>,
+ pub target_branch: Option<String>,
+
+ // Merge settings
+ pub merge_mode: Option<String>,
+ pub pr_url: Option<String>,
+
+ // Completion action settings
+ /// Path to user's local repository (outside ~/.makima)
+ pub target_repo_path: Option<String>,
+ /// Action on completion: "none", "branch", "merge", "pr"
+ pub completion_action: Option<String>,
+
+ // Progress tracking
+ pub progress_summary: Option<String>,
+ pub last_output: Option<String>,
+ pub error_message: Option<String>,
+
+ // Timestamps
+ pub started_at: Option<DateTime<Utc>>,
+ pub completed_at: Option<DateTime<Utc>>,
+ pub version: i32,
+ pub created_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+
+ // Task continuation
+ /// Task ID to continue from (copy worktree from this task when starting).
+ /// Used for sequential subtask dependencies.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub continue_from_task_id: Option<Uuid>,
+ /// Files to copy from parent task's worktree when starting.
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub copy_files: Option<serde_json::Value>,
+}
+
+impl Task {
+ /// Parse status string to TaskStatus enum
+ pub fn status_enum(&self) -> Result<TaskStatus, String> {
+ self.status.parse()
+ }
+
+ /// Parse merge_mode string to MergeMode enum
+ pub fn merge_mode_enum(&self) -> Option<Result<MergeMode, String>> {
+ self.merge_mode.as_ref().map(|s| s.parse())
+ }
+}
+
+/// Summary of a task for list views
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct TaskSummary {
+ pub id: Uuid,
+ pub parent_task_id: Option<Uuid>,
+ /// Depth in task hierarchy: 0=orchestrator (top-level), 1=subtask (max)
+ pub depth: i32,
+ pub name: String,
+ pub status: String,
+ pub priority: i32,
+ pub progress_summary: Option<String>,
+ pub subtask_count: i64,
+ pub version: i32,
+ pub created_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+/// Response for task list endpoint
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct TaskListResponse {
+ pub tasks: Vec<TaskSummary>,
+ pub total: i64,
+}
+
+/// Request payload for creating a new task
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateTaskRequest {
+ /// Name of the task
+ pub name: String,
+ /// Optional description
+ pub description: Option<String>,
+ /// The plan/instructions for Claude Code
+ pub plan: String,
+ /// Parent task ID (for subtasks)
+ pub parent_task_id: Option<Uuid>,
+ /// Priority (higher = more urgent)
+ #[serde(default)]
+ pub priority: i32,
+ /// Repository URL
+ pub repository_url: Option<String>,
+ /// Base branch for overlay
+ pub base_branch: Option<String>,
+ /// Target branch to merge into
+ pub target_branch: Option<String>,
+ /// Merge mode (pr, auto, manual)
+ pub merge_mode: Option<String>,
+ /// Path to user's local repository (outside ~/.makima)
+ pub target_repo_path: Option<String>,
+ /// Action on completion: "none", "branch", "merge", "pr"
+ pub completion_action: Option<String>,
+ /// Task ID to continue from (copy worktree from this task when starting)
+ pub continue_from_task_id: Option<Uuid>,
+ /// Files to copy from parent task's worktree when starting
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub copy_files: Option<Vec<String>>,
+}
+
+/// Request payload for updating a task
+#[derive(Debug, Default, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateTaskRequest {
+ pub name: Option<String>,
+ pub description: Option<String>,
+ pub plan: Option<String>,
+ pub status: Option<String>,
+ pub priority: Option<i32>,
+ pub progress_summary: Option<String>,
+ pub last_output: Option<String>,
+ pub error_message: Option<String>,
+ pub merge_mode: Option<String>,
+ pub pr_url: Option<String>,
+ /// Path to user's local repository (outside ~/.makima)
+ pub target_repo_path: Option<String>,
+ /// Action on completion: "none", "branch", "merge", "pr"
+ pub completion_action: Option<String>,
+ /// The daemon currently running this task
+ pub daemon_id: Option<Uuid>,
+ /// Explicitly clear daemon_id (set to NULL)
+ #[serde(default)]
+ pub clear_daemon_id: bool,
+ /// Version for optimistic locking
+ pub version: Option<i32>,
+}
+
+/// Task with its subtasks for detail view
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct TaskWithSubtasks {
+ #[serde(flatten)]
+ pub task: Task,
+ pub subtasks: Vec<TaskSummary>,
+}
+
+/// Request to send a message to a running task's stdin.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct SendMessageRequest {
+ /// The message to send to the task's stdin.
+ pub message: String,
+}
+
+// =============================================================================
+// Daemon Types
+// =============================================================================
+
+/// Daemon status
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "lowercase")]
+pub enum DaemonStatus {
+ Connected,
+ Disconnected,
+ Unhealthy,
+}
+
+impl std::fmt::Display for DaemonStatus {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ DaemonStatus::Connected => write!(f, "connected"),
+ DaemonStatus::Disconnected => write!(f, "disconnected"),
+ DaemonStatus::Unhealthy => write!(f, "unhealthy"),
+ }
+ }
+}
+
+impl std::str::FromStr for DaemonStatus {
+ type Err = String;
+
+ fn from_str(s: &str) -> Result<Self, Self::Err> {
+ match s.to_lowercase().as_str() {
+ "connected" => Ok(DaemonStatus::Connected),
+ "disconnected" => Ok(DaemonStatus::Disconnected),
+ "unhealthy" => Ok(DaemonStatus::Unhealthy),
+ _ => Err(format!("Unknown daemon status: {}", s)),
+ }
+ }
+}
+
+/// Connected daemon record from the database
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct Daemon {
+ pub id: Uuid,
+ pub owner_id: Uuid,
+ pub connection_id: String,
+ pub hostname: Option<String>,
+ pub machine_id: Option<String>,
+ pub max_concurrent_tasks: i32,
+ pub current_task_count: i32,
+ pub status: String,
+ pub last_heartbeat_at: DateTime<Utc>,
+ pub connected_at: DateTime<Utc>,
+ pub disconnected_at: Option<DateTime<Utc>>,
+}
+
+impl Daemon {
+ /// Parse status string to DaemonStatus enum
+ pub fn status_enum(&self) -> Result<DaemonStatus, String> {
+ self.status.parse()
+ }
+}
+
+/// Response for daemon list endpoint
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct DaemonListResponse {
+ pub daemons: Vec<Daemon>,
+ pub total: i64,
+}
+
+/// Response for daemon directories endpoint
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct DaemonDirectoriesResponse {
+ /// List of suggested directories from connected daemons
+ pub directories: Vec<DaemonDirectory>,
+}
+
+/// A suggested directory from a daemon
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct DaemonDirectory {
+ /// Path to the directory
+ pub path: String,
+ /// Display label for the directory
+ pub label: String,
+ /// Type of directory: "working", "makima", "worktrees"
+ pub directory_type: String,
+ /// Daemon hostname this directory is from
+ pub hostname: Option<String>,
+ /// Whether the directory already exists (for validation)
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub exists: Option<bool>,
+}
+
+// =============================================================================
+// Task Event Types
+// =============================================================================
+
+/// Task event record from the database
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct TaskEvent {
+ pub id: Uuid,
+ pub task_id: Uuid,
+ pub event_type: String,
+ pub previous_status: Option<String>,
+ pub new_status: Option<String>,
+ #[sqlx(json)]
+ pub event_data: Option<serde_json::Value>,
+ pub created_at: DateTime<Utc>,
+}
+
+/// Response for task events list endpoint
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct TaskEventListResponse {
+ pub events: Vec<TaskEvent>,
+ pub total: i64,
+}
+
+/// A single output entry from a Claude Code task
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct TaskOutputEntry {
+ pub id: Uuid,
+ pub task_id: Uuid,
+ /// Message type: "assistant", "tool_use", "tool_result", "result", "system", "error", "raw"
+ pub message_type: String,
+ /// Main text content
+ pub content: String,
+ /// Tool name if tool_use message
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub tool_name: Option<String>,
+ /// Tool input JSON if tool_use message
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub tool_input: Option<serde_json::Value>,
+ /// Whether tool result was an error
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub is_error: Option<bool>,
+ /// Cost in USD if result message
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub cost_usd: Option<f64>,
+ /// Duration in ms if result message
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub duration_ms: Option<u64>,
+ /// Timestamp when this output was recorded
+ pub created_at: DateTime<Utc>,
+}
+
+impl TaskOutputEntry {
+ /// Convert a TaskEvent with event_type='output' to a TaskOutputEntry
+ pub fn from_task_event(event: TaskEvent) -> Option<Self> {
+ if event.event_type != "output" {
+ return None;
+ }
+ let data = event.event_data?;
+ Some(Self {
+ id: event.id,
+ task_id: event.task_id,
+ message_type: data.get("messageType")?.as_str()?.to_string(),
+ content: data.get("content")?.as_str().unwrap_or("").to_string(),
+ tool_name: data.get("toolName").and_then(|v| v.as_str()).map(|s| s.to_string()),
+ tool_input: data.get("toolInput").cloned(),
+ is_error: data.get("isError").and_then(|v| v.as_bool()),
+ cost_usd: data.get("costUsd").and_then(|v| v.as_f64()),
+ duration_ms: data.get("durationMs").and_then(|v| v.as_u64()),
+ created_at: event.created_at,
+ })
+ }
+}
+
+/// Response for task output history endpoint
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct TaskOutputResponse {
+ pub entries: Vec<TaskOutputEntry>,
+ pub total: usize,
+ pub task_id: Uuid,
+}
+
+// =============================================================================
+// Mesh Chat History Types
+// =============================================================================
+
+/// Mesh chat conversation for persisting history
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct MeshChatConversation {
+ pub id: Uuid,
+ pub owner_id: Uuid,
+ pub name: Option<String>,
+ pub is_active: bool,
+ pub created_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+/// Individual message in a mesh chat conversation
+#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct MeshChatMessageRecord {
+ pub id: Uuid,
+ pub conversation_id: Uuid,
+ pub role: String,
+ pub content: String,
+ pub context_type: String,
+ pub context_task_id: Option<Uuid>,
+ /// Tool calls made during this message (JSON, nullable)
+ pub tool_calls: Option<serde_json::Value>,
+ /// Pending questions requiring user response (JSON, nullable)
+ pub pending_questions: Option<serde_json::Value>,
+ pub created_at: DateTime<Utc>,
+}
+
+/// Response for chat history endpoint
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct MeshChatHistoryResponse {
+ pub conversation_id: Uuid,
+ pub messages: Vec<MeshChatMessageRecord>,
+}
+
+// =============================================================================
+// Merge API Types
+// =============================================================================
+
+/// Information about a task branch
+#[derive(Debug, Clone, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct BranchInfo {
+ /// Full branch name
+ pub name: String,
+ /// Task ID extracted from branch name (if parseable)
+ pub task_id: Option<Uuid>,
+ /// Whether this branch has been merged
+ pub is_merged: bool,
+ /// Short SHA of the last commit
+ pub last_commit: String,
+ /// Subject line of the last commit
+ pub last_commit_message: String,
+}
+
+/// Response for branch list endpoint
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct BranchListResponse {
+ pub branches: Vec<BranchInfo>,
+}
+
+/// Request to start a merge
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct MergeStartRequest {
+ /// Branch name to merge
+ pub source_branch: String,
+}
+
+/// Current merge state
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct MergeStatusResponse {
+ /// Whether a merge is in progress
+ pub in_progress: bool,
+ /// Branch being merged (if in progress)
+ pub source_branch: Option<String>,
+ /// Files with unresolved conflicts
+ pub conflicted_files: Vec<String>,
+}
+
+/// Request to resolve a conflict
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct MergeResolveRequest {
+ /// File path to resolve
+ pub file: String,
+ /// Resolution strategy: "ours" or "theirs"
+ pub strategy: String,
+}
+
+/// Request to commit a merge
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct MergeCommitRequest {
+ /// Commit message
+ pub message: String,
+}
+
+/// Request to skip a subtask branch
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct MergeSkipRequest {
+ /// Subtask ID to skip
+ pub subtask_id: Uuid,
+ /// Reason for skipping
+ pub reason: String,
+}
+
+/// Result of a merge operation
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct MergeResultResponse {
+ /// Whether the operation succeeded
+ pub success: bool,
+ /// Human-readable message
+ pub message: String,
+ /// Commit SHA (if a commit was created)
+ pub commit_sha: Option<String>,
+ /// Conflicted files (if conflicts occurred)
+ pub conflicts: Option<Vec<String>>,
+}
+
+/// Response to check if all branches are merged
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct MergeCompleteCheckResponse {
+ /// Whether the orchestrator can mark itself as complete
+ pub can_complete: bool,
+ /// Branches not yet merged or skipped
+ pub unmerged_branches: Vec<String>,
+ /// Count of merged branches
+ pub merged_count: u32,
+ /// Count of skipped branches
+ pub skipped_count: u32,
+}
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs
index 4137ba6..ce1e97d 100644
--- a/makima/src/db/repository.rs
+++ b/makima/src/db/repository.rs
@@ -4,10 +4,10 @@ use chrono::Utc;
use sqlx::PgPool;
use uuid::Uuid;
-use super::models::{CreateFileRequest, File, FileVersion, UpdateFileRequest};
-
-/// Default owner ID for anonymous users.
-pub const ANONYMOUS_OWNER_ID: Uuid = Uuid::from_u128(0x00000000_0000_0000_0000_000000000002);
+use super::models::{
+ CreateFileRequest, CreateTaskRequest, Daemon, File, FileVersion, MeshChatConversation,
+ MeshChatMessageRecord, Task, TaskEvent, TaskSummary, UpdateFileRequest, UpdateTaskRequest,
+};
/// Repository error types.
#[derive(Debug)]
@@ -60,12 +60,11 @@ pub async fn create_file(pool: &PgPool, req: CreateFileRequest) -> Result<File,
sqlx::query_as::<_, File>(
r#"
- INSERT INTO files (owner_id, name, description, transcript, location, summary, body)
- VALUES ($1, $2, $3, $4, $5, NULL, $6)
+ INSERT INTO files (name, description, transcript, location, summary, body)
+ VALUES ($1, $2, $3, $4, NULL, $5)
RETURNING id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at
"#,
)
- .bind(ANONYMOUS_OWNER_ID)
.bind(&name)
.bind(&req.description)
.bind(&transcript_json)
@@ -81,26 +80,23 @@ pub async fn get_file(pool: &PgPool, id: Uuid) -> Result<Option<File>, sqlx::Err
r#"
SELECT id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at
FROM files
- WHERE id = $1 AND owner_id = $2
+ WHERE id = $1
"#,
)
.bind(id)
- .bind(ANONYMOUS_OWNER_ID)
.fetch_optional(pool)
.await
}
-/// List all files for the owner, ordered by created_at DESC.
+/// List all files, ordered by created_at DESC.
pub async fn list_files(pool: &PgPool) -> Result<Vec<File>, sqlx::Error> {
sqlx::query_as::<_, File>(
r#"
SELECT id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at
FROM files
- WHERE owner_id = $1
ORDER BY created_at DESC
"#,
)
- .bind(ANONYMOUS_OWNER_ID)
.fetch_all(pool)
.await
}
@@ -146,13 +142,12 @@ pub async fn update_file(
sqlx::query_as::<_, File>(
r#"
UPDATE files
- SET name = $3, description = $4, transcript = $5, summary = $6, body = $7, updated_at = NOW()
- WHERE id = $1 AND owner_id = $2 AND version = $8
+ SET name = $2, description = $3, transcript = $4, summary = $5, body = $6, updated_at = NOW()
+ WHERE id = $1 AND version = $7
RETURNING id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at
"#,
)
.bind(id)
- .bind(ANONYMOUS_OWNER_ID)
.bind(&name)
.bind(&description)
.bind(&transcript_json)
@@ -166,13 +161,12 @@ pub async fn update_file(
sqlx::query_as::<_, File>(
r#"
UPDATE files
- SET name = $3, description = $4, transcript = $5, summary = $6, body = $7, updated_at = NOW()
- WHERE id = $1 AND owner_id = $2
+ SET name = $2, description = $3, transcript = $4, summary = $5, body = $6, updated_at = NOW()
+ WHERE id = $1
RETURNING id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at
"#,
)
.bind(id)
- .bind(ANONYMOUS_OWNER_ID)
.bind(&name)
.bind(&description)
.bind(&transcript_json)
@@ -201,21 +195,19 @@ pub async fn delete_file(pool: &PgPool, id: Uuid) -> Result<bool, sqlx::Error> {
let result = sqlx::query(
r#"
DELETE FROM files
- WHERE id = $1 AND owner_id = $2
+ WHERE id = $1
"#,
)
.bind(id)
- .bind(ANONYMOUS_OWNER_ID)
.execute(pool)
.await?;
Ok(result.rows_affected() > 0)
}
-/// Count total files for owner.
+/// Count total files.
pub async fn count_files(pool: &PgPool) -> Result<i64, sqlx::Error> {
- let result: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM files WHERE owner_id = $1")
- .bind(ANONYMOUS_OWNER_ID)
+ let result: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM files")
.fetch_one(pool)
.await?;
@@ -223,6 +215,178 @@ pub async fn count_files(pool: &PgPool) -> Result<i64, sqlx::Error> {
}
// =============================================================================
+// Owner-Scoped File Functions
+// =============================================================================
+
+/// Create a new file record for a specific owner.
+pub async fn create_file_for_owner(
+ pool: &PgPool,
+ owner_id: Uuid,
+ req: CreateFileRequest,
+) -> Result<File, sqlx::Error> {
+ let name = req.name.unwrap_or_else(generate_default_name);
+ let transcript_json = serde_json::to_value(&req.transcript).unwrap_or_default();
+ let body_json = serde_json::to_value::<Vec<super::models::BodyElement>>(vec![]).unwrap();
+
+ sqlx::query_as::<_, File>(
+ r#"
+ INSERT INTO files (owner_id, name, description, transcript, location, summary, body)
+ VALUES ($1, $2, $3, $4, $5, NULL, $6)
+ RETURNING id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at
+ "#,
+ )
+ .bind(owner_id)
+ .bind(&name)
+ .bind(&req.description)
+ .bind(&transcript_json)
+ .bind(&req.location)
+ .bind(&body_json)
+ .fetch_one(pool)
+ .await
+}
+
+/// Get a file by ID, scoped to owner.
+pub async fn get_file_for_owner(
+ pool: &PgPool,
+ id: Uuid,
+ owner_id: Uuid,
+) -> Result<Option<File>, sqlx::Error> {
+ sqlx::query_as::<_, File>(
+ r#"
+ SELECT id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at
+ FROM files
+ WHERE id = $1 AND owner_id = $2
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .fetch_optional(pool)
+ .await
+}
+
+/// List all files for an owner, ordered by created_at DESC.
+pub async fn list_files_for_owner(pool: &PgPool, owner_id: Uuid) -> Result<Vec<File>, sqlx::Error> {
+ sqlx::query_as::<_, File>(
+ r#"
+ SELECT id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at
+ FROM files
+ WHERE owner_id = $1
+ ORDER BY created_at DESC
+ "#,
+ )
+ .bind(owner_id)
+ .fetch_all(pool)
+ .await
+}
+
+/// Update a file by ID with optimistic locking, scoped to owner.
+pub async fn update_file_for_owner(
+ pool: &PgPool,
+ id: Uuid,
+ owner_id: Uuid,
+ req: UpdateFileRequest,
+) -> Result<Option<File>, RepositoryError> {
+ // Get the existing file first (scoped to owner)
+ let existing = get_file_for_owner(pool, id, owner_id).await?;
+ let Some(existing) = existing else {
+ return Ok(None);
+ };
+
+ // Check version if provided (optimistic locking)
+ if let Some(expected_version) = req.version {
+ if existing.version != expected_version {
+ return Err(RepositoryError::VersionConflict {
+ expected: expected_version,
+ actual: existing.version,
+ });
+ }
+ }
+
+ // Apply updates
+ let name = req.name.unwrap_or(existing.name);
+ let description = req.description.or(existing.description);
+ let transcript = req.transcript.unwrap_or(existing.transcript);
+ let transcript_json = serde_json::to_value(&transcript).unwrap_or_default();
+ let summary = req.summary.or(existing.summary);
+ let body = req.body.unwrap_or(existing.body);
+ let body_json = serde_json::to_value(&body).unwrap_or_default();
+
+ // Update with version check in WHERE clause for race condition safety
+ let result = if req.version.is_some() {
+ sqlx::query_as::<_, File>(
+ r#"
+ UPDATE files
+ SET name = $3, description = $4, transcript = $5, summary = $6, body = $7, updated_at = NOW()
+ WHERE id = $1 AND owner_id = $2 AND version = $8
+ RETURNING id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .bind(&name)
+ .bind(&description)
+ .bind(&transcript_json)
+ .bind(&summary)
+ .bind(&body_json)
+ .bind(req.version.unwrap())
+ .fetch_optional(pool)
+ .await?
+ } else {
+ // No version check for internal updates
+ sqlx::query_as::<_, File>(
+ r#"
+ UPDATE files
+ SET name = $3, description = $4, transcript = $5, summary = $6, body = $7, updated_at = NOW()
+ WHERE id = $1 AND owner_id = $2
+ RETURNING id, owner_id, name, description, transcript, location, summary, body, version, created_at, updated_at
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .bind(&name)
+ .bind(&description)
+ .bind(&transcript_json)
+ .bind(&summary)
+ .bind(&body_json)
+ .fetch_optional(pool)
+ .await?
+ };
+
+ // If versioned update returned None, there was a race condition
+ if result.is_none() && req.version.is_some() {
+ // Re-fetch to get the actual version
+ if let Some(current) = get_file_for_owner(pool, id, owner_id).await? {
+ return Err(RepositoryError::VersionConflict {
+ expected: req.version.unwrap(),
+ actual: current.version,
+ });
+ }
+ }
+
+ Ok(result)
+}
+
+/// Delete a file by ID, scoped to owner.
+pub async fn delete_file_for_owner(
+ pool: &PgPool,
+ id: Uuid,
+ owner_id: Uuid,
+) -> Result<bool, sqlx::Error> {
+ let result = sqlx::query(
+ r#"
+ DELETE FROM files
+ WHERE id = $1 AND owner_id = $2
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+}
+
+// =============================================================================
// Version History Functions
// =============================================================================
@@ -363,3 +527,1186 @@ pub async fn count_file_versions(pool: &PgPool, file_id: Uuid) -> Result<i64, sq
Ok(result.0)
}
+
+// =============================================================================
+// Task Functions
+// =============================================================================
+
+/// Create a new task.
+///
+/// If creating a subtask (parent_task_id is set) and repository settings are not provided,
+/// the subtask will inherit repository_url, base_branch, target_branch, merge_mode,
+/// and target_repo_path from the parent task. Depth is calculated from parent and limited
+/// to max 1 (2 levels: orchestrator at depth 0, subtasks at depth 1).
+///
+/// NOTE: completion_action is NOT inherited - subtasks should not auto-merge unless
+/// explicitly configured. The orchestrator controls when completion steps happen.
+pub async fn create_task(pool: &PgPool, req: CreateTaskRequest) -> Result<Task, sqlx::Error> {
+ // Calculate depth and inherit settings from parent if applicable
+ let (depth, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action) =
+ if let Some(parent_id) = req.parent_task_id {
+ // Fetch parent task to get depth and inherit repo settings
+ let parent = get_task(pool, parent_id).await?
+ .ok_or_else(|| sqlx::Error::RowNotFound)?;
+
+ let new_depth = parent.depth + 1;
+
+ // Validate max depth (must be < 2, i.e., 0 or 1 only)
+ // Orchestrators are at depth 0, subtasks at depth 1
+ // Subtasks cannot have their own children
+ if new_depth >= 2 {
+ return Err(sqlx::Error::Protocol(format!(
+ "Maximum task depth exceeded. Cannot create subtask at depth {} (max is 1). Subtasks cannot have children.",
+ new_depth
+ )));
+ }
+
+ // Inherit repo settings if not provided
+ let repo_url = req.repository_url.clone().or(parent.repository_url);
+ let base_branch = req.base_branch.clone().or(parent.base_branch);
+ let target_branch = req.target_branch.clone().or(parent.target_branch);
+ let merge_mode = req.merge_mode.clone().or(parent.merge_mode);
+ let target_repo_path = req.target_repo_path.clone().or(parent.target_repo_path);
+ // NOTE: completion_action is NOT inherited - subtasks should not auto-merge.
+ // The orchestrator integrates subtask work from their worktrees.
+ let completion_action = req.completion_action.clone();
+
+ (new_depth, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action)
+ } else {
+ // Top-level task: depth 0
+ (
+ 0,
+ req.repository_url.clone(),
+ req.base_branch.clone(),
+ req.target_branch.clone(),
+ req.merge_mode.clone(),
+ req.target_repo_path.clone(),
+ req.completion_action.clone(),
+ )
+ };
+
+ let copy_files_json = req.copy_files.as_ref().map(|f| serde_json::to_value(f).unwrap_or_default());
+
+ sqlx::query_as::<_, Task>(
+ r#"
+ INSERT INTO tasks (
+ parent_task_id, depth, name, description, plan, priority,
+ repository_url, base_branch, target_branch, merge_mode,
+ target_repo_path, completion_action, continue_from_task_id, copy_files
+ )
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
+ RETURNING *
+ "#,
+ )
+ .bind(req.parent_task_id)
+ .bind(depth)
+ .bind(&req.name)
+ .bind(&req.description)
+ .bind(&req.plan)
+ .bind(req.priority)
+ .bind(&repo_url)
+ .bind(&base_branch)
+ .bind(&target_branch)
+ .bind(&merge_mode)
+ .bind(&target_repo_path)
+ .bind(&completion_action)
+ .bind(&req.continue_from_task_id)
+ .bind(&copy_files_json)
+ .fetch_one(pool)
+ .await
+}
+
+/// Get a task by ID.
+pub async fn get_task(pool: &PgPool, id: Uuid) -> Result<Option<Task>, sqlx::Error> {
+ sqlx::query_as::<_, Task>(
+ r#"
+ SELECT *
+ FROM tasks
+ WHERE id = $1
+ "#,
+ )
+ .bind(id)
+ .fetch_optional(pool)
+ .await
+}
+
+/// List all top-level tasks (no parent), ordered by created_at DESC.
+pub async fn list_tasks(pool: &PgPool) -> Result<Vec<TaskSummary>, sqlx::Error> {
+ sqlx::query_as::<_, TaskSummary>(
+ r#"
+ SELECT
+ t.id, t.parent_task_id, t.depth, t.name, t.status, t.priority,
+ t.progress_summary, t.version, t.created_at, t.updated_at,
+ (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count
+ FROM tasks t
+ WHERE t.parent_task_id IS NULL
+ ORDER BY t.priority DESC, t.created_at DESC
+ "#,
+ )
+ .fetch_all(pool)
+ .await
+}
+
+/// List subtasks of a parent task.
+pub async fn list_subtasks(pool: &PgPool, parent_id: Uuid) -> Result<Vec<TaskSummary>, sqlx::Error> {
+ sqlx::query_as::<_, TaskSummary>(
+ r#"
+ SELECT
+ t.id, t.parent_task_id, t.depth, t.name, t.status, t.priority,
+ t.progress_summary, t.version, t.created_at, t.updated_at,
+ (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count
+ FROM tasks t
+ WHERE t.parent_task_id = $1
+ ORDER BY t.priority DESC, t.created_at DESC
+ "#,
+ )
+ .bind(parent_id)
+ .fetch_all(pool)
+ .await
+}
+
+/// Update a task by ID with optimistic locking.
+pub async fn update_task(
+ pool: &PgPool,
+ id: Uuid,
+ req: UpdateTaskRequest,
+) -> Result<Option<Task>, RepositoryError> {
+ // Get the existing task first
+ let existing = get_task(pool, id).await?;
+ let Some(existing) = existing else {
+ return Ok(None);
+ };
+
+ // Check version if provided (optimistic locking)
+ if let Some(expected_version) = req.version {
+ if existing.version != expected_version {
+ return Err(RepositoryError::VersionConflict {
+ expected: expected_version,
+ actual: existing.version,
+ });
+ }
+ }
+
+ // Apply updates
+ let name = req.name.unwrap_or(existing.name);
+ let description = req.description.or(existing.description);
+ let plan = req.plan.unwrap_or(existing.plan);
+ let status = req.status.unwrap_or(existing.status);
+ let priority = req.priority.unwrap_or(existing.priority);
+ let progress_summary = req.progress_summary.or(existing.progress_summary);
+ let last_output = req.last_output.or(existing.last_output);
+ let error_message = req.error_message.or(existing.error_message);
+ let merge_mode = req.merge_mode.or(existing.merge_mode);
+ let pr_url = req.pr_url.or(existing.pr_url);
+ let target_repo_path = req.target_repo_path.or(existing.target_repo_path);
+ let completion_action = req.completion_action.or(existing.completion_action);
+ // Handle clear_daemon_id: if true, set to NULL; otherwise use provided value or keep existing
+ let daemon_id = if req.clear_daemon_id {
+ None
+ } else {
+ req.daemon_id.or(existing.daemon_id)
+ };
+
+ // Update with version check in WHERE clause for race condition safety
+ let result = if req.version.is_some() {
+ sqlx::query_as::<_, Task>(
+ r#"
+ UPDATE tasks
+ SET name = $2, description = $3, plan = $4, status = $5, priority = $6,
+ progress_summary = $7, last_output = $8, error_message = $9,
+ merge_mode = $10, pr_url = $11, daemon_id = $12,
+ target_repo_path = $13, completion_action = $14, updated_at = NOW()
+ WHERE id = $1 AND version = $15
+ RETURNING *
+ "#,
+ )
+ .bind(id)
+ .bind(&name)
+ .bind(&description)
+ .bind(&plan)
+ .bind(&status)
+ .bind(priority)
+ .bind(&progress_summary)
+ .bind(&last_output)
+ .bind(&error_message)
+ .bind(&merge_mode)
+ .bind(&pr_url)
+ .bind(daemon_id)
+ .bind(&target_repo_path)
+ .bind(&completion_action)
+ .bind(req.version.unwrap())
+ .fetch_optional(pool)
+ .await?
+ } else {
+ sqlx::query_as::<_, Task>(
+ r#"
+ UPDATE tasks
+ SET name = $2, description = $3, plan = $4, status = $5, priority = $6,
+ progress_summary = $7, last_output = $8, error_message = $9,
+ merge_mode = $10, pr_url = $11, daemon_id = $12,
+ target_repo_path = $13, completion_action = $14, updated_at = NOW()
+ WHERE id = $1
+ RETURNING *
+ "#,
+ )
+ .bind(id)
+ .bind(&name)
+ .bind(&description)
+ .bind(&plan)
+ .bind(&status)
+ .bind(priority)
+ .bind(&progress_summary)
+ .bind(&last_output)
+ .bind(&error_message)
+ .bind(&merge_mode)
+ .bind(&pr_url)
+ .bind(daemon_id)
+ .bind(&target_repo_path)
+ .bind(&completion_action)
+ .fetch_optional(pool)
+ .await?
+ };
+
+ // If versioned update returned None, there was a race condition
+ if result.is_none() && req.version.is_some() {
+ if let Some(current) = get_task(pool, id).await? {
+ return Err(RepositoryError::VersionConflict {
+ expected: req.version.unwrap(),
+ actual: current.version,
+ });
+ }
+ }
+
+ Ok(result)
+}
+
+/// Delete a task by ID.
+pub async fn delete_task(pool: &PgPool, id: Uuid) -> Result<bool, sqlx::Error> {
+ let result = sqlx::query(
+ r#"
+ DELETE FROM tasks
+ WHERE id = $1
+ "#,
+ )
+ .bind(id)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+}
+
+/// Count total tasks.
+pub async fn count_tasks(pool: &PgPool) -> Result<i64, sqlx::Error> {
+ let result: (i64,) = sqlx::query_as(
+ "SELECT COUNT(*) FROM tasks WHERE parent_task_id IS NULL",
+ )
+ .fetch_one(pool)
+ .await?;
+
+ Ok(result.0)
+}
+
+// =============================================================================
+// Owner-Scoped Task Functions
+// =============================================================================
+
+/// Create a new task for a specific owner.
+pub async fn create_task_for_owner(
+ pool: &PgPool,
+ owner_id: Uuid,
+ req: CreateTaskRequest,
+) -> Result<Task, sqlx::Error> {
+ // Calculate depth and inherit settings from parent if applicable
+ let (depth, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action) =
+ if let Some(parent_id) = req.parent_task_id {
+ // Fetch parent task to get depth and inherit repo settings (must belong to same owner)
+ let parent = get_task_for_owner(pool, parent_id, owner_id).await?
+ .ok_or_else(|| sqlx::Error::RowNotFound)?;
+
+ let new_depth = parent.depth + 1;
+
+ // Validate max depth
+ if new_depth >= 2 {
+ return Err(sqlx::Error::Protocol(format!(
+ "Maximum task depth exceeded. Cannot create subtask at depth {} (max is 1). Subtasks cannot have children.",
+ new_depth
+ )));
+ }
+
+ // Inherit repo settings if not provided
+ let repo_url = req.repository_url.clone().or(parent.repository_url);
+ let base_branch = req.base_branch.clone().or(parent.base_branch);
+ let target_branch = req.target_branch.clone().or(parent.target_branch);
+ let merge_mode = req.merge_mode.clone().or(parent.merge_mode);
+ let target_repo_path = req.target_repo_path.clone().or(parent.target_repo_path);
+ // NOTE: completion_action is NOT inherited - subtasks should not auto-merge.
+ // The orchestrator integrates subtask work from their worktrees.
+ let completion_action = req.completion_action.clone();
+
+ (new_depth, repo_url, base_branch, target_branch, merge_mode, target_repo_path, completion_action)
+ } else {
+ // Top-level task: depth 0
+ (
+ 0,
+ req.repository_url.clone(),
+ req.base_branch.clone(),
+ req.target_branch.clone(),
+ req.merge_mode.clone(),
+ req.target_repo_path.clone(),
+ req.completion_action.clone(),
+ )
+ };
+
+ let copy_files_json = req.copy_files.as_ref().map(|f| serde_json::to_value(f).unwrap_or_default());
+
+ sqlx::query_as::<_, Task>(
+ r#"
+ INSERT INTO tasks (
+ owner_id, parent_task_id, depth, name, description, plan, priority,
+ repository_url, base_branch, target_branch, merge_mode,
+ target_repo_path, completion_action, continue_from_task_id, copy_files
+ )
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
+ RETURNING *
+ "#,
+ )
+ .bind(owner_id)
+ .bind(req.parent_task_id)
+ .bind(depth)
+ .bind(&req.name)
+ .bind(&req.description)
+ .bind(&req.plan)
+ .bind(req.priority)
+ .bind(&repo_url)
+ .bind(&base_branch)
+ .bind(&target_branch)
+ .bind(&merge_mode)
+ .bind(&target_repo_path)
+ .bind(&completion_action)
+ .bind(&req.continue_from_task_id)
+ .bind(&copy_files_json)
+ .fetch_one(pool)
+ .await
+}
+
+/// Get a task by ID, scoped to owner.
+pub async fn get_task_for_owner(
+ pool: &PgPool,
+ id: Uuid,
+ owner_id: Uuid,
+) -> Result<Option<Task>, sqlx::Error> {
+ sqlx::query_as::<_, Task>(
+ r#"
+ SELECT *
+ FROM tasks
+ WHERE id = $1 AND owner_id = $2
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .fetch_optional(pool)
+ .await
+}
+
+/// List all top-level tasks (no parent) for an owner, ordered by created_at DESC.
+pub async fn list_tasks_for_owner(
+ pool: &PgPool,
+ owner_id: Uuid,
+) -> Result<Vec<TaskSummary>, sqlx::Error> {
+ sqlx::query_as::<_, TaskSummary>(
+ r#"
+ SELECT
+ t.id, t.parent_task_id, t.depth, t.name, t.status, t.priority,
+ t.progress_summary, t.version, t.created_at, t.updated_at,
+ (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count
+ FROM tasks t
+ WHERE t.owner_id = $1 AND t.parent_task_id IS NULL
+ ORDER BY t.priority DESC, t.created_at DESC
+ "#,
+ )
+ .bind(owner_id)
+ .fetch_all(pool)
+ .await
+}
+
+/// List subtasks of a parent task, scoped to owner.
+pub async fn list_subtasks_for_owner(
+ pool: &PgPool,
+ parent_id: Uuid,
+ owner_id: Uuid,
+) -> Result<Vec<TaskSummary>, sqlx::Error> {
+ sqlx::query_as::<_, TaskSummary>(
+ r#"
+ SELECT
+ t.id, t.parent_task_id, t.depth, t.name, t.status, t.priority,
+ t.progress_summary, t.version, t.created_at, t.updated_at,
+ (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count
+ FROM tasks t
+ WHERE t.owner_id = $1 AND t.parent_task_id = $2
+ ORDER BY t.priority DESC, t.created_at DESC
+ "#,
+ )
+ .bind(owner_id)
+ .bind(parent_id)
+ .fetch_all(pool)
+ .await
+}
+
+/// Update a task by ID with optimistic locking, scoped to owner.
+pub async fn update_task_for_owner(
+ pool: &PgPool,
+ id: Uuid,
+ owner_id: Uuid,
+ req: UpdateTaskRequest,
+) -> Result<Option<Task>, RepositoryError> {
+ // Get the existing task first (scoped to owner)
+ let existing = get_task_for_owner(pool, id, owner_id).await?;
+ let Some(existing) = existing else {
+ return Ok(None);
+ };
+
+ // Check version if provided (optimistic locking)
+ if let Some(expected_version) = req.version {
+ if existing.version != expected_version {
+ return Err(RepositoryError::VersionConflict {
+ expected: expected_version,
+ actual: existing.version,
+ });
+ }
+ }
+
+ // Apply updates
+ let name = req.name.unwrap_or(existing.name);
+ let description = req.description.or(existing.description);
+ let plan = req.plan.unwrap_or(existing.plan);
+ let status = req.status.unwrap_or(existing.status);
+ let priority = req.priority.unwrap_or(existing.priority);
+ let progress_summary = req.progress_summary.or(existing.progress_summary);
+ let last_output = req.last_output.or(existing.last_output);
+ let error_message = req.error_message.or(existing.error_message);
+ let merge_mode = req.merge_mode.or(existing.merge_mode);
+ let pr_url = req.pr_url.or(existing.pr_url);
+ let target_repo_path = req.target_repo_path.or(existing.target_repo_path);
+ let completion_action = req.completion_action.or(existing.completion_action);
+ let daemon_id = if req.clear_daemon_id {
+ None
+ } else {
+ req.daemon_id.or(existing.daemon_id)
+ };
+
+ // Update with version check in WHERE clause for race condition safety
+ let result = if req.version.is_some() {
+ sqlx::query_as::<_, Task>(
+ r#"
+ UPDATE tasks
+ SET name = $3, description = $4, plan = $5, status = $6, priority = $7,
+ progress_summary = $8, last_output = $9, error_message = $10,
+ merge_mode = $11, pr_url = $12, daemon_id = $13,
+ target_repo_path = $14, completion_action = $15, updated_at = NOW()
+ WHERE id = $1 AND owner_id = $2 AND version = $16
+ RETURNING *
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .bind(&name)
+ .bind(&description)
+ .bind(&plan)
+ .bind(&status)
+ .bind(priority)
+ .bind(&progress_summary)
+ .bind(&last_output)
+ .bind(&error_message)
+ .bind(&merge_mode)
+ .bind(&pr_url)
+ .bind(daemon_id)
+ .bind(&target_repo_path)
+ .bind(&completion_action)
+ .bind(req.version.unwrap())
+ .fetch_optional(pool)
+ .await?
+ } else {
+ sqlx::query_as::<_, Task>(
+ r#"
+ UPDATE tasks
+ SET name = $3, description = $4, plan = $5, status = $6, priority = $7,
+ progress_summary = $8, last_output = $9, error_message = $10,
+ merge_mode = $11, pr_url = $12, daemon_id = $13,
+ target_repo_path = $14, completion_action = $15, updated_at = NOW()
+ WHERE id = $1 AND owner_id = $2
+ RETURNING *
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .bind(&name)
+ .bind(&description)
+ .bind(&plan)
+ .bind(&status)
+ .bind(priority)
+ .bind(&progress_summary)
+ .bind(&last_output)
+ .bind(&error_message)
+ .bind(&merge_mode)
+ .bind(&pr_url)
+ .bind(daemon_id)
+ .bind(&target_repo_path)
+ .bind(&completion_action)
+ .fetch_optional(pool)
+ .await?
+ };
+
+ // If versioned update returned None, there was a race condition
+ if result.is_none() && req.version.is_some() {
+ if let Some(current) = get_task_for_owner(pool, id, owner_id).await? {
+ return Err(RepositoryError::VersionConflict {
+ expected: req.version.unwrap(),
+ actual: current.version,
+ });
+ }
+ }
+
+ Ok(result)
+}
+
+/// Delete a task by ID, scoped to owner.
+pub async fn delete_task_for_owner(
+ pool: &PgPool,
+ id: Uuid,
+ owner_id: Uuid,
+) -> Result<bool, sqlx::Error> {
+ let result = sqlx::query(
+ r#"
+ DELETE FROM tasks
+ WHERE id = $1 AND owner_id = $2
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+}
+
+/// Update task status and record event.
+pub async fn update_task_status(
+ pool: &PgPool,
+ id: Uuid,
+ new_status: &str,
+ event_data: Option<serde_json::Value>,
+) -> Result<Option<Task>, sqlx::Error> {
+ // Get existing status
+ let existing = get_task(pool, id).await?;
+ let Some(existing) = existing else {
+ return Ok(None);
+ };
+
+ let previous_status = existing.status.clone();
+
+ // Update task status
+ let task = sqlx::query_as::<_, Task>(
+ r#"
+ UPDATE tasks
+ SET status = $2, updated_at = NOW(),
+ started_at = CASE WHEN $2 = 'running' AND started_at IS NULL THEN NOW() ELSE started_at END,
+ completed_at = CASE WHEN $2 IN ('done', 'failed', 'merged') THEN NOW() ELSE completed_at END
+ WHERE id = $1
+ RETURNING *
+ "#,
+ )
+ .bind(id)
+ .bind(new_status)
+ .fetch_optional(pool)
+ .await?;
+
+ // Record event
+ if task.is_some() {
+ let _ = create_task_event(
+ pool,
+ id,
+ "status_change",
+ Some(&previous_status),
+ Some(new_status),
+ event_data,
+ )
+ .await;
+ }
+
+ Ok(task)
+}
+
+// =============================================================================
+// Task Event Functions
+// =============================================================================
+
+/// Create a task event.
+pub async fn create_task_event(
+ pool: &PgPool,
+ task_id: Uuid,
+ event_type: &str,
+ previous_status: Option<&str>,
+ new_status: Option<&str>,
+ event_data: Option<serde_json::Value>,
+) -> Result<TaskEvent, sqlx::Error> {
+ sqlx::query_as::<_, TaskEvent>(
+ r#"
+ INSERT INTO task_events (task_id, event_type, previous_status, new_status, event_data)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING *
+ "#,
+ )
+ .bind(task_id)
+ .bind(event_type)
+ .bind(previous_status)
+ .bind(new_status)
+ .bind(event_data)
+ .fetch_one(pool)
+ .await
+}
+
+/// List events for a task.
+pub async fn list_task_events(
+ pool: &PgPool,
+ task_id: Uuid,
+ limit: Option<i64>,
+) -> Result<Vec<TaskEvent>, sqlx::Error> {
+ let limit = limit.unwrap_or(100);
+ sqlx::query_as::<_, TaskEvent>(
+ r#"
+ SELECT *
+ FROM task_events
+ WHERE task_id = $1
+ ORDER BY created_at DESC
+ LIMIT $2
+ "#,
+ )
+ .bind(task_id)
+ .bind(limit)
+ .fetch_all(pool)
+ .await
+}
+
+// =============================================================================
+// Daemon Functions
+// =============================================================================
+
+/// Register a new daemon connection.
+pub async fn register_daemon(
+ pool: &PgPool,
+ owner_id: Uuid,
+ connection_id: &str,
+ hostname: Option<&str>,
+ machine_id: Option<&str>,
+ max_concurrent_tasks: i32,
+) -> Result<Daemon, sqlx::Error> {
+ sqlx::query_as::<_, Daemon>(
+ r#"
+ INSERT INTO daemons (owner_id, connection_id, hostname, machine_id, max_concurrent_tasks)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING *
+ "#,
+ )
+ .bind(owner_id)
+ .bind(connection_id)
+ .bind(hostname)
+ .bind(machine_id)
+ .bind(max_concurrent_tasks)
+ .fetch_one(pool)
+ .await
+}
+
+/// Get a daemon by ID.
+pub async fn get_daemon(pool: &PgPool, id: Uuid) -> Result<Option<Daemon>, sqlx::Error> {
+ sqlx::query_as::<_, Daemon>(
+ r#"
+ SELECT *
+ FROM daemons
+ WHERE id = $1
+ "#,
+ )
+ .bind(id)
+ .fetch_optional(pool)
+ .await
+}
+
+/// Get a daemon by connection ID.
+pub async fn get_daemon_by_connection(
+ pool: &PgPool,
+ connection_id: &str,
+) -> Result<Option<Daemon>, sqlx::Error> {
+ sqlx::query_as::<_, Daemon>(
+ r#"
+ SELECT *
+ FROM daemons
+ WHERE connection_id = $1
+ "#,
+ )
+ .bind(connection_id)
+ .fetch_optional(pool)
+ .await
+}
+
+/// List all daemons.
+pub async fn list_daemons(pool: &PgPool) -> Result<Vec<Daemon>, sqlx::Error> {
+ sqlx::query_as::<_, Daemon>(
+ r#"
+ SELECT *
+ FROM daemons
+ ORDER BY connected_at DESC
+ "#,
+ )
+ .fetch_all(pool)
+ .await
+}
+
+/// List daemons for a specific owner.
+pub async fn list_daemons_for_owner(pool: &PgPool, owner_id: Uuid) -> Result<Vec<Daemon>, sqlx::Error> {
+ sqlx::query_as::<_, Daemon>(
+ r#"
+ SELECT *
+ FROM daemons
+ WHERE owner_id = $1
+ ORDER BY connected_at DESC
+ "#,
+ )
+ .bind(owner_id)
+ .fetch_all(pool)
+ .await
+}
+
+/// Get a daemon by ID for a specific owner.
+pub async fn get_daemon_for_owner(pool: &PgPool, id: Uuid, owner_id: Uuid) -> Result<Option<Daemon>, sqlx::Error> {
+ sqlx::query_as::<_, Daemon>(
+ r#"
+ SELECT *
+ FROM daemons
+ WHERE id = $1 AND owner_id = $2
+ "#,
+ )
+ .bind(id)
+ .bind(owner_id)
+ .fetch_optional(pool)
+ .await
+}
+
+/// Update daemon heartbeat.
+pub async fn update_daemon_heartbeat(pool: &PgPool, id: Uuid) -> Result<bool, sqlx::Error> {
+ let result = sqlx::query(
+ r#"
+ UPDATE daemons
+ SET last_heartbeat_at = NOW(), status = 'connected'
+ WHERE id = $1
+ "#,
+ )
+ .bind(id)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+}
+
+/// Update daemon status.
+pub async fn update_daemon_status(
+ pool: &PgPool,
+ id: Uuid,
+ status: &str,
+) -> Result<bool, sqlx::Error> {
+ let result = sqlx::query(
+ r#"
+ UPDATE daemons
+ SET status = $2,
+ disconnected_at = CASE WHEN $2 = 'disconnected' THEN NOW() ELSE disconnected_at END
+ WHERE id = $1
+ "#,
+ )
+ .bind(id)
+ .bind(status)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+}
+
+/// Update daemon task count.
+pub async fn update_daemon_task_count(
+ pool: &PgPool,
+ id: Uuid,
+ delta: i32,
+) -> Result<bool, sqlx::Error> {
+ let result = sqlx::query(
+ r#"
+ UPDATE daemons
+ SET current_task_count = GREATEST(0, current_task_count + $2)
+ WHERE id = $1
+ "#,
+ )
+ .bind(id)
+ .bind(delta)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+}
+
+/// Delete a daemon by ID.
+pub async fn delete_daemon(pool: &PgPool, id: Uuid) -> Result<bool, sqlx::Error> {
+ let result = sqlx::query(
+ r#"
+ DELETE FROM daemons
+ WHERE id = $1
+ "#,
+ )
+ .bind(id)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+}
+
+/// Delete a daemon by connection ID.
+pub async fn delete_daemon_by_connection(
+ pool: &PgPool,
+ connection_id: &str,
+) -> Result<bool, sqlx::Error> {
+ let result = sqlx::query(
+ r#"
+ DELETE FROM daemons
+ WHERE connection_id = $1
+ "#,
+ )
+ .bind(connection_id)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+}
+
+/// Count connected daemons.
+pub async fn count_daemons(pool: &PgPool) -> Result<i64, sqlx::Error> {
+ let result: (i64,) = sqlx::query_as(
+ "SELECT COUNT(*) FROM daemons WHERE status = 'connected'",
+ )
+ .fetch_one(pool)
+ .await?;
+
+ Ok(result.0)
+}
+
+// =============================================================================
+// Sibling Awareness Functions
+// =============================================================================
+
+/// List sibling tasks (tasks with the same parent, excluding the given task).
+pub async fn list_sibling_tasks(
+ pool: &PgPool,
+ task_id: Uuid,
+ parent_id: Option<Uuid>,
+) -> Result<Vec<TaskSummary>, sqlx::Error> {
+ match parent_id {
+ Some(parent) => {
+ sqlx::query_as::<_, TaskSummary>(
+ r#"
+ SELECT
+ t.id, t.parent_task_id, t.depth, t.name, t.status, t.priority,
+ t.progress_summary, t.version, t.created_at, t.updated_at,
+ (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count
+ FROM tasks t
+ WHERE t.parent_task_id = $1 AND t.id != $2
+ ORDER BY t.priority DESC, t.created_at DESC
+ "#,
+ )
+ .bind(parent)
+ .bind(task_id)
+ .fetch_all(pool)
+ .await
+ }
+ None => {
+ // Top-level tasks (no parent) - siblings are other top-level tasks
+ sqlx::query_as::<_, TaskSummary>(
+ r#"
+ SELECT
+ t.id, t.parent_task_id, t.depth, t.name, t.status, t.priority,
+ t.progress_summary, t.version, t.created_at, t.updated_at,
+ (SELECT COUNT(*) FROM tasks WHERE parent_task_id = t.id) as subtask_count
+ FROM tasks t
+ WHERE t.parent_task_id IS NULL AND t.id != $1
+ ORDER BY t.priority DESC, t.created_at DESC
+ "#,
+ )
+ .bind(task_id)
+ .fetch_all(pool)
+ .await
+ }
+ }
+}
+
+/// Get running sibling tasks (for context injection).
+pub async fn get_running_siblings(
+ pool: &PgPool,
+ owner_id: Uuid,
+ task_id: Uuid,
+ parent_id: Option<Uuid>,
+) -> Result<Vec<Task>, sqlx::Error> {
+ match parent_id {
+ Some(parent) => {
+ sqlx::query_as::<_, Task>(
+ r#"
+ SELECT *
+ FROM tasks t
+ WHERE t.owner_id = $1
+ AND t.parent_task_id = $2
+ AND t.id != $3
+ AND t.status = 'running'
+ ORDER BY t.priority DESC
+ "#,
+ )
+ .bind(owner_id)
+ .bind(parent)
+ .bind(task_id)
+ .fetch_all(pool)
+ .await
+ }
+ None => {
+ sqlx::query_as::<_, Task>(
+ r#"
+ SELECT *
+ FROM tasks t
+ WHERE t.owner_id = $1
+ AND t.parent_task_id IS NULL
+ AND t.id != $2
+ AND t.status = 'running'
+ ORDER BY t.priority DESC
+ "#,
+ )
+ .bind(owner_id)
+ .bind(task_id)
+ .fetch_all(pool)
+ .await
+ }
+ }
+}
+
+/// Get task with its siblings for context awareness.
+pub async fn get_task_with_siblings(
+ pool: &PgPool,
+ id: Uuid,
+) -> Result<Option<(Task, Vec<TaskSummary>)>, sqlx::Error> {
+ let task = get_task(pool, id).await?;
+ let Some(task) = task else {
+ return Ok(None);
+ };
+
+ let siblings = list_sibling_tasks(pool, id, task.parent_task_id).await?;
+ Ok(Some((task, siblings)))
+}
+
+// =============================================================================
+// Task Output Persistence Functions
+// =============================================================================
+
+/// Save task output to the database.
+/// This stores output in the task_events table with event_type='output'.
+pub async fn save_task_output(
+ pool: &PgPool,
+ task_id: Uuid,
+ message_type: &str,
+ content: &str,
+ tool_name: Option<&str>,
+ tool_input: Option<serde_json::Value>,
+ is_error: Option<bool>,
+ cost_usd: Option<f64>,
+ duration_ms: Option<u64>,
+) -> Result<TaskEvent, sqlx::Error> {
+ let event_data = serde_json::json!({
+ "messageType": message_type,
+ "content": content,
+ "toolName": tool_name,
+ "toolInput": tool_input,
+ "isError": is_error,
+ "costUsd": cost_usd,
+ "durationMs": duration_ms,
+ });
+
+ create_task_event(pool, task_id, "output", None, None, Some(event_data)).await
+}
+
+/// Get task output from the database.
+/// Retrieves all output events for a task, ordered by creation time.
+pub async fn get_task_output(
+ pool: &PgPool,
+ task_id: Uuid,
+ limit: Option<i64>,
+) -> Result<Vec<TaskEvent>, sqlx::Error> {
+ let limit = limit.unwrap_or(1000);
+ sqlx::query_as::<_, TaskEvent>(
+ r#"
+ SELECT *
+ FROM task_events
+ WHERE task_id = $1 AND event_type = 'output'
+ ORDER BY created_at ASC
+ LIMIT $2
+ "#,
+ )
+ .bind(task_id)
+ .bind(limit)
+ .fetch_all(pool)
+ .await
+}
+
+/// Update task completion status with error message.
+/// Sets the task status to 'done' or 'failed' and records completion time.
+pub async fn complete_task(
+ pool: &PgPool,
+ task_id: Uuid,
+ success: bool,
+ error_message: Option<&str>,
+) -> Result<Option<Task>, sqlx::Error> {
+ let status = if success { "done" } else { "failed" };
+
+ let task = sqlx::query_as::<_, Task>(
+ r#"
+ UPDATE tasks
+ SET status = $2,
+ error_message = COALESCE($3, error_message),
+ completed_at = NOW(),
+ updated_at = NOW()
+ WHERE id = $1
+ RETURNING *
+ "#,
+ )
+ .bind(task_id)
+ .bind(status)
+ .bind(error_message)
+ .fetch_optional(pool)
+ .await?;
+
+ // Record completion event
+ if task.is_some() {
+ let event_data = serde_json::json!({
+ "success": success,
+ "errorMessage": error_message,
+ });
+ let _ = create_task_event(
+ pool,
+ task_id,
+ "complete",
+ Some("running"),
+ Some(status),
+ Some(event_data),
+ )
+ .await;
+ }
+
+ Ok(task)
+}
+
+// =============================================================================
+// Mesh Chat History Functions
+// =============================================================================
+
+/// Get or create the active conversation for an owner.
+pub async fn get_or_create_active_conversation(
+ pool: &PgPool,
+ owner_id: Uuid,
+) -> Result<MeshChatConversation, sqlx::Error> {
+ // Try to get existing active conversation for this owner
+ let existing = sqlx::query_as::<_, MeshChatConversation>(
+ r#"
+ SELECT *
+ FROM mesh_chat_conversations
+ WHERE is_active = true AND owner_id = $1
+ LIMIT 1
+ "#,
+ )
+ .bind(owner_id)
+ .fetch_optional(pool)
+ .await?;
+
+ if let Some(conv) = existing {
+ return Ok(conv);
+ }
+
+ // Create new conversation
+ sqlx::query_as::<_, MeshChatConversation>(
+ r#"
+ INSERT INTO mesh_chat_conversations (owner_id, is_active)
+ VALUES ($1, true)
+ RETURNING *
+ "#,
+ )
+ .bind(owner_id)
+ .fetch_one(pool)
+ .await
+}
+
+/// List messages for a conversation.
+pub async fn list_chat_messages(
+ pool: &PgPool,
+ conversation_id: Uuid,
+ limit: Option<i32>,
+) -> Result<Vec<MeshChatMessageRecord>, sqlx::Error> {
+ let limit = limit.unwrap_or(100);
+ sqlx::query_as::<_, MeshChatMessageRecord>(
+ r#"
+ SELECT *
+ FROM mesh_chat_messages
+ WHERE conversation_id = $1
+ ORDER BY created_at ASC
+ LIMIT $2
+ "#,
+ )
+ .bind(conversation_id)
+ .bind(limit)
+ .fetch_all(pool)
+ .await
+}
+
+/// Add a message to a conversation.
+#[allow(clippy::too_many_arguments)]
+pub async fn add_chat_message(
+ pool: &PgPool,
+ conversation_id: Uuid,
+ role: &str,
+ content: &str,
+ context_type: &str,
+ context_task_id: Option<Uuid>,
+ tool_calls: Option<serde_json::Value>,
+ pending_questions: Option<serde_json::Value>,
+) -> Result<MeshChatMessageRecord, sqlx::Error> {
+ sqlx::query_as::<_, MeshChatMessageRecord>(
+ r#"
+ INSERT INTO mesh_chat_messages
+ (conversation_id, role, content, context_type, context_task_id, tool_calls, pending_questions)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
+ RETURNING *
+ "#,
+ )
+ .bind(conversation_id)
+ .bind(role)
+ .bind(content)
+ .bind(context_type)
+ .bind(context_task_id)
+ .bind(tool_calls)
+ .bind(pending_questions)
+ .fetch_one(pool)
+ .await
+}
+
+/// Clear conversation (archive existing and create new).
+pub async fn clear_conversation(pool: &PgPool, owner_id: Uuid) -> Result<MeshChatConversation, sqlx::Error> {
+ // Mark existing as inactive for this owner
+ sqlx::query(
+ r#"
+ UPDATE mesh_chat_conversations
+ SET is_active = false, updated_at = NOW()
+ WHERE is_active = true AND owner_id = $1
+ "#,
+ )
+ .bind(owner_id)
+ .execute(pool)
+ .await?;
+
+ // Create new active conversation
+ get_or_create_active_conversation(pool, owner_id).await
+}
diff --git a/makima/src/llm/mesh_tools.rs b/makima/src/llm/mesh_tools.rs
new file mode 100644
index 0000000..1d12c66
--- /dev/null
+++ b/makima/src/llm/mesh_tools.rs
@@ -0,0 +1,1080 @@
+//! Tool definitions for task mesh orchestration via LLM.
+//!
+//! These tools allow the LLM to create, manage, and coordinate tasks across
+//! connected daemons running Claude Code containers.
+
+use serde_json::json;
+use uuid::Uuid;
+
+use super::tools::Tool;
+
+/// Available tools for mesh/task orchestration
+pub static MESH_TOOLS: once_cell::sync::Lazy<Vec<Tool>> = once_cell::sync::Lazy::new(|| {
+ vec![
+ // =============================================================================
+ // Task Lifecycle Tools
+ // =============================================================================
+ Tool {
+ name: "create_task".to_string(),
+ description: "Create a new task (or subtask if parent_task_id provided). The task will be in 'pending' status until run_task is called.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "name": {
+ "type": "string",
+ "description": "Name of the task"
+ },
+ "plan": {
+ "type": "string",
+ "description": "Detailed instructions/plan for what the task should accomplish"
+ },
+ "parent_task_id": {
+ "type": "string",
+ "description": "Optional parent task ID to create this as a subtask"
+ },
+ "repository_url": {
+ "type": "string",
+ "description": "Git repository URL or local path for the task (required)"
+ },
+ "base_branch": {
+ "type": "string",
+ "description": "Optional base branch to start from (default: main)"
+ },
+ "merge_mode": {
+ "type": "string",
+ "enum": ["pr", "auto", "manual"],
+ "description": "How to handle completion: 'pr' creates PR, 'auto' auto-merges, 'manual' leaves changes for review"
+ },
+ "priority": {
+ "type": "integer",
+ "description": "Task priority (higher = more important, default: 0)"
+ }
+ },
+ "required": ["name", "plan", "repository_url"]
+ }),
+ },
+ Tool {
+ name: "run_task".to_string(),
+ description: "Start executing a pending task on an available daemon. The task must be in 'pending' or 'paused' status.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the task to run"
+ },
+ "daemon_id": {
+ "type": "string",
+ "description": "Optional specific daemon ID to run on. If not specified, an available daemon will be selected."
+ }
+ },
+ "required": ["task_id"]
+ }),
+ },
+ Tool {
+ name: "pause_task".to_string(),
+ description: "Pause a running task. The container state will be preserved.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the task to pause"
+ }
+ },
+ "required": ["task_id"]
+ }),
+ },
+ Tool {
+ name: "resume_task".to_string(),
+ description: "Resume a paused task.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the task to resume"
+ }
+ },
+ "required": ["task_id"]
+ }),
+ },
+ Tool {
+ name: "interrupt_task".to_string(),
+ description: "Interrupt a running task. Use graceful=true to allow current operation to complete.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the task to interrupt"
+ },
+ "graceful": {
+ "type": "boolean",
+ "description": "If true, wait for current operation to complete before stopping"
+ }
+ },
+ "required": ["task_id"]
+ }),
+ },
+ Tool {
+ name: "discard_task".to_string(),
+ description: "Discard a task and delete its overlay. All changes will be lost. Use with caution.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the task to discard"
+ },
+ "confirm": {
+ "type": "boolean",
+ "description": "Must be true to confirm deletion"
+ }
+ },
+ "required": ["task_id", "confirm"]
+ }),
+ },
+ // =============================================================================
+ // Task Query Tools
+ // =============================================================================
+ Tool {
+ name: "query_task_status".to_string(),
+ description: "Get detailed status and information about a task.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the task to query"
+ }
+ },
+ "required": ["task_id"]
+ }),
+ },
+ Tool {
+ name: "list_tasks".to_string(),
+ description: "List all tasks, optionally filtered by status or parent.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "status_filter": {
+ "type": "string",
+ "enum": ["pending", "running", "paused", "blocked", "done", "failed", "merged"],
+ "description": "Optional filter by task status"
+ },
+ "parent_task_id": {
+ "type": "string",
+ "description": "Optional filter to list only subtasks of this parent"
+ }
+ }
+ }),
+ },
+ Tool {
+ name: "list_subtasks".to_string(),
+ description: "List all subtasks of a specific task.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the parent task"
+ }
+ },
+ "required": ["task_id"]
+ }),
+ },
+ Tool {
+ name: "list_siblings".to_string(),
+ description: "List sibling tasks (tasks with the same parent) of a specific task.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the task to find siblings for"
+ }
+ },
+ "required": ["task_id"]
+ }),
+ },
+ Tool {
+ name: "list_daemons".to_string(),
+ description: "List all connected daemons and their current status.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {}
+ }),
+ },
+ Tool {
+ name: "list_daemon_directories".to_string(),
+ description: "List all available directories from connected daemons. Use this to find existing repositories and suggested working directories when creating tasks. Returns directories like the daemon's working directory and home directory where repos can be cloned.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {}
+ }),
+ },
+ // =============================================================================
+ // File Access Tools
+ // =============================================================================
+ Tool {
+ name: "list_files".to_string(),
+ description: "List all files available in the system. Returns file IDs, names, and descriptions.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {}
+ }),
+ },
+ Tool {
+ name: "read_file".to_string(),
+ description: "Read the contents of a file from the files system. Returns the file's name, description, summary, body content (headings and paragraphs), and transcript entries with speaker and timing information.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "file_id": {
+ "type": "string",
+ "description": "ID of the file to read"
+ }
+ },
+ "required": ["file_id"]
+ }),
+ },
+ // =============================================================================
+ // Task Communication Tools
+ // =============================================================================
+ Tool {
+ name: "send_message_to_task".to_string(),
+ description: "Send a message to a running task's Claude Code instance. Use this to provide additional context, answer questions, or give new instructions.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the running task"
+ },
+ "message": {
+ "type": "string",
+ "description": "Message to send to the task"
+ }
+ },
+ "required": ["task_id", "message"]
+ }),
+ },
+ Tool {
+ name: "update_task_plan".to_string(),
+ description: "Update the plan/instructions for a task. Can optionally interrupt a running task to apply new plan.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the task to update"
+ },
+ "new_plan": {
+ "type": "string",
+ "description": "New plan/instructions for the task"
+ },
+ "interrupt_if_running": {
+ "type": "boolean",
+ "description": "If true and task is running, interrupt it to apply new plan"
+ }
+ },
+ "required": ["task_id", "new_plan"]
+ }),
+ },
+ // =============================================================================
+ // Overlay/Merge Tools
+ // =============================================================================
+ Tool {
+ name: "peek_sibling_overlay".to_string(),
+ description: "View the changes made by a sibling task's overlay. Useful for understanding what other tasks have done before merging.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "sibling_task_id": {
+ "type": "string",
+ "description": "ID of the sibling task to peek at"
+ }
+ },
+ "required": ["sibling_task_id"]
+ }),
+ },
+ Tool {
+ name: "get_overlay_diff".to_string(),
+ description: "Get a git diff of all changes in a task's overlay.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the task"
+ }
+ },
+ "required": ["task_id"]
+ }),
+ },
+ Tool {
+ name: "preview_merge".to_string(),
+ description: "Preview what a merge would look like without actually merging. Shows potential conflicts.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the task to preview merge for"
+ }
+ },
+ "required": ["task_id"]
+ }),
+ },
+ Tool {
+ name: "merge_subtask".to_string(),
+ description: "Merge a completed subtask's changes to its parent branch.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the subtask to merge"
+ }
+ },
+ "required": ["task_id"]
+ }),
+ },
+ Tool {
+ name: "complete_task".to_string(),
+ description: "Mark a task as complete and trigger the merge flow based on merge_mode. For 'pr' mode, creates a pull request. For 'auto' mode, merges directly.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the task to complete"
+ }
+ },
+ "required": ["task_id"]
+ }),
+ },
+ Tool {
+ name: "set_merge_mode".to_string(),
+ description: "Change the merge mode for a task.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "task_id": {
+ "type": "string",
+ "description": "ID of the task"
+ },
+ "mode": {
+ "type": "string",
+ "enum": ["pr", "auto", "manual"],
+ "description": "New merge mode: 'pr' (create PR), 'auto' (auto-merge), 'manual' (leave for manual review)"
+ }
+ },
+ "required": ["task_id", "mode"]
+ }),
+ },
+ // =============================================================================
+ // Interactive Tools
+ // =============================================================================
+ Tool {
+ name: "ask_user".to_string(),
+ description: "Ask the user one or more questions. Use this when you need clarification, want to offer choices, or need user input before proceeding.".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "questions": {
+ "type": "array",
+ "description": "List of questions to ask the user",
+ "items": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string",
+ "description": "Unique identifier for this question"
+ },
+ "question": {
+ "type": "string",
+ "description": "The question to ask the user"
+ },
+ "options": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "Multiple choice options for the user to select from"
+ },
+ "allowMultiple": {
+ "type": "boolean",
+ "description": "If true, user can select multiple options"
+ },
+ "allowCustom": {
+ "type": "boolean",
+ "description": "If true, user can provide a custom answer"
+ }
+ },
+ "required": ["id", "question", "options"]
+ }
+ }
+ },
+ "required": ["questions"]
+ }),
+ },
+ ]
+});
+
+/// Request for mesh tool operations that require async database/daemon access
+#[derive(Debug, Clone)]
+pub enum MeshToolRequest {
+ // Task lifecycle
+ CreateTask {
+ name: String,
+ plan: String,
+ parent_task_id: Option<Uuid>,
+ repository_url: Option<String>,
+ base_branch: Option<String>,
+ merge_mode: Option<String>,
+ priority: Option<i32>,
+ },
+ RunTask {
+ task_id: Uuid,
+ daemon_id: Option<Uuid>,
+ },
+ PauseTask {
+ task_id: Uuid,
+ },
+ ResumeTask {
+ task_id: Uuid,
+ },
+ InterruptTask {
+ task_id: Uuid,
+ graceful: bool,
+ },
+ DiscardTask {
+ task_id: Uuid,
+ },
+
+ // Task queries
+ QueryTaskStatus {
+ task_id: Uuid,
+ },
+ ListTasks {
+ status_filter: Option<String>,
+ parent_task_id: Option<Uuid>,
+ },
+ ListSubtasks {
+ task_id: Uuid,
+ },
+ ListSiblings {
+ task_id: Uuid,
+ },
+ ListDaemons,
+ ListDaemonDirectories,
+
+ // File access
+ ListFiles,
+ ReadFile {
+ file_id: Uuid,
+ },
+
+ // Task communication
+ SendMessageToTask {
+ task_id: Uuid,
+ message: String,
+ },
+ UpdateTaskPlan {
+ task_id: Uuid,
+ new_plan: String,
+ interrupt_if_running: bool,
+ },
+
+ // Overlay/merge operations
+ PeekSiblingOverlay {
+ sibling_task_id: Uuid,
+ },
+ GetOverlayDiff {
+ task_id: Uuid,
+ },
+ PreviewMerge {
+ task_id: Uuid,
+ },
+ MergeSubtask {
+ task_id: Uuid,
+ },
+ CompleteTask {
+ task_id: Uuid,
+ },
+ SetMergeMode {
+ task_id: Uuid,
+ mode: String,
+ },
+}
+
+/// Result from executing a mesh tool
+#[derive(Debug)]
+pub struct MeshToolExecutionResult {
+ pub success: bool,
+ pub message: String,
+ pub data: Option<serde_json::Value>,
+ /// Request for async operations (handled by mesh_chat handler)
+ pub request: Option<MeshToolRequest>,
+ /// Questions to ask the user (pauses conversation)
+ pub pending_questions: Option<Vec<super::tools::UserQuestion>>,
+}
+
+/// Parse and validate a mesh tool call, returning a MeshToolRequest for async handling
+pub fn parse_mesh_tool_call(
+ call: &super::tools::ToolCall,
+) -> MeshToolExecutionResult {
+ match call.name.as_str() {
+ // Task lifecycle
+ "create_task" => parse_create_task(call),
+ "run_task" => parse_run_task(call),
+ "pause_task" => parse_pause_task(call),
+ "resume_task" => parse_resume_task(call),
+ "interrupt_task" => parse_interrupt_task(call),
+ "discard_task" => parse_discard_task(call),
+
+ // Task queries
+ "query_task_status" => parse_query_task_status(call),
+ "list_tasks" => parse_list_tasks(call),
+ "list_subtasks" => parse_list_subtasks(call),
+ "list_siblings" => parse_list_siblings(call),
+ "list_daemons" => parse_list_daemons(),
+ "list_daemon_directories" => parse_list_daemon_directories(),
+
+ // File access
+ "list_files" => parse_list_files(),
+ "read_file" => parse_read_file(call),
+
+ // Task communication
+ "send_message_to_task" => parse_send_message_to_task(call),
+ "update_task_plan" => parse_update_task_plan(call),
+
+ // Overlay/merge operations
+ "peek_sibling_overlay" => parse_peek_sibling_overlay(call),
+ "get_overlay_diff" => parse_get_overlay_diff(call),
+ "preview_merge" => parse_preview_merge(call),
+ "merge_subtask" => parse_merge_subtask(call),
+ "complete_task" => parse_complete_task(call),
+ "set_merge_mode" => parse_set_merge_mode(call),
+
+ // Interactive tools
+ "ask_user" => parse_ask_user(call),
+
+ _ => MeshToolExecutionResult {
+ success: false,
+ message: format!("Unknown mesh tool: {}", call.name),
+ data: None,
+ request: None,
+ pending_questions: None,
+ },
+ }
+}
+
+// =============================================================================
+// Tool Parsing Functions
+// =============================================================================
+
+fn parse_create_task(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let name = call.arguments.get("name").and_then(|v| v.as_str());
+ let plan = call.arguments.get("plan").and_then(|v| v.as_str());
+ let repository_url = call
+ .arguments
+ .get("repository_url")
+ .and_then(|v| v.as_str());
+
+ let Some(name) = name else {
+ return error_result("Missing required parameter: name");
+ };
+ let Some(plan) = plan else {
+ return error_result("Missing required parameter: plan");
+ };
+ let Some(repository_url) = repository_url else {
+ return error_result("Missing required parameter: repository_url");
+ };
+
+ let parent_task_id = call
+ .arguments
+ .get("parent_task_id")
+ .and_then(|v| v.as_str())
+ .and_then(|s| Uuid::parse_str(s).ok());
+
+ let repository_url = Some(repository_url.to_string());
+
+ let base_branch = call
+ .arguments
+ .get("base_branch")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+
+ let merge_mode = call
+ .arguments
+ .get("merge_mode")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+
+ let priority = call
+ .arguments
+ .get("priority")
+ .and_then(|v| v.as_i64())
+ .map(|v| v as i32);
+
+ MeshToolExecutionResult {
+ success: true,
+ message: "Creating task...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::CreateTask {
+ name: name.to_string(),
+ plan: plan.to_string(),
+ parent_task_id,
+ repository_url,
+ base_branch,
+ merge_mode,
+ priority,
+ }),
+ pending_questions: None,
+ }
+}
+
+fn parse_run_task(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let task_id = parse_uuid_arg(call, "task_id");
+ let Some(task_id) = task_id else {
+ return error_result("Missing or invalid required parameter: task_id");
+ };
+
+ let daemon_id = call
+ .arguments
+ .get("daemon_id")
+ .and_then(|v| v.as_str())
+ .and_then(|s| Uuid::parse_str(s).ok());
+
+ MeshToolExecutionResult {
+ success: true,
+ message: "Starting task...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::RunTask { task_id, daemon_id }),
+ pending_questions: None,
+ }
+}
+
+fn parse_pause_task(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let task_id = parse_uuid_arg(call, "task_id");
+ let Some(task_id) = task_id else {
+ return error_result("Missing or invalid required parameter: task_id");
+ };
+
+ MeshToolExecutionResult {
+ success: true,
+ message: "Pausing task...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::PauseTask { task_id }),
+ pending_questions: None,
+ }
+}
+
+fn parse_resume_task(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let task_id = parse_uuid_arg(call, "task_id");
+ let Some(task_id) = task_id else {
+ return error_result("Missing or invalid required parameter: task_id");
+ };
+
+ MeshToolExecutionResult {
+ success: true,
+ message: "Resuming task...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::ResumeTask { task_id }),
+ pending_questions: None,
+ }
+}
+
+fn parse_interrupt_task(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let task_id = parse_uuid_arg(call, "task_id");
+ let Some(task_id) = task_id else {
+ return error_result("Missing or invalid required parameter: task_id");
+ };
+
+ let graceful = call
+ .arguments
+ .get("graceful")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(true);
+
+ MeshToolExecutionResult {
+ success: true,
+ message: if graceful {
+ "Gracefully interrupting task...".to_string()
+ } else {
+ "Force interrupting task...".to_string()
+ },
+ data: None,
+ request: Some(MeshToolRequest::InterruptTask { task_id, graceful }),
+ pending_questions: None,
+ }
+}
+
+fn parse_discard_task(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let task_id = parse_uuid_arg(call, "task_id");
+ let Some(task_id) = task_id else {
+ return error_result("Missing or invalid required parameter: task_id");
+ };
+
+ let confirm = call
+ .arguments
+ .get("confirm")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+
+ if !confirm {
+ return error_result("Must set confirm=true to discard a task");
+ }
+
+ MeshToolExecutionResult {
+ success: true,
+ message: "Discarding task...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::DiscardTask { task_id }),
+ pending_questions: None,
+ }
+}
+
+fn parse_query_task_status(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let task_id = parse_uuid_arg(call, "task_id");
+ let Some(task_id) = task_id else {
+ return error_result("Missing or invalid required parameter: task_id");
+ };
+
+ MeshToolExecutionResult {
+ success: true,
+ message: "Querying task status...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::QueryTaskStatus { task_id }),
+ pending_questions: None,
+ }
+}
+
+fn parse_list_tasks(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let status_filter = call
+ .arguments
+ .get("status_filter")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+
+ let parent_task_id = call
+ .arguments
+ .get("parent_task_id")
+ .and_then(|v| v.as_str())
+ .and_then(|s| Uuid::parse_str(s).ok());
+
+ MeshToolExecutionResult {
+ success: true,
+ message: "Listing tasks...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::ListTasks {
+ status_filter,
+ parent_task_id,
+ }),
+ pending_questions: None,
+ }
+}
+
+fn parse_list_subtasks(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let task_id = parse_uuid_arg(call, "task_id");
+ let Some(task_id) = task_id else {
+ return error_result("Missing or invalid required parameter: task_id");
+ };
+
+ MeshToolExecutionResult {
+ success: true,
+ message: "Listing subtasks...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::ListSubtasks { task_id }),
+ pending_questions: None,
+ }
+}
+
+fn parse_list_siblings(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let task_id = parse_uuid_arg(call, "task_id");
+ let Some(task_id) = task_id else {
+ return error_result("Missing or invalid required parameter: task_id");
+ };
+
+ MeshToolExecutionResult {
+ success: true,
+ message: "Listing sibling tasks...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::ListSiblings { task_id }),
+ pending_questions: None,
+ }
+}
+
+fn parse_list_daemons() -> MeshToolExecutionResult {
+ MeshToolExecutionResult {
+ success: true,
+ message: "Listing daemons...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::ListDaemons),
+ pending_questions: None,
+ }
+}
+
+fn parse_list_daemon_directories() -> MeshToolExecutionResult {
+ MeshToolExecutionResult {
+ success: true,
+ message: "Listing daemon directories...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::ListDaemonDirectories),
+ pending_questions: None,
+ }
+}
+
+fn parse_list_files() -> MeshToolExecutionResult {
+ MeshToolExecutionResult {
+ success: true,
+ message: "Listing files...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::ListFiles),
+ pending_questions: None,
+ }
+}
+
+fn parse_read_file(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let file_id = parse_uuid_arg(call, "file_id");
+ let Some(file_id) = file_id else {
+ return error_result("Missing or invalid required parameter: file_id");
+ };
+
+ MeshToolExecutionResult {
+ success: true,
+ message: "Reading file...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::ReadFile { file_id }),
+ pending_questions: None,
+ }
+}
+
+fn parse_send_message_to_task(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let task_id = parse_uuid_arg(call, "task_id");
+ let Some(task_id) = task_id else {
+ return error_result("Missing or invalid required parameter: task_id");
+ };
+
+ let message = call.arguments.get("message").and_then(|v| v.as_str());
+ let Some(message) = message else {
+ return error_result("Missing required parameter: message");
+ };
+
+ MeshToolExecutionResult {
+ success: true,
+ message: "Sending message to task...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::SendMessageToTask {
+ task_id,
+ message: message.to_string(),
+ }),
+ pending_questions: None,
+ }
+}
+
+fn parse_update_task_plan(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let task_id = parse_uuid_arg(call, "task_id");
+ let Some(task_id) = task_id else {
+ return error_result("Missing or invalid required parameter: task_id");
+ };
+
+ let new_plan = call.arguments.get("new_plan").and_then(|v| v.as_str());
+ let Some(new_plan) = new_plan else {
+ return error_result("Missing required parameter: new_plan");
+ };
+
+ let interrupt_if_running = call
+ .arguments
+ .get("interrupt_if_running")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+
+ MeshToolExecutionResult {
+ success: true,
+ message: "Updating task plan...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::UpdateTaskPlan {
+ task_id,
+ new_plan: new_plan.to_string(),
+ interrupt_if_running,
+ }),
+ pending_questions: None,
+ }
+}
+
+fn parse_peek_sibling_overlay(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let sibling_task_id = parse_uuid_arg(call, "sibling_task_id");
+ let Some(sibling_task_id) = sibling_task_id else {
+ return error_result("Missing or invalid required parameter: sibling_task_id");
+ };
+
+ MeshToolExecutionResult {
+ success: true,
+ message: "Peeking at sibling overlay...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::PeekSiblingOverlay { sibling_task_id }),
+ pending_questions: None,
+ }
+}
+
+fn parse_get_overlay_diff(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let task_id = parse_uuid_arg(call, "task_id");
+ let Some(task_id) = task_id else {
+ return error_result("Missing or invalid required parameter: task_id");
+ };
+
+ MeshToolExecutionResult {
+ success: true,
+ message: "Getting overlay diff...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::GetOverlayDiff { task_id }),
+ pending_questions: None,
+ }
+}
+
+fn parse_preview_merge(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let task_id = parse_uuid_arg(call, "task_id");
+ let Some(task_id) = task_id else {
+ return error_result("Missing or invalid required parameter: task_id");
+ };
+
+ MeshToolExecutionResult {
+ success: true,
+ message: "Previewing merge...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::PreviewMerge { task_id }),
+ pending_questions: None,
+ }
+}
+
+fn parse_merge_subtask(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let task_id = parse_uuid_arg(call, "task_id");
+ let Some(task_id) = task_id else {
+ return error_result("Missing or invalid required parameter: task_id");
+ };
+
+ MeshToolExecutionResult {
+ success: true,
+ message: "Merging subtask...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::MergeSubtask { task_id }),
+ pending_questions: None,
+ }
+}
+
+fn parse_complete_task(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let task_id = parse_uuid_arg(call, "task_id");
+ let Some(task_id) = task_id else {
+ return error_result("Missing or invalid required parameter: task_id");
+ };
+
+ MeshToolExecutionResult {
+ success: true,
+ message: "Completing task...".to_string(),
+ data: None,
+ request: Some(MeshToolRequest::CompleteTask { task_id }),
+ pending_questions: None,
+ }
+}
+
+fn parse_set_merge_mode(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let task_id = parse_uuid_arg(call, "task_id");
+ let Some(task_id) = task_id else {
+ return error_result("Missing or invalid required parameter: task_id");
+ };
+
+ let mode = call.arguments.get("mode").and_then(|v| v.as_str());
+ let Some(mode) = mode else {
+ return error_result("Missing required parameter: mode");
+ };
+
+ if !["pr", "auto", "manual"].contains(&mode) {
+ return error_result("Invalid mode. Must be 'pr', 'auto', or 'manual'");
+ }
+
+ MeshToolExecutionResult {
+ success: true,
+ message: format!("Setting merge mode to '{}'...", mode),
+ data: None,
+ request: Some(MeshToolRequest::SetMergeMode {
+ task_id,
+ mode: mode.to_string(),
+ }),
+ pending_questions: None,
+ }
+}
+
+fn parse_ask_user(call: &super::tools::ToolCall) -> MeshToolExecutionResult {
+ let questions_value = call.arguments.get("questions");
+
+ let Some(questions_array) = questions_value.and_then(|v| v.as_array()) else {
+ return error_result("Missing or invalid 'questions' parameter");
+ };
+
+ let mut questions: Vec<super::tools::UserQuestion> = Vec::new();
+
+ for q in questions_array {
+ let id = q.get("id").and_then(|v| v.as_str()).unwrap_or("").to_string();
+ let question = q.get("question").and_then(|v| v.as_str()).unwrap_or("").to_string();
+ let options: Vec<String> = q
+ .get("options")
+ .and_then(|v| v.as_array())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|o| o.as_str())
+ .map(|s| s.to_string())
+ .collect()
+ })
+ .unwrap_or_default();
+ let allow_multiple = q.get("allowMultiple").and_then(|v| v.as_bool()).unwrap_or(false);
+ let allow_custom = q.get("allowCustom").and_then(|v| v.as_bool()).unwrap_or(true);
+
+ if id.is_empty() || question.is_empty() || options.is_empty() {
+ continue;
+ }
+
+ questions.push(super::tools::UserQuestion {
+ id,
+ question,
+ options,
+ allow_multiple,
+ allow_custom,
+ });
+ }
+
+ if questions.is_empty() {
+ return error_result("No valid questions provided");
+ }
+
+ let question_count = questions.len();
+ MeshToolExecutionResult {
+ success: true,
+ message: format!("Asking user {} question(s). Waiting for response...", question_count),
+ data: None,
+ request: None,
+ pending_questions: Some(questions),
+ }
+}
+
+// =============================================================================
+// Helper Functions
+// =============================================================================
+
+fn parse_uuid_arg(call: &super::tools::ToolCall, key: &str) -> Option<Uuid> {
+ call.arguments
+ .get(key)
+ .and_then(|v| v.as_str())
+ .and_then(|s| Uuid::parse_str(s).ok())
+}
+
+fn error_result(message: &str) -> MeshToolExecutionResult {
+ MeshToolExecutionResult {
+ success: false,
+ message: message.to_string(),
+ data: None,
+ request: None,
+ pending_questions: None,
+ }
+}
diff --git a/makima/src/llm/mod.rs b/makima/src/llm/mod.rs
index 1001854..39cdbdd 100644
--- a/makima/src/llm/mod.rs
+++ b/makima/src/llm/mod.rs
@@ -2,10 +2,12 @@
pub mod claude;
pub mod groq;
+pub mod mesh_tools;
pub mod tools;
pub use claude::{ClaudeClient, ClaudeModel};
pub use groq::GroqClient;
+pub use mesh_tools::{parse_mesh_tool_call, MeshToolExecutionResult, MeshToolRequest, MESH_TOOLS};
pub use tools::{
execute_tool_call, Tool, ToolCall, ToolResult, UserAnswer, UserQuestion, VersionToolRequest,
AVAILABLE_TOOLS,
diff --git a/makima/src/llm/tools.rs b/makima/src/llm/tools.rs
index 77fc8c6..649633e 100644
--- a/makima/src/llm/tools.rs
+++ b/makima/src/llm/tools.rs
@@ -73,6 +73,51 @@ pub static AVAILABLE_TOOLS: once_cell::sync::Lazy<Vec<Tool>> =
}),
},
Tool {
+ name: "add_code".to_string(),
+ description: "Add a code block element to the file body".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "string",
+ "description": "The code content"
+ },
+ "language": {
+ "type": "string",
+ "description": "Optional programming language for syntax highlighting (e.g., 'javascript', 'python', 'rust')"
+ },
+ "position": {
+ "type": "integer",
+ "description": "Optional position to insert at (0-indexed). If not specified, appends to end."
+ }
+ },
+ "required": ["content"]
+ }),
+ },
+ Tool {
+ name: "add_list".to_string(),
+ description: "Add a list element (ordered or unordered) to the file body".to_string(),
+ parameters: json!({
+ "type": "object",
+ "properties": {
+ "items": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "Array of list item strings"
+ },
+ "ordered": {
+ "type": "boolean",
+ "description": "If true, creates a numbered list; if false (default), creates a bullet list"
+ },
+ "position": {
+ "type": "integer",
+ "description": "Optional position to insert at (0-indexed). If not specified, appends to end."
+ }
+ },
+ "required": ["items"]
+ }),
+ },
+ Tool {
name: "add_chart".to_string(),
description: "Add a chart visualization to the file body. Supports line, bar, pie, and area charts.".to_string(),
parameters: json!({
@@ -122,7 +167,7 @@ pub static AVAILABLE_TOOLS: once_cell::sync::Lazy<Vec<Tool>> =
},
Tool {
name: "update_element".to_string(),
- description: "Update an existing element in the file body. IMPORTANT: You must provide ALL required fields. For heading: type, level (1-6), text. For paragraph: type, text. For chart: type, chartType (line/bar/pie/area), data (array of objects).".to_string(),
+ description: "Update an existing element in the file body. IMPORTANT: You must provide ALL required fields. For heading: type, level (1-6), text. For paragraph: type, text. For code: type, content, language (optional). For list: type, items (array of strings), ordered (boolean). For chart: type, chartType (line/bar/pie/area), data (array of objects).".to_string(),
parameters: json!({
"type": "object",
"properties": {
@@ -132,7 +177,7 @@ pub static AVAILABLE_TOOLS: once_cell::sync::Lazy<Vec<Tool>> =
},
"element_type": {
"type": "string",
- "enum": ["heading", "paragraph", "chart"],
+ "enum": ["heading", "paragraph", "code", "list", "chart"],
"description": "Type of element"
},
"text": {
@@ -143,6 +188,23 @@ pub static AVAILABLE_TOOLS: once_cell::sync::Lazy<Vec<Tool>> =
"type": "integer",
"description": "Heading level 1-6 (required for heading)"
},
+ "content": {
+ "type": "string",
+ "description": "Code content (required for code)"
+ },
+ "language": {
+ "type": "string",
+ "description": "Programming language for syntax highlighting (optional for code)"
+ },
+ "items": {
+ "type": "array",
+ "items": { "type": "string" },
+ "description": "List items (required for list)"
+ },
+ "ordered": {
+ "type": "boolean",
+ "description": "If true, numbered list; if false, bullet list (for list)"
+ },
"chartType": {
"type": "string",
"enum": ["line", "bar", "pie", "area"],
@@ -418,6 +480,8 @@ pub fn execute_tool_call(
match call.name.as_str() {
"add_heading" => execute_add_heading(call, current_body),
"add_paragraph" => execute_add_paragraph(call, current_body),
+ "add_code" => execute_add_code(call, current_body),
+ "add_list" => execute_add_list(call, current_body),
"add_chart" => execute_add_chart(call, current_body),
"remove_element" => execute_remove_element(call, current_body),
"update_element" => execute_update_element(call, current_body),
@@ -605,6 +669,103 @@ fn execute_add_paragraph(call: &ToolCall, current_body: &[BodyElement]) -> ToolE
}
}
+fn execute_add_code(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
+ let language = call
+ .arguments
+ .get("language")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+ let content = call
+ .arguments
+ .get("content")
+ .and_then(|v| v.as_str())
+ .unwrap_or("")
+ .to_string();
+ let position = call.arguments.get("position").and_then(|v| v.as_u64());
+
+ let element = BodyElement::Code {
+ language: language.clone(),
+ content: content.clone(),
+ };
+ let mut new_body = current_body.to_vec();
+
+ if let Some(pos) = position {
+ let pos = pos as usize;
+ if pos <= new_body.len() {
+ new_body.insert(pos, element);
+ } else {
+ new_body.push(element);
+ }
+ } else {
+ new_body.push(element);
+ }
+
+ let lang_str = language.as_deref().unwrap_or("plain");
+ let preview: String = content.chars().take(50).collect();
+
+ ToolExecutionResult {
+ result: ToolResult {
+ success: true,
+ message: format!("Added code block ({}): {}", lang_str, preview),
+ },
+ new_body: Some(new_body),
+ new_summary: None,
+ parsed_data: None,
+ version_request: None,
+ pending_questions: None,
+ }
+}
+
+fn execute_add_list(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
+ let ordered = call
+ .arguments
+ .get("ordered")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+ let items: Vec<String> = call
+ .arguments
+ .get("items")
+ .and_then(|v| v.as_array())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|v| v.as_str().map(|s| s.to_string()))
+ .collect()
+ })
+ .unwrap_or_default();
+ let position = call.arguments.get("position").and_then(|v| v.as_u64());
+
+ let element = BodyElement::List {
+ ordered,
+ items: items.clone(),
+ };
+ let mut new_body = current_body.to_vec();
+
+ if let Some(pos) = position {
+ let pos = pos as usize;
+ if pos <= new_body.len() {
+ new_body.insert(pos, element);
+ } else {
+ new_body.push(element);
+ }
+ } else {
+ new_body.push(element);
+ }
+
+ let list_type = if ordered { "ordered" } else { "unordered" };
+
+ ToolExecutionResult {
+ result: ToolResult {
+ success: true,
+ message: format!("Added {} list with {} items", list_type, items.len()),
+ },
+ new_body: Some(new_body),
+ new_summary: None,
+ parsed_data: None,
+ version_request: None,
+ pending_questions: None,
+ }
+}
+
fn execute_add_chart(call: &ToolCall, current_body: &[BodyElement]) -> ToolExecutionResult {
let chart_type_str = call
.arguments
@@ -778,6 +939,19 @@ fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool
let text = call.arguments.get("text").and_then(|v| v.as_str()).unwrap_or("").to_string();
BodyElement::Paragraph { text }
}
+ "code" => {
+ let language = call.arguments.get("language").and_then(|v| v.as_str()).map(|s| s.to_string());
+ let content = call.arguments.get("content").and_then(|v| v.as_str()).unwrap_or("").to_string();
+ BodyElement::Code { language, content }
+ }
+ "list" => {
+ let ordered = call.arguments.get("ordered").and_then(|v| v.as_bool()).unwrap_or(false);
+ let items = call.arguments.get("items")
+ .and_then(|v| v.as_array())
+ .map(|arr| arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect())
+ .unwrap_or_default();
+ BodyElement::List { ordered, items }
+ }
"chart" => {
let chart_type_str = call.arguments.get("chartType").and_then(|v| v.as_str()).unwrap_or("bar");
let chart_type = match chart_type_str {
@@ -796,7 +970,7 @@ fn execute_update_element(call: &ToolCall, current_body: &[BodyElement]) -> Tool
return ToolExecutionResult {
result: ToolResult {
success: false,
- message: format!("Unknown element_type: {}. Must be heading, paragraph, or chart.", element_type),
+ message: format!("Unknown element_type: {}. Must be heading, paragraph, code, list, or chart.", element_type),
},
new_body: None,
new_summary: None,
@@ -1149,6 +1323,18 @@ fn execute_view_body(current_body: &[BodyElement]) -> ToolExecutionResult {
"type": "paragraph",
"text": text
}),
+ BodyElement::Code { language, content } => json!({
+ "index": i,
+ "type": "code",
+ "language": language,
+ "content": content
+ }),
+ BodyElement::List { ordered, items } => json!({
+ "index": i,
+ "type": "list",
+ "ordered": ordered,
+ "items": items
+ }),
BodyElement::Chart { chart_type, title, data, config } => json!({
"index": i,
"type": "chart",
@@ -1226,6 +1412,18 @@ fn execute_read_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolEx
"type": "paragraph",
"text": text
}),
+ BodyElement::Code { language, content } => json!({
+ "index": index,
+ "type": "code",
+ "language": language,
+ "content": content
+ }),
+ BodyElement::List { ordered, items } => json!({
+ "index": index,
+ "type": "list",
+ "ordered": ordered,
+ "items": items
+ }),
BodyElement::Chart { chart_type, title, data, config } => json!({
"index": index,
"type": "chart",
@@ -1246,6 +1444,8 @@ fn execute_read_element(call: &ToolCall, current_body: &[BodyElement]) -> ToolEx
let type_str = match element {
BodyElement::Heading { .. } => "heading",
BodyElement::Paragraph { .. } => "paragraph",
+ BodyElement::Code { .. } => "code",
+ BodyElement::List { .. } => "list",
BodyElement::Chart { .. } => "chart",
BodyElement::Image { .. } => "image",
};
diff --git a/makima/src/server/auth.rs b/makima/src/server/auth.rs
new file mode 100644
index 0000000..b694df6
--- /dev/null
+++ b/makima/src/server/auth.rs
@@ -0,0 +1,1238 @@
+//! Authentication module for Makima server.
+//!
+//! Supports multiple authentication methods:
+//! - Supabase JWT tokens for web clients (ES256 or RS256 public key verification)
+//! - API keys for programmatic access (daemons, CLI)
+//! - Tool keys for orchestrator internal access
+
+use axum::{
+ extract::FromRequestParts,
+ http::{header::AUTHORIZATION, request::Parts, HeaderMap, StatusCode},
+ response::IntoResponse,
+ Json,
+};
+use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
+use chrono::{DateTime, Utc};
+use dashmap::DashMap;
+use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
+use rand::Rng;
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use sqlx::{FromRow, PgPool, Row};
+use std::time::{Duration, Instant};
+use utoipa::ToSchema;
+use uuid::Uuid;
+
+use crate::server::messages::ApiError;
+use crate::server::state::SharedState;
+
+// =============================================================================
+// Configuration
+// =============================================================================
+
+/// JWT algorithm configuration.
+#[derive(Debug, Clone)]
+pub enum JwtAlgorithm {
+ /// RS256 with RSA public key
+ Rs256 { public_key: String },
+ /// ES256 with ECDSA public key (Supabase projects with JWT Signing Keys)
+ Es256 { public_key: String },
+}
+
+/// Authentication configuration loaded from environment.
+#[derive(Debug, Clone)]
+pub struct AuthConfig {
+ /// Supabase project URL (e.g., https://your-project.supabase.co)
+ pub supabase_url: String,
+ /// JWT algorithm and key material
+ pub algorithm: JwtAlgorithm,
+}
+
+impl AuthConfig {
+ /// Load auth config from environment variables.
+ ///
+ /// Supports two modes (checked in order):
+ /// - ES256: Set SUPABASE_URL and SUPABASE_JWT_PUBLIC_KEY (Supabase with ECDSA)
+ /// - RS256: Set SUPABASE_URL and SUPABASE_JWT_RSA_PUBLIC_KEY (RSA public key)
+ ///
+ /// Returns None if auth is not configured.
+ pub fn from_env() -> Option<Self> {
+ let supabase_url = std::env::var("SUPABASE_URL").ok()?;
+
+ // Try ES256 first (default for Supabase), then RS256
+ let algorithm = if let Ok(public_key) = std::env::var("SUPABASE_JWT_PUBLIC_KEY") {
+ tracing::info!("Using ES256 JWT verification with ECDSA public key");
+ JwtAlgorithm::Es256 { public_key }
+ } else if let Ok(public_key) = std::env::var("SUPABASE_JWT_RSA_PUBLIC_KEY") {
+ tracing::info!("Using RS256 JWT verification with RSA public key");
+ JwtAlgorithm::Rs256 { public_key }
+ } else {
+ return None;
+ };
+
+ Some(Self {
+ supabase_url,
+ algorithm,
+ })
+ }
+}
+
+// =============================================================================
+// JWT Claims
+// =============================================================================
+
+/// JWT claims from Supabase Auth tokens.
+#[derive(Debug, Serialize, Deserialize)]
+pub struct SupabaseClaims {
+ /// Audience (e.g., "authenticated")
+ pub aud: String,
+ /// Expiration time (Unix timestamp)
+ pub exp: i64,
+ /// Issued at (Unix timestamp)
+ pub iat: i64,
+ /// Issuer (Supabase project URL + /auth/v1)
+ pub iss: String,
+ /// Subject (user ID)
+ pub sub: Uuid,
+ /// User's email
+ pub email: Option<String>,
+ /// User's phone
+ pub phone: Option<String>,
+ /// App metadata (set by server/admin)
+ pub app_metadata: Option<serde_json::Value>,
+ /// User metadata (set by user)
+ pub user_metadata: Option<serde_json::Value>,
+ /// Role (e.g., "authenticated")
+ pub role: Option<String>,
+ /// Session ID
+ pub session_id: Option<Uuid>,
+}
+
+// =============================================================================
+// JWT Verifier
+// =============================================================================
+
+/// JWT verifier for Supabase tokens.
+pub struct JwtVerifier {
+ supabase_url: String,
+ decoding_key: DecodingKey,
+ algorithm: Algorithm,
+}
+
+impl JwtVerifier {
+ /// Create a new JWT verifier from auth config.
+ ///
+ /// Supports multiple key formats:
+ /// - JWK (JSON Web Key) - detected by presence of `{`
+ /// - PEM - detected by `-----BEGIN`
+ /// - Base64-encoded DER - fallback
+ pub fn new(config: AuthConfig) -> Result<Self, AuthError> {
+ let (decoding_key, algorithm) = match &config.algorithm {
+ JwtAlgorithm::Rs256 { public_key } => {
+ let key = Self::parse_public_key(public_key, "RSA")?;
+ (key, Algorithm::RS256)
+ }
+ JwtAlgorithm::Es256 { public_key } => {
+ let key = Self::parse_public_key(public_key, "EC")?;
+ (key, Algorithm::ES256)
+ }
+ };
+
+ Ok(Self {
+ supabase_url: config.supabase_url,
+ decoding_key,
+ algorithm,
+ })
+ }
+
+ /// Parse a public key from various formats (JWK, JWKS, PEM, or base64 DER).
+ fn parse_public_key(key_data: &str, key_type: &str) -> Result<DecodingKey, AuthError> {
+ let trimmed = key_data.trim();
+
+ // Check for JSON format (JWK or JWKS)
+ if trimmed.starts_with('{') {
+ // First try to parse as a generic JSON value to inspect structure
+ let mut json_value: serde_json::Value = serde_json::from_str(trimmed)
+ .map_err(|e| AuthError::InvalidToken(format!("Invalid JSON: {}", e)))?;
+
+ // Check if it's a JWKS (has "keys" array)
+ if let Some(keys) = json_value.get_mut("keys").and_then(|k| k.as_array_mut()) {
+ // Find the first signing key (or just use the first key)
+ let jwk_value = keys.first_mut()
+ .ok_or_else(|| AuthError::InvalidToken("JWKS has no keys".to_string()))?;
+
+ // Remove private key component if present (user may have pasted full keypair)
+ if let Some(obj) = jwk_value.as_object_mut() {
+ if obj.remove("d").is_some() {
+ tracing::warn!("Removed private key component 'd' from JWK - only public key is needed for verification");
+ }
+ }
+
+ let jwk: jsonwebtoken::jwk::Jwk = serde_json::from_value(jwk_value.clone())
+ .map_err(|e| AuthError::InvalidToken(format!("Invalid JWK in JWKS: {}", e)))?;
+
+ tracing::info!("Loaded JWT public key from JWKS (first key)");
+ return DecodingKey::from_jwk(&jwk)
+ .map_err(|e| AuthError::InvalidToken(format!("Failed to create key from JWK: {}", e)));
+ }
+
+ // Remove private key component if present (user may have pasted full keypair)
+ if let Some(obj) = json_value.as_object_mut() {
+ if obj.remove("d").is_some() {
+ tracing::warn!("Removed private key component 'd' from JWK - only public key is needed for verification");
+ }
+ }
+
+ // Try as single JWK
+ let jwk: jsonwebtoken::jwk::Jwk = serde_json::from_value(json_value)
+ .map_err(|e| AuthError::InvalidToken(format!("Invalid JWK: {}", e)))?;
+
+ tracing::info!("Loaded JWT public key from JWK");
+ DecodingKey::from_jwk(&jwk)
+ .map_err(|e| AuthError::InvalidToken(format!("Failed to create key from JWK: {}", e)))
+ }
+ // Check for PEM format
+ else if trimmed.contains("-----BEGIN") {
+ tracing::info!("Loaded JWT public key from PEM");
+ match key_type {
+ "RSA" => DecodingKey::from_rsa_pem(trimmed.as_bytes())
+ .map_err(|e| AuthError::InvalidToken(format!("Invalid RSA PEM key: {}", e))),
+ "EC" => DecodingKey::from_ec_pem(trimmed.as_bytes())
+ .map_err(|e| AuthError::InvalidToken(format!("Invalid EC PEM key: {}", e))),
+ _ => Err(AuthError::InvalidToken(format!("Unknown key type: {}", key_type))),
+ }
+ }
+ // Assume base64-encoded DER
+ else {
+ tracing::info!("Loaded JWT public key from base64 DER");
+ let der_bytes = base64::engine::general_purpose::STANDARD
+ .decode(trimmed)
+ .map_err(|e| AuthError::InvalidToken(format!("Invalid base64 key: {}", e)))?;
+
+ match key_type {
+ "RSA" => Ok(DecodingKey::from_rsa_der(&der_bytes)),
+ "EC" => Ok(DecodingKey::from_ec_der(&der_bytes)),
+ _ => Err(AuthError::InvalidToken(format!("Unknown key type: {}", key_type))),
+ }
+ }
+ }
+
+ /// Verify a JWT token and return claims.
+ pub fn verify(&self, token: &str) -> Result<SupabaseClaims, AuthError> {
+ // Decode header to check algorithm mismatch
+ let header = jsonwebtoken::decode_header(token)
+ .map_err(|e| AuthError::InvalidToken(format!("Invalid JWT header: {}", e)))?;
+
+ tracing::debug!(
+ "JWT header: algorithm={:?}, typ={:?}, kid={:?}",
+ header.alg,
+ header.typ,
+ header.kid
+ );
+
+ if header.alg != self.algorithm {
+ let hint = match header.alg {
+ Algorithm::ES256 => "Set SUPABASE_JWT_PUBLIC_KEY with the EC public key from Supabase Dashboard → Project Settings → API → JWT Settings",
+ Algorithm::RS256 => "Set SUPABASE_JWT_RSA_PUBLIC_KEY with the RSA public key",
+ _ => "Check your Supabase JWT configuration - only ES256 and RS256 are supported",
+ };
+ tracing::warn!(
+ "JWT algorithm mismatch: token uses {:?}, server configured for {:?}. {}",
+ header.alg,
+ self.algorithm,
+ hint
+ );
+ return Err(AuthError::InvalidToken(format!(
+ "Algorithm mismatch: token is {:?}, expected {:?}",
+ header.alg, self.algorithm
+ )));
+ }
+
+ let mut validation = Validation::new(self.algorithm);
+ validation.set_audience(&["authenticated"]);
+ validation.set_issuer(&[format!("{}/auth/v1", self.supabase_url)]);
+
+ // First try with full validation
+ let token_data = match decode::<SupabaseClaims>(token, &self.decoding_key, &validation) {
+ Ok(data) => data,
+ Err(e) => {
+ // Log detailed error info
+ tracing::warn!(
+ "JWT verification failed: {} (algorithm: {:?}, issuer: {}/auth/v1)",
+ e,
+ self.algorithm,
+ self.supabase_url
+ );
+
+ // If it's InvalidAlgorithm, try to understand why by decoding payload manually
+ if matches!(e.kind(), jsonwebtoken::errors::ErrorKind::InvalidAlgorithm) {
+ // Decode the payload part of the JWT manually (base64)
+ let parts: Vec<&str> = token.split('.').collect();
+ if parts.len() >= 2 {
+ if let Ok(payload_bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(parts[1]) {
+ if let Ok(payload_str) = String::from_utf8(payload_bytes) {
+ if let Ok(claims) = serde_json::from_str::<serde_json::Value>(&payload_str) {
+ tracing::warn!(
+ "JWT payload (unverified): iss={:?}, aud={:?}, sub={:?}",
+ claims.get("iss"),
+ claims.get("aud"),
+ claims.get("sub")
+ );
+ }
+ }
+ }
+ }
+ }
+
+ return Err(AuthError::InvalidToken(e.to_string()));
+ }
+ };
+
+ Ok(token_data.claims)
+ }
+
+ /// Extract user ID from a token.
+ pub fn get_user_id(&self, token: &str) -> Result<Uuid, AuthError> {
+ let claims = self.verify(token)?;
+ Ok(claims.sub)
+ }
+}
+
+// =============================================================================
+// Auth Error
+// =============================================================================
+
+/// Authentication error types.
+#[derive(Debug)]
+pub enum AuthError {
+ /// No authentication token provided
+ MissingToken,
+ /// Token format is invalid
+ InvalidToken(String),
+ /// Token has expired
+ ExpiredToken,
+ /// User not found in database
+ UserNotFound,
+ /// API key is invalid or revoked
+ InvalidApiKey,
+ /// Database error during auth lookup
+ DatabaseError(String),
+ /// Authentication is not configured
+ NotConfigured,
+ /// Insufficient permissions for the operation
+ InsufficientPermissions,
+}
+
+impl std::fmt::Display for AuthError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ AuthError::MissingToken => write!(f, "Missing authentication token"),
+ AuthError::InvalidToken(msg) => write!(f, "Invalid token: {}", msg),
+ AuthError::ExpiredToken => write!(f, "Token has expired"),
+ AuthError::UserNotFound => write!(f, "User not found"),
+ AuthError::InvalidApiKey => write!(f, "Invalid or revoked API key"),
+ AuthError::DatabaseError(msg) => write!(f, "Database error: {}", msg),
+ AuthError::NotConfigured => write!(f, "Authentication not configured"),
+ AuthError::InsufficientPermissions => write!(f, "Insufficient permissions"),
+ }
+ }
+}
+
+impl std::error::Error for AuthError {}
+
+impl IntoResponse for AuthError {
+ fn into_response(self) -> axum::response::Response {
+ let (status, code, message) = match &self {
+ AuthError::MissingToken => (StatusCode::UNAUTHORIZED, "MISSING_TOKEN", "Authentication required"),
+ AuthError::InvalidToken(_) => (StatusCode::UNAUTHORIZED, "INVALID_TOKEN", "Invalid authentication token"),
+ AuthError::ExpiredToken => (StatusCode::UNAUTHORIZED, "EXPIRED_TOKEN", "Token has expired"),
+ AuthError::UserNotFound => (StatusCode::UNAUTHORIZED, "USER_NOT_FOUND", "User not found"),
+ AuthError::InvalidApiKey => (StatusCode::UNAUTHORIZED, "INVALID_API_KEY", "Invalid or revoked API key"),
+ AuthError::DatabaseError(_) => (StatusCode::INTERNAL_SERVER_ERROR, "DB_ERROR", "Database error"),
+ AuthError::NotConfigured => (StatusCode::SERVICE_UNAVAILABLE, "AUTH_NOT_CONFIGURED", "Authentication not configured"),
+ AuthError::InsufficientPermissions => (StatusCode::FORBIDDEN, "FORBIDDEN", "Insufficient permissions"),
+ };
+
+ (status, Json(ApiError::new(code, message))).into_response()
+ }
+}
+
+// =============================================================================
+// Auth Source
+// =============================================================================
+
+/// Source of authentication.
+#[derive(Debug, Clone)]
+pub enum AuthSource {
+ /// Authenticated via Supabase JWT (web client)
+ Jwt,
+ /// Authenticated via API key (daemon, CLI, integrations)
+ ApiKey,
+ /// Authenticated via tool key (orchestrator internal access)
+ ToolKey(Uuid),
+}
+
+// =============================================================================
+// Authenticated User
+// =============================================================================
+
+/// Authenticated user context extracted from request.
+///
+/// Contains the resolved user_id and owner_id for database operations.
+#[derive(Debug, Clone)]
+pub struct AuthenticatedUser {
+ /// Supabase auth user ID (from auth.users)
+ pub user_id: Uuid,
+ /// Owner ID for data isolation (from users.default_owner_id)
+ pub owner_id: Uuid,
+ /// How the user was authenticated
+ pub auth_source: AuthSource,
+ /// User's email (if available)
+ pub email: Option<String>,
+}
+
+// =============================================================================
+// Header Constants
+// =============================================================================
+
+/// Header name for tool key authentication (orchestrators).
+pub const TOOL_KEY_HEADER: &str = "x-makima-tool-key";
+
+/// Header name for API key authentication.
+pub const API_KEY_HEADER: &str = "x-makima-api-key";
+
+// =============================================================================
+// Helper Functions
+// =============================================================================
+
+/// Hash an API key for database lookup.
+pub fn hash_api_key(key: &str) -> String {
+ let mut hasher = Sha256::new();
+ hasher.update(key.as_bytes());
+ hex::encode(hasher.finalize())
+}
+
+// =============================================================================
+// API Key Generation
+// =============================================================================
+
+/// API key prefix for identification.
+pub const API_KEY_PREFIX: &str = "mk_";
+
+/// Result of generating an API key.
+pub struct GeneratedApiKey {
+ /// The full API key (shown only once to user)
+ pub full_key: String,
+ /// SHA-256 hash of the key (stored in database)
+ pub key_hash: String,
+ /// Prefix for display (first 8 chars after mk_)
+ pub key_prefix: String,
+}
+
+/// Generate a new API key with mk_ prefix.
+///
+/// Returns the full key (to show once), hash (to store), and prefix (for display).
+pub fn generate_api_key() -> GeneratedApiKey {
+ let mut rng = rand::thread_rng();
+ let mut bytes = [0u8; 32];
+ rng.fill(&mut bytes);
+
+ let key_bytes = URL_SAFE_NO_PAD.encode(bytes);
+ let full_key = format!("{}{}", API_KEY_PREFIX, key_bytes);
+
+ let key_hash = hash_api_key(&full_key);
+ let key_prefix = format!("{}{}", API_KEY_PREFIX, &key_bytes[..8]);
+
+ GeneratedApiKey {
+ full_key,
+ key_hash,
+ key_prefix,
+ }
+}
+
+// =============================================================================
+// API Key Cache
+// =============================================================================
+
+/// Cache entry for validated API keys.
+struct ApiKeyCacheEntry {
+ user_id: Uuid,
+ owner_id: Uuid,
+ cached_at: Instant,
+}
+
+/// In-memory cache for API key validation to avoid database lookups on every request.
+pub struct ApiKeyCache {
+ /// key_hash -> (user_id, owner_id, cached_at)
+ cache: DashMap<String, ApiKeyCacheEntry>,
+ /// Time-to-live for cache entries
+ ttl: Duration,
+}
+
+impl ApiKeyCache {
+ /// Create a new cache with the specified TTL in seconds.
+ pub fn new(ttl_seconds: u64) -> Self {
+ Self {
+ cache: DashMap::new(),
+ ttl: Duration::from_secs(ttl_seconds),
+ }
+ }
+
+ /// Get cached user_id and owner_id for a key hash, if not expired.
+ pub fn get(&self, key_hash: &str) -> Option<(Uuid, Uuid)> {
+ self.cache.get(key_hash).and_then(|entry| {
+ if entry.cached_at.elapsed() < self.ttl {
+ Some((entry.user_id, entry.owner_id))
+ } else {
+ None
+ }
+ })
+ }
+
+ /// Cache a validated API key.
+ pub fn set(&self, key_hash: String, user_id: Uuid, owner_id: Uuid) {
+ self.cache.insert(
+ key_hash,
+ ApiKeyCacheEntry {
+ user_id,
+ owner_id,
+ cached_at: Instant::now(),
+ },
+ );
+ }
+
+ /// Invalidate a cache entry (e.g., on key revocation).
+ pub fn invalidate(&self, key_hash: &str) {
+ self.cache.remove(key_hash);
+ }
+
+ /// Clear all cache entries.
+ pub fn clear(&self) {
+ self.cache.clear();
+ }
+}
+
+impl Default for ApiKeyCache {
+ fn default() -> Self {
+ // Default TTL: 5 minutes
+ Self::new(300)
+ }
+}
+
+// =============================================================================
+// API Key Models
+// =============================================================================
+
+/// API key record from the database.
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ApiKey {
+ pub id: Uuid,
+ pub user_id: Uuid,
+ #[serde(skip)]
+ pub key_hash: String,
+ pub key_prefix: String,
+ pub name: Option<String>,
+ pub last_used_at: Option<DateTime<Utc>>,
+ pub created_at: DateTime<Utc>,
+ pub revoked_at: Option<DateTime<Utc>>,
+}
+
+/// Request to create a new API key.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateApiKeyRequest {
+ /// User-provided label for the key
+ pub name: Option<String>,
+}
+
+/// Response after creating an API key (includes the full key - shown only once).
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateApiKeyResponse {
+ pub id: Uuid,
+ /// The full API key - save this, it won't be shown again!
+ pub key: String,
+ pub prefix: String,
+ pub name: Option<String>,
+ pub created_at: DateTime<Utc>,
+}
+
+/// Response for getting API key info (excludes the full key).
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ApiKeyInfoResponse {
+ pub id: Uuid,
+ pub prefix: String,
+ pub name: Option<String>,
+ pub last_used_at: Option<DateTime<Utc>>,
+ pub created_at: DateTime<Utc>,
+}
+
+impl From<ApiKey> for ApiKeyInfoResponse {
+ fn from(key: ApiKey) -> Self {
+ Self {
+ id: key.id,
+ prefix: key.key_prefix,
+ name: key.name,
+ last_used_at: key.last_used_at,
+ created_at: key.created_at,
+ }
+ }
+}
+
+/// Request to refresh an API key.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct RefreshApiKeyRequest {
+ /// New name for the refreshed key
+ pub name: Option<String>,
+}
+
+/// Response after refreshing an API key.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct RefreshApiKeyResponse {
+ pub id: Uuid,
+ /// The new API key - save this, it won't be shown again!
+ pub key: String,
+ pub prefix: String,
+ pub name: Option<String>,
+ pub created_at: DateTime<Utc>,
+ pub previous_key_revoked: bool,
+}
+
+/// Response after revoking an API key.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct RevokeApiKeyResponse {
+ pub message: String,
+ pub revoked_key_prefix: String,
+}
+
+/// API key event types for audit logging.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ApiKeyEventType {
+ Created,
+ Used,
+ Revoked,
+ Refreshed,
+}
+
+impl std::fmt::Display for ApiKeyEventType {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ ApiKeyEventType::Created => write!(f, "created"),
+ ApiKeyEventType::Used => write!(f, "used"),
+ ApiKeyEventType::Revoked => write!(f, "revoked"),
+ ApiKeyEventType::Refreshed => write!(f, "refreshed"),
+ }
+ }
+}
+
+// =============================================================================
+// API Keys Repository
+// =============================================================================
+
+/// Repository error for API key operations.
+#[derive(Debug)]
+pub enum ApiKeyError {
+ /// Database error
+ Database(sqlx::Error),
+ /// An active API key already exists for this user
+ KeyAlreadyExists,
+ /// No active API key found for this user
+ KeyNotFound,
+}
+
+impl std::fmt::Display for ApiKeyError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ ApiKeyError::Database(e) => write!(f, "Database error: {}", e),
+ ApiKeyError::KeyAlreadyExists => write!(f, "An active API key already exists"),
+ ApiKeyError::KeyNotFound => write!(f, "No active API key found"),
+ }
+ }
+}
+
+impl std::error::Error for ApiKeyError {}
+
+impl From<sqlx::Error> for ApiKeyError {
+ fn from(e: sqlx::Error) -> Self {
+ ApiKeyError::Database(e)
+ }
+}
+
+/// Get the active API key for a user (if any).
+pub async fn get_active_api_key(pool: &PgPool, user_id: Uuid) -> Result<Option<ApiKey>, sqlx::Error> {
+ sqlx::query_as::<_, ApiKey>(
+ r#"
+ SELECT id, user_id, key_hash, key_prefix, name, last_used_at, created_at, revoked_at
+ FROM api_keys
+ WHERE user_id = $1 AND revoked_at IS NULL
+ "#,
+ )
+ .bind(user_id)
+ .fetch_optional(pool)
+ .await
+}
+
+/// Create a new API key for a user.
+///
+/// Returns an error if the user already has an active key.
+/// The `generated` parameter should be created using `generate_api_key()`.
+pub async fn create_api_key(
+ pool: &PgPool,
+ user_id: Uuid,
+ generated: &GeneratedApiKey,
+ name: Option<&str>,
+) -> Result<ApiKey, ApiKeyError> {
+ // Check if user already has an active key
+ if let Some(_) = get_active_api_key(pool, user_id).await? {
+ return Err(ApiKeyError::KeyAlreadyExists);
+ }
+
+ let key = sqlx::query_as::<_, ApiKey>(
+ r#"
+ INSERT INTO api_keys (user_id, key_hash, key_prefix, name)
+ VALUES ($1, $2, $3, $4)
+ RETURNING id, user_id, key_hash, key_prefix, name, last_used_at, created_at, revoked_at
+ "#,
+ )
+ .bind(user_id)
+ .bind(&generated.key_hash)
+ .bind(&generated.key_prefix)
+ .bind(name)
+ .fetch_one(pool)
+ .await?;
+
+ // Log the creation event
+ let _ = log_api_key_event(pool, key.id, ApiKeyEventType::Created, None, None).await;
+
+ Ok(key)
+}
+
+/// Revoke an API key by marking it with revoked_at timestamp.
+pub async fn revoke_api_key(pool: &PgPool, user_id: Uuid) -> Result<ApiKey, ApiKeyError> {
+ // Get the active key first
+ let key = get_active_api_key(pool, user_id)
+ .await?
+ .ok_or(ApiKeyError::KeyNotFound)?;
+
+ // Revoke it
+ let revoked = sqlx::query_as::<_, ApiKey>(
+ r#"
+ UPDATE api_keys
+ SET revoked_at = NOW()
+ WHERE id = $1
+ RETURNING id, user_id, key_hash, key_prefix, name, last_used_at, created_at, revoked_at
+ "#,
+ )
+ .bind(key.id)
+ .fetch_one(pool)
+ .await?;
+
+ // Log the revocation event
+ let _ = log_api_key_event(pool, revoked.id, ApiKeyEventType::Revoked, None, None).await;
+
+ Ok(revoked)
+}
+
+/// Refresh an API key: revoke the old one and create a new one atomically.
+///
+/// Returns the new key. The caller should use `generate_api_key()` to create
+/// the `new_generated` parameter.
+pub async fn refresh_api_key(
+ pool: &PgPool,
+ user_id: Uuid,
+ new_generated: &GeneratedApiKey,
+ new_name: Option<&str>,
+) -> Result<(ApiKey, Option<String>), ApiKeyError> {
+ // Get and revoke the old key (if exists)
+ let old_prefix = if let Some(old_key) = get_active_api_key(pool, user_id).await? {
+ let old_prefix = old_key.key_prefix.clone();
+
+ // Revoke the old key
+ sqlx::query(
+ r#"
+ UPDATE api_keys
+ SET revoked_at = NOW()
+ WHERE id = $1
+ "#,
+ )
+ .bind(old_key.id)
+ .execute(pool)
+ .await?;
+
+ // Log the refresh event on the old key
+ let _ = log_api_key_event(pool, old_key.id, ApiKeyEventType::Refreshed, None, None).await;
+
+ Some(old_prefix)
+ } else {
+ None
+ };
+
+ // Create the new key
+ let new_key = sqlx::query_as::<_, ApiKey>(
+ r#"
+ INSERT INTO api_keys (user_id, key_hash, key_prefix, name)
+ VALUES ($1, $2, $3, $4)
+ RETURNING id, user_id, key_hash, key_prefix, name, last_used_at, created_at, revoked_at
+ "#,
+ )
+ .bind(user_id)
+ .bind(&new_generated.key_hash)
+ .bind(&new_generated.key_prefix)
+ .bind(new_name)
+ .fetch_one(pool)
+ .await?;
+
+ // Log the creation event on the new key
+ let _ = log_api_key_event(pool, new_key.id, ApiKeyEventType::Created, None, None).await;
+
+ Ok((new_key, old_prefix))
+}
+
+/// Update last_used_at timestamp for an API key.
+pub async fn update_api_key_last_used(pool: &PgPool, key_hash: &str) -> Result<(), sqlx::Error> {
+ sqlx::query(
+ r#"
+ UPDATE api_keys
+ SET last_used_at = NOW()
+ WHERE key_hash = $1 AND revoked_at IS NULL
+ "#,
+ )
+ .bind(key_hash)
+ .execute(pool)
+ .await?;
+
+ Ok(())
+}
+
+/// Log an API key event for audit purposes.
+pub async fn log_api_key_event(
+ pool: &PgPool,
+ api_key_id: Uuid,
+ event_type: ApiKeyEventType,
+ ip_address: Option<&str>,
+ user_agent: Option<&str>,
+) -> Result<(), sqlx::Error> {
+ sqlx::query(
+ r#"
+ INSERT INTO api_key_events (api_key_id, event_type, ip_address, user_agent)
+ VALUES ($1, $2, $3::inet, $4)
+ "#,
+ )
+ .bind(api_key_id)
+ .bind(event_type.to_string())
+ .bind(ip_address)
+ .bind(user_agent)
+ .execute(pool)
+ .await?;
+
+ Ok(())
+}
+
+// =============================================================================
+// Internal Helper Functions
+// =============================================================================
+
+/// Resolve owner_id from user_id by looking up the users table.
+/// If the user doesn't exist, auto-creates them on first login.
+/// Uses ON CONFLICT to handle race conditions when multiple requests arrive simultaneously.
+async fn resolve_owner_id(pool: &PgPool, user_id: Uuid, email: Option<&str>) -> Result<Uuid, AuthError> {
+ // First, try to get existing user
+ let row = sqlx::query("SELECT default_owner_id FROM users WHERE id = $1")
+ .bind(user_id)
+ .fetch_optional(pool)
+ .await
+ .map_err(|e| AuthError::DatabaseError(e.to_string()))?;
+
+ if let Some(row) = row {
+ let owner_id: Option<Uuid> = row.try_get("default_owner_id")
+ .map_err(|e| AuthError::DatabaseError(e.to_string()))?;
+ return owner_id.ok_or(AuthError::UserNotFound);
+ }
+
+ // User doesn't exist - auto-create on first login
+ tracing::info!("Creating new user record for {}", user_id);
+
+ // Create owner first (use ON CONFLICT to handle race conditions)
+ let owner_id = Uuid::new_v4();
+ sqlx::query("INSERT INTO owners (id, name) VALUES ($1, $2) ON CONFLICT DO NOTHING")
+ .bind(owner_id)
+ .bind(email.unwrap_or("Unknown"))
+ .execute(pool)
+ .await
+ .map_err(|e| AuthError::DatabaseError(format!("Failed to create owner: {}", e)))?;
+
+ // Create user with reference to owner (use ON CONFLICT to handle race conditions)
+ sqlx::query(
+ "INSERT INTO users (id, email, default_owner_id) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING"
+ )
+ .bind(user_id)
+ .bind(email)
+ .bind(owner_id)
+ .execute(pool)
+ .await
+ .map_err(|e| AuthError::DatabaseError(format!("Failed to create user: {}", e)))?;
+
+ // Re-fetch the user to get the actual owner_id (in case another request created it first)
+ let row = sqlx::query("SELECT default_owner_id FROM users WHERE id = $1")
+ .bind(user_id)
+ .fetch_optional(pool)
+ .await
+ .map_err(|e| AuthError::DatabaseError(e.to_string()))?;
+
+ match row {
+ Some(row) => {
+ let owner_id: Option<Uuid> = row.try_get("default_owner_id")
+ .map_err(|e| AuthError::DatabaseError(e.to_string()))?;
+ owner_id.ok_or(AuthError::UserNotFound)
+ }
+ None => Err(AuthError::DatabaseError("Failed to create user record".to_string()))
+ }
+}
+
+/// Validate an API key and return (user_id, owner_id).
+async fn validate_api_key(pool: &PgPool, key: &str) -> Result<(Uuid, Uuid), AuthError> {
+ let key_hash = hash_api_key(key);
+
+ // Look up the API key and join with users to get owner_id
+ let row = sqlx::query(
+ r#"
+ SELECT ak.user_id, u.default_owner_id
+ FROM api_keys ak
+ JOIN users u ON u.id = ak.user_id
+ WHERE ak.key_hash = $1 AND ak.revoked_at IS NULL
+ "#,
+ )
+ .bind(&key_hash)
+ .fetch_optional(pool)
+ .await
+ .map_err(|e| AuthError::DatabaseError(e.to_string()))?;
+
+ match row {
+ Some(row) => {
+ let user_id: Uuid = row.try_get("user_id")
+ .map_err(|e| AuthError::DatabaseError(e.to_string()))?;
+ let owner_id: Option<Uuid> = row.try_get("default_owner_id")
+ .map_err(|e| AuthError::DatabaseError(e.to_string()))?;
+ let owner_id = owner_id.ok_or(AuthError::UserNotFound)?;
+
+ // Update last_used_at asynchronously (fire and forget)
+ let pool_clone = pool.clone();
+ let key_hash_clone = key_hash.clone();
+ tokio::spawn(async move {
+ let _ = sqlx::query("UPDATE api_keys SET last_used_at = NOW() WHERE key_hash = $1")
+ .bind(&key_hash_clone)
+ .execute(&pool_clone)
+ .await;
+ });
+
+ Ok((user_id, owner_id))
+ }
+ None => Err(AuthError::InvalidApiKey),
+ }
+}
+
+/// Extract authentication from request headers.
+///
+/// Tries authentication methods in order:
+/// 1. Tool Key (X-Makima-Tool-Key) - for orchestrators
+/// 2. API Key (X-Makima-API-Key) - for daemons/CLI
+/// 3. JWT (Authorization: Bearer) - for web clients
+async fn extract_auth(
+ state: &SharedState,
+ headers: &HeaderMap,
+) -> Result<AuthenticatedUser, AuthError> {
+ // 1. Check for tool key (orchestrator access)
+ if let Some(tool_key) = headers.get(TOOL_KEY_HEADER) {
+ if let Ok(key_str) = tool_key.to_str() {
+ if let Some(task_id) = state.validate_tool_key(key_str) {
+ // Tool keys are trusted - use a placeholder user/owner for orchestrator actions
+ // The orchestrator inherits the owner_id from its task
+ let pool = state.db_pool.as_ref().ok_or(AuthError::NotConfigured)?;
+
+ // Get owner_id from the task
+ let row = sqlx::query("SELECT owner_id FROM tasks WHERE id = $1")
+ .bind(task_id)
+ .fetch_optional(pool)
+ .await
+ .map_err(|e| AuthError::DatabaseError(e.to_string()))?
+ .ok_or(AuthError::UserNotFound)?;
+
+ let task_owner: Uuid = row.try_get("owner_id")
+ .map_err(|e| AuthError::DatabaseError(e.to_string()))?;
+
+ return Ok(AuthenticatedUser {
+ user_id: Uuid::nil(), // Tool keys don't have a user
+ owner_id: task_owner,
+ auth_source: AuthSource::ToolKey(task_id),
+ email: None,
+ });
+ }
+ tracing::warn!("Invalid tool key provided");
+ }
+ }
+
+ // 2. Check for API key
+ if let Some(api_key) = headers.get(API_KEY_HEADER) {
+ if let Ok(key_str) = api_key.to_str() {
+ let pool = state.db_pool.as_ref().ok_or(AuthError::NotConfigured)?;
+ let (user_id, owner_id) = validate_api_key(pool, key_str).await?;
+
+ return Ok(AuthenticatedUser {
+ user_id,
+ owner_id,
+ auth_source: AuthSource::ApiKey,
+ email: None,
+ });
+ }
+ }
+
+ // 3. Check for JWT (Bearer token)
+ if let Some(auth_header) = headers.get(AUTHORIZATION) {
+ if let Ok(auth_str) = auth_header.to_str() {
+ if let Some(token) = auth_str.strip_prefix("Bearer ") {
+ let verifier = state
+ .jwt_verifier
+ .as_ref()
+ .ok_or(AuthError::NotConfigured)?;
+
+ let claims = verifier.verify(token)?;
+ let pool = state.db_pool.as_ref().ok_or(AuthError::NotConfigured)?;
+ let owner_id = resolve_owner_id(pool, claims.sub, claims.email.as_deref()).await?;
+
+ return Ok(AuthenticatedUser {
+ user_id: claims.sub,
+ owner_id,
+ auth_source: AuthSource::Jwt,
+ email: claims.email,
+ });
+ }
+ }
+ }
+
+ Err(AuthError::MissingToken)
+}
+
+// =============================================================================
+// Extractors
+// =============================================================================
+
+/// Extractor for authenticated requests.
+///
+/// Tries authentication methods in order:
+/// 1. Tool Key (X-Makima-Tool-Key) - for orchestrators
+/// 2. API Key (X-Makima-API-Key) - for daemons/CLI
+/// 3. JWT (Authorization: Bearer) - for web clients
+///
+/// Returns 401 Unauthorized if no valid authentication is found.
+///
+/// # Example
+/// ```ignore
+/// async fn protected_handler(
+/// Authenticated(user): Authenticated,
+/// ) -> impl IntoResponse {
+/// Json(format!("Hello user {}", user.user_id))
+/// }
+/// ```
+pub struct Authenticated(pub AuthenticatedUser);
+
+impl FromRequestParts<SharedState> for Authenticated {
+ type Rejection = AuthError;
+
+ async fn from_request_parts(
+ parts: &mut Parts,
+ state: &SharedState,
+ ) -> Result<Self, Self::Rejection> {
+ let user = extract_auth(state, &parts.headers).await?;
+ Ok(Authenticated(user))
+ }
+}
+
+/// Extractor for user-only authentication (JWT or API key, no tool keys).
+///
+/// Use this for endpoints that should only be accessible to actual users,
+/// not orchestrators with tool keys.
+///
+/// Returns 401 Unauthorized if no valid user authentication is found.
+/// Returns 403 Forbidden if a tool key is used.
+///
+/// # Example
+/// ```ignore
+/// async fn user_profile(
+/// UserOnly(user): UserOnly,
+/// ) -> impl IntoResponse {
+/// // Only actual users can access this
+/// Json(format!("User profile for {}", user.user_id))
+/// }
+/// ```
+pub struct UserOnly(pub AuthenticatedUser);
+
+impl FromRequestParts<SharedState> for UserOnly {
+ type Rejection = AuthError;
+
+ async fn from_request_parts(
+ parts: &mut Parts,
+ state: &SharedState,
+ ) -> Result<Self, Self::Rejection> {
+ let user = extract_auth(state, &parts.headers).await?;
+
+ // Reject tool key authentication
+ if matches!(user.auth_source, AuthSource::ToolKey(_)) {
+ return Err(AuthError::InsufficientPermissions);
+ }
+
+ Ok(UserOnly(user))
+ }
+}
+
+/// Extractor for optional authentication.
+///
+/// Returns Some(AuthenticatedUser) if valid auth is provided, None otherwise.
+/// Never returns an error - invalid auth is treated as no auth.
+///
+/// # Example
+/// ```ignore
+/// async fn public_or_private(
+/// MaybeAuthenticated(user): MaybeAuthenticated,
+/// ) -> impl IntoResponse {
+/// match user {
+/// Some(u) => Json(format!("Hello {}", u.user_id)),
+/// None => Json("Hello anonymous".to_string()),
+/// }
+/// }
+/// ```
+pub struct MaybeAuthenticated(pub Option<AuthenticatedUser>);
+
+impl FromRequestParts<SharedState> for MaybeAuthenticated {
+ type Rejection = std::convert::Infallible;
+
+ async fn from_request_parts(
+ parts: &mut Parts,
+ state: &SharedState,
+ ) -> Result<Self, Self::Rejection> {
+ let user = extract_auth(state, &parts.headers).await.ok();
+ Ok(MaybeAuthenticated(user))
+ }
+}
+
+// =============================================================================
+// Tests
+// =============================================================================
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_hash_api_key() {
+ let key = "mk_test123456789";
+ let hash = hash_api_key(key);
+
+ // Hash should be consistent
+ assert_eq!(hash, hash_api_key(key));
+
+ // Hash should be 64 characters (SHA-256 hex)
+ assert_eq!(hash.len(), 64);
+ }
+
+ #[test]
+ fn test_auth_error_display() {
+ assert_eq!(
+ AuthError::MissingToken.to_string(),
+ "Missing authentication token"
+ );
+ assert_eq!(
+ AuthError::InvalidToken("bad".to_string()).to_string(),
+ "Invalid token: bad"
+ );
+ }
+
+ #[test]
+ fn test_generate_api_key_format() {
+ let generated = generate_api_key();
+
+ // Full key should start with mk_ prefix
+ assert!(generated.full_key.starts_with(API_KEY_PREFIX));
+
+ // Full key should be mk_ + 43 chars (32 bytes base64url encoded)
+ assert_eq!(generated.full_key.len(), 3 + 43); // "mk_" + 43
+
+ // Prefix should be mk_ + first 8 chars
+ assert!(generated.key_prefix.starts_with(API_KEY_PREFIX));
+ assert_eq!(generated.key_prefix.len(), 3 + 8);
+
+ // Hash should be 64 hex chars (SHA-256)
+ assert_eq!(generated.key_hash.len(), 64);
+ }
+
+ #[test]
+ fn test_generate_api_key_uniqueness() {
+ let key1 = generate_api_key();
+ let key2 = generate_api_key();
+
+ // Keys should be unique
+ assert_ne!(key1.full_key, key2.full_key);
+ assert_ne!(key1.key_hash, key2.key_hash);
+ }
+
+ #[test]
+ fn test_api_key_cache_basic() {
+ let cache = ApiKeyCache::new(300);
+ let user_id = Uuid::new_v4();
+ let owner_id = Uuid::new_v4();
+ let key_hash = "test_hash_123";
+
+ // Cache miss initially
+ assert!(cache.get(key_hash).is_none());
+
+ // Set and verify cache hit
+ cache.set(key_hash.to_string(), user_id, owner_id);
+ let result = cache.get(key_hash);
+ assert!(result.is_some());
+ let (cached_user, cached_owner) = result.unwrap();
+ assert_eq!(cached_user, user_id);
+ assert_eq!(cached_owner, owner_id);
+ }
+
+ #[test]
+ fn test_api_key_cache_invalidate() {
+ let cache = ApiKeyCache::new(300);
+ let user_id = Uuid::new_v4();
+ let owner_id = Uuid::new_v4();
+ let key_hash = "test_hash_456";
+
+ cache.set(key_hash.to_string(), user_id, owner_id);
+ assert!(cache.get(key_hash).is_some());
+
+ cache.invalidate(key_hash);
+ assert!(cache.get(key_hash).is_none());
+ }
+
+ #[test]
+ fn test_api_key_cache_clear() {
+ let cache = ApiKeyCache::new(300);
+
+ cache.set("hash1".to_string(), Uuid::new_v4(), Uuid::new_v4());
+ cache.set("hash2".to_string(), Uuid::new_v4(), Uuid::new_v4());
+
+ assert!(cache.get("hash1").is_some());
+ assert!(cache.get("hash2").is_some());
+
+ cache.clear();
+
+ assert!(cache.get("hash1").is_none());
+ assert!(cache.get("hash2").is_none());
+ }
+
+ #[test]
+ fn test_api_key_event_type_display() {
+ assert_eq!(ApiKeyEventType::Created.to_string(), "created");
+ assert_eq!(ApiKeyEventType::Used.to_string(), "used");
+ assert_eq!(ApiKeyEventType::Revoked.to_string(), "revoked");
+ assert_eq!(ApiKeyEventType::Refreshed.to_string(), "refreshed");
+ }
+}
diff --git a/makima/src/server/handlers/api_keys.rs b/makima/src/server/handlers/api_keys.rs
new file mode 100644
index 0000000..5a678a2
--- /dev/null
+++ b/makima/src/server/handlers/api_keys.rs
@@ -0,0 +1,282 @@
+//! HTTP handlers for API key management.
+//!
+//! These endpoints allow users to create, view, refresh, and revoke their API keys.
+//! API keys are used for daemon authentication and programmatic access.
+
+use axum::{
+ extract::State,
+ http::StatusCode,
+ response::IntoResponse,
+ Json,
+};
+
+use crate::server::auth::{
+ create_api_key, generate_api_key, get_active_api_key, refresh_api_key, revoke_api_key,
+ ApiKeyError, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse,
+ RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse, UserOnly,
+};
+use crate::server::messages::ApiError;
+use crate::server::state::SharedState;
+
+/// Create a new API key for the authenticated user.
+///
+/// Each user can only have one active API key at a time. If an existing key
+/// exists, this will return a 409 Conflict error - use the refresh endpoint
+/// to replace the existing key, or revoke it first.
+#[utoipa::path(
+ post,
+ path = "/api/v1/auth/api-keys",
+ request_body = CreateApiKeyRequest,
+ responses(
+ (status = 201, description = "API key created", body = CreateApiKeyResponse),
+ (status = 401, description = "Not authenticated", body = ApiError),
+ (status = 403, description = "Forbidden (tool keys not allowed)", body = ApiError),
+ (status = 409, description = "API key already exists", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = [])
+ ),
+ tag = "API Keys"
+)]
+pub async fn create_api_key_handler(
+ State(state): State<SharedState>,
+ UserOnly(user): UserOnly,
+ Json(req): Json<CreateApiKeyRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Generate a new API key
+ let generated = generate_api_key();
+
+ match create_api_key(pool, user.user_id, &generated, req.name.as_deref()).await {
+ Ok(key) => {
+ let response = CreateApiKeyResponse {
+ id: key.id,
+ key: generated.full_key,
+ prefix: key.key_prefix,
+ name: key.name,
+ created_at: key.created_at,
+ };
+ (StatusCode::CREATED, Json(response)).into_response()
+ }
+ Err(ApiKeyError::KeyAlreadyExists) => (
+ StatusCode::CONFLICT,
+ Json(ApiError::new(
+ "KEY_EXISTS",
+ "An active API key already exists. Revoke it first or use refresh.",
+ )),
+ )
+ .into_response(),
+ Err(ApiKeyError::Database(e)) => {
+ tracing::error!("Failed to create API key: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to create API key: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Get information about the current active API key.
+///
+/// Returns the key's ID, prefix (for identification), name, and timestamps.
+/// The full key is never returned - it was only shown once when created.
+#[utoipa::path(
+ get,
+ path = "/api/v1/auth/api-keys",
+ responses(
+ (status = 200, description = "API key info", body = ApiKeyInfoResponse),
+ (status = 401, description = "Not authenticated", body = ApiError),
+ (status = 403, description = "Forbidden (tool keys not allowed)", body = ApiError),
+ (status = 404, description = "No active API key", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = [])
+ ),
+ tag = "API Keys"
+)]
+pub async fn get_api_key_handler(
+ State(state): State<SharedState>,
+ UserOnly(user): UserOnly,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match get_active_api_key(pool, user.user_id).await {
+ Ok(Some(key)) => {
+ let response: ApiKeyInfoResponse = key.into();
+ Json(response).into_response()
+ }
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NO_KEY", "No active API key found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to get API key: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Refresh the current API key.
+///
+/// This revokes the existing key (if any) and creates a new one atomically.
+/// Use this for key rotation without downtime.
+#[utoipa::path(
+ post,
+ path = "/api/v1/auth/api-keys/refresh",
+ request_body = RefreshApiKeyRequest,
+ responses(
+ (status = 200, description = "API key refreshed", body = RefreshApiKeyResponse),
+ (status = 401, description = "Not authenticated", body = ApiError),
+ (status = 403, description = "Forbidden (tool keys not allowed)", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = [])
+ ),
+ tag = "API Keys"
+)]
+pub async fn refresh_api_key_handler(
+ State(state): State<SharedState>,
+ UserOnly(user): UserOnly,
+ Json(req): Json<RefreshApiKeyRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Generate a new API key
+ let generated = generate_api_key();
+
+ match refresh_api_key(pool, user.user_id, &generated, req.name.as_deref()).await {
+ Ok((key, old_prefix)) => {
+ // Invalidate cache for the old key if we had a cache
+ // (The cache lookup is by hash, but we revoked the old key in DB so it won't match)
+
+ let response = RefreshApiKeyResponse {
+ id: key.id,
+ key: generated.full_key,
+ prefix: key.key_prefix,
+ name: key.name,
+ created_at: key.created_at,
+ previous_key_revoked: old_prefix.is_some(),
+ };
+ Json(response).into_response()
+ }
+ Err(ApiKeyError::Database(e)) => {
+ tracing::error!("Failed to refresh API key: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to refresh API key: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Revoke the current active API key.
+///
+/// After revocation, the key can no longer be used for authentication.
+/// A new key can be created after revocation.
+#[utoipa::path(
+ delete,
+ path = "/api/v1/auth/api-keys",
+ responses(
+ (status = 200, description = "API key revoked", body = RevokeApiKeyResponse),
+ (status = 401, description = "Not authenticated", body = ApiError),
+ (status = 403, description = "Forbidden (tool keys not allowed)", body = ApiError),
+ (status = 404, description = "No active API key", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = [])
+ ),
+ tag = "API Keys"
+)]
+pub async fn revoke_api_key_handler(
+ State(state): State<SharedState>,
+ UserOnly(user): UserOnly,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match revoke_api_key(pool, user.user_id).await {
+ Ok(key) => {
+ let response = RevokeApiKeyResponse {
+ message: "API key revoked successfully".to_string(),
+ revoked_key_prefix: key.key_prefix,
+ };
+ Json(response).into_response()
+ }
+ Err(ApiKeyError::KeyNotFound) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NO_KEY", "No active API key found")),
+ )
+ .into_response(),
+ Err(ApiKeyError::Database(e)) => {
+ tracing::error!("Failed to revoke API key: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to revoke API key: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
diff --git a/makima/src/server/handlers/chat.rs b/makima/src/server/handlers/chat.rs
index 51f17c1..dfdb64e 100644
--- a/makima/src/server/handlers/chat.rs
+++ b/makima/src/server/handlers/chat.rs
@@ -53,6 +53,9 @@ pub struct ChatRequest {
/// Optional conversation history for context continuity
#[serde(default)]
pub history: Option<Vec<ChatHistoryMessage>>,
+ /// Optional focused element index (for targeted editing)
+ #[serde(default)]
+ pub focused_element_index: Option<usize>,
}
#[derive(Debug, Serialize, ToSchema)]
@@ -232,6 +235,9 @@ pub async fn chat_handler(
// Build context about the file
let file_context = build_file_context(&file);
+ // Build focused element context if specified
+ let focused_context = build_focused_element_context(&file.body, request.focused_element_index);
+
// Build agentic system prompt
let system_prompt = format!(
r#"You are an intelligent document editing agent. You help users view, analyze, and modify document files.
@@ -274,13 +280,14 @@ You have access to tools for:
## Current Document Context
{file_context}
-
+{focused_context}
## Important Notes
- Body element indices are 0-based
- When updating elements, provide ALL required fields for that element type
- The transcript is read-only (you cannot modify it, only read it)
- Changes are saved automatically after tool execution"#,
- file_context = file_context
+ file_context = file_context,
+ focused_context = focused_context
);
// Build initial messages (Groq/OpenAI format - will be converted for Claude)
@@ -690,12 +697,25 @@ fn build_file_context(file: &crate::db::models::File) -> String {
let desc = match element {
BodyElement::Heading { level, text } => format!("H{}: {}", level, text),
BodyElement::Paragraph { text } => {
- let preview = if text.len() > 50 {
- format!("{}...", &text[..50])
+ let preview: String = text.chars().take(50).collect();
+ if text.chars().count() > 50 {
+ format!("Paragraph: {}...", preview)
} else {
- text.clone()
- };
- format!("Paragraph: {}", preview)
+ format!("Paragraph: {}", preview)
+ }
+ }
+ BodyElement::Code { language, content } => {
+ let lang = language.as_deref().unwrap_or("plain");
+ let preview: String = content.chars().take(50).collect();
+ if content.chars().count() > 50 {
+ format!("Code ({}): {}...", lang, preview)
+ } else {
+ format!("Code ({}): {}", lang, preview)
+ }
+ }
+ BodyElement::List { ordered, items } => {
+ let list_type = if *ordered { "ordered" } else { "unordered" };
+ format!("List ({}): {} items", list_type, items.len())
}
BodyElement::Chart { chart_type, title, .. } => {
format!(
@@ -726,6 +746,64 @@ fn build_file_context(file: &crate::db::models::File) -> String {
context
}
+/// Build context for a focused element
+fn build_focused_element_context(body: &[BodyElement], focused_index: Option<usize>) -> String {
+ let Some(index) = focused_index else {
+ return String::new();
+ };
+
+ let Some(element) = body.get(index) else {
+ return format!(
+ "\n## Focused Element\nNote: User focused on element [{}] but it doesn't exist (document has {} elements).\n",
+ index,
+ body.len()
+ );
+ };
+
+ let (element_type, full_content) = match element {
+ BodyElement::Heading { level, text } => {
+ (format!("Heading (level {})", level), text.clone())
+ }
+ BodyElement::Paragraph { text } => {
+ ("Paragraph".to_string(), text.clone())
+ }
+ BodyElement::Code { language, content } => {
+ let lang = language.as_deref().unwrap_or("plain");
+ (format!("Code ({})", lang), content.clone())
+ }
+ BodyElement::List { ordered, items } => {
+ let list_type = if *ordered { "Ordered list" } else { "Unordered list" };
+ let content = items.iter()
+ .enumerate()
+ .map(|(i, item)| format!("{}. {}", i + 1, item))
+ .collect::<Vec<_>>()
+ .join("\n");
+ (list_type.to_string(), content)
+ }
+ BodyElement::Chart { chart_type, title, .. } => {
+ let title_str = title.as_deref().unwrap_or("untitled");
+ (format!("Chart ({:?})", chart_type), title_str.to_string())
+ }
+ BodyElement::Image { alt, caption, .. } => {
+ let desc = alt.as_deref().or(caption.as_deref()).unwrap_or("no description");
+ ("Image".to_string(), desc.to_string())
+ }
+ };
+
+ format!(
+ r#"
+## Focused Element
+The user is focusing on element [{}]: {}
+Full content of focused element:
+---
+{}
+---
+When the user's request is ambiguous about which element to modify, prioritize this focused element.
+"#,
+ index, element_type, full_content
+ )
+}
+
/// Result of handling a version tool request
struct VersionRequestResult {
result: ToolResult,
@@ -795,12 +873,25 @@ async fn handle_version_request(
let desc = match element {
BodyElement::Heading { level, text } => format!("H{}: {}", level, text),
BodyElement::Paragraph { text } => {
- let preview = if text.len() > 100 {
- format!("{}...", &text[..100])
+ let preview: String = text.chars().take(100).collect();
+ if text.chars().count() > 100 {
+ format!("Paragraph: {}...", preview)
} else {
- text.clone()
- };
- format!("Paragraph: {}", preview)
+ format!("Paragraph: {}", preview)
+ }
+ }
+ BodyElement::Code { language, content } => {
+ let lang = language.as_deref().unwrap_or("plain");
+ let preview: String = content.chars().take(100).collect();
+ if content.chars().count() > 100 {
+ format!("Code ({}): {}...", lang, preview)
+ } else {
+ format!("Code ({}): {}", lang, preview)
+ }
+ }
+ BodyElement::List { ordered, items } => {
+ let list_type = if *ordered { "ordered" } else { "unordered" };
+ format!("List ({}): {} items", list_type, items.len())
}
BodyElement::Chart { chart_type, title, .. } => {
format!(
diff --git a/makima/src/server/handlers/files.rs b/makima/src/server/handlers/files.rs
index c65eed5..9634b73 100644
--- a/makima/src/server/handlers/files.rs
+++ b/makima/src/server/handlers/files.rs
@@ -10,21 +10,30 @@ use uuid::Uuid;
use crate::db::models::{CreateFileRequest, FileListResponse, FileSummary, UpdateFileRequest};
use crate::db::repository::{self, RepositoryError};
+use crate::server::auth::Authenticated;
use crate::server::messages::ApiError;
use crate::server::state::{FileUpdateNotification, SharedState};
-/// List all files for the current owner.
+/// List all files for the authenticated user's owner.
#[utoipa::path(
get,
path = "/api/v1/files",
responses(
(status = 200, description = "List of files", body = FileListResponse),
+ (status = 401, description = "Unauthorized", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
(status = 500, description = "Internal server error", body = ApiError),
),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
tag = "Files"
)]
-pub async fn list_files(State(state): State<SharedState>) -> impl IntoResponse {
+pub async fn list_files(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
return (
StatusCode::SERVICE_UNAVAILABLE,
@@ -33,7 +42,7 @@ pub async fn list_files(State(state): State<SharedState>) -> impl IntoResponse {
.into_response();
};
- match repository::list_files(pool).await {
+ match repository::list_files_for_owner(pool, auth.owner_id).await {
Ok(files) => {
let summaries: Vec<FileSummary> = files.into_iter().map(FileSummary::from).collect();
let total = summaries.len() as i64;
@@ -54,7 +63,7 @@ pub async fn list_files(State(state): State<SharedState>) -> impl IntoResponse {
}
}
-/// Get a single file by ID.
+/// Get a single file by ID (scoped by owner).
#[utoipa::path(
get,
path = "/api/v1/files/{id}",
@@ -63,14 +72,20 @@ pub async fn list_files(State(state): State<SharedState>) -> impl IntoResponse {
),
responses(
(status = 200, description = "File details", body = crate::db::models::File),
+ (status = 401, description = "Unauthorized", body = ApiError),
(status = 404, description = "File not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
(status = 500, description = "Internal server error", body = ApiError),
),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
tag = "Files"
)]
pub async fn get_file(
State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
@@ -81,7 +96,7 @@ pub async fn get_file(
.into_response();
};
- match repository::get_file(pool, id).await {
+ match repository::get_file_for_owner(pool, id, auth.owner_id).await {
Ok(Some(file)) => Json(file).into_response(),
Ok(None) => (
StatusCode::NOT_FOUND,
@@ -107,13 +122,19 @@ pub async fn get_file(
responses(
(status = 201, description = "File created", body = crate::db::models::File),
(status = 400, description = "Invalid request", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
(status = 500, description = "Internal server error", body = ApiError),
),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
tag = "Files"
)]
pub async fn create_file(
State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
Json(req): Json<CreateFileRequest>,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
@@ -124,7 +145,7 @@ pub async fn create_file(
.into_response();
};
- match repository::create_file(pool, req).await {
+ match repository::create_file_for_owner(pool, auth.owner_id, req).await {
Ok(file) => (StatusCode::CREATED, Json(file)).into_response(),
Err(e) => {
tracing::error!("Failed to create file: {}", e);
@@ -137,7 +158,7 @@ pub async fn create_file(
}
}
-/// Update an existing file.
+/// Update an existing file (scoped by owner).
#[utoipa::path(
put,
path = "/api/v1/files/{id}",
@@ -147,15 +168,21 @@ pub async fn create_file(
request_body = UpdateFileRequest,
responses(
(status = 200, description = "File updated", body = crate::db::models::File),
+ (status = 401, description = "Unauthorized", body = ApiError),
(status = 404, description = "File not found", body = ApiError),
(status = 409, description = "Version conflict", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
(status = 500, description = "Internal server error", body = ApiError),
),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
tag = "Files"
)]
pub async fn update_file(
State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
Json(req): Json<UpdateFileRequest>,
) -> impl IntoResponse {
@@ -185,7 +212,7 @@ pub async fn update_file(
updated_fields.push("body".to_string());
}
- match repository::update_file(pool, id, req).await {
+ match repository::update_file_for_owner(pool, id, auth.owner_id, req).await {
Ok(Some(file)) => {
// Broadcast update notification
state.broadcast_file_update(FileUpdateNotification {
@@ -233,7 +260,7 @@ pub async fn update_file(
}
}
-/// Delete a file.
+/// Delete a file (scoped by owner).
#[utoipa::path(
delete,
path = "/api/v1/files/{id}",
@@ -242,14 +269,20 @@ pub async fn update_file(
),
responses(
(status = 204, description = "File deleted"),
+ (status = 401, description = "Unauthorized", body = ApiError),
(status = 404, description = "File not found", body = ApiError),
(status = 503, description = "Database not configured", body = ApiError),
(status = 500, description = "Internal server error", body = ApiError),
),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
tag = "Files"
)]
pub async fn delete_file(
State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let Some(ref pool) = state.db_pool else {
@@ -260,7 +293,7 @@ pub async fn delete_file(
.into_response();
};
- match repository::delete_file(pool, id).await {
+ match repository::delete_file_for_owner(pool, id, auth.owner_id).await {
Ok(true) => StatusCode::NO_CONTENT.into_response(),
Ok(false) => (
StatusCode::NOT_FOUND,
diff --git a/makima/src/server/handlers/mesh.rs b/makima/src/server/handlers/mesh.rs
new file mode 100644
index 0000000..760740c
--- /dev/null
+++ b/makima/src/server/handlers/mesh.rs
@@ -0,0 +1,1679 @@
+//! HTTP handlers for task and daemon mesh operations.
+
+use axum::{
+ extract::{Path, State},
+ http::{HeaderMap, StatusCode},
+ response::IntoResponse,
+ Json,
+};
+use uuid::Uuid;
+
+use crate::db::models::{
+ CreateTaskRequest, DaemonDirectory, DaemonDirectoriesResponse, DaemonListResponse,
+ SendMessageRequest, Task, TaskEventListResponse, TaskListResponse, TaskOutputEntry,
+ TaskOutputResponse, TaskWithSubtasks, UpdateTaskRequest,
+};
+use crate::db::repository::{self, RepositoryError};
+use crate::server::auth::Authenticated;
+use crate::server::messages::ApiError;
+use crate::server::state::{DaemonCommand, SharedState, TaskUpdateNotification};
+
+// =============================================================================
+// Authentication Types
+// =============================================================================
+
+/// Source of authentication for mesh endpoints.
+#[derive(Debug, Clone)]
+pub enum AuthSource {
+ /// Authenticated via tool key (orchestrator accessing API).
+ /// Contains the task ID that owns this key.
+ ToolKey(Uuid),
+ /// Authenticated via user token (web client).
+ /// Contains the user ID. (Not implemented yet)
+ #[allow(dead_code)]
+ UserToken(Uuid),
+ /// No authentication provided (anonymous access).
+ Anonymous,
+}
+
+/// Header name for tool key authentication.
+pub const TOOL_KEY_HEADER: &str = "x-makima-tool-key";
+
+/// Extract authentication source from request headers.
+///
+/// Checks for:
+/// 1. `X-Makima-Tool-Key` header for orchestrator tool access
+/// 2. `Authorization: Bearer` header for user access (future)
+/// 3. Falls back to Anonymous if no auth provided
+pub fn extract_auth(state: &SharedState, headers: &HeaderMap) -> AuthSource {
+ // Check for tool key header first
+ if let Some(tool_key) = headers.get(TOOL_KEY_HEADER) {
+ if let Ok(key_str) = tool_key.to_str() {
+ if let Some(task_id) = state.validate_tool_key(key_str) {
+ return AuthSource::ToolKey(task_id);
+ }
+ tracing::warn!("Invalid tool key provided");
+ }
+ }
+
+ // Check for Authorization header (future user auth)
+ if let Some(auth_header) = headers.get("authorization") {
+ if let Ok(auth_str) = auth_header.to_str() {
+ if auth_str.starts_with("Bearer ") {
+ // Future: validate JWT and extract user ID
+ tracing::debug!("Bearer token auth not yet implemented");
+ }
+ }
+ }
+
+ // Default to anonymous
+ AuthSource::Anonymous
+}
+
+// =============================================================================
+// Task Handlers
+// =============================================================================
+
+/// List all tasks for the current owner.
+#[utoipa::path(
+ get,
+ path = "/api/v1/mesh/tasks",
+ responses(
+ (status = 200, description = "List of tasks", body = TaskListResponse),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn list_tasks(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::list_tasks_for_owner(pool, auth.owner_id).await {
+ Ok(tasks) => {
+ let total = tasks.len() as i64;
+ Json(TaskListResponse { tasks, total }).into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to list tasks: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Get a single task by ID with its subtasks (scoped by owner).
+#[utoipa::path(
+ get,
+ path = "/api/v1/mesh/tasks/{id}",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ responses(
+ (status = 200, description = "Task details with subtasks", body = TaskWithSubtasks),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Task not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn get_task(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::get_task_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(task)) => {
+ // Get subtasks for this task (also scoped by owner)
+ match repository::list_subtasks_for_owner(pool, id, auth.owner_id).await {
+ Ok(subtasks) => Json(TaskWithSubtasks { task, subtasks }).into_response(),
+ Err(e) => {
+ tracing::error!("Failed to get subtasks for task {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+ }
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to get task {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Create a new task.
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/tasks",
+ request_body = CreateTaskRequest,
+ responses(
+ (status = 201, description = "Task created", body = Task),
+ (status = 400, description = "Invalid request", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn create_task(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Json(req): Json<CreateTaskRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::create_task_for_owner(pool, auth.owner_id, req).await {
+ Ok(task) => (StatusCode::CREATED, Json(task)).into_response(),
+ Err(e) => {
+ tracing::error!("Failed to create task: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Update an existing task (scoped by owner).
+#[utoipa::path(
+ put,
+ path = "/api/v1/mesh/tasks/{id}",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ request_body = UpdateTaskRequest,
+ responses(
+ (status = 200, description = "Task updated", body = Task),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Task not found", body = ApiError),
+ (status = 409, description = "Version conflict", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn update_task(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<UpdateTaskRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Track which fields are being updated for the notification
+ let mut updated_fields = Vec::new();
+ if req.name.is_some() {
+ updated_fields.push("name".to_string());
+ }
+ if req.description.is_some() {
+ updated_fields.push("description".to_string());
+ }
+ if req.status.is_some() {
+ updated_fields.push("status".to_string());
+ }
+ if req.priority.is_some() {
+ updated_fields.push("priority".to_string());
+ }
+ if req.plan.is_some() {
+ updated_fields.push("plan".to_string());
+ }
+ if req.progress_summary.is_some() {
+ updated_fields.push("progress_summary".to_string());
+ }
+ if req.error_message.is_some() {
+ updated_fields.push("error_message".to_string());
+ }
+
+ match repository::update_task_for_owner(pool, id, auth.owner_id, req).await {
+ Ok(Some(task)) => {
+ // Broadcast task update notification
+ state.broadcast_task_update(TaskUpdateNotification {
+ task_id: task.id,
+ owner_id: Some(auth.owner_id),
+ version: task.version,
+ status: task.status.clone(),
+ updated_fields,
+ updated_by: "user".to_string(),
+ });
+ Json(task).into_response()
+ }
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ )
+ .into_response(),
+ Err(RepositoryError::VersionConflict { expected, actual }) => {
+ tracing::info!(
+ "Version conflict on task {}: expected {}, actual {}",
+ id,
+ expected,
+ actual
+ );
+ (
+ StatusCode::CONFLICT,
+ Json(serde_json::json!({
+ "code": "VERSION_CONFLICT",
+ "message": format!(
+ "Task was modified by another user. Expected version {}, actual version {}",
+ expected, actual
+ ),
+ "expectedVersion": expected,
+ "actualVersion": actual,
+ })),
+ )
+ .into_response()
+ }
+ Err(RepositoryError::Database(e)) => {
+ tracing::error!("Failed to update task {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Delete a task (scoped by owner).
+#[utoipa::path(
+ delete,
+ path = "/api/v1/mesh/tasks/{id}",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ responses(
+ (status = 204, description = "Task deleted"),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Task not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn delete_task(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Get the task first to check if it's running and needs to be stopped
+ if let Ok(Some(task)) = repository::get_task_for_owner(pool, id, auth.owner_id).await {
+ let is_active = matches!(
+ task.status.as_str(),
+ "running" | "starting" | "initializing" | "paused"
+ );
+
+ // If task is active and has a daemon, send interrupt command
+ if is_active {
+ if let Some(daemon_id) = task.daemon_id {
+ let command = DaemonCommand::InterruptTask {
+ task_id: id,
+ graceful: false,
+ };
+ if let Err(e) = state.send_daemon_command(daemon_id, command).await {
+ tracing::warn!(
+ task_id = %id,
+ daemon_id = %daemon_id,
+ "Failed to send InterruptTask before delete: {}",
+ e
+ );
+ } else {
+ tracing::info!(
+ task_id = %id,
+ daemon_id = %daemon_id,
+ "Sent InterruptTask before delete"
+ );
+ }
+ }
+ }
+ }
+
+ match repository::delete_task_for_owner(pool, id, auth.owner_id).await {
+ Ok(true) => StatusCode::NO_CONTENT.into_response(),
+ Ok(false) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to delete task {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Start a task by sending it to an available daemon (scoped by owner).
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/tasks/{id}/start",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ responses(
+ (status = 200, description = "Task started", body = Task),
+ (status = 400, description = "Task cannot be started", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Task not found", body = ApiError),
+ (status = 503, description = "Database not configured or no daemons available", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn start_task(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ headers: HeaderMap,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ // Extract authentication to log who is starting the task
+ let legacy_auth = extract_auth(&state, &headers);
+ match &legacy_auth {
+ AuthSource::ToolKey(orchestrator_id) => {
+ tracing::info!(
+ task_id = %id,
+ orchestrator_task_id = %orchestrator_id,
+ owner_id = %auth.owner_id,
+ "Orchestrator starting subtask via tool key"
+ );
+ }
+ AuthSource::Anonymous => {
+ tracing::info!(
+ task_id = %id,
+ owner_id = %auth.owner_id,
+ "Starting task (user request)"
+ );
+ }
+ AuthSource::UserToken(user_id) => {
+ tracing::info!(
+ task_id = %id,
+ user_id = %user_id,
+ owner_id = %auth.owner_id,
+ "Starting task via user token"
+ );
+ }
+ }
+
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Get the task (scoped by owner)
+ let task = match repository::get_task_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get task {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ // Check if task can be started (allow pending, failed, interrupted, done, or merged)
+ let startable_statuses = ["pending", "failed", "interrupted", "done", "merged"];
+ if !startable_statuses.contains(&task.status.as_str()) {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new(
+ "INVALID_STATE",
+ format!("Task cannot be started from status: {}", task.status),
+ )),
+ )
+ .into_response();
+ }
+
+ // Find an available daemon belonging to this owner
+ let target_daemon_id = match state.daemon_connections
+ .iter()
+ .find(|d| d.value().owner_id == auth.owner_id)
+ {
+ Some(d) => d.value().id,
+ None => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new(
+ "NO_DAEMON",
+ "No daemons connected for your account. Cannot start task.",
+ )),
+ )
+ .into_response();
+ }
+ };
+
+ // Check if this is an orchestrator (depth 0 with subtasks)
+ let subtask_count = match repository::list_subtasks_for_owner(pool, id, auth.owner_id).await {
+ Ok(subtasks) => {
+ tracing::info!(
+ task_id = %id,
+ subtask_count = subtasks.len(),
+ subtask_ids = ?subtasks.iter().map(|s| s.id.to_string()).collect::<Vec<_>>(),
+ "Counted subtasks for orchestrator check"
+ );
+ subtasks.len()
+ },
+ Err(e) => {
+ tracing::warn!("Failed to check subtasks for {}: {}", id, e);
+ 0
+ }
+ };
+ let is_orchestrator = task.depth == 0 && subtask_count > 0;
+
+ tracing::info!(
+ task_id = %id,
+ task_depth = task.depth,
+ subtask_count = subtask_count,
+ is_orchestrator = is_orchestrator,
+ "Starting task with orchestrator determination"
+ );
+
+ // IMPORTANT: Update database FIRST to assign daemon_id before sending command
+ // This prevents race conditions where the task starts but daemon_id is not set
+ let update_req = UpdateTaskRequest {
+ status: Some("starting".to_string()),
+ daemon_id: Some(target_daemon_id),
+ version: Some(task.version),
+ ..Default::default()
+ };
+
+ let updated_task = match repository::update_task_for_owner(pool, id, auth.owner_id, update_req).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to update task status: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ // Send SpawnTask command to daemon
+ let command = DaemonCommand::SpawnTask {
+ task_id: id,
+ task_name: task.name.clone(),
+ plan: task.plan.clone(),
+ repo_url: task.repository_url.clone(),
+ base_branch: task.base_branch.clone(),
+ target_branch: task.target_branch.clone(),
+ parent_task_id: task.parent_task_id,
+ depth: task.depth,
+ is_orchestrator,
+ target_repo_path: task.target_repo_path.clone(),
+ completion_action: task.completion_action.clone(),
+ continue_from_task_id: task.continue_from_task_id,
+ copy_files: task.copy_files.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()),
+ };
+
+ if let Err(e) = state.send_daemon_command(target_daemon_id, command).await {
+ tracing::error!("Failed to send SpawnTask command: {}", e);
+ // Rollback: clear daemon_id and reset status since command failed
+ let rollback_req = UpdateTaskRequest {
+ status: Some("pending".to_string()),
+ clear_daemon_id: true, // Explicitly clear daemon_id
+ ..Default::default()
+ };
+ let _ = repository::update_task_for_owner(pool, id, auth.owner_id, rollback_req).await;
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DAEMON_ERROR", e)),
+ )
+ .into_response();
+ }
+
+ // Broadcast task update notification
+ state.broadcast_task_update(TaskUpdateNotification {
+ task_id: id,
+ owner_id: Some(auth.owner_id),
+ version: updated_task.version,
+ status: "starting".to_string(),
+ updated_fields: vec!["status".to_string(), "daemon_id".to_string()],
+ updated_by: "system".to_string(),
+ });
+
+ Json(updated_task).into_response()
+}
+
+/// Stop a running task (scoped by owner).
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/tasks/{id}/stop",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ responses(
+ (status = 200, description = "Task stopped", body = Task),
+ (status = 400, description = "Task is not running", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Task not found", body = ApiError),
+ (status = 503, description = "Database not configured or daemon not connected", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn stop_task(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Get the task (scoped by owner)
+ let task = match repository::get_task_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get task {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ // Check if task is running/active
+ let is_active = matches!(
+ task.status.as_str(),
+ "running" | "starting" | "initializing" | "paused"
+ );
+ if !is_active {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new(
+ "INVALID_STATE",
+ format!("Task cannot be stopped from status: {}", task.status),
+ )),
+ )
+ .into_response();
+ }
+
+ // Find the daemon running this task
+ let target_daemon_id = if let Some(daemon_id) = task.daemon_id {
+ daemon_id
+ } else {
+ // No daemon assigned, just update status directly
+ let update_req = UpdateTaskRequest {
+ status: Some("failed".to_string()),
+ error_message: Some("Task stopped by user".to_string()),
+ version: Some(task.version),
+ ..Default::default()
+ };
+
+ return match repository::update_task_for_owner(pool, id, auth.owner_id, update_req).await {
+ Ok(Some(updated_task)) => {
+ state.broadcast_task_update(TaskUpdateNotification {
+ task_id: id,
+ owner_id: Some(auth.owner_id),
+ version: updated_task.version,
+ status: "failed".to_string(),
+ updated_fields: vec!["status".to_string(), "error_message".to_string()],
+ updated_by: "user".to_string(),
+ });
+ Json(updated_task).into_response()
+ }
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to update task status: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ };
+ };
+
+ // Send InterruptTask command to daemon
+ let command = DaemonCommand::InterruptTask {
+ task_id: id,
+ graceful: false,
+ };
+
+ if let Err(e) = state.send_daemon_command(target_daemon_id, command).await {
+ tracing::warn!("Failed to send InterruptTask command: {}", e);
+ // Daemon might be disconnected - update task status directly
+ let update_req = UpdateTaskRequest {
+ status: Some("failed".to_string()),
+ error_message: Some("Task stopped by user (daemon unavailable)".to_string()),
+ version: Some(task.version),
+ ..Default::default()
+ };
+
+ return match repository::update_task_for_owner(pool, id, auth.owner_id, update_req).await {
+ Ok(Some(updated_task)) => {
+ state.broadcast_task_update(TaskUpdateNotification {
+ task_id: id,
+ owner_id: Some(auth.owner_id),
+ version: updated_task.version,
+ status: "failed".to_string(),
+ updated_fields: vec!["status".to_string(), "error_message".to_string()],
+ updated_by: "user".to_string(),
+ });
+ Json(updated_task).into_response()
+ }
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to update task status: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ };
+ }
+
+ // Update task status to "failed" (stopped)
+ let update_req = UpdateTaskRequest {
+ status: Some("failed".to_string()),
+ error_message: Some("Task stopped by user".to_string()),
+ version: Some(task.version),
+ ..Default::default()
+ };
+
+ match repository::update_task_for_owner(pool, id, auth.owner_id, update_req).await {
+ Ok(Some(updated_task)) => {
+ // Broadcast task update notification
+ state.broadcast_task_update(TaskUpdateNotification {
+ task_id: id,
+ owner_id: Some(auth.owner_id),
+ version: updated_task.version,
+ status: "failed".to_string(),
+ updated_fields: vec!["status".to_string(), "error_message".to_string()],
+ updated_by: "user".to_string(),
+ });
+
+ Json(updated_task).into_response()
+ }
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to update task status: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Send a message to a running task's stdin (scoped by owner).
+///
+/// This can be used to provide input to Claude Code when it's waiting for user input,
+/// or to inject context/instructions into a running task.
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/tasks/{id}/message",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ request_body = SendMessageRequest,
+ responses(
+ (status = 200, description = "Message sent successfully"),
+ (status = 400, description = "Task is not running", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Task not found", body = ApiError),
+ (status = 503, description = "Database not configured or daemon not connected", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn send_message(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(req): Json<SendMessageRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Get the task (scoped by owner)
+ let task = match repository::get_task_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get task {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ // Check if task is running
+ if task.status != "running" {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new(
+ "INVALID_STATE",
+ format!(
+ "Cannot send message to task in status: {}. Task must be running.",
+ task.status
+ ),
+ )),
+ )
+ .into_response();
+ }
+
+ // Find the daemon running this task
+ let target_daemon_id = if let Some(daemon_id) = task.daemon_id {
+ daemon_id
+ } else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new(
+ "NO_DAEMON",
+ "Task has no assigned daemon. Cannot send message.",
+ )),
+ )
+ .into_response();
+ };
+
+ // Send SendMessage command to daemon
+ let command = DaemonCommand::SendMessage {
+ task_id: id,
+ message: req.message.clone(),
+ };
+
+ if let Err(e) = state.send_daemon_command(target_daemon_id, command).await {
+ tracing::error!("Failed to send SendMessage command: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DAEMON_ERROR", e)),
+ )
+ .into_response();
+ }
+
+ tracing::info!(task_id = %id, message_len = req.message.len(), "Message sent to task");
+
+ // Return success
+ (
+ StatusCode::OK,
+ Json(serde_json::json!({
+ "success": true,
+ "taskId": id,
+ "messageLength": req.message.len()
+ })),
+ )
+ .into_response()
+}
+
+/// Get task output history (scoped by owner).
+///
+/// Retrieves all recorded output from a task's Claude Code process.
+/// This allows the frontend to fetch missed output when subscribing late
+/// or reconnecting after a disconnect.
+#[utoipa::path(
+ get,
+ path = "/api/v1/mesh/tasks/{id}/output",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ responses(
+ (status = 200, description = "Task output history", body = TaskOutputResponse),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Task not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn get_task_output(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify task exists and belongs to owner
+ match repository::get_task_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get task: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ // Get output history (task already verified to belong to owner)
+ match repository::get_task_output(pool, id, None).await {
+ Ok(events) => {
+ let entries: Vec<TaskOutputEntry> = events
+ .into_iter()
+ .filter_map(TaskOutputEntry::from_task_event)
+ .collect();
+ let total = entries.len();
+
+ Json(TaskOutputResponse {
+ entries,
+ total,
+ task_id: id,
+ })
+ .into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to get task output: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// List subtasks for a parent task (scoped by owner).
+#[utoipa::path(
+ get,
+ path = "/api/v1/mesh/tasks/{id}/subtasks",
+ params(
+ ("id" = Uuid, Path, description = "Parent task ID")
+ ),
+ responses(
+ (status = 200, description = "List of subtasks", body = TaskListResponse),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn list_subtasks(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::list_subtasks_for_owner(pool, id, auth.owner_id).await {
+ Ok(tasks) => {
+ let total = tasks.len() as i64;
+ Json(TaskListResponse { tasks, total }).into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to list subtasks for task {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// List events for a task (scoped by owner).
+#[utoipa::path(
+ get,
+ path = "/api/v1/mesh/tasks/{id}/events",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ responses(
+ (status = 200, description = "List of task events", body = TaskEventListResponse),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Task not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn list_task_events(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Verify task exists and belongs to owner
+ match repository::get_task_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(_)) => {}
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get task: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ }
+
+ match repository::list_task_events(pool, id, None).await {
+ Ok(events) => {
+ let total = events.len() as i64;
+ Json(TaskEventListResponse { events, total }).into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to list events for task {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Retry completion action for a completed task (scoped by owner).
+///
+/// This allows retrying a completion action (push branch, merge, create PR)
+/// after filling in the target_repo_path if it wasn't set when the task completed.
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/tasks/{id}/retry-completion",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ responses(
+ (status = 200, description = "Completion action initiated"),
+ (status = 400, description = "Invalid request (task not completed, no completion action, etc.)", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Task not found", body = ApiError),
+ (status = 503, description = "Database not configured or daemon not connected", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn retry_completion_action(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Get the task (scoped by owner)
+ let task = match repository::get_task_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get task {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ // Check if task is in a terminal state
+ let terminal_statuses = ["done", "failed", "merged"];
+ if !terminal_statuses.contains(&task.status.as_str()) {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new(
+ "INVALID_STATE",
+ format!(
+ "Task must be completed to retry completion action. Current status: {}",
+ task.status
+ ),
+ )),
+ )
+ .into_response();
+ }
+
+ // Check if completion action is set
+ let action = match &task.completion_action {
+ Some(action) if action != "none" => action.clone(),
+ _ => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new(
+ "NO_COMPLETION_ACTION",
+ "Task has no completion action configured (or is set to 'none')",
+ )),
+ )
+ .into_response();
+ }
+ };
+
+ // Check if target_repo_path is set
+ let target_repo_path = match &task.target_repo_path {
+ Some(path) if !path.is_empty() => path.clone(),
+ _ => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new(
+ "NO_TARGET_REPO",
+ "Target repository path must be set before retrying completion action",
+ )),
+ )
+ .into_response();
+ }
+ };
+
+ // Note: We don't check overlay_path here because the server may not have it
+ // The daemon will scan its worktrees directory to find the worktree by task ID
+
+ // Find a daemon to execute the action (must belong to this owner)
+ // Prefer the daemon that ran the task, but fall back to any available daemon for this owner
+ let target_daemon_id = if let Some(daemon_id) = task.daemon_id {
+ // Check if this daemon is still connected and belongs to this owner
+ if state.daemon_connections.iter().any(|d| d.value().id == daemon_id && d.value().owner_id == auth.owner_id) {
+ daemon_id
+ } else {
+ // Fall back to any connected daemon for this owner
+ match state.daemon_connections.iter().find(|d| d.value().owner_id == auth.owner_id) {
+ Some(d) => d.value().id,
+ None => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new(
+ "NO_DAEMON",
+ "No daemons connected for your account. Cannot execute completion action.",
+ )),
+ )
+ .into_response();
+ }
+ }
+ }
+ } else {
+ // No daemon assigned - use any available for this owner
+ match state.daemon_connections.iter().find(|d| d.value().owner_id == auth.owner_id) {
+ Some(d) => d.value().id,
+ None => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new(
+ "NO_DAEMON",
+ "No daemons connected for your account. Cannot execute completion action.",
+ )),
+ )
+ .into_response();
+ }
+ }
+ };
+
+ // Send RetryCompletionAction command to daemon
+ let command = DaemonCommand::RetryCompletionAction {
+ task_id: id,
+ task_name: task.name.clone(),
+ action: action.clone(),
+ target_repo_path: target_repo_path.clone(),
+ target_branch: task.target_branch.clone(),
+ };
+
+ if let Err(e) = state.send_daemon_command(target_daemon_id, command).await {
+ tracing::error!("Failed to send RetryCompletionAction command: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DAEMON_ERROR", e)),
+ )
+ .into_response();
+ }
+
+ tracing::info!(
+ task_id = %id,
+ action = %action,
+ target_repo = %target_repo_path,
+ "Retry completion action initiated"
+ );
+
+ (
+ StatusCode::OK,
+ Json(serde_json::json!({
+ "success": true,
+ "taskId": id,
+ "action": action,
+ "targetRepoPath": target_repo_path,
+ "message": "Completion action initiated. Check task output for results."
+ })),
+ )
+ .into_response()
+}
+
+// =============================================================================
+// Daemon Handlers
+// =============================================================================
+
+/// List all connected daemons (requires authentication).
+#[utoipa::path(
+ get,
+ path = "/api/v1/mesh/daemons",
+ responses(
+ (status = 200, description = "List of daemons", body = DaemonListResponse),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn list_daemons(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Only list daemons belonging to this owner
+ match repository::list_daemons_for_owner(pool, auth.owner_id).await {
+ Ok(daemons) => {
+ let total = daemons.len() as i64;
+ Json(DaemonListResponse { daemons, total }).into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to list daemons: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Get a single daemon by ID (requires authentication).
+#[utoipa::path(
+ get,
+ path = "/api/v1/mesh/daemons/{id}",
+ params(
+ ("id" = Uuid, Path, description = "Daemon ID")
+ ),
+ responses(
+ (status = 200, description = "Daemon details", body = crate::db::models::Daemon),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Daemon not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn get_daemon(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Only get daemon if it belongs to this owner
+ match repository::get_daemon_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(daemon)) => Json(daemon).into_response(),
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Daemon not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to get daemon {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Get suggested directories from connected daemons (requires authentication).
+///
+/// Returns directories that can be used as target_repo_path for completion actions.
+#[utoipa::path(
+ get,
+ path = "/api/v1/mesh/daemons/directories",
+ responses(
+ (status = 200, description = "List of suggested directories", body = DaemonDirectoriesResponse),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn get_daemon_directories(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+) -> impl IntoResponse {
+ let mut directories = Vec::new();
+
+ // Iterate over connected daemons belonging to this owner and collect their directories
+ for entry in state.daemon_connections.iter() {
+ let daemon = entry.value();
+
+ // Only include daemons belonging to this owner
+ if daemon.owner_id != auth.owner_id {
+ continue;
+ }
+
+ // Add working directory if available
+ if let Some(ref working_dir) = daemon.working_directory {
+ directories.push(DaemonDirectory {
+ path: working_dir.clone(),
+ label: "Working Directory".to_string(),
+ directory_type: "working".to_string(),
+ hostname: daemon.hostname.clone(),
+ exists: None,
+ });
+ }
+
+ // Add home directory if available (for cloning completed work)
+ if let Some(ref home_dir) = daemon.home_directory {
+ directories.push(DaemonDirectory {
+ path: home_dir.clone(),
+ label: "Makima Home".to_string(),
+ directory_type: "home".to_string(),
+ hostname: daemon.hostname.clone(),
+ exists: None,
+ });
+ }
+ }
+
+ Json(DaemonDirectoriesResponse { directories })
+}
+
+/// Request to clone a worktree to a target directory.
+#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CloneWorktreeRequest {
+ /// Path to the target directory.
+ pub target_dir: String,
+}
+
+/// Clone a task's worktree to a target directory (scoped by owner).
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/tasks/{id}/clone",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ request_body = CloneWorktreeRequest,
+ responses(
+ (status = 200, description = "Clone command sent"),
+ (status = 400, description = "Invalid request or task not completed", body = ApiError),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 404, description = "Task not found", body = ApiError),
+ (status = 503, description = "Database not configured or daemon not connected", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn clone_worktree(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(body): Json<CloneWorktreeRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Get the task (scoped by owner)
+ let task = match repository::get_task_for_owner(pool, id, auth.owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "Task not found")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to get task {}: {}", id, e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response();
+ }
+ };
+
+ // Verify task is in a completed state
+ let is_completed = matches!(task.status.as_str(), "done" | "failed" | "merged");
+ if !is_completed {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new(
+ "INVALID_STATE",
+ format!("Task must be completed to clone (current status: {})", task.status),
+ )),
+ )
+ .into_response();
+ }
+
+ // Find a connected daemon belonging to this owner to send the command
+ let daemon_entry = state.daemon_connections.iter().find(|d| d.value().owner_id == auth.owner_id);
+ let daemon_id = match daemon_entry {
+ Some(entry) => entry.value().id,
+ None => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("NO_DAEMON", "No daemon connected for your account")),
+ )
+ .into_response();
+ }
+ };
+
+ // Send CloneWorktree command to daemon
+ let command = DaemonCommand::CloneWorktree {
+ task_id: id,
+ target_dir: body.target_dir.clone(),
+ };
+
+ if let Err(e) = state.send_daemon_command(daemon_id, command).await {
+ tracing::error!("Failed to send CloneWorktree command: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DAEMON_ERROR", e)),
+ )
+ .into_response();
+ }
+
+ Json(serde_json::json!({
+ "status": "cloning",
+ "taskId": id.to_string(),
+ "targetDir": body.target_dir,
+ }))
+ .into_response()
+}
+
+/// Request to check if a target directory exists.
+#[derive(Debug, serde::Deserialize, utoipa::ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CheckTargetExistsRequest {
+ /// Path to check.
+ pub target_dir: String,
+}
+
+/// Response for check target exists.
+#[derive(Debug, serde::Serialize, utoipa::ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CheckTargetExistsResponse {
+ /// Whether the target directory exists.
+ pub exists: bool,
+ /// The path that was checked (expanded).
+ pub target_dir: String,
+}
+
+/// Check if a target directory exists (for clone validation, requires authentication).
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/tasks/{id}/check-target",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ request_body = CheckTargetExistsRequest,
+ responses(
+ (status = 200, description = "Check result", body = CheckTargetExistsResponse),
+ (status = 401, description = "Unauthorized", body = ApiError),
+ (status = 503, description = "No daemon connected", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn check_target_exists(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(id): Path<Uuid>,
+ Json(body): Json<CheckTargetExistsRequest>,
+) -> impl IntoResponse {
+ // Find a connected daemon belonging to this owner to send the command
+ let daemon_entry = state.daemon_connections.iter().find(|d| d.value().owner_id == auth.owner_id);
+ let daemon_id = match daemon_entry {
+ Some(entry) => entry.value().id,
+ None => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("NO_DAEMON", "No daemon connected for your account")),
+ )
+ .into_response();
+ }
+ };
+
+ // Send CheckTargetExists command to daemon
+ let command = DaemonCommand::CheckTargetExists {
+ task_id: id,
+ target_dir: body.target_dir.clone(),
+ };
+
+ if let Err(e) = state.send_daemon_command(daemon_id, command).await {
+ tracing::error!("Failed to send CheckTargetExists command: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DAEMON_ERROR", e)),
+ )
+ .into_response();
+ }
+
+ // The actual result will be sent back via WebSocket
+ // For now, just acknowledge the request was sent
+ Json(serde_json::json!({
+ "status": "checking",
+ "taskId": id.to_string(),
+ "targetDir": body.target_dir,
+ }))
+ .into_response()
+}
diff --git a/makima/src/server/handlers/mesh_chat.rs b/makima/src/server/handlers/mesh_chat.rs
new file mode 100644
index 0000000..5d6d2ee
--- /dev/null
+++ b/makima/src/server/handlers/mesh_chat.rs
@@ -0,0 +1,2088 @@
+//! Chat endpoint for LLM-powered task orchestration.
+//!
+//! This handler provides an agentic loop for managing tasks, daemons, and
+//! overlay operations through LLM tool calling.
+
+use axum::{
+ extract::{Path, State},
+ http::StatusCode,
+ response::IntoResponse,
+ Json,
+};
+use serde::{Deserialize, Serialize};
+use serde_json::json;
+use utoipa::ToSchema;
+use uuid::Uuid;
+
+use crate::db::{models::CreateTaskRequest, repository};
+use crate::llm::{
+ claude::{self, ClaudeClient, ClaudeError, ClaudeModel},
+ groq::{GroqClient, GroqError, Message, ToolCallResponse},
+ parse_mesh_tool_call, LlmModel, MeshToolRequest, ToolCall, ToolResult, UserQuestion,
+ MESH_TOOLS,
+};
+use crate::server::auth::Authenticated;
+use crate::server::state::{DaemonCommand, SharedState, TaskUpdateNotification};
+
+/// Maximum number of tool-calling rounds to prevent infinite loops
+const MAX_TOOL_ROUNDS: usize = 30;
+
+#[derive(Debug, Clone, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct MeshChatHistoryMessage {
+ /// Role: "user" or "assistant"
+ pub role: String,
+ /// Message content
+ pub content: String,
+}
+
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct MeshChatRequest {
+ /// The user's message/instruction
+ pub message: String,
+ /// Optional model selection: "claude-sonnet" (default), "claude-opus", or "groq"
+ #[serde(default)]
+ pub model: Option<String>,
+ /// Optional conversation history for context continuity (deprecated - now loaded from DB)
+ #[serde(default)]
+ pub history: Option<Vec<MeshChatHistoryMessage>>,
+ /// Context type: "mesh", "task", or "subtask"
+ #[serde(default)]
+ pub context_type: Option<String>,
+ /// Task ID if context is task/subtask
+ #[serde(default)]
+ pub context_task_id: Option<Uuid>,
+}
+
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct MeshChatResponse {
+ /// The LLM's response message
+ pub response: String,
+ /// Tool calls that were executed
+ pub tool_calls: Vec<MeshToolCallInfo>,
+ /// Questions pending user answers (pauses conversation)
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub pending_questions: Option<Vec<UserQuestion>>,
+}
+
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct MeshToolCallInfo {
+ pub name: String,
+ pub result: ToolResult,
+}
+
+/// Enum to hold LLM clients
+enum LlmClient {
+ Groq(GroqClient),
+ Claude(ClaudeClient),
+}
+
+/// Unified result from LLM call
+struct LlmResult {
+ content: Option<String>,
+ tool_calls: Vec<ToolCall>,
+ raw_tool_calls: Vec<ToolCallResponse>,
+ finish_reason: String,
+}
+
+/// Chat with mesh orchestrator at the top level (no specific task context)
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/chat",
+ request_body = MeshChatRequest,
+ responses(
+ (status = 200, description = "Chat completed successfully", body = MeshChatResponse),
+ (status = 401, description = "Unauthorized"),
+ (status = 500, description = "Internal server error")
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn mesh_toplevel_chat_handler(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Json(request): Json<MeshChatRequest>,
+) -> impl IntoResponse {
+ // Check if database is configured
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(json!({ "error": "Database not configured" })),
+ )
+ .into_response();
+ };
+
+ // Parse model selection (default to Claude Sonnet)
+ let model = request
+ .model
+ .as_ref()
+ .and_then(|m| LlmModel::from_str(m))
+ .unwrap_or(LlmModel::ClaudeSonnet);
+
+ tracing::info!("Mesh top-level chat using LLM model: {:?}", model);
+
+ // Initialize the appropriate LLM client
+ let llm_client = match model {
+ LlmModel::ClaudeSonnet => match ClaudeClient::from_env(ClaudeModel::Sonnet) {
+ Ok(client) => LlmClient::Claude(client),
+ Err(ClaudeError::MissingApiKey) => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("Claude client error: {}", e) })),
+ )
+ .into_response();
+ }
+ },
+ LlmModel::ClaudeOpus => match ClaudeClient::from_env(ClaudeModel::Opus) {
+ Ok(client) => LlmClient::Claude(client),
+ Err(ClaudeError::MissingApiKey) => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("Claude client error: {}", e) })),
+ )
+ .into_response();
+ }
+ },
+ LlmModel::GroqKimi => match GroqClient::from_env() {
+ Ok(client) => LlmClient::Groq(client),
+ Err(GroqError::MissingApiKey) => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(json!({ "error": "GROQ_API_KEY not configured" })),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("Groq client error: {}", e) })),
+ )
+ .into_response();
+ }
+ },
+ };
+
+ // Build context about all tasks and daemons
+ let mesh_context = build_mesh_overview_context(pool, &state, auth.owner_id).await;
+
+ // Build agentic system prompt for top-level mesh orchestration
+ let system_prompt = format!(
+ r#"You are an intelligent task orchestration agent. You help users manage and coordinate tasks running on connected daemons with Claude Code containers.
+
+## Your Capabilities
+You have access to tools for:
+- **Task Lifecycle**: create_task, run_task, pause_task, resume_task, interrupt_task, discard_task
+- **Task Queries**: query_task_status, list_tasks, list_subtasks, list_siblings, list_daemons
+- **File Access**: list_files, read_file (read documents from the files system)
+- **Task Communication**: send_message_to_task, update_task_plan
+- **Overlay/Merge Operations**: peek_sibling_overlay, get_overlay_diff, preview_merge, merge_subtask, complete_task, set_merge_mode
+
+## Current Mesh Overview
+{mesh_context}
+
+## Agentic Behavior Guidelines
+
+### 1. Analyze Before Acting
+- For complex orchestration requests, first gather information using query_task_status, list_tasks, or list_daemons
+- Understand the current state before making changes
+- For simple, direct requests (e.g., "create a new task"), you can act immediately
+
+### 2. Plan Multi-Step Operations
+- Break complex orchestration into logical steps
+- For parallel execution: create multiple subtasks, then run them on different daemons
+- For sequential execution: create subtasks and run them in order
+
+### 3. Create and Manage Tasks
+- Use create_task to create new top-level tasks or subtasks
+- Assign appropriate priorities and plans
+- **Repository Default**: When creating tasks, use the daemon's working directory as the repository_url by default (shown as "Default Repository" above). Only omit repository_url if the task doesn't involve code, or use a different URL if the user explicitly requests it.
+- If a working directory is a git repository, use it as the repository_url for code-related tasks
+
+### 4. Coordinate Multiple Tasks
+- Use list_tasks to see all tasks and their statuses
+- Use list_daemons to see available compute resources
+- Balance workload across daemons
+
+### 5. Be Efficient
+- Don't over-analyze simple requests
+- Use the minimum number of tool calls needed
+- Provide clear summaries of actions taken
+
+## Important Notes
+- Task IDs are UUIDs - ensure you use the correct format
+- Running a task requires at least one connected daemon
+- When creating subtasks, specify the parent_task_id
+- Always confirm destructive operations (discard_task) with the user"#,
+ mesh_context = mesh_context
+ );
+
+ // Run the shared agentic loop
+ run_mesh_agentic_loop(pool, &state, &llm_client, system_prompt, &request, auth.owner_id).await
+}
+
+/// Chat with task mesh orchestrator using LLM tool calling (scoped by owner)
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/tasks/{id}/chat",
+ request_body = MeshChatRequest,
+ responses(
+ (status = 200, description = "Chat completed successfully", body = MeshChatResponse),
+ (status = 401, description = "Unauthorized"),
+ (status = 404, description = "Task not found"),
+ (status = 500, description = "Internal server error")
+ ),
+ params(
+ ("id" = Uuid, Path, description = "Task ID (context for orchestration)")
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn mesh_chat_handler(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+ Path(task_id): Path<Uuid>,
+ Json(request): Json<MeshChatRequest>,
+) -> impl IntoResponse {
+ // Check if database is configured
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(json!({ "error": "Database not configured" })),
+ )
+ .into_response();
+ };
+
+ // Get the context task (scoped by owner)
+ let task = match repository::get_task_for_owner(pool, task_id, auth.owner_id).await {
+ Ok(Some(task)) => task,
+ Ok(None) => {
+ return (
+ StatusCode::NOT_FOUND,
+ Json(json!({ "error": "Task not found" })),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Database error: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("Database error: {}", e) })),
+ )
+ .into_response();
+ }
+ };
+
+ // Parse model selection (default to Claude Sonnet)
+ let model = request
+ .model
+ .as_ref()
+ .and_then(|m| LlmModel::from_str(m))
+ .unwrap_or(LlmModel::ClaudeSonnet);
+
+ tracing::info!("Mesh chat using LLM model: {:?}", model);
+
+ // Initialize the appropriate LLM client
+ let llm_client = match model {
+ LlmModel::ClaudeSonnet => match ClaudeClient::from_env(ClaudeModel::Sonnet) {
+ Ok(client) => LlmClient::Claude(client),
+ Err(ClaudeError::MissingApiKey) => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("Claude client error: {}", e) })),
+ )
+ .into_response();
+ }
+ },
+ LlmModel::ClaudeOpus => match ClaudeClient::from_env(ClaudeModel::Opus) {
+ Ok(client) => LlmClient::Claude(client),
+ Err(ClaudeError::MissingApiKey) => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(json!({ "error": "ANTHROPIC_API_KEY not configured" })),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("Claude client error: {}", e) })),
+ )
+ .into_response();
+ }
+ },
+ LlmModel::GroqKimi => match GroqClient::from_env() {
+ Ok(client) => LlmClient::Groq(client),
+ Err(GroqError::MissingApiKey) => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(json!({ "error": "GROQ_API_KEY not configured" })),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("Groq client error: {}", e) })),
+ )
+ .into_response();
+ }
+ },
+ };
+
+ // Build context about the current task and mesh state
+ let task_context = build_task_context(&task);
+
+ // Build agentic system prompt for task orchestration
+ let system_prompt = format!(
+ r#"You are an intelligent task orchestration agent. You help users manage and coordinate tasks running on connected daemons with Claude Code containers.
+
+## Your Capabilities
+You have access to tools for:
+- **Task Lifecycle**: create_task, run_task, pause_task, resume_task, interrupt_task, discard_task
+- **Task Queries**: query_task_status, list_tasks, list_subtasks, list_siblings, list_daemons
+- **File Access**: list_files, read_file (read documents from the files system)
+- **Task Communication**: send_message_to_task, update_task_plan
+- **Overlay/Merge Operations**: peek_sibling_overlay, get_overlay_diff, preview_merge, merge_subtask, complete_task, set_merge_mode
+
+## Current Context
+{task_context}
+
+## Agentic Behavior Guidelines
+
+### 1. Analyze Before Acting
+- For complex orchestration requests, first gather information using query_task_status, list_tasks, or list_daemons
+- Understand the current state before making changes
+- For simple, direct requests (e.g., "pause this task"), you can act immediately
+
+### 2. Plan Multi-Step Operations
+- Break complex orchestration into logical steps
+- For parallel execution: create multiple subtasks, then run them on different daemons
+- For sequential execution: create subtasks and run them in order
+
+### 3. Monitor Task Progress
+- Use query_task_status to check on running tasks
+- Watch for status changes and react accordingly
+- Handle failures gracefully (retry, escalate, or report)
+
+### 4. Coordinate Sibling Tasks
+- Use peek_sibling_overlay to see what other tasks have changed
+- Preview merges before completing to catch conflicts
+- Coordinate timing when multiple tasks need to merge
+
+### 5. Be Efficient
+- Don't over-analyze simple requests
+- Use the minimum number of tool calls needed
+- Provide clear summaries of actions taken
+
+## Important Notes
+- Task IDs are UUIDs - ensure you use the correct format
+- Running a task requires at least one connected daemon
+- Overlay operations require the task to have been run at least once
+- Always confirm destructive operations (discard_task) with the user
+- When creating subtasks for this task, use parent_task_id: {task_id}"#,
+ task_context = task_context,
+ task_id = task_id
+ );
+
+ // Run the shared agentic loop
+ run_mesh_agentic_loop(pool, &state, &llm_client, system_prompt, &request, auth.owner_id).await
+}
+
+fn build_task_context(task: &crate::db::models::Task) -> String {
+ let mut context = format!(
+ "Current Task: {} (ID: {})\n",
+ task.name, task.id
+ );
+ context.push_str(&format!("Status: {}\n", task.status));
+ context.push_str(&format!("Priority: {}\n", task.priority));
+
+ if let Some(ref desc) = task.description {
+ context.push_str(&format!("Description: {}\n", desc));
+ }
+
+ // Truncate plan preview if too long
+ let plan_preview = if task.plan.len() > 200 {
+ format!("{}...", &task.plan[..200])
+ } else {
+ task.plan.clone()
+ };
+ context.push_str(&format!("Plan: {}\n", plan_preview));
+
+ if let Some(ref summary) = task.progress_summary {
+ context.push_str(&format!("Progress: {}\n", summary));
+ }
+
+ if let Some(ref error) = task.error_message {
+ context.push_str(&format!("Error: {}\n", error));
+ }
+
+ // Repository info
+ if let Some(ref url) = task.repository_url {
+ context.push_str(&format!("Repository: {}\n", url));
+ }
+ if let Some(ref branch) = task.base_branch {
+ context.push_str(&format!("Base branch: {}\n", branch));
+ }
+
+ context
+}
+
+/// Build overview context for top-level mesh orchestration
+async fn build_mesh_overview_context(pool: &sqlx::PgPool, state: &SharedState, owner_id: Uuid) -> String {
+ let mut context = String::new();
+
+ // Get task counts by status
+ match repository::list_tasks_for_owner(pool, owner_id).await {
+ Ok(tasks) => {
+ let total = tasks.len();
+ let pending = tasks.iter().filter(|t| t.status == "pending").count();
+ let running = tasks.iter().filter(|t| t.status == "running").count();
+ let paused = tasks.iter().filter(|t| t.status == "paused").count();
+ let done = tasks.iter().filter(|t| t.status == "done").count();
+ let failed = tasks.iter().filter(|t| t.status == "failed").count();
+
+ context.push_str(&format!(
+ "Tasks: {} total ({} pending, {} running, {} paused, {} done, {} failed)\n",
+ total, pending, running, paused, done, failed
+ ));
+
+ // List recent/active tasks
+ if !tasks.is_empty() {
+ context.push_str("\nRecent Tasks:\n");
+ for task in tasks.iter().take(5) {
+ context.push_str(&format!(
+ " - {} (ID: {}, Status: {})\n",
+ task.name, task.id, task.status
+ ));
+ }
+ if tasks.len() > 5 {
+ context.push_str(&format!(" ... and {} more\n", tasks.len() - 5));
+ }
+ }
+ }
+ Err(e) => {
+ context.push_str(&format!("Error fetching tasks: {}\n", e));
+ }
+ }
+
+ // Get connected daemons for this owner
+ let owner_daemons: Vec<_> = state.daemon_connections.iter()
+ .filter(|e| e.value().owner_id == owner_id)
+ .collect();
+ let daemon_count = owner_daemons.len();
+ context.push_str(&format!("\nConnected Daemons: {}\n", daemon_count));
+
+ for entry in owner_daemons.iter().take(3) {
+ let daemon = entry.value();
+ let working_dir = daemon.working_directory.as_deref().unwrap_or("not set");
+ context.push_str(&format!(
+ " - {} (ID: {}, Working Directory: {})\n",
+ daemon.hostname.as_deref().unwrap_or("unknown"),
+ daemon.id,
+ working_dir
+ ));
+ }
+
+ // Add default repository guidance if there's exactly one daemon with a working directory
+ let daemons_with_working_dir: Vec<_> = owner_daemons.iter()
+ .filter(|e| e.value().working_directory.is_some())
+ .collect();
+
+ if daemons_with_working_dir.len() == 1 {
+ if let Some(dir) = &daemons_with_working_dir[0].value().working_directory {
+ context.push_str(&format!(
+ "\nDefault Repository: {} (use this as repository_url when creating tasks unless user specifies otherwise)\n",
+ dir
+ ));
+ }
+ }
+
+ context
+}
+
+/// Run the shared agentic loop for mesh chat
+async fn run_mesh_agentic_loop(
+ pool: &sqlx::PgPool,
+ state: &SharedState,
+ llm_client: &LlmClient,
+ system_prompt: String,
+ request: &MeshChatRequest,
+ owner_id: Uuid,
+) -> axum::response::Response {
+ // Get or create conversation for storing messages
+ let conversation = match repository::get_or_create_active_conversation(pool, owner_id).await {
+ Ok(c) => c,
+ Err(e) => {
+ tracing::error!("Failed to get/create conversation: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("Failed to initialize conversation: {}", e) })),
+ )
+ .into_response();
+ }
+ };
+
+ // Build initial messages
+ let mut messages = vec![Message {
+ role: "system".to_string(),
+ content: Some(system_prompt),
+ tool_calls: None,
+ tool_call_id: None,
+ }];
+
+ // Load conversation history from database (or use provided for backwards compatibility)
+ if let Some(history) = &request.history {
+ // Legacy: use provided history
+ for hist_msg in history {
+ messages.push(Message {
+ role: hist_msg.role.clone(),
+ content: Some(hist_msg.content.clone()),
+ tool_calls: None,
+ tool_call_id: None,
+ });
+ }
+ tracing::info!(
+ history_messages = history.len(),
+ "Loaded mesh conversation history from request (legacy)"
+ );
+ } else {
+ // New: load from database
+ match repository::list_chat_messages(pool, conversation.id, Some(50)).await {
+ Ok(db_messages) => {
+ for msg in db_messages {
+ messages.push(Message {
+ role: msg.role.clone(),
+ content: Some(msg.content.clone()),
+ tool_calls: None,
+ tool_call_id: None,
+ });
+ }
+ tracing::info!(
+ history_messages = messages.len() - 1, // minus system message
+ "Loaded mesh conversation history from database"
+ );
+ }
+ Err(e) => {
+ tracing::warn!("Failed to load chat history: {}", e);
+ // Continue without history
+ }
+ }
+ }
+
+ // Add current user message
+ messages.push(Message {
+ role: "user".to_string(),
+ content: Some(request.message.clone()),
+ tool_calls: None,
+ tool_call_id: None,
+ });
+
+ // State for tracking
+ let mut all_tool_call_infos: Vec<MeshToolCallInfo> = Vec::new();
+ let mut final_response: Option<String> = None;
+ let mut consecutive_failures = 0;
+ const MAX_CONSECUTIVE_FAILURES: usize = 3;
+ let mut pending_questions: Option<Vec<UserQuestion>> = None;
+
+ // Multi-turn agentic tool calling loop
+ for round in 0..MAX_TOOL_ROUNDS {
+ tracing::info!(
+ round = round,
+ total_tool_calls = all_tool_call_infos.len(),
+ "Mesh agentic loop iteration"
+ );
+
+ // Check consecutive failures
+ if consecutive_failures >= MAX_CONSECUTIVE_FAILURES {
+ tracing::warn!(
+ "Breaking mesh loop due to {} consecutive failures",
+ consecutive_failures
+ );
+ final_response = Some(
+ "I encountered multiple consecutive errors and stopped. \
+ Please check the task state and try again."
+ .to_string(),
+ );
+ break;
+ }
+
+ // Call the appropriate LLM API
+ let result = match llm_client {
+ LlmClient::Groq(groq) => {
+ match groq.chat_with_tools(messages.clone(), &MESH_TOOLS).await {
+ Ok(r) => LlmResult {
+ content: r.content,
+ tool_calls: r.tool_calls,
+ raw_tool_calls: r.raw_tool_calls,
+ finish_reason: r.finish_reason,
+ },
+ Err(e) => {
+ tracing::error!("Groq API error: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("LLM API error: {}", e) })),
+ )
+ .into_response();
+ }
+ }
+ }
+ LlmClient::Claude(claude_client) => {
+ let claude_messages = claude::groq_messages_to_claude(&messages);
+ match claude_client
+ .chat_with_tools(claude_messages, &MESH_TOOLS)
+ .await
+ {
+ Ok(r) => {
+ let raw_tool_calls: Vec<ToolCallResponse> = r
+ .tool_calls
+ .iter()
+ .map(|tc| ToolCallResponse {
+ id: tc.id.clone(),
+ call_type: "function".to_string(),
+ function: crate::llm::groq::FunctionCall {
+ name: tc.name.clone(),
+ arguments: tc.arguments.to_string(),
+ },
+ })
+ .collect();
+
+ LlmResult {
+ content: r.content,
+ tool_calls: r.tool_calls,
+ raw_tool_calls,
+ finish_reason: r.stop_reason,
+ }
+ }
+ Err(e) => {
+ tracing::error!("Claude API error: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": format!("LLM API error: {}", e) })),
+ )
+ .into_response();
+ }
+ }
+ }
+ };
+
+ // Check if there are tool calls to execute
+ if result.tool_calls.is_empty() {
+ final_response = result.content;
+ break;
+ }
+
+ // Add assistant message with tool calls to conversation
+ messages.push(Message {
+ role: "assistant".to_string(),
+ content: result.content.clone(),
+ tool_calls: Some(result.raw_tool_calls.clone()),
+ tool_call_id: None,
+ });
+
+ // Execute each tool call
+ for (i, tool_call) in result.tool_calls.iter().enumerate() {
+ tracing::info!(tool = %tool_call.name, round = round, "Executing mesh tool call");
+
+ // Parse the tool call
+ let mut execution_result = parse_mesh_tool_call(tool_call);
+
+ // Handle async mesh tool requests
+ if let Some(mesh_request) = execution_result.request.take() {
+ let async_result = handle_mesh_request(pool, state, mesh_request, owner_id).await;
+ execution_result.success = async_result.success;
+ execution_result.message = async_result.message;
+ execution_result.data = async_result.data;
+ }
+
+ // Track consecutive failures
+ if execution_result.success {
+ consecutive_failures = 0;
+ } else {
+ consecutive_failures += 1;
+ tracing::warn!(
+ tool = %tool_call.name,
+ consecutive_failures = consecutive_failures,
+ "Mesh tool call failed"
+ );
+ }
+
+ // Check for pending user questions
+ if let Some(questions) = execution_result.pending_questions {
+ tracing::info!(
+ question_count = questions.len(),
+ "Mesh LLM requesting user input"
+ );
+ pending_questions = Some(questions);
+ all_tool_call_infos.push(MeshToolCallInfo {
+ name: tool_call.name.clone(),
+ result: ToolResult {
+ success: execution_result.success,
+ message: execution_result.message.clone(),
+ },
+ });
+ break;
+ }
+
+ // Build tool result message
+ let result_content = if let Some(data) = &execution_result.data {
+ json!({
+ "success": execution_result.success,
+ "message": execution_result.message,
+ "data": data
+ })
+ .to_string()
+ } else {
+ json!({
+ "success": execution_result.success,
+ "message": execution_result.message
+ })
+ .to_string()
+ };
+
+ // Add tool result message
+ let tool_call_id = match llm_client {
+ LlmClient::Groq(_) => result.raw_tool_calls[i].id.clone(),
+ LlmClient::Claude(_) => tool_call.id.clone(),
+ };
+
+ messages.push(Message {
+ role: "tool".to_string(),
+ content: Some(result_content),
+ tool_calls: None,
+ tool_call_id: Some(tool_call_id),
+ });
+
+ // Track for response
+ all_tool_call_infos.push(MeshToolCallInfo {
+ name: tool_call.name.clone(),
+ result: ToolResult {
+ success: execution_result.success,
+ message: execution_result.message,
+ },
+ });
+ }
+
+ // If user questions are pending, pause
+ if pending_questions.is_some() {
+ final_response = result.content;
+ break;
+ }
+
+ // If finish reason indicates completion, exit loop
+ let finish_lower = result.finish_reason.to_lowercase();
+ if finish_lower == "stop" || finish_lower == "end_turn" {
+ final_response = result.content;
+ break;
+ }
+ }
+
+ // Build response
+ let response_text = final_response.unwrap_or_else(|| {
+ if all_tool_call_infos.is_empty() {
+ "I couldn't understand your request. Please try rephrasing.".to_string()
+ } else {
+ format!(
+ "Done! Executed {} tool{}.",
+ all_tool_call_infos.len(),
+ if all_tool_call_infos.len() == 1 {
+ ""
+ } else {
+ "s"
+ }
+ )
+ }
+ });
+
+ // Save messages to database (only if not using legacy history mode)
+ if request.history.is_none() {
+ let context_type = request.context_type.clone().unwrap_or_else(|| "mesh".to_string());
+
+ // Validate context_task_id exists before using it (to avoid FK constraint violation)
+ let context_task_id = if let Some(task_id) = request.context_task_id {
+ match repository::get_task_for_owner(pool, task_id, owner_id).await {
+ Ok(Some(_)) => Some(task_id),
+ Ok(None) => {
+ tracing::warn!("context_task_id {} not found, ignoring", task_id);
+ None
+ }
+ Err(e) => {
+ tracing::warn!("Failed to validate context_task_id {}: {}", task_id, e);
+ None
+ }
+ }
+ } else {
+ None
+ };
+
+ // Save user message
+ if let Err(e) = repository::add_chat_message(
+ pool,
+ conversation.id,
+ "user",
+ &request.message,
+ &context_type,
+ context_task_id,
+ None,
+ None,
+ )
+ .await
+ {
+ tracing::warn!("Failed to save user message to DB: {}", e);
+ }
+
+ // Serialize tool calls for storage
+ let tool_calls_json = if all_tool_call_infos.is_empty() {
+ None
+ } else {
+ Some(serde_json::to_value(&all_tool_call_infos).unwrap_or_default())
+ };
+
+ // Serialize pending questions for storage
+ let pending_questions_json = pending_questions
+ .as_ref()
+ .map(|q| serde_json::to_value(q).unwrap_or_default());
+
+ // Save assistant message
+ if let Err(e) = repository::add_chat_message(
+ pool,
+ conversation.id,
+ "assistant",
+ &response_text,
+ &context_type,
+ context_task_id,
+ tool_calls_json,
+ pending_questions_json,
+ )
+ .await
+ {
+ tracing::warn!("Failed to save assistant message to DB: {}", e);
+ }
+
+ tracing::info!(
+ conversation_id = %conversation.id,
+ context_type = %context_type,
+ "Saved mesh chat messages to database"
+ );
+ }
+
+ (
+ StatusCode::OK,
+ Json(MeshChatResponse {
+ response: response_text,
+ tool_calls: all_tool_call_infos,
+ pending_questions,
+ }),
+ )
+ .into_response()
+}
+
+/// Result from handling an async mesh tool request
+struct MeshRequestResult {
+ success: bool,
+ message: String,
+ data: Option<serde_json::Value>,
+}
+
+/// Handle async mesh tool requests that require database/daemon access
+async fn handle_mesh_request(
+ pool: &sqlx::PgPool,
+ state: &SharedState,
+ request: MeshToolRequest,
+ owner_id: Uuid,
+) -> MeshRequestResult {
+ match request {
+ MeshToolRequest::CreateTask {
+ name,
+ plan,
+ parent_task_id,
+ repository_url,
+ base_branch,
+ merge_mode,
+ priority,
+ } => {
+ // Check if repository_url matches a daemon's working directory (for this owner)
+ let is_daemon_working_dir = repository_url.as_ref().map(|url| {
+ state.daemon_connections.iter().any(|entry| {
+ entry.value().owner_id == owner_id &&
+ entry.value().working_directory.as_ref() == Some(url)
+ })
+ }).unwrap_or(false);
+
+ // Derive completion_action from merge_mode, or default to "branch" if using daemon working dir
+ let (completion_action, target_repo_path) = if let Some(ref mode) = merge_mode {
+ // Explicit merge_mode provided - derive from it
+ let action = match mode.as_str() {
+ "pr" => "pr".to_string(),
+ "auto" => "merge".to_string(),
+ "manual" => "branch".to_string(),
+ _ => "none".to_string(),
+ };
+ // If using daemon working dir and action involves the repo, set target_repo_path
+ let target = if is_daemon_working_dir && action != "none" {
+ repository_url.clone()
+ } else {
+ None
+ };
+ (Some(action), target)
+ } else if is_daemon_working_dir {
+ // No merge_mode but using daemon working dir - default to "branch"
+ (Some("branch".to_string()), repository_url.clone())
+ } else {
+ (None, None)
+ };
+
+ let create_req = CreateTaskRequest {
+ name: name.clone(),
+ description: None,
+ plan,
+ parent_task_id,
+ repository_url,
+ base_branch,
+ target_branch: None,
+ merge_mode,
+ priority: priority.unwrap_or(0),
+ target_repo_path,
+ completion_action,
+ continue_from_task_id: None,
+ copy_files: None,
+ };
+
+ match repository::create_task_for_owner(pool, owner_id, create_req).await {
+ Ok(task) => MeshRequestResult {
+ success: true,
+ message: format!("Created task '{}' with ID {}", name, task.id),
+ data: Some(json!({
+ "taskId": task.id,
+ "name": task.name,
+ "status": task.status,
+ })),
+ },
+ Err(e) => MeshRequestResult {
+ success: false,
+ message: format!("Failed to create task: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ MeshToolRequest::RunTask { task_id, daemon_id } => {
+ // Get task to check status
+ let task = match repository::get_task_for_owner(pool, task_id, owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Task {} not found", task_id),
+ data: None,
+ }
+ }
+ Err(e) => {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ }
+ }
+ };
+
+ if task.status != "pending" && task.status != "paused" {
+ return MeshRequestResult {
+ success: false,
+ message: format!(
+ "Task cannot be run - status is '{}' (must be 'pending' or 'paused')",
+ task.status
+ ),
+ data: None,
+ };
+ }
+
+ // Find a daemon to run on (must belong to this owner)
+ let target_daemon_id = if let Some(id) = daemon_id {
+ // Verify the specified daemon belongs to this owner
+ if !state.daemon_connections.iter().any(|d| d.value().id == id && d.value().owner_id == owner_id) {
+ return MeshRequestResult {
+ success: false,
+ message: "Specified daemon not found or not accessible.".to_string(),
+ data: None,
+ };
+ }
+ id
+ } else {
+ // Find any connected daemon for this owner
+ let daemon = state.daemon_connections.iter().find(|d| d.value().owner_id == owner_id);
+ match daemon {
+ Some(d) => d.value().id,
+ None => {
+ return MeshRequestResult {
+ success: false,
+ message: "No daemons connected for your account. Cannot run task.".to_string(),
+ data: None,
+ }
+ }
+ }
+ };
+
+ // Check if this is an orchestrator (depth 0 with subtasks)
+ let subtask_count = match repository::list_subtasks_for_owner(pool, task_id, owner_id).await {
+ Ok(subtasks) => subtasks.len(),
+ Err(_) => 0,
+ };
+ let is_orchestrator = task.depth == 0 && subtask_count > 0;
+
+ // Send SpawnTask command to daemon
+ let command = DaemonCommand::SpawnTask {
+ task_id,
+ task_name: task.name.clone(),
+ plan: task.plan.clone(),
+ repo_url: task.repository_url.clone(),
+ base_branch: task.base_branch.clone(),
+ target_branch: task.target_branch.clone(),
+ parent_task_id: task.parent_task_id,
+ depth: task.depth,
+ is_orchestrator,
+ target_repo_path: task.target_repo_path.clone(),
+ completion_action: task.completion_action.clone(),
+ continue_from_task_id: task.continue_from_task_id,
+ copy_files: task.copy_files.as_ref().and_then(|v| serde_json::from_value(v.clone()).ok()),
+ };
+
+ match state.send_daemon_command(target_daemon_id, command).await {
+ Ok(()) => {
+ // Update task status to running
+ let update_req = crate::db::models::UpdateTaskRequest {
+ status: Some("running".to_string()),
+ version: Some(task.version),
+ ..Default::default()
+ };
+
+ if let Ok(Some(updated)) = repository::update_task_for_owner(pool, task_id, owner_id, update_req).await {
+ state.broadcast_task_update(TaskUpdateNotification {
+ task_id,
+ owner_id: Some(task.owner_id),
+ version: updated.version,
+ status: "running".to_string(),
+ updated_fields: vec!["status".to_string()],
+ updated_by: "system".to_string(),
+ });
+ }
+
+ MeshRequestResult {
+ success: true,
+ message: format!("Task {} is now running on daemon {}", task_id, target_daemon_id),
+ data: Some(json!({
+ "taskId": task_id,
+ "daemonId": target_daemon_id,
+ "status": "running",
+ })),
+ }
+ }
+ Err(e) => MeshRequestResult {
+ success: false,
+ message: format!("Failed to start task: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ MeshToolRequest::PauseTask { task_id } => {
+ // Get task and its daemon
+ let task = match repository::get_task_for_owner(pool, task_id, owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Task {} not found", task_id),
+ data: None,
+ }
+ }
+ Err(e) => {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ }
+ }
+ };
+
+ if task.status != "running" {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Task is not running (status: {})", task.status),
+ data: None,
+ };
+ }
+
+ if let Some(daemon_id) = task.daemon_id {
+ let command = DaemonCommand::PauseTask { task_id };
+ if let Err(e) = state.send_daemon_command(daemon_id, command).await {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Failed to pause task: {}", e),
+ data: None,
+ };
+ }
+ }
+
+ // Update status
+ let update_req = crate::db::models::UpdateTaskRequest {
+ status: Some("paused".to_string()),
+ version: Some(task.version),
+ ..Default::default()
+ };
+
+ if let Ok(Some(updated)) = repository::update_task_for_owner(pool, task_id, owner_id, update_req).await {
+ state.broadcast_task_update(TaskUpdateNotification {
+ task_id,
+ owner_id: Some(task.owner_id),
+ version: updated.version,
+ status: "paused".to_string(),
+ updated_fields: vec!["status".to_string()],
+ updated_by: "system".to_string(),
+ });
+ }
+
+ MeshRequestResult {
+ success: true,
+ message: format!("Task {} paused", task_id),
+ data: Some(json!({ "taskId": task_id, "status": "paused" })),
+ }
+ }
+
+ MeshToolRequest::ResumeTask { task_id } => {
+ let task = match repository::get_task_for_owner(pool, task_id, owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Task {} not found", task_id),
+ data: None,
+ }
+ }
+ Err(e) => {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ }
+ }
+ };
+
+ if task.status != "paused" {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Task is not paused (status: {})", task.status),
+ data: None,
+ };
+ }
+
+ if let Some(daemon_id) = task.daemon_id {
+ let command = DaemonCommand::ResumeTask { task_id };
+ if let Err(e) = state.send_daemon_command(daemon_id, command).await {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Failed to resume task: {}", e),
+ data: None,
+ };
+ }
+ }
+
+ // Update status
+ let update_req = crate::db::models::UpdateTaskRequest {
+ status: Some("running".to_string()),
+ version: Some(task.version),
+ ..Default::default()
+ };
+
+ if let Ok(Some(updated)) = repository::update_task_for_owner(pool, task_id, owner_id, update_req).await {
+ state.broadcast_task_update(TaskUpdateNotification {
+ task_id,
+ owner_id: Some(task.owner_id),
+ version: updated.version,
+ status: "running".to_string(),
+ updated_fields: vec!["status".to_string()],
+ updated_by: "system".to_string(),
+ });
+ }
+
+ MeshRequestResult {
+ success: true,
+ message: format!("Task {} resumed", task_id),
+ data: Some(json!({ "taskId": task_id, "status": "running" })),
+ }
+ }
+
+ MeshToolRequest::InterruptTask { task_id, graceful } => {
+ let task = match repository::get_task_for_owner(pool, task_id, owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Task {} not found", task_id),
+ data: None,
+ }
+ }
+ Err(e) => {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ }
+ }
+ };
+
+ if let Some(daemon_id) = task.daemon_id {
+ let command = DaemonCommand::InterruptTask { task_id, graceful };
+ if let Err(e) = state.send_daemon_command(daemon_id, command).await {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Failed to interrupt task: {}", e),
+ data: None,
+ };
+ }
+ }
+
+ // Update status
+ let update_req = crate::db::models::UpdateTaskRequest {
+ status: Some("paused".to_string()),
+ version: Some(task.version),
+ ..Default::default()
+ };
+
+ if let Ok(Some(updated)) = repository::update_task_for_owner(pool, task_id, owner_id, update_req).await {
+ state.broadcast_task_update(TaskUpdateNotification {
+ task_id,
+ owner_id: Some(task.owner_id),
+ version: updated.version,
+ status: "paused".to_string(),
+ updated_fields: vec!["status".to_string()],
+ updated_by: "system".to_string(),
+ });
+ }
+
+ MeshRequestResult {
+ success: true,
+ message: format!(
+ "Task {} {}interrupted",
+ task_id,
+ if graceful { "gracefully " } else { "" }
+ ),
+ data: Some(json!({ "taskId": task_id, "status": "paused" })),
+ }
+ }
+
+ MeshToolRequest::DiscardTask { task_id } => {
+ match repository::delete_task_for_owner(pool, task_id, owner_id).await {
+ Ok(true) => MeshRequestResult {
+ success: true,
+ message: format!("Task {} discarded", task_id),
+ data: Some(json!({ "taskId": task_id, "deleted": true })),
+ },
+ Ok(false) => MeshRequestResult {
+ success: false,
+ message: format!("Task {} not found", task_id),
+ data: None,
+ },
+ Err(e) => MeshRequestResult {
+ success: false,
+ message: format!("Failed to delete task: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ MeshToolRequest::QueryTaskStatus { task_id } => {
+ match repository::get_task_for_owner(pool, task_id, owner_id).await {
+ Ok(Some(task)) => MeshRequestResult {
+ success: true,
+ message: format!("Task '{}' is {}", task.name, task.status),
+ data: Some(json!({
+ "taskId": task.id,
+ "name": task.name,
+ "status": task.status,
+ "priority": task.priority,
+ "description": task.description,
+ "plan": task.plan,
+ "progressSummary": task.progress_summary,
+ "errorMessage": task.error_message,
+ "repositoryUrl": task.repository_url,
+ "baseBranch": task.base_branch,
+ "targetBranch": task.target_branch,
+ "mergeMode": task.merge_mode,
+ "prUrl": task.pr_url,
+ "daemonId": task.daemon_id,
+ "containerId": task.container_id,
+ "createdAt": task.created_at,
+ "startedAt": task.started_at,
+ "completedAt": task.completed_at,
+ })),
+ },
+ Ok(None) => MeshRequestResult {
+ success: false,
+ message: format!("Task {} not found", task_id),
+ data: None,
+ },
+ Err(e) => MeshRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ MeshToolRequest::ListTasks {
+ status_filter,
+ parent_task_id,
+ } => {
+ // TODO: Add filtering support to repository
+ match repository::list_tasks_for_owner(pool, owner_id).await {
+ Ok(mut tasks) => {
+ // Apply filters
+ if let Some(ref status) = status_filter {
+ tasks.retain(|t| &t.status == status);
+ }
+ if let Some(ref parent_id) = parent_task_id {
+ tasks.retain(|t| t.parent_task_id.as_ref() == Some(parent_id));
+ }
+
+ let task_data: Vec<serde_json::Value> = tasks
+ .iter()
+ .map(|t| {
+ json!({
+ "taskId": t.id,
+ "name": t.name,
+ "status": t.status,
+ "priority": t.priority,
+ "parentTaskId": t.parent_task_id,
+ })
+ })
+ .collect();
+
+ MeshRequestResult {
+ success: true,
+ message: format!("Found {} tasks", tasks.len()),
+ data: Some(json!({ "tasks": task_data })),
+ }
+ }
+ Err(e) => MeshRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ MeshToolRequest::ListSubtasks { task_id } => {
+ match repository::list_subtasks_for_owner(pool, task_id, owner_id).await {
+ Ok(subtasks) => {
+ let subtask_data: Vec<serde_json::Value> = subtasks
+ .iter()
+ .map(|t| {
+ json!({
+ "taskId": t.id,
+ "name": t.name,
+ "status": t.status,
+ "priority": t.priority,
+ })
+ })
+ .collect();
+
+ MeshRequestResult {
+ success: true,
+ message: format!("Found {} subtasks", subtasks.len()),
+ data: Some(json!({ "subtasks": subtask_data })),
+ }
+ }
+ Err(e) => MeshRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ MeshToolRequest::ListSiblings { task_id } => {
+ // Get task to find parent
+ let task = match repository::get_task_for_owner(pool, task_id, owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Task {} not found", task_id),
+ data: None,
+ }
+ }
+ Err(e) => {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ }
+ }
+ };
+
+ let Some(parent_id) = task.parent_task_id else {
+ return MeshRequestResult {
+ success: true,
+ message: "Task has no parent, so no siblings".to_string(),
+ data: Some(json!({ "siblings": [] })),
+ };
+ };
+
+ // Get all subtasks of parent, excluding current task
+ match repository::list_subtasks_for_owner(pool, parent_id, owner_id).await {
+ Ok(siblings) => {
+ let sibling_data: Vec<serde_json::Value> = siblings
+ .iter()
+ .filter(|t| t.id != task_id)
+ .map(|t| {
+ json!({
+ "taskId": t.id,
+ "name": t.name,
+ "status": t.status,
+ "priority": t.priority,
+ })
+ })
+ .collect();
+
+ MeshRequestResult {
+ success: true,
+ message: format!("Found {} sibling tasks", sibling_data.len()),
+ data: Some(json!({ "siblings": sibling_data })),
+ }
+ }
+ Err(e) => MeshRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ MeshToolRequest::ListDaemons => {
+ // Only list daemons belonging to this owner
+ let daemons: Vec<serde_json::Value> = state
+ .daemon_connections
+ .iter()
+ .filter(|entry| entry.value().owner_id == owner_id)
+ .map(|entry| {
+ let d = entry.value();
+ json!({
+ "daemonId": d.id,
+ "connectionId": d.connection_id,
+ "hostname": d.hostname,
+ "machineId": d.machine_id,
+ })
+ })
+ .collect();
+
+ MeshRequestResult {
+ success: true,
+ message: format!("{} daemon(s) connected", daemons.len()),
+ data: Some(json!({ "daemons": daemons })),
+ }
+ }
+
+ MeshToolRequest::ListDaemonDirectories => {
+ let mut directories: Vec<serde_json::Value> = Vec::new();
+
+ // Only list directories from daemons belonging to this owner
+ for entry in state.daemon_connections.iter() {
+ let daemon = entry.value();
+
+ // Only include daemons belonging to this owner
+ if daemon.owner_id != owner_id {
+ continue;
+ }
+
+ // Add working directory if available
+ if let Some(ref working_dir) = daemon.working_directory {
+ directories.push(json!({
+ "path": working_dir,
+ "label": "Working Directory",
+ "directoryType": "working",
+ "hostname": daemon.hostname,
+ }));
+ }
+
+ // Add home directory if available
+ if let Some(ref home_dir) = daemon.home_directory {
+ directories.push(json!({
+ "path": home_dir,
+ "label": "Makima Home",
+ "directoryType": "home",
+ "hostname": daemon.hostname,
+ }));
+ }
+ }
+
+ MeshRequestResult {
+ success: true,
+ message: format!("Found {} available directories", directories.len()),
+ data: Some(json!({ "directories": directories })),
+ }
+ }
+
+ MeshToolRequest::ListFiles => {
+ match repository::list_files_for_owner(pool, owner_id).await {
+ Ok(files) => {
+ let file_data: Vec<serde_json::Value> = files
+ .iter()
+ .map(|f| {
+ json!({
+ "fileId": f.id,
+ "name": f.name,
+ "description": f.description,
+ "createdAt": f.created_at,
+ "updatedAt": f.updated_at,
+ })
+ })
+ .collect();
+
+ MeshRequestResult {
+ success: true,
+ message: format!("Found {} files", files.len()),
+ data: Some(json!({ "files": file_data })),
+ }
+ }
+ Err(e) => MeshRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ MeshToolRequest::ReadFile { file_id } => {
+ match repository::get_file_for_owner(pool, file_id, owner_id).await {
+ Ok(Some(file)) => {
+ // Convert body elements to readable text
+ let body_content: Vec<serde_json::Value> = file
+ .body
+ .iter()
+ .map(|elem| {
+ match elem {
+ crate::db::models::BodyElement::Heading { level, text } => {
+ json!({ "type": "heading", "level": level, "text": text })
+ }
+ crate::db::models::BodyElement::Paragraph { text } => {
+ json!({ "type": "paragraph", "text": text })
+ }
+ crate::db::models::BodyElement::Code { language, content } => {
+ json!({ "type": "code", "language": language, "content": content })
+ }
+ crate::db::models::BodyElement::List { ordered, items } => {
+ json!({ "type": "list", "ordered": ordered, "items": items })
+ }
+ crate::db::models::BodyElement::Chart { chart_type, title, data, config: _ } => {
+ let data_count = data.as_array().map(|arr| arr.len()).unwrap_or(0);
+ json!({ "type": "chart", "chartType": chart_type, "title": title, "dataPoints": data_count })
+ }
+ crate::db::models::BodyElement::Image { src, alt, caption } => {
+ json!({ "type": "image", "src": src, "alt": alt, "caption": caption })
+ }
+ }
+ })
+ .collect();
+
+ // Also build a plain text version for easier reading
+ let plain_text: String = file
+ .body
+ .iter()
+ .filter_map(|elem| {
+ match elem {
+ crate::db::models::BodyElement::Heading { level, text } => {
+ Some(format!("{} {}", "#".repeat(*level as usize), text))
+ }
+ crate::db::models::BodyElement::Paragraph { text } => {
+ Some(text.clone())
+ }
+ crate::db::models::BodyElement::Code { language, content } => {
+ let lang = language.as_deref().unwrap_or("");
+ Some(format!("```{}\n{}\n```", lang, content))
+ }
+ crate::db::models::BodyElement::List { ordered, items } => {
+ let list_text: Vec<String> = items.iter().enumerate().map(|(i, item)| {
+ if *ordered {
+ format!("{}. {}", i + 1, item)
+ } else {
+ format!("- {}", item)
+ }
+ }).collect();
+ Some(list_text.join("\n"))
+ }
+ _ => None,
+ }
+ })
+ .collect::<Vec<_>>()
+ .join("\n\n");
+
+ // Convert transcript entries to JSON
+ let transcript: Vec<serde_json::Value> = file
+ .transcript
+ .iter()
+ .map(|entry| {
+ json!({
+ "id": entry.id,
+ "speaker": entry.speaker,
+ "start": entry.start,
+ "end": entry.end,
+ "text": entry.text,
+ })
+ })
+ .collect();
+
+ // Build a plain text transcript for easier reading
+ let transcript_text: String = file
+ .transcript
+ .iter()
+ .map(|entry| {
+ format!("[{:.1}s] {}: {}", entry.start, entry.speaker, entry.text)
+ })
+ .collect::<Vec<_>>()
+ .join("\n");
+
+ MeshRequestResult {
+ success: true,
+ message: format!("Read file '{}'", file.name),
+ data: Some(json!({
+ "fileId": file.id,
+ "name": file.name,
+ "description": file.description,
+ "summary": file.summary,
+ "body": body_content,
+ "plainText": plain_text,
+ "transcript": transcript,
+ "transcriptText": transcript_text,
+ "transcriptCount": file.transcript.len(),
+ "createdAt": file.created_at,
+ "updatedAt": file.updated_at,
+ })),
+ }
+ }
+ Ok(None) => MeshRequestResult {
+ success: false,
+ message: format!("File {} not found", file_id),
+ data: None,
+ },
+ Err(e) => MeshRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ MeshToolRequest::SendMessageToTask { task_id, message } => {
+ let task = match repository::get_task_for_owner(pool, task_id, owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Task {} not found", task_id),
+ data: None,
+ }
+ }
+ Err(e) => {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ }
+ }
+ };
+
+ if task.status != "running" {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Task is not running (status: {})", task.status),
+ data: None,
+ };
+ }
+
+ if let Some(daemon_id) = task.daemon_id {
+ let command = DaemonCommand::SendMessage { task_id, message };
+ match state.send_daemon_command(daemon_id, command).await {
+ Ok(()) => MeshRequestResult {
+ success: true,
+ message: "Message sent to task".to_string(),
+ data: Some(json!({ "taskId": task_id })),
+ },
+ Err(e) => MeshRequestResult {
+ success: false,
+ message: format!("Failed to send message: {}", e),
+ data: None,
+ },
+ }
+ } else {
+ MeshRequestResult {
+ success: false,
+ message: "Task has no daemon assigned".to_string(),
+ data: None,
+ }
+ }
+ }
+
+ MeshToolRequest::UpdateTaskPlan {
+ task_id,
+ new_plan,
+ interrupt_if_running,
+ } => {
+ let task = match repository::get_task_for_owner(pool, task_id, owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Task {} not found", task_id),
+ data: None,
+ }
+ }
+ Err(e) => {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ }
+ }
+ };
+
+ // Interrupt if running and requested
+ if task.status == "running" && interrupt_if_running {
+ if let Some(daemon_id) = task.daemon_id {
+ let command = DaemonCommand::InterruptTask {
+ task_id,
+ graceful: true,
+ };
+ let _ = state.send_daemon_command(daemon_id, command).await;
+ }
+ }
+
+ let update_req = crate::db::models::UpdateTaskRequest {
+ plan: Some(new_plan),
+ version: Some(task.version),
+ ..Default::default()
+ };
+
+ match repository::update_task_for_owner(pool, task_id, owner_id, update_req).await {
+ Ok(Some(updated)) => {
+ state.broadcast_task_update(TaskUpdateNotification {
+ task_id,
+ owner_id: Some(task.owner_id),
+ version: updated.version,
+ status: updated.status.clone(),
+ updated_fields: vec!["plan".to_string()],
+ updated_by: "system".to_string(),
+ });
+ MeshRequestResult {
+ success: true,
+ message: "Task plan updated".to_string(),
+ data: Some(json!({ "taskId": task_id })),
+ }
+ }
+ Ok(None) => MeshRequestResult {
+ success: false,
+ message: "Task not found".to_string(),
+ data: None,
+ },
+ Err(e) => MeshRequestResult {
+ success: false,
+ message: format!("Failed to update task: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ // Overlay operations - these require daemon communication
+ // For now, return placeholder responses since daemon implementation is separate
+ MeshToolRequest::PeekSiblingOverlay { sibling_task_id } => MeshRequestResult {
+ success: false,
+ message: format!(
+ "Overlay operations require a connected daemon. Task {} may not have overlay data yet.",
+ sibling_task_id
+ ),
+ data: None,
+ },
+
+ MeshToolRequest::GetOverlayDiff { task_id } => MeshRequestResult {
+ success: false,
+ message: format!(
+ "Overlay operations require a connected daemon. Task {} may not have overlay data yet.",
+ task_id
+ ),
+ data: None,
+ },
+
+ MeshToolRequest::PreviewMerge { task_id } => MeshRequestResult {
+ success: false,
+ message: format!(
+ "Merge preview requires a connected daemon. Task {} may not have overlay data yet.",
+ task_id
+ ),
+ data: None,
+ },
+
+ MeshToolRequest::MergeSubtask { task_id } => MeshRequestResult {
+ success: false,
+ message: format!(
+ "Merge operations require a connected daemon. Task {}",
+ task_id
+ ),
+ data: None,
+ },
+
+ MeshToolRequest::CompleteTask { task_id } => {
+ let task = match repository::get_task_for_owner(pool, task_id, owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Task {} not found", task_id),
+ data: None,
+ }
+ }
+ Err(e) => {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ }
+ }
+ };
+
+ // Update status to done
+ let update_req = crate::db::models::UpdateTaskRequest {
+ status: Some("done".to_string()),
+ version: Some(task.version),
+ ..Default::default()
+ };
+
+ match repository::update_task_for_owner(pool, task_id, owner_id, update_req).await {
+ Ok(Some(updated)) => {
+ state.broadcast_task_update(TaskUpdateNotification {
+ task_id,
+ owner_id: Some(task.owner_id),
+ version: updated.version,
+ status: "done".to_string(),
+ updated_fields: vec!["status".to_string()],
+ updated_by: "system".to_string(),
+ });
+ let merge_mode = task.merge_mode.unwrap_or_else(|| "pr".to_string());
+ MeshRequestResult {
+ success: true,
+ message: format!(
+ "Task {} completed. Merge mode: {}",
+ task_id,
+ &merge_mode
+ ),
+ data: Some(json!({
+ "taskId": task_id,
+ "status": "done",
+ "mergeMode": merge_mode,
+ })),
+ }
+ }
+ Ok(None) => MeshRequestResult {
+ success: false,
+ message: "Task not found".to_string(),
+ data: None,
+ },
+ Err(e) => MeshRequestResult {
+ success: false,
+ message: format!("Failed to complete task: {}", e),
+ data: None,
+ },
+ }
+ }
+
+ MeshToolRequest::SetMergeMode { task_id, mode } => {
+ let task = match repository::get_task_for_owner(pool, task_id, owner_id).await {
+ Ok(Some(t)) => t,
+ Ok(None) => {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Task {} not found", task_id),
+ data: None,
+ }
+ }
+ Err(e) => {
+ return MeshRequestResult {
+ success: false,
+ message: format!("Database error: {}", e),
+ data: None,
+ }
+ }
+ };
+
+ let update_req = crate::db::models::UpdateTaskRequest {
+ merge_mode: Some(mode.clone()),
+ version: Some(task.version),
+ ..Default::default()
+ };
+
+ match repository::update_task_for_owner(pool, task_id, owner_id, update_req).await {
+ Ok(Some(updated)) => {
+ state.broadcast_task_update(TaskUpdateNotification {
+ task_id,
+ owner_id: Some(task.owner_id),
+ version: updated.version,
+ status: updated.status,
+ updated_fields: vec!["merge_mode".to_string()],
+ updated_by: "system".to_string(),
+ });
+ MeshRequestResult {
+ success: true,
+ message: format!("Merge mode set to '{}'", mode),
+ data: Some(json!({ "taskId": task_id, "mergeMode": mode })),
+ }
+ }
+ Ok(None) => MeshRequestResult {
+ success: false,
+ message: "Task not found".to_string(),
+ data: None,
+ },
+ Err(e) => MeshRequestResult {
+ success: false,
+ message: format!("Failed to update merge mode: {}", e),
+ data: None,
+ },
+ }
+ }
+ }
+}
+
+// =============================================================================
+// Chat History Endpoints
+// =============================================================================
+
+use crate::db::models::MeshChatHistoryResponse;
+
+/// Get chat history for the current conversation (requires authentication)
+#[utoipa::path(
+ get,
+ path = "/api/v1/mesh/chat/history",
+ responses(
+ (status = 200, description = "Chat history", body = MeshChatHistoryResponse),
+ (status = 401, description = "Unauthorized"),
+ (status = 503, description = "Database not configured"),
+ (status = 500, description = "Internal server error")
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn get_chat_history(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(json!({ "error": "Database not configured" })),
+ )
+ .into_response();
+ };
+
+ let conversation = match repository::get_or_create_active_conversation(pool, auth.owner_id).await {
+ Ok(c) => c,
+ Err(e) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": e.to_string() })),
+ )
+ .into_response()
+ }
+ };
+
+ let messages = match repository::list_chat_messages(pool, conversation.id, None).await {
+ Ok(m) => m,
+ Err(e) => {
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": e.to_string() })),
+ )
+ .into_response()
+ }
+ };
+
+ (
+ StatusCode::OK,
+ Json(MeshChatHistoryResponse {
+ conversation_id: conversation.id,
+ messages,
+ }),
+ )
+ .into_response()
+}
+
+/// Clear chat history (archives current conversation and starts new, requires authentication)
+#[utoipa::path(
+ delete,
+ path = "/api/v1/mesh/chat/history",
+ responses(
+ (status = 200, description = "History cleared"),
+ (status = 401, description = "Unauthorized"),
+ (status = 503, description = "Database not configured"),
+ (status = 500, description = "Internal server error")
+ ),
+ security(
+ ("bearer_auth" = []),
+ ("api_key" = [])
+ ),
+ tag = "Mesh"
+)]
+pub async fn clear_chat_history(
+ State(state): State<SharedState>,
+ Authenticated(auth): Authenticated,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(json!({ "error": "Database not configured" })),
+ )
+ .into_response();
+ };
+
+ match repository::clear_conversation(pool, auth.owner_id).await {
+ Ok(new_conv) => (
+ StatusCode::OK,
+ Json(json!({ "success": true, "conversationId": new_conv.id })),
+ )
+ .into_response(),
+ Err(e) => (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(json!({ "error": e.to_string() })),
+ )
+ .into_response(),
+ }
+}
diff --git a/makima/src/server/handlers/mesh_daemon.rs b/makima/src/server/handlers/mesh_daemon.rs
new file mode 100644
index 0000000..644d0bc
--- /dev/null
+++ b/makima/src/server/handlers/mesh_daemon.rs
@@ -0,0 +1,959 @@
+//! WebSocket handler for daemon connections.
+//!
+//! Daemons connect to report task progress, stream output, and receive commands.
+//! Each daemon manages Claude Code containers on its local machine.
+//!
+//! ## Authentication
+//!
+//! Daemons authenticate via the `X-Api-Key` header in the WebSocket upgrade request.
+//! The API key is validated against the database and the daemon is associated with
+//! the corresponding owner_id for data isolation.
+
+use axum::{
+ extract::{ws::Message, ws::WebSocket, State, WebSocketUpgrade},
+ http::{HeaderMap, StatusCode},
+ response::{IntoResponse, Response},
+};
+use futures::{SinkExt, StreamExt};
+use serde::Deserialize;
+use sqlx::Row;
+use tokio::sync::mpsc;
+use uuid::Uuid;
+
+use crate::db::repository;
+use crate::server::auth::{hash_api_key, API_KEY_HEADER};
+use crate::server::messages::ApiError;
+use crate::server::state::{
+ DaemonCommand, SharedState, TaskOutputNotification, TaskUpdateNotification,
+};
+
+// =============================================================================
+// Claude Code JSON Output Parsing
+// =============================================================================
+
+/// Claude Code stream-json message structure
+#[derive(Debug, Deserialize)]
+struct ClaudeMessage {
+ #[serde(rename = "type")]
+ msg_type: String,
+ subtype: Option<String>,
+ message: Option<ClaudeMessageContent>,
+ tool_name: Option<String>,
+ tool_input: Option<serde_json::Value>,
+ tool_result: Option<ClaudeToolResult>,
+ result: Option<String>,
+ cost_usd: Option<f64>,
+ duration_ms: Option<u64>,
+ error: Option<String>,
+}
+
+#[derive(Debug, Deserialize)]
+struct ClaudeMessageContent {
+ content: Option<Vec<ClaudeContentBlock>>,
+}
+
+#[derive(Debug, Deserialize)]
+struct ClaudeContentBlock {
+ #[serde(rename = "type")]
+ block_type: String,
+ text: Option<String>,
+ name: Option<String>,
+ input: Option<serde_json::Value>,
+}
+
+#[derive(Debug, Deserialize)]
+struct ClaudeToolResult {
+ content: Option<String>,
+ is_error: Option<bool>,
+}
+
+/// Parse a line of Claude Code output into a structured notification
+fn parse_claude_output(task_id: Uuid, owner_id: Uuid, line: &str, is_partial: bool) -> Option<TaskOutputNotification> {
+ let trimmed = line.trim();
+ if trimmed.is_empty() {
+ return None;
+ }
+
+ // Try to parse as JSON
+ if trimmed.starts_with('{') {
+ if let Ok(msg) = serde_json::from_str::<ClaudeMessage>(trimmed) {
+ return parse_claude_message(task_id, owner_id, msg, is_partial);
+ }
+ }
+
+ // Not JSON or failed to parse - treat as raw output
+ Some(TaskOutputNotification {
+ task_id,
+ owner_id: Some(owner_id),
+ message_type: "raw".to_string(),
+ content: trimmed.to_string(),
+ tool_name: None,
+ tool_input: None,
+ is_error: None,
+ cost_usd: None,
+ duration_ms: None,
+ is_partial,
+ })
+}
+
+fn parse_claude_message(task_id: Uuid, owner_id: Uuid, msg: ClaudeMessage, is_partial: bool) -> Option<TaskOutputNotification> {
+ match msg.msg_type.as_str() {
+ "system" => {
+ // System messages (init, etc.) - include subtype info
+ let content = match msg.subtype.as_deref() {
+ Some("init") => "Session started".to_string(),
+ Some(sub) => format!("System: {}", sub),
+ None => "System message".to_string(),
+ };
+ Some(TaskOutputNotification {
+ task_id,
+ owner_id: Some(owner_id),
+ message_type: "system".to_string(),
+ content,
+ tool_name: None,
+ tool_input: None,
+ is_error: None,
+ cost_usd: None,
+ duration_ms: None,
+ is_partial,
+ })
+ }
+
+ "assistant" => {
+ // Extract text content from message blocks
+ if let Some(message) = msg.message {
+ if let Some(blocks) = message.content {
+ // Check for text blocks
+ let text_content: Vec<String> = blocks
+ .iter()
+ .filter(|b| b.block_type == "text")
+ .filter_map(|b| b.text.clone())
+ .collect();
+
+ if !text_content.is_empty() {
+ return Some(TaskOutputNotification {
+ task_id,
+ owner_id: Some(owner_id),
+ message_type: "assistant".to_string(),
+ content: text_content.join("\n"),
+ tool_name: None,
+ tool_input: None,
+ is_error: None,
+ cost_usd: None,
+ duration_ms: None,
+ is_partial,
+ });
+ }
+
+ // Check for tool_use blocks
+ if let Some(tool_block) = blocks.iter().find(|b| b.block_type == "tool_use") {
+ return Some(TaskOutputNotification {
+ task_id,
+ owner_id: Some(owner_id),
+ message_type: "tool_use".to_string(),
+ content: format!("Using tool: {}", tool_block.name.as_deref().unwrap_or("unknown")),
+ tool_name: tool_block.name.clone(),
+ tool_input: tool_block.input.clone(),
+ is_error: None,
+ cost_usd: None,
+ duration_ms: None,
+ is_partial,
+ });
+ }
+ }
+ }
+ None
+ }
+
+ "tool_use" => {
+ Some(TaskOutputNotification {
+ task_id,
+ owner_id: Some(owner_id),
+ message_type: "tool_use".to_string(),
+ content: format!("Using tool: {}", msg.tool_name.as_deref().unwrap_or("unknown")),
+ tool_name: msg.tool_name,
+ tool_input: msg.tool_input,
+ is_error: None,
+ cost_usd: None,
+ duration_ms: None,
+ is_partial,
+ })
+ }
+
+ "tool_result" => {
+ if let Some(result) = msg.tool_result {
+ let content = result.content.unwrap_or_default();
+ // Truncate long results
+ let content = if content.len() > 500 {
+ format!("{}...", &content[..500])
+ } else {
+ content
+ };
+ Some(TaskOutputNotification {
+ task_id,
+ owner_id: Some(owner_id),
+ message_type: "tool_result".to_string(),
+ content,
+ tool_name: None,
+ tool_input: None,
+ is_error: result.is_error,
+ cost_usd: None,
+ duration_ms: None,
+ is_partial,
+ })
+ } else {
+ None
+ }
+ }
+
+ "result" => {
+ Some(TaskOutputNotification {
+ task_id,
+ owner_id: Some(owner_id),
+ message_type: "result".to_string(),
+ content: msg.result.unwrap_or_else(|| "Task completed".to_string()),
+ tool_name: None,
+ tool_input: None,
+ is_error: None,
+ cost_usd: msg.cost_usd,
+ duration_ms: msg.duration_ms,
+ is_partial,
+ })
+ }
+
+ "error" => {
+ Some(TaskOutputNotification {
+ task_id,
+ owner_id: Some(owner_id),
+ message_type: "error".to_string(),
+ content: msg.error.unwrap_or_else(|| "An error occurred".to_string()),
+ tool_name: None,
+ tool_input: None,
+ is_error: Some(true),
+ cost_usd: None,
+ duration_ms: None,
+ is_partial,
+ })
+ }
+
+ _ => None, // Skip unknown message types
+ }
+}
+
+/// Message from daemon to server.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(tag = "type", rename_all = "camelCase")]
+pub enum DaemonMessage {
+ /// Authentication request (first message required)
+ Authenticate {
+ #[serde(rename = "apiKey")]
+ api_key: String,
+ #[serde(rename = "machineId")]
+ machine_id: String,
+ hostname: String,
+ #[serde(rename = "maxConcurrentTasks")]
+ max_concurrent_tasks: i32,
+ },
+ /// Periodic heartbeat with current status
+ Heartbeat {
+ #[serde(rename = "activeTasks")]
+ active_tasks: Vec<Uuid>,
+ },
+ /// Task output streaming (stdout/stderr from Claude Code)
+ TaskOutput {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ output: String,
+ #[serde(rename = "isPartial")]
+ is_partial: bool,
+ },
+ /// Task status change notification
+ TaskStatusChange {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ #[serde(rename = "oldStatus")]
+ old_status: String,
+ #[serde(rename = "newStatus")]
+ new_status: String,
+ },
+ /// Task progress update with summary
+ TaskProgress {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ summary: String,
+ },
+ /// Task completion notification
+ TaskComplete {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ success: bool,
+ error: Option<String>,
+ },
+ /// Register a tool key for orchestrator API access
+ RegisterToolKey {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ /// The API key for this orchestrator to use when calling mesh endpoints
+ key: String,
+ },
+ /// Revoke a tool key when task completes
+ RevokeToolKey {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+ /// Response to RetryCompletionAction command
+ CompletionActionResult {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ success: bool,
+ message: String,
+ /// PR URL if action was "pr" and successful
+ #[serde(rename = "prUrl")]
+ pr_url: Option<String>,
+ },
+ /// Report daemon's available directories for task output
+ DaemonDirectories {
+ /// Current working directory of the daemon
+ #[serde(rename = "workingDirectory")]
+ working_directory: String,
+ /// Path to ~/.makima/home directory (for cloning completed work)
+ #[serde(rename = "homeDirectory")]
+ home_directory: String,
+ /// Path to worktrees directory (~/.makima/worktrees)
+ #[serde(rename = "worktreesDirectory")]
+ worktrees_directory: String,
+ },
+ /// Response to CloneWorktree command
+ CloneWorktreeResult {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ success: bool,
+ message: String,
+ /// The path where the worktree was cloned
+ #[serde(rename = "targetDir")]
+ target_dir: Option<String>,
+ },
+ /// Response to CheckTargetExists command
+ CheckTargetExistsResult {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ /// Whether the target directory exists
+ exists: bool,
+ /// The path that was checked
+ #[serde(rename = "targetDir")]
+ target_dir: String,
+ },
+}
+
+/// Validated daemon authentication result.
+#[derive(Debug, Clone)]
+struct DaemonAuthResult {
+ /// User ID from the API key
+ user_id: Uuid,
+ /// Owner ID for data isolation
+ owner_id: Uuid,
+}
+
+/// Validate an API key and return (user_id, owner_id).
+async fn validate_daemon_api_key(pool: &sqlx::PgPool, key: &str) -> Result<DaemonAuthResult, String> {
+ let key_hash = hash_api_key(key);
+
+ // Look up the API key and join with users to get owner_id
+ let row = sqlx::query(
+ r#"
+ SELECT ak.user_id, u.default_owner_id
+ FROM api_keys ak
+ JOIN users u ON u.id = ak.user_id
+ WHERE ak.key_hash = $1 AND ak.revoked_at IS NULL
+ "#,
+ )
+ .bind(&key_hash)
+ .fetch_optional(pool)
+ .await
+ .map_err(|e| format!("Database error: {}", e))?;
+
+ match row {
+ Some(row) => {
+ let user_id: Uuid = row.try_get("user_id")
+ .map_err(|e| format!("Failed to get user_id: {}", e))?;
+ let owner_id: Option<Uuid> = row.try_get("default_owner_id")
+ .map_err(|e| format!("Failed to get owner_id: {}", e))?;
+ let owner_id = owner_id.ok_or_else(|| "User has no default owner".to_string())?;
+
+ // Update last_used_at asynchronously (fire and forget)
+ let pool_clone = pool.clone();
+ let key_hash_clone = key_hash.clone();
+ tokio::spawn(async move {
+ let _ = sqlx::query("UPDATE api_keys SET last_used_at = NOW() WHERE key_hash = $1")
+ .bind(&key_hash_clone)
+ .execute(&pool_clone)
+ .await;
+ });
+
+ Ok(DaemonAuthResult { user_id, owner_id })
+ }
+ None => Err("Invalid or revoked API key".to_string()),
+ }
+}
+
+/// WebSocket upgrade handler for daemon connections.
+///
+/// Daemons must authenticate via the `X-Api-Key` header in the WebSocket upgrade request.
+/// The API key is validated against the database and used to determine the owner_id
+/// for data isolation.
+#[utoipa::path(
+ get,
+ path = "/api/v1/mesh/daemons/connect",
+ params(
+ ("X-Api-Key" = String, Header, description = "API key for daemon authentication"),
+ ),
+ responses(
+ (status = 101, description = "WebSocket connection established"),
+ (status = 401, description = "Missing or invalid API key"),
+ (status = 503, description = "Database not configured"),
+ ),
+ tag = "Mesh"
+)]
+pub async fn daemon_handler(
+ ws: WebSocketUpgrade,
+ State(state): State<SharedState>,
+ headers: HeaderMap,
+) -> Response {
+ // Extract API key from headers
+ let api_key = match headers.get(API_KEY_HEADER).or_else(|| headers.get("x-api-key")) {
+ Some(value) => match value.to_str() {
+ Ok(key) if !key.is_empty() => key.to_string(),
+ _ => {
+ return (
+ StatusCode::UNAUTHORIZED,
+ axum::Json(ApiError::new("INVALID_API_KEY", "Invalid API key header value")),
+ )
+ .into_response();
+ }
+ },
+ None => {
+ return (
+ StatusCode::UNAUTHORIZED,
+ axum::Json(ApiError::new("MISSING_API_KEY", "X-Api-Key header required")),
+ )
+ .into_response();
+ }
+ };
+
+ // Validate API key against database
+ let pool = match state.db_pool.as_ref() {
+ Some(pool) => pool,
+ None => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ axum::Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ }
+ };
+
+ let auth_result = match validate_daemon_api_key(pool, &api_key).await {
+ Ok(result) => result,
+ Err(e) => {
+ tracing::warn!("Daemon authentication failed: {}", e);
+ return (
+ StatusCode::UNAUTHORIZED,
+ axum::Json(ApiError::new("AUTH_FAILED", e)),
+ )
+ .into_response();
+ }
+ };
+
+ tracing::info!(
+ user_id = %auth_result.user_id,
+ owner_id = %auth_result.owner_id,
+ "Daemon authenticated via API key"
+ );
+
+ ws.on_upgrade(move |socket| handle_daemon_connection(socket, state, auth_result))
+}
+
+async fn handle_daemon_connection(socket: WebSocket, state: SharedState, auth_result: DaemonAuthResult) {
+ let (mut sender, mut receiver) = socket.split();
+
+ // Generate a unique connection ID and daemon ID
+ let connection_id = Uuid::new_v4().to_string();
+ let daemon_id = Uuid::new_v4();
+ let owner_id = auth_result.owner_id;
+
+ // Create command channel for sending commands to this daemon
+ let (cmd_tx, mut cmd_rx) = mpsc::channel::<DaemonCommand>(64);
+
+ // Wait for the daemon to send its registration info (hostname, machine_id, etc.)
+ // The daemon is already authenticated via API key header, but we need metadata
+ #[allow(unused_assignments)]
+ let mut registered = false;
+
+ // Wait for registration message with metadata
+ loop {
+ tokio::select! {
+ msg = receiver.next() => {
+ match msg {
+ Some(Ok(Message::Text(text))) => {
+ match serde_json::from_str::<DaemonMessage>(&text) {
+ Ok(DaemonMessage::Authenticate { api_key: _, machine_id, hostname, max_concurrent_tasks }) => {
+ // API key was already validated via headers, but we use this message
+ // for backward compatibility to get the machine_id and hostname
+
+ tracing::info!(
+ daemon_id = %daemon_id,
+ owner_id = %owner_id,
+ hostname = %hostname,
+ machine_id = %machine_id,
+ max_concurrent_tasks = max_concurrent_tasks,
+ "Daemon registered"
+ );
+
+ // Register daemon in state with owner_id
+ state.register_daemon(
+ connection_id.clone(),
+ daemon_id,
+ owner_id,
+ Some(hostname),
+ Some(machine_id),
+ cmd_tx.clone(),
+ );
+
+ registered = true;
+
+ // Send authentication confirmation
+ let response = DaemonCommand::Authenticated { daemon_id };
+ let json = serde_json::to_string(&response).unwrap();
+ if sender.send(Message::Text(json.into())).await.is_err() {
+ break;
+ }
+
+ break; // Exit registration loop, continue to main loop
+ }
+ Ok(_) => {
+ // Non-auth message before registration - still requires registration message
+ let response = DaemonCommand::Error {
+ code: "NOT_REGISTERED".into(),
+ message: "Must send registration message (Authenticate) first".into(),
+ };
+ let json = serde_json::to_string(&response).unwrap();
+ let _ = sender.send(Message::Text(json.into())).await;
+ }
+ Err(e) => {
+ let response = DaemonCommand::Error {
+ code: "PARSE_ERROR".into(),
+ message: e.to_string(),
+ };
+ let json = serde_json::to_string(&response).unwrap();
+ let _ = sender.send(Message::Text(json.into())).await;
+ }
+ }
+ }
+ Some(Ok(Message::Close(_))) | None => {
+ tracing::debug!("Daemon disconnected during registration");
+ return;
+ }
+ Some(Err(e)) => {
+ tracing::warn!("Daemon WebSocket error during registration: {}", e);
+ return;
+ }
+ _ => {}
+ }
+ }
+ }
+ }
+
+ if !registered {
+ return;
+ }
+
+ let daemon_uuid = daemon_id;
+
+ // Main message loop after authentication
+ loop {
+ tokio::select! {
+ // Handle incoming messages from daemon
+ msg = receiver.next() => {
+ match msg {
+ Some(Ok(Message::Text(text))) => {
+ match serde_json::from_str::<DaemonMessage>(&text) {
+ Ok(DaemonMessage::Heartbeat { active_tasks }) => {
+ tracing::trace!(
+ "Daemon {} heartbeat: {} active tasks",
+ daemon_uuid, active_tasks.len()
+ );
+ // TODO: Update daemon last_heartbeat_at in DB
+ }
+ Ok(DaemonMessage::TaskOutput { task_id, output, is_partial }) => {
+ // Parse the output line and broadcast structured data
+ if let Some(notification) = parse_claude_output(task_id, owner_id, &output, is_partial) {
+ // Broadcast to connected clients
+ state.broadcast_task_output(notification.clone());
+
+ // Persist to database (fire and forget)
+ if let Some(ref pool) = state.db_pool {
+ let pool = pool.clone();
+ let notification = notification.clone();
+ tokio::spawn(async move {
+ if let Err(e) = repository::save_task_output(
+ &pool,
+ notification.task_id,
+ &notification.message_type,
+ &notification.content,
+ notification.tool_name.as_deref(),
+ notification.tool_input.clone(),
+ notification.is_error,
+ notification.cost_usd,
+ notification.duration_ms,
+ ).await {
+ tracing::warn!(
+ task_id = %notification.task_id,
+ "Failed to persist task output: {}",
+ e
+ );
+ }
+ });
+ }
+ }
+ }
+ Ok(DaemonMessage::TaskStatusChange { task_id, old_status, new_status }) => {
+ tracing::info!(
+ "Task {} status change: {} -> {}",
+ task_id, old_status, new_status
+ );
+
+ // Update task status in database and broadcast
+ if let Some(ref pool) = state.db_pool {
+ let pool = pool.clone();
+ let state = state.clone();
+ let new_status_owned = new_status.clone();
+ tokio::spawn(async move {
+ match repository::update_task_status(
+ &pool,
+ task_id,
+ &new_status_owned,
+ None,
+ ).await {
+ Ok(Some(updated_task)) => {
+ state.broadcast_task_update(TaskUpdateNotification {
+ task_id,
+ owner_id: Some(owner_id),
+ version: updated_task.version,
+ status: new_status_owned,
+ updated_fields: vec!["status".into()],
+ updated_by: "daemon".into(),
+ });
+ }
+ Ok(None) => {
+ tracing::warn!(
+ task_id = %task_id,
+ "Task not found when updating status"
+ );
+ }
+ Err(e) => {
+ tracing::error!(
+ task_id = %task_id,
+ "Failed to update task status: {}",
+ e
+ );
+ }
+ }
+ });
+ } else {
+ // No DB, just broadcast
+ state.broadcast_task_update(TaskUpdateNotification {
+ task_id,
+ owner_id: Some(owner_id),
+ version: 0,
+ status: new_status,
+ updated_fields: vec!["status".into()],
+ updated_by: "daemon".into(),
+ });
+ }
+ }
+ Ok(DaemonMessage::TaskProgress { task_id, summary }) => {
+ tracing::debug!("Task {} progress: {}", task_id, summary);
+ // TODO: Update task progress_summary in database
+ state.broadcast_task_update(TaskUpdateNotification {
+ task_id,
+ owner_id: Some(owner_id),
+ version: 0,
+ status: "running".into(),
+ updated_fields: vec!["progress_summary".into()],
+ updated_by: "daemon".into(),
+ });
+ }
+ Ok(DaemonMessage::TaskComplete { task_id, success, error }) => {
+ let status = if success { "done" } else { "failed" };
+ tracing::info!(
+ "Task {} completed: success={}, error={:?}",
+ task_id, success, error
+ );
+
+ // Revoke any tool keys for this task
+ state.revoke_tool_key(task_id);
+
+ // Update task in database with completion info
+ if let Some(ref pool) = state.db_pool {
+ let pool = pool.clone();
+ let state = state.clone();
+ let error_clone = error.clone();
+ tokio::spawn(async move {
+ match repository::complete_task(
+ &pool,
+ task_id,
+ success,
+ error_clone.as_deref(),
+ ).await {
+ Ok(Some(updated_task)) => {
+ state.broadcast_task_update(TaskUpdateNotification {
+ task_id,
+ owner_id: Some(owner_id),
+ version: updated_task.version,
+ status: updated_task.status.clone(),
+ updated_fields: vec![
+ "status".into(),
+ "completed_at".into(),
+ "error_message".into(),
+ ],
+ updated_by: "daemon".into(),
+ });
+ }
+ Ok(None) => {
+ tracing::warn!(
+ task_id = %task_id,
+ "Task not found when completing"
+ );
+ }
+ Err(e) => {
+ tracing::error!(
+ task_id = %task_id,
+ "Failed to complete task: {}",
+ e
+ );
+ }
+ }
+ });
+ } else {
+ // No DB, just broadcast
+ state.broadcast_task_update(TaskUpdateNotification {
+ task_id,
+ owner_id: Some(owner_id),
+ version: 0,
+ status: status.into(),
+ updated_fields: vec!["status".into(), "completed_at".into()],
+ updated_by: "daemon".into(),
+ });
+ }
+ }
+ Ok(DaemonMessage::Authenticate { .. }) => {
+ // Already authenticated, ignore
+ }
+ Ok(DaemonMessage::RegisterToolKey { task_id, key }) => {
+ tracing::info!(
+ task_id = %task_id,
+ "Registering tool key for orchestrator"
+ );
+ state.register_tool_key(key, task_id);
+ }
+ Ok(DaemonMessage::RevokeToolKey { task_id }) => {
+ tracing::info!(
+ task_id = %task_id,
+ "Revoking tool key for task"
+ );
+ state.revoke_tool_key(task_id);
+ }
+ Ok(DaemonMessage::DaemonDirectories { working_directory, home_directory, worktrees_directory }) => {
+ tracing::info!(
+ daemon_id = %daemon_uuid,
+ working_directory = %working_directory,
+ home_directory = %home_directory,
+ worktrees_directory = %worktrees_directory,
+ "Daemon directories received"
+ );
+ state.update_daemon_directories(
+ &connection_id,
+ working_directory,
+ home_directory,
+ worktrees_directory,
+ );
+ }
+ Ok(DaemonMessage::CompletionActionResult { task_id, success, message, pr_url }) => {
+ tracing::info!(
+ task_id = %task_id,
+ success = success,
+ message = %message,
+ pr_url = ?pr_url,
+ "Completion action result received"
+ );
+
+ // Update task with PR URL if created
+ if let Some(ref url) = pr_url {
+ if let Some(ref pool) = state.db_pool {
+ let update_req = crate::db::models::UpdateTaskRequest {
+ pr_url: Some(url.clone()),
+ ..Default::default()
+ };
+ if let Err(e) = crate::db::repository::update_task(pool, task_id, update_req).await {
+ tracing::error!("Failed to update task PR URL: {}", e);
+ }
+ }
+ }
+
+ // Broadcast as task output so UI can see the result
+ let output_text = if success {
+ format!("✓ Completion action succeeded: {}", message)
+ } else {
+ format!("✗ Completion action failed: {}", message)
+ };
+ state.broadcast_task_output(TaskOutputNotification {
+ task_id,
+ owner_id: Some(owner_id),
+ message_type: "system".to_string(),
+ content: output_text,
+ tool_name: None,
+ tool_input: None,
+ is_error: Some(!success),
+ cost_usd: None,
+ duration_ms: None,
+ is_partial: false,
+ });
+ }
+ Ok(DaemonMessage::CloneWorktreeResult { task_id, success, message, target_dir }) => {
+ tracing::info!(
+ task_id = %task_id,
+ success = success,
+ message = %message,
+ target_dir = ?target_dir,
+ "Clone worktree result received"
+ );
+
+ // Broadcast as task output so UI can see the result
+ let output_text = if success {
+ format!("✓ Clone succeeded: {}", message)
+ } else {
+ format!("✗ Clone failed: {}", message)
+ };
+ state.broadcast_task_output(TaskOutputNotification {
+ task_id,
+ owner_id: Some(owner_id),
+ message_type: "system".to_string(),
+ content: output_text,
+ tool_name: None,
+ tool_input: None,
+ is_error: Some(!success),
+ cost_usd: None,
+ duration_ms: None,
+ is_partial: false,
+ });
+ }
+ Ok(DaemonMessage::CheckTargetExistsResult { task_id, exists, target_dir }) => {
+ tracing::debug!(
+ task_id = %task_id,
+ exists = exists,
+ target_dir = %target_dir,
+ "Check target exists result received"
+ );
+
+ // Broadcast as task output so UI can use the result
+ let output_text = if exists {
+ format!("Target directory exists: {}", target_dir)
+ } else {
+ format!("Target directory does not exist: {}", target_dir)
+ };
+ state.broadcast_task_output(TaskOutputNotification {
+ task_id,
+ owner_id: Some(owner_id),
+ message_type: "system".to_string(),
+ content: output_text,
+ tool_name: None,
+ tool_input: None,
+ is_error: None,
+ cost_usd: None,
+ duration_ms: None,
+ is_partial: false,
+ });
+ }
+ Err(e) => {
+ tracing::warn!("Failed to parse daemon message: {}", e);
+ }
+ }
+ }
+ Some(Ok(Message::Close(_))) | None => {
+ tracing::info!("Daemon {} disconnected", daemon_uuid);
+ break;
+ }
+ Some(Err(e)) => {
+ tracing::warn!("Daemon {} WebSocket error: {}", daemon_uuid, e);
+ break;
+ }
+ _ => {}
+ }
+ }
+
+ // Handle commands to send to daemon
+ cmd = cmd_rx.recv() => {
+ match cmd {
+ Some(command) => {
+ let json = serde_json::to_string(&command).unwrap();
+ if sender.send(Message::Text(json.into())).await.is_err() {
+ tracing::warn!("Failed to send command to daemon {}", daemon_uuid);
+ break;
+ }
+ }
+ None => {
+ // Channel closed
+ break;
+ }
+ }
+ }
+ }
+ }
+
+ // Cleanup on disconnect
+ state.unregister_daemon(&connection_id);
+
+ // Clear daemon_id from any tasks that were running on this daemon
+ if let Some(ref pool) = state.db_pool {
+ let pool = pool.clone();
+ tokio::spawn(async move {
+ // Find tasks assigned to this daemon that are still active
+ if let Err(e) = clear_daemon_from_tasks(&pool, daemon_uuid).await {
+ tracing::error!(
+ daemon_id = %daemon_uuid,
+ error = %e,
+ "Failed to clear daemon from tasks on disconnect"
+ );
+ }
+ });
+ }
+}
+
+/// Clear daemon_id from tasks when daemon disconnects
+async fn clear_daemon_from_tasks(pool: &sqlx::PgPool, daemon_id: Uuid) -> Result<(), sqlx::Error> {
+ // Update tasks that were running on this daemon to failed state
+ let result = sqlx::query(
+ r#"
+ UPDATE tasks
+ SET daemon_id = NULL,
+ status = 'failed',
+ error_message = 'Daemon disconnected',
+ updated_at = NOW()
+ WHERE daemon_id = $1
+ AND status IN ('starting', 'running', 'initializing')
+ "#,
+ )
+ .bind(daemon_id)
+ .execute(pool)
+ .await?;
+
+ if result.rows_affected() > 0 {
+ tracing::warn!(
+ daemon_id = %daemon_id,
+ tasks_affected = result.rows_affected(),
+ "Marked tasks as failed due to daemon disconnect"
+ );
+ }
+
+ Ok(())
+}
diff --git a/makima/src/server/handlers/mesh_merge.rs b/makima/src/server/handlers/mesh_merge.rs
new file mode 100644
index 0000000..2d7c742
--- /dev/null
+++ b/makima/src/server/handlers/mesh_merge.rs
@@ -0,0 +1,441 @@
+//! Merge operation handlers for orchestrator tasks.
+//!
+//! These endpoints allow orchestrators to merge subtask branches.
+//! Commands are forwarded to the daemon via WebSocket; the daemon
+//! responds asynchronously through the WebSocket channel.
+
+use axum::{
+ extract::{Path, State},
+ http::StatusCode,
+ response::IntoResponse,
+ Json,
+};
+use uuid::Uuid;
+
+use crate::db::models::{
+ BranchListResponse, MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest,
+ MergeResultResponse, MergeSkipRequest, MergeStartRequest, MergeStatusResponse,
+};
+use crate::db::repository;
+use crate::server::messages::ApiError;
+use crate::server::state::{DaemonCommand, SharedState};
+
+/// Get the daemon ID for a task, returning error if not found.
+async fn get_task_daemon_id(
+ state: &SharedState,
+ task_id: Uuid,
+) -> Result<Uuid, (StatusCode, Json<ApiError>)> {
+ let pool = state.db_pool.as_ref().ok_or_else(|| {
+ (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("service_unavailable", "Database not configured")),
+ )
+ })?;
+
+ // Get task and its daemon_id
+ let task = repository::get_task(pool, task_id)
+ .await
+ .map_err(|e| {
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("database_error", format!("Database error: {}", e))),
+ )
+ })?
+ .ok_or_else(|| {
+ (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("not_found", format!("Task {} not found", task_id))),
+ )
+ })?;
+
+ task.daemon_id.ok_or_else(|| {
+ (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("bad_request", "Task has no assigned daemon")),
+ )
+ })
+}
+
+/// List all subtask branches for a task.
+///
+/// GET /api/v1/mesh/tasks/{id}/branches
+#[utoipa::path(
+ get,
+ path = "/api/v1/mesh/tasks/{id}/branches",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ responses(
+ (status = 202, description = "Command sent to daemon"),
+ (status = 404, description = "Task not found"),
+ (status = 503, description = "Database not configured")
+ ),
+ tag = "Mesh"
+)]
+pub async fn list_branches(
+ State(state): State<SharedState>,
+ Path(task_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let daemon_id = match get_task_daemon_id(&state, task_id).await {
+ Ok(id) => id,
+ Err(e) => return e.into_response(),
+ };
+
+ let command = DaemonCommand::ListBranches { task_id };
+
+ match state.send_daemon_command(daemon_id, command).await {
+ Ok(()) => (
+ StatusCode::ACCEPTED,
+ Json(BranchListResponse { branches: vec![] }),
+ )
+ .into_response(),
+ Err(e) => (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("daemon_error", e)),
+ )
+ .into_response(),
+ }
+}
+
+/// Start merging a subtask branch.
+///
+/// POST /api/v1/mesh/tasks/{id}/merge/start
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/tasks/{id}/merge/start",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ request_body = MergeStartRequest,
+ responses(
+ (status = 202, description = "Merge command sent"),
+ (status = 404, description = "Task not found"),
+ (status = 503, description = "Database not configured or daemon not connected")
+ ),
+ tag = "Mesh"
+)]
+pub async fn merge_start(
+ State(state): State<SharedState>,
+ Path(task_id): Path<Uuid>,
+ Json(req): Json<MergeStartRequest>,
+) -> impl IntoResponse {
+ let daemon_id = match get_task_daemon_id(&state, task_id).await {
+ Ok(id) => id,
+ Err(e) => return e.into_response(),
+ };
+
+ let command = DaemonCommand::MergeStart {
+ task_id,
+ source_branch: req.source_branch,
+ };
+
+ match state.send_daemon_command(daemon_id, command).await {
+ Ok(()) => (
+ StatusCode::ACCEPTED,
+ Json(MergeResultResponse {
+ success: true,
+ message: "Merge command sent".to_string(),
+ commit_sha: None,
+ conflicts: None,
+ }),
+ )
+ .into_response(),
+ Err(e) => (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("daemon_error", e)),
+ )
+ .into_response(),
+ }
+}
+
+/// Get current merge status.
+///
+/// GET /api/v1/mesh/tasks/{id}/merge/status
+#[utoipa::path(
+ get,
+ path = "/api/v1/mesh/tasks/{id}/merge/status",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ responses(
+ (status = 202, description = "Status request sent"),
+ (status = 404, description = "Task not found"),
+ (status = 503, description = "Database not configured or daemon not connected")
+ ),
+ tag = "Mesh"
+)]
+pub async fn merge_status(
+ State(state): State<SharedState>,
+ Path(task_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let daemon_id = match get_task_daemon_id(&state, task_id).await {
+ Ok(id) => id,
+ Err(e) => return e.into_response(),
+ };
+
+ let command = DaemonCommand::MergeStatus { task_id };
+
+ match state.send_daemon_command(daemon_id, command).await {
+ Ok(()) => (
+ StatusCode::ACCEPTED,
+ Json(MergeStatusResponse {
+ in_progress: false,
+ source_branch: None,
+ conflicted_files: vec![],
+ }),
+ )
+ .into_response(),
+ Err(e) => (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("daemon_error", e)),
+ )
+ .into_response(),
+ }
+}
+
+/// Resolve a merge conflict.
+///
+/// POST /api/v1/mesh/tasks/{id}/merge/resolve
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/tasks/{id}/merge/resolve",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ request_body = MergeResolveRequest,
+ responses(
+ (status = 202, description = "Resolve command sent"),
+ (status = 404, description = "Task not found"),
+ (status = 503, description = "Database not configured or daemon not connected")
+ ),
+ tag = "Mesh"
+)]
+pub async fn merge_resolve(
+ State(state): State<SharedState>,
+ Path(task_id): Path<Uuid>,
+ Json(req): Json<MergeResolveRequest>,
+) -> impl IntoResponse {
+ let daemon_id = match get_task_daemon_id(&state, task_id).await {
+ Ok(id) => id,
+ Err(e) => return e.into_response(),
+ };
+
+ let command = DaemonCommand::MergeResolve {
+ task_id,
+ file: req.file,
+ strategy: req.strategy,
+ };
+
+ match state.send_daemon_command(daemon_id, command).await {
+ Ok(()) => (
+ StatusCode::ACCEPTED,
+ Json(MergeResultResponse {
+ success: true,
+ message: "Resolve command sent".to_string(),
+ commit_sha: None,
+ conflicts: None,
+ }),
+ )
+ .into_response(),
+ Err(e) => (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("daemon_error", e)),
+ )
+ .into_response(),
+ }
+}
+
+/// Commit the current merge.
+///
+/// POST /api/v1/mesh/tasks/{id}/merge/commit
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/tasks/{id}/merge/commit",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ request_body = MergeCommitRequest,
+ responses(
+ (status = 202, description = "Commit command sent"),
+ (status = 404, description = "Task not found"),
+ (status = 503, description = "Database not configured or daemon not connected")
+ ),
+ tag = "Mesh"
+)]
+pub async fn merge_commit(
+ State(state): State<SharedState>,
+ Path(task_id): Path<Uuid>,
+ Json(req): Json<MergeCommitRequest>,
+) -> impl IntoResponse {
+ let daemon_id = match get_task_daemon_id(&state, task_id).await {
+ Ok(id) => id,
+ Err(e) => return e.into_response(),
+ };
+
+ let command = DaemonCommand::MergeCommit {
+ task_id,
+ message: req.message,
+ };
+
+ match state.send_daemon_command(daemon_id, command).await {
+ Ok(()) => (
+ StatusCode::ACCEPTED,
+ Json(MergeResultResponse {
+ success: true,
+ message: "Commit command sent".to_string(),
+ commit_sha: None,
+ conflicts: None,
+ }),
+ )
+ .into_response(),
+ Err(e) => (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("daemon_error", e)),
+ )
+ .into_response(),
+ }
+}
+
+/// Abort the current merge.
+///
+/// POST /api/v1/mesh/tasks/{id}/merge/abort
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/tasks/{id}/merge/abort",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ responses(
+ (status = 202, description = "Abort command sent"),
+ (status = 404, description = "Task not found"),
+ (status = 503, description = "Database not configured or daemon not connected")
+ ),
+ tag = "Mesh"
+)]
+pub async fn merge_abort(
+ State(state): State<SharedState>,
+ Path(task_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let daemon_id = match get_task_daemon_id(&state, task_id).await {
+ Ok(id) => id,
+ Err(e) => return e.into_response(),
+ };
+
+ let command = DaemonCommand::MergeAbort { task_id };
+
+ match state.send_daemon_command(daemon_id, command).await {
+ Ok(()) => (
+ StatusCode::ACCEPTED,
+ Json(MergeResultResponse {
+ success: true,
+ message: "Abort command sent".to_string(),
+ commit_sha: None,
+ conflicts: None,
+ }),
+ )
+ .into_response(),
+ Err(e) => (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("daemon_error", e)),
+ )
+ .into_response(),
+ }
+}
+
+/// Skip merging a subtask branch.
+///
+/// POST /api/v1/mesh/tasks/{id}/merge/skip
+#[utoipa::path(
+ post,
+ path = "/api/v1/mesh/tasks/{id}/merge/skip",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ request_body = MergeSkipRequest,
+ responses(
+ (status = 202, description = "Skip command sent"),
+ (status = 404, description = "Task not found"),
+ (status = 503, description = "Database not configured or daemon not connected")
+ ),
+ tag = "Mesh"
+)]
+pub async fn merge_skip(
+ State(state): State<SharedState>,
+ Path(task_id): Path<Uuid>,
+ Json(req): Json<MergeSkipRequest>,
+) -> impl IntoResponse {
+ let daemon_id = match get_task_daemon_id(&state, task_id).await {
+ Ok(id) => id,
+ Err(e) => return e.into_response(),
+ };
+
+ let command = DaemonCommand::MergeSkip {
+ task_id,
+ subtask_id: req.subtask_id,
+ reason: req.reason,
+ };
+
+ match state.send_daemon_command(daemon_id, command).await {
+ Ok(()) => (
+ StatusCode::ACCEPTED,
+ Json(MergeResultResponse {
+ success: true,
+ message: "Skip command sent".to_string(),
+ commit_sha: None,
+ conflicts: None,
+ }),
+ )
+ .into_response(),
+ Err(e) => (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("daemon_error", e)),
+ )
+ .into_response(),
+ }
+}
+
+/// Check if all branches are merged or skipped.
+///
+/// GET /api/v1/mesh/tasks/{id}/merge/check
+#[utoipa::path(
+ get,
+ path = "/api/v1/mesh/tasks/{id}/merge/check",
+ params(
+ ("id" = Uuid, Path, description = "Task ID")
+ ),
+ responses(
+ (status = 202, description = "Check command sent"),
+ (status = 404, description = "Task not found"),
+ (status = 503, description = "Database not configured or daemon not connected")
+ ),
+ tag = "Mesh"
+)]
+pub async fn merge_check(
+ State(state): State<SharedState>,
+ Path(task_id): Path<Uuid>,
+) -> impl IntoResponse {
+ let daemon_id = match get_task_daemon_id(&state, task_id).await {
+ Ok(id) => id,
+ Err(e) => return e.into_response(),
+ };
+
+ let command = DaemonCommand::CheckMergeComplete { task_id };
+
+ match state.send_daemon_command(daemon_id, command).await {
+ Ok(()) => (
+ StatusCode::ACCEPTED,
+ Json(MergeCompleteCheckResponse {
+ can_complete: true,
+ unmerged_branches: vec![],
+ merged_count: 0,
+ skipped_count: 0,
+ }),
+ )
+ .into_response(),
+ Err(e) => (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("daemon_error", e)),
+ )
+ .into_response(),
+ }
+}
diff --git a/makima/src/server/handlers/mesh_ws.rs b/makima/src/server/handlers/mesh_ws.rs
new file mode 100644
index 0000000..d15fba7
--- /dev/null
+++ b/makima/src/server/handlers/mesh_ws.rs
@@ -0,0 +1,346 @@
+//! WebSocket handler for task change subscriptions and output streaming.
+//!
+//! Clients can subscribe to specific tasks or all tasks to receive real-time notifications
+//! when tasks are updated. They can also subscribe to task output for live terminal streaming.
+//!
+//! ## Owner-scoped filtering
+//!
+//! Notifications are filtered by owner_id. If a notification has an owner_id set,
+//! it will only be delivered to clients who are subscribed to tasks belonging to that owner.
+//! The task's owner_id is looked up from the database when the client subscribes.
+
+use axum::{
+ extract::{ws::Message, ws::WebSocket, State, WebSocketUpgrade},
+ response::Response,
+};
+use futures::{SinkExt, StreamExt};
+use serde::{Deserialize, Serialize};
+use sqlx::Row;
+use std::collections::HashMap;
+use uuid::Uuid;
+
+use crate::server::state::SharedState;
+
+/// Client message for task subscription management.
+#[derive(Debug, Clone, Deserialize)]
+#[serde(tag = "type", rename_all = "camelCase")]
+pub enum TaskClientMessage {
+ /// Subscribe to updates for a specific task
+ Subscribe {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+ /// Unsubscribe from updates for a specific task
+ Unsubscribe {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+ /// Subscribe to all task updates
+ SubscribeAll,
+ /// Unsubscribe from all task updates
+ UnsubscribeAll,
+ /// Subscribe to live output streaming for a specific task
+ SubscribeOutput {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+ /// Unsubscribe from output streaming for a specific task
+ UnsubscribeOutput {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+}
+
+/// Server message for task subscription WebSocket.
+#[derive(Debug, Clone, Serialize)]
+#[serde(tag = "type", rename_all = "camelCase")]
+pub enum TaskServerMessage {
+ /// Subscription confirmed for specific task
+ Subscribed {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+ /// Unsubscription confirmed for specific task
+ Unsubscribed {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+ /// Subscribed to all task updates
+ SubscribedAll,
+ /// Unsubscribed from all task updates
+ UnsubscribedAll,
+ /// Task was updated
+ TaskUpdated {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ version: i32,
+ status: String,
+ #[serde(rename = "updatedFields")]
+ updated_fields: Vec<String>,
+ #[serde(rename = "updatedBy")]
+ updated_by: String,
+ },
+ /// Live output from Claude Code container (parsed and structured)
+ TaskOutput {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ /// Message type: "assistant", "tool_use", "tool_result", "result", "system", "error", "raw"
+ #[serde(rename = "messageType")]
+ message_type: String,
+ /// Main text content
+ content: String,
+ /// Tool name if tool_use message
+ #[serde(rename = "toolName", skip_serializing_if = "Option::is_none")]
+ tool_name: Option<String>,
+ /// Tool input JSON if tool_use message
+ #[serde(rename = "toolInput", skip_serializing_if = "Option::is_none")]
+ tool_input: Option<serde_json::Value>,
+ /// Whether tool result was an error
+ #[serde(rename = "isError", skip_serializing_if = "Option::is_none")]
+ is_error: Option<bool>,
+ /// Cost in USD if result message
+ #[serde(rename = "costUsd", skip_serializing_if = "Option::is_none")]
+ cost_usd: Option<f64>,
+ /// Duration in ms if result message
+ #[serde(rename = "durationMs", skip_serializing_if = "Option::is_none")]
+ duration_ms: Option<u64>,
+ #[serde(rename = "isPartial")]
+ is_partial: bool,
+ },
+ /// Output subscription confirmed
+ OutputSubscribed {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+ /// Output unsubscription confirmed
+ OutputUnsubscribed {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+ /// Error occurred
+ Error { code: String, message: String },
+}
+
+/// WebSocket upgrade handler for task subscriptions.
+#[utoipa::path(
+ get,
+ path = "/api/v1/mesh/tasks/subscribe",
+ responses(
+ (status = 101, description = "WebSocket connection established"),
+ ),
+ tag = "Mesh"
+)]
+pub async fn task_subscription_handler(
+ ws: WebSocketUpgrade,
+ State(state): State<SharedState>,
+) -> Response {
+ ws.on_upgrade(|socket| handle_task_subscription(socket, state))
+}
+
+/// Look up the owner_id for a task from the database.
+async fn get_task_owner_id(pool: &sqlx::PgPool, task_id: Uuid) -> Option<Uuid> {
+ let row = sqlx::query("SELECT owner_id FROM tasks WHERE id = $1")
+ .bind(task_id)
+ .fetch_optional(pool)
+ .await
+ .ok()??;
+
+ row.try_get("owner_id").ok()
+}
+
+async fn handle_task_subscription(socket: WebSocket, state: SharedState) {
+ let (mut sender, mut receiver) = socket.split();
+
+ // Map of task IDs to their owner_ids for this client's subscriptions
+ let mut task_subscriptions: HashMap<Uuid, Option<Uuid>> = HashMap::new();
+ // Whether client is subscribed to all task updates (not owner-scoped)
+ let mut subscribed_all = false;
+ // Map of task IDs to their owner_ids for output streaming subscriptions
+ let mut output_subscriptions: HashMap<Uuid, Option<Uuid>> = HashMap::new();
+
+ // Subscribe to broadcast channels
+ let mut task_update_rx = state.task_updates.subscribe();
+ let mut task_output_rx = state.task_output.subscribe();
+
+ loop {
+ tokio::select! {
+ // Handle incoming WebSocket messages from client
+ msg = receiver.next() => {
+ match msg {
+ Some(Ok(Message::Text(text))) => {
+ match serde_json::from_str::<TaskClientMessage>(&text) {
+ Ok(TaskClientMessage::Subscribe { task_id }) => {
+ // Look up owner_id for this task
+ let owner_id = if let Some(ref pool) = state.db_pool {
+ get_task_owner_id(pool, task_id).await
+ } else {
+ None
+ };
+ task_subscriptions.insert(task_id, owner_id);
+ let response = TaskServerMessage::Subscribed { task_id };
+ let json = serde_json::to_string(&response).unwrap();
+ if sender.send(Message::Text(json.into())).await.is_err() {
+ break;
+ }
+ tracing::debug!("Client subscribed to task {} (owner: {:?})", task_id, owner_id);
+ }
+ Ok(TaskClientMessage::Unsubscribe { task_id }) => {
+ task_subscriptions.remove(&task_id);
+ let response = TaskServerMessage::Unsubscribed { task_id };
+ let json = serde_json::to_string(&response).unwrap();
+ if sender.send(Message::Text(json.into())).await.is_err() {
+ break;
+ }
+ tracing::debug!("Client unsubscribed from task {}", task_id);
+ }
+ Ok(TaskClientMessage::SubscribeAll) => {
+ subscribed_all = true;
+ let response = TaskServerMessage::SubscribedAll;
+ let json = serde_json::to_string(&response).unwrap();
+ if sender.send(Message::Text(json.into())).await.is_err() {
+ break;
+ }
+ tracing::debug!("Client subscribed to all tasks");
+ }
+ Ok(TaskClientMessage::UnsubscribeAll) => {
+ subscribed_all = false;
+ let response = TaskServerMessage::UnsubscribedAll;
+ let json = serde_json::to_string(&response).unwrap();
+ if sender.send(Message::Text(json.into())).await.is_err() {
+ break;
+ }
+ tracing::debug!("Client unsubscribed from all tasks");
+ }
+ Ok(TaskClientMessage::SubscribeOutput { task_id }) => {
+ // Look up owner_id for this task
+ let owner_id = if let Some(ref pool) = state.db_pool {
+ get_task_owner_id(pool, task_id).await
+ } else {
+ None
+ };
+ output_subscriptions.insert(task_id, owner_id);
+ let response = TaskServerMessage::OutputSubscribed { task_id };
+ let json = serde_json::to_string(&response).unwrap();
+ if sender.send(Message::Text(json.into())).await.is_err() {
+ break;
+ }
+ tracing::debug!("Client subscribed to output for task {} (owner: {:?})", task_id, owner_id);
+ }
+ Ok(TaskClientMessage::UnsubscribeOutput { task_id }) => {
+ output_subscriptions.remove(&task_id);
+ let response = TaskServerMessage::OutputUnsubscribed { task_id };
+ let json = serde_json::to_string(&response).unwrap();
+ if sender.send(Message::Text(json.into())).await.is_err() {
+ break;
+ }
+ tracing::debug!("Client unsubscribed from output for task {}", task_id);
+ }
+ Err(e) => {
+ let response = TaskServerMessage::Error {
+ code: "PARSE_ERROR".into(),
+ message: e.to_string(),
+ };
+ let json = serde_json::to_string(&response).unwrap();
+ let _ = sender.send(Message::Text(json.into())).await;
+ }
+ }
+ }
+ Some(Ok(Message::Close(_))) | None => {
+ tracing::debug!("Client disconnected from task subscription");
+ break;
+ }
+ Some(Err(e)) => {
+ tracing::warn!("Task WebSocket error: {}", e);
+ break;
+ }
+ _ => {}
+ }
+ }
+
+ // Handle task update broadcasts
+ notification = task_update_rx.recv() => {
+ match notification {
+ Ok(notification) => {
+ // Check if client should receive this notification
+ let should_forward = if subscribed_all {
+ // SubscribeAll gets all notifications (typically for admin views)
+ true
+ } else if let Some(subscribed_owner) = task_subscriptions.get(&notification.task_id) {
+ // Client is subscribed to this specific task
+ // Verify owner_id matches (if set on both sides)
+ match (notification.owner_id, subscribed_owner) {
+ (Some(notif_owner), Some(sub_owner)) => notif_owner == *sub_owner,
+ _ => true, // Allow if owner_id not set on either side
+ }
+ } else {
+ false
+ };
+
+ if should_forward {
+ let response = TaskServerMessage::TaskUpdated {
+ task_id: notification.task_id,
+ version: notification.version,
+ status: notification.status,
+ updated_fields: notification.updated_fields,
+ updated_by: notification.updated_by,
+ };
+ let json = serde_json::to_string(&response).unwrap();
+ if sender.send(Message::Text(json.into())).await.is_err() {
+ break;
+ }
+ }
+ }
+ Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
+ tracing::warn!("Task subscription client lagged, skipped {} messages", n);
+ }
+ Err(tokio::sync::broadcast::error::RecvError::Closed) => {
+ break;
+ }
+ }
+ }
+
+ // Handle task output broadcasts
+ output = task_output_rx.recv() => {
+ match output {
+ Ok(output) => {
+ // Check if client should receive this output
+ let should_forward = if let Some(subscribed_owner) = output_subscriptions.get(&output.task_id) {
+ // Client is subscribed to output for this task
+ // Verify owner_id matches (if set on both sides)
+ match (output.owner_id, subscribed_owner) {
+ (Some(notif_owner), Some(sub_owner)) => notif_owner == *sub_owner,
+ _ => true, // Allow if owner_id not set on either side
+ }
+ } else {
+ false
+ };
+
+ if should_forward {
+ let response = TaskServerMessage::TaskOutput {
+ task_id: output.task_id,
+ message_type: output.message_type,
+ content: output.content,
+ tool_name: output.tool_name,
+ tool_input: output.tool_input,
+ is_error: output.is_error,
+ cost_usd: output.cost_usd,
+ duration_ms: output.duration_ms,
+ is_partial: output.is_partial,
+ };
+ let json = serde_json::to_string(&response).unwrap();
+ if sender.send(Message::Text(json.into())).await.is_err() {
+ break;
+ }
+ }
+ }
+ Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => {
+ tracing::warn!("Task output subscription client lagged, skipped {} messages", n);
+ }
+ Err(tokio::sync::broadcast::error::RecvError::Closed) => {
+ break;
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs
index 3211f94..8681104 100644
--- a/makima/src/server/handlers/mod.rs
+++ b/makima/src/server/handlers/mod.rs
@@ -1,7 +1,14 @@
//! HTTP and WebSocket request handlers.
+pub mod api_keys;
pub mod chat;
pub mod file_ws;
pub mod files;
pub mod listen;
+pub mod mesh;
+pub mod mesh_chat;
+pub mod mesh_daemon;
+pub mod mesh_merge;
+pub mod mesh_ws;
+pub mod users;
pub mod versions;
diff --git a/makima/src/server/handlers/users.rs b/makima/src/server/handlers/users.rs
new file mode 100644
index 0000000..0b2ccdd
--- /dev/null
+++ b/makima/src/server/handlers/users.rs
@@ -0,0 +1,972 @@
+//! HTTP handlers for user account management.
+//!
+//! These endpoints allow users to manage their account settings:
+//! - Change password
+//! - Change email
+//! - Delete account
+
+use axum::{
+ extract::State,
+ http::{HeaderMap, StatusCode},
+ response::IntoResponse,
+ Json,
+};
+use serde::{Deserialize, Serialize};
+use utoipa::ToSchema;
+
+use crate::server::auth::UserOnly;
+use crate::server::messages::ApiError;
+use crate::server::state::SharedState;
+
+// =============================================================================
+// Request/Response Types
+// =============================================================================
+
+/// Request to change password.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChangePasswordRequest {
+ /// The user's current password for verification
+ pub current_password: String,
+ /// The new password to set
+ pub new_password: String,
+}
+
+/// Response after changing password.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChangePasswordResponse {
+ pub success: bool,
+ pub message: String,
+}
+
+/// Request to change email.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChangeEmailRequest {
+ /// The user's password for verification
+ pub password: String,
+ /// The new email address to set
+ pub new_email: String,
+}
+
+/// Response after changing email.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct ChangeEmailResponse {
+ pub success: bool,
+ pub message: String,
+ /// Whether a verification email was sent to the new address
+ pub verification_sent: bool,
+}
+
+/// Request to delete account.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct DeleteAccountRequest {
+ /// The user's password for verification
+ pub password: String,
+ /// Confirmation text - must match the user's email
+ pub confirmation: String,
+}
+
+/// Response after deleting account.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct DeleteAccountResponse {
+ pub success: bool,
+ pub message: String,
+}
+
+// =============================================================================
+// Password Validation
+// =============================================================================
+
+/// Password strength validation result.
+#[derive(Debug)]
+pub struct PasswordValidation {
+ pub is_valid: bool,
+ pub errors: Vec<String>,
+}
+
+/// Validate password strength.
+/// Requirements:
+/// - At least 6 characters (matches login form)
+fn validate_password_strength(password: &str) -> PasswordValidation {
+ let mut errors = Vec::new();
+
+ if password.len() < 6 {
+ errors.push("Password must be at least 6 characters long".to_string());
+ }
+
+ PasswordValidation {
+ is_valid: errors.is_empty(),
+ errors,
+ }
+}
+
+/// Validate email format.
+fn validate_email(email: &str) -> bool {
+ // Basic email validation - must contain @ and at least one . after @
+ let parts: Vec<&str> = email.split('@').collect();
+ if parts.len() != 2 {
+ return false;
+ }
+ let local = parts[0];
+ let domain = parts[1];
+ // Local part must not be empty
+ if local.is_empty() {
+ return false;
+ }
+ // Domain must have at least one dot and not start/end with dot
+ domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.')
+}
+
+// =============================================================================
+// Supabase Admin Client
+// =============================================================================
+
+/// Supabase Admin API client for user management operations.
+/// Uses the service role key for admin-level operations.
+pub struct SupabaseAdminClient {
+ base_url: String,
+ secret_api_key: String,
+ client: reqwest::Client,
+}
+
+impl SupabaseAdminClient {
+ /// Create a new Supabase admin client from environment variables.
+ pub fn from_env() -> Option<Self> {
+ let base_url = std::env::var("SUPABASE_URL").ok()?;
+ let secret_api_key = std::env::var("SUPABASE_SECRET_API_KEY").ok()?;
+
+ Some(Self {
+ base_url,
+ secret_api_key,
+ client: reqwest::Client::new(),
+ })
+ }
+
+ /// Verify a user's password by attempting to sign in.
+ pub async fn verify_password(&self, email: &str, password: &str) -> Result<bool, String> {
+ let url = format!("{}/auth/v1/token?grant_type=password", self.base_url);
+
+ let response = self
+ .client
+ .post(&url)
+ .header("apikey", &self.secret_api_key)
+ .header("Content-Type", "application/json")
+ .json(&serde_json::json!({
+ "email": email,
+ "password": password
+ }))
+ .send()
+ .await
+ .map_err(|e| format!("Failed to verify password: {}", e))?;
+
+ Ok(response.status().is_success())
+ }
+
+ /// Update a user's password.
+ pub async fn update_password(
+ &self,
+ user_id: &str,
+ new_password: &str,
+ ) -> Result<(), String> {
+ let url = format!("{}/auth/v1/admin/users/{}", self.base_url, user_id);
+
+ let response = self
+ .client
+ .put(&url)
+ .header("apikey", &self.secret_api_key)
+ .header("Authorization", format!("Bearer {}", self.secret_api_key))
+ .header("Content-Type", "application/json")
+ .json(&serde_json::json!({
+ "password": new_password
+ }))
+ .send()
+ .await
+ .map_err(|e| format!("Failed to update password: {}", e))?;
+
+ if response.status().is_success() {
+ Ok(())
+ } else {
+ let error_text = response.text().await.unwrap_or_default();
+ Err(format!("Failed to update password: {}", error_text))
+ }
+ }
+
+ /// Update a user's email.
+ pub async fn update_email(
+ &self,
+ user_id: &str,
+ new_email: &str,
+ ) -> Result<(), String> {
+ let url = format!("{}/auth/v1/admin/users/{}", self.base_url, user_id);
+
+ let response = self
+ .client
+ .put(&url)
+ .header("apikey", &self.secret_api_key)
+ .header("Authorization", format!("Bearer {}", self.secret_api_key))
+ .header("Content-Type", "application/json")
+ .json(&serde_json::json!({
+ "email": new_email,
+ "email_confirm": true
+ }))
+ .send()
+ .await
+ .map_err(|e| format!("Failed to update email: {}", e))?;
+
+ if response.status().is_success() {
+ Ok(())
+ } else {
+ let error_text = response.text().await.unwrap_or_default();
+ Err(format!("Failed to update email: {}", error_text))
+ }
+ }
+
+ /// Delete a user from Supabase Auth.
+ pub async fn delete_user(&self, user_id: &str) -> Result<(), String> {
+ let url = format!("{}/auth/v1/admin/users/{}", self.base_url, user_id);
+
+ let response = self
+ .client
+ .delete(&url)
+ .header("apikey", &self.secret_api_key)
+ .header("Authorization", format!("Bearer {}", self.secret_api_key))
+ .send()
+ .await
+ .map_err(|e| format!("Failed to delete user: {}", e))?;
+
+ if response.status().is_success() {
+ Ok(())
+ } else {
+ let error_text = response.text().await.unwrap_or_default();
+ Err(format!("Failed to delete user: {}", error_text))
+ }
+ }
+
+ /// Get user info including email.
+ pub async fn get_user(&self, user_id: &str) -> Result<Option<String>, String> {
+ let url = format!("{}/auth/v1/admin/users/{}", self.base_url, user_id);
+
+ let response = self
+ .client
+ .get(&url)
+ .header("apikey", &self.secret_api_key)
+ .header("Authorization", format!("Bearer {}", self.secret_api_key))
+ .send()
+ .await
+ .map_err(|e| format!("Failed to get user: {}", e))?;
+
+ if response.status().is_success() {
+ let json: serde_json::Value = response
+ .json()
+ .await
+ .map_err(|e| format!("Failed to parse user data: {}", e))?;
+ Ok(json.get("email").and_then(|e| e.as_str()).map(String::from))
+ } else if response.status() == reqwest::StatusCode::NOT_FOUND {
+ Ok(None)
+ } else {
+ let error_text = response.text().await.unwrap_or_default();
+ Err(format!("Failed to get user: {}", error_text))
+ }
+ }
+}
+
+// =============================================================================
+// Supabase User Client (uses user's JWT, no admin required)
+// =============================================================================
+
+/// Supabase User API client for self-service operations.
+/// Uses the user's JWT token - no admin/service role key required.
+pub struct SupabaseUserClient {
+ base_url: String,
+ anon_key: String,
+ jwt_token: String,
+ client: reqwest::Client,
+}
+
+impl SupabaseUserClient {
+ /// Create a new Supabase user client from environment and JWT token.
+ pub fn new(jwt_token: String) -> Option<Self> {
+ let base_url = std::env::var("SUPABASE_URL").ok()?;
+ let anon_key = std::env::var("SUPABASE_ANON_KEY").ok()?;
+
+ Some(Self {
+ base_url,
+ anon_key,
+ jwt_token,
+ client: reqwest::Client::new(),
+ })
+ }
+
+ /// Update the user's password using their own JWT.
+ pub async fn update_password(&self, new_password: &str) -> Result<(), String> {
+ let url = format!("{}/auth/v1/user", self.base_url);
+
+ let response = self
+ .client
+ .put(&url)
+ .header("apikey", &self.anon_key)
+ .header("Authorization", format!("Bearer {}", self.jwt_token))
+ .header("Content-Type", "application/json")
+ .json(&serde_json::json!({
+ "password": new_password
+ }))
+ .send()
+ .await
+ .map_err(|e| format!("Failed to update password: {}", e))?;
+
+ if response.status().is_success() {
+ Ok(())
+ } else {
+ let error_text = response.text().await.unwrap_or_default();
+ Err(format!("Failed to update password: {}", error_text))
+ }
+ }
+
+ /// Update the user's email using their own JWT.
+ pub async fn update_email(&self, new_email: &str) -> Result<(), String> {
+ let url = format!("{}/auth/v1/user", self.base_url);
+
+ let response = self
+ .client
+ .put(&url)
+ .header("apikey", &self.anon_key)
+ .header("Authorization", format!("Bearer {}", self.jwt_token))
+ .header("Content-Type", "application/json")
+ .json(&serde_json::json!({
+ "email": new_email
+ }))
+ .send()
+ .await
+ .map_err(|e| format!("Failed to update email: {}", e))?;
+
+ if response.status().is_success() {
+ Ok(())
+ } else {
+ let error_text = response.text().await.unwrap_or_default();
+ Err(format!("Failed to update email: {}", error_text))
+ }
+ }
+
+ /// Verify current password by attempting to sign in.
+ pub async fn verify_password(&self, email: &str, password: &str) -> Result<bool, String> {
+ let url = format!("{}/auth/v1/token?grant_type=password", self.base_url);
+
+ let response = self
+ .client
+ .post(&url)
+ .header("apikey", &self.anon_key)
+ .header("Content-Type", "application/json")
+ .json(&serde_json::json!({
+ "email": email,
+ "password": password
+ }))
+ .send()
+ .await
+ .map_err(|e| format!("Failed to verify password: {}", e))?;
+
+ Ok(response.status().is_success())
+ }
+}
+
+// =============================================================================
+// Handlers
+// =============================================================================
+
+/// Change the authenticated user's password.
+///
+/// Requires verification of the current password before allowing the change.
+/// The new password must meet strength requirements.
+#[utoipa::path(
+ put,
+ path = "/api/v1/users/me/password",
+ request_body = ChangePasswordRequest,
+ responses(
+ (status = 200, description = "Password changed successfully", body = ChangePasswordResponse),
+ (status = 400, description = "Invalid request (weak password, wrong current password)", body = ApiError),
+ (status = 401, description = "Not authenticated", body = ApiError),
+ (status = 403, description = "Forbidden (tool keys not allowed)", body = ApiError),
+ (status = 503, description = "Supabase not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = [])
+ ),
+ tag = "Users"
+)]
+pub async fn change_password_handler(
+ State(_state): State<SharedState>,
+ headers: HeaderMap,
+ UserOnly(user): UserOnly,
+ Json(req): Json<ChangePasswordRequest>,
+) -> impl IntoResponse {
+ // Validate new password strength
+ let validation = validate_password_strength(&req.new_password);
+ if !validation.is_valid {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new(
+ "WEAK_PASSWORD",
+ &validation.errors.join("; "),
+ )),
+ )
+ .into_response();
+ }
+
+ // Get user's email (required for password verification)
+ let email = match &user.email {
+ Some(email) => email.clone(),
+ None => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("EMAIL_REQUIRED", "User email not available")),
+ )
+ .into_response();
+ }
+ };
+
+ // Extract JWT from Authorization header for user-level API calls
+ let jwt_token = headers
+ .get("Authorization")
+ .and_then(|v| v.to_str().ok())
+ .and_then(|s| s.strip_prefix("Bearer "))
+ .map(|s| s.to_string());
+
+ // Try user client first (uses JWT, no admin required), fall back to admin client
+ if let Some(token) = jwt_token {
+ if let Some(user_client) = SupabaseUserClient::new(token) {
+ // Verify current password
+ match user_client.verify_password(&email, &req.current_password).await {
+ Ok(true) => {}
+ Ok(false) => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("INVALID_PASSWORD", "Current password is incorrect")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to verify password: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", "Failed to verify password")),
+ )
+ .into_response();
+ }
+ }
+
+ // Update password using user's JWT
+ return match user_client.update_password(&req.new_password).await {
+ Ok(()) => {
+ tracing::info!("Password changed for user {}", user.user_id);
+ Json(ChangePasswordResponse {
+ success: true,
+ message: "Password changed successfully".to_string(),
+ })
+ .into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to update password: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", "Failed to update password")),
+ )
+ .into_response()
+ }
+ };
+ }
+ }
+
+ // Fall back to admin client if user client not available
+ let admin_client = match SupabaseAdminClient::from_env() {
+ Some(client) => client,
+ None => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new(
+ "SUPABASE_NOT_CONFIGURED",
+ "Supabase not configured. Ensure SUPABASE_URL and SUPABASE_ANON_KEY are set.",
+ )),
+ )
+ .into_response();
+ }
+ };
+
+ // Verify current password
+ match admin_client.verify_password(&email, &req.current_password).await {
+ Ok(true) => {}
+ Ok(false) => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("INVALID_PASSWORD", "Current password is incorrect")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to verify password: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", "Failed to verify password")),
+ )
+ .into_response();
+ }
+ }
+
+ // Update password in Supabase
+ match admin_client
+ .update_password(&user.user_id.to_string(), &req.new_password)
+ .await
+ {
+ Ok(()) => {
+ tracing::info!("Password changed for user {}", user.user_id);
+ Json(ChangePasswordResponse {
+ success: true,
+ message: "Password changed successfully".to_string(),
+ })
+ .into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to update password: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", "Failed to update password")),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Change the authenticated user's email address.
+///
+/// Requires password verification before allowing the change.
+/// The new email will be updated directly (Supabase handles verification if configured).
+#[utoipa::path(
+ put,
+ path = "/api/v1/users/me/email",
+ request_body = ChangeEmailRequest,
+ responses(
+ (status = 200, description = "Email changed successfully", body = ChangeEmailResponse),
+ (status = 400, description = "Invalid request (invalid email, wrong password)", body = ApiError),
+ (status = 401, description = "Not authenticated", body = ApiError),
+ (status = 403, description = "Forbidden (tool keys not allowed)", body = ApiError),
+ (status = 503, description = "Supabase not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = [])
+ ),
+ tag = "Users"
+)]
+pub async fn change_email_handler(
+ State(state): State<SharedState>,
+ headers: HeaderMap,
+ UserOnly(user): UserOnly,
+ Json(req): Json<ChangeEmailRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Validate new email format
+ if !validate_email(&req.new_email) {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("INVALID_EMAIL", "Invalid email format")),
+ )
+ .into_response();
+ }
+
+ // Get user's current email (required for password verification)
+ let current_email = match &user.email {
+ Some(email) => email.clone(),
+ None => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("EMAIL_REQUIRED", "User email not available")),
+ )
+ .into_response();
+ }
+ };
+
+ // Extract JWT from Authorization header for user-level API calls
+ let jwt_token = headers
+ .get("Authorization")
+ .and_then(|v| v.to_str().ok())
+ .and_then(|s| s.strip_prefix("Bearer "))
+ .map(|s| s.to_string());
+
+ // Try user client first (uses JWT, no admin required), fall back to admin client
+ if let Some(token) = jwt_token {
+ if let Some(user_client) = SupabaseUserClient::new(token) {
+ // Verify password
+ match user_client.verify_password(&current_email, &req.password).await {
+ Ok(true) => {}
+ Ok(false) => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("INVALID_PASSWORD", "Password is incorrect")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to verify password: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", "Failed to verify password")),
+ )
+ .into_response();
+ }
+ }
+
+ // Update email using user's JWT
+ if let Err(e) = user_client.update_email(&req.new_email).await {
+ tracing::error!("Failed to update email in Supabase: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", "Failed to update email")),
+ )
+ .into_response();
+ }
+
+ // Update email in our database
+ if let Err(e) = sqlx::query("UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2")
+ .bind(&req.new_email)
+ .bind(user.user_id)
+ .execute(pool)
+ .await
+ {
+ tracing::error!("Failed to update email in database: {}", e);
+ }
+
+ tracing::info!(
+ "Email changed for user {} from {} to {}",
+ user.user_id,
+ current_email,
+ req.new_email
+ );
+
+ return Json(ChangeEmailResponse {
+ success: true,
+ message: "Email changed successfully".to_string(),
+ verification_sent: false,
+ })
+ .into_response();
+ }
+ }
+
+ // Fall back to admin client if user client not available
+ let admin_client = match SupabaseAdminClient::from_env() {
+ Some(client) => client,
+ None => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new(
+ "SUPABASE_NOT_CONFIGURED",
+ "Supabase not configured. Ensure SUPABASE_URL and SUPABASE_ANON_KEY are set.",
+ )),
+ )
+ .into_response();
+ }
+ };
+
+ // Verify password
+ match admin_client.verify_password(&current_email, &req.password).await {
+ Ok(true) => {}
+ Ok(false) => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("INVALID_PASSWORD", "Password is incorrect")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to verify password: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", "Failed to verify password")),
+ )
+ .into_response();
+ }
+ }
+
+ // Update email in Supabase
+ if let Err(e) = admin_client
+ .update_email(&user.user_id.to_string(), &req.new_email)
+ .await
+ {
+ tracing::error!("Failed to update email in Supabase: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", "Failed to update email")),
+ )
+ .into_response();
+ }
+
+ // Update email in our database
+ if let Err(e) = sqlx::query("UPDATE users SET email = $1, updated_at = NOW() WHERE id = $2")
+ .bind(&req.new_email)
+ .bind(user.user_id)
+ .execute(pool)
+ .await
+ {
+ tracing::error!("Failed to update email in database: {}", e);
+ }
+
+ tracing::info!(
+ "Email changed for user {} from {} to {}",
+ user.user_id,
+ current_email,
+ req.new_email
+ );
+
+ Json(ChangeEmailResponse {
+ success: true,
+ message: "Email changed successfully".to_string(),
+ verification_sent: false,
+ })
+ .into_response()
+}
+
+/// Delete the authenticated user's account.
+///
+/// This permanently deletes:
+/// - The user's Supabase Auth account
+/// - The user's record in our database
+/// - All associated data (API keys, files, tasks, etc. via CASCADE)
+///
+/// Requires password verification and confirmation text matching the user's email.
+#[utoipa::path(
+ delete,
+ path = "/api/v1/users/me",
+ request_body = DeleteAccountRequest,
+ responses(
+ (status = 200, description = "Account deleted successfully", body = DeleteAccountResponse),
+ (status = 400, description = "Invalid request (wrong password, wrong confirmation)", body = ApiError),
+ (status = 401, description = "Not authenticated", body = ApiError),
+ (status = 403, description = "Forbidden (tool keys not allowed)", body = ApiError),
+ (status = 503, description = "Supabase not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ security(
+ ("bearer_auth" = [])
+ ),
+ tag = "Users"
+)]
+pub async fn delete_account_handler(
+ State(state): State<SharedState>,
+ UserOnly(user): UserOnly,
+ Json(req): Json<DeleteAccountRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ // Get Supabase admin client - required for full account deletion
+ let admin_client = match SupabaseAdminClient::from_env() {
+ Some(client) => client,
+ None => {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new(
+ "SUPABASE_ADMIN_NOT_CONFIGURED",
+ "Account deletion requires SUPABASE_SECRET_API_KEY to be configured",
+ )),
+ )
+ .into_response();
+ }
+ };
+
+ // Verify confirmation is "DELETE MY ACCOUNT"
+ const REQUIRED_CONFIRMATION: &str = "DELETE MY ACCOUNT";
+ if req.confirmation != REQUIRED_CONFIRMATION {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new(
+ "INVALID_CONFIRMATION",
+ format!("Confirmation text must be exactly: {}", REQUIRED_CONFIRMATION),
+ )),
+ )
+ .into_response();
+ }
+
+ // Get user's email (required for password verification)
+ let email = match &user.email {
+ Some(e) => e.clone(),
+ None => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("EMAIL_REQUIRED", "User email not available")),
+ )
+ .into_response();
+ }
+ };
+
+ // Verify password
+ match admin_client.verify_password(&email, &req.password).await {
+ Ok(true) => {}
+ Ok(false) => {
+ return (
+ StatusCode::BAD_REQUEST,
+ Json(ApiError::new("INVALID_PASSWORD", "Password is incorrect")),
+ )
+ .into_response();
+ }
+ Err(e) => {
+ tracing::error!("Failed to verify password: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", "Failed to verify password")),
+ )
+ .into_response();
+ }
+ }
+
+ // Delete from our database first (this will cascade to related records)
+ // Get the owner_id before deleting
+ let owner_id = user.owner_id;
+
+ // Delete API keys for this user (explicit deletion for audit purposes)
+ if let Err(e) = sqlx::query("UPDATE api_keys SET revoked_at = NOW() WHERE user_id = $1 AND revoked_at IS NULL")
+ .bind(user.user_id)
+ .execute(pool)
+ .await
+ {
+ tracing::warn!("Failed to revoke API keys during account deletion: {}", e);
+ }
+
+ // Delete user record
+ if let Err(e) = sqlx::query("DELETE FROM users WHERE id = $1")
+ .bind(user.user_id)
+ .execute(pool)
+ .await
+ {
+ tracing::error!("Failed to delete user from database: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("INTERNAL_ERROR", "Failed to delete account")),
+ )
+ .into_response();
+ }
+
+ // Delete files owned by this user
+ if let Err(e) = sqlx::query("DELETE FROM files WHERE owner_id = $1")
+ .bind(owner_id)
+ .execute(pool)
+ .await
+ {
+ tracing::warn!("Failed to delete user files: {}", e);
+ }
+
+ // Delete tasks owned by this user
+ if let Err(e) = sqlx::query("DELETE FROM tasks WHERE owner_id = $1")
+ .bind(owner_id)
+ .execute(pool)
+ .await
+ {
+ tracing::warn!("Failed to delete user tasks: {}", e);
+ }
+
+ // Delete mesh chat conversations owned by this user
+ if let Err(e) = sqlx::query("DELETE FROM mesh_chat_conversations WHERE owner_id = $1")
+ .bind(owner_id)
+ .execute(pool)
+ .await
+ {
+ tracing::warn!("Failed to delete mesh chat conversations: {}", e);
+ }
+
+ // Delete daemons owned by this user
+ if let Err(e) = sqlx::query("DELETE FROM daemons WHERE owner_id = $1")
+ .bind(owner_id)
+ .execute(pool)
+ .await
+ {
+ tracing::warn!("Failed to delete user daemons: {}", e);
+ }
+
+ // Delete owner record
+ if let Err(e) = sqlx::query("DELETE FROM owners WHERE id = $1")
+ .bind(owner_id)
+ .execute(pool)
+ .await
+ {
+ tracing::warn!("Failed to delete owner record: {}", e);
+ }
+
+ // Delete from Supabase Auth
+ if let Err(e) = admin_client.delete_user(&user.user_id.to_string()).await {
+ tracing::error!("Failed to delete user from Supabase Auth: {}", e);
+ return (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new(
+ "SUPABASE_DELETE_FAILED",
+ "Failed to delete user from authentication system",
+ )),
+ )
+ .into_response();
+ }
+
+ tracing::info!("Account deleted for user {} ({})", user.user_id, email);
+
+ Json(DeleteAccountResponse {
+ success: true,
+ message: "Account deleted successfully".to_string(),
+ })
+ .into_response()
+}
+
+// =============================================================================
+// Tests
+// =============================================================================
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_password_validation_success() {
+ // Minimum 6 characters
+ let result = validate_password_strength("abcdef");
+ assert!(result.is_valid);
+ assert!(result.errors.is_empty());
+
+ let result = validate_password_strength("Password123");
+ assert!(result.is_valid);
+ assert!(result.errors.is_empty());
+ }
+
+ #[test]
+ fn test_password_validation_too_short() {
+ let result = validate_password_strength("12345");
+ assert!(!result.is_valid);
+ assert!(result.errors.iter().any(|e| e.contains("6 characters")));
+ }
+
+ #[test]
+ fn test_email_validation_valid() {
+ assert!(validate_email("user@example.com"));
+ assert!(validate_email("user.name@example.co.uk"));
+ assert!(validate_email("user+tag@example.org"));
+ }
+
+ #[test]
+ fn test_email_validation_invalid() {
+ assert!(!validate_email("userexample.com"));
+ assert!(!validate_email("user@"));
+ assert!(!validate_email("@example.com"));
+ assert!(!validate_email("user@.com"));
+ assert!(!validate_email("user@example."));
+ }
+}
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index ee5e9bd..a096a5c 100644
--- a/makima/src/server/mod.rs
+++ b/makima/src/server/mod.rs
@@ -1,5 +1,6 @@
//! Web server module for the makima audio API.
+pub mod auth;
pub mod handlers;
pub mod messages;
pub mod openapi;
@@ -17,7 +18,7 @@ use tower_http::trace::TraceLayer;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
-use crate::server::handlers::{chat, file_ws, files, listen, versions};
+use crate::server::handlers::{api_keys, chat, file_ws, files, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_ws, users, versions};
use crate::server::openapi::ApiDoc;
use crate::server::state::SharedState;
@@ -56,6 +57,62 @@ pub fn make_router(state: SharedState) -> Router {
.route("/files/{id}/versions", get(versions::list_versions))
.route("/files/{id}/versions/{version}", get(versions::get_version))
.route("/files/{id}/versions/restore", post(versions::restore_version))
+ // Mesh/task orchestration endpoints
+ .route(
+ "/mesh/tasks",
+ get(mesh::list_tasks).post(mesh::create_task),
+ )
+ .route(
+ "/mesh/tasks/{id}",
+ get(mesh::get_task)
+ .put(mesh::update_task)
+ .delete(mesh::delete_task),
+ )
+ .route("/mesh/tasks/{id}/subtasks", get(mesh::list_subtasks))
+ .route("/mesh/tasks/{id}/events", get(mesh::list_task_events))
+ .route("/mesh/tasks/{id}/output", get(mesh::get_task_output))
+ .route("/mesh/tasks/{id}/start", post(mesh::start_task))
+ .route("/mesh/tasks/{id}/stop", post(mesh::stop_task))
+ .route("/mesh/tasks/{id}/message", post(mesh::send_message))
+ .route("/mesh/tasks/{id}/retry-completion", post(mesh::retry_completion_action))
+ .route("/mesh/tasks/{id}/clone", post(mesh::clone_worktree))
+ .route("/mesh/tasks/{id}/check-target", post(mesh::check_target_exists))
+ .route("/mesh/chat", post(mesh_chat::mesh_toplevel_chat_handler))
+ .route(
+ "/mesh/chat/history",
+ get(mesh_chat::get_chat_history).delete(mesh_chat::clear_chat_history),
+ )
+ .route("/mesh/tasks/{id}/chat", post(mesh_chat::mesh_chat_handler))
+ .route("/mesh/daemons", get(mesh::list_daemons))
+ .route("/mesh/daemons/directories", get(mesh::get_daemon_directories))
+ .route("/mesh/daemons/{id}", get(mesh::get_daemon))
+ // Merge endpoints for orchestrators
+ .route("/mesh/tasks/{id}/branches", get(mesh_merge::list_branches))
+ .route("/mesh/tasks/{id}/merge/start", post(mesh_merge::merge_start))
+ .route("/mesh/tasks/{id}/merge/status", get(mesh_merge::merge_status))
+ .route("/mesh/tasks/{id}/merge/resolve", post(mesh_merge::merge_resolve))
+ .route("/mesh/tasks/{id}/merge/commit", post(mesh_merge::merge_commit))
+ .route("/mesh/tasks/{id}/merge/abort", post(mesh_merge::merge_abort))
+ .route("/mesh/tasks/{id}/merge/skip", post(mesh_merge::merge_skip))
+ .route("/mesh/tasks/{id}/merge/check", get(mesh_merge::merge_check))
+ // Mesh WebSocket endpoints
+ .route("/mesh/tasks/subscribe", get(mesh_ws::task_subscription_handler))
+ .route("/mesh/daemons/connect", get(mesh_daemon::daemon_handler))
+ // API key management endpoints
+ .route(
+ "/auth/api-keys",
+ post(api_keys::create_api_key_handler)
+ .get(api_keys::get_api_key_handler)
+ .delete(api_keys::revoke_api_key_handler),
+ )
+ .route("/auth/api-keys/refresh", post(api_keys::refresh_api_key_handler))
+ // User account management endpoints
+ .route(
+ "/users/me",
+ axum::routing::delete(users::delete_account_handler),
+ )
+ .route("/users/me/password", axum::routing::put(users::change_password_handler))
+ .route("/users/me/email", axum::routing::put(users::change_email_handler))
.with_state(state);
let swagger = SwaggerUi::new("/swagger-ui")
diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs
index b946ff3..425c466 100644
--- a/makima/src/server/openapi.rs
+++ b/makima/src/server/openapi.rs
@@ -3,9 +3,19 @@
use utoipa::OpenApi;
use crate::db::models::{
- CreateFileRequest, File, FileListResponse, FileSummary, TranscriptEntry, UpdateFileRequest,
+ BranchInfo, BranchListResponse, CreateFileRequest, CreateTaskRequest, Daemon,
+ DaemonDirectoriesResponse, DaemonDirectory, DaemonListResponse, File, FileListResponse,
+ FileSummary, MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest,
+ MergeResultResponse, MergeSkipRequest, MergeStartRequest, MergeStatusResponse,
+ MeshChatConversation, MeshChatHistoryResponse, MeshChatMessageRecord, SendMessageRequest,
+ Task, TaskEventListResponse, TaskListResponse, TaskSummary, TaskWithSubtasks, TranscriptEntry,
+ UpdateFileRequest, UpdateTaskRequest,
};
-use crate::server::handlers::{files, listen};
+use crate::server::auth::{
+ ApiKey, ApiKeyInfoResponse, CreateApiKeyRequest, CreateApiKeyResponse,
+ RefreshApiKeyRequest, RefreshApiKeyResponse, RevokeApiKeyResponse,
+};
+use crate::server::handlers::{api_keys, files, listen, mesh, mesh_chat, mesh_merge, users};
use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage};
#[derive(OpenApi)]
@@ -23,6 +33,44 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
files::create_file,
files::update_file,
files::delete_file,
+ // Mesh endpoints
+ mesh::list_tasks,
+ mesh::get_task,
+ mesh::create_task,
+ mesh::update_task,
+ mesh::delete_task,
+ mesh::list_subtasks,
+ mesh::list_task_events,
+ mesh::get_task_output,
+ mesh::start_task,
+ mesh::stop_task,
+ mesh::send_message,
+ mesh::retry_completion_action,
+ mesh::list_daemons,
+ mesh::get_daemon,
+ mesh::get_daemon_directories,
+ mesh::clone_worktree,
+ mesh::check_target_exists,
+ mesh_chat::get_chat_history,
+ mesh_chat::clear_chat_history,
+ // Merge endpoints
+ mesh_merge::list_branches,
+ mesh_merge::merge_start,
+ mesh_merge::merge_status,
+ mesh_merge::merge_resolve,
+ mesh_merge::merge_commit,
+ mesh_merge::merge_abort,
+ mesh_merge::merge_skip,
+ mesh_merge::merge_check,
+ // API key endpoints
+ api_keys::create_api_key_handler,
+ api_keys::get_api_key_handler,
+ api_keys::refresh_api_key_handler,
+ api_keys::revoke_api_key_handler,
+ // User account management endpoints
+ users::change_password_handler,
+ users::change_email_handler,
+ users::delete_account_handler,
),
components(
schemas(
@@ -38,11 +86,55 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
CreateFileRequest,
UpdateFileRequest,
TranscriptEntry,
+ // Mesh/Task schemas
+ Task,
+ TaskSummary,
+ TaskListResponse,
+ TaskWithSubtasks,
+ CreateTaskRequest,
+ UpdateTaskRequest,
+ SendMessageRequest,
+ TaskEventListResponse,
+ Daemon,
+ DaemonListResponse,
+ DaemonDirectoriesResponse,
+ DaemonDirectory,
+ MeshChatConversation,
+ MeshChatMessageRecord,
+ MeshChatHistoryResponse,
+ // Merge schemas
+ BranchInfo,
+ BranchListResponse,
+ MergeStartRequest,
+ MergeStatusResponse,
+ MergeResolveRequest,
+ MergeCommitRequest,
+ MergeSkipRequest,
+ MergeResultResponse,
+ MergeCompleteCheckResponse,
+ // API key schemas
+ ApiKey,
+ ApiKeyInfoResponse,
+ CreateApiKeyRequest,
+ CreateApiKeyResponse,
+ RefreshApiKeyRequest,
+ RefreshApiKeyResponse,
+ RevokeApiKeyResponse,
+ // User account management schemas
+ users::ChangePasswordRequest,
+ users::ChangePasswordResponse,
+ users::ChangeEmailRequest,
+ users::ChangeEmailResponse,
+ users::DeleteAccountRequest,
+ users::DeleteAccountResponse,
)
),
tags(
(name = "Listen", description = "Speech-to-text streaming endpoints"),
(name = "Files", description = "Transcript file management"),
+ (name = "Mesh", description = "Task orchestration for Claude Code instances"),
+ (name = "API Keys", description = "API key management for programmatic access"),
+ (name = "Users", description = "User account management"),
)
)]
pub struct ApiDoc;
diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs
index 239ab77..e89197a 100644
--- a/makima/src/server/state.rs
+++ b/makima/src/server/state.rs
@@ -1,11 +1,13 @@
//! Application state holding shared ML models and database pool.
use std::sync::Arc;
+use dashmap::DashMap;
use sqlx::PgPool;
-use tokio::sync::{broadcast, Mutex};
+use tokio::sync::{broadcast, mpsc, Mutex};
use uuid::Uuid;
use crate::listen::{DiarizationConfig, ParakeetEOU, ParakeetTDT, Sortformer};
+use crate::server::auth::{AuthConfig, JwtVerifier};
/// Notification payload for file updates (broadcast to WebSocket subscribers).
#[derive(Debug, Clone)]
@@ -20,6 +22,262 @@ pub struct FileUpdateNotification {
pub updated_by: String,
}
+// =============================================================================
+// Task/Mesh Notifications
+// =============================================================================
+
+/// Notification payload for task updates (broadcast to WebSocket subscribers).
+#[derive(Debug, Clone)]
+pub struct TaskUpdateNotification {
+ /// ID of the updated task
+ pub task_id: Uuid,
+ /// Owner ID for data isolation (notifications are scoped to owner)
+ pub owner_id: Option<Uuid>,
+ /// New version number after update
+ pub version: i32,
+ /// Current task status
+ pub status: String,
+ /// List of fields that were updated
+ pub updated_fields: Vec<String>,
+ /// Source of the update: "user", "daemon", or "system"
+ pub updated_by: String,
+}
+
+/// Notification for streaming task output from Claude Code containers.
+#[derive(Debug, Clone, serde::Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct TaskOutputNotification {
+ /// ID of the task producing output
+ pub task_id: Uuid,
+ /// Owner ID for data isolation (notifications are scoped to owner)
+ #[serde(skip)]
+ pub owner_id: Option<Uuid>,
+ /// Type of message: "assistant", "tool_use", "tool_result", "result", "system", "error", "raw"
+ pub message_type: String,
+ /// Main text content of the message
+ pub content: String,
+ /// Tool name if this is a tool_use message
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub tool_name: Option<String>,
+ /// Tool input (JSON) if this is a tool_use message
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub tool_input: Option<serde_json::Value>,
+ /// Whether tool result was an error
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub is_error: Option<bool>,
+ /// Cost in USD if this is a result message
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub cost_usd: Option<f64>,
+ /// Duration in milliseconds if this is a result message
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub duration_ms: Option<u64>,
+ /// Whether this is a partial line (more coming) or complete
+ pub is_partial: bool,
+}
+
+/// Command sent from server to daemon.
+#[derive(Debug, Clone, serde::Serialize)]
+#[serde(tag = "type", rename_all = "camelCase")]
+pub enum DaemonCommand {
+ /// Confirm successful authentication
+ Authenticated {
+ #[serde(rename = "daemonId")]
+ daemon_id: Uuid,
+ },
+ /// Spawn a new task in a container
+ SpawnTask {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ /// Human-readable task name (used for commit messages)
+ #[serde(rename = "taskName")]
+ task_name: String,
+ plan: String,
+ #[serde(rename = "repoUrl")]
+ repo_url: Option<String>,
+ #[serde(rename = "baseBranch")]
+ base_branch: Option<String>,
+ /// Target branch to merge into (used for completion actions)
+ #[serde(rename = "targetBranch")]
+ target_branch: Option<String>,
+ /// Parent task ID if this is a subtask
+ #[serde(rename = "parentTaskId")]
+ parent_task_id: Option<Uuid>,
+ /// Depth in task hierarchy (0=top-level, 1=subtask, 2=sub-subtask)
+ depth: i32,
+ /// Whether this task should run as an orchestrator (true if depth==0 and has subtasks)
+ #[serde(rename = "isOrchestrator")]
+ is_orchestrator: bool,
+ /// Path to user's local repository (outside ~/.makima) for completion actions
+ #[serde(rename = "targetRepoPath")]
+ target_repo_path: Option<String>,
+ /// Action on completion: "none", "branch", "merge", "pr"
+ #[serde(rename = "completionAction")]
+ completion_action: Option<String>,
+ /// Task ID to continue from (copy worktree from this task)
+ #[serde(rename = "continueFromTaskId")]
+ continue_from_task_id: Option<Uuid>,
+ /// Files to copy from parent task's worktree
+ #[serde(rename = "copyFiles")]
+ copy_files: Option<Vec<String>>,
+ },
+ /// Pause a running task
+ PauseTask {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+ /// Resume a paused task
+ ResumeTask {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+ /// Interrupt a task (gracefully or forced)
+ InterruptTask {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ graceful: bool,
+ },
+ /// Send a message to a running task
+ SendMessage {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ message: String,
+ },
+ /// Inject context about sibling task progress
+ InjectSiblingContext {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ #[serde(rename = "siblingTaskId")]
+ sibling_task_id: Uuid,
+ #[serde(rename = "siblingName")]
+ sibling_name: String,
+ #[serde(rename = "siblingStatus")]
+ sibling_status: String,
+ #[serde(rename = "progressSummary")]
+ progress_summary: Option<String>,
+ #[serde(rename = "changedFiles")]
+ changed_files: Vec<String>,
+ },
+
+ // =========================================================================
+ // Merge Commands (for orchestrators to merge subtask branches)
+ // =========================================================================
+
+ /// List all subtask branches for a task
+ ListBranches {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+ /// Start merging a subtask branch
+ MergeStart {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ #[serde(rename = "sourceBranch")]
+ source_branch: String,
+ },
+ /// Get current merge status
+ MergeStatus {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+ /// Resolve a merge conflict
+ MergeResolve {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ file: String,
+ /// "ours" or "theirs"
+ strategy: String,
+ },
+ /// Commit the current merge
+ MergeCommit {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ message: String,
+ },
+ /// Abort the current merge
+ MergeAbort {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+ /// Skip merging a subtask branch (mark as intentionally not merged)
+ MergeSkip {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ #[serde(rename = "subtaskId")]
+ subtask_id: Uuid,
+ reason: String,
+ },
+ /// Check if all subtask branches have been merged or skipped (completion gate)
+ CheckMergeComplete {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ },
+
+ // =========================================================================
+ // Completion Action Commands
+ // =========================================================================
+
+ /// Retry a completion action for a completed task
+ RetryCompletionAction {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ /// Human-readable task name (used for commit messages)
+ #[serde(rename = "taskName")]
+ task_name: String,
+ /// The action to execute: "branch", "merge", or "pr"
+ action: String,
+ /// Path to the target repository
+ #[serde(rename = "targetRepoPath")]
+ target_repo_path: String,
+ /// Target branch to merge into (for merge/pr actions)
+ #[serde(rename = "targetBranch")]
+ target_branch: Option<String>,
+ },
+
+ /// Clone worktree to a target directory
+ CloneWorktree {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ /// Path to the target directory
+ #[serde(rename = "targetDir")]
+ target_dir: String,
+ },
+
+ /// Check if a target directory exists
+ CheckTargetExists {
+ #[serde(rename = "taskId")]
+ task_id: Uuid,
+ /// Path to check
+ #[serde(rename = "targetDir")]
+ target_dir: String,
+ },
+
+ /// Error response
+ Error { code: String, message: String },
+}
+
+/// Active daemon connection info stored in state.
+#[derive(Debug)]
+pub struct DaemonConnectionInfo {
+ /// Database ID of the daemon
+ pub id: Uuid,
+ /// Owner ID for data isolation (from API key authentication)
+ pub owner_id: Uuid,
+ /// WebSocket connection identifier
+ pub connection_id: String,
+ /// Daemon hostname
+ pub hostname: Option<String>,
+ /// Machine identifier
+ pub machine_id: Option<String>,
+ /// Channel to send commands to this daemon
+ pub command_sender: mpsc::Sender<DaemonCommand>,
+ /// Current working directory of the daemon
+ pub working_directory: Option<String>,
+ /// Path to ~/.makima/home directory on daemon (for cloning completed work)
+ pub home_directory: Option<String>,
+ /// Path to worktrees directory (~/.makima/worktrees) on daemon
+ pub worktrees_directory: Option<String>,
+}
+
/// Shared application state containing ML models and database pool.
///
/// Models are wrapped in `Mutex` for thread-safe mutable access during inference.
@@ -34,6 +292,16 @@ pub struct AppState {
pub db_pool: Option<PgPool>,
/// Broadcast channel for file update notifications
pub file_updates: broadcast::Sender<FileUpdateNotification>,
+ /// Broadcast channel for task update notifications
+ pub task_updates: broadcast::Sender<TaskUpdateNotification>,
+ /// Broadcast channel for task output streaming
+ pub task_output: broadcast::Sender<TaskOutputNotification>,
+ /// Active daemon connections (keyed by connection_id)
+ pub daemon_connections: DashMap<String, DaemonConnectionInfo>,
+ /// Tool keys for orchestrator API access (key -> task_id)
+ pub tool_keys: DashMap<String, Uuid>,
+ /// JWT verifier for Supabase authentication (None if not configured)
+ pub jwt_verifier: Option<JwtVerifier>,
}
impl AppState {
@@ -56,8 +324,38 @@ impl AppState {
DiarizationConfig::callhome(),
)?;
- // Create broadcast channel with buffer for 256 messages
+ // Create broadcast channels with buffer for 256 messages
let (file_updates, _) = broadcast::channel(256);
+ let (task_updates, _) = broadcast::channel(256);
+ let (task_output, _) = broadcast::channel(1024); // Larger buffer for output streaming
+
+ // Initialize JWT verifier from environment (optional)
+ // Requires SUPABASE_URL and either SUPABASE_JWT_PUBLIC_KEY (RS256) or SUPABASE_JWT_SECRET (HS256)
+ let jwt_verifier = match AuthConfig::from_env() {
+ Some(config) => match JwtVerifier::new(config) {
+ Ok(verifier) => {
+ tracing::info!("JWT authentication configured");
+ Some(verifier)
+ }
+ Err(e) => {
+ tracing::error!("Failed to initialize JWT verifier: {}", e);
+ None
+ }
+ },
+ None => {
+ // Log which env vars are missing
+ let has_url = std::env::var("SUPABASE_URL").is_ok();
+ let has_public_key = std::env::var("SUPABASE_JWT_PUBLIC_KEY").is_ok();
+ let has_secret = std::env::var("SUPABASE_JWT_SECRET").is_ok();
+
+ if !has_url {
+ tracing::info!("JWT authentication not configured (SUPABASE_URL not set)");
+ } else if !has_public_key && !has_secret {
+ tracing::info!("JWT authentication not configured (set SUPABASE_JWT_PUBLIC_KEY for RS256 or SUPABASE_JWT_SECRET for HS256)");
+ }
+ None
+ }
+ };
Ok(Self {
parakeet: Mutex::new(parakeet),
@@ -65,6 +363,11 @@ impl AppState {
sortformer: Mutex::new(sortformer),
db_pool: None,
file_updates,
+ task_updates,
+ task_output,
+ daemon_connections: DashMap::new(),
+ tool_keys: DashMap::new(),
+ jwt_verifier,
})
}
@@ -81,6 +384,166 @@ impl AppState {
// Ignore send errors - they just mean no one is listening
let _ = self.file_updates.send(notification);
}
+
+ /// Broadcast a task update notification to all subscribers.
+ ///
+ /// This is a no-op if there are no subscribers (ignores send errors).
+ pub fn broadcast_task_update(&self, notification: TaskUpdateNotification) {
+ let _ = self.task_updates.send(notification);
+ }
+
+ /// Broadcast task output to all subscribers.
+ ///
+ /// Used for streaming Claude Code container output to frontend clients.
+ pub fn broadcast_task_output(&self, notification: TaskOutputNotification) {
+ let _ = self.task_output.send(notification);
+ }
+
+ /// Register a new daemon connection.
+ ///
+ /// Returns the connection_id for later reference.
+ pub fn register_daemon(
+ &self,
+ connection_id: String,
+ daemon_id: Uuid,
+ owner_id: Uuid,
+ hostname: Option<String>,
+ machine_id: Option<String>,
+ command_sender: mpsc::Sender<DaemonCommand>,
+ ) {
+ self.daemon_connections.insert(
+ connection_id.clone(),
+ DaemonConnectionInfo {
+ id: daemon_id,
+ owner_id,
+ connection_id,
+ hostname,
+ machine_id,
+ command_sender,
+ working_directory: None,
+ home_directory: None,
+ worktrees_directory: None,
+ },
+ );
+ }
+
+ /// Update daemon directory information.
+ pub fn update_daemon_directories(
+ &self,
+ connection_id: &str,
+ working_directory: String,
+ home_directory: String,
+ worktrees_directory: String,
+ ) {
+ if let Some(mut entry) = self.daemon_connections.get_mut(connection_id) {
+ entry.working_directory = Some(working_directory);
+ entry.home_directory = Some(home_directory);
+ entry.worktrees_directory = Some(worktrees_directory);
+ }
+ }
+
+ /// Unregister a daemon connection.
+ pub fn unregister_daemon(&self, connection_id: &str) {
+ self.daemon_connections.remove(connection_id);
+ }
+
+ /// Get a daemon connection by connection_id.
+ pub fn get_daemon(&self, connection_id: &str) -> Option<dashmap::mapref::one::Ref<'_, String, DaemonConnectionInfo>> {
+ self.daemon_connections.get(connection_id)
+ }
+
+ /// Get a daemon by its database ID.
+ pub fn get_daemon_by_id(&self, daemon_id: Uuid) -> Option<dashmap::mapref::one::Ref<'_, String, DaemonConnectionInfo>> {
+ self.daemon_connections
+ .iter()
+ .find(|entry| entry.value().id == daemon_id)
+ .map(|entry| {
+ // Return a reference to the found entry
+ self.daemon_connections.get(entry.key()).unwrap()
+ })
+ }
+
+ /// Send a command to a specific daemon by its database ID.
+ pub async fn send_daemon_command(&self, daemon_id: Uuid, command: DaemonCommand) -> Result<(), String> {
+ if let Some(daemon) = self.daemon_connections
+ .iter()
+ .find(|entry| entry.value().id == daemon_id)
+ {
+ daemon.value().command_sender.send(command).await
+ .map_err(|e| format!("Failed to send command to daemon: {}", e))
+ } else {
+ Err(format!("Daemon {} not connected", daemon_id))
+ }
+ }
+
+ /// Broadcast sibling progress to all running sibling tasks.
+ ///
+ /// This is used for sibling awareness - when a task makes progress,
+ /// its siblings are notified so they can adjust their work if needed.
+ pub async fn broadcast_sibling_progress(
+ &self,
+ source_task_id: Uuid,
+ source_task_name: &str,
+ source_task_status: &str,
+ progress_summary: Option<String>,
+ changed_files: Vec<String>,
+ running_sibling_daemon_ids: Vec<(Uuid, Uuid)>, // (task_id, daemon_id)
+ ) {
+ for (sibling_task_id, daemon_id) in running_sibling_daemon_ids {
+ let command = DaemonCommand::InjectSiblingContext {
+ task_id: sibling_task_id,
+ sibling_task_id: source_task_id,
+ sibling_name: source_task_name.to_string(),
+ sibling_status: source_task_status.to_string(),
+ progress_summary: progress_summary.clone(),
+ changed_files: changed_files.clone(),
+ };
+
+ // Fire and forget - don't block on sending to all daemons
+ if let Err(e) = self.send_daemon_command(daemon_id, command).await {
+ tracing::warn!(
+ "Failed to inject sibling context to task {}: {}",
+ sibling_task_id,
+ e
+ );
+ }
+ }
+ }
+
+ /// Get list of connected daemon IDs.
+ pub fn list_connected_daemon_ids(&self) -> Vec<Uuid> {
+ self.daemon_connections
+ .iter()
+ .map(|entry| entry.value().id)
+ .collect()
+ }
+
+ // =========================================================================
+ // Tool Key Management
+ // =========================================================================
+
+ /// Register a tool key for a task.
+ ///
+ /// This allows orchestrators to authenticate with the API using
+ /// the `X-Makima-Tool-Key` header.
+ pub fn register_tool_key(&self, key: String, task_id: Uuid) {
+ tracing::info!(task_id = %task_id, "Registering tool key");
+ self.tool_keys.insert(key, task_id);
+ }
+
+ /// Validate a tool key and return the associated task ID.
+ pub fn validate_tool_key(&self, key: &str) -> Option<Uuid> {
+ self.tool_keys.get(key).map(|entry| *entry.value())
+ }
+
+ /// Revoke a tool key for a task.
+ ///
+ /// This should be called when a task completes or is terminated.
+ pub fn revoke_tool_key(&self, task_id: Uuid) {
+ // Find and remove the key for this task
+ self.tool_keys.retain(|_, v| *v != task_id);
+ tracing::info!(task_id = %task_id, "Revoked tool key");
+ }
}
/// Type alias for the shared application state.