From 2f723238fc350c5559110ca0b20c30f4221bcf75 Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Sat, 14 Feb 2026 23:17:58 -0500 Subject: [PATCH 1/7] fix: make tmux start reliable + support OpenAI-compatible env + noninteractive update --- README.md | 17 +++++++++++++++++ lib/daemon.sh | 10 ++++++---- lib/heartbeat-cron.sh | 2 +- lib/update.sh | 24 ++++++++++++++++++++---- tinyclaw.sh | 13 +++++++++++++ 5 files changed, 57 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8e89c02..a819a9b 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,17 @@ Commands work with `tinyclaw` (if CLI installed) or `./tinyclaw.sh` (direct scri | `reset` | Reset all conversations | `tinyclaw reset` | | `channels reset ` | Reset channel authentication | `tinyclaw channels reset whatsapp` | +### OpenAI-Compatible Providers (Optional) + +If you select the `openai` provider, TinyClaw uses the Codex CLI. You can point it at an OpenAI-compatible endpoint (for example, Cerebras) by exporting: + +```bash +export TINYCLAW_OPENAI_API_KEY="..." +export TINYCLAW_OPENAI_BASE_URL="https://api.cerebras.ai/v1" +# or, as a shortcut: +export CEREBRAS_API_KEY="..." +``` + ### Pairing Commands Use sender pairing to control who can message your agents. @@ -206,6 +217,12 @@ Pairing behavior: tinyclaw update ``` +**Non-interactive update (CI/automation):** + +```bash +TINYCLAW_UPDATE_YES=1 tinyclaw update +``` + This will: 1. Check for latest release diff --git a/lib/daemon.sh b/lib/daemon.sh index 7b3c430..6e5fd44 100644 --- a/lib/daemon.sh +++ b/lib/daemon.sh @@ -115,23 +115,25 @@ 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 + # Use respawn-pane instead of send-keys so the target command reliably starts + # even if the pane shell hasn't finished initializing yet. + tmux respawn-pane -k -t "$TMUX_SESSION:0.$pane_idx" "cd '$SCRIPT_DIR' && node ${CHANNEL_SCRIPT[$ch]}" tmux select-pane -t "$TMUX_SESSION:0.$pane_idx" -T "${CHANNEL_DISPLAY[$ch]}" pane_idx=$((pane_idx + 1)) done # Queue pane - tmux send-keys -t "$TMUX_SESSION:0.$pane_idx" "cd '$SCRIPT_DIR' && node dist/queue-processor.js" C-m + tmux respawn-pane -k -t "$TMUX_SESSION:0.$pane_idx" "cd '$SCRIPT_DIR' && node dist/queue-processor.js" tmux select-pane -t "$TMUX_SESSION:0.$pane_idx" -T "Queue" pane_idx=$((pane_idx + 1)) # Heartbeat pane - tmux send-keys -t "$TMUX_SESSION:0.$pane_idx" "cd '$SCRIPT_DIR' && ./lib/heartbeat-cron.sh" C-m + tmux respawn-pane -k -t "$TMUX_SESSION:0.$pane_idx" "cd '$SCRIPT_DIR' && ./lib/heartbeat-cron.sh" tmux select-pane -t "$TMUX_SESSION:0.$pane_idx" -T "Heartbeat" pane_idx=$((pane_idx + 1)) # Logs pane - tmux send-keys -t "$TMUX_SESSION:0.$pane_idx" "cd '$SCRIPT_DIR' && $log_tail_cmd" C-m + tmux respawn-pane -k -t "$TMUX_SESSION:0.$pane_idx" "cd '$SCRIPT_DIR' && $log_tail_cmd" tmux select-pane -t "$TMUX_SESSION:0.$pane_idx" -T "Logs" echo "" diff --git a/lib/heartbeat-cron.sh b/lib/heartbeat-cron.sh index e123de2..366370f 100755 --- a/lib/heartbeat-cron.sh +++ b/lib/heartbeat-cron.sh @@ -11,7 +11,7 @@ fi LOG_FILE="$TINYCLAW_HOME/logs/heartbeat.log" QUEUE_INCOMING="$TINYCLAW_HOME/queue/incoming" QUEUE_OUTGOING="$TINYCLAW_HOME/queue/outgoing" -SETTINGS_FILE="$PROJECT_ROOT/.tinyclaw/settings.json" +SETTINGS_FILE="$TINYCLAW_HOME/settings.json" # Read interval from settings.json, default to 3600 if [ -f "$SETTINGS_FILE" ]; then diff --git a/lib/update.sh b/lib/update.sh index e3a2dbd..9171a1a 100755 --- a/lib/update.sh +++ b/lib/update.sh @@ -118,6 +118,24 @@ show_update_notification() { echo "" } +# Confirm a yes/no prompt. +# - Interactive: prompts. +# - Non-interactive: requires TINYCLAW_UPDATE_YES=1 (otherwise cancels with a helpful message). +confirm_update() { + local prompt="$1" + + if [ "${TINYCLAW_UPDATE_YES:-}" = "1" ]; then + return 0 + fi + + local CONFIRM + if ! read -rp "$prompt" CONFIRM; then + echo "No input available. Re-run with TINYCLAW_UPDATE_YES=1 to auto-confirm." + return 1 + fi + [[ "$CONFIRM" =~ ^[yY] ]] +} + # Perform update do_update() { echo -e "${BLUE}TinyClaw Update${NC}" @@ -128,8 +146,7 @@ do_update() { if session_exists; then echo -e "${YELLOW}Warning: TinyClaw is currently running${NC}" echo "" - read -rp "Stop and update? [y/N]: " CONFIRM - if [[ ! "$CONFIRM" =~ ^[yY] ]]; then + if ! confirm_update "Stop and update? [y/N]: "; then echo "Update cancelled." return 1 fi @@ -165,8 +182,7 @@ do_update() { echo " https://github.com/$GITHUB_REPO/releases/v${latest_version}" echo "" - read -rp "Update to v${latest_version}? [y/N]: " CONFIRM - if [[ ! "$CONFIRM" =~ ^[yY] ]]; then + if ! confirm_update "Update to v${latest_version}? [y/N]: "; then echo "Update cancelled." return 1 fi diff --git a/tinyclaw.sh b/tinyclaw.sh index 8b17467..bf81760 100755 --- a/tinyclaw.sh +++ b/tinyclaw.sh @@ -24,6 +24,19 @@ fi mkdir -p "$LOG_DIR" +# OpenAI-compatible provider overrides +# These env vars let you point the OpenAI/Codex provider at non-OpenAI endpoints (e.g. Cerebras). +# They are applied only for the TinyClaw process. +if [ -z "${TINYCLAW_OPENAI_API_KEY:-}" ] && [ -n "${CEREBRAS_API_KEY:-}" ]; then + export TINYCLAW_OPENAI_API_KEY="$CEREBRAS_API_KEY" +fi +if [ -n "${TINYCLAW_OPENAI_API_KEY:-}" ]; then + export OPENAI_API_KEY="$TINYCLAW_OPENAI_API_KEY" +fi +if [ -n "${TINYCLAW_OPENAI_BASE_URL:-}" ]; then + export OPENAI_BASE_URL="$TINYCLAW_OPENAI_BASE_URL" +fi + # Source library files source "$SCRIPT_DIR/lib/common.sh" source "$SCRIPT_DIR/lib/daemon.sh" From 5c690488f72f836041fb6199543bc02e30f1c7ad Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Sat, 14 Feb 2026 23:31:10 -0500 Subject: [PATCH 2/7] fix: keep default agent in sync when switching provider/model --- README.md | 2 ++ tinyclaw.sh | 12 ++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a819a9b..8dfb681 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,8 @@ export TINYCLAW_OPENAI_BASE_URL="https://api.cerebras.ai/v1" export CEREBRAS_API_KEY="..." ``` +Note: model availability can depend on how the Codex CLI is authenticated. If you see errors about a model being unsupported with a ChatGPT account, switch to a supported Codex model (e.g. `gpt-5.3-codex`) or authenticate with an API-key style credential appropriate for your endpoint. + ### Pairing Commands Use sender pairing to control who can message your agents. diff --git a/tinyclaw.sh b/tinyclaw.sh index bf81760..4d55ada 100755 --- a/tinyclaw.sh +++ b/tinyclaw.sh @@ -135,11 +135,11 @@ case "${1:-}" in tmp_file="$SETTINGS_FILE.tmp" if [ -n "$MODEL_ARG" ]; then # Set both provider and model - jq ".models.provider = \"anthropic\" | .models.anthropic.model = \"$MODEL_ARG\"" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + jq ".models.provider = \"anthropic\" | .models.anthropic.model = \"$MODEL_ARG\" | (if .agents.assistant? then .agents.assistant.provider = \"anthropic\" | .agents.assistant.model = \"$MODEL_ARG\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" echo -e "${GREEN}✓ Switched to Anthropic provider with model: $MODEL_ARG${NC}" else # Set provider only - jq ".models.provider = \"anthropic\"" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + jq ".models.provider = \"anthropic\" | (if .agents.assistant? then .agents.assistant.provider = \"anthropic\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" echo -e "${GREEN}✓ Switched to Anthropic provider${NC}" echo "" echo "Use 'tinyclaw model {sonnet|opus}' to set the model." @@ -155,13 +155,13 @@ case "${1:-}" in tmp_file="$SETTINGS_FILE.tmp" if [ -n "$MODEL_ARG" ]; then # Set both provider and model (supports any model name) - jq ".models.provider = \"openai\" | .models.openai.model = \"$MODEL_ARG\"" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + jq ".models.provider = \"openai\" | .models.openai.model = \"$MODEL_ARG\" | (if .agents.assistant? then .agents.assistant.provider = \"openai\" | .agents.assistant.model = \"$MODEL_ARG\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" echo -e "${GREEN}✓ Switched to OpenAI/Codex provider with model: $MODEL_ARG${NC}" echo "" echo "Note: Make sure you have the 'codex' CLI installed and authenticated." else # Set provider only - jq ".models.provider = \"openai\"" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + jq ".models.provider = \"openai\" | (if .agents.assistant? then .agents.assistant.provider = \"openai\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" echo -e "${GREEN}✓ Switched to OpenAI/Codex provider${NC}" echo "" echo "Use 'tinyclaw model {gpt-5.3-codex|gpt-5.2}' to set the model." @@ -213,7 +213,7 @@ case "${1:-}" in # Update model using jq tmp_file="$SETTINGS_FILE.tmp" - jq ".models.anthropic.model = \"$2\"" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + jq ".models.anthropic.model = \"$2\" | (if .agents.assistant? then .agents.assistant.model = \"$2\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" echo -e "${GREEN}✓ Model switched to: $2${NC}" echo "" @@ -227,7 +227,7 @@ case "${1:-}" in # Update model using jq tmp_file="$SETTINGS_FILE.tmp" - jq ".models.openai.model = \"$2\"" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + jq ".models.openai.model = \"$2\" | (if .agents.assistant? then .agents.assistant.model = \"$2\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" echo -e "${GREEN}✓ Model switched to: $2${NC}" echo "" From e17f80c0c2d431131690f725e9e8b44021d9ca41 Mon Sep 17 00:00:00 2001 From: jeffscottward Date: Sun, 15 Feb 2026 00:11:06 -0500 Subject: [PATCH 3/7] feat: add Cerebras provider + improve daemon startup --- README.md | 24 ++++++++ lib/daemon.sh | 19 ++++-- lib/setup-wizard.sh | 29 ++++++++- src/lib/cerebras.ts | 131 +++++++++++++++++++++++++++++++++++++++++ src/lib/invoke.ts | 31 +++++++++- src/lib/types.ts | 3 +- src/queue-processor.ts | 4 +- tinyclaw.sh | 130 +++++++++++++++++++++++----------------- 8 files changed, 307 insertions(+), 64 deletions(-) create mode 100644 src/lib/cerebras.ts diff --git a/README.md b/README.md index 8dfb681..8046471 100644 --- a/README.md +++ b/README.md @@ -176,6 +176,30 @@ export CEREBRAS_API_KEY="..." Note: model availability can depend on how the Codex CLI is authenticated. If you see errors about a model being unsupported with a ChatGPT account, switch to a supported Codex model (e.g. `gpt-5.3-codex`) or authenticate with an API-key style credential appropriate for your endpoint. +### Cerebras (Qwen/LLama) Provider (Optional) + +Codex CLI currently calls the OpenAI **Responses** API (`/v1/responses`). Some OpenAI-compatible providers (including Cerebras) only expose **Chat Completions** (`/v1/chat/completions`), so TinyClaw includes an optional `cerebras` provider that talks to Cerebras directly. + +Environment variables: + +```bash +export CEREBRAS_API_KEY="..." +export OPENAI_BASE_URL="https://api.cerebras.ai/v1" # optional (defaults to Cerebras) +``` + +In `settings.json`, set the agent provider/model: + +```json +{ + "agents": { + "assistant": { + "provider": "cerebras", + "model": "qwen-3-32b" + } + } +} +``` + ### Pairing Commands Use sender pairing to control who can message your agents. diff --git a/lib/daemon.sh b/lib/daemon.sh index 6e5fd44..53266f9 100644 --- a/lib/daemon.sh +++ b/lib/daemon.sh @@ -18,18 +18,21 @@ start_daemon() { PUPPETEER_SKIP_DOWNLOAD=true npm install fi - # Build TypeScript if any src file is newer than its dist counterpart + # Build TypeScript if any src file is newer than its dist counterpart. + # Check recursively so changes under src/lib/* also trigger rebuilds. local needs_build=false if [ ! -d "$SCRIPT_DIR/dist" ]; then needs_build=true else - for ts_file in "$SCRIPT_DIR"/src/*.ts; do - local js_file="$SCRIPT_DIR/dist/$(basename "${ts_file%.ts}.js")" + while IFS= read -r -d '' ts_file; do + local rel="${ts_file#"$SCRIPT_DIR/src/"}" + local js_rel="${rel%.ts}.js" + local js_file="$SCRIPT_DIR/dist/$js_rel" if [ ! -f "$js_file" ] || [ "$ts_file" -nt "$js_file" ]; then needs_build=true break fi - done + done < <(find "$SCRIPT_DIR/src" -type f -name '*.ts' -print0) fi if [ "$needs_build" = true ]; then echo -e "${YELLOW}Building TypeScript...${NC}" @@ -104,6 +107,14 @@ start_daemon() { tmux new-session -d -s "$TMUX_SESSION" -n "tinyclaw" -c "$SCRIPT_DIR" + # Ensure critical env vars are present inside tmux panes. + # tmux servers can outlive the calling shell, so relying on inheritance is brittle. + for k in CEREBRAS_API_KEY OPENAI_API_KEY OPENAI_BASE_URL TINYCLAW_OPENAI_API_KEY TINYCLAW_OPENAI_BASE_URL TINYCLAW_CEREBRAS_BASE_URL; do + if [ -n "${!k:-}" ]; then + tmux set-environment -t "$TMUX_SESSION" "$k" "${!k}" + fi + done + # Create remaining panes (pane 0 already exists) for ((i=1; i "$SETTINGS_FILE" < l.trim()).filter(Boolean); + const msgs: Msg[] = []; + for (const line of lines) { + try { + const j = JSON.parse(line); + if (j && (j.role === 'user' || j.role === 'assistant' || j.role === 'system') && typeof j.content === 'string') { + msgs.push({ role: j.role, content: j.content }); + } + } catch { + // ignore + } + } + return msgs.slice(-MAX_HISTORY_MESSAGES); + } catch { + return []; + } +} + +function appendHistory(agentDir: string, msg: Msg): void { + const p = getHistoryPath(agentDir); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.appendFileSync(p, JSON.stringify(msg) + '\n'); +} + +export function resetCerebrasHistory(agentDir: string): void { + const p = getHistoryPath(agentDir); + try { + fs.unlinkSync(p); + } catch { + // ignore + } +} + +function getBaseUrl(): string { + // Accept either OPENAI_BASE_URL (set by tinyclaw.sh) or a dedicated var. + return (process.env.TINYCLAW_CEREBRAS_BASE_URL || process.env.OPENAI_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, ''); +} + +function getApiKey(): string { + return process.env.CEREBRAS_API_KEY || process.env.TINYCLAW_OPENAI_API_KEY || process.env.OPENAI_API_KEY || ''; +} + +export async function cerebrasChatCompletion(opts: { + agentDir: string; + model: string; + userMessage: string; +}): Promise { + const baseUrl = getBaseUrl(); + const apiKey = getApiKey(); + if (!apiKey) { + throw new Error('Missing Cerebras API key (set CEREBRAS_API_KEY or TINYCLAW_OPENAI_API_KEY).'); + } + + const url = `${baseUrl}/chat/completions`; + + const history = readHistory(opts.agentDir); + const system = readSystemPrompt(opts.agentDir); + const messages: Msg[] = [{ role: 'system', content: system }, ...history, { role: 'user', content: opts.userMessage }]; + + // Persist user message before sending so history is consistent even if we crash mid-call. + appendHistory(opts.agentDir, { role: 'user', content: opts.userMessage }); + + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: opts.model, + messages, + }), + }); + + const text = await res.text(); + let json: any; + try { + json = JSON.parse(text); + } catch { + throw new Error(`Cerebras HTTP ${res.status}: ${text.slice(0, 200)}`); + } + + if (!res.ok) { + const msg = json?.error?.message || json?.message || `Cerebras HTTP ${res.status}`; + throw new Error(msg); + } + + const content: string | undefined = json?.choices?.[0]?.message?.content; + if (!content) { + throw new Error('Cerebras returned no message content.'); + } + + appendHistory(opts.agentDir, { role: 'assistant', content }); + return content; +} diff --git a/src/lib/invoke.ts b/src/lib/invoke.ts index 471eaf2..a8519bb 100644 --- a/src/lib/invoke.ts +++ b/src/lib/invoke.ts @@ -5,6 +5,7 @@ import { AgentConfig, TeamConfig } from './types'; import { SCRIPT_DIR, resolveClaudeModel, resolveCodexModel } from './config'; import { log } from './logging'; import { ensureAgentDirectory, updateAgentTeammates } from './agent-setup'; +import { cerebrasChatCompletion, resetCerebrasHistory } from './cerebras'; export async function runCommand(command: string, args: string[], cwd?: string): Promise { return new Promise((resolve, reject) => { @@ -76,7 +77,35 @@ export async function invokeAgent( const provider = agent.provider || 'anthropic'; - if (provider === 'openai') { + if (provider === 'cerebras') { + log('INFO', `Using Cerebras provider (agent: ${agentId})`); + + if (shouldReset) { + resetCerebrasHistory(agentDir); + } + + // Best-effort fallback: if user config requests an inaccessible model, fall back to qwen-3-32b. + const preferredModel = agent.model || 'qwen-3-32b'; + try { + return await cerebrasChatCompletion({ + agentDir, + model: preferredModel, + userMessage: message, + }); + } catch (e) { + const msg = (e as Error).message || ''; + const isModelNotFound = /does not exist|do not have access|model_not_found/i.test(msg); + if (!isModelNotFound || preferredModel === 'qwen-3-32b') { + throw e; + } + log('WARN', `Cerebras model "${preferredModel}" unavailable; falling back to qwen-3-32b (agent: ${agentId})`); + return await cerebrasChatCompletion({ + agentDir, + model: 'qwen-3-32b', + userMessage: message, + }); + } + } else if (provider === 'openai') { log('INFO', `Using Codex CLI (agent: ${agentId})`); const shouldResume = !shouldReset; diff --git a/src/lib/types.ts b/src/lib/types.ts index f0b2e40..dae1c3c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,6 +1,6 @@ export interface AgentConfig { name: string; - provider: string; // 'anthropic' or 'openai' + provider: string; // 'anthropic' | 'openai' | 'cerebras' (others allowed) model: string; // e.g. 'sonnet', 'opus', 'gpt-5.3-codex' working_directory: string; } @@ -77,6 +77,7 @@ export interface Conversation { export interface ResponseData { channel: string; sender: string; + senderId?: string; message: string; originalMessage: string; timestamp: number; diff --git a/src/queue-processor.ts b/src/queue-processor.ts index e89b989..85026cc 100644 --- a/src/queue-processor.ts +++ b/src/queue-processor.ts @@ -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 providerLabel = provider === 'openai' ? 'Codex' : provider === 'cerebras' ? 'Cerebras' : 'Claude'; + log('ERROR', `${providerLabel} error (agent: ${agentId}): ${(error as Error).message}`); response = "Sorry, I encountered an error processing your request. Please check the queue logs."; } @@ -371,6 +372,7 @@ async function processMessage(messageFile: string): Promise { const responseData: ResponseData = { channel, sender, + senderId: messageData.senderId, message: responseMessage, originalMessage: rawMessage, timestamp: Date.now(), diff --git a/tinyclaw.sh b/tinyclaw.sh index 4d55ada..b376c90 100755 --- a/tinyclaw.sh +++ b/tinyclaw.sh @@ -102,11 +102,15 @@ case "${1:-}" in provider) if [ -z "$2" ]; then if [ -f "$SETTINGS_FILE" ]; then - CURRENT_PROVIDER=$(jq -r '.models.provider // "anthropic"' "$SETTINGS_FILE" 2>/dev/null) - if [ "$CURRENT_PROVIDER" = "openai" ]; then - CURRENT_MODEL=$(jq -r '.models.openai.model // empty' "$SETTINGS_FILE" 2>/dev/null) - else - CURRENT_MODEL=$(jq -r '.models.anthropic.model // empty' "$SETTINGS_FILE" 2>/dev/null) + # Prefer per-agent config if present (newer multi-agent config format). + CURRENT_PROVIDER=$(jq -r '.agents.assistant.provider // .models.provider // "anthropic"' "$SETTINGS_FILE" 2>/dev/null) + CURRENT_MODEL=$(jq -r '.agents.assistant.model // empty' "$SETTINGS_FILE" 2>/dev/null) + if [ -z "$CURRENT_MODEL" ]; then + if [ "$CURRENT_PROVIDER" = "openai" ]; then + CURRENT_MODEL=$(jq -r '.models.openai.model // empty' "$SETTINGS_FILE" 2>/dev/null) + else + CURRENT_MODEL=$(jq -r '.models.anthropic.model // empty' "$SETTINGS_FILE" 2>/dev/null) + fi fi echo -e "${BLUE}Current provider: ${GREEN}$CURRENT_PROVIDER${NC}" if [ -n "$CURRENT_MODEL" ]; then @@ -168,16 +172,37 @@ case "${1:-}" in echo "Note: Make sure you have the 'codex' CLI installed and authenticated." fi ;; + cerebras) + if [ ! -f "$SETTINGS_FILE" ]; then + echo -e "${RED}No settings file found. Run setup first.${NC}" + exit 1 + fi + + # Switch to Cerebras provider (native Chat Completions client) + tmp_file="$SETTINGS_FILE.tmp" + if [ -n "$MODEL_ARG" ]; then + jq ".models.provider = \"cerebras\" | (if .agents.assistant? then .agents.assistant.provider = \"cerebras\" | .agents.assistant.model = \"$MODEL_ARG\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + echo -e "${GREEN}✓ Switched to Cerebras provider with model: $MODEL_ARG${NC}" + else + jq ".models.provider = \"cerebras\" | (if .agents.assistant? then .agents.assistant.provider = \"cerebras\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + echo -e "${GREEN}✓ Switched to Cerebras provider${NC}" + echo "" + echo "Tip: set a model with e.g. 'tinyclaw provider cerebras --model qwen-3-32b'" + echo "Note: some Cerebras models may require allowlist access." + fi + ;; *) - echo "Usage: $0 provider {anthropic|openai} [--model MODEL_NAME]" + echo "Usage: $0 provider {anthropic|openai|cerebras} [--model MODEL_NAME]" echo "" echo "Examples:" echo " $0 provider # Show current provider and model" echo " $0 provider anthropic # Switch to Anthropic" echo " $0 provider openai # Switch to OpenAI" + echo " $0 provider cerebras # Switch to Cerebras" echo " $0 provider anthropic --model sonnet # Switch to Anthropic with Sonnet" echo " $0 provider openai --model gpt-5.3-codex # Switch to OpenAI with GPT-5.3 Codex" echo " $0 provider openai --model gpt-4o # Switch to OpenAI with custom model" + echo " $0 provider cerebras --model qwen-3-32b # Switch to Cerebras with Qwen" exit 1 ;; esac @@ -186,11 +211,14 @@ case "${1:-}" in model) if [ -z "$2" ]; then if [ -f "$SETTINGS_FILE" ]; then - CURRENT_PROVIDER=$(jq -r '.models.provider // "anthropic"' "$SETTINGS_FILE" 2>/dev/null) - if [ "$CURRENT_PROVIDER" = "openai" ]; then - CURRENT_MODEL=$(jq -r '.models.openai.model // empty' "$SETTINGS_FILE" 2>/dev/null) - else - CURRENT_MODEL=$(jq -r '.models.anthropic.model // empty' "$SETTINGS_FILE" 2>/dev/null) + CURRENT_PROVIDER=$(jq -r '.agents.assistant.provider // .models.provider // "anthropic"' "$SETTINGS_FILE" 2>/dev/null) + CURRENT_MODEL=$(jq -r '.agents.assistant.model // empty' "$SETTINGS_FILE" 2>/dev/null) + if [ -z "$CURRENT_MODEL" ]; then + if [ "$CURRENT_PROVIDER" = "openai" ]; then + CURRENT_MODEL=$(jq -r '.models.openai.model // empty' "$SETTINGS_FILE" 2>/dev/null) + else + CURRENT_MODEL=$(jq -r '.models.anthropic.model // empty' "$SETTINGS_FILE" 2>/dev/null) + fi fi if [ -n "$CURRENT_MODEL" ]; then echo -e "${BLUE}Current provider: ${GREEN}$CURRENT_PROVIDER${NC}" @@ -204,53 +232,47 @@ case "${1:-}" in exit 1 fi else - case "$2" in - sonnet|opus) - if [ ! -f "$SETTINGS_FILE" ]; then - echo -e "${RED}No settings file found. Run setup first.${NC}" - exit 1 - fi + if [ ! -f "$SETTINGS_FILE" ]; then + echo -e "${RED}No settings file found. Run setup first.${NC}" + exit 1 + fi - # Update model using jq - tmp_file="$SETTINGS_FILE.tmp" - jq ".models.anthropic.model = \"$2\" | (if .agents.assistant? then .agents.assistant.model = \"$2\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + CURRENT_PROVIDER=$(jq -r '.agents.assistant.provider // .models.provider // "anthropic"' "$SETTINGS_FILE" 2>/dev/null) + MODEL_NAME="$2" - echo -e "${GREEN}✓ Model switched to: $2${NC}" - echo "" - echo "Note: This affects the queue processor. Changes take effect on next message." - ;; - gpt-5.2|gpt-5.3-codex) - if [ ! -f "$SETTINGS_FILE" ]; then - echo -e "${RED}No settings file found. Run setup first.${NC}" + if [ "$CURRENT_PROVIDER" = "anthropic" ]; then + case "$MODEL_NAME" in + sonnet|opus) + tmp_file="$SETTINGS_FILE.tmp" + jq ".models.anthropic.model = \"$MODEL_NAME\" | (if .agents.assistant? then .agents.assistant.model = \"$MODEL_NAME\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + echo -e "${GREEN}✓ Model switched to: $MODEL_NAME${NC}" + echo "" + echo "Note: This affects the queue processor. Changes take effect on next message." + ;; + *) + echo "Usage: $0 model {sonnet|opus}" + echo "" + echo "Anthropic models:" + echo " sonnet # Claude Sonnet (fast)" + echo " opus # Claude Opus (smartest)" exit 1 - fi - - # Update model using jq - tmp_file="$SETTINGS_FILE.tmp" - jq ".models.openai.model = \"$2\" | (if .agents.assistant? then .agents.assistant.model = \"$2\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + ;; + esac + else + # For OpenAI and Cerebras, accept any model id (provider-specific). + tmp_file="$SETTINGS_FILE.tmp" + if [ "$CURRENT_PROVIDER" = "openai" ]; then + jq ".models.openai.model = \"$MODEL_NAME\" | (if .agents.assistant? then .agents.assistant.model = \"$MODEL_NAME\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + elif [ "$CURRENT_PROVIDER" = "cerebras" ]; then + jq ".models.cerebras.model = \"$MODEL_NAME\" | (if .agents.assistant? then .agents.assistant.model = \"$MODEL_NAME\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + else + jq "(if .agents.assistant? then .agents.assistant.model = \"$MODEL_NAME\" else . end)" "$SETTINGS_FILE" > "$tmp_file" && mv "$tmp_file" "$SETTINGS_FILE" + fi - echo -e "${GREEN}✓ Model switched to: $2${NC}" - echo "" - echo "Note: This affects the queue processor. Changes take effect on next message." - ;; - *) - echo "Usage: $0 model {sonnet|opus|gpt-5.2|gpt-5.3-codex}" - echo "" - echo "Anthropic models:" - echo " sonnet # Claude Sonnet (fast)" - echo " opus # Claude Opus (smartest)" - echo "" - echo "OpenAI models:" - echo " gpt-5.3-codex # GPT-5.3 Codex" - echo " gpt-5.2 # GPT-5.2" - echo "" - echo "Examples:" - echo " $0 model # Show current model" - echo " $0 model sonnet # Switch to Claude Sonnet" - echo " $0 model gpt-5.3-codex # Switch to GPT-5.3 Codex" - exit 1 - ;; - esac + echo -e "${GREEN}✓ Model switched to: $MODEL_NAME${NC}" + echo "" + echo "Note: This affects the queue processor. Changes take effect on next message." + fi fi ;; agent) From 67592d850818a63b9f840265f9ce4a67143e53c1 Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Sun, 15 Feb 2026 00:14:58 -0500 Subject: [PATCH 4/7] fix: retry Cerebras overload + friendlier errors --- src/lib/cerebras.ts | 90 +++++++++++++++++++++++++++++------------- src/queue-processor.ts | 7 +++- 2 files changed, 68 insertions(+), 29 deletions(-) diff --git a/src/lib/cerebras.ts b/src/lib/cerebras.ts index 6ca66de..6ca36b4 100644 --- a/src/lib/cerebras.ts +++ b/src/lib/cerebras.ts @@ -76,6 +76,16 @@ function getApiKey(): string { return process.env.CEREBRAS_API_KEY || process.env.TINYCLAW_OPENAI_API_KEY || process.env.OPENAI_API_KEY || ''; } +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +function isRetryable(status: number, message: string): boolean { + if ([408, 409, 425, 429, 500, 502, 503, 504].includes(status)) return true; + const m = message.toLowerCase(); + return m.includes('high traffic') || m.includes('rate limit') || m.includes('timeout') || m.includes('temporar'); +} + export async function cerebrasChatCompletion(opts: { agentDir: string; model: string; @@ -96,36 +106,60 @@ export async function cerebrasChatCompletion(opts: { // Persist user message before sending so history is consistent even if we crash mid-call. appendHistory(opts.agentDir, { role: 'user', content: opts.userMessage }); - const res = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model: opts.model, - messages, - }), - }); - - const text = await res.text(); - let json: any; - try { - json = JSON.parse(text); - } catch { - throw new Error(`Cerebras HTTP ${res.status}: ${text.slice(0, 200)}`); - } + let lastErrMsg = 'Unknown Cerebras error'; + let lastStatus = 0; + for (let attempt = 0; attempt < 3; attempt++) { + const res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: opts.model, + messages, + }), + }); + + lastStatus = res.status; + const text = await res.text(); + let json: any; + try { + json = JSON.parse(text); + } catch { + lastErrMsg = `Cerebras HTTP ${res.status}: ${text.slice(0, 200)}`; + if (attempt < 2 && isRetryable(res.status, lastErrMsg)) { + await sleep(250 * Math.pow(2, attempt)); + continue; + } + throw new Error(lastErrMsg); + } - if (!res.ok) { - const msg = json?.error?.message || json?.message || `Cerebras HTTP ${res.status}`; - throw new Error(msg); - } + if (!res.ok) { + const msg = json?.error?.message || json?.message || `Cerebras HTTP ${res.status}`; + lastErrMsg = msg; + if (attempt < 2 && isRetryable(res.status, msg)) { + const ra = Number(res.headers.get('retry-after') || ''); + const delay = Number.isFinite(ra) && ra > 0 ? ra * 1000 : 250 * Math.pow(2, attempt); + await sleep(delay); + continue; + } + throw new Error(msg); + } + + const content: string | undefined = json?.choices?.[0]?.message?.content; + if (!content) { + lastErrMsg = 'Cerebras returned no message content.'; + if (attempt < 2) { + await sleep(250 * Math.pow(2, attempt)); + continue; + } + throw new Error(lastErrMsg); + } - const content: string | undefined = json?.choices?.[0]?.message?.content; - if (!content) { - throw new Error('Cerebras returned no message content.'); + appendHistory(opts.agentDir, { role: 'assistant', content }); + return content; } - appendHistory(opts.agentDir, { role: 'assistant', content }); - return content; + throw new Error(`Cerebras temporarily unavailable (HTTP ${lastStatus}): ${lastErrMsg}`); } diff --git a/src/queue-processor.ts b/src/queue-processor.ts index 85026cc..6e66690 100644 --- a/src/queue-processor.ts +++ b/src/queue-processor.ts @@ -349,7 +349,12 @@ async function processMessage(messageFile: string): Promise { const provider = agent.provider || 'anthropic'; const providerLabel = provider === 'openai' ? 'Codex' : provider === 'cerebras' ? 'Cerebras' : 'Claude'; log('ERROR', `${providerLabel} error (agent: ${agentId}): ${(error as Error).message}`); - response = "Sorry, I encountered an error processing your request. Please check the queue logs."; + const msg = ((error as Error).message || '').toLowerCase(); + if (provider === 'cerebras' && (msg.includes('high traffic') || msg.includes('rate limit') || msg.includes('temporarily unavailable'))) { + response = "Cerebras is experiencing high traffic right now. Please try again in ~30 seconds."; + } else { + response = "Sorry, I encountered an error processing your request. Please check the queue logs."; + } } emitEvent('chain_step_done', { agentId, agentName: agent.name, responseLength: response.length, responseText: response }); From 6e6d14f6ae729841d1c4c7feb05d4d209b7fe05a Mon Sep 17 00:00:00 2001 From: jeffscottward Date: Sun, 15 Feb 2026 00:36:13 -0500 Subject: [PATCH 5/7] fix: make Cerebras overload errors user-friendly --- src/lib/cerebras.ts | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/lib/cerebras.ts b/src/lib/cerebras.ts index 6ca36b4..1b6c087 100644 --- a/src/lib/cerebras.ts +++ b/src/lib/cerebras.ts @@ -109,17 +109,28 @@ export async function cerebrasChatCompletion(opts: { let lastErrMsg = 'Unknown Cerebras error'; let lastStatus = 0; for (let attempt = 0; attempt < 3; attempt++) { - const res = await fetch(url, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - model: opts.model, - messages, - }), - }); + let res: Response; + try { + res = await fetch(url, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + model: opts.model, + messages, + }), + }); + } catch (e) { + lastStatus = 0; + lastErrMsg = (e as Error).message || 'Network error'; + if (attempt < 2) { + await sleep(250 * Math.pow(2, attempt)); + continue; + } + break; + } lastStatus = res.status; const text = await res.text(); @@ -132,6 +143,7 @@ export async function cerebrasChatCompletion(opts: { await sleep(250 * Math.pow(2, attempt)); continue; } + if (isRetryable(res.status, lastErrMsg)) break; throw new Error(lastErrMsg); } @@ -144,6 +156,7 @@ export async function cerebrasChatCompletion(opts: { await sleep(delay); continue; } + if (isRetryable(res.status, msg)) break; throw new Error(msg); } @@ -154,12 +167,12 @@ export async function cerebrasChatCompletion(opts: { await sleep(250 * Math.pow(2, attempt)); continue; } - throw new Error(lastErrMsg); + break; } appendHistory(opts.agentDir, { role: 'assistant', content }); return content; } - throw new Error(`Cerebras temporarily unavailable (HTTP ${lastStatus}): ${lastErrMsg}`); + throw new Error(`Cerebras temporarily unavailable${lastStatus ? ` (HTTP ${lastStatus})` : ''}: ${lastErrMsg}`); } From 688f36eb75ee52825cbf9b7a26e57b0b02989418 Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Sun, 15 Feb 2026 00:51:42 -0500 Subject: [PATCH 6/7] perf: bun-first builds + Cerebras speed logs --- lib/daemon.sh | 13 ++++++-- scripts/bundle.sh | 19 +++++++++-- scripts/remote-install.sh | 32 +++++++++++++----- src/lib/cerebras.ts | 69 ++++++++++++++++++++++++++++++++++++++- src/queue-processor.ts | 5 +++ tinyclaw.sh | 6 +++- 6 files changed, 128 insertions(+), 16 deletions(-) diff --git a/lib/daemon.sh b/lib/daemon.sh index 53266f9..a39b12e 100644 --- a/lib/daemon.sh +++ b/lib/daemon.sh @@ -15,7 +15,12 @@ start_daemon() { if [ ! -d "$SCRIPT_DIR/node_modules" ]; then echo -e "${YELLOW}Installing Node.js dependencies...${NC}" cd "$SCRIPT_DIR" - PUPPETEER_SKIP_DOWNLOAD=true npm install + if command -v bun >/dev/null 2>&1; then + # bun is typically much faster than npm. Avoid writing lockfiles in user installs. + PUPPETEER_SKIP_DOWNLOAD=true bun install --no-save + else + PUPPETEER_SKIP_DOWNLOAD=true npm install + fi fi # Build TypeScript if any src file is newer than its dist counterpart. @@ -37,7 +42,11 @@ start_daemon() { if [ "$needs_build" = true ]; then echo -e "${YELLOW}Building TypeScript...${NC}" cd "$SCRIPT_DIR" - npm run build + if command -v bun >/dev/null 2>&1; then + bun run build + else + npm run build + fi fi # Load settings or run setup wizard diff --git a/scripts/bundle.sh b/scripts/bundle.sh index 4fa744b..1b9bcd1 100755 --- a/scripts/bundle.sh +++ b/scripts/bundle.sh @@ -52,13 +52,21 @@ echo "" # Step 2: Install dependencies for build echo -e "${BLUE}[2/5] Installing dependencies...${NC}" echo "This may take a few minutes..." -PUPPETEER_SKIP_DOWNLOAD=true npm install --silent +if command -v bun >/dev/null 2>&1; then + PUPPETEER_SKIP_DOWNLOAD=true bun install --no-save --silent +else + PUPPETEER_SKIP_DOWNLOAD=true npm install --silent +fi echo -e "${GREEN}✓ Dependencies installed${NC}" echo "" # Step 3: Build TypeScript echo -e "${BLUE}[3/5] Building TypeScript...${NC}" -npm run build --silent +if command -v bun >/dev/null 2>&1; then + bun run build +else + npm run build --silent +fi echo -e "${GREEN}✓ Build complete${NC}" echo "" @@ -66,7 +74,12 @@ echo "" echo -e "${BLUE}[4/5] Creating bundle...${NC}" # Keep runtime bundle lean: remove development-only dependencies after build. -npm prune --omit=dev --silent +if command -v bun >/dev/null 2>&1; then + rm -rf node_modules + PUPPETEER_SKIP_DOWNLOAD=true bun install --production --no-save --silent +else + npm prune --omit=dev --silent +fi mkdir -p "$BUNDLE_DIR" diff --git a/scripts/remote-install.sh b/scripts/remote-install.sh index 784aaa6..c5f0318 100755 --- a/scripts/remote-install.sh +++ b/scripts/remote-install.sh @@ -41,8 +41,8 @@ if ! command_exists node; then MISSING_DEPS+=("node") fi -if ! command_exists npm; then - MISSING_DEPS+=("npm") +if ! command_exists bun && ! command_exists npm; then + MISSING_DEPS+=("bun or npm") fi if ! command_exists tmux; then @@ -60,7 +60,9 @@ if [ ${#MISSING_DEPS[@]} -ne 0 ]; then done echo "" echo "Install instructions:" - echo " - Node.js/npm: https://nodejs.org/" + echo " - Node.js: https://nodejs.org/" + echo " - bun (recommended): https://bun.sh/" + echo " - npm: included with Node.js" echo " - tmux: sudo apt install tmux (or brew install tmux)" echo " - Claude Code: https://claude.com/claude-code" echo "" @@ -152,14 +154,26 @@ if [ "$USE_BUNDLE" = false ]; then echo -e "${BLUE}[5/6] Installing dependencies...${NC}" cd "$INSTALL_DIR" - echo "Running npm install (this may take a few minutes)..." - PUPPETEER_SKIP_DOWNLOAD=true npm install --silent + if command_exists bun; then + echo "Running bun install..." + PUPPETEER_SKIP_DOWNLOAD=true bun install --no-save --silent - echo "Building TypeScript..." - npm run build --silent + echo "Building TypeScript..." + bun run build - echo "Pruning development dependencies..." - npm prune --omit=dev --silent + echo "Installing production-only dependencies..." + rm -rf node_modules + PUPPETEER_SKIP_DOWNLOAD=true bun install --production --no-save --silent + else + echo "Running npm install (this may take a few minutes)..." + PUPPETEER_SKIP_DOWNLOAD=true npm install --silent + + echo "Building TypeScript..." + npm run build --silent + + echo "Pruning development dependencies..." + npm prune --omit=dev --silent + fi echo -e "${GREEN}✓ Dependencies installed${NC}" echo "" diff --git a/src/lib/cerebras.ts b/src/lib/cerebras.ts index 1b6c087..5974f46 100644 --- a/src/lib/cerebras.ts +++ b/src/lib/cerebras.ts @@ -1,11 +1,13 @@ import fs from 'fs'; import path from 'path'; +import { log } from './logging'; type Role = 'system' | 'user' | 'assistant'; type Msg = { role: Role; content: string }; const DEFAULT_BASE_URL = 'https://api.cerebras.ai/v1'; const HISTORY_FILE = '.tinyclaw/cerebras_history.jsonl'; +const META_FILE = '.tinyclaw/cerebras_meta.json'; const MAX_HISTORY_MESSAGES = 20; const SYSTEM_PROMPT_FILE = path.join('.claude', 'CLAUDE.md'); @@ -13,6 +15,28 @@ function getHistoryPath(agentDir: string): string { return path.join(agentDir, HISTORY_FILE); } +function getMetaPath(agentDir: string): string { + return path.join(agentDir, META_FILE); +} + +function readMeta(agentDir: string): { model?: string } { + const p = getMetaPath(agentDir); + try { + const raw = fs.readFileSync(p, 'utf8'); + const j = JSON.parse(raw); + if (j && typeof j.model === 'string') return { model: j.model }; + } catch { + // ignore + } + return {}; +} + +function writeMeta(agentDir: string, meta: { model: string }): void { + const p = getMetaPath(agentDir); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, JSON.stringify({ ...meta, updatedAt: Date.now() }, null, 2)); +} + function readSystemPrompt(agentDir: string): string { const p = path.join(agentDir, SYSTEM_PROMPT_FILE); try { @@ -52,6 +76,22 @@ function readHistory(agentDir: string): Msg[] { } } +function inferModelFromHistory(history: Msg[]): string | undefined { + // Best-effort: detect self-reported model strings in recent assistant messages + // to avoid getting "stuck" after switching models. + for (let i = history.length - 1; i >= 0; i--) { + const m = history[i]; + if (m.role !== 'assistant') continue; + const txt = m.content || ''; + const match = + txt.match(/\bprovider\s*=\s*cerebras\b[\s\S]{0,80}\bmodel\s*=\s*([a-z0-9._-]+)/i) || + txt.match(/\bruntime model:\s*([a-z0-9._-]+)/i) || + txt.match(/\bmodel\s*=\s*([a-z0-9._-]+)\b/i); + if (match && match[1]) return match[1].toLowerCase(); + } + return undefined; +} + function appendHistory(agentDir: string, msg: Msg): void { const p = getHistoryPath(agentDir); fs.mkdirSync(path.dirname(p), { recursive: true }); @@ -99,8 +139,26 @@ export async function cerebrasChatCompletion(opts: { const url = `${baseUrl}/chat/completions`; + // If the configured model changed, reset history to avoid model "stickiness". + // This is intentionally conservative: we'd rather lose a bit of conversational context + // than keep returning stale self-identification. + const initialHistory = readHistory(opts.agentDir); + const inferred = inferModelFromHistory(initialHistory); + const prevMeta = readMeta(opts.agentDir); + if ((prevMeta.model && prevMeta.model !== opts.model) || (inferred && inferred !== opts.model)) { + resetCerebrasHistory(opts.agentDir); + } + writeMeta(opts.agentDir, { model: opts.model }); + const history = readHistory(opts.agentDir); - const system = readSystemPrompt(opts.agentDir); + const baseSystem = readSystemPrompt(opts.agentDir); + const system = [ + `Runtime provider: cerebras`, + `Runtime model: ${opts.model}`, + `If asked what provider/model you're using, answer exactly: provider=cerebras model=${opts.model}.`, + '', + baseSystem, + ].join('\n'); const messages: Msg[] = [{ role: 'system', content: system }, ...history, { role: 'user', content: opts.userMessage }]; // Persist user message before sending so history is consistent even if we crash mid-call. @@ -109,6 +167,7 @@ export async function cerebrasChatCompletion(opts: { let lastErrMsg = 'Unknown Cerebras error'; let lastStatus = 0; for (let attempt = 0; attempt < 3; attempt++) { + const attemptStart = Date.now(); let res: Response; try { res = await fetch(url, { @@ -153,6 +212,7 @@ export async function cerebrasChatCompletion(opts: { if (attempt < 2 && isRetryable(res.status, msg)) { const ra = Number(res.headers.get('retry-after') || ''); const delay = Number.isFinite(ra) && ra > 0 ? ra * 1000 : 250 * Math.pow(2, attempt); + log('WARN', `Cerebras retrying (model=${opts.model}, status=${res.status}, attempt=${attempt + 1}/3, delayMs=${delay}, ms=${Date.now() - attemptStart}): ${msg}`); await sleep(delay); continue; } @@ -170,6 +230,13 @@ export async function cerebrasChatCompletion(opts: { break; } + const usage = json?.usage || {}; + const completionTokens = Number(usage.completion_tokens || usage.completionTokens || 0) || 0; + const totalTokens = Number(usage.total_tokens || usage.totalTokens || 0) || 0; + const ms = Date.now() - attemptStart; + const tps = completionTokens > 0 && ms > 0 ? Math.round((completionTokens / ms) * 1000) : 0; + log('INFO', `Cerebras ok (model=${opts.model}, status=${res.status}, attempt=${attempt + 1}/3, ms=${ms}, completionTokens=${completionTokens || 'n/a'}, totalTokens=${totalTokens || 'n/a'}, tps=${tps || 'n/a'})`); + appendHistory(opts.agentDir, { role: 'assistant', content }); return content; } diff --git a/src/queue-processor.ts b/src/queue-processor.ts index 6e66690..57588dc 100644 --- a/src/queue-processor.ts +++ b/src/queue-processor.ts @@ -231,6 +231,7 @@ async function processMessage(messageFile: string): Promise { const messageData: MessageData = JSON.parse(fs.readFileSync(processingFile, 'utf8')); const { channel, sender, message: rawMessage, timestamp, messageId } = messageData; const isInternal = !!messageData.conversationId; + const startedAt = Date.now(); log('INFO', `Processing [${isInternal ? 'internal' : channel}] ${isInternal ? `@${messageData.fromAgent}→@${messageData.agent}` : `from ${sender}`}: ${rawMessage.substring(0, 50)}...`); if (!isInternal) { @@ -279,6 +280,7 @@ async function processMessage(messageFile: string): Promise { fs.writeFileSync(responseFile, JSON.stringify(responseData, null, 2)); fs.unlinkSync(processingFile); log('INFO', `✓ Easter egg sent to ${sender}`); + log('INFO', `Handled message ${messageId} (channel=${channel}, agent=error, ms=${Date.now() - startedAt})`); return; } @@ -343,6 +345,7 @@ async function processMessage(messageFile: string): Promise { // Invoke agent emitEvent('chain_step_start', { agentId, agentName: agent.name, fromAgent: messageData.fromAgent || null }); let response: string; + const invokeStartedAt = Date.now(); try { response = await invokeAgent(agent, agentId, message, workspacePath, shouldReset, agents, teams); } catch (error) { @@ -356,6 +359,7 @@ async function processMessage(messageFile: string): Promise { response = "Sorry, I encountered an error processing your request. Please check the queue logs."; } } + log('INFO', `Agent invoke done (agent=${agentId}, provider=${agent.provider}/${agent.model}, ms=${Date.now() - invokeStartedAt})`); emitEvent('chain_step_done', { agentId, agentName: agent.name, responseLength: response.length, responseText: response }); @@ -396,6 +400,7 @@ async function processMessage(messageFile: string): Promise { emitEvent('response_ready', { channel, sender, agentId, responseLength: finalResponse.length, responseText: finalResponse, messageId }); fs.unlinkSync(processingFile); + log('INFO', `Handled message ${messageId} (channel=${channel}, agent=${agentId}, provider=${agent.provider}/${agent.model}, ms=${Date.now() - startedAt})`); return; } diff --git a/tinyclaw.sh b/tinyclaw.sh index b376c90..f3eba6f 100755 --- a/tinyclaw.sh +++ b/tinyclaw.sh @@ -355,7 +355,11 @@ case "${1:-}" in if [ ! -f "$SCRIPT_DIR/dist/visualizer/team-visualizer.js" ] || \ [ "$SCRIPT_DIR/src/visualizer/team-visualizer.tsx" -nt "$SCRIPT_DIR/dist/visualizer/team-visualizer.js" ]; then echo -e "${BLUE}Building team visualizer...${NC}" - cd "$SCRIPT_DIR" && npm run build:visualizer 2>/dev/null + if command -v bun >/dev/null 2>&1; then + cd "$SCRIPT_DIR" && bun run build:visualizer 2>/dev/null + else + cd "$SCRIPT_DIR" && npm run build:visualizer 2>/dev/null + fi if [ $? -ne 0 ]; then echo -e "${RED}Failed to build visualizer.${NC}" exit 1 From c07f8af4a8a620a679182b9ab3a906b7ee817b97 Mon Sep 17 00:00:00 2001 From: Jeff Scott Ward Date: Sun, 15 Feb 2026 02:29:33 -0500 Subject: [PATCH 7/7] fix: stabilize Codex exec + isolate Cerebras env --- README.md | 13 +++++----- docs/AGENTS.md | 4 +-- docs/QUEUE.md | 4 +-- lib/daemon.sh | 2 +- src/lib/cerebras.ts | 9 ++++--- src/lib/invoke.ts | 61 +++++++++++++++++++++++++++++++++++++-------- tinyclaw.sh | 16 +++--------- 7 files changed, 71 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 8046471..769547e 100644 --- a/README.md +++ b/README.md @@ -163,15 +163,14 @@ Commands work with `tinyclaw` (if CLI installed) or `./tinyclaw.sh` (direct scri | `reset` | Reset all conversations | `tinyclaw reset` | | `channels reset ` | Reset channel authentication | `tinyclaw channels reset whatsapp` | -### OpenAI-Compatible Providers (Optional) +### OpenAI (Codex CLI) Provider -If you select the `openai` provider, TinyClaw uses the Codex CLI. You can point it at an OpenAI-compatible endpoint (for example, Cerebras) by exporting: +If you select the `openai` provider, TinyClaw uses the Codex CLI. + +Environment variables: ```bash -export TINYCLAW_OPENAI_API_KEY="..." -export TINYCLAW_OPENAI_BASE_URL="https://api.cerebras.ai/v1" -# or, as a shortcut: -export CEREBRAS_API_KEY="..." +export OPENAI_API_KEY="..." ``` Note: model availability can depend on how the Codex CLI is authenticated. If you see errors about a model being unsupported with a ChatGPT account, switch to a supported Codex model (e.g. `gpt-5.3-codex`) or authenticate with an API-key style credential appropriate for your endpoint. @@ -184,7 +183,7 @@ Environment variables: ```bash export CEREBRAS_API_KEY="..." -export OPENAI_BASE_URL="https://api.cerebras.ai/v1" # optional (defaults to Cerebras) +export TINYCLAW_CEREBRAS_BASE_URL="https://api.cerebras.ai/v1" # optional (defaults to Cerebras) ``` In `settings.json`, set the agent provider/model: diff --git a/docs/AGENTS.md b/docs/AGENTS.md index 156c393..3aded6f 100644 --- a/docs/AGENTS.md +++ b/docs/AGENTS.md @@ -182,10 +182,10 @@ claude --dangerously-skip-permissions \ **OpenAI (Codex):** ```bash cd "$agent_working_directory" # e.g., ~/tinyclaw-workspace/coder/ -codex exec resume --last \ +codex --ask-for-approval never exec --ephemeral \ --model gpt-5.3-codex \ --skip-git-repo-check \ - --dangerously-bypass-approvals-and-sandbox \ + --sandbox danger-full-access \ --json \ "User message here" ``` diff --git a/docs/QUEUE.md b/docs/QUEUE.md index 0a31ad9..7491415 100644 --- a/docs/QUEUE.md +++ b/docs/QUEUE.md @@ -133,10 +133,10 @@ claude --dangerously-skip-permissions \ **Codex (OpenAI):** ```bash cd ~/workspace/coder/ -codex exec resume --last \ +codex --ask-for-approval never exec --ephemeral \ --model gpt-5.3-codex \ --skip-git-repo-check \ - --dangerously-bypass-approvals-and-sandbox \ + --sandbox danger-full-access \ --json "fix the authentication bug" ``` diff --git a/lib/daemon.sh b/lib/daemon.sh index a39b12e..53118f0 100644 --- a/lib/daemon.sh +++ b/lib/daemon.sh @@ -118,7 +118,7 @@ start_daemon() { # Ensure critical env vars are present inside tmux panes. # tmux servers can outlive the calling shell, so relying on inheritance is brittle. - for k in CEREBRAS_API_KEY OPENAI_API_KEY OPENAI_BASE_URL TINYCLAW_OPENAI_API_KEY TINYCLAW_OPENAI_BASE_URL TINYCLAW_CEREBRAS_BASE_URL; do + for k in CEREBRAS_API_KEY OPENAI_API_KEY TINYCLAW_CEREBRAS_BASE_URL; do if [ -n "${!k:-}" ]; then tmux set-environment -t "$TMUX_SESSION" "$k" "${!k}" fi diff --git a/src/lib/cerebras.ts b/src/lib/cerebras.ts index 5974f46..72b94a5 100644 --- a/src/lib/cerebras.ts +++ b/src/lib/cerebras.ts @@ -108,12 +108,13 @@ export function resetCerebrasHistory(agentDir: string): void { } function getBaseUrl(): string { - // Accept either OPENAI_BASE_URL (set by tinyclaw.sh) or a dedicated var. - return (process.env.TINYCLAW_CEREBRAS_BASE_URL || process.env.OPENAI_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, ''); + // Keep Cerebras isolated from OpenAI/Codex env vars to avoid accidental cross-contamination. + return (process.env.TINYCLAW_CEREBRAS_BASE_URL || DEFAULT_BASE_URL).replace(/\/+$/, ''); } function getApiKey(): string { - return process.env.CEREBRAS_API_KEY || process.env.TINYCLAW_OPENAI_API_KEY || process.env.OPENAI_API_KEY || ''; + // Do not fall back to OPENAI_API_KEY: it's for Codex/OpenAI, not Cerebras. + return process.env.CEREBRAS_API_KEY || ''; } function sleep(ms: number): Promise { @@ -134,7 +135,7 @@ export async function cerebrasChatCompletion(opts: { const baseUrl = getBaseUrl(); const apiKey = getApiKey(); if (!apiKey) { - throw new Error('Missing Cerebras API key (set CEREBRAS_API_KEY or TINYCLAW_OPENAI_API_KEY).'); + throw new Error('Missing Cerebras API key (set CEREBRAS_API_KEY).'); } const url = `${baseUrl}/chat/completions`; diff --git a/src/lib/invoke.ts b/src/lib/invoke.ts index a8519bb..5f52134 100644 --- a/src/lib/invoke.ts +++ b/src/lib/invoke.ts @@ -44,6 +44,41 @@ export async function runCommand(command: string, args: string[], cwd?: string): }); } +async function runCommandWithExitCode( + command: string, + args: string[], + cwd?: string +): Promise<{ stdout: string; stderr: string; code: number | null }> { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: cwd || SCRIPT_DIR, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + + child.on('error', (error) => { + reject(error); + }); + + child.on('close', (code) => { + resolve({ stdout, stderr, code }); + }); + }); +} + /** * Invoke a single agent with a message. Contains all Claude/Codex invocation logic. * Returns the raw response text. @@ -108,27 +143,29 @@ export async function invokeAgent( } else 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'); - } + // Avoid `codex exec resume --last`: a corrupted local rollout state can make resume fail + // and break the bot. Stateless `--ephemeral` is more reliable for chat bridges. + const codexArgs = ['--ask-for-approval', 'never', 'exec', '--ephemeral']; if (modelId) { codexArgs.push('--model', modelId); } - codexArgs.push('--skip-git-repo-check', '--dangerously-bypass-approvals-and-sandbox', '--json', message); + codexArgs.push('--skip-git-repo-check', '--sandbox', 'danger-full-access', '--json', message); - const codexOutput = await runCommand('codex', codexArgs, workingDir); + const { stdout: codexStdout, stderr: codexStderr, code } = await runCommandWithExitCode('codex', codexArgs, workingDir); + if (codexStderr.trim()) { + const isRolloutWarn = /state db missing rollout path|codex_core::rollout::list/i.test(codexStderr); + const level = isRolloutWarn ? 'WARN' : (code === 0 ? 'WARN' : 'ERROR'); + log(level as any, `Codex stderr (agent: ${agentId}): ${codexStderr.trim()}`); + } // Parse JSONL output and extract final agent_message let response = ''; - const lines = codexOutput.trim().split('\n'); + const lines = codexStdout.trim().split('\n').filter(Boolean); for (const line of lines) { try { const json = JSON.parse(line); @@ -140,7 +177,11 @@ export async function invokeAgent( } } - return response || 'Sorry, I could not generate a response from Codex.'; + if (response) return response; + if (code && code !== 0) { + throw new Error(codexStderr.trim() || `Codex exited with code ${code}`); + } + return 'Sorry, I could not generate a response from Codex.'; } else { // Default to Claude (Anthropic) log('INFO', `Using Claude provider (agent: ${agentId})`); diff --git a/tinyclaw.sh b/tinyclaw.sh index f3eba6f..d647644 100755 --- a/tinyclaw.sh +++ b/tinyclaw.sh @@ -24,18 +24,10 @@ fi mkdir -p "$LOG_DIR" -# OpenAI-compatible provider overrides -# These env vars let you point the OpenAI/Codex provider at non-OpenAI endpoints (e.g. Cerebras). -# They are applied only for the TinyClaw process. -if [ -z "${TINYCLAW_OPENAI_API_KEY:-}" ] && [ -n "${CEREBRAS_API_KEY:-}" ]; then - export TINYCLAW_OPENAI_API_KEY="$CEREBRAS_API_KEY" -fi -if [ -n "${TINYCLAW_OPENAI_API_KEY:-}" ]; then - export OPENAI_API_KEY="$TINYCLAW_OPENAI_API_KEY" -fi -if [ -n "${TINYCLAW_OPENAI_BASE_URL:-}" ]; then - export OPENAI_BASE_URL="$TINYCLAW_OPENAI_BASE_URL" -fi +# Provider env vars +# Keep Cerebras and OpenAI/Codex environment variables separate to avoid breaking tool-capable CLIs. +# - Cerebras: set CEREBRAS_API_KEY and optional TINYCLAW_CEREBRAS_BASE_URL +# - OpenAI/Codex: set OPENAI_API_KEY (Codex CLI manages its own config/state) # Source library files source "$SCRIPT_DIR/lib/common.sh"