diff --git a/.dockerignore b/.dockerignore index f169f9361..d632da5ea 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,7 +2,7 @@ .gitignore build/ .picoclaw/ -# config/ +config/ .env .env.example *.md diff --git a/.gitignore b/.gitignore index 449d06f8a..457290ce5 100644 --- a/.gitignore +++ b/.gitignore @@ -16,8 +16,10 @@ cmd/**/workspace # PicoClaw .picoclaw/ +pkg/agent/secret.txt config.json sessions/ +logs/ build/ # Coverage diff --git a/.golangci.yaml b/.golangci.yaml index b2b772406..149e4cfae 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -1,4 +1,3 @@ -version: "2" linters: default: all @@ -10,25 +9,21 @@ linters: - dupword - err113 - exhaustruct - - funcorder - gochecknoglobals - godot - intrange - ireturn - nlreturn - noctx - - noinlineerr - nonamedreturns - tagliatelle - testpackage - varnamelen - wrapcheck - wsl - - wsl_v5 # TODO: Disabled, because they are failing at the moment, we should fix them and enable (step by step) - contextcheck - - embeddedstructfieldcheck - errcheck - errchkjson - errorlint @@ -47,7 +42,6 @@ linters: - lll - maintidx - mnd - - modernize - nestif - nilnil - paralleltest diff --git a/Makefile b/Makefile index 5f8c26e1a..2d2e73f11 100644 --- a/Makefile +++ b/Makefile @@ -56,7 +56,8 @@ PTY_PATCH_LOONG64=pty_dir=$$(go env GOMODCACHE)/github.com/creack/pty@v1.1.9; \ fi # Golangci-lint -GOLANGCI_LINT?=golangci-lint +GOLANGCI_LINT_BIN := $(shell if [ -f $(CURDIR)/golangci-lint ]; then echo $(CURDIR)/golangci-lint; else echo golangci-lint; fi) +GOLANGCI_LINT?=$(GOLANGCI_LINT_BIN) # Installation INSTALL_PREFIX?=$(HOME)/.local @@ -293,8 +294,8 @@ update-deps: @$(GO) get -u ./... @$(GO) mod tidy -## check: Run vet, fmt, and verify dependencies -check: deps fmt vet test +## check: Run vet, fmt, lint, and verify dependencies +check: deps fmt vet lint test ## run: Build and run picoclaw run: build diff --git a/TEAMS_ID_MAPPING_ANALYSIS.md b/TEAMS_ID_MAPPING_ANALYSIS.md deleted file mode 100644 index f26b14fed..000000000 --- a/TEAMS_ID_MAPPING_ANALYSIS.md +++ /dev/null @@ -1,363 +0,0 @@ -# Teams Channel Integration & ID Mapping Analysis - -## Executive Summary - -**Teams Channel Implementation Status**: ❌ **NOT YET IMPLEMENTED** -- Search results show no Teams/MSTeams channel in `pkg/channels/` -- Only reference found: migration config reference in `pkg/migrate/sources/openclaw/openclaw_config.go:123` -- **Foundry Integration**: Only implemented as an LLM **provider** (Azure AI Foundry), not as a channel - ---- - -## InboundMessage Structure (Bus Layer) - -**Location**: [pkg/bus/types.go](pkg/bus/types.go) - -### Core Fields Available - -```go -type InboundMessage struct { - Channel string // Channel name (e.g., "teams", "slack", "telegram") - SenderID string // Platform-specific sender identifier - Sender SenderInfo // Structured sender information - ChatID string // Conversation/chat identifier (CRITICAL FOR ISOLATION) - Content string // Message text content - Media []string // Media references (attachments) - Peer Peer // Routing peer information - MessageID string // Platform-specific message ID - MediaScope string // Media lifecycle tracking scope - SessionKey string // Session key (optional, can be auto-resolved) - Metadata map[string]string // Platform-specific metadata -} -``` - -### SenderInfo Sub-structure - -```go -type SenderInfo struct { - Platform string // "telegram", "discord", "slack", "teams", etc. - PlatformID string // Raw platform ID (e.g., Teams UserID "29:...") - CanonicalID string // Normalized "platform:id" format (e.g., "teams:29:...") - Username string // Display username (e.g., "@alice") - DisplayName string // Full display name -} -``` - -### Peer Sub-structure - -```go -type Peer struct { - Kind string // "direct" | "group" | "channel" | "" - ID string // Peer identifier (user_id, group_id, channel_id, etc.) -} -``` - ---- - -## ID Mapping for Hypothetical Teams Implementation - -### What Teams Would Need to Provide - -If Teams were to be integrated, the following IDs should map as follows: - -| Teams ID | InboundMessage Field | Notes | -|----------|----------------------|-------| -| User ID (e.g., `29:1ABC123`) | `SenderID`, `Sender.PlatformID` | Teams uses format `29:uuid` | -| Conversation ID | `ChatID` | CRITICAL: Identifies conversation scope | -| Team ID | `Metadata["team_id"]`, potentially routing input | Can be used for team-level routing | -| Channel ID | `Peer.ID` (if channel) | When in Team channel | -| Service URL | `Metadata["service_url"]` | Teams service endpoint | -| Activity ID | `MessageID` | Platform message identifier | - -### Canonical ID Format - -**Pattern**: `platform:platform_id` - -**Example for Teams**: -``` -"teams:29:1ABC123" = Canonical ID for Teams user 29:1ABC123 -``` - -Built via: [pkg/identity/identity.go](pkg/identity/identity.go) -```go -func BuildCanonicalID(platform, platformID string) string { - p := strings.ToLower(strings.TrimSpace(platform)) - id := strings.TrimSpace(platformID) - if p == "" || id == "" { - return "" - } - return p + ":" + id // "teams:29:abc123" -} -``` - ---- - -## ChatID Usage & Session Isolation - -**Location**: [pkg/agent/loop.go](pkg/agent/loop.go#L1250-L1270) - -### Current ChatID Role - -The `ChatID` field is **THE PRIMARY KEY** for conversation isolation: - -1. **Session Binding**: Each unique `ChatID` can map to a separate session depending on DMScope -2. **Workspace Isolation**: When non-empty and not "direct", creates isolated agent workspace: - ```go - if isolationID != "" && isolationID != "direct" { - // Create transient isolated instance for this chat session - agent = NewAgentInstance(ac, cfg, baseAgent.Provider, isolationID) - } - ``` -3. **State Persistence**: Last ChatID tracked for workspace continuity - -**Example mapping**: -- Single direct message with user → `ChatID = "teams:29:1ABC123"` -- Team channel conversation → `ChatID = "teams-channel:xyz789"` -- Group chat → `ChatID = "teams-groupchat:123abc"` - ---- - -## Session Key Construction & Resolution - -**Location**: [pkg/routing/session_key.go](pkg/routing/session_key.go) + [pkg/routing/route.go](pkg/routing/route.go) - -### RouteInput (What Channel Provides to Router) - -```go -type RouteInput struct { - Channel string // "teams" (if implemented) - AccountID string // Bot account/app ID - Peer *RoutePeer // Who message is from (user) - ParentPeer *RoutePeer // Parent context (e.g., Team) - GuildID string // Guild/workspace ID (if applicable) - TeamID string // Teams Team ID (would go here) -} -``` - -### ResolvedRoute Output - -```go -type ResolvedRoute struct { - AgentID string // Which agent handles this message - SessionKey string // Session identifier pattern - MainSessionKey string // Main session fallback - MatchedBy string // How routing was matched -} -``` - -### Session Key Patterns - -**DMScope** configuration determines how sessions are keyed: - -| DMScope Mode | Format | Example | Use Case | -|--------------|--------|---------|----------| -| `DMScopeMain` | `agent:agentid:main` | `agent:teams-bot:main` | Single shared session | -| `DMScopePerPeer` | `agent:agentid:direct:peerid` | `agent:teams-bot:direct:user123` | Per-user sessions | -| `DMScopePerChannelPeer` | `agent:agentid:channel:direct:peerid` | `agent:teams-bot:teams:direct:user123` | Per-channel-per-user | -| `DMScopePerAccountChannelPeer` | `agent:agentid:channel:account:direct:peerid` | `agent:teams-bot:teams:acct1:direct:user123` | Per-account-channel-user | - -**Location**: [pkg/routing/session_key.go:40-100](pkg/routing/session_key.go#L40-L100) - -```go -// For Teams direct message: -BuildAgentPeerSessionKey(SessionKeyParams{ - AgentID: "teams-bot", - Channel: "teams", - AccountID: "bot-app-id", - Peer: &RoutePeer{Kind: "direct", ID: "29:abc123"}, - DMScope: DMScopePerChannelPeer, -}) -// Returns: "agent:teams-bot:teams:direct:29:abc123" -``` - ---- - -## ID Priority Cascade for Agent Routing - -**Location**: [pkg/routing/route.go:68-126](pkg/routing/route.go#L68-L126) - -The agent resolver uses this **7-level priority**: - -1. **Peer binding** → Match on specific user/peer ID -2. **Parent peer binding** → Match on parent context (Team, Guild, etc.) -3. **Guild binding** → Match on Guild/Workspace ID -4. **Team binding** → Match on Team ID ← **TEAMS WOULD USE THIS** -5. **Account binding** → Match on account/app ID -6. **Channel wildcard** → Match on channel with wildcard -7. **Default agent** → Fallback - -**For Teams, routing would likely use**: -- Level 2: ParentPeer = Team -- Level 3: GuildID = Team ID -- Level 4: TeamID = Team ID - ---- - -## Foundry Integration Status - -**Locations**: -- [pkg/providers/factory_provider.go:196](pkg/providers/factory_provider.go#L196) -- [pkg/providers/openai_compat/provider.go:432](pkg/providers/openai_compat/provider.go#L432) - -### Current Foundry Support - -**Type**: LLM **Provider Only** (NOT Channel) - -```go -case "azure-ai", "azure-foundry": - // Azure AI Foundry / Studio compatible with OpenAI API format - // Used for LLM backend, not message channeling -``` - -**What's Missing for Teams/Foundry Integration**: -- ❌ No Teams Channel handler -- ❌ No Foundry Agent channel integration -- ❌ No Teams webhook receiver -- ❌ No Teams message routing - -**What Exists**: -- ✅ Azure AI Foundry as LLM provider backend -- ✅ OpenAI-compatible API handling -- ✅ Generic inbound message bus infrastructure - ---- - -## Metadata Field Usage - -All channels populate `InboundMessage.Metadata` with platform-specific data: - -### Example: WeCom (for comparison) -**Location**: [pkg/channels/wecom/app.go:605-620](pkg/channels/wecom/app.go#L605-L620) - -```go -metadata := map[string]string{ - "msg_type": msg.MsgType, - "msg_id": fmt.Sprintf("%d", msg.MsgId), - "agent_id": fmt.Sprintf("%d", msg.AgentID), - "platform": "wecom", - "media_id": msg.MediaId, - "create_time": fmt.Sprintf("%d", msg.CreateTime), -} -``` - -### For Teams Implementation, Would Include: - -```go -metadata := map[string]string{ - "team_id": msg.TeamsTeamID, - "channel_id": msg.TeamsChannelID, - "service_url": msg.ServiceURL, - "activity_id": msg.ActivityID, - "conversation_id": msg.ConversationID, - "from_user_id": msg.FromUserID, - "platform": "teams", - ... -} -``` - ---- - -## Identity Matching System - -**Location**: [pkg/identity/identity.go](pkg/identity/identity.go) - -The framework provides legacy-compatible and modern identity matching: - -### Allowed Formats in Config - -```yaml -allow_from: - - "29:abc123" # Raw Teams user ID - - "teams:29:abc123" # Canonical format - - "@alice" # Username format - - "29:abc123|alice" # Compound format -``` - -### Matching Logic - -```go -func MatchAllowed(sender bus.SenderInfo, allowed string) bool { - // 1. Try canonical "platform:id" first - if platform, id, ok := ParseCanonicalID(allowed); ok { - if sender.CanonicalID == BuildCanonicalID(platform, id) { - return true - } - } - - // 2. Fall back to PlatformID or Username - if sender.PlatformID == allowed { return true } - if sender.Username == "@" + allowed { return true } - - return false -} -``` - ---- - -## What a Teams Channel Implementation Would Need - -### Minimum Required Fields in InboundMessage - -```go -InboundMessage{ - Channel: "teams", - SenderID: userID, // Teams: "29:uuid" - Sender: bus.SenderInfo{ - Platform: "teams", - PlatformID: userID, // "29:uuid" - CanonicalID: "teams:29:uuid", - Username: userName, - DisplayName: displayName, - }, - ChatID: conversationID, // Teams ConversationReference.conversation_id - Content: messageContent, - Peer: bus.Peer{ - Kind: "direct" || "channel", - ID: channelID || userID, - }, - MessageID: activityID, // Teams Activity ID - Metadata: map[string]string{ - "team_id": teamID, - "channel_id": channelID, - "service_url": serviceURL, - // ... other Teams-specific fields - }, -} -``` - -### Routing Setup in Config - -```yaml -agents: - routing: - - agent_id: "teams-agent" - match: - channel: "teams" - team_id: "team-xyz" # Route by Teams Team ID -``` - ---- - -## Key Takeaways for Teams + Foundry Integration - -1. **Framework is Ready**: GenericBus message structure can handle Teams IDs -2. **ChatID is Primary**: Use Teams `ConversationReference.conversation_id` as ChatID for isolation -3. **SessionKey Auto-Generated**: Routing + DMScope automatically creates session keys -4. **Identity System Ready**: Canonical "teams:29:uuid" format supported -5. **No Channel Implementation Yet**: Need to implement webhook receiver + message publisher -6. **Foundry is Provider Only**: Currently only LLM backend, not messaging channel -7. **User ID Format**: Teams uses `29:uuid` format - should populate both PlatformID and CanonicalID -8. **Conversation Scope**: Teams conversation_id maps directly to InboundMessage.ChatID - ---- - -## Reference Architecture Files - -| Component | File | Key Types | -|-----------|------|-----------| -| Bus Types | [pkg/bus/types.go](pkg/bus/types.go) | InboundMessage, SenderInfo, Peer | -| Routing | [pkg/routing/route.go](pkg/routing/route.go) | RouteInput, ResolvedRoute | -| Session Keys | [pkg/routing/session_key.go](pkg/routing/session_key.go) | SessionKeyParams, DM scopes | -| Identity | [pkg/identity/identity.go](pkg/identity/identity.go) | BuildCanonicalID, MatchAllowed | -| Agent Loop | [pkg/agent/loop.go](pkg/agent/loop.go) | Message processing, session isolation | -| Example Channel | [pkg/channels/wecom/app.go](pkg/channels/wecom/app.go) | Channel implementation pattern | diff --git a/TEAMS_QUICK_REFERENCE.md b/TEAMS_QUICK_REFERENCE.md deleted file mode 100644 index a789e1c34..000000000 --- a/TEAMS_QUICK_REFERENCE.md +++ /dev/null @@ -1,315 +0,0 @@ -# Quick Reference: Teams Integration Questions - -## Q1: Teams Channel Integration - Message Receiving & Processing - -**Status**: ❌ NOT IMPLEMENTED - -**Where it would go**: `pkg/channels/teams/` (currently doesn't exist) - -**Current Similar Implementation**: See [pkg/channels/wecom/app.go](pkg/channels/wecom/app.go) for webhook pattern - -**Expected Pattern**: -1. HTTP webhook receiver on configured port -2. Verify Teams Bot Framework signature -3. Parse activity/message payload -4. Build `InboundMessage` struct -5. Publish to bus via `channel.HandleMessage()` or `messageBus.PublishInbound()` - -**Key Files to Reference**: -- [pkg/channels/base.go](pkg/channels/base.go) - Base channel interface -- [pkg/channels/manager.go](pkg/channels/manager.go) - Channel registration/lifecycle -- [pkg/channels/wecom/app.go:605-650](pkg/channels/wecom/app.go#L605-L650) - HandleMessage pattern - ---- - -## Q2: InboundMessage Structure - All Available Fields - -**Location**: [pkg/bus/types.go:18-35](pkg/bus/types.go#L18-L35) - -### Complete Field List - -| Field | Type | Purpose | Example | -|-------|------|---------|---------| -| `Channel` | string | Platform identifier | `"teams"` | -| `SenderID` | string | Raw user ID | `"29:1ABC123"` | -| `Sender` | SenderInfo | Structured identity | (see below) | -| `Sender.Platform` | string | Platform name | `"teams"` | -| `Sender.PlatformID` | string | User platform ID | `"29:1ABC123"` | -| `Sender.CanonicalID` | string | **Normalized format** | `"teams:29:1abc123"` | -| `Sender.Username` | string | Handle/username | `"alice"` | -| `Sender.DisplayName` | string | Full display name | `"Alice Smith"` | -| `ChatID` | string | **Conversation ID (PRIMARY)** | `"teams-conv-abc123"` | -| `Content` | string | Message text | `"Hello world"` | -| `Media` | []string | Media references | `["media://ref123"]` | -| `Peer.Kind` | string | Peer type | `"direct"` \| `"channel"` | -| `Peer.ID` | string | Peer ID | User/channel ID | -| `MessageID` | string | Platform message ID | `"activity-123"` | -| `MediaScope` | string | Media cleanup scope | `"teams:conv-abc123:msg-123"` | -| `SessionKey` | string | **Session identifier** | `"agent:bot:teams:direct:29:abc123"` | -| `Metadata` | map | Platform-specific data | (see below) | - -### Metadata Map (Platform-Specific) - -```go -metadata := map[string]string{ - "team_id": "T12345", - "channel_id": "C12345", - "conversation_id": "19:...", - "service_url": "https://smba.trafficmanager.net/...", - "activity_id": "...", - "from_user_id": "29:...", - "from_user_name": "alice", - "recipient_id": "28:...", - "conversation_type": "personal|groupChat|channel", - "platform": "teams", - // ... any other Teams-specific fields -} -``` - ---- - -## Q3: Unique User/Conversation ID Capture from Teams - -### What Teams Provides vs. What PicoClaw Needs - -**Teams → PicoClaw Mapping**: - -``` -Teams Activity Object -├── from.id → SenderID (raw), Sender.PlatformID -├── from.aadObjectId → (optional, use if available) -├── conversation.id → ChatID (THE KEY FIELD) -├── conversation.tenantId → Metadata["tenant_id"] -├── channelData.teamsChannelId → Peer.ID (if channel) -├── channelData.teamsTeamId → Metadata["team_id"], routing input -├── serviceUrl → Metadata["service_url"] -└── id → MessageID -``` - -### ID Construction - -**User Identity Chain**: -``` -Teams: from.id = "29:U123ABC" - ↓ -Stored as: SenderID = "29:U123ABC" -Stored as: Sender.PlatformID = "29:U123ABC" -Normalized as: Sender.CanonicalID = "teams:29:u123abc" (lowercased) -``` - -**Conversation Identity Chain**: -``` -Teams: conversation.id = "19:abc123@thread.v2" - ↓ -Stored as: ChatID = "19:abc123@thread.v2" (conversation scope) -Used for: Session isolation, message routing, state persistence -``` - -**Team Identity Chain**: -``` -Teams: channelData.teamsTeamId = "T12345678" - ↓ -Stored as: Metadata["team_id"] = "T12345678" - ↓ -Used in: Routing cascade (Level 4), agent selection -``` - -### Canonical ID Format - -Built by [pkg/identity/identity.go:BuildCanonicalID()](pkg/identity/identity.go#L11-L20): - -```go -BuildCanonicalID("teams", "29:U123ABC") -// Returns: "teams:29:u123abc" (normalized to lowercase) -``` - -**Used for**: -- Access control matching -- Cross-platform user linking (via identity_links in config) -- User identity validation - ---- - -## Q4: Foundry Agent Integration Points & ID Provision - -**Status**: ⚠️ PARTIAL - Foundry is an LLM Provider, NOT a Channel - -### Current Foundry Support - -**Location**: [pkg/providers/factory_provider.go:196](pkg/providers/factory_provider.go#L196) - -Foundry is integrated **only as LLM backend** (OpenAI-compatible API): - -```go -case "azure-ai", "azure_foundry": - // Use for model calls, not messaging -``` - -**What Foundry Would Provide (if implemented as channel)**: -- Foundry Agent service/conversation IDs -- Foundry user session tracking -- Foundry-specific message format - -**What's MISSING**: -1. ❌ Foundry Agent channel receiver -2. ❌ Foundry conversation → ChatID mapping -3. ❌ Foundry agent ID → Agent routing - -### If Foundry Channel Were to Exist - -Expected `InboundMessage` would be: - -```go -InboundMessage{ - Channel: "foundry-agent", - SenderID: foundryUserID, - Sender: SenderInfo{ - Platform: "foundry", - PlatformID: foundryUserID, - CanonicalID: "foundry:" + foundryUserID, - DisplayName: userName, - }, - ChatID: foundryConversationID, // Critical for isolation - Content: message, - Metadata: map[string]string{ - "foundry_agent_id": agentID, - "foundry_conversation_id": conversationID, - "foundry_message_id": messageID, - "platform": "foundry", - // ... other Foundry fields - }, -} -``` - -### Foundry ID Mapping Table (Hypothetical) - -| Foundry ID | InboundMessage Field | Purpose | -|----------|----------------------|---------| -| Agent ID | Routing/Config | Which agent handles | -| User ID | SenderID | Who sent message | -| Conversation ID | **ChatID** | Session isolation | -| Message ID | MessageID | For threading | -| Service Endpoint | Metadata | For API calls | - ---- - -## Q5: How ChatID is Currently Used for Session ID Association - -**Location**: [pkg/agent/loop.go:1248-1270](pkg/agent/loop.go#L1248-L1270) - -### ChatID → SessionKey Conversion - -**Process**: - -``` -1. InboundMessage arrives with ChatID - ↓ -2. Router resolves agent (via RouteInput) - ↓ -3. SessionKey built from: - - Agent ID - - Channel name - - Peer information (ChatID wrapped as Peer.ID) - - DMScope configuration - ↓ -4. Result: SessionKey = "agent:botname:team:type:id" - ↓ -5. SessionKey used to find/create workspace & history -``` - -### Session Key Patterns by DMScope - -**From config `session.dm_scope`**: - -| Setting | Session Behavior | Key Format | -|---------|------------------|-----------| -| Not set / `main` | Single shared session | `agent:bot:main` | -| `per_peer` | One session per user | `agent:bot:direct:user123` | -| `per_channel_peer` | One per channel+user | `agent:bot:teams:direct:user123` | -| `per_account_channel_peer` | One per account+channel+user | `agent:bot:teams:act1:direct:user123` | - -**Code Reference**: [pkg/routing/session_key.go:40-100](pkg/routing/session_key.go#L40-L100) - -### Session Isolation via ChatID - -When ChatID is unique and non-"direct": - -```go -// From pkg/agent/loop.go:1248-1270 -if isolationID != "" && isolationID != "direct" { - // Creates isolated agent instance with separate: - // - Workspace directory - // - Session history - // - Memory storage - // - State - agent = NewAgentInstance(ac, cfg, baseAgent.Provider, isolationID) -} -``` - -**Isolation Example**: - -``` -ChatID = "teams-channel-abc123" - ↓ -Creates: workspace/teams-channel-abc123/ - ├── sessions/ - ├── memory/ - ├── skills/ - └── state/ - ↓ -Each channel conversation has completely isolated history -``` - -### State Persistence - -Tracks last ChatID: - -```go -// Record last chat for workspace continuity -al.RecordLastChatID(chatID) // pkg/agent/loop.go -``` - -Stored in: `workspace/state/state.json`: -```json -{ - "last_channel": "teams", - "last_chat_id": "19:abc123@thread.v2", - "timestamp": "2025-03-26T10:00:00Z" -} -``` - ---- - -## Summary Table: ID Field Mapping - -| Concept | Field | Example | Used For | -|---------|-------|---------|----------| -| **User** | `SenderID` + `Sender.PlatformID` | `"29:U123ABC"` | Message author | -| **User (Normalized)** | `Sender.CanonicalID` | `"teams:29:u123abc"` | Access control | -| **Conversation** | `ChatID` | `"19:abc123@thread.v2"` | **Session isolation** | -| **Team** | `Metadata["team_id"]` | `"T12345678"` | Agent routing level | -| **Channel** | `Peer.Kind` + `Peer.ID` | `"channel:C12345"` | Routing peer | -| **Message** | `MessageID` | `"activity-123"` | Threading, dedup | -| **Workspace** | Derived from ChatID | `workspace/19:abc123@thread.v2/` | Data isolation | -| **Session** | `SessionKey` | `"agent:bot:teams:direct:29:u123abc"` | History tracking | - ---- - -## File Cross-References - -### For Teams Implementation -- Start: [pkg/channels/manager.go](pkg/channels/manager.go) - Channel registration -- Reference: [pkg/channels/wecom/app.go](pkg/channels/wecom/app.go) - Full implementation pattern -- Base: [pkg/channels/base.go](pkg/channels/base.go) - Handler interface - -### For Routing/Session -- Routing: [pkg/routing/route.go](pkg/routing/route.go) - 7-level cascade -- Keys: [pkg/routing/session_key.go](pkg/routing/session_key.go) - Key building -- Isolation: [pkg/agent/loop.go:1248+](pkg/agent/loop.go#L1248) - ChatID isolation - -### For Identity -- Identity: [pkg/identity/identity.go](pkg/identity/identity.go) - CanonicalID logic -- Matching: Lines 28-100 - Access control matching - -### For State -- State: [pkg/state/state.go](pkg/state/state.go) - LastChatID persistence diff --git a/cmd/picoclaw-launcher-tui/ui/channels.go b/cmd/picoclaw-launcher-tui/ui/channels.go index c976f1fcd..b4cf7e0a7 100644 --- a/cmd/picoclaw-launcher-tui/ui/channels.go +++ b/cmd/picoclaw-launcher-tui/ui/channels.go @@ -145,10 +145,8 @@ func (a *App) showChannelEditForm(configPath, channelName string, existing map[s } updated := make(map[string]any) - if existing != nil { - for k, v := range existing { - updated[k] = v - } + for k, v := range existing { + updated[k] = v } for k, field := range fields { val := field.GetText() diff --git a/cmd/picoclaw/internal/agent/helpers.go b/cmd/picoclaw/internal/agent/helpers.go index 23227d56a..51b292b3f 100644 --- a/cmd/picoclaw/internal/agent/helpers.go +++ b/cmd/picoclaw/internal/agent/helpers.go @@ -132,7 +132,7 @@ func interactiveMode(agentLoop *agent.AgentLoop, sessionKey string) { func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) { reader := bufio.NewReader(os.Stdin) for { - fmt.Print(fmt.Sprintf("%s You: ", internal.Logo)) + fmt.Printf("%s You: ", internal.Logo) line, err := reader.ReadString('\n') if err != nil { if err == io.EOF { diff --git a/docs/api.md b/docs/api.md index af59081cd..1c46a428a 100644 --- a/docs/api.md +++ b/docs/api.md @@ -6,13 +6,13 @@ By default, the gateway listens on `127.0.0.1:18790`. ## 💬 Chat API -The `/chat` (and alias `/cgat`) endpoint allows you to interact with the PicoClaw agent via a simple HTTP interface. This API is designed to be **asynchronous** to avoid timeouts during long-running LLM tasks or tool executions. +The `/chat` endpoint allows you to interact with the PicoClaw agent via a simple HTTP interface. This API is designed to be **asynchronous** to avoid timeouts during long-running LLM tasks or tool executions. ### 1. Initiate a Chat Session (POST) Start a new chat request. -**Endpoint:** `POST /chat` (or `POST /cgat`) +**Endpoint:** `POST /chat` **Content-Type:** `application/json` **Request Body:** @@ -35,7 +35,7 @@ Start a new chat request. Retrieve the status and response of a previously initiated session. -**Endpoint:** `GET /chat?session_id=` (or `GET /cgat?session_id=`) +**Endpoint:** `GET /chat?session_id=` **Possible Responses:** diff --git a/config/config.json.azure b/docs/examples/azure-config.json similarity index 100% rename from config/config.json.azure rename to docs/examples/azure-config.json diff --git a/logs/gateway.log b/logs/gateway.log deleted file mode 100644 index 770d23f8b..000000000 --- a/logs/gateway.log +++ /dev/null @@ -1,2 +0,0 @@ -{"level":"warn","path":"/home/stevef/dev/tomerge/github/picoclaw/config.json","time":"2026-03-24T08:13:49+01:00","caller":"/home/stevef/dev/tomerge/github/picoclaw/pkg/config/config.go:1363","message":"config file not found, using default config"} -{"level":"warn","path":"/home/stevef/dev/tomerge/github/picoclaw/config.json","time":"2026-03-24T08:15:23+01:00","caller":"/home/stevef/dev/tomerge/github/picoclaw/pkg/config/config.go:1363","message":"config file not found, using default config"} diff --git a/logs/gateway_panic.log b/logs/gateway_panic.log deleted file mode 100644 index 67e98bfaf..000000000 --- a/logs/gateway_panic.log +++ /dev/null @@ -1,26 +0,0 @@ -Error: error creating provider: model "" not found in model_list: model "" not found in model_list or providers -Usage: - picoclaw gateway [flags] - -Aliases: - gateway, g - -Flags: - -E, --allow-empty Continue starting even when no default model is configured - -d, --debug Enable debug logging - -h, --help help for gateway - -T, --no-truncate Disable string truncation in debug logs - -Error: error creating provider: model "" not found in model_list: model "" not found in model_list or providers -Usage: - picoclaw gateway [flags] - -Aliases: - gateway, g - -Flags: - -E, --allow-empty Continue starting even when no default model is configured - -d, --debug Enable debug logging - -h, --help help for gateway - -T, --no-truncate Disable string truncation in debug logs - diff --git a/pkg/agent/context.go b/pkg/agent/context.go index 3975a6da7..c325c53ff 100644 --- a/pkg/agent/context.go +++ b/pkg/agent/context.go @@ -342,11 +342,7 @@ func (cb *ContextBuilder) sourceFilesChangedLocked() bool { return true } } - if skillFilesChangedSince(cb.skillRoots(), cb.skillFilesAtCache) { - return true - } - - return false + return skillFilesChangedSince(cb.skillRoots(), cb.skillFilesAtCache) } // fileChangedSince returns true if a tracked source file has been modified, diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 446283ed2..5828546d1 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -67,8 +67,7 @@ type AgentLoop struct { // Agent instance caching for multi-user isolation // Each unique chatID gets its own agent instance to maintain state/model selection - agentCache sync.Map // key: channel:chatID, value: *AgentInstance - agentCacheMu sync.RWMutex + agentCache sync.Map // key: channel:chatID, value: *AgentInstance agentCacheTTL time.Duration // How long to keep cached agents alive agentCleaner *time.Ticker // Periodic cleanup of stale cached agents lastCacheCheck sync.Map // key: channel:chatID, value: time.Time (last access time) @@ -160,6 +159,19 @@ func NewAgentLoop( cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)), } + + al.agentCacheTTL = 24 * time.Hour + cleanInterval := 1 * time.Hour + if cfg.Agents.Defaults.AgentCacheTTLSeconds > 0 { + al.agentCacheTTL = time.Duration(cfg.Agents.Defaults.AgentCacheTTLSeconds) * time.Second + cleanInterval = al.agentCacheTTL / 10 + if cleanInterval < 1*time.Minute { + cleanInterval = 1 * time.Minute + } + } + al.agentCleaner = time.NewTicker(cleanInterval) + go al.agentCacheCleanupLoop() + al.hooks = NewHookManager(eventBus) configureHookManagerFromConfig(al.hooks, cfg) al.contextManager = al.resolveContextManager() @@ -796,6 +808,28 @@ func (al *AgentLoop) UnmountHook(name string) { al.hooks.Unmount(name) } +func (al *AgentLoop) agentCacheCleanupLoop() { + if al.agentCleaner == nil { + return + } + for range al.agentCleaner.C { + now := time.Now() + al.lastCacheCheck.Range(func(key, value any) bool { + lastAccess := value.(time.Time) + if now.Sub(lastAccess) > al.agentCacheTTL { + // Evict stale isolated agent + al.agentCache.Delete(key) + al.lastCacheCheck.Delete(key) + logger.InfoCF("agent", "Evicted stale isolated agent", map[string]any{ + "cache_key": key, + "ttl": al.agentCacheTTL.String(), + }) + } + return true + }) + } +} + // SubscribeEvents registers a subscriber for agent-loop events. func (al *AgentLoop) SubscribeEvents(buffer int) EventSubscription { if al == nil || al.eventBus == nil { diff --git a/pkg/agent/loop_mcp.go b/pkg/agent/loop_mcp.go index b00a9d8a0..8f25c74cd 100644 --- a/pkg/agent/loop_mcp.go +++ b/pkg/agent/loop_mcp.go @@ -30,12 +30,6 @@ func (r *mcpRuntime) setManager(manager *mcp.Manager) { r.mu.Unlock() } -func (r *mcpRuntime) setInitErr(err error) { - r.mu.Lock() - r.initErr = err - r.mu.Unlock() -} - func (r *mcpRuntime) getInitErr() error { r.mu.Lock() defer r.mu.Unlock() diff --git a/pkg/agent/secret.txt b/pkg/agent/secret.txt deleted file mode 100644 index d1af05448..000000000 --- a/pkg/agent/secret.txt +++ /dev/null @@ -1 +0,0 @@ -isolated-content \ No newline at end of file diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 29705e9bf..acc003141 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -1251,7 +1251,7 @@ func (m *Manager) SendToChannel(ctx context.Context, channelName, chatID, conten } // Fallback: direct send (should not happen) - channel, _ := m.channels[channelName] + channel := m.channels[channelName] _, err := channel.Send(ctx, msg) return err } diff --git a/pkg/channels/onebot/onebot.go b/pkg/channels/onebot/onebot.go index 0c59965c1..ef19ca728 100644 --- a/pkg/channels/onebot/onebot.go +++ b/pkg/channels/onebot/onebot.go @@ -824,7 +824,7 @@ func (c *OneBotChannel) parseMessageSegments( case "face": if data != nil { - faceID, _ := data["id"] + faceID := data["id"] textParts = append(textParts, fmt.Sprintf("[face:%v]", faceID)) } diff --git a/pkg/channels/wecom/media.go b/pkg/channels/wecom/media.go index 974a3bf4d..ce75b1121 100644 --- a/pkg/channels/wecom/media.go +++ b/pkg/channels/wecom/media.go @@ -737,9 +737,7 @@ func (c *WeComChannel) uploadOutboundMedia( finishEnv, err := c.sendCommandAck(wecomCommand{ Cmd: wecomCmdUploadMediaEnd, Headers: wecomHeaders{ReqID: randomID(10)}, - Body: wecomUploadMediaFinishBody{ - UploadID: initResp.UploadID, - }, + Body: wecomUploadMediaFinishBody(initResp), }, wecomUploadTimeout) if err != nil { return nil, err diff --git a/pkg/config/config.go b/pkg/config/config.go index 26b15de9e..a1375d36e 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -159,7 +159,7 @@ func (m AgentModelConfig) MarshalJSON() ([]byte, error) { Primary string `json:"primary,omitempty"` Fallbacks []string `json:"fallbacks,omitempty"` } - return json.Marshal(raw{Primary: m.Primary, Fallbacks: m.Fallbacks}) + return json.Marshal(raw(m)) } type AgentConfig struct { @@ -249,6 +249,7 @@ type AgentDefaults struct { SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker ContextManager string `json:"context_manager,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER"` ContextManagerConfig json.RawMessage `json:"context_manager_config,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER_CONFIG"` + AgentCacheTTLSeconds int `json:"agent_cache_ttl_seconds,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_AGENT_CACHE_TTL_SECONDS"` } const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 87dc0c7cb..3351a306a 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -32,7 +32,8 @@ func DefaultConfig() *Config { Enabled: false, MaxArgsLength: 300, }, - SplitOnMarker: false, + SplitOnMarker: false, + AgentCacheTTLSeconds: 86400, // 24 hours }, }, Bindings: []AgentBinding{}, diff --git a/pkg/health/server.go b/pkg/health/server.go index 48ed74bc9..273dc3ba9 100644 --- a/pkg/health/server.go +++ b/pkg/health/server.go @@ -2,11 +2,13 @@ package health import ( "context" + "crypto/subtle" "encoding/json" "fmt" "maps" "net/http" "os" + "strings" "sync" "time" @@ -52,6 +54,7 @@ type Server struct { apiKey string chatResults map[string]*chatStatus chatResultsMu sync.RWMutex + rateLimits sync.Map // key: string (ID or IP), value: time.Time } type Check struct { @@ -82,7 +85,6 @@ func NewServer(host string, port int, token string) *Server { mux.HandleFunc("/ready", s.readyHandler) mux.HandleFunc("/reload", s.reloadHandler) mux.HandleFunc("/chat", s.chatHandler) - mux.HandleFunc("/cgat", s.chatHandler) // Start task cleanup goroutine go s.taskCleanupLoop() @@ -174,17 +176,47 @@ func (s *Server) SetAPIKey(key string) { s.apiKey = key } -func (s *Server) verifyAPIKey(r *http.Request) bool { +// SetAuthToken sets the expected Bearer token. +func (s *Server) SetAuthToken(token string) { + s.mu.Lock() + defer s.mu.Unlock() + s.authToken = token +} + +func (s *Server) verifyAuth(r *http.Request) bool { s.mu.RLock() defer s.mu.RUnlock() - if s.apiKey == "" { + + // If no authentication is configured, allow the request. + if s.apiKey == "" && s.authToken == "" { return true } - return r.Header.Get("X-API-Key") == s.apiKey + + // Check X-API-Key header. + if s.apiKey != "" { + gotKey := r.Header.Get("X-API-Key") + if subtle.ConstantTimeCompare([]byte(gotKey), []byte(s.apiKey)) == 1 { + return true + } + } + + // Check Authorization: Bearer header. + if s.authToken != "" { + authHeader := r.Header.Get("Authorization") + const prefix = "Bearer " + if len(authHeader) > len(prefix) && strings.EqualFold(authHeader[:len(prefix)], prefix) { + gotToken := authHeader[len(prefix):] + if subtle.ConstantTimeCompare([]byte(gotToken), []byte(s.authToken)) == 1 { + return true + } + } + } + + return false } func (s *Server) reloadHandler(w http.ResponseWriter, r *http.Request) { - if !s.verifyAPIKey(r) { + if !s.verifyAuth(r) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(map[string]string{"error": "unauthorized"}) @@ -284,11 +316,6 @@ func (s *Server) RegisterOnMux(mux HandlerMux) { mux.HandleFunc("/ready", s.readyHandler) mux.HandleFunc("/reload", s.reloadHandler) mux.HandleFunc("/chat", s.chatHandler) - mux.HandleFunc("/cgat", s.chatHandler) - mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) { - logger.Error("GATEWAY IS HITTING ITSELF FOR LLM CALLS!") - http.Error(w, "GATEWAY LOOP DETECTION", http.StatusLoopDetected) - }) } // chatHandler handles POST /chat (initiate async) and GET /chat (poll for result). @@ -297,13 +324,20 @@ func (s *Server) RegisterOnMux(mux HandlerMux) { // GET query: ?session_id=... // GET response: {"response": "...", "status": "completed"} func (s *Server) chatHandler(w http.ResponseWriter, r *http.Request) { - if !s.verifyAPIKey(r) { + if !s.verifyAuth(r) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusUnauthorized) json.NewEncoder(w).Encode(ChatResponse{Error: "unauthorized"}) return } + if !s.checkRateLimit(r) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusTooManyRequests) + json.NewEncoder(w).Encode(ChatResponse{Error: "rate limit exceeded"}) + return + } + if r.Method == http.MethodPost { s.handlePostChat(w, r) return @@ -375,6 +409,8 @@ func (s *Server) handlePostChat(w http.ResponseWriter, r *http.Request) { chatID = req.SessionID } } + chatID = s.sanitizeID(chatID) + sessionID = s.sanitizeID(sessionID) if chatID != "" { logger.InfoCF("api", "Resolved isolation ID for request", map[string]any{ @@ -398,6 +434,9 @@ func (s *Server) handlePostChat(w http.ResponseWriter, r *http.Request) { if sessionID == "" { sessionID = fmt.Sprintf("chat-%d", time.Now().UnixNano()) + } else { + // Even if provided, sanitize the user-provided sessionID again to be sure + sessionID = s.sanitizeID(sessionID) } // Initialize status @@ -511,6 +550,45 @@ func (s *Server) taskCleanupLoop() { } } +func (s *Server) sanitizeID(id string) string { + if len(id) > 128 { + id = id[:128] + } + + result := make([]rune, 0, len(id)) + for _, r := range id { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' || r == '-' { + result = append(result, r) + } else { + result = append(result, '_') + } + } + return string(result) +} + +func (s *Server) checkRateLimit(r *http.Request) bool { + // Simple rate limit: 1 request per second per ID or IP + // This is defensive against automated spamming. + key := r.Header.Get("X-PicoClaw-Chat-ID") + if key == "" { + key = r.RemoteAddr + // Strip port if present + if i := strings.LastIndex(key, ":"); i != -1 { + key = key[:i] + } + } + + if val, ok := s.rateLimits.Load(key); ok { + lastAccess := val.(time.Time) + if time.Since(lastAccess) < time.Second { + return false + } + } + + s.rateLimits.Store(key, time.Now()) + return true +} + func statusString(ok bool) string { if ok { return "ok" diff --git a/pkg/health/server_test.go b/pkg/health/server_test.go index c4982fff9..4f64e9416 100644 --- a/pkg/health/server_test.go +++ b/pkg/health/server_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "net/http/httptest" + "strings" "testing" "time" ) @@ -153,6 +154,7 @@ func TestReloadHandler_MethodNotAllowed(t *testing.T) { s := newTestServer() req := httptest.NewRequest(http.MethodGet, "/reload", nil) + req.Header.Set("Authorization", "Bearer test") w := httptest.NewRecorder() s.reloadHandler(w, req) @@ -346,3 +348,77 @@ func TestStatusString(t *testing.T) { } } } + +func TestVerifyAuth(t *testing.T) { + s := &Server{ + apiKey: "api-key", + authToken: "auth-token", + } + + t.Run("Valid X-API-Key", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-API-Key", "api-key") + if !s.verifyAuth(req) { + t.Error("expected true for valid X-API-Key") + } + }) + + t.Run("Valid Bearer Token", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer auth-token") + if !s.verifyAuth(req) { + t.Error("expected true for valid Bearer token") + } + }) + + t.Run("Invalid X-API-Key", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("X-API-Key", "wrong") + if s.verifyAuth(req) { + t.Error("expected false for invalid X-API-Key") + } + }) + + t.Run("Invalid Bearer Token", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Authorization", "Bearer wrong") + if s.verifyAuth(req) { + t.Error("expected false for invalid Bearer token") + } + }) + + t.Run("Empty Headers When Auth Required", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + if s.verifyAuth(req) { + t.Error("expected false for missing auth headers when auth required") + } + }) + + t.Run("No Auth Configuration", func(t *testing.T) { + sNoAuth := &Server{} + req := httptest.NewRequest(http.MethodGet, "/", nil) + if !sNoAuth.verifyAuth(req) { + t.Error("expected true when no auth is configured") + } + }) +} + +func TestSanitizeID(t *testing.T) { + s := &Server{} + tests := []struct { + input string + want string + }{ + {"abc-123_XYZ", "abc-123_XYZ"}, + {"abc/def..path", "abc_def__path"}, + {"very" + strings.Repeat("a", 150), "very" + strings.Repeat("a", 124)}, + {"", ""}, + {"!@#$%^&*()", "__________"}, + } + for _, tt := range tests { + got := s.sanitizeID(tt.input) + if got != tt.want { + t.Errorf("sanitizeID(%q) = %q, want %q", tt.input, got, tt.want) + } + } +} diff --git a/pkg/migrate/sources/openclaw/openclaw_config.go b/pkg/migrate/sources/openclaw/openclaw_config.go index 4436c1861..b17831c4e 100644 --- a/pkg/migrate/sources/openclaw/openclaw_config.go +++ b/pkg/migrate/sources/openclaw/openclaw_config.go @@ -453,27 +453,27 @@ func (c *OpenClawConfig) GetAgents() []OpenClawAgentEntry { } func (c *OpenClawConfig) HasSkills() bool { - return c.Skills != nil && c.Skills.Entries != nil && len(c.Skills.Entries) > 0 + return c.Skills != nil && len(c.Skills.Entries) > 0 } func (c *OpenClawConfig) HasMemory() bool { - return c.Memory != nil && len(c.Memory) > 0 + return len(c.Memory) > 0 } func (c *OpenClawConfig) HasCron() bool { - return c.Cron != nil && len(c.Cron) > 0 + return len(c.Cron) > 0 } func (c *OpenClawConfig) HasHooks() bool { - return c.Hooks != nil && len(c.Hooks) > 0 + return len(c.Hooks) > 0 } func (c *OpenClawConfig) HasSession() bool { - return c.Session != nil && len(c.Session) > 0 + return len(c.Session) > 0 } func (c *OpenClawConfig) HasAuthProfiles() bool { - return c.Auth != nil && c.Auth.Profiles != nil && len(c.Auth.Profiles) > 0 + return c.Auth != nil && len(c.Auth.Profiles) > 0 } func (c *OpenClawConfig) ConvertToPicoClaw(sourceHome string) (*PicoClawConfig, []string, error) { @@ -510,7 +510,7 @@ func (c *OpenClawConfig) ConvertToPicoClaw(sourceHome string) (*PicoClawConfig, continue } cfg.ModelList = append(cfg.ModelList, ModelConfig{ - ModelName: fmt.Sprintf("%s", provName), + ModelName: provName, Model: fmt.Sprintf("%s/%s", provName, provName), APIKey: provCfg.ApiKey, APIBase: provCfg.BaseUrl, diff --git a/pkg/tools/search_tool.go b/pkg/tools/search_tool.go index f41c80d90..e9e648d9c 100644 --- a/pkg/tools/search_tool.go +++ b/pkg/tools/search_tool.go @@ -229,7 +229,7 @@ type bm25CachedEngine struct { func snapshotToSearchDocs(snap HiddenToolSnapshot) []searchDoc { docs := make([]searchDoc, len(snap.Docs)) for i, d := range snap.Docs { - docs[i] = searchDoc{Name: d.Name, Description: d.Description} + docs[i] = searchDoc(d) } return docs } diff --git a/web/backend/api/model_status_test.go b/web/backend/api/model_status_test.go index d5463a856..36e1344bf 100644 --- a/web/backend/api/model_status_test.go +++ b/web/backend/api/model_status_test.go @@ -337,7 +337,7 @@ func TestProbeLocalModelAvailability_DeduplicatesInflightProbe(t *testing.T) { results := make(chan bool, workers) workerStarted := make(chan struct{}, workers) - for range workers { + for i := 0; i < workers; i++ { wg.Add(1) go func() { defer wg.Done() @@ -346,7 +346,7 @@ func TestProbeLocalModelAvailability_DeduplicatesInflightProbe(t *testing.T) { }() } - for range workers { + for i := 0; i < workers; i++ { <-workerStarted } diff --git a/web/backend/api/version.go b/web/backend/api/version.go index 6232b989b..e690a7ee5 100644 --- a/web/backend/api/version.go +++ b/web/backend/api/version.go @@ -76,7 +76,7 @@ func (h *Handler) handleGetVersion(w http.ResponseWriter, r *http.Request) { // resolveSystemVersionInfo prefers the actual picoclaw binary version output, // and falls back to launcher build metadata when command execution fails. func (h *Handler) resolveSystemVersionInfo(ctx context.Context) systemVersionResponse { - for range maxVersionResolveAttempts { + for i := 0; i < maxVersionResolveAttempts; i++ { gatewayPID, gatewayAlive := currentGatewayVersionState() if cached, ok := versionInfoCache.get(gatewayPID, gatewayAlive); ok { return cached diff --git a/web/backend/main.go b/web/backend/main.go index 5e9f3315f..bf07f2440 100644 --- a/web/backend/main.go +++ b/web/backend/main.go @@ -353,14 +353,8 @@ func main() { signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) // Main event loop - wait for signals or config changes - for { - select { - case <-sigChan: - logger.Info("Shutting down...") - - return - } - } + <-sigChan + logger.Info("Shutting down...") } else { // GUI mode: start system tray runTray() diff --git a/workspace/HEARTBEAT.md b/workspace/HEARTBEAT.md deleted file mode 100644 index 9a4e3ca80..000000000 --- a/workspace/HEARTBEAT.md +++ /dev/null @@ -1,22 +0,0 @@ -# Heartbeat Check List - -This file contains tasks for the heartbeat service to check periodically. - -## Examples - -- Check for unread messages -- Review upcoming calendar events -- Check device status (e.g., MaixCam) - -## Instructions - -- Execute ALL tasks listed below. Do NOT skip any task. -- For simple tasks (e.g., report current time), respond directly. -- For complex tasks that may take time, use the spawn tool to create a subagent. -- The spawn tool is async - subagent results will be sent to the user automatically. -- After spawning a subagent, CONTINUE to process remaining tasks. -- Only respond with HEARTBEAT_OK when ALL tasks are done AND nothing needs attention. - ---- - -Add your heartbeat tasks below this line: diff --git a/workspace/cron/jobs.json b/workspace/cron/jobs.json deleted file mode 100644 index b8cdc503b..000000000 --- a/workspace/cron/jobs.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "version": 1, - "jobs": [] -} \ No newline at end of file