diff --git a/.gitignore b/.gitignore
index ccf99673..5ceeb568 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,6 +8,10 @@ dist/
*.test
*.out
+# Python cache artifacts
+__pycache__/
+*.py[cod]
+
# Web frontend local artifacts
node_modules/
web/app/playwright-report/
diff --git a/cli/app.go b/cli/app.go
index 898bd50a..4867e9f2 100644
--- a/cli/app.go
+++ b/cli/app.go
@@ -221,7 +221,7 @@ func (a *App) usage() {
fmt.Fprintln(a.stderr, " csgclaw hub -h")
fmt.Fprintln(a.stderr, " csgclaw skill -h")
fmt.Fprintln(a.stderr, " csgclaw agent create -h")
- fmt.Fprintln(a.stderr, " csgclaw message create --channel csgclaw --room-id room-1 --sender-id u-admin --content hello")
+ fmt.Fprintln(a.stderr, " csgclaw message create --channel csgclaw --room-id room-1 --sender-id admin --content hello")
fmt.Fprintln(a.stderr)
fmt.Fprintln(a.stderr, "Global flags:")
fmt.Fprintf(a.stderr, " --endpoint string HTTP server endpoint (default %s)\n", envBaseURL)
diff --git a/cli/serve/serve_test.go b/cli/serve/serve_test.go
index 33afc83a..0ac1a773 100644
--- a/cli/serve/serve_test.go
+++ b/cli/serve/serve_test.go
@@ -146,6 +146,7 @@ func TestServeRunSkipsAutoBootstrapWhenStateComplete(t *testing.T) {
ConfigComplete: true,
IMBootstrapComplete: true,
ManagerAgentComplete: true,
+ AdminParticipantComplete: true,
ManagerParticipantComplete: true,
}, nil
}
@@ -219,6 +220,7 @@ debian_registries_override = []
ConfigComplete: true,
IMBootstrapComplete: true,
ManagerAgentComplete: true,
+ AdminParticipantComplete: true,
ManagerParticipantComplete: true,
}, nil
}
@@ -425,6 +427,7 @@ func TestServeRunRepeatedAutoBootstrapRemainsIdempotent(t *testing.T) {
ConfigComplete: complete,
IMBootstrapComplete: complete,
ManagerAgentComplete: complete,
+ AdminParticipantComplete: complete,
ManagerParticipantComplete: complete,
}, nil
}
@@ -1404,6 +1407,7 @@ func stubServeDependencies(t *testing.T) func() {
ConfigComplete: true,
IMBootstrapComplete: true,
ManagerAgentComplete: true,
+ AdminParticipantComplete: true,
ManagerParticipantComplete: true,
}, nil
}
diff --git a/internal/agent/image_migration.go b/internal/agent/image_migration.go
index 6c1c96cd..9e1beade 100644
--- a/internal/agent/image_migration.go
+++ b/internal/agent/image_migration.go
@@ -2,13 +2,17 @@ package agent
import (
"context"
+ "strconv"
"strings"
)
func (s *Service) withRuntimeImageMigrationStatus(ctx context.Context, a Agent) Agent {
+ return s.withRuntimeImageMigrationStatusFromCandidates(ctx, a, s.localImageCandidates(ctx))
+}
+
+func (s *Service) withRuntimeImageMigrationStatusFromCandidates(ctx context.Context, a Agent, localImages []string) Agent {
a = *cloneAgent(&a)
- latestImage, ok := s.currentDefaultImageForAgent(ctx, a)
- if !ok || !imageNeedsDefaultRecreate(a.Image, latestImage) {
+ if _, ok := s.imageUpgradeCandidateFromCandidates(ctx, a, localImages); !ok {
return a
}
a.AgentProfile.ImageUpgradeRequired = true
@@ -17,8 +21,7 @@ func (s *Service) withRuntimeImageMigrationStatus(ctx context.Context, a Agent)
func (s *Service) imageForRecreate(ctx context.Context, a Agent) string {
current := strings.TrimSpace(a.Image)
- latestImage, ok := s.currentDefaultImageForAgent(ctx, a)
- if ok && imageNeedsDefaultRecreate(current, latestImage) {
+ if latestImage, ok := s.imageUpgradeCandidateFromCandidates(ctx, a, s.localImageCandidates(ctx)); ok {
return latestImage
}
if isGatewayRuntimeKind(strings.TrimSpace(a.RuntimeKind)) && current == "" {
@@ -30,6 +33,49 @@ func (s *Service) imageForRecreate(ctx context.Context, a Agent) string {
return current
}
+func (s *Service) imageForUpgrade(ctx context.Context, a Agent) (string, bool) {
+ if image, ok := s.imageUpgradeCandidateFromCandidates(ctx, a, s.localImageCandidates(ctx)); ok {
+ return image, true
+ }
+ image, ok := s.currentDefaultImageForAgent(ctx, a)
+ if isDevImageTag(dockerImageTag(image)) {
+ return "", false
+ }
+ return strings.TrimSpace(image), ok && strings.TrimSpace(image) != ""
+}
+
+func (s *Service) imageUpgradeCandidateFromCandidates(ctx context.Context, a Agent, localImages []string) (string, bool) {
+ if image, ok := latestNewerLocalImageForReference(a.Image, localImages); ok {
+ return image, true
+ }
+ latestImage, ok := s.currentDefaultImageForAgent(ctx, a)
+ if ok && imageNeedsDefaultRecreate(a.Image, latestImage) {
+ return latestImage, true
+ }
+ return "", false
+}
+
+func (s *Service) localImageCandidates(ctx context.Context) []string {
+ if s == nil {
+ return nil
+ }
+ s.mu.RLock()
+ provider := s.sandbox
+ s.mu.RUnlock()
+ if provider == nil {
+ return nil
+ }
+ homeDir, err := SandboxRuntimeHome(ManagerName)
+ if err != nil {
+ return nil
+ }
+ images, err := provider.ListImages(ctx, homeDir)
+ if err != nil {
+ return nil
+ }
+ return images
+}
+
func (s *Service) currentDefaultImageForAgent(ctx context.Context, a Agent) (string, bool) {
if s == nil || !isGatewayRuntimeKind(strings.TrimSpace(a.RuntimeKind)) {
return "", false
@@ -80,6 +126,36 @@ func defaultTemplateMatchesAgent(templateRole, templateRuntimeKind, agentRole, a
return templateRuntimeKind == strings.TrimSpace(agentRuntimeKind)
}
+func latestNewerLocalImageForReference(current string, candidates []string) (string, bool) {
+ current = strings.TrimSpace(current)
+ currentRepo := dockerImageRepository(current)
+ currentTag := dockerImageTag(current)
+ if current == "" || currentRepo == "" || currentTag == "" || isDevImageTag(currentTag) {
+ return "", false
+ }
+ best := ""
+ for _, candidate := range candidates {
+ candidate = strings.TrimSpace(candidate)
+ if candidate == "" || candidate == current {
+ continue
+ }
+ if !strings.EqualFold(currentRepo, dockerImageRepository(candidate)) {
+ continue
+ }
+ if cmp, ok := compareImageTags(currentTag, dockerImageTag(candidate)); !ok || cmp >= 0 {
+ continue
+ }
+ if best == "" {
+ best = candidate
+ continue
+ }
+ if cmp, ok := compareImageTags(dockerImageTag(best), dockerImageTag(candidate)); ok && cmp < 0 {
+ best = candidate
+ }
+ }
+ return best, best != ""
+}
+
func imageNeedsDefaultRecreate(current, latest string) bool {
current = strings.TrimSpace(current)
latest = strings.TrimSpace(latest)
@@ -91,7 +167,25 @@ func imageNeedsDefaultRecreate(current, latest string) bool {
}
currentRepo := dockerImageRepository(current)
latestRepo := dockerImageRepository(latest)
- return currentRepo != "" && latestRepo != "" && strings.EqualFold(currentRepo, latestRepo)
+ if currentRepo == "" || latestRepo == "" || !strings.EqualFold(currentRepo, latestRepo) {
+ return false
+ }
+ currentTag := dockerImageTag(current)
+ latestTag := dockerImageTag(latest)
+ if currentTag == latestTag {
+ return false
+ }
+ if isDevImageTag(currentTag) || isDevImageTag(latestTag) {
+ return false
+ }
+ if cmp, ok := compareImageTags(currentTag, latestTag); ok {
+ return cmp < 0
+ }
+ return true
+}
+
+func isDevImageTag(tag string) bool {
+ return strings.EqualFold(strings.TrimSpace(tag), "dev")
}
func dockerImageRepository(ref string) string {
@@ -109,3 +203,77 @@ func dockerImageRepository(ref string) string {
}
return strings.TrimSpace(ref)
}
+
+func dockerImageTag(ref string) string {
+ ref = strings.TrimSpace(ref)
+ if ref == "" {
+ return ""
+ }
+ if beforeDigest, _, ok := strings.Cut(ref, "@"); ok {
+ ref = beforeDigest
+ }
+ lastSlash := strings.LastIndex(ref, "/")
+ lastColon := strings.LastIndex(ref, ":")
+ if lastColon > lastSlash {
+ return strings.TrimSpace(ref[lastColon+1:])
+ }
+ return ""
+}
+
+func compareImageTags(current, latest string) (int, bool) {
+ currentParts, ok := parseNumericImageTag(current)
+ if !ok {
+ return 0, false
+ }
+ latestParts, ok := parseNumericImageTag(latest)
+ if !ok {
+ return 0, false
+ }
+ maxLen := len(currentParts)
+ if len(latestParts) > maxLen {
+ maxLen = len(latestParts)
+ }
+ for i := 0; i < maxLen; i++ {
+ currentPart := 0
+ if i < len(currentParts) {
+ currentPart = currentParts[i]
+ }
+ latestPart := 0
+ if i < len(latestParts) {
+ latestPart = latestParts[i]
+ }
+ if currentPart < latestPart {
+ return -1, true
+ }
+ if currentPart > latestPart {
+ return 1, true
+ }
+ }
+ return 0, true
+}
+
+func parseNumericImageTag(tag string) ([]int, bool) {
+ tag = strings.TrimSpace(tag)
+ tag = strings.TrimPrefix(strings.ToLower(tag), "v")
+ if tag == "" {
+ return nil, false
+ }
+ fields := strings.FieldsFunc(tag, func(r rune) bool {
+ return r == '.' || r == '-' || r == '_'
+ })
+ if len(fields) == 0 {
+ return nil, false
+ }
+ parts := make([]int, 0, len(fields))
+ for _, field := range fields {
+ if field == "" {
+ return nil, false
+ }
+ part, err := strconv.Atoi(field)
+ if err != nil {
+ return nil, false
+ }
+ parts = append(parts, part)
+ }
+ return parts, true
+}
diff --git a/internal/agent/service.go b/internal/agent/service.go
index 86825bcf..86b948c2 100644
--- a/internal/agent/service.go
+++ b/internal/agent/service.go
@@ -1443,8 +1443,9 @@ func (s *Service) List() []Agent {
agents := sortedAgentsFromMap(s.agents)
s.mu.RUnlock()
ctx := context.Background()
+ localImages := s.localImageCandidates(ctx)
for idx := range agents {
- agents[idx] = s.withRuntimeImageMigrationStatus(ctx, s.hydrateAgentStatus(ctx, agents[idx]))
+ agents[idx] = s.withRuntimeImageMigrationStatusFromCandidates(ctx, s.hydrateAgentStatus(ctx, agents[idx]), localImages)
}
return agents
}
diff --git a/internal/agent/service_profiles.go b/internal/agent/service_profiles.go
index 6f9e2f15..4fda2d40 100644
--- a/internal/agent/service_profiles.go
+++ b/internal/agent/service_profiles.go
@@ -304,7 +304,7 @@ func (s *Service) Recreate(ctx context.Context, id string) (Agent, error) {
func (s *Service) Upgrade(ctx context.Context, id string) (Agent, error) {
return s.recreate(ctx, id, func(ctx context.Context, got Agent) (string, error) {
- latest, ok := s.currentDefaultImageForAgent(ctx, got)
+ latest, ok := s.imageForUpgrade(ctx, got)
if !ok || strings.TrimSpace(latest) == "" {
return "", fmt.Errorf("agent %q has no default image to upgrade", got.ID)
}
diff --git a/internal/agent/service_test.go b/internal/agent/service_test.go
index 99d12326..6bf7922b 100644
--- a/internal/agent/service_test.go
+++ b/internal/agent/service_test.go
@@ -4179,6 +4179,114 @@ func TestAgentMarksOutdatedManagerImageUpgradeRequiredWhenGatewayRuntimeChanged(
}
}
+func TestAgentMarksOutdatedManagerImageUpgradeRequiredFromLocalSameRepositoryCandidate(t *testing.T) {
+ homeDir := t.TempDir()
+ t.Setenv("HOME", homeDir)
+ provider := sandboxtest.NewProvider()
+ provider.Images = []string{
+ "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw-manager:dev",
+ "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.6.8",
+ "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:participant-local",
+ "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.5.27",
+ }
+
+ svc, err := NewService(
+ testModelConfig(),
+ config.ServerConfig{},
+ "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw-manager:dev",
+ "",
+ WithSandboxProvider(provider),
+ WithRuntime(fakeAgentRuntime{
+ kind: RuntimeKindPicoClawSandbox,
+ info: func(_ context.Context, h agentruntime.Handle) (agentruntime.Info, error) {
+ return agentruntime.Info{HandleID: h.HandleID, State: agentruntime.StateRunning}, nil
+ },
+ }),
+ )
+ if err != nil {
+ t.Fatalf("NewService() error = %v", err)
+ }
+ svc.agents[ManagerUserID] = Agent{
+ ID: ManagerUserID,
+ Name: ManagerName,
+ RuntimeID: runtimeIDForAgentID(ManagerUserID),
+ RuntimeKind: RuntimeKindPicoClawSandbox,
+ Image: "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.5.27",
+ BoxID: "box-manager",
+ Role: RoleManager,
+ Status: string(agentruntime.StateRunning),
+ CreatedAt: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC),
+ AgentProfile: AgentProfile{
+ Name: ManagerName,
+ Provider: ProviderCodex,
+ ModelID: "gpt-5.5",
+ ProfileComplete: true,
+ },
+ ProfileComplete: true,
+ }
+
+ got, ok := svc.Agent(ManagerUserID)
+ if !ok {
+ t.Fatal("Agent() ok = false, want true")
+ }
+ if !got.AgentProfile.ImageUpgradeRequired {
+ t.Fatalf("Agent().AgentProfile.ImageUpgradeRequired = false, want true for newer same-repository local image")
+ }
+}
+
+func TestAgentDevImageDoesNotRequireUpgrade(t *testing.T) {
+ homeDir := t.TempDir()
+ t.Setenv("HOME", homeDir)
+ provider := sandboxtest.NewProvider()
+ provider.Images = []string{
+ "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.6.8",
+ "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:dev",
+ }
+
+ svc, err := NewService(
+ testModelConfig(),
+ config.ServerConfig{},
+ "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.6.8",
+ "",
+ WithSandboxProvider(provider),
+ WithRuntime(fakeAgentRuntime{
+ kind: RuntimeKindPicoClawSandbox,
+ info: func(_ context.Context, h agentruntime.Handle) (agentruntime.Info, error) {
+ return agentruntime.Info{HandleID: h.HandleID, State: agentruntime.StateRunning}, nil
+ },
+ }),
+ )
+ if err != nil {
+ t.Fatalf("NewService() error = %v", err)
+ }
+ svc.agents[ManagerUserID] = Agent{
+ ID: ManagerUserID,
+ Name: ManagerName,
+ RuntimeID: runtimeIDForAgentID(ManagerUserID),
+ RuntimeKind: RuntimeKindPicoClawSandbox,
+ Image: "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:dev",
+ BoxID: "box-manager",
+ Role: RoleManager,
+ Status: string(agentruntime.StateRunning),
+ CreatedAt: time.Date(2026, 6, 8, 12, 0, 0, 0, time.UTC),
+ AgentProfile: AgentProfile{
+ Name: ManagerName,
+ Provider: ProviderCodex,
+ ModelID: "gpt-5.5",
+ ProfileComplete: true,
+ },
+ ProfileComplete: true,
+ }
+
+ got, ok := svc.Agent(ManagerUserID)
+ if !ok {
+ t.Fatal("Agent() ok = false, want true")
+ }
+ if got.AgentProfile.ImageUpgradeRequired {
+ t.Fatalf("Agent().AgentProfile.ImageUpgradeRequired = true, want false for dev image")
+ }
+}
+
func TestRecreateUsesLatestDefaultTemplateImageAndPreservesUserSkills(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
diff --git a/internal/api/csgclaw_channel_test.go b/internal/api/csgclaw_channel_test.go
index a5430ed6..f66fed56 100644
--- a/internal/api/csgclaw_channel_test.go
+++ b/internal/api/csgclaw_channel_test.go
@@ -43,7 +43,7 @@ func TestHandleCsgclawChannelRoutesMirrorLocalCollections(t *testing.T) {
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode users: %v", err)
}
- if len(got) < 2 || got[0].ID != "u-admin" {
+ if len(got) < 2 || got[0].ID != "admin" {
t.Fatalf("users = %+v, want local users through csgclaw channel route", got)
}
})
diff --git a/internal/api/handler.go b/internal/api/handler.go
index 9a7478e8..87645311 100644
--- a/internal/api/handler.go
+++ b/internal/api/handler.go
@@ -1470,7 +1470,7 @@ func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) {
}
if user, ok := h.im.User(created.ChannelUserRef); ok {
h.publishUserEvent(im.EventTypeUserCreated, user)
- if room, ok := h.directRoomWithMembers("u-admin", user.ID); ok {
+ if room, ok := h.directRoomWithMembers(im.AdminUserID, user.ID); ok {
h.publishRoomEvent(im.EventTypeRoomCreated, room)
}
writeJSON(w, http.StatusCreated, user)
@@ -1497,7 +1497,7 @@ func (h *Handler) handleCreateUser(w http.ResponseWriter, r *http.Request) {
func shouldCreateWorkerForUser(id, role string) bool {
id = strings.TrimSpace(id)
switch strings.ToLower(id) {
- case "", "u-admin", agent.ManagerUserID:
+ case "", im.AdminUserID, "u-admin", agent.ManagerUserID:
return false
}
diff --git a/internal/api/handler_test.go b/internal/api/handler_test.go
index 18ad81b0..c0afc520 100644
--- a/internal/api/handler_test.go
+++ b/internal/api/handler_test.go
@@ -357,7 +357,7 @@ func TestHandleRoomsMembersListsCsgclawMembers(t *testing.T) {
if err := json.NewDecoder(rec.Body).Decode(&members); err != nil {
t.Fatalf("decode members: %v", err)
}
- if len(members) != 2 || members[0].ID != "u-admin" || members[1].ID != "u-alice" {
+ if len(members) != 2 || members[0].ID != "admin" || members[1].ID != "u-alice" {
t.Fatalf("members = %+v, want room members", members)
}
}
@@ -558,6 +558,372 @@ func TestHandleAgentUpgradeUsesLatestDefaultImage(t *testing.T) {
}
}
+func TestHandleAgentsListReportsImageUpgradeRequiredByImageTag(t *testing.T) {
+ tests := []struct {
+ name string
+ currentImage string
+ latestImage string
+ wantRequired bool
+ }{
+ {
+ name: "older tag requires upgrade",
+ currentImage: "registry.example/picoclaw-worker:2026.05.27",
+ latestImage: "registry.example/picoclaw-worker:2026.06.03",
+ wantRequired: true,
+ },
+ {
+ name: "newer tag does not require upgrade",
+ currentImage: "registry.example/picoclaw-worker:2026.06.09",
+ latestImage: "registry.example/picoclaw-worker:2026.06.03",
+ wantRequired: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Setenv("HOME", t.TempDir())
+ t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider()))
+ hubSvc := mustNewLocalTemplateHubServiceWithoutWorkspace(t, "frontend-worker", hub.Template{
+ ID: "frontend-worker",
+ Name: "frontend-worker",
+ Description: "frontend worker",
+ Role: hub.TemplateRoleWorker,
+ RuntimeKind: agent.RuntimeKindPicoClawSandbox,
+ Image: tt.latestImage,
+ })
+ statePath := filepath.Join(t.TempDir(), "agents.json")
+ if err := writeSeededAgents(statePath, []agent.Agent{
+ {
+ ID: "u-alice",
+ Name: "alice",
+ RuntimeID: "rt-u-alice",
+ RuntimeKind: agent.RuntimeKindPicoClawSandbox,
+ Image: tt.currentImage,
+ BoxID: "box-alice",
+ Role: agent.RoleWorker,
+ Status: string(agentruntime.StateRunning),
+ AgentProfile: agent.AgentProfile{Name: "alice", Provider: agent.ProviderCodex, ModelID: "gpt-5.5", ProfileComplete: true},
+ CreatedAt: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC),
+ },
+ }); err != nil {
+ t.Fatalf("writeSeededAgents() error = %v", err)
+ }
+ svc, err := agent.NewService(
+ config.ModelConfig{},
+ config.ServerConfig{},
+ "manager-image:test",
+ statePath,
+ agent.WithHubService(hubSvc),
+ agent.WithBootstrapDefaultTemplates(config.BootstrapConfig{DefaultWorkerTemplate: "local/frontend-worker"}),
+ agent.WithRuntime(fakeCompatRuntime{
+ info: func(_ context.Context, h agentruntime.Handle) (agentruntime.Info, error) {
+ return agentruntime.Info{HandleID: h.HandleID, State: agentruntime.StateRunning}, nil
+ },
+ }),
+ )
+ if err != nil {
+ t.Fatalf("NewService() error = %v", err)
+ }
+
+ srv := &Handler{svc: svc}
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
+ rec := httptest.NewRecorder()
+
+ srv.Routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ var got []agentResponse
+ if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("len(agents) = %d, want 1", len(got))
+ }
+ if got[0].AgentProfile.ImageUpgradeRequired != tt.wantRequired {
+ t.Fatalf("image_upgrade_required = %t, want %t; response=%+v", got[0].AgentProfile.ImageUpgradeRequired, tt.wantRequired, got[0])
+ }
+ })
+ }
+}
+
+func TestHandleManagerGetReportsImageUpgradeRequiredByImageTag(t *testing.T) {
+ tests := []struct {
+ name string
+ currentImage string
+ latestImage string
+ wantRequired bool
+ }{
+ {
+ name: "older manager tag requires upgrade",
+ currentImage: "registry.example/opencsghq/picoclaw:2026.5.22",
+ latestImage: "registry.example/opencsghq/picoclaw:2026.6.3",
+ wantRequired: true,
+ },
+ {
+ name: "newer manager tag does not require upgrade",
+ currentImage: "registry.example/opencsghq/picoclaw:2026.6.9",
+ latestImage: "registry.example/opencsghq/picoclaw:2026.6.3",
+ wantRequired: false,
+ },
+ {
+ name: "dev manager tag does not require upgrade",
+ currentImage: "registry.example/opencsghq/picoclaw:dev",
+ latestImage: "registry.example/opencsghq/picoclaw:2026.6.3",
+ wantRequired: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Setenv("HOME", t.TempDir())
+ t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider()))
+ statePath := filepath.Join(t.TempDir(), "agents.json")
+ if err := writeSeededAgents(statePath, []agent.Agent{
+ {
+ ID: agent.ManagerUserID,
+ Name: agent.ManagerName,
+ RuntimeID: "rt-manager",
+ RuntimeKind: agent.RuntimeKindPicoClawSandbox,
+ Image: tt.currentImage,
+ BoxID: "box-manager",
+ Role: agent.RoleManager,
+ Status: string(agentruntime.StateRunning),
+ AgentProfile: agent.AgentProfile{Name: agent.ManagerName, Provider: agent.ProviderCodex, ModelID: "gpt-5.5", ProfileComplete: true},
+ CreatedAt: time.Date(2026, 5, 22, 12, 0, 0, 0, time.UTC),
+ },
+ }); err != nil {
+ t.Fatalf("writeSeededAgents() error = %v", err)
+ }
+ svc, err := agent.NewService(
+ config.ModelConfig{},
+ config.ServerConfig{},
+ tt.latestImage,
+ statePath,
+ agent.WithRuntime(fakeCompatRuntime{
+ info: func(_ context.Context, h agentruntime.Handle) (agentruntime.Info, error) {
+ return agentruntime.Info{HandleID: h.HandleID, State: agentruntime.StateRunning}, nil
+ },
+ }),
+ )
+ if err != nil {
+ t.Fatalf("NewService() error = %v", err)
+ }
+
+ srv := &Handler{svc: svc}
+ req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/u-manager", nil)
+ rec := httptest.NewRecorder()
+
+ srv.Routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
+ }
+ var got agentResponse
+ if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
+ t.Fatalf("decode response: %v", err)
+ }
+ if got.AgentProfile.ImageUpgradeRequired != tt.wantRequired {
+ t.Fatalf("manager image_upgrade_required = %t, want %t; response=%+v", got.AgentProfile.ImageUpgradeRequired, tt.wantRequired, got)
+ }
+ })
+ }
+}
+
+func TestHandleAgentUpgradeClearsOutdatedImageFlag(t *testing.T) {
+ t.Setenv("HOME", t.TempDir())
+ t.Cleanup(agent.TestOnlySetSandboxProvider(sandboxtest.NewProvider()))
+ hubSvc := mustNewLocalTemplateHubServiceWithoutWorkspace(t, "frontend-worker", hub.Template{
+ ID: "frontend-worker",
+ Name: "frontend-worker",
+ Description: "frontend worker",
+ Role: hub.TemplateRoleWorker,
+ RuntimeKind: agent.RuntimeKindPicoClawSandbox,
+ Image: "registry.example/picoclaw-worker:2026.06.03",
+ })
+ statePath := filepath.Join(t.TempDir(), "agents.json")
+ if err := writeSeededAgents(statePath, []agent.Agent{
+ {
+ ID: "u-alice",
+ Name: "alice",
+ RuntimeID: "rt-u-alice",
+ RuntimeKind: agent.RuntimeKindPicoClawSandbox,
+ Image: "registry.example/picoclaw-worker:2026.05.27",
+ BoxID: "box-alice-old",
+ Role: agent.RoleWorker,
+ Status: string(agentruntime.StateRunning),
+ AgentProfile: agent.AgentProfile{Name: "alice", Provider: agent.ProviderCodex, ModelID: "gpt-5.5", ProfileComplete: true},
+ CreatedAt: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC),
+ },
+ }); err != nil {
+ t.Fatalf("writeSeededAgents() error = %v", err)
+ }
+ var newImage string
+ svc, err := agent.NewService(
+ config.ModelConfig{},
+ config.ServerConfig{},
+ "manager-image:test",
+ statePath,
+ agent.WithHubService(hubSvc),
+ agent.WithBootstrapDefaultTemplates(config.BootstrapConfig{DefaultWorkerTemplate: "local/frontend-worker"}),
+ agent.WithRuntime(fakeCompatRuntime{
+ new: func(_ context.Context, spec agentruntime.Spec) (agentruntime.Handle, error) {
+ newImage = spec.Image
+ return agentruntime.Handle{RuntimeID: spec.RuntimeID, HandleID: "box-alice-new"}, nil
+ },
+ info: func(_ context.Context, h agentruntime.Handle) (agentruntime.Info, error) {
+ return agentruntime.Info{HandleID: h.HandleID, State: agentruntime.StateRunning, CreatedAt: time.Date(2026, 6, 3, 10, 0, 0, 0, time.UTC)}, nil
+ },
+ }),
+ )
+ if err != nil {
+ t.Fatalf("NewService() error = %v", err)
+ }
+
+ srv := &Handler{svc: svc}
+ beforeReq := httptest.NewRequest(http.MethodGet, "/api/v1/agents/u-alice", nil)
+ beforeRec := httptest.NewRecorder()
+ srv.Routes().ServeHTTP(beforeRec, beforeReq)
+ if beforeRec.Code != http.StatusOK {
+ t.Fatalf("pre-upgrade status = %d, want %d; body=%s", beforeRec.Code, http.StatusOK, beforeRec.Body.String())
+ }
+ var before agentResponse
+ if err := json.NewDecoder(beforeRec.Body).Decode(&before); err != nil {
+ t.Fatalf("decode pre-upgrade response: %v", err)
+ }
+ if !before.AgentProfile.ImageUpgradeRequired {
+ t.Fatalf("pre-upgrade image_upgrade_required = false, want true; response=%+v", before)
+ }
+
+ upgradeReq := httptest.NewRequest(http.MethodPost, "/api/v1/agents/u-alice/upgrade", nil)
+ upgradeRec := httptest.NewRecorder()
+ srv.Routes().ServeHTTP(upgradeRec, upgradeReq)
+ if upgradeRec.Code != http.StatusOK {
+ t.Fatalf("upgrade status = %d, want %d; body=%s", upgradeRec.Code, http.StatusOK, upgradeRec.Body.String())
+ }
+ if newImage != "registry.example/picoclaw-worker:2026.06.03" {
+ t.Fatalf("runtime New() image = %q, want latest default image", newImage)
+ }
+ var upgraded agentResponse
+ if err := json.NewDecoder(upgradeRec.Body).Decode(&upgraded); err != nil {
+ t.Fatalf("decode upgrade response: %v", err)
+ }
+ if upgraded.Image != "registry.example/picoclaw-worker:2026.06.03" {
+ t.Fatalf("upgrade response Image = %q, want latest default image", upgraded.Image)
+ }
+ if upgraded.AgentProfile.ImageUpgradeRequired {
+ t.Fatalf("upgrade response image_upgrade_required = true, want false; response=%+v", upgraded)
+ }
+
+ afterReq := httptest.NewRequest(http.MethodGet, "/api/v1/agents/u-alice", nil)
+ afterRec := httptest.NewRecorder()
+ srv.Routes().ServeHTTP(afterRec, afterReq)
+ if afterRec.Code != http.StatusOK {
+ t.Fatalf("post-upgrade status = %d, want %d; body=%s", afterRec.Code, http.StatusOK, afterRec.Body.String())
+ }
+ var after agentResponse
+ if err := json.NewDecoder(afterRec.Body).Decode(&after); err != nil {
+ t.Fatalf("decode post-upgrade response: %v", err)
+ }
+ if after.Image != "registry.example/picoclaw-worker:2026.06.03" || after.AgentProfile.ImageUpgradeRequired {
+ t.Fatalf("post-upgrade response = %+v, want latest image and no image upgrade flag", after)
+ }
+}
+
+func TestHandleManagerUpgradeUsesNewerLocalSameRepositoryImage(t *testing.T) {
+ t.Setenv("HOME", t.TempDir())
+ provider := sandboxtest.NewProvider()
+ provider.Images = []string{
+ "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw-manager:dev",
+ "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.6.8",
+ "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:participant-local",
+ "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.5.27",
+ }
+ statePath := filepath.Join(t.TempDir(), "agents.json")
+ if err := writeSeededAgents(statePath, []agent.Agent{
+ {
+ ID: agent.ManagerUserID,
+ Name: agent.ManagerName,
+ RuntimeID: "rt-manager",
+ RuntimeKind: agent.RuntimeKindPicoClawSandbox,
+ Image: "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.5.27",
+ BoxID: "box-manager-old",
+ Role: agent.RoleManager,
+ Status: string(agentruntime.StateRunning),
+ AgentProfile: agent.AgentProfile{Name: agent.ManagerName, Provider: agent.ProviderCodex, ModelID: "gpt-5.5", ProfileComplete: true},
+ CreatedAt: time.Date(2026, 5, 27, 12, 0, 0, 0, time.UTC),
+ },
+ }); err != nil {
+ t.Fatalf("writeSeededAgents() error = %v", err)
+ }
+ var newImage string
+ svc, err := agent.NewService(
+ config.ModelConfig{},
+ config.ServerConfig{},
+ "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw-manager:dev",
+ statePath,
+ agent.WithSandboxProvider(provider),
+ agent.WithRuntime(fakeCompatRuntime{
+ new: func(_ context.Context, spec agentruntime.Spec) (agentruntime.Handle, error) {
+ newImage = spec.Image
+ return agentruntime.Handle{RuntimeID: spec.RuntimeID, HandleID: "box-manager-new"}, nil
+ },
+ info: func(_ context.Context, h agentruntime.Handle) (agentruntime.Info, error) {
+ return agentruntime.Info{HandleID: h.HandleID, State: agentruntime.StateRunning, CreatedAt: time.Date(2026, 6, 8, 10, 0, 0, 0, time.UTC)}, nil
+ },
+ }),
+ )
+ if err != nil {
+ t.Fatalf("NewService() error = %v", err)
+ }
+
+ srv := &Handler{svc: svc}
+ beforeReq := httptest.NewRequest(http.MethodGet, "/api/v1/agents/u-manager", nil)
+ beforeRec := httptest.NewRecorder()
+ srv.Routes().ServeHTTP(beforeRec, beforeReq)
+ if beforeRec.Code != http.StatusOK {
+ t.Fatalf("pre-upgrade status = %d, want %d; body=%s", beforeRec.Code, http.StatusOK, beforeRec.Body.String())
+ }
+ var before agentResponse
+ if err := json.NewDecoder(beforeRec.Body).Decode(&before); err != nil {
+ t.Fatalf("decode pre-upgrade response: %v", err)
+ }
+ if !before.AgentProfile.ImageUpgradeRequired {
+ t.Fatalf("pre-upgrade image_upgrade_required = false, want true; response=%+v", before)
+ }
+
+ upgradeReq := httptest.NewRequest(http.MethodPost, "/api/v1/agents/u-manager/upgrade", nil)
+ upgradeRec := httptest.NewRecorder()
+ srv.Routes().ServeHTTP(upgradeRec, upgradeReq)
+ if upgradeRec.Code != http.StatusOK {
+ t.Fatalf("upgrade status = %d, want %d; body=%s", upgradeRec.Code, http.StatusOK, upgradeRec.Body.String())
+ }
+ wantImage := "opencsg-registry.cn-beijing.cr.aliyuncs.com/opencsghq/picoclaw:2026.6.8"
+ if newImage != wantImage {
+ t.Fatalf("runtime New() image = %q, want %q", newImage, wantImage)
+ }
+ var upgraded agentResponse
+ if err := json.NewDecoder(upgradeRec.Body).Decode(&upgraded); err != nil {
+ t.Fatalf("decode upgrade response: %v", err)
+ }
+ if upgraded.Image != wantImage || upgraded.AgentProfile.ImageUpgradeRequired {
+ t.Fatalf("upgrade response = %+v, want latest same-repository image and no image upgrade flag", upgraded)
+ }
+
+ afterReq := httptest.NewRequest(http.MethodGet, "/api/v1/agents/u-manager", nil)
+ afterRec := httptest.NewRecorder()
+ srv.Routes().ServeHTTP(afterRec, afterReq)
+ if afterRec.Code != http.StatusOK {
+ t.Fatalf("post-upgrade status = %d, want %d; body=%s", afterRec.Code, http.StatusOK, afterRec.Body.String())
+ }
+ var after agentResponse
+ if err := json.NewDecoder(afterRec.Body).Decode(&after); err != nil {
+ t.Fatalf("decode post-upgrade response: %v", err)
+ }
+ if after.Image != wantImage || after.AgentProfile.ImageUpgradeRequired {
+ t.Fatalf("post-upgrade response = %+v, want latest same-repository image and no image upgrade flag", after)
+ }
+}
+
func TestHandleAgentsListRedactsProfileAPIKey(t *testing.T) {
svc := mustNewSeededService(t, []agent.Agent{
{
@@ -1967,7 +2333,7 @@ func TestHandleUsersCreateProvisionsIMUser(t *testing.T) {
t.Fatal("User(u-alice) ok = false, want true after create")
}
rooms := srv.im.ListRooms()
- if len(rooms) != 1 || !containsMember(rooms[0].Members, "u-admin") || !containsMember(rooms[0].Members, "u-alice") {
+ if len(rooms) != 1 || !containsMember(rooms[0].Members, "admin") || !containsMember(rooms[0].Members, "u-alice") {
t.Fatalf("rooms = %+v, want one bootstrap room with admin and u-alice", rooms)
}
@@ -2252,7 +2618,7 @@ func TestHandleMessagesPostCreatesMessage(t *testing.T) {
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode response: %v", err)
}
- if got.SenderID != "u-admin" || got.Content != "hello @manager" {
+ if got.SenderID != "admin" || got.Content != "hello @manager" {
t.Fatalf("message = %+v, want sender/content populated", got)
}
if len(got.Mentions) != 1 || got.Mentions[0].ID != "manager" || got.Mentions[0].Name != "manager" {
@@ -2903,7 +3269,7 @@ func TestHandleRoomsPostCreatesRoom(t *testing.T) {
if got.Title != "Launch" {
t.Fatalf("conversation.Title = %q, want Launch", got.Title)
}
- if !containsMember(got.Members, "u-admin") || !containsMember(got.Members, "u-alice") || !containsMember(got.Members, "manager") {
+ if !containsMember(got.Members, "admin") || !containsMember(got.Members, "u-alice") || !containsMember(got.Members, "manager") {
t.Fatalf("members = %+v, want admin, alice, and manager", got.Members)
}
}
@@ -2931,7 +3297,7 @@ func TestHandleRoomsPostUsesCsgclawChannelAdapter(t *testing.T) {
if err := json.NewDecoder(rec.Body).Decode(&got); err != nil {
t.Fatalf("decode response: %v", err)
}
- if !containsMember(got.Members, "u-admin") || !containsMember(got.Members, "u-alice") {
+ if !containsMember(got.Members, "admin") || !containsMember(got.Members, "u-alice") {
t.Fatalf("members = %+v, want trimmed bot IDs", got.Members)
}
}
diff --git a/internal/im/provisioning.go b/internal/im/provisioning.go
index ecd08976..679a8f3d 100644
--- a/internal/im/provisioning.go
+++ b/internal/im/provisioning.go
@@ -71,7 +71,7 @@ func (p *Provisioner) scheduleBootstrapMessage(roomID, name, description string)
time.Sleep(p.bootstrapDelay)
message, err := p.service.CreateMessage(CreateMessageRequest{
RoomID: roomID,
- SenderID: "u-admin",
+ SenderID: adminUserID,
Content: buildWorkerBootstrapMessage(name, description),
})
if err != nil {
diff --git a/internal/im/provisioning_test.go b/internal/im/provisioning_test.go
index c4cd39cc..3b027d0f 100644
--- a/internal/im/provisioning_test.go
+++ b/internal/im/provisioning_test.go
@@ -42,7 +42,7 @@ func TestProvisionerEnsureAgentUserPublishesBootstrapRoom(t *testing.T) {
if second.Room.Title != "alice" {
t.Fatalf("second event.Room.Title = %q, want %q", second.Room.Title, "alice")
}
- if !containsUserIDInRoom(*second.Room, "u-admin") || !containsUserIDInRoom(*second.Room, "u-alice") {
+ if !containsUserIDInRoom(*second.Room, "admin") || !containsUserIDInRoom(*second.Room, "u-alice") {
t.Fatalf("second event.Room.Members = %+v, want admin and worker", second.Room.Members)
}
@@ -59,15 +59,15 @@ func TestProvisionerEnsureAgentUserPublishesBootstrapRoom(t *testing.T) {
if third.Message == nil {
t.Fatal("third event.Message = nil, want bootstrap message")
}
- if third.Message.SenderID != "u-admin" {
- t.Fatalf("third event.Message.SenderID = %q, want %q", third.Message.SenderID, "u-admin")
+ if third.Message.SenderID != "admin" {
+ t.Fatalf("third event.Message.SenderID = %q, want %q", third.Message.SenderID, "admin")
}
wantContent := "Write this down in your memory: your name is Alice. Your responsibility is test lead"
if third.Message.Content != wantContent {
t.Fatalf("third event.Message.Content = %q, want %q", third.Message.Content, wantContent)
}
- if third.Sender == nil || third.Sender.ID != "u-admin" {
- t.Fatalf("third event.Sender = %+v, want u-admin", third.Sender)
+ if third.Sender == nil || third.Sender.ID != "admin" {
+ t.Fatalf("third event.Sender = %+v, want admin", third.Sender)
}
}
diff --git a/internal/im/service.go b/internal/im/service.go
index 6b637163..00f16d6a 100644
--- a/internal/im/service.go
+++ b/internal/im/service.go
@@ -160,7 +160,9 @@ func HasMentionTagForUser(content, userID string) bool {
const (
sessionsDirName = "sessions"
- adminUserID = "u-admin"
+ AdminUserID = "admin"
+ adminUserID = AdminUserID
+ legacyAdminUserID = "u-admin"
managerParticipantUserID = "manager"
legacyManagerUserID = "u-manager"
)
@@ -237,7 +239,7 @@ func NewServiceFromBootstrapWithBus(state Bootstrap, bus *Bus) *Service {
func DefaultBootstrap() Bootstrap {
return Bootstrap{
- CurrentUserID: "u-admin",
+ CurrentUserID: adminUserID,
Users: nil,
Rooms: nil,
}
@@ -514,10 +516,13 @@ func normalizeBootstrap(state Bootstrap) Bootstrap {
if state.CurrentUserID == "" {
state.CurrentUserID = DefaultBootstrap().CurrentUserID
}
+ adminAliases := adminUserAliases(state.Users)
managerAliases := managerUserAliases(state.Users)
state.Users = ensureUsers(state.Users)
- state.Rooms = migrateLegacyManagerRoomRefs(cloneRooms(state.Rooms), managerAliases)
+ state.Rooms = migrateLegacyAdminRoomRefs(cloneRooms(state.Rooms), adminAliases)
+ state.Rooms = migrateLegacyManagerRoomRefs(state.Rooms, managerAliases)
if !containsUserID(state.Users, state.CurrentUserID) {
+ state.CurrentUserID = migrateLegacyAdminID(state.CurrentUserID, adminAliases)
state.CurrentUserID = migrateLegacyManagerID(state.CurrentUserID, managerAliases)
if !containsUserID(state.Users, state.CurrentUserID) {
state.CurrentUserID = defaultCurrentUserID(state.Users)
@@ -544,6 +549,7 @@ func ensureUsers(users []User) []User {
} else {
for i := range result {
if strings.EqualFold(strings.TrimSpace(result[i].Handle), "admin") {
+ result[i].ID = adminUserID
result[i].Name = "admin"
result[i].Role = "admin"
}
@@ -568,10 +574,33 @@ func ensureUsers(users []User) []User {
}
}
}
+ result = dropLegacyAdminUserDuplicates(result)
result = dropLegacyManagerUserDuplicates(result)
return result
}
+func dropLegacyAdminUserDuplicates(users []User) []User {
+ out := make([]User, 0, len(users))
+ seen := make(map[string]struct{}, len(users))
+ for _, user := range users {
+ id := strings.TrimSpace(user.ID)
+ if id == "" || id == legacyAdminUserID {
+ if strings.EqualFold(strings.TrimSpace(user.Handle), "admin") ||
+ strings.EqualFold(strings.TrimSpace(user.Name), "admin") ||
+ strings.EqualFold(strings.TrimSpace(user.Role), "admin") {
+ id = adminUserID
+ user.ID = adminUserID
+ }
+ }
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ out = append(out, user)
+ }
+ return out
+}
+
func dropLegacyManagerUserDuplicates(users []User) []User {
out := make([]User, 0, len(users))
seen := make(map[string]struct{}, len(users))
@@ -631,6 +660,25 @@ func defaultCurrentUserID(users []User) string {
return ""
}
+func adminUserAliases(users []User) map[string]struct{} {
+ aliases := map[string]struct{}{
+ legacyAdminUserID: {},
+ adminUserID: {},
+ }
+ for _, user := range users {
+ id := strings.TrimSpace(user.ID)
+ if id == "" {
+ continue
+ }
+ if strings.EqualFold(strings.TrimSpace(user.Handle), "admin") ||
+ strings.EqualFold(strings.TrimSpace(user.Name), "admin") ||
+ strings.EqualFold(strings.TrimSpace(user.Role), "admin") {
+ aliases[id] = struct{}{}
+ }
+ }
+ return aliases
+}
+
func managerUserAliases(users []User) map[string]struct{} {
aliases := map[string]struct{}{
legacyManagerUserID: {},
@@ -658,12 +706,34 @@ func cloneRooms(rooms []Room) []Room {
return cloned
}
+func migrateLegacyAdminRoomRefs(rooms []Room, adminAliases map[string]struct{}) []Room {
+ for i := range rooms {
+ rooms[i].Members = migrateLegacyAdminIDs(rooms[i].Members, adminAliases)
+ for j := range rooms[i].Messages {
+ rooms[i].Messages[j].SenderID = migrateLegacyAdminID(rooms[i].Messages[j].SenderID, adminAliases)
+ rooms[i].Messages[j].Content = migrateLegacyAdminMentionTags(rooms[i].Messages[j].Content, adminAliases)
+ if rooms[i].Messages[j].Event != nil {
+ rooms[i].Messages[j].Event.ActorID = migrateLegacyAdminID(rooms[i].Messages[j].Event.ActorID, adminAliases)
+ rooms[i].Messages[j].Event.TargetIDs = migrateLegacyAdminIDs(rooms[i].Messages[j].Event.TargetIDs, adminAliases)
+ }
+ for k := range rooms[i].Messages[j].Mentions {
+ rooms[i].Messages[j].Mentions[k].ID = migrateLegacyAdminID(rooms[i].Messages[j].Mentions[k].ID, adminAliases)
+ }
+ }
+ }
+ return rooms
+}
+
func migrateLegacyManagerRoomRefs(rooms []Room, managerAliases map[string]struct{}) []Room {
for i := range rooms {
rooms[i].Members = migrateLegacyManagerIDs(rooms[i].Members, managerAliases)
for j := range rooms[i].Messages {
rooms[i].Messages[j].SenderID = migrateLegacyManagerID(rooms[i].Messages[j].SenderID, managerAliases)
rooms[i].Messages[j].Content = migrateLegacyManagerMentionTags(rooms[i].Messages[j].Content, managerAliases)
+ if rooms[i].Messages[j].Event != nil {
+ rooms[i].Messages[j].Event.ActorID = migrateLegacyManagerID(rooms[i].Messages[j].Event.ActorID, managerAliases)
+ rooms[i].Messages[j].Event.TargetIDs = migrateLegacyManagerIDs(rooms[i].Messages[j].Event.TargetIDs, managerAliases)
+ }
for k := range rooms[i].Messages[j].Mentions {
rooms[i].Messages[j].Mentions[k].ID = migrateLegacyManagerID(rooms[i].Messages[j].Mentions[k].ID, managerAliases)
}
@@ -672,6 +742,26 @@ func migrateLegacyManagerRoomRefs(rooms []Room, managerAliases map[string]struct
return rooms
}
+func migrateLegacyAdminIDs(ids []string, adminAliases map[string]struct{}) []string {
+ if len(ids) == 0 {
+ return nil
+ }
+ out := make([]string, 0, len(ids))
+ seen := make(map[string]struct{}, len(ids))
+ for _, id := range ids {
+ id = migrateLegacyAdminID(id, adminAliases)
+ if id == "" {
+ continue
+ }
+ if _, ok := seen[id]; ok {
+ continue
+ }
+ seen[id] = struct{}{}
+ out = append(out, id)
+ }
+ return out
+}
+
func migrateLegacyManagerIDs(ids []string, managerAliases map[string]struct{}) []string {
if len(ids) == 0 {
return nil
@@ -692,6 +782,14 @@ func migrateLegacyManagerIDs(ids []string, managerAliases map[string]struct{}) [
return out
}
+func migrateLegacyAdminID(id string, adminAliases map[string]struct{}) string {
+ id = strings.TrimSpace(id)
+ if _, ok := adminAliases[id]; ok {
+ return adminUserID
+ }
+ return id
+}
+
func migrateLegacyManagerID(id string, managerAliases map[string]struct{}) string {
id = strings.TrimSpace(id)
if _, ok := managerAliases[id]; ok {
@@ -700,6 +798,16 @@ func migrateLegacyManagerID(id string, managerAliases map[string]struct{}) strin
return id
}
+func migrateLegacyAdminMentionTags(content string, adminAliases map[string]struct{}) string {
+ for id := range adminAliases {
+ if id == adminUserID {
+ continue
+ }
+ content = strings.ReplaceAll(content, `user_id="`+id+`"`, `user_id="`+adminUserID+`"`)
+ }
+ return content
+}
+
func migrateLegacyManagerMentionTags(content string, managerAliases map[string]struct{}) string {
for id := range managerAliases {
if id == managerParticipantUserID {
@@ -1088,6 +1196,7 @@ func (s *Service) DeleteUser(userID string) error {
}
s.mu.Lock()
+ userID = s.resolveUserIDLocked(userID)
user, ok := s.users[userID]
if !ok {
s.mu.Unlock()
@@ -1266,10 +1375,11 @@ func (s *Service) UpdateAgentUser(req UpdateAgentUserRequest) (User, bool, error
func (s *Service) CreateMessage(req CreateMessageRequest) (Message, error) {
content := strings.TrimSpace(req.Content)
roomID := strings.TrimSpace(req.RoomID)
+ senderID := strings.TrimSpace(req.SenderID)
if roomID == "" {
return Message{}, fmt.Errorf("room_id is required")
}
- if req.SenderID == "" {
+ if senderID == "" {
return Message{}, fmt.Errorf("sender_id is required")
}
if content == "" {
@@ -1279,7 +1389,8 @@ func (s *Service) CreateMessage(req CreateMessageRequest) (Message, error) {
s.mu.Lock()
defer s.mu.Unlock()
- if _, ok := s.users[req.SenderID]; !ok {
+ senderID = s.resolveUserIDLocked(senderID)
+ if _, ok := s.users[senderID]; !ok {
return Message{}, fmt.Errorf("sender not found")
}
content, err := s.contentWithMentionPrefixLocked(content, req.MentionID)
@@ -1299,7 +1410,7 @@ func (s *Service) CreateMessage(req CreateMessageRequest) (Message, error) {
s.ensureThreadStateLocked(room, relatesTo.EventID)
}
- message := s.newMessage("", req.SenderID, MessageKindMessage, content)
+ message := s.newMessage("", senderID, MessageKindMessage, content)
message.RelatesTo = relatesTo
room.Messages = append(room.Messages, message)
if err := s.saveLocked(); err != nil {
@@ -1399,21 +1510,23 @@ func (s *Service) publishMessageCreatedLocked(roomID, senderID string, message M
func (s *Service) CreateRoom(req CreateRoomRequest) (Room, error) {
title := strings.TrimSpace(req.Title)
description := strings.TrimSpace(req.Description)
+ creatorID := strings.TrimSpace(req.CreatorID)
if title == "" {
return Room{}, fmt.Errorf("title is required")
}
- if req.CreatorID == "" {
+ if creatorID == "" {
return Room{}, fmt.Errorf("creator_id is required")
}
s.mu.Lock()
defer s.mu.Unlock()
- if _, ok := s.users[req.CreatorID]; !ok {
+ creatorID = s.resolveUserIDLocked(creatorID)
+ if _, ok := s.users[creatorID]; !ok {
return Room{}, fmt.Errorf("creator not found")
}
- members, err := s.normalizeMembers(req.CreatorID, req.MemberIDs)
+ members, err := s.normalizeMembers(creatorID, req.MemberIDs)
if err != nil {
return Room{}, err
}
@@ -1428,11 +1541,11 @@ func (s *Service) CreateRoom(req CreateRoomRequest) (Room, error) {
Messages: []Message{
{
ID: fmt.Sprintf("msg-%d", time.Now().UnixNano()),
- SenderID: req.CreatorID,
+ SenderID: creatorID,
Kind: MessageKindEvent,
Event: &EventPayload{
Key: "room_created",
- ActorID: req.CreatorID,
+ ActorID: creatorID,
Title: title,
},
CreatedAt: time.Now().UTC(),
@@ -1453,10 +1566,11 @@ func (s *Service) CreateConversation(req CreateConversationRequest) (Conversatio
func (s *Service) AddRoomMembers(req AddRoomMembersRequest) (Room, error) {
roomID := strings.TrimSpace(req.RoomID)
+ inviterID := strings.TrimSpace(req.InviterID)
if roomID == "" {
return Room{}, fmt.Errorf("room_id is required")
}
- if req.InviterID == "" {
+ if inviterID == "" {
return Room{}, fmt.Errorf("inviter_id is required")
}
if len(req.UserIDs) == 0 {
@@ -1470,10 +1584,11 @@ func (s *Service) AddRoomMembers(req AddRoomMembersRequest) (Room, error) {
if !ok {
return Room{}, fmt.Errorf("room not found")
}
- if _, ok := s.users[req.InviterID]; !ok {
+ inviterID = s.resolveUserIDLocked(inviterID)
+ if _, ok := s.users[inviterID]; !ok {
return Room{}, fmt.Errorf("inviter not found")
}
- if !slices.Contains(room.Members, req.InviterID) {
+ if !slices.Contains(room.Members, inviterID) {
return Room{}, fmt.Errorf("inviter is not a room member")
}
if room.IsDirect {
@@ -1487,7 +1602,7 @@ func (s *Service) AddRoomMembers(req AddRoomMembersRequest) (Room, error) {
addedIDs := make([]string, 0, len(req.UserIDs))
for _, userID := range req.UserIDs {
- userID = strings.TrimSpace(userID)
+ userID = s.resolveUserIDLocked(userID)
if userID == "" {
continue
}
@@ -1508,11 +1623,11 @@ func (s *Service) AddRoomMembers(req AddRoomMembersRequest) (Room, error) {
room.Subtitle = formatRoomSubtitle(len(room.Members))
room.Messages = append(room.Messages, Message{
ID: fmt.Sprintf("msg-%d", time.Now().UnixNano()),
- SenderID: req.InviterID,
+ SenderID: inviterID,
Kind: MessageKindEvent,
Event: &EventPayload{
Key: "room_members_added",
- ActorID: req.InviterID,
+ ActorID: inviterID,
TargetIDs: append([]string(nil), addedIDs...),
},
CreatedAt: time.Now().UTC(),
@@ -1566,6 +1681,7 @@ func (s *Service) User(userID string) (User, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
+ userID = s.resolveUserIDLocked(userID)
user, ok := s.users[userID]
return user, ok
}
@@ -1614,7 +1730,7 @@ func (s *Service) normalizeMembers(creatorID string, memberIDs []string) ([]stri
seen := map[string]struct{}{creatorID: {}}
members := []string{creatorID}
for _, userID := range memberIDs {
- userID = strings.TrimSpace(userID)
+ userID = s.resolveUserIDLocked(userID)
if userID == "" {
continue
}
@@ -2179,7 +2295,7 @@ func (s *Service) newMessage(messageID, senderID, kind, content string) Message
}
func (s *Service) contentWithMentionPrefixLocked(content, mentionID string) (string, error) {
- mentionID = strings.TrimSpace(mentionID)
+ mentionID = s.resolveUserIDLocked(mentionID)
if mentionID == "" {
return content, nil
}
@@ -2308,7 +2424,7 @@ func (s *Service) ensureAdminAgentRoomLocked(agentID, agentName string) (*Room,
if len(room.Members) != 2 {
continue
}
- if containsUserIDInRoom(*room, "u-admin") && containsUserIDInRoom(*room, agentID) {
+ if containsUserIDInRoom(*room, adminUserID) && containsUserIDInRoom(*room, agentID) {
presented := s.presentRoomLocked(*room)
return &presented, false
}
@@ -2321,7 +2437,7 @@ func (s *Service) ensureAdminAgentRoomLocked(agentID, agentName string) (*Room,
Subtitle: formatRoomSubtitle(2),
Description: fmt.Sprintf("Bootstrap room for admin and %s.", agentName),
IsDirect: true,
- Members: []string{"u-admin", agentID},
+ Members: []string{adminUserID, agentID},
Messages: []Message{
{
ID: fmt.Sprintf("msg-%d", now.UnixNano()+1),
diff --git a/internal/im/service_test.go b/internal/im/service_test.go
index be69655f..2b10bb2a 100644
--- a/internal/im/service_test.go
+++ b/internal/im/service_test.go
@@ -30,7 +30,7 @@ func TestEnsureWorkerUserCreatesUserAndBootstrapRoom(t *testing.T) {
if !room.IsDirect {
t.Fatalf("EnsureWorkerUser() room.IsDirect = %v, want true", room.IsDirect)
}
- if len(room.Members) != 2 || !containsUserIDInRoom(*room, "u-admin") || !containsUserIDInRoom(*room, "u-alice") {
+ if len(room.Members) != 2 || !containsUserIDInRoom(*room, "admin") || !containsUserIDInRoom(*room, "u-alice") {
t.Fatalf("EnsureWorkerUser() room members = %+v, want admin and worker", room.Members)
}
}
@@ -72,7 +72,7 @@ func TestListMembersReturnsRoomMembers(t *testing.T) {
if err != nil {
t.Fatalf("ListMembers() error = %v", err)
}
- if len(members) != 2 || members[0].ID != "u-admin" || members[1].ID != "u-alice" {
+ if len(members) != 2 || members[0].ID != "admin" || members[1].ID != "u-alice" {
t.Fatalf("ListMembers() = %+v, want room members in member order", members)
}
}
@@ -111,8 +111,8 @@ func TestAddAgentToRoomSupportsRoomID(t *testing.T) {
t.Fatalf("AddAgentToRoom() members = %+v, want agent joined", updated.Members)
}
last := updated.Messages[len(updated.Messages)-1]
- if last.Event == nil || last.Event.Key != "room_members_added" || last.Event.ActorID != "u-admin" {
- t.Fatalf("AddAgentToRoom() event = %+v, want structured room_members_added by u-admin", last)
+ if last.Event == nil || last.Event.Key != "room_members_added" || last.Event.ActorID != "admin" {
+ t.Fatalf("AddAgentToRoom() event = %+v, want structured room_members_added by admin", last)
}
if len(last.Event.TargetIDs) != 1 || last.Event.TargetIDs[0] != "u-alice" {
t.Fatalf("AddAgentToRoom() target_ids = %+v, want [u-alice]", last.Event.TargetIDs)
@@ -141,7 +141,7 @@ func TestCreateRoomStoresStructuredEvent(t *testing.T) {
t.Fatalf("CreateRoom() room.IsDirect = %v, want false", room.IsDirect)
}
got := room.Messages[0]
- if got.Kind != MessageKindEvent || got.Event == nil || got.Event.Key != "room_created" || got.Event.ActorID != "u-admin" || got.Event.Title != "Ops" {
+ if got.Kind != MessageKindEvent || got.Event == nil || got.Event.Key != "room_created" || got.Event.ActorID != "admin" || got.Event.Title != "Ops" {
t.Fatalf("CreateRoom() event = %+v, want structured room_created event", got)
}
if got.Content != "" {
@@ -830,7 +830,7 @@ func TestEnsureBootstrapStateCreatesAdminManagerDMWhenOnlyGroupExists(t *testing
var dm *Room
for i := range loaded.Rooms {
room := &loaded.Rooms[i]
- if room.IsDirect && len(room.Members) == 2 && containsUserIDInRoom(*room, "u-admin") && containsUserIDInRoom(*room, "manager") {
+ if room.IsDirect && len(room.Members) == 2 && containsUserIDInRoom(*room, "admin") && containsUserIDInRoom(*room, "manager") {
dm = room
break
}
@@ -899,6 +899,62 @@ func TestEnsureBootstrapStateMigratesMisspelledManagerReferences(t *testing.T) {
}
}
+func TestEnsureBootstrapStateMigratesLegacyAdminReferences(t *testing.T) {
+ dir := t.TempDir()
+ statePath := filepath.Join(dir, "state.json")
+
+ state := Bootstrap{
+ CurrentUserID: "u-admin",
+ Users: []User{
+ {ID: "u-admin", Name: "admin", Handle: "admin", Role: "admin"},
+ {ID: "manager", Name: "manager", Handle: "manager", Role: "manager"},
+ {ID: "u-alice", Name: "Alice", Handle: "alice", Role: "worker"},
+ },
+ Rooms: []Room{{
+ ID: "room-1",
+ Title: "Ops",
+ Members: []string{"u-admin", "manager", "u-alice"},
+ Messages: []Message{{
+ ID: "msg-1",
+ SenderID: "u-admin",
+ Event: &EventPayload{Key: "room_created", ActorID: "u-admin", TargetIDs: []string{"u-admin"}},
+ Content: `admin hello`,
+ CreatedAt: time.Now().UTC(),
+ Mentions: []Mention{{ID: "u-admin", Name: "admin"}},
+ }},
+ }},
+ }
+ if err := SaveBootstrap(statePath, state); err != nil {
+ t.Fatalf("SaveBootstrap() error = %v", err)
+ }
+
+ if err := EnsureBootstrapState(statePath); err != nil {
+ t.Fatalf("EnsureBootstrapState() error = %v", err)
+ }
+
+ loaded, err := LoadBootstrap(statePath)
+ if err != nil {
+ t.Fatalf("LoadBootstrap() error = %v", err)
+ }
+ if loaded.CurrentUserID != "admin" {
+ t.Fatalf("CurrentUserID = %q, want admin", loaded.CurrentUserID)
+ }
+ if containsUserID(loaded.Users, "u-admin") {
+ t.Fatal("legacy admin user u-admin still exists")
+ }
+ room := loaded.Rooms[0]
+ if !containsUserIDInRoom(room, "admin") || containsUserIDInRoom(room, "u-admin") {
+ t.Fatalf("room.Members = %+v, want admin only", room.Members)
+ }
+ got := room.Messages[0]
+ if got.SenderID != "admin" || got.Event == nil || got.Event.ActorID != "admin" || len(got.Event.TargetIDs) != 1 || got.Event.TargetIDs[0] != "admin" || len(got.Mentions) != 1 || got.Mentions[0].ID != "admin" {
+ t.Fatalf("message = %+v, want admin sender and mention", got)
+ }
+ if !strings.Contains(got.Content, `user_id="admin"`) || strings.Contains(got.Content, "u-admin") {
+ t.Fatalf("message.Content = %q, want admin mention tag", got.Content)
+ }
+}
+
func TestReloadRefreshesRoomsFromStateFile(t *testing.T) {
dir := t.TempDir()
statePath := filepath.Join(dir, "state.json")
diff --git a/internal/im/user_resolve.go b/internal/im/user_resolve.go
index e533e2b8..c25feffe 100644
--- a/internal/im/user_resolve.go
+++ b/internal/im/user_resolve.go
@@ -19,6 +19,11 @@ func (s *Service) resolveUserIDLocked(userID string) string {
if userID == "" || s == nil {
return userID
}
+ if userID == legacyAdminUserID {
+ if _, ok := s.users[adminUserID]; ok {
+ return adminUserID
+ }
+ }
if _, ok := s.users[userID]; ok {
return userID
}
diff --git a/internal/onboard/detect.go b/internal/onboard/detect.go
index 2b58fcf0..a6ad0d9d 100644
--- a/internal/onboard/detect.go
+++ b/internal/onboard/detect.go
@@ -47,6 +47,7 @@ type DetectStateResult struct {
ConfigComplete bool
IMBootstrapComplete bool
ManagerAgentComplete bool
+ AdminParticipantComplete bool
ManagerParticipantComplete bool
}
@@ -55,6 +56,7 @@ func (r DetectStateResult) Complete() bool {
r.ConfigComplete &&
r.IMBootstrapComplete &&
r.ManagerAgentComplete &&
+ r.AdminParticipantComplete &&
r.ManagerParticipantComplete
}
@@ -115,7 +117,9 @@ func DetectState(opts DetectStateOptions) (DetectStateResult, error) {
if err != nil {
return DetectStateResult{}, err
}
- result.ManagerParticipantComplete = managerParticipantComplete(store.List(participant.ListOptions{Channel: participant.ChannelCSGClaw}))
+ participants := store.List(participant.ListOptions{Channel: participant.ChannelCSGClaw})
+ result.AdminParticipantComplete = adminParticipantComplete(participants)
+ result.ManagerParticipantComplete = managerParticipantComplete(participants)
return result, nil
}
@@ -125,7 +129,7 @@ func imBootstrapComplete(state im.Bootstrap) bool {
if len(state.InviteDraftUserIDs) > 0 {
return false
}
- if !hasIMUser(state.Users, "u-admin", "admin", "admin") {
+ if !hasIMUser(state.Users, im.AdminUserID, "admin", "admin") {
return false
}
if !hasIMUser(state.Users, agent.ManagerParticipantID, "manager", "manager") {
@@ -134,7 +138,7 @@ func imBootstrapComplete(state im.Bootstrap) bool {
for _, room := range state.Rooms {
if room.IsDirect &&
len(room.Members) == 2 &&
- containsMember(room.Members, "u-admin") &&
+ containsMember(room.Members, im.AdminUserID) &&
containsMember(room.Members, agent.ManagerParticipantID) {
return true
}
@@ -184,6 +188,25 @@ func managerAgentComplete(state agentStateReader) bool {
return strings.EqualFold(strings.TrimSpace(managerAgent.Role), agent.RoleManager)
}
+func adminParticipantComplete(items []participant.Participant) bool {
+ for _, item := range items {
+ if strings.TrimSpace(item.Channel) != participant.ChannelCSGClaw {
+ continue
+ }
+ if strings.TrimSpace(item.ID) != im.AdminUserID {
+ continue
+ }
+ if !strings.EqualFold(strings.TrimSpace(item.Type), participant.TypeHuman) {
+ return false
+ }
+ if strings.TrimSpace(item.AgentID) != "" {
+ return false
+ }
+ return strings.TrimSpace(item.ChannelUserRef) == im.AdminUserID
+ }
+ return false
+}
+
func managerParticipantComplete(items []participant.Participant) bool {
for _, item := range items {
if strings.TrimSpace(item.Channel) != participant.ChannelCSGClaw {
diff --git a/internal/onboard/detect_test.go b/internal/onboard/detect_test.go
index 44b5894b..6cf92c69 100644
--- a/internal/onboard/detect_test.go
+++ b/internal/onboard/detect_test.go
@@ -34,6 +34,9 @@ func TestDetectStateFreshHomeReportsIncompleteBootstrap(t *testing.T) {
if result.ManagerAgentComplete {
t.Fatal("ManagerAgentComplete = true, want false")
}
+ if result.AdminParticipantComplete {
+ t.Fatal("AdminParticipantComplete = true, want false")
+ }
if result.ManagerParticipantComplete {
t.Fatal("ManagerParticipantComplete = true, want false")
}
@@ -90,13 +93,27 @@ func TestDetectStateCompleteBootstrapReportsComplete(t *testing.T) {
}); err != nil {
t.Fatalf("writeManagerBotState() error = %v", err)
}
+ if err := writeParticipantsState(t, []apitypes.Participant{{
+ ID: im.AdminUserID,
+ Channel: participant.ChannelCSGClaw,
+ Type: participant.TypeHuman,
+ Name: "admin",
+ ChannelUserRef: im.AdminUserID,
+ ChannelUserKind: participant.ChannelUserKindLocalUserID,
+ LifecycleStatus: participant.LifecycleStatusActive,
+ Mentionable: true,
+ CreatedAt: time.Date(2026, 5, 1, 8, 0, 0, 0, time.UTC),
+ UpdatedAt: time.Date(2026, 5, 1, 8, 0, 0, 0, time.UTC),
+ }}); err != nil {
+ t.Fatalf("writeParticipantsState() error = %v", err)
+ }
result, err := DetectState(DetectStateOptions{})
if err != nil {
t.Fatalf("DetectState() error = %v", err)
}
- if !result.ConfigExists || !result.ConfigComplete || !result.IMBootstrapComplete || !result.ManagerAgentComplete || !result.ManagerParticipantComplete {
+ if !result.ConfigExists || !result.ConfigComplete || !result.IMBootstrapComplete || !result.ManagerAgentComplete || !result.AdminParticipantComplete || !result.ManagerParticipantComplete {
t.Fatalf("DetectState() completeness = %+v, want all true", result)
}
if !result.Complete() {
@@ -135,6 +152,9 @@ func TestDetectStateFlagsMissingManagerBotWhenOtherBootstrapStateExists(t *testi
if !result.ConfigExists || !result.ConfigComplete || !result.IMBootstrapComplete || !result.ManagerAgentComplete {
t.Fatalf("DetectState() = %+v, want config/im/agent complete", result)
}
+ if result.AdminParticipantComplete {
+ t.Fatal("AdminParticipantComplete = true, want false")
+ }
if result.ManagerParticipantComplete {
t.Fatal("ManagerParticipantComplete = true, want false")
}
@@ -143,6 +163,58 @@ func TestDetectStateFlagsMissingManagerBotWhenOtherBootstrapStateExists(t *testi
}
}
+func TestDetectStateFlagsMissingAdminParticipantWhenManagerParticipantExists(t *testing.T) {
+ t.Setenv("HOME", t.TempDir())
+
+ configPath, err := config.DefaultPath()
+ if err != nil {
+ t.Fatalf("DefaultPath() error = %v", err)
+ }
+ if err := defaultConfig().Save(configPath); err != nil {
+ t.Fatalf("Save() error = %v", err)
+ }
+ imStatePath, err := config.DefaultIMStatePath()
+ if err != nil {
+ t.Fatalf("DefaultIMStatePath() error = %v", err)
+ }
+ if err := im.EnsureBootstrapState(imStatePath); err != nil {
+ t.Fatalf("EnsureBootstrapState() error = %v", err)
+ }
+ if err := writeManagerAgentState(t); err != nil {
+ t.Fatalf("writeManagerAgentState() error = %v", err)
+ }
+ if err := writeParticipantsState(t, []apitypes.Participant{{
+ ID: agent.ManagerParticipantID,
+ Channel: participant.ChannelCSGClaw,
+ Type: participant.TypeAgent,
+ Name: "manager",
+ ChannelUserRef: agent.ManagerParticipantID,
+ ChannelUserKind: participant.ChannelUserKindLocalUserID,
+ AgentID: agent.ManagerUserID,
+ LifecycleStatus: participant.LifecycleStatusActive,
+ Mentionable: true,
+ CreatedAt: time.Date(2026, 5, 1, 8, 0, 0, 0, time.UTC),
+ UpdatedAt: time.Date(2026, 5, 1, 8, 0, 0, 0, time.UTC),
+ }}); err != nil {
+ t.Fatalf("writeParticipantsState() error = %v", err)
+ }
+
+ result, err := DetectState(DetectStateOptions{})
+ if err != nil {
+ t.Fatalf("DetectState() error = %v", err)
+ }
+
+ if result.AdminParticipantComplete {
+ t.Fatal("AdminParticipantComplete = true for manager-only participant state, want false")
+ }
+ if !result.ManagerParticipantComplete {
+ t.Fatal("ManagerParticipantComplete = false, want true for manager participant fixture")
+ }
+ if result.Complete() {
+ t.Fatalf("Complete() = true for manager-only participant state: %+v", result)
+ }
+}
+
func writeManagerAgentState(t *testing.T) error {
t.Helper()
@@ -195,6 +267,27 @@ func writeManagerBotState(t *testing.T, manager apitypes.LegacyBot) error {
return os.WriteFile(path, append(data, '\n'), 0o600)
}
+func writeParticipantsState(t *testing.T, participants []apitypes.Participant) error {
+ t.Helper()
+
+ imStatePath, err := config.DefaultIMStatePath()
+ if err != nil {
+ return err
+ }
+ path := filepath.Join(filepath.Dir(imStatePath), "participants.json")
+ if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
+ return err
+ }
+
+ data, err := json.MarshalIndent(map[string]any{
+ "participants": participants,
+ }, "", " ")
+ if err != nil {
+ return err
+ }
+ return os.WriteFile(path, append(data, '\n'), 0o600)
+}
+
func assertLegacyBotsMigrated(t *testing.T) {
t.Helper()
diff --git a/internal/onboard/onboard.go b/internal/onboard/onboard.go
index 0e97a55c..8fe1dd1f 100644
--- a/internal/onboard/onboard.go
+++ b/internal/onboard/onboard.go
@@ -150,6 +150,9 @@ func createManagerParticipant(ctx context.Context, agentsPath, imStatePath strin
participant.WithAgentService(agentSvc),
participant.WithIMService(imSvc),
)
+ if _, err := participantSvc.EnsureBootstrapAdmin(ctx); err != nil {
+ return participant.Participant{}, err
+ }
created, err := participantSvc.EnsureBootstrapManager(ctx)
if err != nil {
return participant.Participant{}, err
diff --git a/internal/onboard/onboard_test.go b/internal/onboard/onboard_test.go
index a547610f..40701f1d 100644
--- a/internal/onboard/onboard_test.go
+++ b/internal/onboard/onboard_test.go
@@ -9,6 +9,7 @@ import (
"csgclaw/internal/agent"
"csgclaw/internal/config"
+ "csgclaw/internal/im"
"csgclaw/internal/participant"
)
@@ -93,6 +94,39 @@ func TestEnsureStateCreatesConfigAndBootstrapsManagerState(t *testing.T) {
}
}
+func TestCreateManagerParticipantBootstrapsAdminParticipant(t *testing.T) {
+ t.Setenv("HOME", t.TempDir())
+
+ dir := t.TempDir()
+ agentsPath := filepath.Join(dir, "agents.json")
+ imStatePath := filepath.Join(dir, "im", "state.json")
+ if err := im.EnsureBootstrapState(imStatePath); err != nil {
+ t.Fatalf("EnsureBootstrapState() error = %v", err)
+ }
+
+ if _, err := createManagerParticipant(context.Background(), agentsPath, imStatePath, defaultConfig()); err != nil {
+ t.Fatalf("createManagerParticipant() error = %v", err)
+ }
+
+ store, err := participant.NewStore(filepath.Join(filepath.Dir(imStatePath), "participants.json"))
+ if err != nil {
+ t.Fatalf("participant.NewStore() error = %v", err)
+ }
+ admin, ok := store.Get(participant.ChannelCSGClaw, im.AdminUserID)
+ if !ok {
+ t.Fatal("admin participant was not created")
+ }
+ if admin.Type != participant.TypeHuman {
+ t.Fatalf("admin participant type = %q, want %q", admin.Type, participant.TypeHuman)
+ }
+ if admin.AgentID != "" {
+ t.Fatalf("admin participant agent_id = %q, want empty", admin.AgentID)
+ }
+ if admin.ChannelUserRef != im.AdminUserID {
+ t.Fatalf("admin participant channel_user_ref = %q, want %q", admin.ChannelUserRef, im.AdminUserID)
+ }
+}
+
func TestEnsureStatePreservesExistingStaticLLMConfig(t *testing.T) {
t.Setenv("HOME", t.TempDir())
diff --git a/internal/participant/service.go b/internal/participant/service.go
index f77eac12..152df44b 100644
--- a/internal/participant/service.go
+++ b/internal/participant/service.go
@@ -108,6 +108,71 @@ func (s *Service) Create(ctx context.Context, req CreateRequest) (apitypes.Parti
return created, nil
}
+func (s *Service) EnsureBootstrapAdmin(_ context.Context) (apitypes.Participant, error) {
+ if s == nil || s.store == nil {
+ return apitypes.Participant{}, fmt.Errorf("participant store is required")
+ }
+
+ now := time.Now().UTC()
+ createdAt := now
+ existing, ok := s.store.Get(ChannelCSGClaw, im.AdminUserID)
+ legacyExisting, legacyOK := s.store.Get(ChannelCSGClaw, legacyAdminParticipantID)
+ source := existing
+ hasLegacySource := false
+ if !ok && legacyOK && isLegacyAdminParticipant(legacyExisting) {
+ source = legacyExisting
+ hasLegacySource = true
+ }
+ if (ok || hasLegacySource) && !source.CreatedAt.IsZero() {
+ createdAt = source.CreatedAt.UTC()
+ }
+
+ name := strings.TrimSpace(source.Name)
+ if name == "" {
+ name = "admin"
+ }
+ avatar := strings.TrimSpace(source.Avatar)
+ metadata := map[string]any(nil)
+ if ok || hasLegacySource {
+ metadata = cloneMetadata(source.Metadata)
+ }
+ if s.im != nil {
+ if _, _, err := s.im.EnsureAgentUser(im.EnsureAgentUserRequest{
+ ID: im.AdminUserID,
+ Name: "admin",
+ Handle: "admin",
+ Role: "admin",
+ Avatar: avatar,
+ }); err != nil {
+ return apitypes.Participant{}, err
+ }
+ }
+
+ item := apitypes.Participant{
+ ID: im.AdminUserID,
+ Channel: ChannelCSGClaw,
+ Type: TypeHuman,
+ Name: name,
+ Avatar: avatar,
+ ChannelUserRef: im.AdminUserID,
+ ChannelUserKind: ChannelUserKindLocalUserID,
+ LifecycleStatus: LifecycleStatusActive,
+ Mentionable: true,
+ Metadata: metadata,
+ CreatedAt: createdAt,
+ UpdatedAt: now,
+ }
+ if err := s.store.Save(item); err != nil {
+ return apitypes.Participant{}, err
+ }
+ if legacyOK && isLegacyAdminParticipant(legacyExisting) {
+ if _, _, err := s.store.Delete(ChannelCSGClaw, legacyAdminParticipantID); err != nil {
+ return apitypes.Participant{}, err
+ }
+ }
+ return item, nil
+}
+
func (s *Service) EnsureBootstrapManager(ctx context.Context) (apitypes.Participant, error) {
if s == nil || s.store == nil {
return apitypes.Participant{}, fmt.Errorf("participant store is required")
@@ -193,6 +258,21 @@ func (s *Service) EnsureBootstrapManager(ctx context.Context) (apitypes.Particip
return item, nil
}
+const (
+ bootstrapAdminParticipantID = "admin"
+ legacyAdminParticipantID = "u-admin"
+)
+
+func isLegacyAdminParticipant(item apitypes.Participant) bool {
+ if strings.TrimSpace(item.ID) != legacyAdminParticipantID {
+ return false
+ }
+ if strings.TrimSpace(item.Channel) != ChannelCSGClaw {
+ return false
+ }
+ return true
+}
+
func (s *Service) legacyManagerParticipants() []apitypes.Participant {
if s == nil || s.store == nil {
return nil
diff --git a/internal/participant/service_test.go b/internal/participant/service_test.go
index 3b7ff973..3a411726 100644
--- a/internal/participant/service_test.go
+++ b/internal/participant/service_test.go
@@ -105,6 +105,84 @@ func TestCreateAgentParticipantCanReuseExistingAgentWithDifferentParticipantID(t
}
}
+func TestEnsureBootstrapAdminCreatesHumanParticipantWithoutAgent(t *testing.T) {
+ imSvc := im.NewService()
+ store := NewMemoryStore(nil)
+ svc := NewService(store, WithIMService(imSvc))
+
+ created, err := svc.EnsureBootstrapAdmin(context.Background())
+ if err != nil {
+ t.Fatalf("EnsureBootstrapAdmin() error = %v", err)
+ }
+
+ if created.ID != im.AdminUserID {
+ t.Fatalf("participant ID = %q, want %q", created.ID, im.AdminUserID)
+ }
+ if created.Type != TypeHuman {
+ t.Fatalf("participant type = %q, want %q", created.Type, TypeHuman)
+ }
+ if created.AgentID != "" {
+ t.Fatalf("agent ID = %q, want empty for human admin", created.AgentID)
+ }
+ if created.ChannelUserRef != im.AdminUserID {
+ t.Fatalf("channel user ref = %q, want %q", created.ChannelUserRef, im.AdminUserID)
+ }
+ if created.ChannelUserKind != ChannelUserKindLocalUserID {
+ t.Fatalf("channel user kind = %q, want %q", created.ChannelUserKind, ChannelUserKindLocalUserID)
+ }
+ if !created.Mentionable {
+ t.Fatal("admin participant Mentionable = false, want true")
+ }
+ if _, ok := store.Get(ChannelCSGClaw, im.AdminUserID); !ok {
+ t.Fatal("store missing admin participant")
+ }
+ if user, ok := imSvc.User(im.AdminUserID); !ok || user.ID != im.AdminUserID || user.Handle != "admin" || user.Role != "admin" {
+ t.Fatalf("admin channel user = %+v, ok=%v; want local admin user", user, ok)
+ }
+}
+
+func TestEnsureBootstrapAdminRenamesLegacyAdminParticipant(t *testing.T) {
+ imSvc := im.NewService()
+ createdAt := time.Date(2026, 6, 9, 10, 5, 0, 0, time.UTC)
+ store := NewMemoryStore([]Participant{{
+ ID: "u-admin",
+ Channel: ChannelCSGClaw,
+ Type: TypeHuman,
+ Name: "Local Admin",
+ Avatar: "avatar.png",
+ ChannelUserRef: "u-admin",
+ ChannelUserKind: ChannelUserKindLocalUserID,
+ AgentID: "u-admin",
+ LifecycleStatus: LifecycleStatusActive,
+ Mentionable: true,
+ Metadata: map[string]any{"legacy": "kept"},
+ CreatedAt: createdAt,
+ UpdatedAt: createdAt,
+ }})
+ svc := NewService(store, WithIMService(imSvc))
+
+ created, err := svc.EnsureBootstrapAdmin(context.Background())
+ if err != nil {
+ t.Fatalf("EnsureBootstrapAdmin() error = %v", err)
+ }
+
+ if created.ID != im.AdminUserID || created.Type != TypeHuman {
+ t.Fatalf("admin participant = %+v, want human participant %q", created, im.AdminUserID)
+ }
+ if created.AgentID != "" {
+ t.Fatalf("agent ID = %q, want empty after admin migration", created.AgentID)
+ }
+ if created.ChannelUserRef != im.AdminUserID {
+ t.Fatalf("channel user ref = %q, want %q", created.ChannelUserRef, im.AdminUserID)
+ }
+ if !created.CreatedAt.Equal(createdAt) || created.Avatar != "avatar.png" || created.Metadata["legacy"] != "kept" {
+ t.Fatalf("admin participant did not preserve legacy fields: %+v", created)
+ }
+ if _, ok := store.Get(ChannelCSGClaw, "u-admin"); ok {
+ t.Fatal("legacy admin participant u-admin was not deleted")
+ }
+}
+
func TestEnsureBootstrapManagerUsesDefaultParticipantIDSeparateFromAgentID(t *testing.T) {
agentSvc := mustNewManagerAgentService(t)
imSvc := im.NewService()
diff --git a/internal/participant/store.go b/internal/participant/store.go
index 0ed27c3c..360ad508 100644
--- a/internal/participant/store.go
+++ b/internal/participant/store.go
@@ -144,9 +144,10 @@ func (s *Store) load() error {
if err != nil {
return err
}
+ repairedLegacyAdmin := migrateLegacyCSGClawAdminParticipant(items)
repairedLegacyIDs := migrateLegacyCSGClawAgentParticipantIDs(items)
s.items = items
- if legacyExists || repairedLegacyIDs {
+ if legacyExists || repairedLegacyAdmin || repairedLegacyIDs {
if err := s.saveLocked(); err != nil {
return fmt.Errorf("write migrated participant state: %w", err)
}
@@ -260,6 +261,105 @@ func storeKey(channel, id string) string {
return strings.TrimSpace(channel) + "\x00" + strings.TrimSpace(id)
}
+func migrateLegacyCSGClawAdminParticipant(items map[string]apitypes.Participant) bool {
+ if len(items) == 0 {
+ return false
+ }
+
+ changed := false
+ adminKey := storeKey(ChannelCSGClaw, bootstrapAdminParticipantID)
+ legacyKey := storeKey(ChannelCSGClaw, legacyAdminParticipantID)
+ if legacy, ok := items[legacyKey]; ok && isLegacyStoredAdminParticipant(legacy) {
+ next := repairStoredAdminParticipant(legacy)
+ if existing, exists := items[adminKey]; exists {
+ next = mergeAdminParticipant(existing, next)
+ }
+ items[adminKey] = next
+ delete(items, legacyKey)
+ changed = true
+ }
+
+ if existing, ok := items[adminKey]; ok && adminParticipantNeedsRepair(existing) {
+ items[adminKey] = repairStoredAdminParticipant(existing)
+ changed = true
+ }
+ return changed
+}
+
+func isLegacyStoredAdminParticipant(item apitypes.Participant) bool {
+ item = normalizeStoredParticipant(item)
+ return item.Channel == ChannelCSGClaw && item.ID == legacyAdminParticipantID
+}
+
+func adminParticipantNeedsRepair(item apitypes.Participant) bool {
+ item = normalizeStoredParticipant(item)
+ if item.Channel != ChannelCSGClaw || item.ID != bootstrapAdminParticipantID {
+ return false
+ }
+ return item.Type != TypeHuman ||
+ item.Name == "" ||
+ item.ChannelUserRef != bootstrapAdminParticipantID ||
+ item.ChannelUserKind != ChannelUserKindLocalUserID ||
+ item.AgentID != "" ||
+ item.LifecycleStatus == "" ||
+ !item.Mentionable
+}
+
+func repairStoredAdminParticipant(item apitypes.Participant) apitypes.Participant {
+ item = normalizeStoredParticipant(item)
+ item.ID = bootstrapAdminParticipantID
+ item.Channel = ChannelCSGClaw
+ item.Type = TypeHuman
+ if item.Name == "" {
+ item.Name = bootstrapAdminParticipantID
+ }
+ item.ChannelUserRef = bootstrapAdminParticipantID
+ item.ChannelUserKind = ChannelUserKindLocalUserID
+ item.AgentID = ""
+ if item.LifecycleStatus == "" {
+ item.LifecycleStatus = LifecycleStatusActive
+ }
+ item.Mentionable = true
+ return item
+}
+
+func mergeAdminParticipant(existing, legacy apitypes.Participant) apitypes.Participant {
+ merged := repairStoredAdminParticipant(existing)
+ legacy = repairStoredAdminParticipant(legacy)
+ if merged.Name == "" {
+ merged.Name = legacy.Name
+ }
+ if merged.Avatar == "" {
+ merged.Avatar = legacy.Avatar
+ }
+ if merged.ChannelUserKind == "" {
+ merged.ChannelUserKind = legacy.ChannelUserKind
+ }
+ if merged.LifecycleStatus == "" {
+ merged.LifecycleStatus = legacy.LifecycleStatus
+ }
+ if merged.Presence == "" {
+ merged.Presence = legacy.Presence
+ }
+ merged.Mentionable = true
+ if merged.Metadata == nil {
+ merged.Metadata = cloneParticipant(legacy).Metadata
+ } else {
+ for key, value := range legacy.Metadata {
+ if _, ok := merged.Metadata[key]; !ok {
+ merged.Metadata[key] = value
+ }
+ }
+ }
+ if merged.CreatedAt.IsZero() || (!legacy.CreatedAt.IsZero() && legacy.CreatedAt.Before(merged.CreatedAt)) {
+ merged.CreatedAt = legacy.CreatedAt
+ }
+ if merged.UpdatedAt.IsZero() || legacy.UpdatedAt.After(merged.UpdatedAt) {
+ merged.UpdatedAt = legacy.UpdatedAt
+ }
+ return merged
+}
+
func migrateLegacyCSGClawAgentParticipantIDs(items map[string]apitypes.Participant) bool {
if len(items) == 0 {
return false
diff --git a/internal/participant/store_test.go b/internal/participant/store_test.go
index a8bc8867..a8e2c1ce 100644
--- a/internal/participant/store_test.go
+++ b/internal/participant/store_test.go
@@ -166,6 +166,68 @@ func TestNewStoreRepairsLegacyPrefixedAgentParticipants(t *testing.T) {
}
}
+func TestNewStoreRepairsLegacyAdminParticipant(t *testing.T) {
+ dir := t.TempDir()
+ participantsPath := filepath.Join(dir, "participants.json")
+ createdAt := time.Date(2026, 6, 9, 11, 30, 0, 0, time.UTC)
+ writeJSONFile(t, participantsPath, persistedState{Participants: []apitypes.Participant{
+ {
+ ID: "u-admin",
+ Channel: ChannelCSGClaw,
+ Type: TypeAgent,
+ Name: "Admin",
+ Avatar: "avatar.png",
+ ChannelUserRef: "u-admin",
+ ChannelUserKind: ChannelUserKindLocalUserID,
+ AgentID: "u-admin",
+ LifecycleStatus: LifecycleStatusActive,
+ Mentionable: true,
+ Metadata: map[string]any{"legacy": "kept"},
+ CreatedAt: createdAt,
+ UpdatedAt: createdAt.Add(time.Minute),
+ },
+ }})
+
+ store, err := NewStore(participantsPath)
+ if err != nil {
+ t.Fatalf("NewStore() error = %v", err)
+ }
+
+ admin, ok := store.Get(ChannelCSGClaw, "admin")
+ if !ok {
+ t.Fatal("admin participant was not repaired from legacy u-admin")
+ }
+ if admin.Type != TypeHuman {
+ t.Fatalf("admin type = %q, want %q", admin.Type, TypeHuman)
+ }
+ if admin.AgentID != "" {
+ t.Fatalf("admin agent_id = %q, want empty for human participant", admin.AgentID)
+ }
+ if admin.ChannelUserRef != "admin" || admin.ChannelUserKind != ChannelUserKindLocalUserID {
+ t.Fatalf("admin channel identity = %+v, want local user admin", admin)
+ }
+ if !admin.Mentionable || admin.LifecycleStatus != LifecycleStatusActive {
+ t.Fatalf("admin lifecycle fields = %+v, want active mentionable participant", admin)
+ }
+ if !admin.CreatedAt.Equal(createdAt) || !admin.UpdatedAt.Equal(createdAt.Add(time.Minute)) || admin.Avatar != "avatar.png" || admin.Metadata["legacy"] != "kept" {
+ t.Fatalf("admin preserved fields = %+v, want legacy fields preserved", admin)
+ }
+ if _, ok := store.Get(ChannelCSGClaw, "u-admin"); ok {
+ t.Fatal("legacy admin participant u-admin still exists after repair")
+ }
+
+ reloaded, err := NewStore(participantsPath)
+ if err != nil {
+ t.Fatalf("reload NewStore() error = %v", err)
+ }
+ if _, ok := reloaded.Get(ChannelCSGClaw, "admin"); !ok {
+ t.Fatal("reloaded store missing repaired admin participant")
+ }
+ if _, ok := reloaded.Get(ChannelCSGClaw, "u-admin"); ok {
+ t.Fatal("reloaded store still has legacy participant u-admin")
+ }
+}
+
func writeJSONFile(t *testing.T, path string, value any) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py b/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py
index b04055c0..04a0a8a5 100644
--- a/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py
+++ b/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py
@@ -499,7 +499,7 @@ def _mock_response(
payload: dict[str, Any] | None,
) -> dict[str, Any] | list[dict[str, Any]]:
if method == "GET" and path == "/api/v1/bootstrap":
- return {"current_user_id": "u-admin", "users": [], "rooms": []}
+ return {"current_user_id": "admin", "users": [], "rooms": []}
if method == "GET" and (
path.startswith("/api/v1/messages?") or path.startswith("/api/v1/channels/feishu/messages?")
diff --git a/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/test_manager_worker_api.py b/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/test_manager_worker_api.py
index a98fc04b..aa34ee42 100644
--- a/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/test_manager_worker_api.py
+++ b/internal/templates/embed/openclaw-manager/workspace/skills/manager-worker-dispatch/scripts/test_manager_worker_api.py
@@ -42,7 +42,7 @@ def make_message(sender_id, content, created_at):
def make_bootstrap():
return {
- "current_user_id": "u-admin",
+ "current_user_id": "admin",
"users": [
{"id": "u-manager", "handle": "manager", "name": "manager"},
{"id": "u-ux", "handle": "ux", "name": "ux"},
@@ -52,7 +52,7 @@ def make_bootstrap():
"rooms": [
{
"id": ROOM_ID,
- "members": ["u-admin", "u-manager", "u-ux", "u-dev", "u-qa"],
+ "members": ["admin", "u-manager", "u-ux", "u-dev", "u-qa"],
}
],
}
diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py b/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py
index b04055c0..04a0a8a5 100644
--- a/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py
+++ b/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/manager_worker_api.py
@@ -499,7 +499,7 @@ def _mock_response(
payload: dict[str, Any] | None,
) -> dict[str, Any] | list[dict[str, Any]]:
if method == "GET" and path == "/api/v1/bootstrap":
- return {"current_user_id": "u-admin", "users": [], "rooms": []}
+ return {"current_user_id": "admin", "users": [], "rooms": []}
if method == "GET" and (
path.startswith("/api/v1/messages?") or path.startswith("/api/v1/channels/feishu/messages?")
diff --git a/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/test_manager_worker_api.py b/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/test_manager_worker_api.py
index a98fc04b..aa34ee42 100644
--- a/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/test_manager_worker_api.py
+++ b/internal/templates/embed/picoclaw-manager/workspace/skills/manager-worker-dispatch/scripts/test_manager_worker_api.py
@@ -42,7 +42,7 @@ def make_message(sender_id, content, created_at):
def make_bootstrap():
return {
- "current_user_id": "u-admin",
+ "current_user_id": "admin",
"users": [
{"id": "u-manager", "handle": "manager", "name": "manager"},
{"id": "u-ux", "handle": "ux", "name": "ux"},
@@ -52,7 +52,7 @@ def make_bootstrap():
"rooms": [
{
"id": ROOM_ID,
- "members": ["u-admin", "u-manager", "u-ux", "u-dev", "u-qa"],
+ "members": ["admin", "u-manager", "u-ux", "u-dev", "u-qa"],
}
],
}
diff --git a/internal/upgrade/restart_daemon.go b/internal/upgrade/restart_daemon.go
index b8ff1262..019f95c9 100644
--- a/internal/upgrade/restart_daemon.go
+++ b/internal/upgrade/restart_daemon.go
@@ -39,7 +39,7 @@ func RestartDaemon(ctx context.Context, exePath string, opts RestartOptions) (Re
return RestartResult{}, fmt.Errorf("stop running daemon: %w", err)
}
- if err := runUpgradeCommand(ctx, exePath, commandArgsWithConfig(opts.ConfigPath, "serve", "-d")...); err != nil {
+ if err := runUpgradeCommand(ctx, exePath, commandArgsWithConfig(opts.ConfigPath, "serve", "--daemon")...); err != nil {
return RestartResult{}, fmt.Errorf("restart daemon: %w", err)
}
diff --git a/internal/upgrade/upgrade_test.go b/internal/upgrade/upgrade_test.go
index f14ef356..5df2390f 100644
--- a/internal/upgrade/upgrade_test.go
+++ b/internal/upgrade/upgrade_test.go
@@ -682,7 +682,7 @@ func TestClientRestartIfRunningStopsAndStartsDaemon(t *testing.T) {
}
want := [][]string{
{filepath.Join(installRoot, "bin", "csgclaw"), "stop"},
- {filepath.Join(installRoot, "bin", "csgclaw"), "--config", "/tmp/custom.toml", "serve", "-d"},
+ {filepath.Join(installRoot, "bin", "csgclaw"), "--config", "/tmp/custom.toml", "serve", "--daemon"},
}
if !reflect.DeepEqual(calls, want) {
t.Fatalf("exec calls = %#v, want %#v", calls, want)
diff --git a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx
index 70c26fa6..58b225db 100644
--- a/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx
+++ b/web/app/src/pages/AgentPage/components/AgentDetailPane/AgentDetailPane.tsx
@@ -116,6 +116,7 @@ export function AgentDetailPane({
onStart,
onStop,
onRecreate,
+ onUpgrade,
onDelete,
onInvite,
onOpenDM,
@@ -172,6 +173,16 @@ export function AgentDetailPane({
>
{t("openDM")}
+ {onUpgrade ? (
+
+ ) : null}
@@ -117,6 +119,7 @@ export function AgentRow({
onStart,
onStop,
onRecreate,
+ onUpgrade,
onDelete,
onInvite,
}: AgentRowProps) {
@@ -177,6 +180,15 @@ export function AgentRow({
) : null}
{!isNotification ? (
<>
+ {onUpgrade ? (
+
+ ) : null}