Files
cc-connect/docs/plans/2026-03-12-multi-workspace-plan.md
2026-06-02 23:14:41 +08:00

1355 lines
35 KiB
Markdown

# 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:<name>", 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/<channel-name> 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 <url>`."))
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 <git-url>`")
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 <url> | 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/<channel-name> automatically.
# Use /workspace init <url> 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"
```