//go:build ignore // Subagent Widget — /sub, /subclear, /subrm, /subcont commands with live widgets // // Each /sub spawns a background Kit subagent as a subprocess with its own // live widget showing status, task, elapsed time, and last output line. // /subcont continues a finished subagent by passing conversation history. // // Commands: // // /sub — spawn a new subagent // /subcont — continue subagent #'s conversation // /subrm — remove subagent # widget // /subclear — clear all subagent widgets // // The LLM can also use tools: subagent_create, subagent_continue, // subagent_remove, subagent_list. // // Ported from https://github.com/disler/pi-vs-claude-code extensions/subagent-widget.ts // // Usage: kit -e examples/extensions/subagent-widget.go package main import ( "bufio" "encoding/json" "fmt" "os" "os/exec" "strconv" "strings" "sync" "time" "kit/ext" ) // subJSONOutput matches the JSON envelope produced by `kit --json`. type subJSONOutput struct { Response string `json:"response"` } // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type subState struct { ID int Status string // "running", "done", "error" Task string Chunks []string // accumulated output chunks Elapsed time.Duration TurnCount int History string // conversation history for /subcont Proc *os.Process // active process for killing Removed bool // set when /subrm or /subclear removes this agent mu sync.Mutex } func (s *subState) appendChunk(chunk string) { s.mu.Lock() defer s.mu.Unlock() s.Chunks = append(s.Chunks, chunk) } func (s *subState) setElapsed(d time.Duration) { s.mu.Lock() defer s.mu.Unlock() s.Elapsed = d } func (s *subState) setProc(p *os.Process) { s.mu.Lock() defer s.mu.Unlock() s.Proc = p } func (s *subState) snapshot() (int, string, string, string, time.Duration, int) { s.mu.Lock() defer s.mu.Unlock() fullText := strings.Join(s.Chunks, "") return s.ID, s.Status, s.Task, fullText, s.Elapsed, s.TurnCount } // --------------------------------------------------------------------------- // Package-level state // --------------------------------------------------------------------------- var ( mu sync.Mutex latestCtx ext.Context hasCtx bool agents = map[int]*subState{} nextID = 1 kitBinary string ) // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- func findKitBinary() string { if exe, err := os.Executable(); err == nil { if _, err := os.Stat(exe); err == nil { return exe } } if p, err := exec.LookPath("kit"); err == nil { return p } return "kit" } func truncate(s string, max int) string { runes := []rune(s) if len(runes) <= max { return s } if max < 4 { return string(runes[:max]) } return string(runes[:max-3]) + "..." } func lastNonEmptyLine(text string) string { lines := strings.Split(text, "\n") for i := len(lines) - 1; i >= 0; i-- { trimmed := strings.TrimSpace(lines[i]) if trimmed != "" { return trimmed } } return "" } // --------------------------------------------------------------------------- // Widget rendering // --------------------------------------------------------------------------- func updateWidgets() { mu.Lock() ctx := latestCtx ok := hasCtx agentsCopy := make([]*subState, 0, len(agents)) for _, s := range agents { agentsCopy = append(agentsCopy, s) } mu.Unlock() if !ok { return } for _, state := range agentsCopy { id, status, task, fullText, elapsed, turnCount := state.snapshot() var icon, color string switch status { case "running": icon = "●" color = "#89b4fa" // blue case "done": icon = "✓" color = "#a6e3a1" // green default: icon = "✗" color = "#f38ba8" // red } taskPreview := truncate(task, 40) turnLabel := "" if turnCount > 1 { turnLabel = fmt.Sprintf(" · Turn %d", turnCount) } header := fmt.Sprintf("%s Subagent #%d%s %s (%ds)", icon, id, turnLabel, taskPreview, int(elapsed.Seconds())) lastLine := truncate(lastNonEmptyLine(fullText), 80) text := header if lastLine != "" { text += "\n " + lastLine } ctx.SetWidget(ext.WidgetConfig{ ID: fmt.Sprintf("subagent:%d", id), Placement: ext.WidgetAbove, Content: ext.WidgetContent{Text: text}, Style: ext.WidgetStyle{BorderColor: color}, Priority: id, }) } } // --------------------------------------------------------------------------- // Subprocess spawning // --------------------------------------------------------------------------- func spawnAgent(state *subState) { prompt := state.Task state.mu.Lock() history := state.History state.mu.Unlock() if history != "" { prompt = "Previous conversation:\n" + history + "\n\nNew instruction: " + state.Task } args := []string{ "--json", "--no-session", "--no-extensions", prompt, } cmd := exec.Command(kitBinary, args...) cmd.Env = os.Environ() stdout, err := cmd.StdoutPipe() if err != nil { state.mu.Lock() state.Status = "error" state.Chunks = append(state.Chunks, "Pipe error: "+err.Error()) state.mu.Unlock() updateWidgets() return } stderr, err := cmd.StderrPipe() if err != nil { state.mu.Lock() state.Status = "error" state.Chunks = append(state.Chunks, "Pipe error: "+err.Error()) state.mu.Unlock() updateWidgets() return } start := time.Now() if err := cmd.Start(); err != nil { state.mu.Lock() state.Status = "error" state.Chunks = append(state.Chunks, "Start error: "+err.Error()) state.mu.Unlock() updateWidgets() return } state.setProc(cmd.Process) // Timer goroutine: update widget every second with elapsed time. doneCh := make(chan struct{}) go func() { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { select { case <-doneCh: return case <-ticker.C: state.setElapsed(time.Since(start)) updateWidgets() } } }() // Read stderr in background goroutine (live widget updates). var readWg sync.WaitGroup readWg.Add(1) go func() { defer readWg.Done() scanner := bufio.NewScanner(stderr) scanner.Buffer(make([]byte, 256*1024), 256*1024) for scanner.Scan() { line := scanner.Text() if strings.TrimSpace(line) != "" { state.appendChunk(line + "\n") updateWidgets() } } }() // Read stdout into a separate buffer (JSON output from --json mode). var stdoutBuf strings.Builder scanner := bufio.NewScanner(stdout) scanner.Buffer(make([]byte, 256*1024), 256*1024) for scanner.Scan() { stdoutBuf.WriteString(scanner.Text() + "\n") } // Wait for all pipe readers, then the process. readWg.Wait() waitErr := cmd.Wait() close(doneCh) // stop timer // Parse JSON output from --json mode to extract the response. var result string rawStdout := strings.TrimSpace(stdoutBuf.String()) var parsed subJSONOutput if rawStdout != "" && json.Unmarshal([]byte(rawStdout), &parsed) == nil && parsed.Response != "" { result = parsed.Response } else { // Fallback: use raw stdout (e.g. older kit binary without --json). result = rawStdout } state.mu.Lock() state.Elapsed = time.Since(start) state.Proc = nil if waitErr != nil { state.Status = "error" } else { state.Status = "done" } // Save history for /subcont continuations (cap at 16 KB). state.History += fmt.Sprintf("\n--- Turn %d ---\nTask: %s\nResult:\n%s\n", state.TurnCount, state.Task, result) if len(state.History) > 16000 { state.History = state.History[len(state.History)-16000:] } removed := state.Removed id := state.ID elapsed := state.Elapsed turnCount := state.TurnCount task := state.Task state.mu.Unlock() updateWidgets() // Don't deliver follow-up for agents removed via /subrm or /subclear. if removed { return } // Deliver result as a follow-up message so the LLM can act on it. mu.Lock() ctx := latestCtx ok := hasCtx mu.Unlock() if ok { resultText := result if len(resultText) > 8000 { resultText = resultText[:8000] + "\n\n... [truncated]" } turnSuffix := "" if turnCount > 1 { turnSuffix = fmt.Sprintf(" (Turn %d)", turnCount) } ctx.SendMessage(fmt.Sprintf( "Subagent #%d%s finished \"%s\" in %ds.\n\nResult:\n%s", id, turnSuffix, task, int(elapsed.Seconds()), resultText, )) } } // --------------------------------------------------------------------------- // Init // --------------------------------------------------------------------------- func Init(api ext.API) { kitBinary = findKitBinary() // ── Session Start: reset state, show help ── api.OnSessionStart(func(_ ext.SessionStartEvent, ctx ext.Context) { mu.Lock() latestCtx = ctx hasCtx = true mu.Unlock() // Kill lingering agents from previous session. mu.Lock() for id, state := range agents { state.mu.Lock() if state.Proc != nil && state.Status == "running" { state.Proc.Kill() } state.mu.Unlock() ctx.RemoveWidget(fmt.Sprintf("subagent:%d", id)) } agents = map[int]*subState{} nextID = 1 mu.Unlock() ctx.PrintInfo( "Subagent Widget loaded\n\n" + "/sub Spawn a new subagent\n" + "/subcont Continue a finished subagent\n" + "/subrm Remove a subagent\n" + "/subclear Clear all subagents\n\n" + "The LLM can also spawn subagents with the subagent_create tool.") }) // ── Agent End: keep context fresh ── api.OnAgentEnd(func(_ ext.AgentEndEvent, ctx ext.Context) { mu.Lock() latestCtx = ctx mu.Unlock() }) // ── Session Shutdown: cleanup ── api.OnSessionShutdown(func(_ ext.SessionShutdownEvent, ctx ext.Context) { mu.Lock() defer mu.Unlock() for id, state := range agents { state.mu.Lock() if state.Proc != nil && state.Status == "running" { state.Proc.Kill() } state.mu.Unlock() ctx.RemoveWidget(fmt.Sprintf("subagent:%d", id)) } agents = map[int]*subState{} }) // ── Tool: subagent_create ── api.RegisterTool(ext.ToolDef{ Name: "subagent_create", Description: `Spawn a background subagent to perform a task. Returns the subagent ID immediately while it runs in the background. Results are delivered as a follow-up message when the subagent finishes. Each subagent runs as a separate Kit subprocess with full tool access. Use this to delegate independent subtasks that can run in parallel with your main work.`, Parameters: `{ "type": "object", "properties": { "task": { "type": "string", "description": "The complete task description for the subagent to perform" } }, "required": ["task"] }`, Execute: func(input string) (string, error) { var params struct { Task string `json:"task"` } if err := json.Unmarshal([]byte(input), ¶ms); err != nil { return "", fmt.Errorf("invalid parameters: %w", err) } if strings.TrimSpace(params.Task) == "" { return "", fmt.Errorf("task is required") } mu.Lock() id := nextID nextID++ state := &subState{ ID: id, Status: "running", Task: params.Task, TurnCount: 1, } agents[id] = state mu.Unlock() updateWidgets() go spawnAgent(state) return fmt.Sprintf("Subagent #%d spawned and running in background.", id), nil }, }) // ── Tool: subagent_continue ── api.RegisterTool(ext.ToolDef{ Name: "subagent_continue", Description: `Continue an existing subagent's conversation with a follow-up prompt. The subagent receives its previous conversation history as context. Use this to refine or extend a finished subagent's work.`, Parameters: `{ "type": "object", "properties": { "id": { "type": "number", "description": "The ID of the subagent to continue" }, "prompt": { "type": "string", "description": "The follow-up prompt or new instructions" } }, "required": ["id", "prompt"] }`, Execute: func(input string) (string, error) { var params struct { ID int `json:"id"` Prompt string `json:"prompt"` } if err := json.Unmarshal([]byte(input), ¶ms); err != nil { return "", fmt.Errorf("invalid parameters: %w", err) } mu.Lock() state, ok := agents[params.ID] mu.Unlock() if !ok { return fmt.Sprintf("Error: No subagent #%d found.", params.ID), nil } state.mu.Lock() if state.Status == "running" { state.mu.Unlock() return fmt.Sprintf("Error: Subagent #%d is still running.", params.ID), nil } state.Status = "running" state.Task = params.Prompt state.Chunks = nil state.Elapsed = 0 state.TurnCount++ turn := state.TurnCount state.mu.Unlock() updateWidgets() go spawnAgent(state) return fmt.Sprintf("Subagent #%d continuing conversation in background (Turn %d).", params.ID, turn), nil }, }) // ── Tool: subagent_remove ── api.RegisterTool(ext.ToolDef{ Name: "subagent_remove", Description: "Remove a specific subagent. Kills it if currently running.", Parameters: `{ "type": "object", "properties": { "id": { "type": "number", "description": "The ID of the subagent to remove" } }, "required": ["id"] }`, Execute: func(input string) (string, error) { var params struct { ID int `json:"id"` } if err := json.Unmarshal([]byte(input), ¶ms); err != nil { return "", fmt.Errorf("invalid parameters: %w", err) } mu.Lock() state, ok := agents[params.ID] if !ok { mu.Unlock() return fmt.Sprintf("Error: No subagent #%d found.", params.ID), nil } delete(agents, params.ID) mu.Unlock() state.mu.Lock() state.Removed = true if state.Proc != nil && state.Status == "running" { state.Proc.Kill() } state.mu.Unlock() mu.Lock() ctx := latestCtx ok2 := hasCtx mu.Unlock() if ok2 { ctx.RemoveWidget(fmt.Sprintf("subagent:%d", params.ID)) } return fmt.Sprintf("Subagent #%d removed.", params.ID), nil }, }) // ── Tool: subagent_list ── api.RegisterTool(ext.ToolDef{ Name: "subagent_list", Description: "List all active and finished subagents with their IDs, tasks, and status.", Parameters: `{"type": "object", "properties": {}}`, Execute: func(input string) (string, error) { mu.Lock() agentsCopy := make([]*subState, 0, len(agents)) for _, s := range agents { agentsCopy = append(agentsCopy, s) } mu.Unlock() if len(agentsCopy) == 0 { return "No active subagents.", nil } var sb strings.Builder sb.WriteString("Subagents:\n") for _, s := range agentsCopy { id, status, task, _, _, turnCount := s.snapshot() fmt.Fprintf(&sb, "#%d [%s] (Turn %d) — %s\n", id, strings.ToUpper(status), turnCount, task) } return sb.String(), nil }, }) // ── Tool Renderers ── api.RegisterToolRenderer(ext.ToolRenderConfig{ ToolName: "subagent_create", DisplayName: "Spawn Subagent", BorderColor: "#89b4fa", RenderHeader: func(toolArgs string, width int) string { var args struct { Task string `json:"task"` } if err := json.Unmarshal([]byte(toolArgs), &args); err != nil { return "" } return truncate(args.Task, width) }, RenderBody: func(toolResult string, isError bool, width int) string { return truncate(toolResult, width) }, }) api.RegisterToolRenderer(ext.ToolRenderConfig{ ToolName: "subagent_continue", DisplayName: "Continue Subagent", BorderColor: "#cba6f7", RenderHeader: func(toolArgs string, width int) string { var args struct { ID int `json:"id"` Prompt string `json:"prompt"` } if err := json.Unmarshal([]byte(toolArgs), &args); err != nil { return "" } return truncate(fmt.Sprintf("#%d: %s", args.ID, args.Prompt), width) }, RenderBody: func(toolResult string, isError bool, width int) string { return truncate(toolResult, width) }, }) // ── Command: /sub ── api.RegisterCommand(ext.CommandDef{ Name: "sub", Description: "Spawn a subagent with live widget: /sub ", Execute: func(args string, ctx ext.Context) (string, error) { mu.Lock() latestCtx = ctx hasCtx = true mu.Unlock() task := strings.TrimSpace(args) if task == "" { return "Usage: /sub ", nil } mu.Lock() id := nextID nextID++ state := &subState{ ID: id, Status: "running", Task: task, TurnCount: 1, } agents[id] = state mu.Unlock() updateWidgets() go spawnAgent(state) return fmt.Sprintf("Subagent #%d spawned: %s", id, truncate(task, 60)), nil }, }) // ── Command: /subcont ── api.RegisterCommand(ext.CommandDef{ Name: "subcont", Description: "Continue subagent conversation: /subcont ", Execute: func(args string, ctx ext.Context) (string, error) { mu.Lock() latestCtx = ctx hasCtx = true mu.Unlock() trimmed := strings.TrimSpace(args) spaceIdx := strings.IndexByte(trimmed, ' ') if spaceIdx < 0 { return "Usage: /subcont ", nil } num, err := strconv.Atoi(trimmed[:spaceIdx]) if err != nil { return "Usage: /subcont ", nil } prompt := strings.TrimSpace(trimmed[spaceIdx+1:]) if prompt == "" { return "Usage: /subcont ", nil } mu.Lock() state, ok := agents[num] mu.Unlock() if !ok { return fmt.Sprintf("No subagent #%d found. Use /sub to create one.", num), nil } state.mu.Lock() if state.Status == "running" { state.mu.Unlock() return fmt.Sprintf("Subagent #%d is still running — wait for it to finish.", num), nil } state.Status = "running" state.Task = prompt state.Chunks = nil state.Elapsed = 0 state.TurnCount++ turn := state.TurnCount state.mu.Unlock() updateWidgets() go spawnAgent(state) return fmt.Sprintf("Continuing subagent #%d (Turn %d): %s", num, turn, truncate(prompt, 50)), nil }, }) // ── Command: /subrm ── api.RegisterCommand(ext.CommandDef{ Name: "subrm", Description: "Remove a subagent widget: /subrm ", Execute: func(args string, ctx ext.Context) (string, error) { mu.Lock() latestCtx = ctx hasCtx = true mu.Unlock() num, err := strconv.Atoi(strings.TrimSpace(args)) if err != nil { return "Usage: /subrm ", nil } mu.Lock() state, ok := agents[num] if !ok { mu.Unlock() return fmt.Sprintf("No subagent #%d found.", num), nil } delete(agents, num) mu.Unlock() state.mu.Lock() state.Removed = true killed := false if state.Proc != nil && state.Status == "running" { state.Proc.Kill() killed = true } state.mu.Unlock() ctx.RemoveWidget(fmt.Sprintf("subagent:%d", num)) if killed { return fmt.Sprintf("Subagent #%d killed and removed.", num), nil } return fmt.Sprintf("Subagent #%d removed.", num), nil }, }) // ── Command: /subclear ── api.RegisterCommand(ext.CommandDef{ Name: "subclear", Description: "Clear all subagent widgets", Execute: func(args string, ctx ext.Context) (string, error) { mu.Lock() latestCtx = ctx hasCtx = true agentsCopy := make(map[int]*subState, len(agents)) for k, v := range agents { agentsCopy[k] = v } agents = map[int]*subState{} nextID = 1 mu.Unlock() killed := 0 total := len(agentsCopy) for id, state := range agentsCopy { state.mu.Lock() state.Removed = true if state.Proc != nil && state.Status == "running" { state.Proc.Kill() killed++ } state.mu.Unlock() ctx.RemoveWidget(fmt.Sprintf("subagent:%d", id)) } if total == 0 { return "No subagents to clear.", nil } msg := fmt.Sprintf("Cleared %d subagent", total) if total != 1 { msg += "s" } if killed > 0 { msg += fmt.Sprintf(" (%d killed)", killed) } msg += "." return msg, nil }, }) }