diff --git a/README.md b/README.md index 88de727..d1eacd7 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ - ✅ **Multi-team collaboration** - Agents hand off work to teammates via chain execution and fan-out - ✅ **Multi-channel** - Discord, WhatsApp, and Telegram - ✅ **Team Observation** - You can observe agent teams conversations via `tinyclaw team visualize` -- ✅ **Multiple AI providers** - Anthropic Claude and OpenAI Codex using existing subscriptions without breaking ToS +- ✅ **Multiple AI providers** - Use multiple provider coding agents using existing subscriptions without breaking ToS - ✅ **Parallel processing** - Agents process messages concurrently - ✅ **Live TUI dashboard** - Real-time team visualizer for monitoring agent chains - ✅ **Persistent sessions** - Conversation context maintained across restarts @@ -80,10 +80,12 @@ The setup wizard will guide you through: 2. **Bot tokens** - Enter tokens for enabled channels 3. **Workspace setup** - Name your workspace directory 4. **Default agent** - Configure your main AI assistant -5. **AI provider** - Select Anthropic (Claude) or OpenAI +5. **AI provider** - Select Anthropic (Claude), OpenAI, or Qoder 6. **Model selection** - Choose model (e.g., Sonnet, Opus, GPT-5.3) 7. **Heartbeat interval** - Set proactive check-in frequency +Provider definitions are loaded from `config/providers.json` at startup. Add a new provider by editing that file (executable, args, output parsing, model mappings) and restart TinyClaw. +
📱 Channel Setup Guides diff --git a/channels/discord.json b/channels/discord.json new file mode 100644 index 0000000..654691a --- /dev/null +++ b/channels/discord.json @@ -0,0 +1,12 @@ +{ + "id": "discord", + "display_name": "Discord", + "alias": "dc", + "script": "dist/channels/discord-client.js", + "token": { + "settings_key": "bot_token", + "env_var": "DISCORD_BOT_TOKEN", + "prompt": "Enter your Discord bot token:", + "help": "(Get one at: https://discord.com/developers/applications)" + } +} diff --git a/channels/telegram.json b/channels/telegram.json new file mode 100644 index 0000000..6dc0b04 --- /dev/null +++ b/channels/telegram.json @@ -0,0 +1,12 @@ +{ + "id": "telegram", + "display_name": "Telegram", + "alias": "tg", + "script": "dist/channels/telegram-client.js", + "token": { + "settings_key": "bot_token", + "env_var": "TELEGRAM_BOT_TOKEN", + "prompt": "Enter your Telegram bot token:", + "help": "(Create a bot via @BotFather on Telegram to get a token)" + } +} diff --git a/channels/tui.json b/channels/tui.json new file mode 100644 index 0000000..2f77842 --- /dev/null +++ b/channels/tui.json @@ -0,0 +1,6 @@ +{ + "id": "tui", + "display_name": "TUI (stdin/stdout)", + "alias": "tui", + "script": "dist/channels/tui-client.js" +} diff --git a/channels/whatsapp.json b/channels/whatsapp.json new file mode 100644 index 0000000..5ea1e28 --- /dev/null +++ b/channels/whatsapp.json @@ -0,0 +1,6 @@ +{ + "id": "whatsapp", + "display_name": "WhatsApp", + "alias": "wa", + "script": "dist/channels/whatsapp-client.js" +} diff --git a/config/providers.json b/config/providers.json new file mode 100644 index 0000000..8a05fa9 --- /dev/null +++ b/config/providers.json @@ -0,0 +1,53 @@ +{ + "version": 1, + "providers": { + "anthropic": { + "display_name": "Claude", + "executable": "claude", + "args": ["--dangerously-skip-permissions", "{{?model}}--model", "{{model}}", "{{?resume}}-c", "-p", "{{message}}"], + "output": { "type": "plain" }, + "models": { + "sonnet": "claude-sonnet-4-5", + "opus": "claude-opus-4-6", + "claude-sonnet-4-5": "claude-sonnet-4-5", + "claude-opus-4-6": "claude-opus-4-6" + } + }, + "openai": { + "display_name": "Codex", + "executable": "codex", + "args": ["exec", "{{?resume}}resume", "{{?resume}}--last", "{{?model}}--model", "{{model}}", "--skip-git-repo-check", "--dangerously-bypass-approvals-and-sandbox", "--json", "{{message}}"], + "output": { + "type": "jsonl", + "select": { + "match": { + "type": "item.completed", + "item.type": "agent_message" + }, + "field": "item.text" + } + }, + "models": { + "gpt-5.2": "gpt-5.2", + "gpt-5.3-codex": "gpt-5.3-codex" + } + }, + "qoder": { + "display_name": "Qoder", + "executable": "qodercli", + "args": ["--workspace", "{{cwd}}", "{{?model}}--model", "{{model}}", "{{?resume}}--continue", "--print", "{{message}}"], + "output": { "type": "plain" }, + "models": { + "auto": "auto", + "efficient": "efficient", + "gmodel": "gmodel", + "kmodel": "kmodel", + "lite": "lite", + "mmodel": "mmodel", + "performance": "performance", + "qmodel": "qmodel", + "ultimate": "ultimate" + } + } + } +} diff --git a/lib/agents.sh b/lib/agents.sh index dcadc27..c5acdc3 100644 --- a/lib/agents.sh +++ b/lib/agents.sh @@ -129,36 +129,71 @@ agent_add() { fi # Provider + PROVIDERS_FILE="$SCRIPT_DIR/config/providers.json" + PROVIDER_IDS=() + PROVIDER_NAMES=() + DEFAULT_PROVIDER="anthropic" + + if command -v jq &> /dev/null && [ -f "$PROVIDERS_FILE" ]; then + mapfile -t PROVIDER_IDS < <(jq -r '.providers | keys[]' "$PROVIDERS_FILE") + for pid in "${PROVIDER_IDS[@]}"; do + pname=$(jq -r --arg id "$pid" '.providers[$id].display_name // $id' "$PROVIDERS_FILE") + PROVIDER_NAMES+=("$pname") + done + for pid in "${PROVIDER_IDS[@]}"; do + if [ "$pid" = "anthropic" ]; then + DEFAULT_PROVIDER="anthropic" + break + fi + DEFAULT_PROVIDER="${PROVIDER_IDS[0]}" + done + else + PROVIDER_IDS=("anthropic" "openai" "qoder") + PROVIDER_NAMES=("Anthropic (Claude)" "OpenAI (Codex)" "Qoder") + fi + echo "" echo "Provider:" - echo " 1) Anthropic (Claude)" - echo " 2) OpenAI (Codex)" - read -rp "Choose [1-2, default: 1]: " AGENT_PROVIDER_CHOICE - case "$AGENT_PROVIDER_CHOICE" in - 2) AGENT_PROVIDER="openai" ;; - *) AGENT_PROVIDER="anthropic" ;; - esac + for i in "${!PROVIDER_IDS[@]}"; do + idx=$((i + 1)) + label="${PROVIDER_NAMES[$i]} (${PROVIDER_IDS[$i]})" + if [ "${PROVIDER_IDS[$i]}" = "$DEFAULT_PROVIDER" ]; then + label="${label} (recommended)" + fi + echo " ${idx}) ${label}" + done + read -rp "Choose [1-${#PROVIDER_IDS[@]}, default: 1]: " AGENT_PROVIDER_CHOICE + if [[ "$AGENT_PROVIDER_CHOICE" =~ ^[0-9]+$ ]] && [ "$AGENT_PROVIDER_CHOICE" -ge 1 ] && [ "$AGENT_PROVIDER_CHOICE" -le "${#PROVIDER_IDS[@]}" ]; then + AGENT_PROVIDER="${PROVIDER_IDS[$((AGENT_PROVIDER_CHOICE - 1))]}" + else + AGENT_PROVIDER="${PROVIDER_IDS[0]}" + fi # Model echo "" - if [ "$AGENT_PROVIDER" = "anthropic" ]; then - echo "Model:" - echo " 1) Sonnet (fast)" - echo " 2) Opus (smartest)" - read -rp "Choose [1-2, default: 1]: " AGENT_MODEL_CHOICE - case "$AGENT_MODEL_CHOICE" in - 2) AGENT_MODEL="opus" ;; - *) AGENT_MODEL="sonnet" ;; - esac + if command -v jq &> /dev/null && [ -f "$PROVIDERS_FILE" ]; then + mapfile -t MODEL_IDS < <(jq -r --arg id "$AGENT_PROVIDER" '.providers[$id].models // {} | keys[]' "$PROVIDERS_FILE") + else + MODEL_IDS=() + fi + + if [ "${#MODEL_IDS[@]}" -eq 0 ]; then + AGENT_MODEL="" + elif [ "${#MODEL_IDS[@]}" -eq 1 ]; then + AGENT_MODEL="${MODEL_IDS[0]}" + echo "Model: ${AGENT_MODEL}" else echo "Model:" - echo " 1) GPT-5.3 Codex" - echo " 2) GPT-5.2" - read -rp "Choose [1-2, default: 1]: " AGENT_MODEL_CHOICE - case "$AGENT_MODEL_CHOICE" in - 2) AGENT_MODEL="gpt-5.2" ;; - *) AGENT_MODEL="gpt-5.3-codex" ;; - esac + for i in "${!MODEL_IDS[@]}"; do + idx=$((i + 1)) + echo " ${idx}) ${MODEL_IDS[$i]}" + done + read -rp "Choose [1-${#MODEL_IDS[@]}, default: 1]: " AGENT_MODEL_CHOICE + if [[ "$AGENT_MODEL_CHOICE" =~ ^[0-9]+$ ]] && [ "$AGENT_MODEL_CHOICE" -ge 1 ] && [ "$AGENT_MODEL_CHOICE" -le "${#MODEL_IDS[@]}" ]; then + AGENT_MODEL="${MODEL_IDS[$((AGENT_MODEL_CHOICE - 1))]}" + else + AGENT_MODEL="${MODEL_IDS[0]}" + fi fi # Working directory - automatically set to agent directory diff --git a/lib/common.sh b/lib/common.sh index ee63e9a..2e37bd9 100644 --- a/lib/common.sh +++ b/lib/common.sh @@ -29,33 +29,88 @@ BLUE='\033[0;34m' NC='\033[0m' # --- Channel registry --- -# Single source of truth. Add new channels here and everything else adapts. - -ALL_CHANNELS=(discord whatsapp telegram) - -declare -A CHANNEL_DISPLAY=( - [discord]="Discord" - [whatsapp]="WhatsApp" - [telegram]="Telegram" -) -declare -A CHANNEL_SCRIPT=( - [discord]="dist/channels/discord-client.js" - [whatsapp]="dist/channels/whatsapp-client.js" - [telegram]="dist/channels/telegram-client.js" -) -declare -A CHANNEL_ALIAS=( - [discord]="dc" - [whatsapp]="wa" - [telegram]="tg" -) -declare -A CHANNEL_TOKEN_KEY=( - [discord]="discord_bot_token" - [telegram]="telegram_bot_token" -) -declare -A CHANNEL_TOKEN_ENV=( - [discord]="DISCORD_BOT_TOKEN" - [telegram]="TELEGRAM_BOT_TOKEN" -) +# Source of truth is channels/*.json manifests. + +CHANNELS_DIR="$SCRIPT_DIR/channels" + +ALL_CHANNELS=() +declare -A CHANNEL_DISPLAY=() +declare -A CHANNEL_SCRIPT=() +declare -A CHANNEL_ALIAS=() +declare -A CHANNEL_TOKEN_KEY=() +declare -A CHANNEL_TOKEN_ENV=() +declare -A CHANNEL_TOKEN_PROMPT=() +declare -A CHANNEL_TOKEN_HELP=() + +load_channel_registry() { + ALL_CHANNELS=() + CHANNEL_DISPLAY=() + CHANNEL_SCRIPT=() + CHANNEL_ALIAS=() + CHANNEL_TOKEN_KEY=() + CHANNEL_TOKEN_ENV=() + CHANNEL_TOKEN_PROMPT=() + CHANNEL_TOKEN_HELP=() + + if [ ! -d "$CHANNELS_DIR" ]; then + echo -e "${RED}Channel registry not found: ${CHANNELS_DIR}${NC}" + return 1 + fi + + if ! command -v jq &> /dev/null; then + echo -e "${RED}Error: jq is required for channel registry parsing${NC}" + echo "Install with: brew install jq (macOS) or apt-get install jq (Linux)" + return 1 + fi + + local files=() + while IFS= read -r f; do + files+=("$f") + done < <(find "$CHANNELS_DIR" -maxdepth 1 -type f -name '*.json' | sort) + + if [ ${#files[@]} -eq 0 ]; then + echo -e "${RED}No channel manifests found in ${CHANNELS_DIR}${NC}" + return 1 + fi + + local file id display script alias token_key token_env token_prompt token_help + for file in "${files[@]}"; do + id=$(jq -r '.id // empty' "$file") + if [ -z "$id" ] || [ "$id" = "null" ]; then + echo -e "${YELLOW}Skipping invalid channel manifest (missing id): ${file}${NC}" + continue + fi + ALL_CHANNELS+=("$id") + + display=$(jq -r '.display_name // .id // empty' "$file") + [ -n "$display" ] && CHANNEL_DISPLAY["$id"]="$display" + + script=$(jq -r '.script // empty' "$file") + [ -n "$script" ] && CHANNEL_SCRIPT["$id"]="$script" + + alias=$(jq -r '.alias // empty' "$file") + [ -n "$alias" ] && CHANNEL_ALIAS["$id"]="$alias" + + token_key=$(jq -r '.token.settings_key // empty' "$file") + [ -n "$token_key" ] && CHANNEL_TOKEN_KEY["$id"]="$token_key" + + token_env=$(jq -r '.token.env_var // empty' "$file") + [ -n "$token_env" ] && CHANNEL_TOKEN_ENV["$id"]="$token_env" + + token_prompt=$(jq -r '.token.prompt // empty' "$file") + [ -n "$token_prompt" ] && CHANNEL_TOKEN_PROMPT["$id"]="$token_prompt" + + token_help=$(jq -r '.token.help // empty' "$file") + [ -n "$token_help" ] && CHANNEL_TOKEN_HELP["$id"]="$token_help" + done + + if [ ${#ALL_CHANNELS[@]} -eq 0 ]; then + echo -e "${RED}Channel registry loaded zero channels from ${CHANNELS_DIR}${NC}" + return 1 + fi + + return 0 +} # Runtime state: filled by load_settings ACTIVE_CHANNELS=() @@ -111,7 +166,7 @@ load_settings() { for ch in "${ALL_CHANNELS[@]}"; do local token_key="${CHANNEL_TOKEN_KEY[$ch]:-}" if [ -n "$token_key" ]; then - CHANNEL_TOKENS[$ch]=$(jq -r ".channels.${ch}.bot_token // empty" "$SETTINGS_FILE" 2>/dev/null) + CHANNEL_TOKENS[$ch]=$(jq -r ".channels.${ch}.${token_key} // empty" "$SETTINGS_FILE" 2>/dev/null) fi done diff --git a/lib/daemon.sh b/lib/daemon.sh index 0e2e5b5..bb970c9 100644 --- a/lib/daemon.sh +++ b/lib/daemon.sh @@ -90,9 +90,15 @@ start_daemon() { # Validate tokens for channels that need them for ch in "${ACTIVE_CHANNELS[@]}"; do + local display="${CHANNEL_DISPLAY[$ch]:-$ch}" + local script="${CHANNEL_SCRIPT[$ch]:-}" + if [ -z "$script" ]; then + echo -e "${RED}Channel '${display}' is missing a script entry in its manifest${NC}" + return 1 + fi local token_key="${CHANNEL_TOKEN_KEY[$ch]:-}" if [ -n "$token_key" ] && [ -z "${CHANNEL_TOKENS[$ch]:-}" ]; then - echo -e "${RED}${CHANNEL_DISPLAY[$ch]} is configured but bot token is missing${NC}" + echo -e "${RED}${display} is configured but bot token is missing${NC}" echo "Run 'tinyclaw setup' to reconfigure" return 1 fi @@ -119,7 +125,8 @@ start_daemon() { # Report channels echo -e "${BLUE}Channels:${NC}" for ch in "${ACTIVE_CHANNELS[@]}"; do - echo -e " ${GREEN}✓${NC} ${CHANNEL_DISPLAY[$ch]}" + local display="${CHANNEL_DISPLAY[$ch]:-$ch}" + echo -e " ${GREEN}✓${NC} ${display}" done echo "" @@ -146,8 +153,10 @@ start_daemon() { local whatsapp_pane=-1 for ch in "${ACTIVE_CHANNELS[@]}"; do [ "$ch" = "whatsapp" ] && whatsapp_pane=$pane_idx - tmux send-keys -t "$TMUX_SESSION:0.$pane_idx" "cd '$SCRIPT_DIR' && node ${CHANNEL_SCRIPT[$ch]}" C-m - tmux select-pane -t "$TMUX_SESSION:0.$pane_idx" -T "${CHANNEL_DISPLAY[$ch]}" + local script="${CHANNEL_SCRIPT[$ch]}" + local display="${CHANNEL_DISPLAY[$ch]:-$ch}" + tmux send-keys -t "$TMUX_SESSION:0.$pane_idx" "cd '$SCRIPT_DIR' && node ${script}" C-m + tmux select-pane -t "$TMUX_SESSION:0.$pane_idx" -T "${display}" pane_idx=$((pane_idx + 1)) done @@ -259,7 +268,10 @@ stop_daemon() { # Kill any remaining channel processes for ch in "${ALL_CHANNELS[@]}"; do - pkill -f "${CHANNEL_SCRIPT[$ch]}" || true + local script="${CHANNEL_SCRIPT[$ch]:-}" + if [ -n "$script" ]; then + pkill -f "${script}" || true + fi done pkill -f "dist/queue-processor.js" || true pkill -f "heartbeat-cron.sh" || true @@ -308,13 +320,13 @@ status_daemon() { local ready_file="$TINYCLAW_HOME/channels/whatsapp_ready" for ch in "${ALL_CHANNELS[@]}"; do - local display="${CHANNEL_DISPLAY[$ch]}" - local script="${CHANNEL_SCRIPT[$ch]}" + local display="${CHANNEL_DISPLAY[$ch]:-$ch}" + local script="${CHANNEL_SCRIPT[$ch]:-}" local pad="" # Pad display name to align output while [ $((${#display} + ${#pad})) -lt 16 ]; do pad="$pad "; done - if pgrep -f "$script" > /dev/null; then + if [ -n "$script" ] && pgrep -f "$script" > /dev/null; then if [ "$ch" = "whatsapp" ] && [ -f "$ready_file" ]; then echo -e "${display}:${pad}${GREEN}Running & Ready${NC}" elif [ "$ch" = "whatsapp" ]; then @@ -344,7 +356,8 @@ status_daemon() { for ch in "${ALL_CHANNELS[@]}"; do if [ -f "$LOG_DIR/${ch}.log" ]; then echo "" - echo "Recent ${CHANNEL_DISPLAY[$ch]} Activity:" + local display="${CHANNEL_DISPLAY[$ch]:-$ch}" + echo "Recent ${display} Activity:" printf '%0.s─' {1..24}; echo "" tail -n 5 "$LOG_DIR/${ch}.log" fi @@ -358,7 +371,7 @@ status_daemon() { echo "" echo "Logs:" for ch in "${ALL_CHANNELS[@]}"; do - local display="${CHANNEL_DISPLAY[$ch]}" + local display="${CHANNEL_DISPLAY[$ch]:-$ch}" local pad="" while [ $((${#display} + ${#pad})) -lt 10 ]; do pad="$pad "; done echo " ${display}:${pad}tail -f $LOG_DIR/${ch}.log" diff --git a/lib/messaging.sh b/lib/messaging.sh index 28aec92..adc77b3 100644 --- a/lib/messaging.sh +++ b/lib/messaging.sh @@ -45,7 +45,7 @@ logs() { # Reset a channel's authentication channels_reset() { local ch="$1" - local display="${CHANNEL_DISPLAY[$ch]:-}" + local display="${CHANNEL_DISPLAY[$ch]:-$ch}" if [ -z "$display" ]; then local channel_names diff --git a/lib/setup-wizard.sh b/lib/setup-wizard.sh index c66abb7..10c78b3 100755 --- a/lib/setup-wizard.sh +++ b/lib/setup-wizard.sh @@ -3,6 +3,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SCRIPT_DIR="$PROJECT_ROOT" SETTINGS_FILE="$HOME/.tinyclaw/settings.json" GREEN='\033[0;32m' @@ -18,37 +19,29 @@ echo -e "${BLUE}━━━━━━━━━━━━━━━━━━━━━ echo "" # --- Channel registry --- -# To add a new channel, add its ID here and fill in the config arrays below. -ALL_CHANNELS=(telegram discord whatsapp) - -declare -A CHANNEL_DISPLAY=( - [telegram]="Telegram" - [discord]="Discord" - [whatsapp]="WhatsApp" -) -declare -A CHANNEL_TOKEN_KEY=( - [discord]="discord_bot_token" - [telegram]="telegram_bot_token" -) -declare -A CHANNEL_TOKEN_PROMPT=( - [discord]="Enter your Discord bot token:" - [telegram]="Enter your Telegram bot token:" -) -declare -A CHANNEL_TOKEN_HELP=( - [discord]="(Get one at: https://discord.com/developers/applications)" - [telegram]="(Create a bot via @BotFather on Telegram to get a token)" -) +source "$PROJECT_ROOT/lib/common.sh" +if ! load_channel_registry; then + echo -e "${RED}Failed to load channel registry${NC}" + exit 1 +fi # Channel selection - simple checklist -echo "Which messaging channels (Telegram, Discord, WhatsApp) do you want to enable?" +if [ ${#ALL_CHANNELS[@]} -eq 0 ]; then + echo -e "${RED}No channels available in registry${NC}" + exit 1 +fi + +channel_list=$(IFS=', '; echo "${ALL_CHANNELS[*]}") +echo "Which messaging channels (${channel_list}) do you want to enable?" echo "" ENABLED_CHANNELS=() for ch in "${ALL_CHANNELS[@]}"; do - read -rp " Enable ${CHANNEL_DISPLAY[$ch]}? [y/N]: " choice + display="${CHANNEL_DISPLAY[$ch]:-$ch}" + read -rp " Enable ${display}? [y/N]: " choice if [[ "$choice" =~ ^[yY] ]]; then ENABLED_CHANNELS+=("$ch") - echo -e " ${GREEN}✓ ${CHANNEL_DISPLAY[$ch]} enabled${NC}" + echo -e " ${GREEN}✓ ${display} enabled${NC}" fi done echo "" @@ -63,76 +56,129 @@ declare -A TOKENS for ch in "${ENABLED_CHANNELS[@]}"; do token_key="${CHANNEL_TOKEN_KEY[$ch]:-}" if [ -n "$token_key" ]; then - echo "${CHANNEL_TOKEN_PROMPT[$ch]}" - echo -e "${YELLOW}${CHANNEL_TOKEN_HELP[$ch]}${NC}" + prompt="${CHANNEL_TOKEN_PROMPT[$ch]:-Enter token:}" + help="${CHANNEL_TOKEN_HELP[$ch]:-}" + display="${CHANNEL_DISPLAY[$ch]:-$ch}" + echo "${prompt}" + if [ -n "$help" ]; then + echo -e "${YELLOW}${help}${NC}" + fi echo "" read -rp "Token: " token_value if [ -z "$token_value" ]; then - echo -e "${RED}${CHANNEL_DISPLAY[$ch]} bot token is required${NC}" + echo -e "${RED}${display} bot token is required${NC}" exit 1 fi TOKENS[$ch]="$token_value" - echo -e "${GREEN}✓ ${CHANNEL_DISPLAY[$ch]} token saved${NC}" + echo -e "${GREEN}✓ ${display} token saved${NC}" echo "" fi done # Provider selection -echo "Which AI provider?" -echo "" -echo " 1) Anthropic (Claude) (recommended)" -echo " 2) OpenAI (Codex/GPT)" -echo "" -read -rp "Choose [1-2]: " PROVIDER_CHOICE - -case "$PROVIDER_CHOICE" in - 1) PROVIDER="anthropic" ;; - 2) PROVIDER="openai" ;; - *) - echo -e "${RED}Invalid choice${NC}" - exit 1 - ;; -esac -echo -e "${GREEN}✓ Provider: $PROVIDER${NC}" -echo "" +PROVIDERS_FILE="$PROJECT_ROOT/config/providers.json" +PROVIDER_IDS=() +PROVIDER_NAMES=() +DEFAULT_PROVIDER="anthropic" + +if command -v jq &> /dev/null && [ -f "$PROVIDERS_FILE" ]; then + mapfile -t PROVIDER_IDS < <(jq -r '.providers | keys[]' "$PROVIDERS_FILE") + for pid in "${PROVIDER_IDS[@]}"; do + pname=$(jq -r --arg id "$pid" '.providers[$id].display_name // $id' "$PROVIDERS_FILE") + PROVIDER_NAMES+=("$pname") + done + for pid in "${PROVIDER_IDS[@]}"; do + if [ "$pid" = "anthropic" ]; then + DEFAULT_PROVIDER="anthropic" + break + fi + DEFAULT_PROVIDER="${PROVIDER_IDS[0]}" + done +else + PROVIDER_IDS=("anthropic" "openai" "qoder") + PROVIDER_NAMES=("Anthropic (Claude)" "OpenAI (Codex/GPT)" "Qoder") +fi -# Model selection based on provider -if [ "$PROVIDER" = "anthropic" ]; then - echo "Which Claude model?" +PROVIDER="" +while [ -z "$PROVIDER" ]; do + echo "Which AI provider?" echo "" - echo " 1) Sonnet (fast, recommended)" - echo " 2) Opus (smartest)" + for i in "${!PROVIDER_IDS[@]}"; do + idx=$((i + 1)) + label="${PROVIDER_NAMES[$i]} (${PROVIDER_IDS[$i]})" + if [ "${PROVIDER_IDS[$i]}" = "$DEFAULT_PROVIDER" ]; then + label="${label} (recommended)" + fi + echo " ${idx}) ${label}" + done + echo " s) Skip (use default: ${DEFAULT_PROVIDER})" echo "" - read -rp "Choose [1-2]: " MODEL_CHOICE + read -rp "Choose [1-${#PROVIDER_IDS[@]}, s]: " PROVIDER_CHOICE - case "$MODEL_CHOICE" in - 1) MODEL="sonnet" ;; - 2) MODEL="opus" ;; - *) - echo -e "${RED}Invalid choice${NC}" - exit 1 - ;; - esac - echo -e "${GREEN}✓ Model: $MODEL${NC}" - echo "" + if [[ "$PROVIDER_CHOICE" =~ ^[sS]$ ]]; then + echo -e "${YELLOW}Skipping provider selection (will use defaults)${NC}" + PROVIDER="$DEFAULT_PROVIDER" + break + fi + if [[ "$PROVIDER_CHOICE" =~ ^[0-9]+$ ]] && [ "$PROVIDER_CHOICE" -ge 1 ] && [ "$PROVIDER_CHOICE" -le "${#PROVIDER_IDS[@]}" ]; then + PROVIDER="${PROVIDER_IDS[$((PROVIDER_CHOICE - 1))]}" + else + echo -e "${RED}Invalid choice, please try again${NC}" + echo "" + fi +done + +echo -e "${GREEN}✓ Provider: $PROVIDER${NC}" +echo "" + +# Model selection based on provider (from registry) +MODEL="" +if command -v jq &> /dev/null && [ -f "$PROVIDERS_FILE" ]; then + mapfile -t MODEL_IDS < <(jq -r --arg id "$PROVIDER" '.providers[$id].models // {} | keys[]' "$PROVIDERS_FILE") else - # OpenAI models - echo "Which OpenAI model?" + MODEL_IDS=() +fi + +if [ "${#MODEL_IDS[@]}" -eq 0 ]; then + MODEL="" + echo "Model (optional)?" + echo -e "${YELLOW}(No model list available for provider '${PROVIDER}'. Enter a model name or leave blank.)${NC}" echo "" - echo " 1) GPT-5.3 Codex (recommended)" - echo " 2) GPT-5.2" + read -rp "Model: " MODEL_INPUT + MODEL="${MODEL_INPUT}" + if [ -n "$MODEL" ]; then + echo -e "${GREEN}✓ Model: $MODEL${NC}" + echo "" + fi +elif [ "${#MODEL_IDS[@]}" -eq 1 ]; then + MODEL="${MODEL_IDS[0]}" + echo -e "${GREEN}✓ Model: $MODEL${NC}" echo "" - read -rp "Choose [1-2]: " MODEL_CHOICE +else + while [ -z "$MODEL" ]; do + echo "Which model?" + echo "" + for i in "${!MODEL_IDS[@]}"; do + idx=$((i + 1)) + echo " ${idx}) ${MODEL_IDS[$i]}" + done + echo " s) Skip (use default: ${MODEL_IDS[0]})" + echo "" + read -rp "Choose [1-${#MODEL_IDS[@]}, s]: " MODEL_CHOICE - case "$MODEL_CHOICE" in - 1) MODEL="gpt-5.3-codex" ;; - 2) MODEL="gpt-5.2" ;; - *) - echo -e "${RED}Invalid choice${NC}" - exit 1 - ;; - esac + if [[ "$MODEL_CHOICE" =~ ^[sS]$ ]]; then + echo -e "${YELLOW}Using default model: ${MODEL_IDS[0]}${NC}" + MODEL="${MODEL_IDS[0]}" + break + fi + if [[ "$MODEL_CHOICE" =~ ^[0-9]+$ ]] && [ "$MODEL_CHOICE" -ge 1 ] && [ "$MODEL_CHOICE" -le "${#MODEL_IDS[@]}" ]; then + MODEL="${MODEL_IDS[$((MODEL_CHOICE - 1))]}" + else + echo -e "${RED}Invalid choice, please try again${NC}" + echo "" + fi + done echo -e "${GREEN}✓ Model: $MODEL${NC}" echo "" fi @@ -216,27 +262,73 @@ if [[ "$SETUP_AGENTS" =~ ^[yY] ]]; then read -rp " Display name: " NEW_AGENT_NAME [ -z "$NEW_AGENT_NAME" ] && NEW_AGENT_NAME="$NEW_AGENT_ID" - echo " Provider: 1) Anthropic 2) OpenAI" - read -rp " Choose [1-2, default: 1]: " NEW_PROVIDER_CHOICE - case "$NEW_PROVIDER_CHOICE" in - 2) NEW_PROVIDER="openai" ;; - *) NEW_PROVIDER="anthropic" ;; - esac - - if [ "$NEW_PROVIDER" = "anthropic" ]; then - echo " Model: 1) Sonnet 2) Opus" - read -rp " Choose [1-2, default: 1]: " NEW_MODEL_CHOICE - case "$NEW_MODEL_CHOICE" in - 2) NEW_MODEL="opus" ;; - *) NEW_MODEL="sonnet" ;; - esac + NEW_PROVIDER="" + while [ -z "$NEW_PROVIDER" ]; do + echo " Provider:" + for i in "${!PROVIDER_IDS[@]}"; do + idx=$((i + 1)) + label="${PROVIDER_NAMES[$i]} (${PROVIDER_IDS[$i]})" + if [ "${PROVIDER_IDS[$i]}" = "$DEFAULT_PROVIDER" ]; then + label="${label} (recommended)" + fi + echo " ${idx}) ${label}" + done + echo " s) Skip (use default: ${DEFAULT_PROVIDER})" + read -rp " Choose [1-${#PROVIDER_IDS[@]}, s, default: 1]: " NEW_PROVIDER_CHOICE + if [[ "$NEW_PROVIDER_CHOICE" =~ ^[sS]$ ]]; then + echo -e " ${YELLOW}Using default provider: ${DEFAULT_PROVIDER}${NC}" + NEW_PROVIDER="$DEFAULT_PROVIDER" + break + fi + if [[ -z "$NEW_PROVIDER_CHOICE" ]]; then + NEW_PROVIDER="${PROVIDER_IDS[0]}" + break + fi + if [[ "$NEW_PROVIDER_CHOICE" =~ ^[0-9]+$ ]] && [ "$NEW_PROVIDER_CHOICE" -ge 1 ] && [ "$NEW_PROVIDER_CHOICE" -le "${#PROVIDER_IDS[@]}" ]; then + NEW_PROVIDER="${PROVIDER_IDS[$((NEW_PROVIDER_CHOICE - 1))]}" + else + echo -e " ${RED}Invalid choice, please try again${NC}" + echo "" + fi + done + + NEW_MODEL="" + if command -v jq &> /dev/null && [ -f "$PROVIDERS_FILE" ]; then + mapfile -t NEW_MODEL_IDS < <(jq -r --arg id "$NEW_PROVIDER" '.providers[$id].models // {} | keys[]' "$PROVIDERS_FILE") else - echo " Model: 1) GPT-5.3 Codex 2) GPT-5.2" - read -rp " Choose [1-2, default: 1]: " NEW_MODEL_CHOICE - case "$NEW_MODEL_CHOICE" in - 2) NEW_MODEL="gpt-5.2" ;; - *) NEW_MODEL="gpt-5.3-codex" ;; - esac + NEW_MODEL_IDS=() + fi + + if [ "${#NEW_MODEL_IDS[@]}" -eq 0 ]; then + NEW_MODEL="" + elif [ "${#NEW_MODEL_IDS[@]}" -eq 1 ]; then + NEW_MODEL="${NEW_MODEL_IDS[0]}" + echo -e " ${GREEN}✓ Model: $NEW_MODEL${NC}" + else + while [ -z "$NEW_MODEL" ]; do + echo " Model:" + for i in "${!NEW_MODEL_IDS[@]}"; do + idx=$((i + 1)) + echo " ${idx}) ${NEW_MODEL_IDS[$i]}" + done + echo " s) Skip (use default: ${NEW_MODEL_IDS[0]})" + read -rp " Choose [1-${#NEW_MODEL_IDS[@]}, s, default: 1]: " NEW_MODEL_CHOICE + if [[ "$NEW_MODEL_CHOICE" =~ ^[sS]$ ]]; then + echo -e " ${YELLOW}Using default model: ${NEW_MODEL_IDS[0]}${NC}" + NEW_MODEL="${NEW_MODEL_IDS[0]}" + break + fi + if [[ -z "$NEW_MODEL_CHOICE" ]]; then + NEW_MODEL="${NEW_MODEL_IDS[0]}" + break + fi + if [[ "$NEW_MODEL_CHOICE" =~ ^[0-9]+$ ]] && [ "$NEW_MODEL_CHOICE" -ge 1 ] && [ "$NEW_MODEL_CHOICE" -le "${#NEW_MODEL_IDS[@]}" ]; then + NEW_MODEL="${NEW_MODEL_IDS[$((NEW_MODEL_CHOICE - 1))]}" + else + echo -e " ${RED}Invalid choice, please try again${NC}" + echo "" + fi + done fi NEW_AGENT_DIR="$WORKSPACE_PATH/$NEW_AGENT_ID" @@ -262,17 +354,21 @@ for i in "${!ENABLED_CHANNELS[@]}"; do done CHANNELS_JSON="${CHANNELS_JSON}]" -# Build channel configs with tokens -DISCORD_TOKEN="${TOKENS[discord]:-}" -TELEGRAM_TOKEN="${TOKENS[telegram]:-}" - # Write settings.json with layered structure # Use jq to build valid JSON to avoid escaping issues with agent prompts -if [ "$PROVIDER" = "anthropic" ]; then - MODELS_SECTION='"models": { "provider": "anthropic", "anthropic": { "model": "'"${MODEL}"'" } }' -else - MODELS_SECTION='"models": { "provider": "openai", "openai": { "model": "'"${MODEL}"'" } }' -fi +MODELS_SECTION='"models": { "provider": "'"${PROVIDER}"'", "'"${PROVIDER}"'": { "model": "'"${MODEL}"'" } }' + +CHANNEL_CONFIG_JSON="" +for ch in "${ALL_CHANNELS[@]}"; do + token_key="${CHANNEL_TOKEN_KEY[$ch]:-}" + if [ -n "$token_key" ]; then + token_value="${TOKENS[$ch]:-}" + CHANNEL_CONFIG_JSON="$CHANNEL_CONFIG_JSON\"${ch}\": { \"${token_key}\": \"${token_value}\" }," + else + CHANNEL_CONFIG_JSON="$CHANNEL_CONFIG_JSON\"${ch}\": {}," + fi +done +CHANNEL_CONFIG_JSON="${CHANNEL_CONFIG_JSON%,}" cat > "$SETTINGS_FILE" < "$SETTINGS_FILE" < { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } +}); + +const CHANNEL_ID = 'tui'; +const REQUIRE_PAIRING = process.env.TUI_REQUIRE_PAIRING === '1'; +let senderId = process.env.TUI_SENDER_ID || `local_${process.pid}`; +let senderName = process.env.TUI_SENDER_NAME || senderId; + +interface ResponseData { + channel: string; + sender: string; + message: string; + originalMessage: string; + timestamp: number; + messageId: string; + files?: string[]; +} + +interface PendingMessage { + message: string; + createdAt: number; +} + +const pending = new Map(); +let messageCounter = 0; +let shuttingDown = false; + +function log(level: string, message: string): void { + const timestamp = new Date().toISOString(); + const logMessage = `[${timestamp}] [${level}] ${message}\n`; + fs.appendFileSync(LOG_FILE, logMessage); +} + +function makeMessageId(): string { + messageCounter += 1; + return `tui_${senderId}_${Date.now()}_${messageCounter}`; +} + +function enqueueMessage(message: string, messageId: string): void { + const payload = { + channel: CHANNEL_ID, + sender: senderName, + senderId, + message, + timestamp: Date.now(), + messageId, + }; + const queueFile = path.join(QUEUE_INCOMING, `tui_${messageId}.json`); + fs.writeFileSync(queueFile, JSON.stringify(payload, null, 2)); +} + +function pairingMessage(code: string): string { + return [ + 'This sender is not paired yet.', + `Your pairing code: ${code}`, + 'Approve with:', + `tinyclaw pairing approve ${code}`, + ].join('\n'); +} + +function printResponse(text: string): void { + const clean = text.trimEnd(); + if (!clean) { + return; + } + process.stdout.write(`\n${clean}\n`); + rl.prompt(true); +} + +function checkOutgoing(): void { + if (pending.size === 0) { + return; + } + let files: string[] = []; + try { + files = fs.readdirSync(QUEUE_OUTGOING) + .filter(f => f.startsWith('tui_') && f.endsWith('.json')); + } catch { + return; + } + + for (const file of files) { + const fullPath = path.join(QUEUE_OUTGOING, file); + let data: ResponseData | null = null; + try { + data = JSON.parse(fs.readFileSync(fullPath, 'utf8')) as ResponseData; + } catch { + continue; + } + if (!data || data.channel !== CHANNEL_ID) continue; + if (!pending.has(data.messageId)) continue; + + pending.delete(data.messageId); + try { fs.unlinkSync(fullPath); } catch { /* ignore */ } + + if (data.files && data.files.length > 0) { + printResponse(`${data.message}\n\n[files]\n${data.files.join('\n')}`); + } else { + printResponse(data.message); + } + } +} + +const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + terminal: Boolean(process.stdin.isTTY && process.stdout.isTTY), +}); + +function shutdown(): void { + if (shuttingDown) return; + shuttingDown = true; + rl.close(); + process.stdout.write('\n'); +} + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); + +process.stdout.write('TinyClaw TUI (stdin/stdout)\n'); +function showHelp(): void { + const helpLines = [ + 'Commands:', + ' /help Show this help', + ' /whoami Show current sender', + ' /as [name] Set sender id/name', + ' /exit or /quit Exit the TUI', + '', + 'Routing:', + ' Prefix with @agent or @team to route', + ]; + printResponse(helpLines.join('\n')); +} + +process.stdout.write('Type /help for commands. Prefix @agent or @team to route.\n\n'); +rl.setPrompt('> '); +rl.prompt(); + +const pollInterval = setInterval(checkOutgoing, 400); + +rl.on('line', (line) => { + const input = line.trim(); + if (!input) { + rl.prompt(); + return; + } + if (input === '/exit' || input === '/quit') { + clearInterval(pollInterval); + shutdown(); + return; + } + if (input === '/help') { + showHelp(); + return; + } + if (input.startsWith('/as ')) { + const parts = input.split(/\s+/).slice(1); + if (parts.length === 0 || !parts[0]) { + printResponse('Usage: /as [name]'); + return; + } + senderId = parts[0]; + senderName = parts.slice(1).join(' ') || senderId; + printResponse(`Sender set to ${senderName} (${senderId})`); + return; + } + if (input === '/whoami') { + printResponse(`Sender: ${senderName} (${senderId})`); + return; + } + + if (REQUIRE_PAIRING) { + const pairing = ensureSenderPaired(PAIRING_FILE, CHANNEL_ID, senderId, senderName); + if (!pairing.approved) { + printResponse(pairingMessage(pairing.code || '')); + return; + } + } + + const messageId = makeMessageId(); + pending.set(messageId, { message: input, createdAt: Date.now() }); + enqueueMessage(input, messageId); + rl.prompt(); +}); + +rl.on('close', () => { + clearInterval(pollInterval); + process.exit(0); +}); diff --git a/src/lib/config.ts b/src/lib/config.ts index de370c0..a58a013 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; import { jsonrepair } from 'jsonrepair'; -import { Settings, AgentConfig, TeamConfig, CLAUDE_MODEL_IDS, CODEX_MODEL_IDS } from './types'; +import { Settings, AgentConfig, TeamConfig, ProviderRegistry, ProviderConfig } from './types'; export const SCRIPT_DIR = path.resolve(__dirname, '../..'); const _localTinyclaw = path.join(SCRIPT_DIR, '.tinyclaw'); @@ -17,6 +17,98 @@ export const SETTINGS_FILE = path.join(TINYCLAW_HOME, 'settings.json'); export const EVENTS_DIR = path.join(TINYCLAW_HOME, 'events'); export const CHATS_DIR = path.join(TINYCLAW_HOME, 'chats'); export const FILES_DIR = path.join(TINYCLAW_HOME, 'files'); +export const PROVIDERS_FILE = path.join(SCRIPT_DIR, 'config/providers.json'); + +const DEFAULT_PROVIDER_REGISTRY: ProviderRegistry = { + version: 1, + providers: { + anthropic: { + display_name: 'Claude', + executable: 'claude', + args: ['--dangerously-skip-permissions', '{{?model}}--model', '{{model}}', '{{?resume}}-c', '-p', '{{message}}'], + output: { type: 'plain' }, + models: { + 'sonnet': 'claude-sonnet-4-5', + 'opus': 'claude-opus-4-6', + 'claude-sonnet-4-5': 'claude-sonnet-4-5', + 'claude-opus-4-6': 'claude-opus-4-6' + } + }, + openai: { + display_name: 'Codex', + executable: 'codex', + args: ['exec', '{{?resume}}resume', '{{?resume}}--last', '{{?model}}--model', '{{model}}', '--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox', '--json', '{{message}}'], + output: { + type: 'jsonl', + select: { + match: { 'type': 'item.completed', 'item.type': 'agent_message' }, + field: 'item.text' + } + }, + models: { + 'gpt-5.2': 'gpt-5.2', + 'gpt-5.3-codex': 'gpt-5.3-codex', + } + }, + qoder: { + display_name: 'Qoder', + executable: 'qodercli', + args: ['--workspace', '{{cwd}}', '{{?model}}--model', '{{model}}', '{{?resume}}--continue', '--print', '{{message}}'], + output: { type: 'plain' }, + models: { + 'auto': 'auto', + 'efficient': 'efficient', + 'gmodel': 'gmodel', + 'kmodel': 'kmodel', + 'lite': 'lite', + 'mmodel': 'mmodel', + 'performance': 'performance', + 'qmodel': 'qmodel', + 'ultimate': 'ultimate', + } + } + } +}; + +function loadProviderRegistry(): ProviderRegistry { + try { + if (!fs.existsSync(PROVIDERS_FILE)) { + console.error(`[WARN] providers.json not found at ${PROVIDERS_FILE} — using defaults`); + return DEFAULT_PROVIDER_REGISTRY; + } + const raw = fs.readFileSync(PROVIDERS_FILE, 'utf8'); + let parsed: ProviderRegistry; + try { + parsed = JSON.parse(raw); + } catch (parseError) { + console.error(`[WARN] providers.json contains invalid JSON: ${(parseError as Error).message}`); + try { + const repaired = jsonrepair(raw); + parsed = JSON.parse(repaired); + } catch { + console.error('[ERROR] Could not parse providers.json — using defaults'); + return DEFAULT_PROVIDER_REGISTRY; + } + } + if (!parsed || !parsed.providers) { + console.error('[WARN] providers.json missing providers map — using defaults'); + return DEFAULT_PROVIDER_REGISTRY; + } + return parsed; + } catch { + return DEFAULT_PROVIDER_REGISTRY; + } +} + +const PROVIDER_REGISTRY = loadProviderRegistry(); + +export function getProviderRegistry(): ProviderRegistry { + return PROVIDER_REGISTRY; +} + +export function getProviderConfig(providerId: string): ProviderConfig | null { + return PROVIDER_REGISTRY.providers[providerId] || null; +} export function getSettings(): Settings { try { @@ -52,6 +144,9 @@ export function getSettings(): Settings { } else if (settings?.models?.anthropic) { if (!settings.models) settings.models = {}; settings.models.provider = 'anthropic'; + } else if (settings?.models?.qoder) { + if (!settings.models) settings.models = {}; + settings.models.provider = 'qoder'; } } @@ -70,6 +165,8 @@ export function getDefaultAgentFromModels(settings: Settings): AgentConfig { let model = ''; if (provider === 'openai') { model = settings?.models?.openai?.model || 'gpt-5.3-codex'; + } else if (provider === 'qoder') { + model = settings?.models?.qoder?.model || 'qoder'; } else { model = settings?.models?.anthropic?.model || 'sonnet'; } @@ -106,15 +203,15 @@ export function getTeams(settings: Settings): Record { } /** - * Resolve the model ID for Claude (Anthropic). + * Resolve the model ID for any provider. */ -export function resolveClaudeModel(model: string): string { - return CLAUDE_MODEL_IDS[model] || model || ''; +export function resolveProviderModel(providerId: string, model: string): string { + if (!model) return ''; + const provider = getProviderConfig(providerId); + if (!provider?.models) return model; + return provider.models[model] || model; } -/** - * Resolve the model ID for Codex (OpenAI). - */ -export function resolveCodexModel(model: string): string { - return CODEX_MODEL_IDS[model] || model || ''; +export function getProviderDisplayName(providerId: string): string { + return getProviderConfig(providerId)?.display_name || providerId; } diff --git a/src/lib/invoke.ts b/src/lib/invoke.ts index 471eaf2..9543a4f 100644 --- a/src/lib/invoke.ts +++ b/src/lib/invoke.ts @@ -2,10 +2,91 @@ import { spawn } from 'child_process'; import fs from 'fs'; import path from 'path'; import { AgentConfig, TeamConfig } from './types'; -import { SCRIPT_DIR, resolveClaudeModel, resolveCodexModel } from './config'; +import { SCRIPT_DIR, getProviderConfig, resolveProviderModel } from './config'; import { log } from './logging'; import { ensureAgentDirectory, updateAgentTeammates } from './agent-setup'; +type ArgContext = { + message: string; + model: string; + cwd: string; + resume: boolean; +}; + +function renderArg(arg: string, ctx: ArgContext): string { + const conditional = arg.match(/^\{\{\?([a-zA-Z0-9_]+)\}\}(.*)$/); + if (conditional) { + const key = conditional[1]!; + const value = (ctx as unknown as Record)[key]; + if (!value) return ''; + arg = conditional[2] || ''; + } + return arg + .replace(/\{\{message\}\}/g, ctx.message) + .replace(/\{\{model\}\}/g, ctx.model) + .replace(/\{\{cwd\}\}/g, ctx.cwd) + .replace(/\{\{resume\}\}/g, ctx.resume ? 'true' : ''); +} + +function buildArgs(baseArgs: string[], conditionalArgs: Record | undefined, ctx: ArgContext): string[] { + const args: string[] = []; + for (const a of baseArgs) { + const rendered = renderArg(a, ctx).trim(); + if (rendered) args.push(rendered); + } + if (conditionalArgs) { + for (const [cond, list] of Object.entries(conditionalArgs)) { + const condValue = (ctx as unknown as Record)[cond]; + if (condValue) { + for (const a of list) { + const rendered = renderArg(a, ctx).trim(); + if (rendered) args.push(rendered); + } + } + } + } + return args; +} + +function getByPath(obj: unknown, pathStr: string): unknown { + if (!obj || !pathStr) return undefined; + const parts = pathStr.split('.'); + let cur: any = obj; + for (const p of parts) { + if (cur == null) return undefined; + cur = cur[p]; + } + return cur; +} + +function parseJsonlOutput(output: string, match: Record | undefined, field: string | undefined): string { + if (!field) return ''; + let response = ''; + const lines = output.trim().split('\n'); + for (const line of lines) { + if (!line.trim()) continue; + try { + const json = JSON.parse(line); + let ok = true; + if (match) { + for (const [k, v] of Object.entries(match)) { + if (getByPath(json, k) !== v) { + ok = false; + break; + } + } + } + if (ok) { + const value = getByPath(json, field); + if (typeof value === 'string') response = value; + } + } catch { + // Ignore lines that aren't valid JSON + } + } + return response; +} + export async function runCommand(command: string, args: string[], cwd?: string): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { @@ -73,65 +154,44 @@ export async function invokeAgent( ? agent.working_directory : path.join(workspacePath, agent.working_directory)) : agentDir; + if (!fs.existsSync(workingDir)) { + fs.mkdirSync(workingDir, { recursive: true }); + log('WARN', `Working directory did not exist; created: ${workingDir}`); + } const provider = agent.provider || 'anthropic'; + const providerConfig = getProviderConfig(provider); + if (!providerConfig) { + throw new Error(`Unknown provider: ${provider}`); + } - if (provider === 'openai') { - log('INFO', `Using Codex CLI (agent: ${agentId})`); - - const shouldResume = !shouldReset; - - if (shouldReset) { - log('INFO', `🔄 Resetting Codex conversation for agent: ${agentId}`); - } - - const modelId = resolveCodexModel(agent.model); - const codexArgs = ['exec']; - if (shouldResume) { - codexArgs.push('resume', '--last'); - } - if (modelId) { - codexArgs.push('--model', modelId); - } - codexArgs.push('--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox', '--json', message); - - const codexOutput = await runCommand('codex', codexArgs, workingDir); - - // Parse JSONL output and extract final agent_message - let response = ''; - const lines = codexOutput.trim().split('\n'); - for (const line of lines) { - try { - const json = JSON.parse(line); - if (json.type === 'item.completed' && json.item?.type === 'agent_message') { - response = json.item.text; - } - } catch (e) { - // Ignore lines that aren't valid JSON - } - } - - return response || 'Sorry, I could not generate a response from Codex.'; - } else { - // Default to Claude (Anthropic) - log('INFO', `Using Claude provider (agent: ${agentId})`); + let continueConversation = !shouldReset; + if (provider === 'qoder') { + // Qoder errors if --continue is used before a session exists. + continueConversation = false; + } + if (shouldReset) { + log('INFO', `🔄 Resetting conversation for agent: ${agentId}`); + } - const continueConversation = !shouldReset; + log('INFO', `Using ${providerConfig.display_name} provider (agent: ${agentId})`); - if (shouldReset) { - log('INFO', `🔄 Resetting conversation for agent: ${agentId}`); - } + const modelId = resolveProviderModel(provider, agent.model); + const ctx: ArgContext = { + message, + model: modelId, + cwd: workingDir, + resume: continueConversation, + }; - const modelId = resolveClaudeModel(agent.model); - const claudeArgs = ['--dangerously-skip-permissions']; - if (modelId) { - claudeArgs.push('--model', modelId); - } - if (continueConversation) { - claudeArgs.push('-c'); - } - claudeArgs.push('-p', message); + const args = buildArgs(providerConfig.args, providerConfig.conditional_args, ctx); + log('INFO', `Invoking ${providerConfig.display_name}: ${providerConfig.executable} ${args.join(' ')}`); + const output = await runCommand(providerConfig.executable, args, workingDir); - return await runCommand('claude', claudeArgs, workingDir); + if (providerConfig.output?.type === 'jsonl') { + const response = parseJsonlOutput(output, providerConfig.output.select?.match, providerConfig.output.select?.field); + return response || `Sorry, I could not generate a response from ${providerConfig.display_name}.`; } + + return output; } diff --git a/src/lib/types.ts b/src/lib/types.ts index f0b2e40..f252237 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,7 +1,7 @@ export interface AgentConfig { name: string; - provider: string; // 'anthropic' or 'openai' - model: string; // e.g. 'sonnet', 'opus', 'gpt-5.3-codex' + provider: string; // provider id (from providers registry) + model: string; // provider-specific model name or id working_directory: string; } @@ -16,6 +16,11 @@ export interface ChainStep { response: string; } +export interface ChannelConfig { + bot_token?: string; + [key: string]: unknown; +} + export interface Settings { workspace?: { path?: string; @@ -23,18 +28,19 @@ export interface Settings { }; channels?: { enabled?: string[]; - discord?: { bot_token?: string }; - telegram?: { bot_token?: string }; - whatsapp?: {}; + [channelId: string]: ChannelConfig | string[] | undefined; }; models?: { - provider?: string; // 'anthropic' or 'openai' + provider?: string; // 'anthropic', 'openai', or 'qoder' anthropic?: { model?: string; }; openai?: { model?: string; }; + qoder?: { + model?: string; + }; }; agents?: Record; teams?: Record; @@ -43,6 +49,30 @@ export interface Settings { }; } +export interface ProviderOutputSelect { + match?: Record; + field?: string; +} + +export interface ProviderOutputConfig { + type: 'plain' | 'jsonl'; + select?: ProviderOutputSelect; +} + +export interface ProviderConfig { + display_name: string; + executable: string; + args: string[]; + conditional_args?: Record; + output?: ProviderOutputConfig; + models?: Record; +} + +export interface ProviderRegistry { + version: number; + providers: Record; +} + export interface MessageData { channel: string; sender: string; @@ -90,16 +120,3 @@ export interface QueueFile { path: string; time: number; } - -// Model name mapping -export const CLAUDE_MODEL_IDS: Record = { - 'sonnet': 'claude-sonnet-4-5', - 'opus': 'claude-opus-4-6', - 'claude-sonnet-4-5': 'claude-sonnet-4-5', - 'claude-opus-4-6': 'claude-opus-4-6' -}; - -export const CODEX_MODEL_IDS: Record = { - 'gpt-5.2': 'gpt-5.2', - 'gpt-5.3-codex': 'gpt-5.3-codex', -}; diff --git a/src/queue-processor.ts b/src/queue-processor.ts index e89b989..f9bb718 100644 --- a/src/queue-processor.ts +++ b/src/queue-processor.ts @@ -20,7 +20,7 @@ import { MessageData, ResponseData, QueueFile, ChainStep, Conversation, TeamConf import { QUEUE_INCOMING, QUEUE_OUTGOING, QUEUE_PROCESSING, LOG_FILE, EVENTS_DIR, CHATS_DIR, FILES_DIR, - getSettings, getAgents, getTeams + getSettings, getAgents, getTeams, getProviderDisplayName } from './lib/config'; import { log, emitEvent } from './lib/logging'; import { parseAgentRouting, findTeamForAgent, getAgentResetFlag, extractTeammateMentions } from './lib/routing'; @@ -347,7 +347,8 @@ async function processMessage(messageFile: string): Promise { response = await invokeAgent(agent, agentId, message, workspacePath, shouldReset, agents, teams); } catch (error) { const provider = agent.provider || 'anthropic'; - log('ERROR', `${provider === 'openai' ? 'Codex' : 'Claude'} error (agent: ${agentId}): ${(error as Error).message}`); + const providerName = getProviderDisplayName(provider); + log('ERROR', `${providerName} error (agent: ${agentId}): ${(error as Error).message}`); response = "Sorry, I encountered an error processing your request. Please check the queue logs."; } diff --git a/tinyclaw.sh b/tinyclaw.sh index 7073a24..44b209d 100755 --- a/tinyclaw.sh +++ b/tinyclaw.sh @@ -3,9 +3,8 @@ # # To add a new channel: # 1. Create src/channels/-client.ts -# 2. Add the channel ID to ALL_CHANNELS in lib/common.sh -# 3. Fill in the CHANNEL_* registry arrays in lib/common.sh -# 4. Run setup wizard to enable it +# 2. Add a manifest at channels/.json +# 3. Run setup wizard to enable it # SCRIPT_DIR = repo root (where bash scripts live) SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -36,6 +35,12 @@ source "$SCRIPT_DIR/lib/teams.sh" source "$SCRIPT_DIR/lib/pairing.sh" source "$SCRIPT_DIR/lib/update.sh" +# Load channel registry (channels/*.json) +if ! load_channel_registry; then + echo -e "${RED}Failed to load channel registry${NC}" + exit 1 +fi + # --- Main command dispatch --- case "${1:-}" in