From fc6405e4be72649c485bc775ca49101e86b2f345 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:07:39 +0800 Subject: [PATCH 1/7] fix(trigger): allow on_comment when thread root @mentions assignee agent (#382) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a member-started thread root @mentions the assignee agent, replies in that thread should trigger on_comment — the thread is a conversation with the agent, not a member-to-member chat. Previously isReplyToMemberThread only checked the reply content for assignee mentions. Now it also checks the parent (thread root) content. This fixes a gap where path 1 (on_comment) suppressed the trigger and path 2 (on_mention) skipped the assignee, leaving no trigger path. --- .../comment_trigger_integration_test.go | 14 +++++++ server/internal/handler/comment.go | 14 ++++++- server/internal/handler/trigger_test.go | 38 +++++++++++++++++-- 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/server/cmd/server/comment_trigger_integration_test.go b/server/cmd/server/comment_trigger_integration_test.go index cfa2028c..b9337dad 100644 --- a/server/cmd/server/comment_trigger_integration_test.go +++ b/server/cmd/server/comment_trigger_integration_test.go @@ -230,6 +230,20 @@ func TestCommentTriggerOnComment(t *testing.T) { t.Errorf("expected 1 pending task (assignee mentioned in member thread), got %d", n) } }) + + t.Run("reply to member thread that @mentioned assignee triggers without re-mention", func(t *testing.T) { + clearTasks(t, issueID) + // Member starts a thread that @mentions the assignee agent. + content := fmt.Sprintf("[@Agent](mention://agent/%s) can you review this?", agentID) + threadID := postComment(t, issueID, content, nil) + // Clear the task created by the top-level mention. + clearTasks(t, issueID) + // Reply in the thread WITHOUT re-mentioning the assignee. + postComment(t, issueID, "Here is more context for you", strPtr(threadID)) + if n := countPendingTasks(t, issueID); n != 1 { + t.Errorf("expected 1 pending task (assignee mentioned in thread root), got %d", n) + } + }) } // TestCommentTriggerAtAllSuppression verifies that @all mentions do not diff --git a/server/internal/handler/comment.go b/server/internal/handler/comment.go index d17917a8..95c00fa5 100644 --- a/server/internal/handler/comment.go +++ b/server/internal/handler/comment.go @@ -227,6 +227,8 @@ func (h *Handler) commentMentionsOthersButNotAssignee(content string, issue db.I // continuing a human conversation — not requesting work from the assigned agent. // Replying to an agent-started thread, or explicitly @mentioning the assignee // in the reply, still triggers on_comment as expected. +// If the parent (thread root) itself @mentions the assignee, the thread is +// considered a conversation with the agent, so replies are allowed to trigger. func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issue db.Issue) bool { if parent == nil { return false // Not a reply — normal top-level comment @@ -235,14 +237,22 @@ func (h *Handler) isReplyToMemberThread(parent *db.Comment, content string, issu return false // Thread started by an agent — allow trigger } // Thread was started by a member. Suppress on_comment unless the reply - // explicitly @mentions the assignee agent. + // or the parent explicitly @mentions the assignee agent. if !issue.AssigneeID.Valid { return true // No assignee to mention } assigneeID := uuidToString(issue.AssigneeID) + // Check current comment mentions. for _, m := range util.ParseMentions(content) { if m.ID == assigneeID { - return false // Assignee explicitly mentioned — allow trigger + return false // Assignee explicitly mentioned in reply — allow trigger + } + } + // Check parent (thread root) mentions — if the thread was started by + // mentioning the assignee, replies continue that conversation. + for _, m := range util.ParseMentions(parent.Content) { + if m.ID == assigneeID { + return false // Assignee mentioned in thread root — allow trigger } } return true // Reply to member thread without mentioning agent — suppress diff --git a/server/internal/handler/trigger_test.go b/server/internal/handler/trigger_test.go index e2358b88..9fe7a950 100644 --- a/server/internal/handler/trigger_test.go +++ b/server/internal/handler/trigger_test.go @@ -117,8 +117,20 @@ func TestIsReplyToMemberThread(t *testing.T) { h := &Handler{} issue := issueWithAgentAssignee() - memberParent := &db.Comment{AuthorType: "member", AuthorID: testUUID(memberID)} - agentParent := &db.Comment{AuthorType: "agent", AuthorID: testUUID(agentAssigneeID)} + memberParent := &db.Comment{AuthorType: "member", AuthorID: testUUID(memberID), Content: "plain thread starter"} + agentParent := &db.Comment{AuthorType: "agent", AuthorID: testUUID(agentAssigneeID), Content: "agent thread starter"} + // Member-started thread root that @mentions the assignee agent. + memberParentMentioningAssignee := &db.Comment{ + AuthorType: "member", + AuthorID: testUUID(memberID), + Content: fmt.Sprintf("[@Agent](mention://agent/%s) can you look at this?", agentAssigneeID), + } + // Member-started thread root that @mentions a non-assignee agent. + memberParentMentioningOther := &db.Comment{ + AuthorType: "member", + AuthorID: testUUID(memberID), + Content: fmt.Sprintf("[@Other](mention://agent/%s) what do you think?", otherAgentID), + } tests := []struct { name string @@ -168,6 +180,18 @@ func TestIsReplyToMemberThread(t *testing.T) { content: fmt.Sprintf("[@Other](mention://agent/%s) take a look", otherAgentID), want: true, }, + { + name: "reply to member thread that @mentioned assignee, no re-mention → allow", + parent: memberParentMentioningAssignee, + content: "here is more context for you", + want: false, + }, + { + name: "reply to member thread that @mentioned other agent, no re-mention → suppress", + parent: memberParentMentioningOther, + content: "here is more context", + want: true, // parent mentioned other agent, not assignee — still suppress on_comment + }, } for _, tt := range tests { @@ -188,8 +212,13 @@ func TestOnCommentTriggerDecision(t *testing.T) { h := &Handler{} issue := issueWithAgentAssignee() - memberParent := &db.Comment{AuthorType: "member", AuthorID: testUUID(memberID)} - agentParent := &db.Comment{AuthorType: "agent", AuthorID: testUUID(agentAssigneeID)} + memberParent := &db.Comment{AuthorType: "member", AuthorID: testUUID(memberID), Content: "plain thread starter"} + agentParent := &db.Comment{AuthorType: "agent", AuthorID: testUUID(agentAssigneeID), Content: "agent thread starter"} + memberParentMentioningAssignee := &db.Comment{ + AuthorType: "member", + AuthorID: testUUID(memberID), + Content: fmt.Sprintf("[@Agent](mention://agent/%s) help me", agentAssigneeID), + } // Simulates the combined check from CreateComment: // !commentMentionsOthersButNotAssignee && !isReplyToMemberThread @@ -213,6 +242,7 @@ func TestOnCommentTriggerDecision(t *testing.T) { {"reply member thread, no mention", memberParent, "agreed", false}, {"reply member thread, mention other member", memberParent, fmt.Sprintf("[@Bob](mention://member/%s) ok", memberID), false}, {"reply member thread, mention assignee", memberParent, fmt.Sprintf("[@Agent](mention://agent/%s) help", agentAssigneeID), true}, + {"reply member thread that @mentioned assignee, no re-mention", memberParentMentioningAssignee, "here is more info", true}, {"top-level, @all broadcast", nil, "[@All](mention://all/all) heads up team", false}, {"reply agent thread, @all broadcast", agentParent, "[@All](mention://all/all) update for everyone", false}, {"reply member thread, @all broadcast", memberParent, "[@All](mention://all/all) fyi", false}, From e314badf1898cb54433a82cc15ada55897315087 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Fri, 3 Apr 2026 15:39:27 +0800 Subject: [PATCH 2/7] fix(web): filter archived agents from all dropdown selectors Add `!a.archived_at` check to agent filters in create-issue modal, issues-header filter panel, issue-detail assignee dropdown, and issue-detail subscriber list. assignee-picker and mention-suggestion already filter correctly. --- apps/web/features/issues/components/issue-detail.tsx | 6 +++--- apps/web/features/issues/components/issues-header.tsx | 2 +- apps/web/features/modals/create-issue.tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index 4343a2ba..163d3730 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -513,7 +513,7 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo {issue.assignee_type === "member" && issue.assignee_id === m.user_id && } ))} - {agents.filter((a) => canAssignAgent(a, user?.id, currentMemberRole)).map((a) => ( + {agents.filter((a) => !a.archived_at && canAssignAgent(a, user?.id, currentMemberRole)).map((a) => ( handleUpdateField({ assignee_type: "agent", assignee_id: a.id })} @@ -742,9 +742,9 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo })} )} - {agents.length > 0 && ( + {agents.filter((a) => !a.archived_at).length > 0 && ( - {agents.map((a) => { + {agents.filter((a) => !a.archived_at).map((a) => { const sub = subscribers.find((s) => s.user_type === "agent" && s.user_id === a.id); const isSubbed = !!sub; return ( diff --git a/apps/web/features/issues/components/issues-header.tsx b/apps/web/features/issues/components/issues-header.tsx index 20556fc8..cdd25813 100644 --- a/apps/web/features/issues/components/issues-header.tsx +++ b/apps/web/features/issues/components/issues-header.tsx @@ -162,7 +162,7 @@ function ActorSubContent({ m.name.toLowerCase().includes(query), ); const filteredAgents = agents.filter((a) => - a.name.toLowerCase().includes(query), + !a.archived_at && a.name.toLowerCase().includes(query), ); const isSelected = (type: "member" | "agent", id: string) => diff --git a/apps/web/features/modals/create-issue.tsx b/apps/web/features/modals/create-issue.tsx index bc11bb46..c2d13059 100644 --- a/apps/web/features/modals/create-issue.tsx +++ b/apps/web/features/modals/create-issue.tsx @@ -99,7 +99,7 @@ export function CreateIssueModal({ onClose, data }: { onClose: () => void; data? const assigneeQuery = assigneeFilter.toLowerCase(); const filteredMembers = members.filter((m) => m.name.toLowerCase().includes(assigneeQuery)); - const filteredAgents = agents.filter((a) => a.name.toLowerCase().includes(assigneeQuery)); + const filteredAgents = agents.filter((a) => !a.archived_at && a.name.toLowerCase().includes(assigneeQuery)); const assigneeLabel = assigneeType && assigneeId From 32a3a3543df57db6f3357d7ef982488e7dda0c43 Mon Sep 17 00:00:00 2001 From: Bohan Jiang <52446949+Bohan-J@users.noreply.github.com> Date: Fri, 3 Apr 2026 15:40:15 +0800 Subject: [PATCH 3/7] docs(web): add v0.1.5 changelog entry for 2026-04-02 (#386) --- apps/web/features/landing/i18n/en.ts | 17 +++++++++++++++++ apps/web/features/landing/i18n/zh.ts | 17 +++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/apps/web/features/landing/i18n/en.ts b/apps/web/features/landing/i18n/en.ts index 7ae26655..99249e1f 100644 --- a/apps/web/features/landing/i18n/en.ts +++ b/apps/web/features/landing/i18n/en.ts @@ -272,6 +272,23 @@ export const en: LandingDict = { title: "Changelog", subtitle: "New updates and improvements to Multica.", entries: [ + { + version: "0.1.5", + date: "2026-04-02", + title: "Mentions & Permissions", + changes: [ + "@mention issues in comments with server-side auto-expansion", + "@all mention to notify every workspace member", + "Inbox auto-scrolls to the referenced comment from a notification", + "Repositories extracted into a standalone settings tab", + "CLI update support from the web runtime page and direct download for non-Homebrew installs", + "CLI commands for viewing issue execution runs and run messages", + "Agent permission model — owners and admins manage agents, members manage skills on their own agents", + "Per-issue serial execution to prevent concurrent task collisions", + "File upload now supports all file types", + "README redesign with quickstart guide", + ], + }, { version: "0.1.4", date: "2026-04-01", diff --git a/apps/web/features/landing/i18n/zh.ts b/apps/web/features/landing/i18n/zh.ts index 9f87b9f6..7c2554eb 100644 --- a/apps/web/features/landing/i18n/zh.ts +++ b/apps/web/features/landing/i18n/zh.ts @@ -272,6 +272,23 @@ export const zh: LandingDict = { title: "\u66f4\u65b0\u65e5\u5fd7", subtitle: "Multica \u7684\u6700\u65b0\u66f4\u65b0\u548c\u6539\u8fdb\u3002", entries: [ + { + version: "0.1.5", + date: "2026-04-02", + title: "提及与权限", + changes: [ + "评论中支持 @提及 Issue,服务端自动展开", + "支持 @all 提及工作区所有成员", + "收件箱通知点击后自动滚动到对应评论", + "仓库管理独立为设置页单独标签页", + "支持从网页端运行时页面更新 CLI,非 Homebrew 安装支持直接下载更新", + "新增 CLI 命令查看 Issue 执行记录和运行消息", + "Agent 权限模型优化——所有者和管理员管理 Agent,成员可管理自己 Agent 的技能", + "每个 Issue 串行执行,防止并发任务冲突", + "文件上传支持所有文件类型", + "README 重新设计,新增快速入门指南", + ], + }, { version: "0.1.4", date: "2026-04-01", From 10b482fac2f5af5990944c5b1546d57568d7b3ca Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Fri, 3 Apr 2026 15:41:17 +0800 Subject: [PATCH 4/7] feat(cli): add agent, skill, and runtime management commands Expand CLI coverage for agent/skill/runtime APIs that previously had no CLI wrappers despite having fully implemented backend endpoints. Agent commands (was: only list): - agent get/create/update/archive/restore/tasks - agent skills list/set - agent list --include-archived Skill commands (new, was: 0% coverage): - skill list/get/create/update/delete/import - skill files list/upsert/delete Runtime commands (new, was: 0% coverage): - runtime list/usage/activity/ping/update - ping and update support --wait for polling --- server/cmd/multica/cmd_agent.go | 437 +++++++++++++++++++++++++++++- server/cmd/multica/cmd_runtime.go | 303 +++++++++++++++++++++ server/cmd/multica/cmd_skill.go | 416 ++++++++++++++++++++++++++++ server/cmd/multica/main.go | 2 + 4 files changed, 1155 insertions(+), 3 deletions(-) create mode 100644 server/cmd/multica/cmd_runtime.go create mode 100644 server/cmd/multica/cmd_skill.go diff --git a/server/cmd/multica/cmd_agent.go b/server/cmd/multica/cmd_agent.go index d5391544..11d4834c 100644 --- a/server/cmd/multica/cmd_agent.go +++ b/server/cmd/multica/cmd_agent.go @@ -2,9 +2,11 @@ package main import ( "context" + "encoding/json" "fmt" "net/url" "os" + "strings" "time" "github.com/spf13/cobra" @@ -24,10 +26,122 @@ var agentListCmd = &cobra.Command{ RunE: runAgentList, } +var agentGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get agent details", + Args: cobra.ExactArgs(1), + RunE: runAgentGet, +} + +var agentCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new agent", + RunE: runAgentCreate, +} + +var agentUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update an agent", + Args: cobra.ExactArgs(1), + RunE: runAgentUpdate, +} + +var agentArchiveCmd = &cobra.Command{ + Use: "archive ", + Short: "Archive an agent", + Args: cobra.ExactArgs(1), + RunE: runAgentArchive, +} + +var agentRestoreCmd = &cobra.Command{ + Use: "restore ", + Short: "Restore an archived agent", + Args: cobra.ExactArgs(1), + RunE: runAgentRestore, +} + +var agentTasksCmd = &cobra.Command{ + Use: "tasks ", + Short: "List tasks for an agent", + Args: cobra.ExactArgs(1), + RunE: runAgentTasks, +} + +// Agent skills subcommands. + +var agentSkillsCmd = &cobra.Command{ + Use: "skills", + Short: "Manage agent skill assignments", +} + +var agentSkillsListCmd = &cobra.Command{ + Use: "list ", + Short: "List skills assigned to an agent", + Args: cobra.ExactArgs(1), + RunE: runAgentSkillsList, +} + +var agentSkillsSetCmd = &cobra.Command{ + Use: "set ", + Short: "Set skills for an agent (replaces all current assignments)", + Args: cobra.ExactArgs(1), + RunE: runAgentSkillsSet, +} + func init() { agentCmd.AddCommand(agentListCmd) + agentCmd.AddCommand(agentGetCmd) + agentCmd.AddCommand(agentCreateCmd) + agentCmd.AddCommand(agentUpdateCmd) + agentCmd.AddCommand(agentArchiveCmd) + agentCmd.AddCommand(agentRestoreCmd) + agentCmd.AddCommand(agentTasksCmd) + agentCmd.AddCommand(agentSkillsCmd) + agentSkillsCmd.AddCommand(agentSkillsListCmd) + agentSkillsCmd.AddCommand(agentSkillsSetCmd) + + // agent list agentListCmd.Flags().String("output", "table", "Output format: table or json") + agentListCmd.Flags().Bool("include-archived", false, "Include archived agents") + + // agent get + agentGetCmd.Flags().String("output", "json", "Output format: table or json") + + // agent create + agentCreateCmd.Flags().String("name", "", "Agent name (required)") + agentCreateCmd.Flags().String("description", "", "Agent description") + agentCreateCmd.Flags().String("instructions", "", "Agent instructions") + agentCreateCmd.Flags().String("runtime-id", "", "Runtime ID (required)") + agentCreateCmd.Flags().String("visibility", "private", "Visibility: private or workspace") + agentCreateCmd.Flags().Int32("max-concurrent-tasks", 6, "Maximum concurrent tasks") + agentCreateCmd.Flags().String("output", "json", "Output format: table or json") + + // agent update + agentUpdateCmd.Flags().String("name", "", "New name") + agentUpdateCmd.Flags().String("description", "", "New description") + agentUpdateCmd.Flags().String("instructions", "", "New instructions") + agentUpdateCmd.Flags().String("runtime-id", "", "New runtime ID") + agentUpdateCmd.Flags().String("visibility", "", "New visibility: private or workspace") + agentUpdateCmd.Flags().String("status", "", "New status") + agentUpdateCmd.Flags().Int32("max-concurrent-tasks", 0, "New max concurrent tasks") + agentUpdateCmd.Flags().String("output", "json", "Output format: table or json") + + // agent archive + agentArchiveCmd.Flags().String("output", "json", "Output format: table or json") + + // agent restore + agentRestoreCmd.Flags().String("output", "json", "Output format: table or json") + + // agent tasks + agentTasksCmd.Flags().String("output", "table", "Output format: table or json") + + // agent skills list + agentSkillsListCmd.Flags().String("output", "table", "Output format: table or json") + + // agent skills set + agentSkillsSetCmd.Flags().StringSlice("skill-ids", nil, "Skill IDs to assign (comma-separated)") + agentSkillsSetCmd.Flags().String("output", "json", "Output format: table or json") } // resolveProfile returns the --profile flag value (empty string means default profile). @@ -90,6 +204,10 @@ func resolveWorkspaceID(cmd *cobra.Command) string { return cfg.WorkspaceID } +// --------------------------------------------------------------------------- +// Agent commands +// --------------------------------------------------------------------------- + func runAgentList(cmd *cobra.Command, _ []string) error { client, err := newAPIClient(cmd) if err != nil { @@ -100,9 +218,16 @@ func runAgentList(cmd *cobra.Command, _ []string) error { defer cancel() var agents []map[string]any - path := "/api/agents" + params := url.Values{} if client.WorkspaceID != "" { - path += "?" + url.Values{"workspace_id": {client.WorkspaceID}}.Encode() + params.Set("workspace_id", client.WorkspaceID) + } + if v, _ := cmd.Flags().GetBool("include-archived"); v { + params.Set("include_archived", "true") + } + path := "/api/agents" + if len(params) > 0 { + path += "?" + params.Encode() } if err := client.GetJSON(ctx, path, &agents); err != nil { return fmt.Errorf("list agents: %w", err) @@ -113,20 +238,326 @@ func runAgentList(cmd *cobra.Command, _ []string) error { return cli.PrintJSON(os.Stdout, agents) } - headers := []string{"ID", "NAME", "STATUS", "RUNTIME"} + headers := []string{"ID", "NAME", "STATUS", "RUNTIME", "ARCHIVED"} rows := make([][]string, 0, len(agents)) for _, a := range agents { + archived := "" + if v := strVal(a, "archived_at"); v != "" { + archived = "yes" + } rows = append(rows, []string{ strVal(a, "id"), strVal(a, "name"), strVal(a, "status"), strVal(a, "runtime_mode"), + archived, }) } cli.PrintTable(os.Stdout, headers, rows) return nil } +func runAgentGet(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var agent map[string]any + if err := client.GetJSON(ctx, "/api/agents/"+args[0], &agent); err != nil { + return fmt.Errorf("get agent: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, agent) + } + + headers := []string{"ID", "NAME", "STATUS", "RUNTIME", "VISIBILITY", "DESCRIPTION"} + rows := [][]string{{ + strVal(agent, "id"), + strVal(agent, "name"), + strVal(agent, "status"), + strVal(agent, "runtime_mode"), + strVal(agent, "visibility"), + strVal(agent, "description"), + }} + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +func runAgentCreate(cmd *cobra.Command, _ []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + name, _ := cmd.Flags().GetString("name") + if name == "" { + return fmt.Errorf("--name is required") + } + runtimeID, _ := cmd.Flags().GetString("runtime-id") + if runtimeID == "" { + return fmt.Errorf("--runtime-id is required") + } + + body := map[string]any{ + "name": name, + "runtime_id": runtimeID, + } + if v, _ := cmd.Flags().GetString("description"); v != "" { + body["description"] = v + } + if v, _ := cmd.Flags().GetString("instructions"); v != "" { + body["instructions"] = v + } + if cmd.Flags().Changed("visibility") { + v, _ := cmd.Flags().GetString("visibility") + body["visibility"] = v + } + if cmd.Flags().Changed("max-concurrent-tasks") { + v, _ := cmd.Flags().GetInt32("max-concurrent-tasks") + body["max_concurrent_tasks"] = v + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var result map[string]any + if err := client.PostJSON(ctx, "/api/agents", body, &result); err != nil { + return fmt.Errorf("create agent: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, result) + } + + fmt.Printf("Agent created: %s (%s)\n", strVal(result, "name"), strVal(result, "id")) + return nil +} + +func runAgentUpdate(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + body := map[string]any{} + if cmd.Flags().Changed("name") { + v, _ := cmd.Flags().GetString("name") + body["name"] = v + } + if cmd.Flags().Changed("description") { + v, _ := cmd.Flags().GetString("description") + body["description"] = v + } + if cmd.Flags().Changed("instructions") { + v, _ := cmd.Flags().GetString("instructions") + body["instructions"] = v + } + if cmd.Flags().Changed("runtime-id") { + v, _ := cmd.Flags().GetString("runtime-id") + body["runtime_id"] = v + } + if cmd.Flags().Changed("visibility") { + v, _ := cmd.Flags().GetString("visibility") + body["visibility"] = v + } + if cmd.Flags().Changed("status") { + v, _ := cmd.Flags().GetString("status") + body["status"] = v + } + if cmd.Flags().Changed("max-concurrent-tasks") { + v, _ := cmd.Flags().GetInt32("max-concurrent-tasks") + body["max_concurrent_tasks"] = v + } + + if len(body) == 0 { + return fmt.Errorf("no fields to update; use --name, --description, --instructions, --runtime-id, --visibility, --status, or --max-concurrent-tasks") + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var result map[string]any + if err := client.PutJSON(ctx, "/api/agents/"+args[0], body, &result); err != nil { + return fmt.Errorf("update agent: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, result) + } + + fmt.Printf("Agent updated: %s (%s)\n", strVal(result, "name"), strVal(result, "id")) + return nil +} + +func runAgentArchive(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var result map[string]any + if err := client.PostJSON(ctx, "/api/agents/"+args[0]+"/archive", nil, &result); err != nil { + return fmt.Errorf("archive agent: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, result) + } + + fmt.Printf("Agent archived: %s (%s)\n", strVal(result, "name"), strVal(result, "id")) + return nil +} + +func runAgentRestore(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var result map[string]any + if err := client.PostJSON(ctx, "/api/agents/"+args[0]+"/restore", nil, &result); err != nil { + return fmt.Errorf("restore agent: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, result) + } + + fmt.Printf("Agent restored: %s (%s)\n", strVal(result, "name"), strVal(result, "id")) + return nil +} + +func runAgentTasks(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var tasks []map[string]any + if err := client.GetJSON(ctx, "/api/agents/"+args[0]+"/tasks", &tasks); err != nil { + return fmt.Errorf("list agent tasks: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, tasks) + } + + headers := []string{"ID", "ISSUE_ID", "STATUS", "CREATED_AT"} + rows := make([][]string, 0, len(tasks)) + for _, t := range tasks { + rows = append(rows, []string{ + strVal(t, "id"), + strVal(t, "issue_id"), + strVal(t, "status"), + strVal(t, "created_at"), + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +// --------------------------------------------------------------------------- +// Agent skills subcommands +// --------------------------------------------------------------------------- + +func runAgentSkillsList(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var skills []map[string]any + if err := client.GetJSON(ctx, "/api/agents/"+args[0]+"/skills", &skills); err != nil { + return fmt.Errorf("list agent skills: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, skills) + } + + headers := []string{"ID", "NAME", "DESCRIPTION"} + rows := make([][]string, 0, len(skills)) + for _, s := range skills { + rows = append(rows, []string{ + strVal(s, "id"), + strVal(s, "name"), + strVal(s, "description"), + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +func runAgentSkillsSet(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + skillIDs, _ := cmd.Flags().GetStringSlice("skill-ids") + if len(skillIDs) == 0 { + return fmt.Errorf("--skill-ids is required (comma-separated skill IDs, or empty string to clear)") + } + // Allow passing empty string to clear all skills. + cleanIDs := make([]string, 0, len(skillIDs)) + for _, id := range skillIDs { + id = strings.TrimSpace(id) + if id != "" { + cleanIDs = append(cleanIDs, id) + } + } + + body := map[string]any{ + "skill_ids": cleanIDs, + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var result json.RawMessage + if err := client.PutJSON(ctx, "/api/agents/"+args[0]+"/skills", body, &result); err != nil { + return fmt.Errorf("set agent skills: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + var pretty any + json.Unmarshal(result, &pretty) + return cli.PrintJSON(os.Stdout, pretty) + } + + fmt.Printf("Skills updated for agent %s\n", args[0]) + return nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + func strVal(m map[string]any, key string) string { v, ok := m[key] if !ok || v == nil { diff --git a/server/cmd/multica/cmd_runtime.go b/server/cmd/multica/cmd_runtime.go new file mode 100644 index 00000000..b73593d4 --- /dev/null +++ b/server/cmd/multica/cmd_runtime.go @@ -0,0 +1,303 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/multica-ai/multica/server/internal/cli" +) + +var runtimeCmd = &cobra.Command{ + Use: "runtime", + Short: "Manage agent runtimes", +} + +var runtimeListCmd = &cobra.Command{ + Use: "list", + Short: "List runtimes in the workspace", + RunE: runRuntimeList, +} + +var runtimeUsageCmd = &cobra.Command{ + Use: "usage ", + Short: "Get token usage for a runtime", + Args: cobra.ExactArgs(1), + RunE: runRuntimeUsage, +} + +var runtimeActivityCmd = &cobra.Command{ + Use: "activity ", + Short: "Get hourly task activity for a runtime", + Args: cobra.ExactArgs(1), + RunE: runRuntimeActivity, +} + +var runtimePingCmd = &cobra.Command{ + Use: "ping ", + Short: "Ping a runtime to check connectivity", + Args: cobra.ExactArgs(1), + RunE: runRuntimePing, +} + +var runtimeUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Initiate a CLI update on a runtime", + Args: cobra.ExactArgs(1), + RunE: runRuntimeUpdate, +} + +func init() { + runtimeCmd.AddCommand(runtimeListCmd) + runtimeCmd.AddCommand(runtimeUsageCmd) + runtimeCmd.AddCommand(runtimeActivityCmd) + runtimeCmd.AddCommand(runtimePingCmd) + runtimeCmd.AddCommand(runtimeUpdateCmd) + + // runtime list + runtimeListCmd.Flags().String("output", "table", "Output format: table or json") + + // runtime usage + runtimeUsageCmd.Flags().String("output", "table", "Output format: table or json") + runtimeUsageCmd.Flags().Int("days", 90, "Number of days of usage data to retrieve (max 365)") + + // runtime activity + runtimeActivityCmd.Flags().String("output", "table", "Output format: table or json") + + // runtime ping + runtimePingCmd.Flags().String("output", "json", "Output format: table or json") + runtimePingCmd.Flags().Bool("wait", false, "Wait for ping to complete (poll until done)") + + // runtime update + runtimeUpdateCmd.Flags().String("target-version", "", "Target version to update to (required)") + runtimeUpdateCmd.Flags().String("output", "json", "Output format: table or json") + runtimeUpdateCmd.Flags().Bool("wait", false, "Wait for update to complete (poll until done)") +} + +// --------------------------------------------------------------------------- +// Runtime commands +// --------------------------------------------------------------------------- + +func runRuntimeList(cmd *cobra.Command, _ []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var runtimes []map[string]any + if err := client.GetJSON(ctx, "/api/runtimes", &runtimes); err != nil { + return fmt.Errorf("list runtimes: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, runtimes) + } + + headers := []string{"ID", "NAME", "MODE", "PROVIDER", "STATUS", "LAST_SEEN"} + rows := make([][]string, 0, len(runtimes)) + for _, rt := range runtimes { + rows = append(rows, []string{ + strVal(rt, "id"), + strVal(rt, "name"), + strVal(rt, "runtime_mode"), + strVal(rt, "provider"), + strVal(rt, "status"), + strVal(rt, "last_seen_at"), + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +func runRuntimeUsage(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + days, _ := cmd.Flags().GetInt("days") + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var usage []map[string]any + path := fmt.Sprintf("/api/runtimes/%s/usage?days=%d", args[0], days) + if err := client.GetJSON(ctx, path, &usage); err != nil { + return fmt.Errorf("get runtime usage: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, usage) + } + + headers := []string{"DATE", "PROVIDER", "MODEL", "INPUT_TOKENS", "OUTPUT_TOKENS", "CACHE_READ", "CACHE_WRITE"} + rows := make([][]string, 0, len(usage)) + for _, u := range usage { + rows = append(rows, []string{ + strVal(u, "date"), + strVal(u, "provider"), + strVal(u, "model"), + strVal(u, "input_tokens"), + strVal(u, "output_tokens"), + strVal(u, "cache_read_tokens"), + strVal(u, "cache_write_tokens"), + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +func runRuntimeActivity(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var activity []map[string]any + if err := client.GetJSON(ctx, "/api/runtimes/"+args[0]+"/activity", &activity); err != nil { + return fmt.Errorf("get runtime activity: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, activity) + } + + headers := []string{"HOUR", "COUNT"} + rows := make([][]string, 0, len(activity)) + for _, a := range activity { + rows = append(rows, []string{ + strVal(a, "hour"), + strVal(a, "count"), + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +func runRuntimePing(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + + // Initiate ping. + var ping map[string]any + if err := client.PostJSON(ctx, "/api/runtimes/"+args[0]+"/ping", nil, &ping); err != nil { + return fmt.Errorf("initiate ping: %w", err) + } + + wait, _ := cmd.Flags().GetBool("wait") + if !wait { + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, ping) + } + fmt.Printf("Ping initiated: %s (status: %s)\n", strVal(ping, "id"), strVal(ping, "status")) + return nil + } + + // Poll until completed/failed/timeout. + pingID := strVal(ping, "id") + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timed out waiting for ping") + case <-time.After(1 * time.Second): + } + + if err := client.GetJSON(ctx, "/api/runtimes/"+args[0]+"/ping/"+pingID, &ping); err != nil { + return fmt.Errorf("get ping status: %w", err) + } + + status := strVal(ping, "status") + if status == "completed" || status == "failed" || status == "timeout" { + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, ping) + } + if status == "completed" { + fmt.Printf("Ping completed in %sms\n", strVal(ping, "duration_ms")) + } else { + fmt.Printf("Ping %s: %s\n", status, strVal(ping, "error")) + } + return nil + } + } +} + +func runRuntimeUpdate(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + targetVersion, _ := cmd.Flags().GetString("target-version") + if targetVersion == "" { + return fmt.Errorf("--target-version is required") + } + + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Second) + defer cancel() + + body := map[string]any{ + "target_version": targetVersion, + } + + var update map[string]any + if err := client.PostJSON(ctx, "/api/runtimes/"+args[0]+"/update", body, &update); err != nil { + return fmt.Errorf("initiate update: %w", err) + } + + wait, _ := cmd.Flags().GetBool("wait") + if !wait { + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, update) + } + fmt.Printf("Update initiated: %s (status: %s)\n", strVal(update, "id"), strVal(update, "status")) + return nil + } + + // Poll until completed/failed/timeout. + updateID := strVal(update, "id") + for { + select { + case <-ctx.Done(): + return fmt.Errorf("timed out waiting for update") + case <-time.After(2 * time.Second): + } + + if err := client.GetJSON(ctx, "/api/runtimes/"+args[0]+"/update/"+updateID, &update); err != nil { + return fmt.Errorf("get update status: %w", err) + } + + status := strVal(update, "status") + if status == "completed" || status == "failed" || status == "timeout" { + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, update) + } + if status == "completed" { + fmt.Printf("Update completed: %s\n", strVal(update, "output")) + } else { + fmt.Printf("Update %s: %s\n", status, strVal(update, "error")) + } + return nil + } + } +} diff --git a/server/cmd/multica/cmd_skill.go b/server/cmd/multica/cmd_skill.go new file mode 100644 index 00000000..664de1c1 --- /dev/null +++ b/server/cmd/multica/cmd_skill.go @@ -0,0 +1,416 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "github.com/multica-ai/multica/server/internal/cli" +) + +var skillCmd = &cobra.Command{ + Use: "skill", + Short: "Manage skills", +} + +var skillListCmd = &cobra.Command{ + Use: "list", + Short: "List skills in the workspace", + RunE: runSkillList, +} + +var skillGetCmd = &cobra.Command{ + Use: "get ", + Short: "Get skill details (includes files)", + Args: cobra.ExactArgs(1), + RunE: runSkillGet, +} + +var skillCreateCmd = &cobra.Command{ + Use: "create", + Short: "Create a new skill", + RunE: runSkillCreate, +} + +var skillUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a skill", + Args: cobra.ExactArgs(1), + RunE: runSkillUpdate, +} + +var skillDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a skill", + Args: cobra.ExactArgs(1), + RunE: runSkillDelete, +} + +var skillImportCmd = &cobra.Command{ + Use: "import", + Short: "Import a skill from a URL (clawhub.ai or skills.sh)", + RunE: runSkillImport, +} + +// Skill file subcommands. + +var skillFilesCmd = &cobra.Command{ + Use: "files", + Short: "Manage skill files", +} + +var skillFilesListCmd = &cobra.Command{ + Use: "list ", + Short: "List files for a skill", + Args: cobra.ExactArgs(1), + RunE: runSkillFilesList, +} + +var skillFilesUpsertCmd = &cobra.Command{ + Use: "upsert ", + Short: "Create or update a skill file", + Args: cobra.ExactArgs(1), + RunE: runSkillFilesUpsert, +} + +var skillFilesDeleteCmd = &cobra.Command{ + Use: "delete ", + Short: "Delete a skill file", + Args: cobra.ExactArgs(2), + RunE: runSkillFilesDelete, +} + +func init() { + skillCmd.AddCommand(skillListCmd) + skillCmd.AddCommand(skillGetCmd) + skillCmd.AddCommand(skillCreateCmd) + skillCmd.AddCommand(skillUpdateCmd) + skillCmd.AddCommand(skillDeleteCmd) + skillCmd.AddCommand(skillImportCmd) + skillCmd.AddCommand(skillFilesCmd) + + skillFilesCmd.AddCommand(skillFilesListCmd) + skillFilesCmd.AddCommand(skillFilesUpsertCmd) + skillFilesCmd.AddCommand(skillFilesDeleteCmd) + + // skill list + skillListCmd.Flags().String("output", "table", "Output format: table or json") + + // skill get + skillGetCmd.Flags().String("output", "json", "Output format: table or json") + + // skill create + skillCreateCmd.Flags().String("name", "", "Skill name (required)") + skillCreateCmd.Flags().String("description", "", "Skill description") + skillCreateCmd.Flags().String("content", "", "Skill content (SKILL.md body)") + skillCreateCmd.Flags().String("output", "json", "Output format: table or json") + + // skill update + skillUpdateCmd.Flags().String("name", "", "New name") + skillUpdateCmd.Flags().String("description", "", "New description") + skillUpdateCmd.Flags().String("content", "", "New content") + skillUpdateCmd.Flags().String("output", "json", "Output format: table or json") + + // skill delete (no extra flags) + + // skill import + skillImportCmd.Flags().String("url", "", "URL to import from (required)") + skillImportCmd.Flags().String("output", "json", "Output format: table or json") + + // skill files list + skillFilesListCmd.Flags().String("output", "table", "Output format: table or json") + + // skill files upsert + skillFilesUpsertCmd.Flags().String("path", "", "File path within the skill (required)") + skillFilesUpsertCmd.Flags().String("content", "", "File content (required)") + skillFilesUpsertCmd.Flags().String("output", "json", "Output format: table or json") +} + +// --------------------------------------------------------------------------- +// Skill commands +// --------------------------------------------------------------------------- + +func runSkillList(cmd *cobra.Command, _ []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var skills []map[string]any + if err := client.GetJSON(ctx, "/api/skills", &skills); err != nil { + return fmt.Errorf("list skills: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, skills) + } + + headers := []string{"ID", "NAME", "DESCRIPTION", "CREATED_AT"} + rows := make([][]string, 0, len(skills)) + for _, s := range skills { + rows = append(rows, []string{ + strVal(s, "id"), + strVal(s, "name"), + strVal(s, "description"), + strVal(s, "created_at"), + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +func runSkillGet(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var skill map[string]any + if err := client.GetJSON(ctx, "/api/skills/"+args[0], &skill); err != nil { + return fmt.Errorf("get skill: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, skill) + } + + headers := []string{"ID", "NAME", "DESCRIPTION", "CREATED_AT"} + rows := [][]string{{ + strVal(skill, "id"), + strVal(skill, "name"), + strVal(skill, "description"), + strVal(skill, "created_at"), + }} + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +func runSkillCreate(cmd *cobra.Command, _ []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + name, _ := cmd.Flags().GetString("name") + if name == "" { + return fmt.Errorf("--name is required") + } + + body := map[string]any{ + "name": name, + } + if v, _ := cmd.Flags().GetString("description"); v != "" { + body["description"] = v + } + if v, _ := cmd.Flags().GetString("content"); v != "" { + body["content"] = v + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var result map[string]any + if err := client.PostJSON(ctx, "/api/skills", body, &result); err != nil { + return fmt.Errorf("create skill: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, result) + } + + fmt.Printf("Skill created: %s (%s)\n", strVal(result, "name"), strVal(result, "id")) + return nil +} + +func runSkillUpdate(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + body := map[string]any{} + if cmd.Flags().Changed("name") { + v, _ := cmd.Flags().GetString("name") + body["name"] = v + } + if cmd.Flags().Changed("description") { + v, _ := cmd.Flags().GetString("description") + body["description"] = v + } + if cmd.Flags().Changed("content") { + v, _ := cmd.Flags().GetString("content") + body["content"] = v + } + + if len(body) == 0 { + return fmt.Errorf("no fields to update; use --name, --description, or --content") + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var result map[string]any + if err := client.PutJSON(ctx, "/api/skills/"+args[0], body, &result); err != nil { + return fmt.Errorf("update skill: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, result) + } + + fmt.Printf("Skill updated: %s (%s)\n", strVal(result, "name"), strVal(result, "id")) + return nil +} + +func runSkillDelete(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if err := client.DeleteJSON(ctx, "/api/skills/"+args[0]); err != nil { + return fmt.Errorf("delete skill: %w", err) + } + + fmt.Printf("Skill deleted: %s\n", args[0]) + return nil +} + +func runSkillImport(cmd *cobra.Command, _ []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + importURL, _ := cmd.Flags().GetString("url") + if importURL == "" { + return fmt.Errorf("--url is required") + } + + body := map[string]any{ + "url": importURL, + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + var result map[string]any + if err := client.PostJSON(ctx, "/api/skills/import", body, &result); err != nil { + return fmt.Errorf("import skill: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, result) + } + + fmt.Printf("Skill imported: %s (%s)\n", strVal(result, "name"), strVal(result, "id")) + return nil +} + +// --------------------------------------------------------------------------- +// Skill file subcommands +// --------------------------------------------------------------------------- + +func runSkillFilesList(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var files []map[string]any + if err := client.GetJSON(ctx, "/api/skills/"+args[0]+"/files", &files); err != nil { + return fmt.Errorf("list skill files: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, files) + } + + headers := []string{"ID", "PATH", "CREATED_AT", "UPDATED_AT"} + rows := make([][]string, 0, len(files)) + for _, f := range files { + rows = append(rows, []string{ + strVal(f, "id"), + strVal(f, "path"), + strVal(f, "created_at"), + strVal(f, "updated_at"), + }) + } + cli.PrintTable(os.Stdout, headers, rows) + return nil +} + +func runSkillFilesUpsert(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + filePath, _ := cmd.Flags().GetString("path") + if filePath == "" { + return fmt.Errorf("--path is required") + } + content, _ := cmd.Flags().GetString("content") + if content == "" { + return fmt.Errorf("--content is required") + } + + body := map[string]any{ + "path": filePath, + "content": content, + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + var result map[string]any + if err := client.PutJSON(ctx, "/api/skills/"+args[0]+"/files", body, &result); err != nil { + return fmt.Errorf("upsert skill file: %w", err) + } + + output, _ := cmd.Flags().GetString("output") + if output == "json" { + return cli.PrintJSON(os.Stdout, result) + } + + fmt.Printf("Skill file upserted: %s (%s)\n", strVal(result, "path"), strVal(result, "id")) + return nil +} + +func runSkillFilesDelete(cmd *cobra.Command, args []string) error { + client, err := newAPIClient(cmd) + if err != nil { + return err + } + + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + if err := client.DeleteJSON(ctx, "/api/skills/"+args[0]+"/files/"+args[1]); err != nil { + return fmt.Errorf("delete skill file: %w", err) + } + + fmt.Printf("Skill file deleted: %s\n", args[1]) + return nil +} diff --git a/server/cmd/multica/main.go b/server/cmd/multica/main.go index 75e7120a..53cc903f 100644 --- a/server/cmd/multica/main.go +++ b/server/cmd/multica/main.go @@ -36,6 +36,8 @@ func init() { rootCmd.AddCommand(repoCmd) rootCmd.AddCommand(versionCmd) rootCmd.AddCommand(updateCmd) + rootCmd.AddCommand(skillCmd) + rootCmd.AddCommand(runtimeCmd) } func main() { From 91cbf32fd1cf5dfb5fa94f054b8cfd4bc37bc510 Mon Sep 17 00:00:00 2001 From: Jiayuan Date: Fri, 3 Apr 2026 16:00:12 +0800 Subject: [PATCH 5/7] fix(cli): address code review feedback for agent/skill/runtime commands - Add --config flag to skill create/update (accepts JSON string) - Add --runtime-config flag to agent create/update (accepts JSON string) - Add --yes flag to skill delete with confirmation prompt - Improve agent skills set error message (guide users to --skill-ids '') - Validate --days range (1-365) in runtime usage - Include last known status in ping/update timeout errors --- server/cmd/multica/cmd_agent.go | 26 +++++++++++++++++---- server/cmd/multica/cmd_runtime.go | 7 ++++-- server/cmd/multica/cmd_skill.go | 38 +++++++++++++++++++++++++++++-- 3 files changed, 63 insertions(+), 8 deletions(-) diff --git a/server/cmd/multica/cmd_agent.go b/server/cmd/multica/cmd_agent.go index 11d4834c..a6ca6c2a 100644 --- a/server/cmd/multica/cmd_agent.go +++ b/server/cmd/multica/cmd_agent.go @@ -113,6 +113,7 @@ func init() { agentCreateCmd.Flags().String("description", "", "Agent description") agentCreateCmd.Flags().String("instructions", "", "Agent instructions") agentCreateCmd.Flags().String("runtime-id", "", "Runtime ID (required)") + agentCreateCmd.Flags().String("runtime-config", "", "Runtime config as JSON string") agentCreateCmd.Flags().String("visibility", "private", "Visibility: private or workspace") agentCreateCmd.Flags().Int32("max-concurrent-tasks", 6, "Maximum concurrent tasks") agentCreateCmd.Flags().String("output", "json", "Output format: table or json") @@ -122,6 +123,7 @@ func init() { agentUpdateCmd.Flags().String("description", "", "New description") agentUpdateCmd.Flags().String("instructions", "", "New instructions") agentUpdateCmd.Flags().String("runtime-id", "", "New runtime ID") + agentUpdateCmd.Flags().String("runtime-config", "", "New runtime config as JSON string") agentUpdateCmd.Flags().String("visibility", "", "New visibility: private or workspace") agentUpdateCmd.Flags().String("status", "", "New status") agentUpdateCmd.Flags().Int32("max-concurrent-tasks", 0, "New max concurrent tasks") @@ -314,6 +316,14 @@ func runAgentCreate(cmd *cobra.Command, _ []string) error { if v, _ := cmd.Flags().GetString("instructions"); v != "" { body["instructions"] = v } + if cmd.Flags().Changed("runtime-config") { + v, _ := cmd.Flags().GetString("runtime-config") + var rc any + if err := json.Unmarshal([]byte(v), &rc); err != nil { + return fmt.Errorf("--runtime-config must be valid JSON: %w", err) + } + body["runtime_config"] = rc + } if cmd.Flags().Changed("visibility") { v, _ := cmd.Flags().GetString("visibility") body["visibility"] = v @@ -363,6 +373,14 @@ func runAgentUpdate(cmd *cobra.Command, args []string) error { v, _ := cmd.Flags().GetString("runtime-id") body["runtime_id"] = v } + if cmd.Flags().Changed("runtime-config") { + v, _ := cmd.Flags().GetString("runtime-config") + var rc any + if err := json.Unmarshal([]byte(v), &rc); err != nil { + return fmt.Errorf("--runtime-config must be valid JSON: %w", err) + } + body["runtime_config"] = rc + } if cmd.Flags().Changed("visibility") { v, _ := cmd.Flags().GetString("visibility") body["visibility"] = v @@ -377,7 +395,7 @@ func runAgentUpdate(cmd *cobra.Command, args []string) error { } if len(body) == 0 { - return fmt.Errorf("no fields to update; use --name, --description, --instructions, --runtime-id, --visibility, --status, or --max-concurrent-tasks") + return fmt.Errorf("no fields to update; use --name, --description, --instructions, --runtime-id, --runtime-config, --visibility, --status, or --max-concurrent-tasks") } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) @@ -518,10 +536,10 @@ func runAgentSkillsSet(cmd *cobra.Command, args []string) error { return err } - skillIDs, _ := cmd.Flags().GetStringSlice("skill-ids") - if len(skillIDs) == 0 { - return fmt.Errorf("--skill-ids is required (comma-separated skill IDs, or empty string to clear)") + if !cmd.Flags().Changed("skill-ids") { + return fmt.Errorf("--skill-ids is required (comma-separated skill IDs; use --skill-ids '' to clear all)") } + skillIDs, _ := cmd.Flags().GetStringSlice("skill-ids") // Allow passing empty string to clear all skills. cleanIDs := make([]string, 0, len(skillIDs)) for _, id := range skillIDs { diff --git a/server/cmd/multica/cmd_runtime.go b/server/cmd/multica/cmd_runtime.go index b73593d4..417efec0 100644 --- a/server/cmd/multica/cmd_runtime.go +++ b/server/cmd/multica/cmd_runtime.go @@ -123,6 +123,9 @@ func runRuntimeUsage(cmd *cobra.Command, args []string) error { } days, _ := cmd.Flags().GetInt("days") + if days < 1 || days > 365 { + return fmt.Errorf("--days must be between 1 and 365") + } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -216,7 +219,7 @@ func runRuntimePing(cmd *cobra.Command, args []string) error { for { select { case <-ctx.Done(): - return fmt.Errorf("timed out waiting for ping") + return fmt.Errorf("timed out waiting for ping (last status: %s)", strVal(ping, "status")) case <-time.After(1 * time.Second): } @@ -278,7 +281,7 @@ func runRuntimeUpdate(cmd *cobra.Command, args []string) error { for { select { case <-ctx.Done(): - return fmt.Errorf("timed out waiting for update") + return fmt.Errorf("timed out waiting for update (last status: %s)", strVal(update, "status")) case <-time.After(2 * time.Second): } diff --git a/server/cmd/multica/cmd_skill.go b/server/cmd/multica/cmd_skill.go index 664de1c1..f6de394a 100644 --- a/server/cmd/multica/cmd_skill.go +++ b/server/cmd/multica/cmd_skill.go @@ -1,9 +1,12 @@ package main import ( + "bufio" "context" + "encoding/json" "fmt" "os" + "strings" "time" "github.com/spf13/cobra" @@ -106,15 +109,18 @@ func init() { skillCreateCmd.Flags().String("name", "", "Skill name (required)") skillCreateCmd.Flags().String("description", "", "Skill description") skillCreateCmd.Flags().String("content", "", "Skill content (SKILL.md body)") + skillCreateCmd.Flags().String("config", "", "Skill config as JSON string") skillCreateCmd.Flags().String("output", "json", "Output format: table or json") // skill update skillUpdateCmd.Flags().String("name", "", "New name") skillUpdateCmd.Flags().String("description", "", "New description") skillUpdateCmd.Flags().String("content", "", "New content") + skillUpdateCmd.Flags().String("config", "", "New config as JSON string") skillUpdateCmd.Flags().String("output", "json", "Output format: table or json") - // skill delete (no extra flags) + // skill delete + skillDeleteCmd.Flags().Bool("yes", false, "Skip confirmation prompt") // skill import skillImportCmd.Flags().String("url", "", "URL to import from (required)") @@ -216,6 +222,14 @@ func runSkillCreate(cmd *cobra.Command, _ []string) error { if v, _ := cmd.Flags().GetString("content"); v != "" { body["content"] = v } + if cmd.Flags().Changed("config") { + v, _ := cmd.Flags().GetString("config") + var config any + if err := json.Unmarshal([]byte(v), &config); err != nil { + return fmt.Errorf("--config must be valid JSON: %w", err) + } + body["config"] = config + } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() @@ -253,9 +267,17 @@ func runSkillUpdate(cmd *cobra.Command, args []string) error { v, _ := cmd.Flags().GetString("content") body["content"] = v } + if cmd.Flags().Changed("config") { + v, _ := cmd.Flags().GetString("config") + var config any + if err := json.Unmarshal([]byte(v), &config); err != nil { + return fmt.Errorf("--config must be valid JSON: %w", err) + } + body["config"] = config + } if len(body) == 0 { - return fmt.Errorf("no fields to update; use --name, --description, or --content") + return fmt.Errorf("no fields to update; use --name, --description, --content, or --config") } ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) @@ -276,6 +298,18 @@ func runSkillUpdate(cmd *cobra.Command, args []string) error { } func runSkillDelete(cmd *cobra.Command, args []string) error { + yes, _ := cmd.Flags().GetBool("yes") + if !yes { + fmt.Printf("Are you sure you want to delete skill %s? This cannot be undone. [y/N] ", args[0]) + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "y" && answer != "yes" { + fmt.Println("Aborted.") + return nil + } + } + client, err := newAPIClient(cmd) if err != nil { return err From 4353340ea68a9e9df7c44f43f6fdd5457572a6fe Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:05:35 +0800 Subject: [PATCH 6/7] feat(issues): sticky mini bar for agent live card + toast icon colors Agent live card now uses the sentinel pattern to detect when it scrolls out of view. When stuck, it collapses to a compact header bar with brand styling and backdrop blur, with a ChevronUp button to scroll back. When scrolled back into view, the card seamlessly expands to full view. Also adds semantic colors to Sonner toast icons (success/info/warning/ error/loading) and fixes icon-to-text alignment in toasts globally. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/web/app/custom.css | 9 + apps/web/components/ui/sonner.tsx | 10 +- .../issues/components/agent-live-card.tsx | 175 ++++++++++++------ .../issues/components/issue-detail.tsx | 11 +- 4 files changed, 137 insertions(+), 68 deletions(-) diff --git a/apps/web/app/custom.css b/apps/web/app/custom.css index 92c207a4..d24ef7a0 100644 --- a/apps/web/app/custom.css +++ b/apps/web/app/custom.css @@ -30,3 +30,12 @@ background-color: var(--sidebar-accent); color: var(--sidebar-accent-foreground); } + +/* Sonner toast: align icon to first line of text, not vertically centered */ +[data-sonner-toast] { + align-items: flex-start !important; +} + +[data-sonner-toast] [data-icon] { + margin-top: 2.5px; +} diff --git a/apps/web/components/ui/sonner.tsx b/apps/web/components/ui/sonner.tsx index 9280ee52..cb49e93b 100644 --- a/apps/web/components/ui/sonner.tsx +++ b/apps/web/components/ui/sonner.tsx @@ -13,19 +13,19 @@ const Toaster = ({ ...props }: ToasterProps) => { className="toaster group" icons={{ success: ( - + ), info: ( - + ), warning: ( - + ), error: ( - + ), loading: ( - + ), }} style={ diff --git a/apps/web/features/issues/components/agent-live-card.tsx b/apps/web/features/issues/components/agent-live-card.tsx index 631fea29..173e0d40 100644 --- a/apps/web/features/issues/components/agent-live-card.tsx +++ b/apps/web/features/issues/components/agent-live-card.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useCallback, useRef } from "react"; -import { Bot, ChevronRight, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square } from "lucide-react"; +import { Bot, ChevronRight, ChevronUp, Loader2, ArrowDown, Brain, AlertCircle, Clock, CheckCircle2, XCircle, Square } from "lucide-react"; import { api } from "@/shared/api"; import { useWSEvent } from "@/features/realtime"; import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload, TaskCancelledPayload } from "@/shared/types/events"; @@ -99,16 +99,20 @@ function buildTimeline(msgs: TaskMessagePayload[]): TimelineItem[] { interface AgentLiveCardProps { issueId: string; agentName?: string; + /** Scroll container ref — needed for sticky sentinel detection. */ + scrollContainerRef?: React.RefObject; } -export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) { +export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentLiveCardProps) { const { getActorName } = useActorName(); const [activeTask, setActiveTask] = useState(null); const [items, setItems] = useState([]); const [elapsed, setElapsed] = useState(""); const [autoScroll, setAutoScroll] = useState(true); const [cancelling, setCancelling] = useState(false); + const [isStuck, setIsStuck] = useState(false); const scrollRef = useRef(null); + const sentinelRef = useRef(null); const seenSeqs = useRef(new Set()); // Check for active task on mount @@ -215,12 +219,36 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) { // Elapsed time useEffect(() => { if (!activeTask?.started_at && !activeTask?.dispatched_at) return; - const ref = activeTask.started_at ?? activeTask.dispatched_at!; - setElapsed(formatElapsed(ref)); - const interval = setInterval(() => setElapsed(formatElapsed(ref)), 1000); + const startRef = activeTask.started_at ?? activeTask.dispatched_at!; + setElapsed(formatElapsed(startRef)); + const interval = setInterval(() => setElapsed(formatElapsed(startRef)), 1000); return () => clearInterval(interval); }, [activeTask?.started_at, activeTask?.dispatched_at]); + // Sentinel pattern: detect when the card is scrolled past and becomes "stuck" + useEffect(() => { + const sentinel = sentinelRef.current; + const root = scrollContainerRef?.current; + if (!sentinel || !root || !activeTask) { + setIsStuck(false); + return; + } + + const observer = new IntersectionObserver( + (entries) => { + if (entries[0]) setIsStuck(!entries[0].isIntersecting); + }, + { root, threshold: 0, rootMargin: "-40px 0px 0px 0px" }, + ); + + observer.observe(sentinel); + return () => observer.disconnect(); + }, [scrollContainerRef, activeTask]); + + const scrollToCard = useCallback(() => { + sentinelRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + }, []); + // Auto-scroll useEffect(() => { if (autoScroll && scrollRef.current) { @@ -248,67 +276,100 @@ export function AgentLiveCard({ issueId, agentName }: AgentLiveCardProps) { if (!activeTask) return null; const toolCount = items.filter((i) => i.type === "tool_use").length; + const name = (activeTask.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent"; return ( -
- {/* Header */} -
-
- -
-
- - {(activeTask?.agent_id ? getActorName("agent", activeTask.agent_id) : agentName) ?? "Agent"} is working -
- {elapsed} - {toolCount > 0 && ( - - {toolCount} tool {toolCount === 1 ? "call" : "calls"} - + <> + {/* Sentinel — zero-height element that IntersectionObserver watches */} +
+ +
- {cancelling ? ( - - ) : ( - + > + {/* Header */} +
+
+ +
+
+ + {name} is working +
+ {elapsed} + {!isStuck && toolCount > 0 && ( + + {toolCount} tool {toolCount === 1 ? "call" : "calls"} + )} - Stop - -
- - {/* Timeline content */} - {items.length > 0 && ( -
- {items.map((item, idx) => ( - - ))} - - {!autoScroll && ( + {isStuck ? ( + ) : ( + )}
- )} -
+ + {/* Timeline content — collapses when stuck */} +
+ {items.length > 0 && ( +
+ {items.map((item, idx) => ( + + ))} + + {!autoScroll && ( + + )} +
+ )} +
+
+ ); } diff --git a/apps/web/features/issues/components/issue-detail.tsx b/apps/web/features/issues/components/issue-detail.tsx index 4343a2ba..8b3687b7 100644 --- a/apps/web/features/issues/components/issue-detail.tsx +++ b/apps/web/features/issues/components/issue-detail.tsx @@ -771,12 +771,11 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{/* Agent live output */} -
- -
+ {/* Agent execution history */}
From 56b49cb2a6f3728a6d035061dcef7b7886065d6d Mon Sep 17 00:00:00 2001 From: Naiyuan Qing <145280634+NevilleQingNY@users.noreply.github.com> Date: Fri, 3 Apr 2026 16:08:12 +0800 Subject: [PATCH 7/7] feat(issues): use ActorAvatar in agent live card header Replace hand-rolled Bot icon circle with ActorAvatar component so agent custom avatars display correctly, consistent with comment cards and other agent-rendered UI. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../issues/components/agent-live-card.tsx | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/apps/web/features/issues/components/agent-live-card.tsx b/apps/web/features/issues/components/agent-live-card.tsx index 173e0d40..110eb1ff 100644 --- a/apps/web/features/issues/components/agent-live-card.tsx +++ b/apps/web/features/issues/components/agent-live-card.tsx @@ -8,6 +8,7 @@ import type { TaskMessagePayload, TaskCompletedPayload, TaskFailedPayload, TaskC import type { AgentTask } from "@/shared/types/agent"; import { cn } from "@/lib/utils"; import { toast } from "sonner"; +import { ActorAvatar } from "@/components/common/actor-avatar"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; import { useActorName } from "@/features/workspace"; import { redactSecrets } from "../utils/redact"; @@ -293,12 +294,16 @@ export function AgentLiveCard({ issueId, agentName, scrollContainerRef }: AgentL > {/* Header */}
-
- -
+ {activeTask.agent_id ? ( + + ) : ( +
+ +
+ )}
{name} is working