- {/* 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 */}
+
+ {activeTask.agent_id ? (
+
) : (
-
+
+
+
)}
-
Stop
-
-
-
- {/* Timeline content */}
- {items.length > 0 && (
-
- {items.map((item, idx) => (
-
- ))}
-
- {!autoScroll && (
+
+
+ {name} is working
+
+
{elapsed}
+ {!isStuck && toolCount > 0 && (
+
+ {toolCount} tool {toolCount === 1 ? "call" : "calls"}
+
+ )}
+ {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..b3a2d0fd 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 (
@@ -771,12 +771,11 @@ export function IssueDetail({ issueId, onDelete, defaultSidebarOpen = true, layo
{/* Agent live output */}
-
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
diff --git a/server/cmd/multica/cmd_agent.go b/server/cmd/multica/cmd_agent.go
index d5391544..a6ca6c2a 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,124 @@ 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("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")
+
+ // 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("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")
+ 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 +206,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 +220,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 +240,342 @@ 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("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
+ }
+ 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("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
+ }
+ 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, --runtime-config, --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
+ }
+
+ 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 {
+ 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..417efec0
--- /dev/null
+++ b/server/cmd/multica/cmd_runtime.go
@@ -0,0 +1,306 @@
+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")
+ 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()
+
+ 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 (last status: %s)", strVal(ping, "status"))
+ 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 (last status: %s)", strVal(update, "status"))
+ 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..f6de394a
--- /dev/null
+++ b/server/cmd/multica/cmd_skill.go
@@ -0,0 +1,450 @@
+package main
+
+import (
+ "bufio"
+ "context"
+ "encoding/json"
+ "fmt"
+ "os"
+ "strings"
+ "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("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
+ skillDeleteCmd.Flags().Bool("yes", false, "Skip confirmation prompt")
+
+ // 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
+ }
+ 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()
+
+ 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 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, --content, or --config")
+ }
+
+ 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 {
+ 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
+ }
+
+ 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() {
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},