# Multi-Workspace Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task. **Goal:** Enable a single cc-connect bot to serve multiple workspaces, routing messages to different Claude Code sessions based on Slack channel. **Architecture:** Engine-level multiplexing. Add workspace resolution to Engine.handleMessage that maps channel → workspace directory, spawns/resumes per-workspace agent subprocesses, and manages idle reaping. Gated behind `mode = "multi-workspace"` in ProjectConfig so existing single-workspace projects are unaffected. **Tech Stack:** Go, Slack API (conversations.info for channel name resolution), JSON file persistence **Design Doc:** `docs/plans/2026-03-12-multi-workspace-design.md` --- ### Task 1: Add config fields **Files:** - Modify: `config/config.go` (ProjectConfig struct ~line 103, validate() ~line 177) - Modify: `config.example.toml` (add multi-workspace example) **Step 1: Add Mode and BaseDir to ProjectConfig** In `config/config.go`, add two fields to ProjectConfig (after line 105): ```go type ProjectConfig struct { Name string `toml:"name"` Mode string `toml:"mode,omitempty"` // "" or "multi-workspace" BaseDir string `toml:"base_dir,omitempty"` // parent dir for workspaces Agent AgentConfig `toml:"agent"` Platforms []PlatformConfig `toml:"platforms"` Quiet *bool `toml:"quiet,omitempty"` DisabledCommands []string `toml:"disabled_commands,omitempty"` } ``` **Step 2: Add validation for multi-workspace mode** In `config/config.go` validate(), after the existing platform checks (~line 196), add: ```go if proj.Mode == "multi-workspace" { if proj.BaseDir == "" { return fmt.Errorf("project %q: multi-workspace mode requires base_dir", proj.Name) } if _, ok := proj.Agent.Options["work_dir"]; ok { return fmt.Errorf("project %q: multi-workspace mode conflicts with agent work_dir (use base_dir instead)", proj.Name) } } ``` **Step 3: Add example to config.example.toml** Add a commented multi-workspace example section showing the config pattern from the design doc. **Step 4: Run existing tests** Run: `go build ./...` Expected: Compiles cleanly **Step 5: Commit** ```bash git add config/config.go config.example.toml git commit -m "feat: add multi-workspace mode and base_dir config fields" ``` --- ### Task 2: Workspace binding persistence **Files:** - Create: `core/workspace_binding.go` - Create: `core/workspace_binding_test.go` **Step 1: Write tests for WorkspaceBindingManager** ```go package core import ( "os" "path/filepath" "testing" ) func TestWorkspaceBindingManager_SaveLoad(t *testing.T) { dir := t.TempDir() storePath := filepath.Join(dir, "bindings.json") mgr := NewWorkspaceBindingManager(storePath) mgr.Bind("project:claude", "C123", "my-channel", "/home/user/workspace/my-channel") b := mgr.Lookup("project:claude", "C123") if b == nil { t.Fatal("expected binding, got nil") } if b.ChannelName != "my-channel" { t.Errorf("expected channel name 'my-channel', got %q", b.ChannelName) } if b.Workspace != "/home/user/workspace/my-channel" { t.Errorf("expected workspace path, got %q", b.Workspace) } // Reload from disk mgr2 := NewWorkspaceBindingManager(storePath) b2 := mgr2.Lookup("project:claude", "C123") if b2 == nil { t.Fatal("expected binding after reload, got nil") } if b2.Workspace != "/home/user/workspace/my-channel" { t.Errorf("expected workspace path after reload, got %q", b2.Workspace) } } func TestWorkspaceBindingManager_Unbind(t *testing.T) { dir := t.TempDir() storePath := filepath.Join(dir, "bindings.json") mgr := NewWorkspaceBindingManager(storePath) mgr.Bind("project:claude", "C123", "chan", "/path") mgr.Unbind("project:claude", "C123") if b := mgr.Lookup("project:claude", "C123"); b != nil { t.Error("expected nil after unbind") } } func TestWorkspaceBindingManager_ListByProject(t *testing.T) { dir := t.TempDir() mgr := NewWorkspaceBindingManager(filepath.Join(dir, "bindings.json")) mgr.Bind("project:claude", "C1", "chan1", "/path1") mgr.Bind("project:claude", "C2", "chan2", "/path2") mgr.Bind("project:other", "C3", "chan3", "/path3") list := mgr.ListByProject("project:claude") if len(list) != 2 { t.Errorf("expected 2 bindings, got %d", len(list)) } } ``` **Step 2: Run tests to verify they fail** Run: `go test ./core/ -run TestWorkspaceBinding -v` Expected: FAIL (types not defined) **Step 3: Implement WorkspaceBindingManager** ```go package core import ( "encoding/json" "log/slog" "os" "sync" "time" ) // WorkspaceBinding maps a channel to a workspace directory. type WorkspaceBinding struct { ChannelName string `json:"channel_name"` Workspace string `json:"workspace"` BoundAt time.Time `json:"bound_at"` } // WorkspaceBindingManager persists channel→workspace mappings. // Top-level key is "project:", second-level key is channel ID. type WorkspaceBindingManager struct { mu sync.RWMutex bindings map[string]map[string]*WorkspaceBinding // projectKey → channelID → binding storePath string } func NewWorkspaceBindingManager(storePath string) *WorkspaceBindingManager { m := &WorkspaceBindingManager{ bindings: make(map[string]map[string]*WorkspaceBinding), storePath: storePath, } if storePath != "" { m.load() } return m } func (m *WorkspaceBindingManager) Bind(projectKey, channelID, channelName, workspace string) { m.mu.Lock() defer m.mu.Unlock() if m.bindings[projectKey] == nil { m.bindings[projectKey] = make(map[string]*WorkspaceBinding) } m.bindings[projectKey][channelID] = &WorkspaceBinding{ ChannelName: channelName, Workspace: workspace, BoundAt: time.Now(), } m.saveLocked() } func (m *WorkspaceBindingManager) Unbind(projectKey, channelID string) { m.mu.Lock() defer m.mu.Unlock() if proj := m.bindings[projectKey]; proj != nil { delete(proj, channelID) if len(proj) == 0 { delete(m.bindings, projectKey) } } m.saveLocked() } func (m *WorkspaceBindingManager) Lookup(projectKey, channelID string) *WorkspaceBinding { m.mu.RLock() defer m.mu.RUnlock() if proj := m.bindings[projectKey]; proj != nil { return proj[channelID] } return nil } func (m *WorkspaceBindingManager) ListByProject(projectKey string) map[string]*WorkspaceBinding { m.mu.RLock() defer m.mu.RUnlock() result := make(map[string]*WorkspaceBinding) if proj := m.bindings[projectKey]; proj != nil { for k, v := range proj { result[k] = v } } return result } func (m *WorkspaceBindingManager) saveLocked() { if m.storePath == "" { return } data, err := json.MarshalIndent(m.bindings, "", " ") if err != nil { slog.Error("workspace bindings: marshal error", "err", err) return } if err := AtomicWriteFile(m.storePath, data, 0o644); err != nil { slog.Error("workspace bindings: save error", "err", err) } } func (m *WorkspaceBindingManager) load() { data, err := os.ReadFile(m.storePath) if err != nil { if !os.IsNotExist(err) { slog.Error("workspace bindings: load error", "err", err) } return } if err := json.Unmarshal(data, &m.bindings); err != nil { slog.Error("workspace bindings: unmarshal error", "err", err) } } ``` Note: This follows the exact same pattern as `core/relay.go` RelayManager persistence. **Step 4: Run tests to verify they pass** Run: `go test ./core/ -run TestWorkspaceBinding -v` Expected: PASS **Step 5: Commit** ```bash git add core/workspace_binding.go core/workspace_binding_test.go git commit -m "feat: add WorkspaceBindingManager for channel-to-workspace persistence" ``` --- ### Task 3: Workspace state and idle reaper **Files:** - Create: `core/workspace_state.go` - Create: `core/workspace_state_test.go` **Step 1: Write tests for workspacePool** ```go package core import ( "context" "testing" "time" ) func TestWorkspacePool_GetOrCreate(t *testing.T) { pool := newWorkspacePool(15 * time.Minute) state1 := pool.GetOrCreate("/workspace/a") state2 := pool.GetOrCreate("/workspace/a") state3 := pool.GetOrCreate("/workspace/b") if state1 != state2 { t.Error("expected same state for same workspace") } if state1 == state3 { t.Error("expected different state for different workspace") } } func TestWorkspacePool_Touch(t *testing.T) { pool := newWorkspacePool(15 * time.Minute) state := pool.GetOrCreate("/workspace/a") before := state.LastActivity() time.Sleep(10 * time.Millisecond) state.Touch() after := state.LastActivity() if !after.After(before) { t.Error("expected lastActivity to advance after Touch()") } } func TestWorkspacePool_ReapIdle(t *testing.T) { pool := newWorkspacePool(50 * time.Millisecond) pool.GetOrCreate("/workspace/a") time.Sleep(100 * time.Millisecond) reaped := pool.ReapIdle() if len(reaped) != 1 || reaped[0] != "/workspace/a" { t.Errorf("expected [/workspace/a] reaped, got %v", reaped) } if s := pool.Get("/workspace/a"); s != nil { t.Error("expected workspace removed after reap") } } ``` **Step 2: Run tests to verify they fail** Run: `go test ./core/ -run TestWorkspacePool -v` Expected: FAIL **Step 3: Implement workspacePool and workspaceState** ```go package core import ( "sync" "time" ) // workspaceState holds the runtime state for a single workspace. type workspaceState struct { mu sync.Mutex workspace string sessions *SessionManager lastActivity time.Time } func newWorkspaceState(workspace string, sessions *SessionManager) *workspaceState { return &workspaceState{ workspace: workspace, sessions: sessions, lastActivity: time.Now(), } } func (ws *workspaceState) Touch() { ws.mu.Lock() ws.lastActivity = time.Now() ws.mu.Unlock() } func (ws *workspaceState) LastActivity() time.Time { ws.mu.Lock() defer ws.mu.Unlock() return ws.lastActivity } // workspacePool manages a set of workspace states with idle reaping. type workspacePool struct { mu sync.RWMutex states map[string]*workspaceState // workspace path → state idleTimeout time.Duration } func newWorkspacePool(idleTimeout time.Duration) *workspacePool { return &workspacePool{ states: make(map[string]*workspaceState), idleTimeout: idleTimeout, } } func (p *workspacePool) Get(workspace string) *workspaceState { p.mu.RLock() defer p.mu.RUnlock() return p.states[workspace] } func (p *workspacePool) GetOrCreate(workspace string) *workspaceState { p.mu.Lock() defer p.mu.Unlock() if s, ok := p.states[workspace]; ok { return s } s := newWorkspaceState(workspace, nil) // SessionManager set later by Engine p.states[workspace] = s return s } // ReapIdle removes and returns workspace paths that have been idle longer than idleTimeout. func (p *workspacePool) ReapIdle() []string { p.mu.Lock() defer p.mu.Unlock() cutoff := time.Now().Add(-p.idleTimeout) var reaped []string for path, state := range p.states { if state.LastActivity().Before(cutoff) { reaped = append(reaped, path) delete(p.states, path) } } return reaped } func (p *workspacePool) All() map[string]*workspaceState { p.mu.RLock() defer p.mu.RUnlock() result := make(map[string]*workspaceState, len(p.states)) for k, v := range p.states { result[k] = v } return result } ``` **Step 4: Run tests to verify they pass** Run: `go test ./core/ -run TestWorkspacePool -v` Expected: PASS **Step 5: Commit** ```bash git add core/workspace_state.go core/workspace_state_test.go git commit -m "feat: add workspacePool for managing per-workspace agent state" ``` --- ### Task 4: Channel name resolution in Slack platform **Files:** - Modify: `platform/slack/slack.go` (~line 25 replyContext, ~line 102 handleEvent) - Modify: `core/interfaces.go` (Platform interface — check if ChannelNameResolver needed) **Step 1: Add ChannelNameResolver interface** In `core/interfaces.go`, add a new optional interface: ```go // ChannelNameResolver is an optional interface for platforms that can resolve // channel IDs to human-readable names. type ChannelNameResolver interface { ResolveChannelName(channelID string) (string, error) } ``` **Step 2: Implement in Slack platform** In `platform/slack/slack.go`, add a channel name cache and resolver: ```go // Add to Platform struct: channelNameCache map[string]string channelCacheMu sync.RWMutex // Initialize in New() or Start(): p.channelNameCache = make(map[string]string) // Add method: func (p *Platform) ResolveChannelName(channelID string) (string, error) { p.channelCacheMu.RLock() if name, ok := p.channelNameCache[channelID]; ok { p.channelCacheMu.RUnlock() return name, nil } p.channelCacheMu.RUnlock() info, err := p.client.GetConversationInfo(&slack.GetConversationInfoInput{ ChannelID: channelID, }) if err != nil { return "", fmt.Errorf("slack: resolve channel name for %s: %w", channelID, err) } p.channelCacheMu.Lock() p.channelNameCache[channelID] = info.Name p.channelCacheMu.Unlock() return info.Name, nil } ``` **Step 3: Build to verify compilation** Run: `go build ./...` Expected: Compiles cleanly **Step 4: Commit** ```bash git add core/interfaces.go platform/slack/slack.go git commit -m "feat: add ChannelNameResolver interface and Slack implementation" ``` --- ### Task 5: Engine multi-workspace fields and constructor **Files:** - Modify: `core/engine.go` (Engine struct ~line 119, NewEngine ~line 200) **Step 1: Add multi-workspace fields to Engine struct** After `eventIdleTimeout` (~line 168), add: ```go // Multi-workspace mode multiWorkspace bool baseDir string workspaceBindings *WorkspaceBindingManager workspacePool *workspacePool initFlows map[string]*workspaceInitFlow // channelID → init state initFlowsMu sync.Mutex ``` Add the init flow state struct: ```go type workspaceInitFlow struct { state string // "awaiting_url", "awaiting_confirm" repoURL string cloneTo string } ``` **Step 2: Add SetMultiWorkspace method** ```go func (e *Engine) SetMultiWorkspace(baseDir, bindingStorePath string) { e.multiWorkspace = true e.baseDir = baseDir e.workspaceBindings = NewWorkspaceBindingManager(bindingStorePath) e.workspacePool = newWorkspacePool(15 * time.Minute) e.initFlows = make(map[string]*workspaceInitFlow) go e.runIdleReaper() } ``` **Step 3: Implement idle reaper goroutine** ```go func (e *Engine) runIdleReaper() { ticker := time.NewTicker(1 * time.Minute) defer ticker.Stop() for { select { case <-e.ctx.Done(): return case <-ticker.C: reaped := e.workspacePool.ReapIdle() for _, ws := range reaped { // Stop interactive states for this workspace e.interactiveMu.Lock() for key, state := range e.interactiveStates { if state.workspaceDir == ws { if state.agentSession != nil { state.agentSession.Close() } delete(e.interactiveStates, key) } } e.interactiveMu.Unlock() slog.Info("workspace idle-reaped", "workspace", ws) } } } } ``` Note: This requires adding `workspaceDir string` to the `interactiveState` struct (~line 174). **Step 4: Build to verify compilation** Run: `go build ./...` Expected: Compiles cleanly **Step 5: Commit** ```bash git add core/engine.go git commit -m "feat: add multi-workspace fields, SetMultiWorkspace, and idle reaper to Engine" ``` --- ### Task 6: Workspace resolution logic **Files:** - Modify: `core/engine.go` **Step 1: Implement resolveWorkspace method** Add this method to Engine. It returns the workspace path or empty string if init flow is needed: ```go // resolveWorkspace resolves a channel to a workspace directory. // Returns (workspacePath, channelName, error). // If workspacePath is empty, the init flow should be triggered. func (e *Engine) resolveWorkspace(p Platform, channelID string) (string, string, error) { projectKey := "project:" + e.name // Step 1: Check existing binding if b := e.workspaceBindings.Lookup(projectKey, channelID); b != nil { // Verify workspace directory still exists if _, err := os.Stat(b.Workspace); err != nil { slog.Warn("bound workspace directory missing, removing binding", "workspace", b.Workspace, "channel", channelID) e.workspaceBindings.Unbind(projectKey, channelID) return "", b.ChannelName, nil } return b.Workspace, b.ChannelName, nil } // Step 2: Resolve channel name for convention match channelName := "" if resolver, ok := p.(ChannelNameResolver); ok { name, err := resolver.ResolveChannelName(channelID) if err != nil { slog.Warn("failed to resolve channel name", "channel", channelID, "err", err) } else { channelName = name } } if channelName == "" { return "", "", nil } // Step 3: Convention match — check if base_dir/ exists candidate := filepath.Join(e.baseDir, channelName) if info, err := os.Stat(candidate); err == nil && info.IsDir() { // Auto-bind with feedback e.workspaceBindings.Bind(projectKey, channelID, channelName, candidate) slog.Info("workspace auto-bound by convention", "channel", channelName, "workspace", candidate) return candidate, channelName, nil } return "", channelName, nil } ``` **Step 2: Build to verify** Run: `go build ./...` Expected: Compiles cleanly **Step 3: Commit** ```bash git add core/engine.go git commit -m "feat: add resolveWorkspace method for channel-to-directory mapping" ``` --- ### Task 7: Init flow conversation handler **Files:** - Modify: `core/engine.go` **Step 1: Implement handleInitFlow** This handles the conversational flow when no workspace is bound for a channel: ```go // handleWorkspaceInitFlow manages the conversational workspace setup. // Returns true if the message was consumed by the init flow. func (e *Engine) handleWorkspaceInitFlow(p Platform, msg *Message, channelID, channelName string) bool { e.initFlowsMu.Lock() flow, exists := e.initFlows[channelID] e.initFlowsMu.Unlock() content := strings.TrimSpace(msg.Content) if !exists { // Check if user is providing a /workspace init command — handled elsewhere if strings.HasPrefix(content, "/") { return false } // Start new init flow e.initFlowsMu.Lock() e.initFlows[channelID] = &workspaceInitFlow{state: "awaiting_url"} e.initFlowsMu.Unlock() e.reply(p, msg.ReplyCtx, fmt.Sprintf( "No workspace found for this channel. Send me a git repo URL to clone, or use `/workspace init `.")) return true } switch flow.state { case "awaiting_url": // Validate it looks like a git URL if !looksLikeGitURL(content) { e.reply(p, msg.ReplyCtx, "That doesn't look like a git URL. Please provide a URL like `https://github.com/org/repo` or `git@github.com:org/repo.git`.") return true } repoName := extractRepoName(content) cloneTo := filepath.Join(e.baseDir, repoName) e.initFlowsMu.Lock() flow.repoURL = content flow.cloneTo = cloneTo flow.state = "awaiting_confirm" e.initFlowsMu.Unlock() e.reply(p, msg.ReplyCtx, fmt.Sprintf( "I'll clone `%s` to `%s` and bind it to this channel. OK? (yes/no)", content, cloneTo)) return true case "awaiting_confirm": lower := strings.ToLower(content) if lower != "yes" && lower != "y" { e.initFlowsMu.Lock() delete(e.initFlows, channelID) e.initFlowsMu.Unlock() e.reply(p, msg.ReplyCtx, "Cancelled. Send a repo URL anytime to try again.") return true } e.reply(p, msg.ReplyCtx, fmt.Sprintf("Cloning `%s` to `%s`...", flow.repoURL, flow.cloneTo)) if err := gitClone(flow.repoURL, flow.cloneTo); err != nil { e.initFlowsMu.Lock() delete(e.initFlows, channelID) e.initFlowsMu.Unlock() e.reply(p, msg.ReplyCtx, fmt.Sprintf("Clone failed: %v\nSend a repo URL to try again.", err)) return true } projectKey := "project:" + e.name e.workspaceBindings.Bind(projectKey, channelID, channelName, flow.cloneTo) e.initFlowsMu.Lock() delete(e.initFlows, channelID) e.initFlowsMu.Unlock() e.reply(p, msg.ReplyCtx, fmt.Sprintf( "Clone complete. Bound workspace `%s` to this channel. Ready.", flow.cloneTo)) return true } return false } func looksLikeGitURL(s string) bool { return strings.HasPrefix(s, "https://") || strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "git@") || strings.HasPrefix(s, "ssh://") } func extractRepoName(url string) string { // Handle both https://github.com/org/repo.git and git@github.com:org/repo.git url = strings.TrimSuffix(url, ".git") parts := strings.Split(url, "/") if len(parts) > 0 { return parts[len(parts)-1] } // Handle git@ format if idx := strings.LastIndex(url, ":"); idx != -1 { remainder := url[idx+1:] parts = strings.Split(remainder, "/") if len(parts) > 0 { return parts[len(parts)-1] } } return "workspace" } func gitClone(repoURL, dest string) error { cmd := exec.Command("git", "clone", repoURL, dest) output, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("%s: %w", strings.TrimSpace(string(output)), err) } return nil } ``` **Step 2: Build to verify** Run: `go build ./...` Expected: Compiles cleanly **Step 3: Commit** ```bash git add core/engine.go git commit -m "feat: add workspace init flow for cloning repos and binding channels" ``` --- ### Task 8: Wire multi-workspace routing into handleMessage **Files:** - Modify: `core/engine.go` (handleMessage ~line 574, getOrCreateInteractiveState) **Step 1: Add workspace routing at the top of handleMessage** After the banned words check (~line 611) and before command dispatch (~line 613), insert multi-workspace resolution: ```go // Multi-workspace resolution var resolvedWorkspace string if e.multiWorkspace { channelID := extractChannelID(msg.SessionKey) workspace, channelName, err := e.resolveWorkspace(p, channelID) if err != nil { slog.Error("workspace resolution failed", "err", err) e.reply(p, msg.ReplyCtx, fmt.Sprintf("Workspace resolution error: %v", err)) return } if workspace == "" { // No workspace — handle init flow (unless it's a /workspace command) if !strings.HasPrefix(content, "/workspace") { if e.handleWorkspaceInitFlow(p, msg, channelID, channelName) { return } } // If init flow didn't consume and no workspace, only /workspace commands work if !strings.HasPrefix(content, "/workspace") { return } } else { resolvedWorkspace = workspace // Touch the workspace pool for idle tracking if ws := e.workspacePool.Get(workspace); ws != nil { ws.Touch() } // Auto-bind feedback for convention matches (first message only) if ws := e.workspacePool.Get(workspace); ws == nil { e.reply(p, msg.ReplyCtx, fmt.Sprintf( "Found `%s` matching this channel. Binding workspace and starting session... Ready.", workspace)) } } } ``` **Step 2: Add extractChannelID helper** ```go func extractChannelID(sessionKey string) string { // Format: "platform:channelID:userID" or "platform:channelID" parts := strings.SplitN(sessionKey, ":", 3) if len(parts) >= 2 { return parts[1] } return "" } ``` **Step 3: Modify getOrCreateInteractiveState for multi-workspace** The existing `getOrCreateInteractiveState` method creates agent sessions using `e.agent`. For multi-workspace, it needs to create an agent session with the resolved workspace's `work_dir`. Find `getOrCreateInteractiveState` and modify it: - Add `resolvedWorkspace` parameter - When `e.multiWorkspace` is true, use a workspace-scoped key and pass the workspace dir to the agent - Key `interactiveStates` by `workspace + ":" + sessionKey` instead of just `sessionKey` - Set `state.workspaceDir` on the interactive state This requires the Agent interface to support dynamic work dirs. Add a method: ```go // In core/interfaces.go, add optional interface: type WorkDirSetter interface { SetWorkDir(dir string) } ``` And in `agent/claudecode/claudecode.go`: ```go func (a *Agent) SetWorkDir(dir string) { a.mu.Lock() defer a.mu.Unlock() a.workDir = dir } func (a *Agent) GetWorkDir() string { a.mu.Lock() defer a.mu.Unlock() return a.workDir } ``` However, since a single Agent instance is shared, we can't just SetWorkDir — it would race. Instead, for multi-workspace we need **per-workspace Agent instances**. Store them in the workspaceState: Add to `workspaceState`: ```go type workspaceState struct { mu sync.Mutex workspace string sessions *SessionManager agent Agent // per-workspace agent clone lastActivity time.Time } ``` Add a method to Engine for creating per-workspace agents: ```go func (e *Engine) getOrCreateWorkspaceAgent(workspace string) (Agent, *SessionManager, error) { ws := e.workspacePool.GetOrCreate(workspace) ws.mu.Lock() defer ws.mu.Unlock() if ws.agent != nil { return ws.agent, ws.sessions, nil } // Create agent clone with this workspace's work_dir agentOpts := make(map[string]any) // Copy relevant options from the original agent if it's a claudecode agent if wds, ok := e.agent.(interface{ Options() map[string]any }); ok { for k, v := range wds.Options() { agentOpts[k] = v } } agentOpts["work_dir"] = workspace agent, err := CreateAgent("claudecode", agentOpts) if err != nil { return nil, nil, fmt.Errorf("create workspace agent: %w", err) } // Wire providers if original agent has them if ps, ok := e.agent.(ProviderSwitcher); ok { if ps2, ok := agent.(ProviderSwitcher); ok { ps2.SetProviders(ps.GetProviders()) } } // Create per-workspace session manager sessionFile := filepath.Join(filepath.Dir(e.sessions.storePath), fmt.Sprintf("%s_ws_%x.json", e.name, sha256Short(workspace))) sessions := NewSessionManager(sessionFile) ws.agent = agent ws.sessions = sessions return agent, sessions, nil } func sha256Short(s string) string { h := sha256.Sum256([]byte(s)) return hex.EncodeToString(h[:4]) } ``` **Step 4: Update processInteractiveMessage to use workspace agent** In the multi-workspace path, before calling `getOrCreateInteractiveState`, swap in the workspace's agent and session manager. The cleanest approach: modify `processInteractiveMessage` to accept optional overrides, or modify `getOrCreateInteractiveState` to accept an agent parameter. Modify `getOrCreateInteractiveState` signature to optionally accept a workspace agent: ```go func (e *Engine) getOrCreateInteractiveState(sessionKey string, p Platform, replyCtx any, session *Session, agent ...Agent) *interactiveState { // ... existing logic, but use agent[0] if provided instead of e.agent } ``` **Step 5: Build to verify** Run: `go build ./...` Expected: Compiles cleanly **Step 6: Commit** ```bash git add core/engine.go core/interfaces.go core/workspace_state.go agent/claudecode/claudecode.go git commit -m "feat: wire multi-workspace routing into handleMessage with per-workspace agents" ``` --- ### Task 9: Workspace commands **Files:** - Modify: `core/engine.go` (handleCommand ~line 1320) **Step 1: Add workspace command handler** In the `handleCommand` switch statement, add cases for workspace commands: ```go case "workspace": if !e.multiWorkspace { e.reply(p, msg.ReplyCtx, "Workspace commands are only available in multi-workspace mode.") return true } e.handleWorkspaceCommand(p, msg, args) return true ``` **Step 2: Implement handleWorkspaceCommand** ```go func (e *Engine) handleWorkspaceCommand(p Platform, msg *Message, args string) { channelID := extractChannelID(msg.SessionKey) projectKey := "project:" + e.name parts := strings.Fields(args) subCmd := "" if len(parts) > 0 { subCmd = parts[0] } switch subCmd { case "": // Show current binding b := e.workspaceBindings.Lookup(projectKey, channelID) if b == nil { e.reply(p, msg.ReplyCtx, "No workspace bound to this channel.") } else { e.reply(p, msg.ReplyCtx, fmt.Sprintf("Workspace: `%s`\nBound: %s", b.Workspace, b.BoundAt.Format(time.RFC3339))) } case "init": if len(parts) < 2 { e.reply(p, msg.ReplyCtx, "Usage: `/workspace init `") return } repoURL := parts[1] if !looksLikeGitURL(repoURL) { e.reply(p, msg.ReplyCtx, "That doesn't look like a git URL.") return } repoName := extractRepoName(repoURL) cloneTo := filepath.Join(e.baseDir, repoName) // Check if already exists if _, err := os.Stat(cloneTo); err == nil { // Directory exists, just bind channelName := "" if resolver, ok := p.(ChannelNameResolver); ok { channelName, _ = resolver.ResolveChannelName(channelID) } e.workspaceBindings.Bind(projectKey, channelID, channelName, cloneTo) e.reply(p, msg.ReplyCtx, fmt.Sprintf( "Directory `%s` already exists. Bound workspace to this channel. Ready.", cloneTo)) return } e.reply(p, msg.ReplyCtx, fmt.Sprintf("Cloning `%s` to `%s`...", repoURL, cloneTo)) if err := gitClone(repoURL, cloneTo); err != nil { e.reply(p, msg.ReplyCtx, fmt.Sprintf("Clone failed: %v", err)) return } channelName := "" if resolver, ok := p.(ChannelNameResolver); ok { channelName, _ = resolver.ResolveChannelName(channelID) } e.workspaceBindings.Bind(projectKey, channelID, channelName, cloneTo) e.reply(p, msg.ReplyCtx, fmt.Sprintf( "Clone complete. Bound workspace `%s` to this channel. Ready.", cloneTo)) case "unbind": e.workspaceBindings.Unbind(projectKey, channelID) // Clean up workspace pool state if ws := e.workspacePool.Get(channelID); ws != nil { // Stop any running sessions } e.reply(p, msg.ReplyCtx, "Workspace unbound from this channel.") case "list": bindings := e.workspaceBindings.ListByProject(projectKey) if len(bindings) == 0 { e.reply(p, msg.ReplyCtx, "No workspaces bound.") return } var sb strings.Builder sb.WriteString("Bound workspaces:\n") for chID, b := range bindings { name := b.ChannelName if name == "" { name = chID } sb.WriteString(fmt.Sprintf("• #%s → `%s`\n", name, b.Workspace)) } e.reply(p, msg.ReplyCtx, sb.String()) default: e.reply(p, msg.ReplyCtx, "Usage: `/workspace [init | unbind | list]`") } } ``` **Step 3: Build to verify** Run: `go build ./...` Expected: Compiles cleanly **Step 4: Commit** ```bash git add core/engine.go git commit -m "feat: add /workspace commands (init, unbind, list, status)" ``` --- ### Task 10: Wire multi-workspace in main.go **Files:** - Modify: `cmd/cc-connect/main.go` (~line 139 project setup loop) **Step 1: Add multi-workspace setup after engine creation** After `engine := core.NewEngine(...)` (~line 195), add: ```go if proj.Mode == "multi-workspace" { baseDir := proj.BaseDir if strings.HasPrefix(baseDir, "~/") { home, _ := os.UserHomeDir() baseDir = filepath.Join(home, baseDir[2:]) } // Ensure base dir exists if err := os.MkdirAll(baseDir, 0o755); err != nil { slog.Error("failed to create base_dir", "path", baseDir, "err", err) continue } bindingStore := filepath.Join(cfg.DataDir, "workspace_bindings.json") engine.SetMultiWorkspace(baseDir, bindingStore) slog.Info("multi-workspace mode enabled", "project", proj.Name, "base_dir", baseDir) } ``` **Step 2: Build to verify** Run: `go build ./...` Expected: Compiles cleanly **Step 3: Commit** ```bash git add cmd/cc-connect/main.go git commit -m "feat: wire multi-workspace mode setup in main.go" ``` --- ### Task 11: Integration testing **Files:** - Create: `core/multi_workspace_test.go` **Step 1: Write integration test for full resolution flow** ```go package core import ( "os" "path/filepath" "testing" ) func TestMultiWorkspaceResolution_ConventionMatch(t *testing.T) { baseDir := t.TempDir() bindingDir := t.TempDir() // Create a directory matching a channel name channelDir := filepath.Join(baseDir, "test-channel") os.MkdirAll(channelDir, 0o755) // Create engine with multi-workspace agent := &mockAgent{} engine := NewEngine("test", agent, nil, "", LangEN) engine.SetMultiWorkspace(baseDir, filepath.Join(bindingDir, "bindings.json")) // Create a mock platform with channel name resolution mockP := &mockPlatformWithChannelResolver{ channelNames: map[string]string{"C123": "test-channel"}, } workspace, channelName, err := engine.resolveWorkspace(mockP, "C123") if err != nil { t.Fatal(err) } if workspace != channelDir { t.Errorf("expected %s, got %s", channelDir, workspace) } if channelName != "test-channel" { t.Errorf("expected test-channel, got %s", channelName) } // Verify binding was saved b := engine.workspaceBindings.Lookup("project:test", "C123") if b == nil { t.Fatal("expected binding to be saved") } } func TestMultiWorkspaceResolution_NoMatch(t *testing.T) { baseDir := t.TempDir() bindingDir := t.TempDir() agent := &mockAgent{} engine := NewEngine("test", agent, nil, "", LangEN) engine.SetMultiWorkspace(baseDir, filepath.Join(bindingDir, "bindings.json")) mockP := &mockPlatformWithChannelResolver{ channelNames: map[string]string{"C456": "unknown-channel"}, } workspace, _, err := engine.resolveWorkspace(mockP, "C456") if err != nil { t.Fatal(err) } if workspace != "" { t.Errorf("expected empty workspace for unmatched channel, got %s", workspace) } } func TestMultiWorkspaceResolution_MissingDirRemovesBinding(t *testing.T) { baseDir := t.TempDir() bindingDir := t.TempDir() agent := &mockAgent{} engine := NewEngine("test", agent, nil, "", LangEN) engine.SetMultiWorkspace(baseDir, filepath.Join(bindingDir, "bindings.json")) // Bind to a non-existent directory engine.workspaceBindings.Bind("project:test", "C789", "deleted-channel", "/nonexistent/path") mockP := &mockPlatformWithChannelResolver{ channelNames: map[string]string{"C789": "deleted-channel"}, } workspace, _, err := engine.resolveWorkspace(mockP, "C789") if err != nil { t.Fatal(err) } if workspace != "" { t.Errorf("expected empty workspace for missing dir, got %s", workspace) } // Binding should be removed b := engine.workspaceBindings.Lookup("project:test", "C789") if b != nil { t.Error("expected binding to be removed for missing directory") } } // Mock types - adapt to match existing test mocks in the project type mockPlatformWithChannelResolver struct { mockPlatform // embed existing mock if available channelNames map[string]string } func (m *mockPlatformWithChannelResolver) ResolveChannelName(channelID string) (string, error) { if name, ok := m.channelNames[channelID]; ok { return name, nil } return "", fmt.Errorf("unknown channel %s", channelID) } ``` Note: The mock types will need to be adapted based on existing test helpers in the codebase. Check `core/*_test.go` for existing mock patterns. **Step 2: Run tests** Run: `go test ./core/ -run TestMultiWorkspace -v` Expected: PASS **Step 3: Run full test suite** Run: `go test ./...` Expected: All existing tests still pass **Step 4: Commit** ```bash git add core/multi_workspace_test.go git commit -m "test: add integration tests for multi-workspace resolution" ``` --- ### Task 12: Update config.example.toml and verify build **Files:** - Modify: `config.example.toml` **Step 1: Add multi-workspace example section** Find the projects section and add a complete multi-workspace example: ```toml # Multi-workspace mode: single bot, multiple workspaces # Channel name maps to ~/workspace/ automatically. # Use /workspace init to clone and bind a new repo. # # [[projects]] # name = "claude" # mode = "multi-workspace" # base_dir = "~/workspace" # # [projects.agent] # type = "claudecode" # permission_mode = "yolo" # # [[projects.platforms]] # type = "slack" # [projects.platforms.options] # bot_token = "xoxb-..." # app_token = "xapp-..." ``` **Step 2: Final build and test** Run: `go build ./... && go test ./...` Expected: Clean build and all tests pass **Step 3: Commit** ```bash git add config.example.toml git commit -m "docs: add multi-workspace example to config.example.toml" ```