agent tracker update

This commit is contained in:
David Chen 2026-07-07 15:49:34 +08:00
parent 816bf84824
commit f667ba7225
39 changed files with 8408 additions and 104 deletions

View file

@ -0,0 +1,468 @@
package main
import (
"flag"
"fmt"
"os"
"sort"
"strings"
)
func runGoal(args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: agent goal <add-goal|delete-goal|add-thread|rename|rename-current|set-goal|set-blocker|done|delete-thread|bind|unbind|merge|promote|revive|cleanup-window|current-thread|list>")
}
switch args[0] {
case "add-goal":
return runGoalAddGoal(args[1:])
case "delete-goal":
return runGoalDeleteGoal(args[1:])
case "add-thread":
return runGoalAddThread(args[1:])
case "rename":
return runGoalRename(args[1:])
case "rename-current":
return runGoalRenameCurrent(args[1:])
case "set-goal":
return runGoalSetGoal(args[1:])
case "set-blocker":
return runGoalSetBlocker(args[1:])
case "done":
return runGoalDone(args[1:])
case "delete-thread":
return runGoalDeleteThread(args[1:])
case "bind":
return runGoalBind(args[1:])
case "unbind":
return runGoalUnbind(args[1:])
case "merge":
return runGoalMerge(args[1:])
case "promote":
return runGoalPromote(args[1:])
case "revive":
return runGoalRevive(args[1:])
case "cleanup-window":
return runGoalCleanupWindow(args[1:])
case "current-thread":
return runGoalCurrentThread(args[1:])
case "set-goal-current":
return runGoalSetGoalCurrent(args[1:])
case "set-blocker-current":
return runGoalSetBlockerCurrent(args[1:])
case "pick":
return runGoalPick(args[1:])
case "reap":
reapOrphanedThreads()
return nil
case "list":
return runGoalList(args[1:])
default:
return fmt.Errorf("unknown goal subcommand: %s", args[0])
}
}
func runGoalAddGoal(args []string) error {
fs := flag.NewFlagSet("agent goal add-goal", flag.ExitOnError)
var title, parent string
fs.StringVar(&title, "title", "", "goal title")
fs.StringVar(&parent, "parent", "", "parent goal id")
fs.Parse(args)
g, err := addGoal(title, parent)
if err != nil {
return err
}
fmt.Println(g.ID)
return nil
}
func runGoalDeleteGoal(args []string) error {
confirm := ""
var goalID string
for i := 0; i < len(args); i++ {
a := args[i]
if a == "--confirm" {
if i+1 < len(args) {
confirm = args[i+1]
i++
}
continue
}
if strings.HasPrefix(a, "--confirm=") {
confirm = strings.TrimPrefix(a, "--confirm=")
continue
}
if strings.HasPrefix(a, "-") {
continue
}
if goalID == "" {
goalID = a
}
}
if strings.TrimSpace(confirm) != "yes" {
return fmt.Errorf("pass --confirm yes to delete a goal (cascades)")
}
if goalID == "" {
return fmt.Errorf("goal id required")
}
return deleteGoalCascade(goalID)
}
func runGoalAddThread(args []string) error {
fs := flag.NewFlagSet("agent goal add-thread", flag.ExitOnError)
var name, goal string
var blocks stringSliceFlag
fs.StringVar(&name, "name", "", "thread name")
fs.StringVar(&goal, "goal", "", "goal id")
fs.Var(&blocks, "blocks", "thread id this depends on (repeatable)")
fs.Parse(args)
t, err := addManualThread(name, goal, blocks)
if err != nil {
return err
}
fmt.Println(t.ID)
return nil
}
func runGoalRename(args []string) error {
fs := flag.NewFlagSet("agent goal rename", flag.ExitOnError)
var thread, name string
fs.StringVar(&thread, "thread", "", "thread id")
fs.StringVar(&name, "name", "", "new name")
fs.Parse(args)
if strings.TrimSpace(thread) == "" {
return fmt.Errorf("--thread is required")
}
return renameThread(thread, name)
}
func runGoalRenameCurrent(args []string) error {
fs := flag.NewFlagSet("agent goal rename-current", flag.ExitOnError)
var window, name string
fs.StringVar(&window, "window", "", "tmux window id (defaults to focused)")
fs.StringVar(&name, "name", "", "new name")
fs.Parse(args)
window = resolveGoalWindowID(window)
threadID, err := ensureThreadForWindow(window, name)
if err != nil {
return err
}
return renameThread(threadID, name)
}
func runGoalSetGoal(args []string) error {
fs := flag.NewFlagSet("agent goal set-goal", flag.ExitOnError)
var thread, goal string
fs.StringVar(&thread, "thread", "", "thread id")
fs.StringVar(&goal, "goal", "", "goal id (empty to unassign)")
fs.Parse(args)
if strings.TrimSpace(thread) == "" {
return fmt.Errorf("--thread is required")
}
return setThreadGoal(thread, goal)
}
func runGoalSetBlocker(args []string) error {
fs := flag.NewFlagSet("agent goal set-blocker", flag.ExitOnError)
var thread string
var by stringSliceFlag
fs.StringVar(&thread, "thread", "", "thread id")
fs.Var(&by, "by", "thread id that blocks this one (repeatable)")
fs.Parse(args)
if strings.TrimSpace(thread) == "" {
return fmt.Errorf("--thread is required")
}
return setThreadBlocker(thread, by)
}
func runGoalDone(args []string) error {
fs := flag.NewFlagSet("agent goal done", flag.ExitOnError)
var thread string
var undo bool
fs.StringVar(&thread, "thread", "", "thread id")
fs.BoolVar(&undo, "undo", false, "mark not-done")
fs.Parse(args)
if strings.TrimSpace(thread) == "" {
return fmt.Errorf("--thread is required")
}
return setThreadDone(thread, !undo)
}
func runGoalDeleteThread(args []string) error {
fs := flag.NewFlagSet("agent goal delete-thread", flag.ExitOnError)
fs.Parse(args)
if fs.NArg() < 1 {
return fmt.Errorf("thread id required")
}
return deleteThread(fs.Arg(0))
}
func runGoalBind(args []string) error {
fs := flag.NewFlagSet("agent goal bind", flag.ExitOnError)
var thread, window string
fs.StringVar(&thread, "thread", "", "thread id")
fs.StringVar(&window, "window", "", "tmux window id")
fs.Parse(args)
if strings.TrimSpace(thread) == "" || strings.TrimSpace(window) == "" {
return fmt.Errorf("--thread and --window are required")
}
return bindThreadWindow(thread, window)
}
func runGoalUnbind(args []string) error {
fs := flag.NewFlagSet("agent goal unbind", flag.ExitOnError)
var thread string
fs.StringVar(&thread, "thread", "", "thread id")
fs.Parse(args)
if strings.TrimSpace(thread) == "" {
return fmt.Errorf("--thread is required")
}
return unbindThreadWindow(thread)
}
func runGoalMerge(args []string) error {
fs := flag.NewFlagSet("agent goal merge", flag.ExitOnError)
var source, target string
fs.StringVar(&source, "source", "", "thread to absorb and delete")
fs.StringVar(&target, "target", "", "thread that survives")
fs.Parse(args)
if strings.TrimSpace(source) == "" || strings.TrimSpace(target) == "" {
return fmt.Errorf("--source and --target are required")
}
return mergeThreads(source, target)
}
func runGoalPromote(args []string) error {
fs := flag.NewFlagSet("agent goal promote", flag.ExitOnError)
var window, name, goal, bind string
fs.StringVar(&window, "window", "", "tmux window id")
fs.StringVar(&name, "name", "", "thread name")
fs.StringVar(&goal, "goal", "", "goal id to attach to")
fs.StringVar(&bind, "bind", "", "existing thread id to bind the window to")
fs.Parse(args)
window = resolveGoalWindowID(window)
if strings.TrimSpace(window) == "" {
return fmt.Errorf("--window is required (or run inside tmux)")
}
t, err := promoteDraftToThread(window, name, goal, bind)
if err != nil {
return err
}
fmt.Println(t.ID)
return nil
}
func runGoalRevive(args []string) error {
fs := flag.NewFlagSet("agent goal revive", flag.ExitOnError)
var window, name string
fs.StringVar(&window, "window", "", "tmux window id")
fs.StringVar(&name, "name", "", "new thread name")
fs.Parse(args)
window = resolveGoalWindowID(window)
if strings.TrimSpace(window) == "" {
return fmt.Errorf("--window is required (or run inside tmux)")
}
return reviveDoneThreadForWindow(window, name)
}
// runGoalCleanupWindow deletes the thread (and its window todos) for a closed window.
func runGoalCleanupWindow(args []string) error {
fs := flag.NewFlagSet("agent goal cleanup-window", flag.ExitOnError)
var window string
fs.StringVar(&window, "window", "", "tmux window id")
fs.Parse(args)
window = strings.TrimSpace(window)
if window == "" {
return nil
}
store, err := loadGoalStore()
if err != nil {
return err
}
t := findThreadByWindow(store, window)
if t != nil {
_ = deleteThread(t.ID)
}
removeWindowTodos(window)
return nil
}
func runGoalCurrentThread(args []string) error {
fs := flag.NewFlagSet("agent goal current-thread", flag.ExitOnError)
var window string
fs.StringVar(&window, "window", "", "tmux window id (defaults to focused)")
fs.Parse(args)
window = resolveGoalWindowID(window)
store, err := loadGoalStore()
if err != nil {
return err
}
t := findThreadByWindow(store, window)
if t == nil {
return nil
}
fmt.Println(t.ID)
return nil
}
func runGoalSetGoalCurrent(args []string) error {
fs := flag.NewFlagSet("agent goal set-goal-current", flag.ExitOnError)
var window, goal, name string
fs.StringVar(&window, "window", "", "tmux window id (defaults to focused)")
fs.StringVar(&goal, "goal", "", "goal id (empty to unassign)")
fs.StringVar(&name, "name", "", "thread name (creates the thread if needed)")
fs.Parse(args)
window = resolveGoalWindowID(window)
if window == "" {
return fmt.Errorf("no current tmux window")
}
threadID, err := ensureThreadForWindow(window, name)
if err != nil {
return err
}
return setThreadGoal(threadID, goal)
}
func runGoalSetBlockerCurrent(args []string) error {
fs := flag.NewFlagSet("agent goal set-blocker-current", flag.ExitOnError)
var window, by string
fs.StringVar(&window, "window", "", "tmux window id (defaults to focused)")
fs.StringVar(&by, "by", "", "thread id that blocks the current thread")
fs.Parse(args)
window = resolveGoalWindowID(window)
if window == "" {
return fmt.Errorf("no current tmux window")
}
threadID, err := ensureThreadForWindow(window, "")
if err != nil {
return err
}
var blockers []string
if strings.TrimSpace(by) != "" {
blockers = []string{strings.TrimSpace(by)}
}
return setThreadBlocker(threadID, blockers)
}
// runGoalPick prints pickable items for fzf-based prefix dialogs.
// `agent goal pick goals` and `agent goal pick threads`.
func runGoalPick(args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: agent goal pick <goals|threads>")
}
store, err := loadGoalStore()
if err != nil {
return err
}
switch args[0] {
case "goals":
fmt.Println("__new__\t— create new goal —")
appendGoalPickLines(store, "", 0)
case "threads":
for _, id := range sortedThreadIDs(store) {
t := store.Threads[id]
if t == nil {
continue
}
path := strings.Join(goalPathTitles(store, t.GoalID), " ")
label := threadDisplayName(t, "")
if path != "" {
label = path + " " + label
}
fmt.Printf("%s\t%s\n", t.ID, label)
}
default:
return fmt.Errorf("unknown pick kind: %s", args[0])
}
return nil
}
func appendGoalPickLines(store *GoalStore, parentID string, depth int) {
var children []*Goal
for _, g := range store.Goals {
if strings.TrimSpace(g.ParentID) == strings.TrimSpace(parentID) {
children = append(children, g)
}
}
sortGoalsByOrder(children)
for _, g := range children {
indent := strings.Repeat(" ", depth)
fmt.Printf("%s\t%s%s\n", g.ID, indent, g.Title)
appendGoalPickLines(store, g.ID, depth+1)
}
}
func sortedThreadIDs(store *GoalStore) []string {
ids := make([]string, 0, len(store.Threads))
for id := range store.Threads {
ids = append(ids, id)
}
sort.Strings(ids)
return ids
}
func runGoalList(args []string) error {
store, err := loadGoalStore()
if err != nil {
return err
}
drafts, _ := collectDrafts()
list := buildGoalFlatList(store, drafts, nil)
for _, n := range list.Nodes {
indent := strings.Repeat(" ", n.Depth)
switch n.Kind {
case nodeGoal:
fmt.Printf("%s# %s (%s)\n", indent, n.Goal.Title, n.Goal.ID)
case nodeThread:
fmt.Printf("%s- %s [%s] (%s)\n", indent, n.Thread.Name, n.Status, n.Thread.ID)
case nodeDraft:
fmt.Printf("%s- %s [draft] (%s)\n", indent, n.Draft.Name, n.Draft.WindowID)
}
}
return nil
}
// ── helpers shared by CLI + panel ───────────────────────────
func resolveGoalWindowID(window string) string {
window = strings.TrimSpace(window)
if window != "" {
return window
}
if os.Getenv("TMUX") == "" {
return ""
}
out, err := runTmuxOutput("display-message", "-p", "#{window_id}")
if err != nil {
return ""
}
return strings.TrimSpace(out)
}
// ensureThreadForWindow resolves (or promotes) the thread for a window, returning its id.
func ensureThreadForWindow(window, name string) (string, error) {
window = strings.TrimSpace(window)
if window == "" {
return "", fmt.Errorf("window id is required")
}
store, err := loadGoalStore()
if err != nil {
return "", err
}
if t := findThreadByWindow(store, window); t != nil {
return t.ID, nil
}
t, err := promoteDraftToThread(window, name, "", "")
if err != nil {
return "", err
}
return t.ID, nil
}
// stringSliceFlag is a repeatable string flag.
type stringSliceFlag []string
func (s *stringSliceFlag) String() string { return strings.Join(*s, ",") }
func (s *stringSliceFlag) Set(v string) error {
*s = append(*s, v)
return nil
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,306 @@
package main
import (
"strings"
)
// collectDrafts returns windows that have tracker activity but no thread.
// Only windows that still exist in tmux are included.
func collectDrafts() (map[string]*draftInfo, error) {
drafts := map[string]*draftInfo{}
store, err := loadGoalStore()
if err != nil {
return drafts, err
}
env, err := trackerLoadState("")
if err != nil || env == nil {
return drafts, nil
}
existing := collectWindowInfo()
claimed := map[string]bool{}
for _, t := range store.Threads {
if wid := strings.TrimSpace(t.WindowID); wid != "" {
claimed[wid] = true
}
}
for _, task := range env.Tasks {
wid := strings.TrimSpace(task.WindowID)
if wid == "" || claimed[wid] {
continue
}
if _, ok := existing[wid]; !ok {
continue
}
running := task.Status == trackerTaskStatusInProgress
summary := firstLine(strings.TrimSpace(task.Summary))
existingDraft := drafts[wid]
if existingDraft == nil {
drafts[wid] = &draftInfo{
WindowID: wid,
LastUpdate: summary,
SessionName: strings.TrimSpace(task.Session),
WindowName: strings.TrimSpace(task.Window),
Running: running,
}
existingDraft = drafts[wid]
} else {
if running {
existingDraft.Running = true
}
if summary != "" && existingDraft.LastUpdate == "" {
existingDraft.LastUpdate = summary
}
}
for _, ts := range []string{task.StartedAt, task.CompletedAt} {
if parsed, ok := trackerParseTimestamp(ts); ok && parsed.After(existingDraft.LastActive) {
existingDraft.LastActive = parsed
}
}
}
return drafts, nil
}
// reapOrphanedThreads deletes threads bound to windows that no longer exist.
// Planned (window-less) threads are preserved. Safe to call after tmux-resurrect
// has re-pointed threads to new window ids via the restore migration.
func reapOrphanedThreads() {
store, err := loadGoalStore()
if err != nil || store == nil {
return
}
existing := collectWindowInfo()
changed := false
for id, t := range store.Threads {
wid := strings.TrimSpace(t.WindowID)
if wid == "" {
continue
}
if _, ok := existing[wid]; ok {
continue
}
delete(store.Threads, id)
for _, other := range store.Threads {
if containsString(other.BlockedBy, id) {
other.BlockedBy = removeString(other.BlockedBy, id)
}
}
_ = removeWindowTodos(wid)
changed = true
}
if changed {
_ = saveGoalStore(store)
}
}
// removeWindowTodos deletes a window's todo list from the todo store.
func removeWindowTodos(windowID string) error {
windowID = strings.TrimSpace(windowID)
if windowID == "" {
return nil
}
store, err := loadTmuxTodoStore()
if err != nil {
return err
}
if store.Windows == nil {
return nil
}
if _, ok := store.Windows[windowID]; !ok {
return nil
}
delete(store.Windows, windowID)
return saveTmuxTodoStore(store)
}
type taskInfo struct {
phase string
unread bool
lastUpdate string
}
func taskInfoByWindow() map[string]taskInfo {
out := map[string]taskInfo{}
env, err := trackerLoadState("")
if err != nil || env == nil {
return out
}
for _, task := range env.Tasks {
wid := strings.TrimSpace(task.WindowID)
if wid == "" {
continue
}
info := taskInfo{
phase: strings.TrimSpace(task.Phase),
unread: task.Status == "completed" && !task.Acknowledged,
lastUpdate: firstLine(strings.TrimSpace(task.Summary)),
}
out[wid] = info
}
return out
}
// runningWindows returns the set of window ids with an in-progress tracker task.
func runningWindows() map[string]bool {
out := map[string]bool{}
env, err := trackerLoadState("")
if err != nil || env == nil {
return out
}
for _, task := range env.Tasks {
if task.Status == trackerTaskStatusInProgress {
if wid := strings.TrimSpace(task.WindowID); wid != "" {
out[wid] = true
}
}
}
return out
}
type windowInfo struct {
sessionName string
windowName string
}
// collectWindowInfo returns live session/window names for every tmux window.
func collectWindowInfo() map[string]windowInfo {
out := map[string]windowInfo{}
lines, err := runTmuxOutput("list-windows", "-a", "-F",
"#{window_id}\t#{session_name}\t#{window_name}")
if err != nil {
return out
}
for _, line := range strings.Split(lines, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
parts := strings.Split(line, "\t")
if len(parts) < 3 {
continue
}
wid := strings.TrimSpace(parts[0])
if wid == "" {
continue
}
out[wid] = windowInfo{
sessionName: strings.TrimSpace(parts[1]),
windowName: strings.TrimSpace(parts[2]),
}
}
return out
}
// buildDisplayList constructs the enriched flat list the panel renders.
// expanded maps thread ids whose todos should be shown inline.
func buildDisplayList(expanded map[string]bool) (*flatList, error) {
store, err := loadGoalStore()
if err != nil {
return nil, err
}
drafts, _ := collectDrafts()
running := runningWindows()
windows := collectWindowInfo()
todoStore, _ := loadTmuxTodoStore()
taskByWindow := taskInfoByWindow()
list := buildGoalFlatList(store, drafts, expanded)
for _, n := range list.Nodes {
switch n.Kind {
case nodeGoal:
case nodeThread:
n.Status = computeThreadStatus(store, n.Thread, running)
n.SessionName, n.WindowName = windowLabel(windows, n.Thread.WindowID)
n.OpenTodos, n.TotalTodos = windowTodoCounts(todoStore, n.Thread.WindowID)
n.HasTodos = n.TotalTodos > 0
if info, ok := taskByWindow[strings.TrimSpace(n.Thread.WindowID)]; ok {
n.Phase = info.phase
n.Unread = info.unread
n.LastUpdate = info.lastUpdate
}
case nodeDraft:
if n.Draft.Running {
n.Status = threadStatusRunning
} else {
n.Status = threadStatusReady
}
n.SessionName = n.Draft.SessionName
n.WindowName = n.Draft.WindowName
n.LastUpdate = n.Draft.LastUpdate
n.OpenTodos, n.TotalTodos = windowTodoCounts(todoStore, n.Draft.WindowID)
n.HasTodos = n.TotalTodos > 0
if info, ok := taskByWindow[strings.TrimSpace(n.Draft.WindowID)]; ok {
n.Phase = info.phase
n.Unread = info.unread
if info.lastUpdate != "" {
n.LastUpdate = info.lastUpdate
}
}
}
}
return spliceExpandedTodos(list, todoStore, expanded), nil
}
// spliceExpandedTodos inserts todo nodes under expanded threads/drafts.
func spliceExpandedTodos(list *flatList, store *tmuxTodoStore, expanded map[string]bool) *flatList {
if store == nil {
return list
}
result := &flatList{}
for _, n := range list.Nodes {
result.Nodes = append(result.Nodes, n)
var windowID string
switch n.Kind {
case nodeThread:
windowID = n.Thread.WindowID
case nodeDraft:
windowID = n.Draft.WindowID
default:
continue
}
var key string
if n.Kind == nodeThread {
key = n.Thread.ID
} else {
key = "draft:" + windowID
}
if !expanded[key] {
continue
}
items := store.Windows[strings.TrimSpace(windowID)]
for idx, item := range items {
result.Nodes = append(result.Nodes, &goalNode{
Kind: nodeTodo,
Depth: n.Depth + 1,
Todo: &item,
TodoWindow: windowID,
TodoIndex: idx,
})
}
}
return result
}
func windowLabel(windows map[string]windowInfo, windowID string) (string, string) {
windowID = strings.TrimSpace(windowID)
if windowID == "" {
return "", ""
}
info, ok := windows[windowID]
if !ok {
return "", ""
}
return info.sessionName, info.windowName
}
func windowTodoCounts(store *tmuxTodoStore, windowID string) (open, total int) {
windowID = strings.TrimSpace(windowID)
if windowID == "" || store == nil {
return 0, 0
}
for _, item := range store.Windows[windowID] {
total++
if !item.Done {
open++
}
}
return open, total
}

View file

@ -83,6 +83,7 @@ type statusRightConfig struct {
Todos *bool `json:"todos,omitempty"`
FlashMoe *bool `json:"flash_moe,omitempty"`
Host *bool `json:"host,omitempty"`
Goal *bool `json:"goal,omitempty"`
}
type keyConfig struct {
@ -105,10 +106,10 @@ type keyConfig struct {
}
type repoConfig struct {
BaseBranch string `yaml:"base_branch,omitempty"`
DefaultDevice string `yaml:"default_device,omitempty"`
CopyIgnore []string `yaml:"copy_ignore,omitempty"`
AgentKeyPaths []string `yaml:"agent_key_paths,omitempty"`
BaseBranch string `yaml:"base_branch,omitempty"`
DefaultDevice string `yaml:"default_device,omitempty"`
CopyIgnore []string `yaml:"copy_ignore,omitempty"`
AgentKeyPaths []string `yaml:"agent_key_paths,omitempty"`
}
type featureConfig struct {
@ -159,7 +160,7 @@ func main() {
func run(args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: agent <start|resume|list|destroy|init|config|setup|tmux|tracker|browser|feature>")
return fmt.Errorf("usage: agent <start|resume|list|destroy|init|config|setup|tmux|tracker|goal|browser|feature|update-hot-reload>")
}
switch args[0] {
case "start":
@ -182,10 +183,14 @@ func run(args []string) error {
return runTmuxCommand(args[1:])
case "tracker":
return runTracker(args[1:])
case "goal":
return runGoal(args[1:])
case "browser":
return runBrowserCommand(args[1:])
case "feature":
return runFeatureCommand(args[1:])
case "update-hot-reload":
return runUpdateHotReload(args[1:])
case "bootstrap":
return runBootstrap(args[1:])
default:
@ -1052,7 +1057,7 @@ func destroyRequiresExplicitConfirm(record *agentRecord) (bool, error) {
func runTmuxCommand(args []string) error {
if len(args) == 0 {
return fmt.Errorf("usage: agent tmux <on-focus|focus|palette|right-status>")
return fmt.Errorf("usage: agent tmux <on-focus|focus|palette|right-status|scratch>")
}
switch args[0] {
case "on-focus":
@ -1063,6 +1068,8 @@ func runTmuxCommand(args []string) error {
return runTmuxPalette(args[1:])
case "right-status":
return runTmuxRightStatus(args[1:])
case "scratch":
return runTmuxScratch(args[1:])
default:
return fmt.Errorf("unknown tmux subcommand: %s", args[0])
}
@ -1366,6 +1373,298 @@ func runTmuxFocus(args []string) error {
return runTmux("select-pane", "-t", target)
}
func runTmuxScratch(args []string) error {
fs := flag.NewFlagSet("agent tmux scratch", flag.ContinueOnError)
var path string
var client string
var warm bool
fs.StringVar(&path, "path", "", "directory for creating the scratch terminal")
fs.StringVar(&client, "client", "", "tmux client tty")
fs.BoolVar(&warm, "warm", false, "prepare the scratch terminal without opening it")
fs.SetOutput(os.Stderr)
if err := fs.Parse(args); err != nil {
return err
}
if warm {
return warmScratchSession(path)
}
current, err := currentTmuxLocation()
if err != nil {
return err
}
reg, err := loadRegistry()
if err != nil {
return err
}
record := reg.Agents["scratch"]
if scratchRecordAlive(record) {
_ = runTmux("rename-session", "-t", record.TmuxSessionID, "scratch")
_ = configureScratchSession(record.TmuxSessionID, record.TmuxWindowID)
if current.SessionID == record.TmuxSessionID {
return closeScratchPopup(client)
}
setScratchOrigin(record.TmuxWindowID, current)
if current.WindowID != "" {
record.LaunchWindowID = current.WindowID
}
now := time.Now()
record.LastFocusedAt = &now
record.UpdatedAt = now
reg.FocusedAgentID = record.ID
if err := saveRegistry(reg); err != nil {
return err
}
return openScratchPopup(record)
}
if record == nil {
record = &agentRecord{ID: "scratch", Name: "scratch", CreatedAt: time.Now()}
}
startPath := firstNonEmpty(path, os.Getenv("HOME"))
if startPath == "" || !dirExists(startPath) {
startPath = os.Getenv("HOME")
}
windowID, sessionID, sessionName, paneID, err := createScratchTmuxSession(startPath)
if err != nil {
return err
}
now := time.Now()
record.ID = "scratch"
record.Name = "scratch"
record.RepoRoot = startPath
record.WorkspaceRoot = startPath
record.RepoCopyPath = startPath
record.TmuxSessionID = sessionID
record.TmuxSessionName = sessionName
record.TmuxWindowID = windowID
record.Panes = agentPanes{AI: paneID}
record.LaunchWindowID = current.WindowID
record.LastFocusedAt = &now
record.UpdatedAt = now
if record.CreatedAt.IsZero() {
record.CreatedAt = now
}
reg.Agents[record.ID] = record
reg.FocusedAgentID = record.ID
if err := saveRegistry(reg); err != nil {
return err
}
setScratchOrigin(windowID, current)
return openScratchPopup(record)
}
func warmScratchSession(path string) error {
reg, err := loadRegistry()
if err != nil {
return err
}
record := reg.Agents["scratch"]
if record == nil {
return nil
}
if scratchRecordAlive(record) {
_ = runTmux("rename-session", "-t", record.TmuxSessionID, "scratch")
return configureScratchSession(record.TmuxSessionID, record.TmuxWindowID)
}
startPath := firstNonEmpty(path, record.RepoCopyPath, record.WorkspaceRoot, record.RepoRoot, os.Getenv("HOME"))
if startPath == "" || !dirExists(startPath) {
startPath = os.Getenv("HOME")
}
windowID, sessionID, sessionName, paneID, err := createScratchTmuxSession(startPath)
if err != nil {
return err
}
now := time.Now()
record.TmuxSessionID = sessionID
record.TmuxSessionName = sessionName
record.TmuxWindowID = windowID
record.Panes = agentPanes{AI: paneID}
record.RepoRoot = startPath
record.WorkspaceRoot = startPath
record.RepoCopyPath = startPath
record.UpdatedAt = now
reg.Agents[record.ID] = record
return saveRegistry(reg)
}
type tmuxLocation struct {
SessionID string
WindowID string
PaneID string
Path string
}
func currentTmuxLocation() (tmuxLocation, error) {
out, err := runTmuxOutput("display-message", "-p", "#{session_id}\n#{window_id}\n#{pane_id}\n#{pane_current_path}")
if err != nil {
return tmuxLocation{}, err
}
parts := strings.SplitN(strings.TrimRight(out, "\n"), "\n", 4)
for len(parts) < 4 {
parts = append(parts, "")
}
return tmuxLocation{
SessionID: strings.TrimSpace(parts[0]),
WindowID: strings.TrimSpace(parts[1]),
PaneID: strings.TrimSpace(parts[2]),
Path: strings.TrimSpace(parts[3]),
}, nil
}
func scratchRecordAlive(record *agentRecord) bool {
return record != nil && windowAlive(record.TmuxSessionID, record.TmuxWindowID)
}
func createScratchTmuxSession(path string) (windowID, sessionID, sessionName, paneID string, err error) {
sessionName = "Scratch"
if existingSessionID, existingSessionName, ok := findTmuxSessionByLabel(sessionName); ok {
sessionID = existingSessionID
sessionName = existingSessionName
windowID, err = runTmuxOutput("new-window", "-P", "-F", "#{window_id}", "-t", sessionID, "-c", path)
if err != nil {
return "", "", "", "", err
}
} else {
if err := runTmux("new-session", "-d", "-s", sessionName, "-c", path); err != nil {
return "", "", "", "", err
}
sessionID, _ = runTmuxOutput("display-message", "-p", "-t", sessionName+":1", "#{session_id}")
windowID, _ = runTmuxOutput("display-message", "-p", "-t", sessionName+":1", "#{window_id}")
}
windowID = strings.TrimSpace(windowID)
sessionID = strings.TrimSpace(sessionID)
sessionName = strings.TrimSpace(sessionName)
if windowID == "" || sessionID == "" {
return "", "", "", "", fmt.Errorf("unable to create scratch terminal")
}
paneID, err = currentPane(windowID)
if err != nil {
return "", "", "", "", err
}
_ = runTmux("rename-session", "-t", sessionID, "Scratch")
_ = runTmux("set-option", "-w", "-t", windowID, "@agent_id", "scratch")
_ = runTmux("set-option", "-w", "-t", windowID, "@scratch_window", "1")
_ = runTmux("set-option", "-p", "-t", paneID, "@agent_role", "scratch")
_ = configureScratchSession(sessionID, windowID)
return windowID, sessionID, "Scratch", strings.TrimSpace(paneID), nil
}
func configureScratchSession(sessionID, windowID string) error {
sessionID = strings.TrimSpace(sessionID)
windowID = strings.TrimSpace(windowID)
if sessionID != "" {
_ = runTmux("set-option", "-t", sessionID, "status", "on")
_ = runTmux("set-option", "-t", sessionID, "status-left", "")
_ = runTmux("set-option", "-t", sessionID, "status-right", "")
_ = runTmux("set-option", "-t", sessionID, "status-left-length", "0")
_ = runTmux("set-option", "-t", sessionID, "status-right-length", "0")
_ = runTmux("set-option", "-t", sessionID, "prefix", "None")
_ = runTmux("set-option", "-t", sessionID, "detach-on-destroy", "on")
}
if windowID != "" {
_ = runTmux("set-option", "-w", "-t", windowID, "@agent_id", "scratch")
_ = runTmux("set-option", "-w", "-t", windowID, "@scratch_window", "1")
}
return nil
}
func openScratchPopup(record *agentRecord) error {
if record == nil {
return fmt.Errorf("scratch terminal is not available")
}
target := strings.TrimSpace(record.TmuxSessionID)
if target == "" {
target = strings.TrimSpace(record.TmuxWindowID)
}
if target == "" {
return fmt.Errorf("scratch terminal is not available")
}
cmd := fmt.Sprintf("tmux attach-session -t %s", shellQuote(target))
return runTmux("display-popup", "-E", "-w", "85%", "-h", "80%", "-T", "Scratch", cmd)
}
func closeScratchPopup(client string) error {
client = strings.TrimSpace(client)
if client == "" {
client = strings.TrimSpace(os.Getenv("TMUX_CLIENT"))
}
args := []string{"detach-client"}
if client != "" {
args = append(args, "-t", client)
}
return runTmux(args...)
}
func setScratchOrigin(scratchWindowID string, origin tmuxLocation) {
scratchWindowID = strings.TrimSpace(scratchWindowID)
if scratchWindowID == "" {
return
}
if origin.SessionID != "" {
_ = runTmux("set-option", "-w", "-t", scratchWindowID, "@scratch_origin_session", origin.SessionID)
}
if origin.WindowID != "" {
_ = runTmux("set-option", "-w", "-t", scratchWindowID, "@scratch_origin_window", origin.WindowID)
}
if origin.PaneID != "" {
_ = runTmux("set-option", "-w", "-t", scratchWindowID, "@scratch_origin_pane", origin.PaneID)
}
}
func focusScratchOrigin(reg *registry) error {
if reg == nil {
return nil
}
record := reg.Agents["scratch"]
if record == nil {
return nil
}
originSessionID := ""
originWindowID := ""
originPaneID := ""
if out, err := runTmuxOutput("show-options", "-wqv", "-t", record.TmuxWindowID, "@scratch_origin_session"); err == nil {
originSessionID = strings.TrimSpace(out)
}
if out, err := runTmuxOutput("show-options", "-wqv", "-t", record.TmuxWindowID, "@scratch_origin_window"); err == nil {
originWindowID = strings.TrimSpace(out)
}
if out, err := runTmuxOutput("show-options", "-wqv", "-t", record.TmuxWindowID, "@scratch_origin_pane"); err == nil {
originPaneID = strings.TrimSpace(out)
}
if !windowAlive(originSessionID, originWindowID) {
originSessionID = ""
originWindowID = firstLiveNonScratchWindow(record.TmuxWindowID)
}
if originWindowID == "" {
return nil
}
reg.FocusedAgentID = ""
_ = saveRegistry(reg)
if originSessionID != "" {
_ = runTmux("switch-client", "-t", originSessionID)
}
if err := selectTmuxWindow(originWindowID); err != nil {
return err
}
if originPaneID != "" {
_ = runTmux("select-pane", "-t", originPaneID)
}
return nil
}
func firstLiveNonScratchWindow(scratchWindowID string) string {
out, err := runTmuxOutput("list-windows", "-a", "-F", "#{window_id}")
if err != nil {
return ""
}
for _, line := range strings.Split(out, "\n") {
windowID := strings.TrimSpace(line)
if windowID != "" && windowID != strings.TrimSpace(scratchWindowID) {
return windowID
}
}
return ""
}
func runTmuxPalette(args []string) error {
fs := flag.NewFlagSet("agent tmux palette", flag.ContinueOnError)
var windowID string
@ -2526,6 +2825,19 @@ exec script -q "$logfile" bash -lc "cd \"$DIR/repo\" && exec flutter run -d \"$d
if err := os.WriteFile(ensurePath, []byte(ensureServer), 0o755); err != nil {
return err
}
if err := writeHotReloadScript(repoCopyPath); err != nil {
return err
}
_ = os.Remove(filepath.Join(workspaceRoot, "hot-reload.sh"))
for _, obsolete := range []string{"open-tab.sh", "refresh-tab.sh", "on-tmux-window-activate.sh"} {
_ = os.Remove(filepath.Join(workspaceRoot, obsolete))
}
_ = url
_ = device
return nil
}
func writeHotReloadScript(repoCopyPath string) error {
hotReload := `#!/usr/bin/env bash
set -euo pipefail
@ -2584,6 +2896,7 @@ find_flutter_pane() {
while IFS= read -r child; do
[[ -z "$child" ]] && continue
if ps -p "$child" -o command= 2>/dev/null | grep -q 'flutter_tools\.snapshot.*run'; then
FLUTTER_RUN_PID="$child"
return 0
fi
if has_flutter_run "$child" $((depth + 1)); then
@ -2593,15 +2906,24 @@ find_flutter_pane() {
return 1
}
local pane_id pane_pid pane_path
while read -r pane_id pane_pid pane_path; do
local pane_id pane_pid fpid fcwd
local repo_dir="${REPO_DIR%/}"
local workspace_dir="${WORKSPACE_DIR%/}"
while read -r pane_id pane_pid; do
[[ -z "$pane_id" || -z "$pane_pid" ]] && continue
[[ "$pane_path" != "$WORKSPACE_DIR" && "$pane_path" != "$REPO_DIR" ]] && continue
if has_flutter_run "$pane_pid"; then
FLUTTER_RUN_PID=""
if ! has_flutter_run "$pane_pid"; then
continue
fi
fpid="$FLUTTER_RUN_PID"
[[ -z "$fpid" ]] && continue
fcwd="$(lsof -a -d cwd -p "$fpid" 2>/dev/null | tail -n +2 | awk '{print $NF}')"
fcwd="${fcwd%/}"
if [[ "$fcwd" == "$repo_dir" || "$fcwd" == "$workspace_dir" ]]; then
printf "%s\n" "$pane_id"
return 0
fi
done < <(tmux list-panes -a -F '#{pane_id} #{pane_pid} #{pane_current_path}' 2>/dev/null)
done < <(tmux list-panes -a -F '#{pane_id} #{pane_pid}' 2>/dev/null)
return 1
}
@ -2655,24 +2977,107 @@ PY
exit 0
) >/dev/null 2>&1 &
echo "Hot reload triggered"
echo "Reloaded the application"
`
if err := ensureGeneratedRepoPathIgnored(repoCopyPath, "hot-reload.sh"); err != nil {
return err
}
hotReloadPath := filepath.Join(repoCopyPath, "hot-reload.sh")
if err := os.WriteFile(hotReloadPath, []byte(hotReload), 0o755); err != nil {
return os.WriteFile(hotReloadPath, []byte(hotReload), 0o755)
}
func runUpdateHotReload(args []string) error {
fs := flag.NewFlagSet("agent update-hot-reload", flag.ContinueOnError)
var allRepos bool
fs.BoolVar(&allRepos, "all", false, "update workspaces across every repo in the registry")
fs.SetOutput(os.Stderr)
if err := fs.Parse(args); err != nil {
return err
}
_ = os.Remove(filepath.Join(workspaceRoot, "hot-reload.sh"))
for _, obsolete := range []string{"open-tab.sh", "refresh-tab.sh", "on-tmux-window-activate.sh"} {
_ = os.Remove(filepath.Join(workspaceRoot, obsolete))
var repoRoots []string
if allRepos {
reg, err := loadRegistry()
if err != nil {
return err
}
seen := map[string]bool{}
for _, record := range reg.Agents {
root := filepath.Clean(record.RepoRoot)
if root == "" || seen[root] {
continue
}
seen[root] = true
repoRoots = append(repoRoots, root)
}
sort.Strings(repoRoots)
} else {
root, err := repoRoot()
if err != nil {
return fmt.Errorf("%w; run inside a git repo or use --all", err)
}
repoRoots = []string{root}
}
_ = url
_ = device
totalUpdated := 0
for _, root := range repoRoots {
updated, err := updateHotReloadInRepo(root)
if err != nil {
return fmt.Errorf("%s: %w", root, err)
}
if len(updated) == 0 {
continue
}
totalUpdated += len(updated)
fmt.Printf("%s:\n", root)
for _, name := range updated {
fmt.Printf(" %s\n", name)
}
}
if totalUpdated == 0 {
fmt.Println("No Flutter workspaces found.")
return nil
}
fmt.Printf("Updated hot-reload.sh in %d workspace(s).\n", totalUpdated)
return nil
}
func updateHotReloadInRepo(repoRoot string) ([]string, error) {
agentsRoot := filepath.Join(repoRoot, ".agents")
entries, err := os.ReadDir(agentsRoot)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var updated []string
for _, entry := range entries {
if !entry.IsDir() {
continue
}
workspaceRoot := filepath.Join(agentsRoot, entry.Name())
featurePath := filepath.Join(workspaceRoot, "agent.json")
repoCopyPath := filepath.Join(workspaceRoot, "repo")
isFlutter := false
if cfg, err := loadFeatureConfig(featurePath); err == nil {
isFlutter = cfg.IsFlutter
}
hotReloadPath := filepath.Join(repoCopyPath, "hot-reload.sh")
if !isFlutter && !fileExists(hotReloadPath) {
continue
}
if !pathExists(repoCopyPath) {
continue
}
if err := writeHotReloadScript(repoCopyPath); err != nil {
return nil, fmt.Errorf("%s: %w", entry.Name(), err)
}
updated = append(updated, entry.Name())
}
return updated, nil
}
func detectDefaultBaseBranch(repoRoot string) string {
for _, candidate := range []string{"develop", "main", "master"} {
if remoteExists(repoRoot, "origin/"+candidate) || localExists(repoRoot, candidate) {
@ -2768,10 +3173,6 @@ func loadRegistry() (*registry, error) {
if decodeErr := dec.Decode(fallback); decodeErr != nil {
return nil, err
}
trailing := strings.TrimSpace(string(data[int(dec.InputOffset()):]))
if trailing == "" || strings.Trim(trailing, "}") != "" {
return nil, err
}
reg = fallback
_ = saveRegistry(reg)
}

View file

@ -22,6 +22,7 @@ const (
paletteModeDevices
paletteModeStatusRight
paletteModeTracker
paletteModeGoals
)
type palettePromptField int
@ -51,6 +52,8 @@ const (
paletteActionOpenTodos
paletteActionOpenDevices
paletteActionOpenTracker
paletteActionOpenGoals
paletteActionOpenScratch
)
type paletteAction struct {

View file

@ -42,6 +42,7 @@ type paletteRuntime struct {
currentSessionName string
currentWindowName string
mainRepoRoot string
startMode paletteMode
}
type paletteModel struct {
@ -50,6 +51,7 @@ type paletteModel struct {
actions []paletteAction
openedAt time.Time
quickSecondaryEscCloses bool
singlePanelMode bool
width int
height int
result paletteResult
@ -58,6 +60,7 @@ type paletteModel struct {
devices *devicePanelModel
status *statusRightPanelModel
tracker *trackerPanelModel
goals *goalPanelModel
}
type paletteStyles struct {
@ -110,7 +113,7 @@ func runBubbleTeaPalette(args []string) error {
if err != nil {
return err
}
state := paletteUIState{Mode: paletteModeList, Message: runtime.startupMessage}
state := paletteUIState{Mode: runtime.startMode, Message: runtime.startupMessage}
for {
model := newPaletteModel(runtime, state)
finalModel, err := tea.NewProgram(model).Run()
@ -169,11 +172,13 @@ func loadPaletteRuntime(args []string) (*paletteRuntime, error) {
var currentPath string
var currentSessionName string
var currentWindowName string
var modeFlag string
fs.StringVar(&windowID, "window", "", "window id")
fs.StringVar(&agentID, "agent-id", "", "agent id")
fs.StringVar(&currentPath, "path", "", "current pane path")
fs.StringVar(&currentSessionName, "session-name", "", "current session name")
fs.StringVar(&currentWindowName, "window-name", "", "current window name")
fs.StringVar(&modeFlag, "mode", "", "initial panel mode (goals, tracker, todos, activity)")
fs.SetOutput(nil)
if err := fs.Parse(args); err != nil {
return nil, err
@ -186,6 +191,18 @@ func loadPaletteRuntime(args []string) (*paletteRuntime, error) {
currentSessionName: firstNonEmpty(currentSessionName, os.Getenv("AGENT_PALETTE_SESSION_NAME")),
currentWindowName: firstNonEmpty(currentWindowName, os.Getenv("AGENT_PALETTE_WINDOW_NAME")),
}
switch strings.ToLower(modeFlag) {
case "goals":
runtime.startMode = paletteModeGoals
case "tracker":
runtime.startMode = paletteModeTracker
case "todos":
runtime.startMode = paletteModeTodos
case "activity":
runtime.startMode = paletteModeActivity
default:
runtime.startMode = paletteModeList
}
logPaletteLaunchIfMalformed(runtime)
if looksLikeTmuxFormatLiteral(runtime.agentID) {
runtime.agentID = ""
@ -351,6 +368,20 @@ func (r *paletteRuntime) buildActions() []paletteAction {
})
}
actions = append(actions,
paletteAction{
Section: "System",
Title: "Scratch terminal",
Subtitle: "Open the persistent scratch shell",
Keywords: []string{"scratch", "terminal", "shell", "popup", "tmux"},
Kind: paletteActionOpenScratch,
},
paletteAction{
Section: "System",
Title: "Goals",
Subtitle: "Goals, threads and todos",
Keywords: []string{"goal", "goals", "thread", "threads", "tracker", "tasks", "status"},
Kind: paletteActionOpenGoals,
},
paletteAction{
Section: "System",
Title: "Tracker",
@ -582,11 +613,22 @@ func (r *paletteRuntime) execute(result paletteResult) (bool, string, error) {
return false, "", nil
case paletteActionReloadTmuxConfig:
return false, "", paletteTmuxRunner("source-file", os.Getenv("HOME")+"/.config/.tmux.conf")
case paletteActionOpenScratch:
return false, "", launchScratchTerminalFromPalette(r.currentPath)
default:
return false, "", nil
}
}
func launchScratchTerminalFromPalette(currentPath string) error {
exe, err := os.Executable()
if err != nil {
return err
}
cmd := fmt.Sprintf("sleep 0.1; %s tmux scratch --path %s", shellQuote(exe), shellQuote(currentPath))
return runTmux("run-shell", "-b", cmd)
}
func statusRightModuleLabel(module string) string {
switch module {
case statusRightModuleCPU:
@ -653,7 +695,7 @@ func newPaletteModel(runtime *paletteRuntime, state paletteUIState) *paletteMode
if len(state.PromptDevices) > 0 {
state.PromptDeviceIndex = clampInt(state.PromptDeviceIndex, 0, len(state.PromptDevices)-1)
}
model := &paletteModel{runtime: runtime, state: state, actions: runtime.buildActions(), openedAt: time.Now()}
model := &paletteModel{runtime: runtime, state: state, actions: runtime.buildActions(), openedAt: time.Now(), singlePanelMode: runtime.startMode != paletteModeList}
if state.Mode == paletteModeTodos {
_ = model.openTodosPanel()
}
@ -669,10 +711,22 @@ func newPaletteModel(runtime *paletteRuntime, state paletteUIState) *paletteMode
if state.Mode == paletteModeTracker {
_, _ = model.openTrackerPanel()
}
if state.Mode == paletteModeGoals {
_, _ = model.openGoalsPanel()
}
return model
}
func (m *paletteModel) Init() tea.Cmd {
if m.goals != nil {
return goalPanelTickCmd()
}
if m.tracker != nil {
return trackerPanelTickCmd()
}
if m.activity != nil {
return activityTickCmd()
}
return nil
}
@ -791,6 +845,24 @@ func (m *paletteModel) openTrackerPanel() (tea.Cmd, error) {
return m.tracker.activate(), nil
}
func (m *paletteModel) openGoalsPanel() (tea.Cmd, error) {
m.noteSecondaryPageOpen()
if m.goals == nil {
m.goals = newGoalPanelModel(m.runtime)
} else {
m.goals.runtime = m.runtime
m.goals.requestBack = false
m.goals.requestClose = false
}
m.goals.width = m.width
m.goals.height = m.height
m.goals.showAltHints = false
m.state.Mode = paletteModeGoals
m.state.Message = ""
m.state.ShowAltHints = false
return m.goals.activate(), nil
}
func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
@ -808,12 +880,16 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.tracker.width = msg.Width
m.tracker.height = msg.Height
}
if m.goals != nil {
m.goals.width = msg.Width
m.goals.height = msg.Height
}
if m.status != nil {
m.status.width = msg.Width
m.status.height = msg.Height
}
case tea.KeyMsg:
if m.state.Mode != paletteModeActivity && m.state.Mode != paletteModeTodos && m.state.Mode != paletteModeDevices && m.state.Mode != paletteModeStatusRight && m.state.Mode != paletteModeTracker {
if m.state.Mode != paletteModeActivity && m.state.Mode != paletteModeTodos && m.state.Mode != paletteModeDevices && m.state.Mode != paletteModeStatusRight && m.state.Mode != paletteModeTracker && m.state.Mode != paletteModeGoals {
if isAltFooterToggleKey(msg) {
m.state.ShowAltHints = !m.state.ShowAltHints
return m, nil
@ -843,6 +919,10 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.closePalette()
case paletteModeTracker:
return m.closePalette()
case paletteModeGoals:
if m.goals != nil && m.goals.mode == goalModeList {
return m.closePalette()
}
case paletteModeSnippets:
return m.closePalette()
}
@ -943,6 +1023,10 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit
}
if m.tracker.requestBack {
if m.singlePanelMode {
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
return m, tea.Quit
}
m.tracker.requestBack = false
m.state.Mode = paletteModeList
m.state.Message = m.tracker.currentStatus()
@ -950,6 +1034,36 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, cmd
}
if m.state.Mode == paletteModeGoals {
if m.goals == nil {
cmd, err := m.openGoalsPanel()
if err != nil {
m.state.Mode = paletteModeList
m.state.Message = err.Error()
return m, nil
}
return m, cmd
}
model, cmd := m.goals.Update(msg)
if updated, ok := model.(*goalPanelModel); ok {
m.goals = updated
}
if m.goals.requestClose {
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
return m, tea.Quit
}
if m.goals.requestBack {
if m.singlePanelMode {
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
return m, tea.Quit
}
m.goals.requestBack = false
m.state.Mode = paletteModeList
m.state.Message = m.goals.currentStatus()
return m, nil
}
return m, cmd
}
switch m.state.Mode {
case paletteModePrompt:
return m.updatePrompt(key)
@ -1030,6 +1144,23 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
return m, cmd
}
if m.state.Mode == paletteModeGoals && m.goals != nil {
model, cmd := m.goals.Update(msg)
if updated, ok := model.(*goalPanelModel); ok {
m.goals = updated
}
if m.goals.requestClose {
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
return m, tea.Quit
}
if m.goals.requestBack {
m.goals.requestBack = false
m.state.Mode = paletteModeList
m.state.Message = m.goals.currentStatus()
return m, nil
}
return m, cmd
}
return m, nil
}
@ -1051,6 +1182,14 @@ func (m *paletteModel) updateList(key string) (tea.Model, tea.Cmd) {
return m, nil
}
if key == "alt+r" {
cmd, err := m.openGoalsPanel()
if err != nil {
m.state.Message = err.Error()
return m, nil
}
return m, cmd
}
if key == "alt+d" {
cmd, err := m.openTrackerPanel()
if err != nil {
m.state.Message = err.Error()
@ -1145,6 +1284,13 @@ func (m *paletteModel) selectAction(action paletteAction) (tea.Model, tea.Cmd) {
return m, nil
}
return m, cmd
case paletteActionOpenGoals:
cmd, err := m.openGoalsPanel()
if err != nil {
m.state.Message = err.Error()
return m, nil
}
return m, cmd
case paletteActionOpenTodos:
if err := m.openTodosPanel(); err != nil {
m.state.Message = err.Error()
@ -1496,6 +1642,14 @@ func (m *paletteModel) View() string {
}
return styles.muted.Render("Tracker unavailable")
}
if m.state.Mode == paletteModeGoals {
if m.goals != nil {
m.goals.width = width
m.goals.height = height
return m.goals.View()
}
return styles.muted.Render("Goals unavailable")
}
return m.renderListView(styles, width, height)
}
@ -2199,9 +2353,9 @@ func renderPaletteFooter(styles paletteStyles, width int, message string, showAl
{{"Enter", "run"}, {"Esc", "close"}, {footerHintToggleKey, "more"}},
},
[][][2]string{
{{"Alt-U/E", "move"}, {"Alt-I", "run"}, {"Alt-C", "create"}, {"Alt-R", "tracker"}, {"Alt-A", "activity"}, {"Alt-P", "snippets"}, {"Alt-T", "todos"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
{{"Alt-C", "create"}, {"Alt-R", "tracker"}, {"Alt-A", "activity"}, {"Alt-T", "todos"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
{{"Alt-C", "create"}, {"Alt-R", "tracker"}, {"Alt-S", "close"}},
{{"Alt-U/E", "move"}, {"Alt-I", "run"}, {"Alt-C", "create"}, {"Alt-R", "goals"}, {"Alt-D", "tracker"}, {"Alt-A", "activity"}, {"Alt-P", "snippets"}, {"Alt-T", "todos"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
{{"Alt-C", "create"}, {"Alt-R", "goals"}, {"Alt-D", "tracker"}, {"Alt-A", "activity"}, {"Alt-T", "todos"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
{{"Alt-C", "create"}, {"Alt-R", "goals"}, {"Alt-D", "tracker"}, {"Alt-S", "close"}},
},
)
}

View file

@ -25,6 +25,7 @@ const (
statusRightModuleTodos = "todos"
statusRightModuleFlashMoe = "flash_moe"
statusRightModuleHost = "host"
statusRightModuleGoal = "goal"
)
const (
@ -37,6 +38,7 @@ const (
statusIconAgent = "󰚩"
statusIconTodos = "󰎚"
statusIconFlashMoe = "󱙺"
statusIconGoal = "⌖"
)
func statusRightModules() []string {
@ -50,6 +52,7 @@ func statusRightModules() []string {
statusRightModuleTodoPreview,
statusRightModuleFlashMoe,
statusRightModuleHost,
statusRightModuleGoal,
}
}
@ -201,6 +204,11 @@ func renderTmuxRightStatus(args tmuxRightStatusArgs) string {
segments = append(segments, statusSegment{FG: "#1d1f21", BG: "#cc6666", Text: label, Bold: true})
}
}
if statusRightModuleEnabled(statusRightModuleGoal) {
if label := loadGoalStatusLabel(args.WindowID); label != "" {
segments = append(segments, statusSegment{FG: "#1d1f21", BG: "#a3be8c", Text: label, Bold: true})
}
}
if statusRightModuleEnabled(statusRightModuleFlashMoe) {
if segment, ok := loadFlashMoeStatusSegment(); ok {
segments = append(segments, segment)
@ -532,6 +540,48 @@ func loadAgentStatusLabel(windowID string) string {
return fmt.Sprintf(" %s %s ", statusIconAgent, device)
}
// loadGoalStatusLabel renders the focused window's goal path + thread name.
func loadGoalStatusLabel(windowID string) string {
windowID = strings.TrimSpace(windowID)
if windowID == "" {
return ""
}
store, err := loadGoalStore()
if err != nil || store == nil {
return ""
}
thread := findThreadByWindow(store, windowID)
if thread == nil {
return ""
}
path := goalPathTitles(store, thread.GoalID)
if len(path) == 0 {
return ""
}
label := strings.Join(path, " ")
return fmt.Sprintf(" %s %s ", statusIconGoal, truncate(label, 40))
}
// goalPathTitles returns ancestor goal titles from root to the given goal.
func goalPathTitles(store *GoalStore, goalID string) []string {
goalID = strings.TrimSpace(goalID)
if goalID == "" || store == nil {
return nil
}
var titles []string
seen := map[string]bool{}
for goalID != "" && !seen[goalID] {
g, ok := store.Goals[goalID]
if !ok {
break
}
seen[goalID] = true
titles = append([]string{strings.TrimSpace(g.Title)}, titles...)
goalID = strings.TrimSpace(g.ParentID)
}
return titles
}
func refreshMemoryUsageCache() {
script := statusMemoryCacheRefreshScript()
if strings.TrimSpace(script) == "" || !fileExists(script) {
@ -690,7 +740,7 @@ func loadHostStatusLabel() string {
func defaultStatusRightModuleEnabled(module string) bool {
switch module {
case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleAgent, statusRightModuleTodoPreview, statusRightModuleTodos, statusRightModuleFlashMoe, statusRightModuleHost:
case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleAgent, statusRightModuleTodoPreview, statusRightModuleTodos, statusRightModuleFlashMoe, statusRightModuleHost, statusRightModuleGoal:
return true
case statusRightModuleMemoryTotals:
return true
@ -701,7 +751,7 @@ func defaultStatusRightModuleEnabled(module string) bool {
func isValidStatusRightModule(module string) bool {
switch module {
case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleMemoryTotals, statusRightModuleAgent, statusRightModuleTodoPreview, statusRightModuleTodos, statusRightModuleFlashMoe, statusRightModuleHost:
case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleMemoryTotals, statusRightModuleAgent, statusRightModuleTodoPreview, statusRightModuleTodos, statusRightModuleFlashMoe, statusRightModuleHost, statusRightModuleGoal:
return true
default:
return false
@ -739,6 +789,8 @@ func (cfg statusRightConfig) moduleEnabled(module string) bool {
return derefBool(cfg.FlashMoe, defaultStatusRightModuleEnabled(module))
case statusRightModuleHost:
return derefBool(cfg.Host, defaultStatusRightModuleEnabled(module))
case statusRightModuleGoal:
return derefBool(cfg.Goal, defaultStatusRightModuleEnabled(module))
default:
return false
}
@ -784,6 +836,8 @@ func (cfg *statusRightConfig) setModuleEnabled(module string, enabled bool) {
cfg.FlashMoe = value
case statusRightModuleHost:
cfg.Host = value
case statusRightModuleGoal:
cfg.Goal = value
}
}
@ -791,7 +845,7 @@ func (cfg *statusRightConfig) isDefault() bool {
if cfg == nil {
return true
}
return cfg.CPU == nil && cfg.Network == nil && cfg.Memory == nil && cfg.MemoryTotals == nil && cfg.Agent == nil && cfg.TodoPreview == nil && cfg.Todos == nil && cfg.FlashMoe == nil && cfg.Host == nil
return cfg.CPU == nil && cfg.Network == nil && cfg.Memory == nil && cfg.MemoryTotals == nil && cfg.Agent == nil && cfg.TodoPreview == nil && cfg.Todos == nil && cfg.FlashMoe == nil && cfg.Host == nil && cfg.Goal == nil
}
func derefBool(value *bool, fallback bool) bool {

View file

@ -30,10 +30,9 @@ type tmuxTodoItem struct {
}
type tmuxTodoStore struct {
Version int `json:"version"`
Global []tmuxTodoItem `json:"global,omitempty"`
Sessions map[string][]tmuxTodoItem `json:"sessions,omitempty"`
Windows map[string][]tmuxTodoItem `json:"windows,omitempty"`
Version int `json:"version"`
Global []tmuxTodoItem `json:"global,omitempty"`
Windows map[string][]tmuxTodoItem `json:"windows,omitempty"`
}
type tmuxTodoListFile struct {
@ -78,20 +77,12 @@ func normalizeTmuxTodoStore(store *tmuxTodoStore) *tmuxTodoStore {
if store.Global == nil {
store.Global = []tmuxTodoItem{}
}
if store.Sessions == nil {
store.Sessions = map[string][]tmuxTodoItem{}
}
if store.Windows == nil {
store.Windows = map[string][]tmuxTodoItem{}
}
for i := range store.Global {
store.Global[i].Priority = normalizeTodoPriority(store.Global[i].Priority)
}
for key := range store.Sessions {
for i := range store.Sessions[key] {
store.Sessions[key][i].Priority = normalizeTodoPriority(store.Sessions[key][i].Priority)
}
}
for key := range store.Windows {
for i := range store.Windows[key] {
store.Windows[key][i].Priority = normalizeTodoPriority(store.Windows[key][i].Priority)
@ -143,8 +134,6 @@ func bootstrapTmuxTodoStore() (*tmuxTodoStore, error) {
func todoItemsForScope(store *tmuxTodoStore, scope todoScope, scopeID string) []tmuxTodoItem {
store = normalizeTmuxTodoStore(store)
switch scope {
case todoScopeSession:
return store.Sessions[scopeID]
case todoScopeWindow:
return store.Windows[scopeID]
default:
@ -155,8 +144,6 @@ func todoItemsForScope(store *tmuxTodoStore, scope todoScope, scopeID string) []
func setTodoItemsForScope(store *tmuxTodoStore, scope todoScope, scopeID string, items []tmuxTodoItem) {
store = normalizeTmuxTodoStore(store)
switch scope {
case todoScopeSession:
store.Sessions[scopeID] = items
case todoScopeWindow:
store.Windows[scopeID] = items
default:
@ -203,20 +190,12 @@ func importLegacyYamlTodos(store *tmuxTodoStore) error {
if err := yaml.Unmarshal(data, &list); err != nil {
continue
}
scope := todoScopeGlobal
scopeID := "global"
switch {
case name == "global.yaml":
scope = todoScopeGlobal
case strings.HasPrefix(name, "session_") && strings.HasSuffix(name, ".yaml"):
scope = todoScopeSession
id := strings.TrimSuffix(strings.TrimPrefix(name, "session_"), ".yaml")
if strings.HasPrefix(id, "_") {
scopeID = "$" + strings.TrimPrefix(id, "_")
} else {
scopeID = id
}
case strings.HasPrefix(name, "window_") && strings.HasSuffix(name, ".yaml"):
scope := todoScopeGlobal
scopeID := "global"
switch {
case name == "global.yaml":
scope = todoScopeGlobal
case strings.HasPrefix(name, "window_") && strings.HasSuffix(name, ".yaml"):
scope = todoScopeWindow
id := strings.TrimSuffix(strings.TrimPrefix(name, "window_"), ".yaml")
if strings.HasPrefix(id, "_") {
@ -252,25 +231,6 @@ func collectAllTmuxTodos(currentSessionID, currentWindowID string) []tmuxTodoEnt
ItemIndex: idx,
})
}
sessionIDs := make([]string, 0, len(store.Sessions))
for id := range store.Sessions {
sessionIDs = append(sessionIDs, id)
}
sort.Strings(sessionIDs)
for _, id := range sessionIDs {
for idx, item := range store.Sessions[id] {
entries = append(entries, tmuxTodoEntry{
Title: item.Title,
Done: item.Done,
Priority: item.Priority,
Scope: todoScopeSession,
ScopeID: id,
ScopeName: "Session",
IsCurrent: strings.TrimSpace(id) == strings.TrimSpace(currentSessionID),
ItemIndex: idx,
})
}
}
windowIDs := make([]string, 0, len(store.Windows))
for id := range store.Windows {
windowIDs = append(windowIDs, id)

View file

@ -67,7 +67,7 @@ func runTrackerCommand(args []string) error {
}
command := strings.TrimSpace(rest[0])
switch command {
case "start_task", "finish_task", "update_task", "acknowledge", "delete_task", "notify":
case "start_task", "finish_task", "update_task", "update_phase", "acknowledge", "delete_task", "notify":
ctx, err := resolveTrackerContext(env.Session, env.SessionID, env.Window, env.WindowID, env.Pane)
if err != nil {
return err

View file

@ -601,6 +601,7 @@ func sendTrackerCommand(command string, env *ipc.Envelope) error {
request.Pane = strings.TrimSpace(env.Pane)
request.Summary = strings.TrimSpace(env.Summary)
request.Message = strings.TrimSpace(env.Message)
request.Phase = strings.TrimSpace(env.Phase)
}
enc := json.NewEncoder(conn)
if err := enc.Encode(&request); err != nil {

View file

@ -0,0 +1,131 @@
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"github.com/david/agent-tracker/internal/ipc"
)
func socketPath() string {
if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
return filepath.Join(dir, "agent-tracker.sock")
}
return filepath.Join(os.TempDir(), "agent-tracker.sock")
}
func main() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: tracker-client <command|state> [flags] [args]")
os.Exit(1)
}
switch os.Args[1] {
case "command":
runCommand(os.Args[2:])
case "state":
runState(os.Args[2:])
default:
fmt.Fprintf(os.Stderr, "unknown subcommand: %s\n", os.Args[1])
os.Exit(1)
}
}
func runCommand(args []string) {
fs := flag.NewFlagSet("command", flag.ExitOnError)
var client, session, sessionID, window, windowID, pane, summary, phase string
fs.StringVar(&client, "client", "", "tmux client tty")
fs.StringVar(&session, "session", "", "tmux session name")
fs.StringVar(&sessionID, "session-id", "", "tmux session id")
fs.StringVar(&window, "window", "", "tmux window name")
fs.StringVar(&windowID, "window-id", "", "tmux window id")
fs.StringVar(&pane, "pane", "", "tmux pane id")
fs.StringVar(&summary, "summary", "", "summary or completion note")
fs.StringVar(&phase, "phase", "", "task phase")
fs.Parse(args)
rest := fs.Args()
if len(rest) == 0 {
fmt.Fprintln(os.Stderr, "command name required")
os.Exit(1)
}
if len(rest) > 1 {
summary = strings.Join(rest[1:], " ")
}
env := ipc.Envelope{
Kind: "command",
Command: strings.TrimSpace(rest[0]),
Client: strings.TrimSpace(client),
Session: strings.TrimSpace(session),
SessionID: strings.TrimSpace(sessionID),
Window: strings.TrimSpace(window),
WindowID: strings.TrimSpace(windowID),
Pane: strings.TrimSpace(pane),
Summary: strings.TrimSpace(summary),
Phase: strings.TrimSpace(phase),
}
if env.Summary != "" && env.Message == "" {
env.Message = env.Summary
}
if err := send(&env); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func runState(args []string) {
fs := flag.NewFlagSet("state", flag.ExitOnError)
var client string
fs.StringVar(&client, "client", "", "tmux client tty")
fs.Parse(args)
env := ipc.Envelope{
Kind: "ui-register",
Client: strings.TrimSpace(client),
}
conn, err := net.Dial("unix", socketPath())
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
defer conn.Close()
enc := json.NewEncoder(conn)
if err := enc.Encode(&env); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
dec := json.NewDecoder(bufio.NewReader(conn))
var reply ipc.Envelope
if err := dec.Decode(&reply); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
out := json.NewEncoder(os.Stdout)
out.SetEscapeHTML(false)
out.Encode(&reply)
}
func send(env *ipc.Envelope) error {
conn, err := net.Dial("unix", socketPath())
if err != nil {
return err
}
defer conn.Close()
enc := json.NewEncoder(conn)
if err := enc.Encode(env); err != nil {
return err
}
dec := json.NewDecoder(bufio.NewReader(conn))
for {
var reply ipc.Envelope
if err := dec.Decode(&reply); err != nil {
return err
}
if reply.Kind == "ack" {
return nil
}
}
}

View file

@ -34,6 +34,7 @@ type taskRecord struct {
Pane string
Summary string
CompletionNote string
Phase string
StartedAt time.Time
CompletedAt *time.Time
Status string
@ -234,6 +235,20 @@ func (s *server) handleCommand(env ipc.Envelope) error {
s.broadcastStateAsync()
s.statusRefreshAsync()
return nil
case "update_phase":
target, err := requireSessionWindow(env)
if err != nil {
return err
}
phase := strings.TrimSpace(env.Phase)
if phase == "" {
return fmt.Errorf("update_phase requires phase")
}
if err := s.updatePhase(target, phase); err != nil {
return err
}
s.broadcastStateAsync()
return nil
case "notifications_toggle":
enabled, err := s.toggleNotifications()
if err != nil {
@ -309,6 +324,7 @@ func (s *server) startTask(target tmuxTarget, summary string) error {
t.Status = statusInProgress
t.CompletedAt = nil
t.CompletionNote = ""
t.Phase = ""
t.Acknowledged = true
return nil
}
@ -347,6 +363,21 @@ func (s *server) updateTaskSummary(target tmuxTarget, summary string) error {
return nil
}
func (s *server) updatePhase(target tmuxTarget, phase string) error {
if target.SessionID == "" || target.WindowID == "" {
return fmt.Errorf("cannot update phase: missing session or window ID")
}
target = normalizeTargetNames(target)
s.mu.Lock()
defer s.mu.Unlock()
key := taskKey(target.SessionID, target.WindowID, target.PaneID)
if t, ok := s.tasks[key]; ok {
t.Phase = phase
mergeTaskNamesFromTarget(t, target)
}
return nil
}
func (s *server) finishTask(target tmuxTarget, note string) (bool, error) {
if target.SessionID == "" || target.WindowID == "" {
return false, nil // silently ignore - pane likely died
@ -377,6 +408,7 @@ func (s *server) finishTask(target tmuxTarget, note string) (bool, error) {
mergeTaskNamesFromTarget(t, target)
t.Status = statusCompleted
t.CompletedAt = &now
t.Phase = ""
if note != "" {
t.CompletionNote = note
}
@ -722,6 +754,7 @@ func (s *server) buildStateEnvelope() *ipc.Envelope {
Status: t.Status,
Summary: t.Summary,
CompletionNote: t.CompletionNote,
Phase: t.Phase,
StartedAt: started,
CompletedAt: completed,
DurationSeconds: duration.Seconds(),