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

@ -84,6 +84,7 @@ set-hook -g session-renamed 'run -b "tmux refresh-client -S"'
set-hook -gu session-closed set-hook -gu session-closed
set-hook -g session-closed 'run -b "tmux refresh-client -S"' set-hook -g session-closed 'run -b "tmux refresh-client -S"'
set-hook -ag session-closed 'run -b "test -x ~/.config/agent-tracker/bin/agent && ~/.config/agent-tracker/bin/agent tmux scratch --warm || true"'
run-shell "~/.config/tmux/scripts/session_created.sh" run-shell "~/.config/tmux/scripts/session_created.sh"
@ -109,6 +110,12 @@ set -g display-time 2000
# create session # create session
bind C-c new-session bind C-c new-session
bind O run-shell "~/.config/tmux/scripts/restart_opencode_pane.sh \"#{pane_id}\"" bind O run-shell "~/.config/tmux/scripts/restart_opencode_pane.sh \"#{pane_id}\""
# ─── Goal / thread prefix binds (C-s t/g/b) ───────────────────
bind t command-prompt -p "Thread name:" "run-shell \"~/.config/agent-tracker/bin/agent goal rename-current --name '%%'\""
bind g run-shell "~/.config/tmux/scripts/goal_pick_goal.sh"
unbind b
bind b run-shell "~/.config/tmux/scripts/goal_pick_blocker.sh"
bind -n M-S run-shell "~/.config/tmux/scripts/new_session.sh #{q:session_id} #{q:pane_current_path}" bind -n M-S run-shell "~/.config/tmux/scripts/new_session.sh #{q:session_id} #{q:pane_current_path}"
bind -n M-O break-pane bind -n M-O break-pane
@ -189,9 +196,10 @@ bind -n M-e select-pane -D
bind -n M-u select-pane -U bind -n M-u select-pane -U
bind -n M-i select-pane -R bind -n M-i select-pane -R
bind -n M-a run-shell 'pane=$(bash ~/.config/tmux/scripts/focus_pane_by_position.sh left "#{window_id}" 2>/dev/null) && [ -n "$pane" ] && tmux select-pane -t "$pane" || true' bind -n M-a run-shell 'pane=$(bash ~/.config/tmux/scripts/focus_pane_by_position.sh left "#{window_id}" 2>/dev/null) && [ -n "$pane" ] && tmux select-pane -t "$pane" || true'
bind -n M-g run-shell 'pane=$(bash ~/.config/tmux/scripts/focus_pane_by_position.sh top-right "#{window_id}" 2>/dev/null) && [ -n "$pane" ] && tmux select-pane -t "$pane" || true'
bind -n M-s run-shell "~/.config/tmux/scripts/open_agent_palette.sh '#{client_tty}' '#{window_id}' '#{@agent_id}' '#{pane_current_path}' '#{session_name}' '#{window_name}'" bind -n M-s run-shell "~/.config/tmux/scripts/open_agent_palette.sh '#{client_tty}' '#{window_id}' '#{@agent_id}' '#{pane_current_path}' '#{session_name}' '#{window_name}'"
bind -n M-r run-shell 'pane=$(bash ~/.config/tmux/scripts/focus_pane_by_position.sh bottom-right "#{window_id}" 2>/dev/null) && [ -n "$pane" ] && tmux select-pane -t "$pane" || true' bind -n M-r run-shell 'pane=$(bash ~/.config/tmux/scripts/focus_pane_by_position.sh bottom-right "#{window_id}" 2>/dev/null) && [ -n "$pane" ] && tmux select-pane -t "$pane" || true'
bind -n M-g run-shell 'pane=$(bash ~/.config/tmux/scripts/focus_pane_by_position.sh top-right "#{window_id}" 2>/dev/null) && [ -n "$pane" ] && tmux select-pane -t "$pane" || true'
bind -n M-/ run-shell -b "~/.config/agent-tracker/bin/agent tmux scratch --client '#{client_tty}'"
bind > swap-pane -D bind > swap-pane -D
bind < swap-pane -U bind < swap-pane -U
bind | swap-pane bind | swap-pane

20
agent-tracker/AGENTS.md Normal file
View file

@ -0,0 +1,20 @@
# Agent Tracker — agent notes
## Build workflow
After editing any palette/goal UI code under `cmd/agent/`, run:
```bash
./install.sh
```
This rebuilds `bin/agent` (plus `bin/tracker-server` and `bin/tracker-mcp`). The tmux
palette (Alt-S → Alt-R) execs `bin/agent palette` fresh on each open via
`display-popup -E`, so rebuilding `bin/agent` is sufficient for UI changes to show —
no service restart needed.
For `tracker-server` code changes, also restart the brew service:
```bash
./deploy # builds tracker-server + tracker-client, installs + restarts brew service
```

View file

@ -0,0 +1,174 @@
# Goals & Threads — Requirements
The unified goal/thread/todo management system that replaces the flat tracker panel (Alt-R) and integrates with the existing tmux + agent-tracker + opencode workflow.
## 1. Concept & Naming
| Term | Meaning |
|---|---|
| **Goal** | Nestable container (branch). E.g. "Ship v2", "Board UX". Can hold sub-goals and threads. |
| **Thread** | Leaf node = a window's current focused work unit. Has a name, status, blockers, and a todo checklist. One thread per window. |
| **Todo** | Checklist item under a thread ("what I'll do next"). The existing window-scoped todos. |
| **Turn** | The tracker's per-opencode-cycle record (renamed from "task" to avoid collision with the thread concept). |
### Core invariants
- Every opened tmux window that has started an opencode conversation **must** have a thread created and **not** deleted.
- The **only** way to delete a thread is to **close that tmux window** (not rename it).
- A thread is **keyed by a stable id**; `window_id` is an optional pointer (survives tmux restarts / resurrect).
- A thread is **born as a draft** (`◇ unnamed`, unassigned) the moment an opencode turn starts in a fresh window.
- A thread can be **window-less** (planned) — created manually before any work, or after unbinding.
## 2. Data Model
Store: `~/.cache/agent/goals.json` (file-based, like todos.json; the `agent` binary reads/writes it directly — no server changes).
```json
{
"version": 1,
"goals": {
"ship-v2": { "title": "Ship v2", "parent": null, "order": 0 },
"board-ux": { "title": "Board UX", "parent": "ship-v2", "order": 0 }
},
"threads": {
"th_01": { "name": "drag-drop", "goal_id": "board-ux", "blocked_by": [], "window_id": "@35", "done": false },
"th_02": { "name": "release notes", "goal_id": "ship-v2", "blocked_by": ["th_01"], "window_id": null, "done": false }
}
}
```
- **Threads are synthesized at view-time** by merging `goals.json` + live tracker state + tmux window list + `todos.json`. A window with tracker activity but no thread entry = an implicit draft.
- **Thread status is computed, not stored** (except `done`):
- `running ◷` — opencode turn active right now (from tracker)
- `ready` — not done, not blocked, not running
- `blocked ⛔` — ≥1 blocker thread not done → shows `blocked ← name`
- `done ✓` — manual (turns auto-finish, but a thread is done only when you say so)
- `planned` — window-less, not done, not blocked
- A thread's todos = `todos.json.windows[window_id]` — already there, zero migration.
## 3. The Alt-R View (replaces the tracker panel)
Alt-S opens the palette; **Alt-R** switches to the goals/threads view inside it. (Alt-T stays the todo editor.) No top-level tmux Alt-R/Alt-T binds — they're palette-internal.
### Layout
- 2-line threads:
- **Line 1:** name (bold) ········· status tag (right-aligned)
- **Line 2:** `session / window · context` — session name + window name pulled **live from tmux** (auto-updating on rename), plus todo progress or blocker note or "p to promote"
- Goals: 1 line — `▾/▸ title`
- No telemetry: no window id, no turns count, no progress counts (no `4/7 done`).
- Todos **collapsed by default**; `→` expands a thread to show its todos inline (todos become navigable rows in the flat list).
### Status tags
`◷ running` (amber) · `ready` (green) · `⛔ blocked` (red) · `✓ done` (green, name dimmed) · `planned` (muted)
### Keys (Alt-R view)
| Key | Action |
|---|---|
| `u` / `e` | flat cursor up/down (through all rows; indentation is visual only) |
| `n` | jump to parent goal (convenience) |
| `→` / `t` | expand/collapse a thread's todos |
| `Enter` | goto the thread's tmux window (or **bind to current window** if planned/window-less) |
| `r` | rename thread (inline input) |
| `g` | set goal (fuzzy picker: existing goals + "create new") |
| `b` | set blocker (picks the thread that is **blocking** this one → adds to `blocked_by`) |
| `m` | enter move mode |
| `p` | promote a draft into a named thread (form) |
| `l` | toggle assigned ↔ unassigned view |
| `o` | more options (fuzzy searchable list of **all** actions) |
| `D` (shift) | **contextual**: on a goal → delete (cascade); on a thread → mark done |
| `a` | add a todo to the selected thread |
| `c` | toggle a todo (when cursor on a todo) |
| `y` | copy thread/todo title to clipboard |
| `Esc` | back to palette list |
### Move mode (`m`)
- `m` to enter; the moved item collapses (children hidden), replaced by a **ghost** at its current position.
- A dashed ghost row shows where the item will land under the current target.
- `u` / `e` move the ghost; `Enter` commits; `Esc` cancels.
- **No `n`/`i` promote/demote keys, no `Ctrl-u`/`Ctrl-e`.** Reordering is done only in move mode.
#### Move semantics (validated)
- Move toward a **goal** neighbor (same depth) → **nest as its first child** (depth+1).
- Move toward a **thread** neighbor (same depth) → **swap** positions, keep depth.
- At the **edge** of your parent (first child + `u`, or last child + `e`) → **pop out** one level (become your parent's sibling).
- Toward a **shallower** neighbor → pop out (depth-1, stay in slot).
- At **root** + `u` → blocked.
- Nesting into a goal that already has children → ghost becomes the **first** child (above existing children).
### Promote form (`p`, on a draft)
- **name** — prefilled from the first user message (editable).
- **goal** — fuzzy pick existing, or type new (supports `parent child` to nest).
- **gates / blocked_by** — optional.
- Two tabs: **create new** / **bind to existing** planned thread.
### More options (`o`)
- A fuzzy, searchable list of every action (so you never have to memorize keys).
- Includes: add goal, add sub-goal, add thread, rename, set goal, set blocker, toggle done, bind/unbind window, goto, merge, delete (for planned threads / goals).
## 4. Delete Semantics
| Target | Key | Confirm | Behavior |
|---|---|---|---|
| **Goal** | `D` | type `yes` + Enter | **Cascade**: delete all sub-goals; **unassign** (not delete) all threads in the subtree. |
| **Thread** (window-bound) | — | — | **Cannot be manually deleted.** Auto-deletes only when the tmux window closes. |
| **Thread** (window-less/planned) | `o` → delete | `y`/`n` | Deleted (it has no window to close). |
| **Todo** | `d` / via `o` | none | Removed from the window's todo list. |
## 5. Thread Lifecycle
1. **Birth (automatic):** opencode turn starts in a fresh window → draft thread appears (`◇ unnamed`, unassigned). Tracked but no identity.
2. **Naming (automatic, via hook):** the draft name = the **first user message**, set by a **script + hook** (agent-agnostic — not the opencode plugin, so the agent runtime is swappable). The existing `jcode/hooks/tracker-hook.sh` is the canonical pattern; opencode's plugin dispatches to a shared shell script.
3. **Promotion (manual):** `p` on a draft → form (name + goal + optional blocker). Or bind to an existing planned thread.
4. **Done (manual):** `D` on a thread → `y`/`n` confirm. Thread is hidden from the active view (archived, not deleted).
5. **Revival:** if a done thread's window starts a new opencode turn, the thread **revives** (un-dones) and takes the **new** turn's name.
6. **Death:** the thread is deleted only when its tmux **window closes** (renaming the window does NOT delete the thread).
## 6. Status Bar (bottom-right)
New segment showing the **focused pane's** goal path + thread name:
```
⌖ Ship v2 Board UX drag-drop
```
- Empty if the focused window has no thread.
- No "active goal" concept — no `◉` marker, no manual pinning, no global "ready next" hint. The bar just reflects whatever pane you're on.
- Enabled by default as a `status_right` module (`goal`).
## 7. Prefix Keys (from any tmux pane, no palette needed)
| Prefix | Action |
|---|---|
| `C-s t` | rename the focused window's thread (inline command-prompt) |
| `C-s g` | set goal — fzf picker dialog with "create new" option |
| `C-s b` | set blocker — fzf picker dialog |
These call `agent goal rename-current / set-goal-current / set-blocker-current` (or `pick` + fzf). Same vocabulary as the in-view keys.
## 8. Todos
- **Window todos** live under threads (shown in Alt-R when expanded; also editable in Alt-T).
- **Global todos** stay in Alt-T (untouched) — lightweight scratch list.
- **Session todos: REMOVED.** The `sessions` map was dead code (the Alt-T UI never displayed them and had no creation path). Stop reading/writing it.
## 9. Resilience
- **tmux-resurrect:** window ids change on restore. `restore_agent_tracker_mapping.py` re-points `thread.window_id` to the new ids (matched by session+window name). `blocked_by` uses stable thread ids, so it survives unchanged.
- **Orphan reaping:** when the goals panel loads, threads bound to windows that no longer exist are deleted (along with their window todos). Planned (window-less) threads are preserved. This is resurrect-safe because the restore migration runs first.
## 10. What was dropped (explicitly decided against)
- ~~Active goal / focus lens~~ — no pinning, no filtering, no `◉` marker.
- ~~Progress counts~~ — no `4/7 done` on goals.
- ~~Promote/demote keys (`n`/`i`)~~`u`/`e` in move mode handle everything.
- ~~`Ctrl-u`/`Ctrl-e` sibling reorder~~ — reorder via move mode only.
- ~~Session todos~~ — removed.
- ~~"task" name for the leaf~~ — renamed to "thread"; tracker record renamed to "turn".
- ~~Telemetry in view~~ — no window id, no turns count.
## 11. Testing Approach
- **No unit tests.**
- Test by **manually running in a Docker container** and verifying the rendered output:
- Linux image + tmux + zsh + Go + jq + python3 + fzf, agent-tracker source mounted.
- Build in-container; run `tracker-server` directly.
- **Fake agent:** a shell script that fires hook events (`turn_start` with a user message, `turn_end`) — no real opencode/API keys.
- Drive: `tmux send-keys` to simulate keystrokes, `tmux capture-pane` to read what rendered.
- Walk the exact scenarios: create goal, fire a fake turn → draft thread named from the message, promote it, each move-mode case, set blocker, mark done, cascade-delete a goal, check the status bar segment.

View file

@ -19,16 +19,17 @@
}, },
"devices": [ "devices": [
"web-server", "web-server",
"ipm",
"opm", "opm",
"macos", "macos",
"ipp" "ipp",
"ip17"
], ],
"status_right": { "status_right": {
"cpu": false, "cpu": false,
"network": false, "network": false,
"memory": false, "memory": false,
"memory_totals": false, "memory_totals": false,
"flash_moe": false "flash_moe": false,
"host": false
} }
} }

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"` Todos *bool `json:"todos,omitempty"`
FlashMoe *bool `json:"flash_moe,omitempty"` FlashMoe *bool `json:"flash_moe,omitempty"`
Host *bool `json:"host,omitempty"` Host *bool `json:"host,omitempty"`
Goal *bool `json:"goal,omitempty"`
} }
type keyConfig struct { type keyConfig struct {
@ -105,10 +106,10 @@ type keyConfig struct {
} }
type repoConfig struct { type repoConfig struct {
BaseBranch string `yaml:"base_branch,omitempty"` BaseBranch string `yaml:"base_branch,omitempty"`
DefaultDevice string `yaml:"default_device,omitempty"` DefaultDevice string `yaml:"default_device,omitempty"`
CopyIgnore []string `yaml:"copy_ignore,omitempty"` CopyIgnore []string `yaml:"copy_ignore,omitempty"`
AgentKeyPaths []string `yaml:"agent_key_paths,omitempty"` AgentKeyPaths []string `yaml:"agent_key_paths,omitempty"`
} }
type featureConfig struct { type featureConfig struct {
@ -159,7 +160,7 @@ func main() {
func run(args []string) error { func run(args []string) error {
if len(args) == 0 { 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] { switch args[0] {
case "start": case "start":
@ -182,10 +183,14 @@ func run(args []string) error {
return runTmuxCommand(args[1:]) return runTmuxCommand(args[1:])
case "tracker": case "tracker":
return runTracker(args[1:]) return runTracker(args[1:])
case "goal":
return runGoal(args[1:])
case "browser": case "browser":
return runBrowserCommand(args[1:]) return runBrowserCommand(args[1:])
case "feature": case "feature":
return runFeatureCommand(args[1:]) return runFeatureCommand(args[1:])
case "update-hot-reload":
return runUpdateHotReload(args[1:])
case "bootstrap": case "bootstrap":
return runBootstrap(args[1:]) return runBootstrap(args[1:])
default: default:
@ -1052,7 +1057,7 @@ func destroyRequiresExplicitConfirm(record *agentRecord) (bool, error) {
func runTmuxCommand(args []string) error { func runTmuxCommand(args []string) error {
if len(args) == 0 { 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] { switch args[0] {
case "on-focus": case "on-focus":
@ -1063,6 +1068,8 @@ func runTmuxCommand(args []string) error {
return runTmuxPalette(args[1:]) return runTmuxPalette(args[1:])
case "right-status": case "right-status":
return runTmuxRightStatus(args[1:]) return runTmuxRightStatus(args[1:])
case "scratch":
return runTmuxScratch(args[1:])
default: default:
return fmt.Errorf("unknown tmux subcommand: %s", args[0]) return fmt.Errorf("unknown tmux subcommand: %s", args[0])
} }
@ -1366,6 +1373,298 @@ func runTmuxFocus(args []string) error {
return runTmux("select-pane", "-t", target) 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 { func runTmuxPalette(args []string) error {
fs := flag.NewFlagSet("agent tmux palette", flag.ContinueOnError) fs := flag.NewFlagSet("agent tmux palette", flag.ContinueOnError)
var windowID string 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 { if err := os.WriteFile(ensurePath, []byte(ensureServer), 0o755); err != nil {
return err 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 hotReload := `#!/usr/bin/env bash
set -euo pipefail set -euo pipefail
@ -2584,6 +2896,7 @@ find_flutter_pane() {
while IFS= read -r child; do while IFS= read -r child; do
[[ -z "$child" ]] && continue [[ -z "$child" ]] && continue
if ps -p "$child" -o command= 2>/dev/null | grep -q 'flutter_tools\.snapshot.*run'; then if ps -p "$child" -o command= 2>/dev/null | grep -q 'flutter_tools\.snapshot.*run'; then
FLUTTER_RUN_PID="$child"
return 0 return 0
fi fi
if has_flutter_run "$child" $((depth + 1)); then if has_flutter_run "$child" $((depth + 1)); then
@ -2593,15 +2906,24 @@ find_flutter_pane() {
return 1 return 1
} }
local pane_id pane_pid pane_path local pane_id pane_pid fpid fcwd
while read -r pane_id pane_pid pane_path; do 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 [[ -z "$pane_id" || -z "$pane_pid" ]] && continue
[[ "$pane_path" != "$WORKSPACE_DIR" && "$pane_path" != "$REPO_DIR" ]] && continue FLUTTER_RUN_PID=""
if has_flutter_run "$pane_pid"; then 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" printf "%s\n" "$pane_id"
return 0 return 0
fi 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 return 1
} }
@ -2655,24 +2977,107 @@ PY
exit 0 exit 0
) >/dev/null 2>&1 & ) >/dev/null 2>&1 &
echo "Hot reload triggered" echo "Reloaded the application"
` `
if err := ensureGeneratedRepoPathIgnored(repoCopyPath, "hot-reload.sh"); err != nil { if err := ensureGeneratedRepoPathIgnored(repoCopyPath, "hot-reload.sh"); err != nil {
return err return err
} }
hotReloadPath := filepath.Join(repoCopyPath, "hot-reload.sh") 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 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"} { var repoRoots []string
_ = os.Remove(filepath.Join(workspaceRoot, obsolete)) 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 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 { func detectDefaultBaseBranch(repoRoot string) string {
for _, candidate := range []string{"develop", "main", "master"} { for _, candidate := range []string{"develop", "main", "master"} {
if remoteExists(repoRoot, "origin/"+candidate) || localExists(repoRoot, candidate) { if remoteExists(repoRoot, "origin/"+candidate) || localExists(repoRoot, candidate) {
@ -2768,10 +3173,6 @@ func loadRegistry() (*registry, error) {
if decodeErr := dec.Decode(fallback); decodeErr != nil { if decodeErr := dec.Decode(fallback); decodeErr != nil {
return nil, err return nil, err
} }
trailing := strings.TrimSpace(string(data[int(dec.InputOffset()):]))
if trailing == "" || strings.Trim(trailing, "}") != "" {
return nil, err
}
reg = fallback reg = fallback
_ = saveRegistry(reg) _ = saveRegistry(reg)
} }

View file

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

View file

@ -42,6 +42,7 @@ type paletteRuntime struct {
currentSessionName string currentSessionName string
currentWindowName string currentWindowName string
mainRepoRoot string mainRepoRoot string
startMode paletteMode
} }
type paletteModel struct { type paletteModel struct {
@ -50,6 +51,7 @@ type paletteModel struct {
actions []paletteAction actions []paletteAction
openedAt time.Time openedAt time.Time
quickSecondaryEscCloses bool quickSecondaryEscCloses bool
singlePanelMode bool
width int width int
height int height int
result paletteResult result paletteResult
@ -58,6 +60,7 @@ type paletteModel struct {
devices *devicePanelModel devices *devicePanelModel
status *statusRightPanelModel status *statusRightPanelModel
tracker *trackerPanelModel tracker *trackerPanelModel
goals *goalPanelModel
} }
type paletteStyles struct { type paletteStyles struct {
@ -110,7 +113,7 @@ func runBubbleTeaPalette(args []string) error {
if err != nil { if err != nil {
return err return err
} }
state := paletteUIState{Mode: paletteModeList, Message: runtime.startupMessage} state := paletteUIState{Mode: runtime.startMode, Message: runtime.startupMessage}
for { for {
model := newPaletteModel(runtime, state) model := newPaletteModel(runtime, state)
finalModel, err := tea.NewProgram(model).Run() finalModel, err := tea.NewProgram(model).Run()
@ -169,11 +172,13 @@ func loadPaletteRuntime(args []string) (*paletteRuntime, error) {
var currentPath string var currentPath string
var currentSessionName string var currentSessionName string
var currentWindowName string var currentWindowName string
var modeFlag string
fs.StringVar(&windowID, "window", "", "window id") fs.StringVar(&windowID, "window", "", "window id")
fs.StringVar(&agentID, "agent-id", "", "agent id") fs.StringVar(&agentID, "agent-id", "", "agent id")
fs.StringVar(&currentPath, "path", "", "current pane path") fs.StringVar(&currentPath, "path", "", "current pane path")
fs.StringVar(&currentSessionName, "session-name", "", "current session name") fs.StringVar(&currentSessionName, "session-name", "", "current session name")
fs.StringVar(&currentWindowName, "window-name", "", "current window name") fs.StringVar(&currentWindowName, "window-name", "", "current window name")
fs.StringVar(&modeFlag, "mode", "", "initial panel mode (goals, tracker, todos, activity)")
fs.SetOutput(nil) fs.SetOutput(nil)
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return nil, err return nil, err
@ -186,6 +191,18 @@ func loadPaletteRuntime(args []string) (*paletteRuntime, error) {
currentSessionName: firstNonEmpty(currentSessionName, os.Getenv("AGENT_PALETTE_SESSION_NAME")), currentSessionName: firstNonEmpty(currentSessionName, os.Getenv("AGENT_PALETTE_SESSION_NAME")),
currentWindowName: firstNonEmpty(currentWindowName, os.Getenv("AGENT_PALETTE_WINDOW_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) logPaletteLaunchIfMalformed(runtime)
if looksLikeTmuxFormatLiteral(runtime.agentID) { if looksLikeTmuxFormatLiteral(runtime.agentID) {
runtime.agentID = "" runtime.agentID = ""
@ -351,6 +368,20 @@ func (r *paletteRuntime) buildActions() []paletteAction {
}) })
} }
actions = append(actions, 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{ paletteAction{
Section: "System", Section: "System",
Title: "Tracker", Title: "Tracker",
@ -582,11 +613,22 @@ func (r *paletteRuntime) execute(result paletteResult) (bool, string, error) {
return false, "", nil return false, "", nil
case paletteActionReloadTmuxConfig: case paletteActionReloadTmuxConfig:
return false, "", paletteTmuxRunner("source-file", os.Getenv("HOME")+"/.config/.tmux.conf") return false, "", paletteTmuxRunner("source-file", os.Getenv("HOME")+"/.config/.tmux.conf")
case paletteActionOpenScratch:
return false, "", launchScratchTerminalFromPalette(r.currentPath)
default: default:
return false, "", nil 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 { func statusRightModuleLabel(module string) string {
switch module { switch module {
case statusRightModuleCPU: case statusRightModuleCPU:
@ -653,7 +695,7 @@ func newPaletteModel(runtime *paletteRuntime, state paletteUIState) *paletteMode
if len(state.PromptDevices) > 0 { if len(state.PromptDevices) > 0 {
state.PromptDeviceIndex = clampInt(state.PromptDeviceIndex, 0, len(state.PromptDevices)-1) 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 { if state.Mode == paletteModeTodos {
_ = model.openTodosPanel() _ = model.openTodosPanel()
} }
@ -669,10 +711,22 @@ func newPaletteModel(runtime *paletteRuntime, state paletteUIState) *paletteMode
if state.Mode == paletteModeTracker { if state.Mode == paletteModeTracker {
_, _ = model.openTrackerPanel() _, _ = model.openTrackerPanel()
} }
if state.Mode == paletteModeGoals {
_, _ = model.openGoalsPanel()
}
return model return model
} }
func (m *paletteModel) Init() tea.Cmd { 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 return nil
} }
@ -791,6 +845,24 @@ func (m *paletteModel) openTrackerPanel() (tea.Cmd, error) {
return m.tracker.activate(), nil 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) { func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: 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.width = msg.Width
m.tracker.height = msg.Height m.tracker.height = msg.Height
} }
if m.goals != nil {
m.goals.width = msg.Width
m.goals.height = msg.Height
}
if m.status != nil { if m.status != nil {
m.status.width = msg.Width m.status.width = msg.Width
m.status.height = msg.Height m.status.height = msg.Height
} }
case tea.KeyMsg: 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) { if isAltFooterToggleKey(msg) {
m.state.ShowAltHints = !m.state.ShowAltHints m.state.ShowAltHints = !m.state.ShowAltHints
return m, nil return m, nil
@ -843,6 +919,10 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.closePalette() return m.closePalette()
case paletteModeTracker: case paletteModeTracker:
return m.closePalette() return m.closePalette()
case paletteModeGoals:
if m.goals != nil && m.goals.mode == goalModeList {
return m.closePalette()
}
case paletteModeSnippets: case paletteModeSnippets:
return m.closePalette() return m.closePalette()
} }
@ -943,6 +1023,10 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit return m, tea.Quit
} }
if m.tracker.requestBack { if m.tracker.requestBack {
if m.singlePanelMode {
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
return m, tea.Quit
}
m.tracker.requestBack = false m.tracker.requestBack = false
m.state.Mode = paletteModeList m.state.Mode = paletteModeList
m.state.Message = m.tracker.currentStatus() m.state.Message = m.tracker.currentStatus()
@ -950,6 +1034,36 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, 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 { switch m.state.Mode {
case paletteModePrompt: case paletteModePrompt:
return m.updatePrompt(key) return m.updatePrompt(key)
@ -1030,6 +1144,23 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, 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 return m, nil
} }
@ -1051,6 +1182,14 @@ func (m *paletteModel) updateList(key string) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
if key == "alt+r" { 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() cmd, err := m.openTrackerPanel()
if err != nil { if err != nil {
m.state.Message = err.Error() m.state.Message = err.Error()
@ -1145,6 +1284,13 @@ func (m *paletteModel) selectAction(action paletteAction) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
return m, cmd 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: case paletteActionOpenTodos:
if err := m.openTodosPanel(); err != nil { if err := m.openTodosPanel(); err != nil {
m.state.Message = err.Error() m.state.Message = err.Error()
@ -1496,6 +1642,14 @@ func (m *paletteModel) View() string {
} }
return styles.muted.Render("Tracker unavailable") 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) 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"}}, {{"Enter", "run"}, {"Esc", "close"}, {footerHintToggleKey, "more"}},
}, },
[][][2]string{ [][][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-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", "tracker"}, {"Alt-A", "activity"}, {"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", "tracker"}, {"Alt-S", "close"}}, {{"Alt-C", "create"}, {"Alt-R", "goals"}, {"Alt-D", "tracker"}, {"Alt-S", "close"}},
}, },
) )
} }

View file

@ -25,6 +25,7 @@ const (
statusRightModuleTodos = "todos" statusRightModuleTodos = "todos"
statusRightModuleFlashMoe = "flash_moe" statusRightModuleFlashMoe = "flash_moe"
statusRightModuleHost = "host" statusRightModuleHost = "host"
statusRightModuleGoal = "goal"
) )
const ( const (
@ -37,6 +38,7 @@ const (
statusIconAgent = "󰚩" statusIconAgent = "󰚩"
statusIconTodos = "󰎚" statusIconTodos = "󰎚"
statusIconFlashMoe = "󱙺" statusIconFlashMoe = "󱙺"
statusIconGoal = "⌖"
) )
func statusRightModules() []string { func statusRightModules() []string {
@ -50,6 +52,7 @@ func statusRightModules() []string {
statusRightModuleTodoPreview, statusRightModuleTodoPreview,
statusRightModuleFlashMoe, statusRightModuleFlashMoe,
statusRightModuleHost, statusRightModuleHost,
statusRightModuleGoal,
} }
} }
@ -201,6 +204,11 @@ func renderTmuxRightStatus(args tmuxRightStatusArgs) string {
segments = append(segments, statusSegment{FG: "#1d1f21", BG: "#cc6666", Text: label, Bold: true}) 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 statusRightModuleEnabled(statusRightModuleFlashMoe) {
if segment, ok := loadFlashMoeStatusSegment(); ok { if segment, ok := loadFlashMoeStatusSegment(); ok {
segments = append(segments, segment) segments = append(segments, segment)
@ -532,6 +540,48 @@ func loadAgentStatusLabel(windowID string) string {
return fmt.Sprintf(" %s %s ", statusIconAgent, device) 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() { func refreshMemoryUsageCache() {
script := statusMemoryCacheRefreshScript() script := statusMemoryCacheRefreshScript()
if strings.TrimSpace(script) == "" || !fileExists(script) { if strings.TrimSpace(script) == "" || !fileExists(script) {
@ -690,7 +740,7 @@ func loadHostStatusLabel() string {
func defaultStatusRightModuleEnabled(module string) bool { func defaultStatusRightModuleEnabled(module string) bool {
switch module { switch module {
case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleAgent, statusRightModuleTodoPreview, statusRightModuleTodos, statusRightModuleFlashMoe, statusRightModuleHost: case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleAgent, statusRightModuleTodoPreview, statusRightModuleTodos, statusRightModuleFlashMoe, statusRightModuleHost, statusRightModuleGoal:
return true return true
case statusRightModuleMemoryTotals: case statusRightModuleMemoryTotals:
return true return true
@ -701,7 +751,7 @@ func defaultStatusRightModuleEnabled(module string) bool {
func isValidStatusRightModule(module string) bool { func isValidStatusRightModule(module string) bool {
switch module { 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 return true
default: default:
return false return false
@ -739,6 +789,8 @@ func (cfg statusRightConfig) moduleEnabled(module string) bool {
return derefBool(cfg.FlashMoe, defaultStatusRightModuleEnabled(module)) return derefBool(cfg.FlashMoe, defaultStatusRightModuleEnabled(module))
case statusRightModuleHost: case statusRightModuleHost:
return derefBool(cfg.Host, defaultStatusRightModuleEnabled(module)) return derefBool(cfg.Host, defaultStatusRightModuleEnabled(module))
case statusRightModuleGoal:
return derefBool(cfg.Goal, defaultStatusRightModuleEnabled(module))
default: default:
return false return false
} }
@ -784,6 +836,8 @@ func (cfg *statusRightConfig) setModuleEnabled(module string, enabled bool) {
cfg.FlashMoe = value cfg.FlashMoe = value
case statusRightModuleHost: case statusRightModuleHost:
cfg.Host = value cfg.Host = value
case statusRightModuleGoal:
cfg.Goal = value
} }
} }
@ -791,7 +845,7 @@ func (cfg *statusRightConfig) isDefault() bool {
if cfg == nil { if cfg == nil {
return true 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 { func derefBool(value *bool, fallback bool) bool {

View file

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

View file

@ -67,7 +67,7 @@ func runTrackerCommand(args []string) error {
} }
command := strings.TrimSpace(rest[0]) command := strings.TrimSpace(rest[0])
switch command { 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) ctx, err := resolveTrackerContext(env.Session, env.SessionID, env.Window, env.WindowID, env.Pane)
if err != nil { if err != nil {
return err return err

View file

@ -601,6 +601,7 @@ func sendTrackerCommand(command string, env *ipc.Envelope) error {
request.Pane = strings.TrimSpace(env.Pane) request.Pane = strings.TrimSpace(env.Pane)
request.Summary = strings.TrimSpace(env.Summary) request.Summary = strings.TrimSpace(env.Summary)
request.Message = strings.TrimSpace(env.Message) request.Message = strings.TrimSpace(env.Message)
request.Phase = strings.TrimSpace(env.Phase)
} }
enc := json.NewEncoder(conn) enc := json.NewEncoder(conn)
if err := enc.Encode(&request); err != nil { 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 Pane string
Summary string Summary string
CompletionNote string CompletionNote string
Phase string
StartedAt time.Time StartedAt time.Time
CompletedAt *time.Time CompletedAt *time.Time
Status string Status string
@ -234,6 +235,20 @@ func (s *server) handleCommand(env ipc.Envelope) error {
s.broadcastStateAsync() s.broadcastStateAsync()
s.statusRefreshAsync() s.statusRefreshAsync()
return nil 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": case "notifications_toggle":
enabled, err := s.toggleNotifications() enabled, err := s.toggleNotifications()
if err != nil { if err != nil {
@ -309,6 +324,7 @@ func (s *server) startTask(target tmuxTarget, summary string) error {
t.Status = statusInProgress t.Status = statusInProgress
t.CompletedAt = nil t.CompletedAt = nil
t.CompletionNote = "" t.CompletionNote = ""
t.Phase = ""
t.Acknowledged = true t.Acknowledged = true
return nil return nil
} }
@ -347,6 +363,21 @@ func (s *server) updateTaskSummary(target tmuxTarget, summary string) error {
return nil 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) { func (s *server) finishTask(target tmuxTarget, note string) (bool, error) {
if target.SessionID == "" || target.WindowID == "" { if target.SessionID == "" || target.WindowID == "" {
return false, nil // silently ignore - pane likely died 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) mergeTaskNamesFromTarget(t, target)
t.Status = statusCompleted t.Status = statusCompleted
t.CompletedAt = &now t.CompletedAt = &now
t.Phase = ""
if note != "" { if note != "" {
t.CompletionNote = note t.CompletionNote = note
} }
@ -722,6 +754,7 @@ func (s *server) buildStateEnvelope() *ipc.Envelope {
Status: t.Status, Status: t.Status,
Summary: t.Summary, Summary: t.Summary,
CompletionNote: t.CompletionNote, CompletionNote: t.CompletionNote,
Phase: t.Phase,
StartedAt: started, StartedAt: started,
CompletedAt: completed, CompletedAt: completed,
DurationSeconds: duration.Seconds(), DurationSeconds: duration.Seconds(),

View file

@ -11,6 +11,7 @@ type Envelope struct {
Pane string `json:"pane,omitempty"` Pane string `json:"pane,omitempty"`
Message string `json:"message,omitempty"` Message string `json:"message,omitempty"`
Summary string `json:"summary,omitempty"` Summary string `json:"summary,omitempty"`
Phase string `json:"phase,omitempty"`
Tasks []Task `json:"tasks,omitempty"` Tasks []Task `json:"tasks,omitempty"`
} }
@ -23,6 +24,7 @@ type Task struct {
Status string `json:"status"` Status string `json:"status"`
Summary string `json:"summary"` Summary string `json:"summary"`
CompletionNote string `json:"completion_note,omitempty"` CompletionNote string `json:"completion_note,omitempty"`
Phase string `json:"phase,omitempty"`
StartedAt string `json:"started_at,omitempty"` StartedAt string `json:"started_at,omitempty"`
CompletedAt string `json:"completed_at,omitempty"` CompletedAt string `json:"completed_at,omitempty"`
DurationSeconds float64 `json:"duration_seconds"` DurationSeconds float64 `json:"duration_seconds"`

View file

@ -0,0 +1,20 @@
FROM golang:1.25-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends \
tmux zsh jq python3 fzf git ca-certificates \
&& rm -rf /var/lib/apt/lists/*
RUN useradd -m -s /bin/zsh testuser
WORKDIR /home/testuser/agent-tracker
# Source is bind-mounted at runtime; build happens in entrypoint
COPY test/docker/entrypoint.sh /entrypoint.sh
COPY test/docker/fake-agent.sh /home/testuser/fake-agent.sh
COPY test/docker/run-tests.sh /run-tests.sh
RUN chmod +x /entrypoint.sh /home/testuser/fake-agent.sh /run-tests.sh
ENV TMUX_BIN=/usr/bin/tmux
ENV GOPROXY=off
ENTRYPOINT ["/entrypoint.sh"]

View file

@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Capture tmux pane as a grid of cells with full ANSI attribute info.
Handles 256-color (38;5;N / 48;5;N) and true-color (38;2;R;G;B) sequences.
"""
import sys, json, subprocess, re
def capture(target):
result = subprocess.run(
["tmux", "capture-pane", "-e", "-p", "-t", target],
capture_output=True, text=True
)
return result.stdout
ANSI_RE = re.compile(r'\x1b\[([0-9;]*)m')
def empty_cell():
return {"ch": " ", "fg": None, "bg": None, "bold": False, "dim": False,
"underline": False, "reverse": False, "italic": False}
class AnsiState:
def __init__(self):
self.fg = None
self.bg = None
self.bold = False
self.dim = False
self.underline = False
self.reverse = False
self.italic = False
def reset(self):
self.fg = None
self.bg = None
self.bold = False
self.dim = False
self.underline = False
self.reverse = False
self.italic = False
def cell(self, ch):
return {
"ch": ch,
"fg": self.fg,
"bg": self.bg,
"bold": self.bold,
"dim": self.dim,
"underline": self.underline,
"reverse": self.reverse,
"italic": self.italic,
}
def apply_sgr(params, st):
"""Apply an SGR parameter list to the state."""
if not params or params == [0]:
st.reset()
return
i = 0
while i < len(params):
p = params[i]
if p == 0:
st.reset()
elif p == 1:
st.bold = True
elif p == 2:
st.dim = True
elif p == 3:
st.italic = True
elif p == 4:
st.underline = True
elif p == 7:
st.reverse = True
elif p == 22:
st.bold = False
st.dim = False
elif p == 23:
st.italic = False
elif p == 24:
st.underline = False
elif p == 27:
st.reverse = False
elif p == 39:
st.fg = None
elif p == 49:
st.bg = None
elif 30 <= p <= 37:
st.fg = str(p - 30)
elif 40 <= p <= 47:
st.bg = str(p - 40)
elif 90 <= p <= 97:
st.fg = str(p - 90 + 8)
elif 100 <= p <= 107:
st.bg = str(p - 100 + 8)
elif p == 38 or p == 48:
# Extended color: 38;5;N (256-color) or 38;2;R;G;B (true-color)
if i + 1 < len(params):
if params[i+1] == 5 and i + 2 < len(params):
# 256-color
color = str(params[i+2])
if p == 38:
st.fg = color
else:
st.bg = color
i += 2
elif params[i+1] == 2 and i + 4 < len(params):
# True-color: approximate to 256-color palette
r, g, b = params[i+2], params[i+3], params[i+4]
color = rgb_to_256(r, g, b)
if p == 38:
st.fg = color
else:
st.bg = color
i += 4
i += 1
def rgb_to_256(r, g, b):
"""Approximate RGB to nearest 256-color palette index."""
# Check grayscale first (232-255)
if r == g == b:
if r < 8:
return "16"
if r > 248:
return "231"
gray = int(round((r - 8) / 247 * 24))
return str(232 + gray)
# 6x6x6 color cube (16-231)
def to_cube(v):
if v < 48:
return 0
if v < 115:
return 1
return min(5, int(round((v - 115) / 40)) + 2)
return str(16 + 36 * to_cube(r) + 6 * to_cube(g) + to_cube(b))
def parse_to_grid(raw, rows, cols):
lines = raw.split('\n')
grid = []
st = AnsiState()
for ri in range(rows):
if ri >= len(lines):
grid.append([empty_cell() for _ in range(cols)])
continue
line = lines[ri]
cells = []
i = 0
while i < len(line) and len(cells) < cols:
ch = line[i]
if ch == '\x1b':
m = ANSI_RE.match(line, i)
if m:
param_str = m.group(1)
if param_str:
params = [int(x) for x in param_str.split(';') if x]
else:
params = [0]
apply_sgr(params, st)
i = m.end()
continue
else:
i += 1
continue
if ch == '\r':
i += 1
continue
cells.append(st.cell(ch))
i += 1
while len(cells) < cols:
c = empty_cell()
c["bg"] = st.bg
cells.append(c)
grid.append(cells[:cols])
return grid
if __name__ == '__main__':
target = sys.argv[1]
rows = int(sys.argv[2]) if len(sys.argv) > 2 else 50
cols = int(sys.argv[3]) if len(sys.argv) > 3 else 100
raw = capture(target)
grid = parse_to_grid(raw, rows, cols)
print(json.dumps(grid))

View file

@ -0,0 +1,40 @@
#!/usr/bin/env python3
"""Debug helper: dump grid cells with attributes for specific rows."""
import sys, os
sys.path.insert(0, os.path.dirname(__file__))
from capture_grid import parse_to_grid
import subprocess, json
target = sys.argv[1]
rows_to_show = [int(x) for x in sys.argv[2:]] if len(sys.argv) > 2 else [3, 4, 5]
raw = subprocess.run(["tmux", "capture-pane", "-e", "-p", "-t", target],
capture_output=True, text=True).stdout
grid = parse_to_grid(raw, 50, 100)
for r in rows_to_show:
if r >= len(grid):
continue
row = grid[r]
text = "".join(c["ch"] for c in row).rstrip()
bg_count = sum(1 for c in row if str(c.get("bg")) not in ("None", "none") and c.get("bg") is not None)
bg238 = sum(1 for c in row if str(c.get("bg")) == "238")
print(f"\nRow {r}: |{text}|")
print(f" cells with any bg: {bg_count}/100, with bg=238: {bg238}/100")
# Show cells with attributes
shown = 0
for i, c in enumerate(row):
bg = c.get("bg")
fg = c.get("fg")
bold = c.get("bold", False)
if (bg and str(bg) not in ("None", "none")) or (fg and str(fg) not in ("None", "none") and c["ch"] != " "):
ch = c["ch"]
parts = []
if bg: parts.append(f"bg={bg}")
if fg: parts.append(f"fg={fg}")
if bold: parts.append("bold")
print(f" col {i}: {repr(ch)} [{', '.join(parts)}]")
shown += 1
if shown > 20:
print(" ... (truncated)")
break

View file

@ -0,0 +1,24 @@
#!/usr/bin/env bash
set -euo pipefail
export HOME=/home/testuser
AGENT_SRC="/home/testuser/agent-tracker"
AGENT_BIN="/home/testuser/bin"
mkdir -p "$AGENT_BIN"
echo "=== Building agent-tracker ==="
cd "$AGENT_SRC"
go build -o "$AGENT_BIN/agent" ./cmd/agent 2>&1
echo "Build complete."
echo "=== Running TUI tests ==="
bash "$AGENT_SRC/test/docker/run-tests.sh"
TUI_EXIT=$?
echo "=== Running visual tests ==="
AGENT_BIN="/home/testuser/bin/agent" python3 "$AGENT_SRC/test/docker/visual_tests.py"
VISUAL_EXIT=$?
echo ""
echo "TUI tests: $TUI_EXIT Visual tests: $VISUAL_EXIT"
exit $((TUI_EXIT + VISUAL_EXIT))

View file

@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Fake agent: simulates an opencode turn by firing tracker hook events.
# Usage: fake-agent.sh <window_id> <user_message>
# This mimics what the real hook script does: start_task with the message as summary.
set -euo pipefail
WINDOW_ID="${1:-}"
MESSAGE="${2:-working...}"
AGENT_BIN="/home/testuser/bin/agent"
TRACKER_BIN="/home/testuser/bin/tracker-client"
# Resolve tmux context for the window
PANE=$(tmux list-panes -a -F '#{window_id}|#{pane_id}' 2>/dev/null | grep "^${WINDOW_ID}|" | head -1 | cut -d'|' -f2 || true)
if [ -z "$PANE" ]; then
echo "fake-agent: no pane for window $WINDOW_ID" >&2
exit 1
fi
SESSION_ID=$(tmux display-message -p -t "$PANE" "#{session_id}" 2>/dev/null || echo "")
WINDOW_NAME=$(tmux display-message -p -t "$PANE" "#{window_name}" 2>/dev/null || echo "")
SESSION_NAME=$(tmux display-message -p -t "$PANE" "#{session_name}" 2>/dev/null || echo "")
echo "fake-agent: window=$WINDOW_ID pane=$PANE session=$SESSION_ID message=$MESSAGE"
# Start a tracker task (this creates the draft thread)
$TRACKER_BIN command \
-session-id "$SESSION_ID" \
-window-id "$WINDOW_ID" \
-pane "$PANE" \
-summary "$MESSAGE" \
start_task 2>/dev/null || true
# Revive any done thread for this window with the new name
$AGENT_BIN goal revive --window "$WINDOW_ID" --name "$MESSAGE" 2>/dev/null || true
echo "fake-agent: turn started"

View file

@ -0,0 +1,43 @@
#!/usr/bin/env bash
set -euo pipefail
export HOME=/home/testuser
AGENT_SRC="/home/testuser/agent-tracker"
AGENT_BIN="/home/testuser/bin"
mkdir -p "$AGENT_BIN"
echo "=== Building agent-tracker ==="
cd "$AGENT_SRC"
go build -o "$AGENT_BIN/agent" ./cmd/agent 2>&1
echo "Build complete."
# Seed fake data and create tmux session with drafts
bash "$AGENT_SRC/test/docker/seed-data.sh"
# Launch goals panel in the main window
MAIN_WID=$(tmux display-message -p -t test:0 "#{window_id}")
tmux send-keys -t test:0 "$AGENT_BIN/agent palette --mode goals --window=$MAIN_WID --path=/tmp --session-name=test --window-name=main" Enter
echo ""
echo "============================================"
echo " Interactive test container ready"
echo "============================================"
echo ""
echo "tmux session 'test' is running with:"
echo " - 4 goals (Ship V2 > Board UX, API Refactor; Marketing Site)"
echo " - 5 threads (3 assigned, 2 standalone, 1 done)"
echo " - 5 drafts (unassigned agent windows)"
echo ""
echo "Goals panel is open in window 0."
echo ""
echo "To interact:"
echo " docker exec -it <container> tmux attach -t test"
echo ""
echo "To send keys from outside:"
echo " docker exec <container> tmux send-keys -t test:0 <key>"
echo ""
echo "To capture screen:"
echo " docker exec <container> tmux capture-pane -p -t test:0"
echo ""
echo "Keeping container alive..."
exec sleep infinity

View file

@ -0,0 +1,113 @@
#!/usr/bin/env bash
# TUI test: drives the goals panel via tmux send-keys + capture-pane.
set -uo pipefail
AGENT="/home/testuser/bin/agent"
PASS=0; FAIL=0
check() {
if echo "$2" | grep -q -- "$3"; then
echo " PASS: $1"; PASS=$((PASS+1))
else
echo " FAIL: $1 (expected '$3')"
echo "$2" | head -24 | sed 's/^/ | /'
FAIL=$((FAIL+1))
fi
}
tk() { tmux send-keys -t test:0.0 "$@"; sleep 0.4; }
cap() { tmux capture-pane -p -t test:0.0 2>/dev/null; }
type_() { printf '%s' "$1" | while IFS= read -r -n1 ch; do tmux send-keys -t test:0.0 -l "$ch"; sleep 0.03; done; sleep 0.2; }
# ── Setup ───────────────────────────────────────────────────
tmux kill-session -t test 2>/dev/null || true
tmux new-session -d -s test -x 200 -y 60
sleep 0.3
rm -rf ~/.cache/agent; mkdir -p ~/.cache/agent
$AGENT goal add-goal --title "Ship V2" >/dev/null
$AGENT goal add-goal --title "Board UX" --parent ship-v2 >/dev/null
$AGENT goal add-thread --name "drag-drop" --goal board-ux >/dev/null
$AGENT goal add-thread --name "notif" --goal board-ux >/dev/null
# ── 1. Palette + Goals view ─────────────────────────────────
echo "=== 1. Palette + Goals view ==="
tmux send-keys -t test:0.0 "$AGENT palette --window=@0 --path=/tmp --session-name=test --window-name=test0" Enter
sleep 1.5
OUT=$(cap); check "palette shows" "$OUT" "Command Palette"
tk M-r
OUT=$(cap)
check "goals header" "$OUT" "Goals"
check "goal Ship V2" "$OUT" "Ship V2"
check "sub-goal Board UX" "$OUT" "Board UX"
check "thread drag-drop" "$OUT" "drag-drop"
check "thread notif" "$OUT" "notif"
# ── 2. Navigation ───────────────────────────────────────────
echo "=== 2. Navigation ==="
tk e; OUT=$(cap); check "e moves down" "$OUT" "Goals"
tk e; OUT=$(cap); check "e again" "$OUT" "Goals"
tk e; OUT=$(cap); check "e to thread" "$OUT" "Goals"
tk u; OUT=$(cap); check "u moves up" "$OUT" "Goals"
tk n; OUT=$(cap); check "n parent jump" "$OUT" "Goals"
# ── 3. Expand thread ────────────────────────────────────────
echo "=== 3. Expand thread ==="
tk e; tk e # navigate to a thread
tk Right
OUT=$(cap); check "expand keeps goals" "$OUT" "Goals"
tk Right # collapse
OUT=$(cap); check "collapse keeps goals" "$OUT" "Goals"
# ── 4. More options (o) ─────────────────────────────────────
echo "=== 4. More options ==="
tk o
OUT=$(cap)
check "more-options opens" "$OUT" "[Aa]ction"
# filter for "add goal"
type_ "add goal"
OUT=$(cap); check "filter works" "$OUT" "[Aa]dd [Gg]oal"
tk Enter
OUT=$(cap); check "add-goal input opens" "$OUT" "[Nn]ew"
# ── 5. Create goal via input ────────────────────────────────
echo "=== 5. Create goal ==="
type_ "NewGoal"
tk Enter
OUT=$(cap); check "new goal in view" "$OUT" "NewGoal"
# ── 6. Rename ───────────────────────────────────────────────
echo "=== 6. Rename ==="
tk u; tk u # move up to a goal
tk r
OUT=$(cap); check "rename input opens" "$OUT" "[Nn]ame\|[Tt]itle"
tk C-u # clear existing text
type_ "RenamedGoal"
tk Enter
OUT=$(cap); check "rename shows in view" "$OUT" "RenamedGoal"
# ── 7. Done toggle (D) ──────────────────────────────────────
echo "=== 7. Done toggle ==="
tk e; tk e # move to a thread
tk S-D # shift-D
OUT=$(cap); check "D confirm opens" "$OUT" "[Dd]elete\|[Dd]one\|[Tt]oggle"
tk y # confirm
OUT=$(cap); check "D action applied" "$OUT" "Goals"
# ── 8. Close + persist check ────────────────────────────────
echo "=== 8. Persist ==="
sleep 0.5
tk M-s
sleep 0.5
LIST=$($AGENT goal list 2>/dev/null)
check "new goal persisted" "$LIST" "NewGoal"
check "rename persisted" "$LIST" "RenamedGoal"
# ── Summary ─────────────────────────────────────────────────
echo ""
echo "========================================"
echo "TUI Results: $PASS passed, $FAIL failed"
echo "========================================"
tmux kill-session -t test 2>/dev/null || true
exit $FAIL

View file

@ -0,0 +1,66 @@
#!/usr/bin/env bash
set -euo pipefail
export HOME=/home/testuser
AGENT=/home/testuser/bin/agent
echo "=== Seeding fake data ==="
rm -rf ~/.cache/agent
mkdir -p ~/.cache/agent
# Goals
$AGENT goal add-goal --title "Ship V2"
$AGENT goal add-goal --title "Board UX" --parent ship-v2
$AGENT goal add-goal --title "API Refactor" --parent ship-v2
$AGENT goal add-goal --title "Marketing Site"
# Threads (assigned to goals)
$AGENT goal add-thread --name "drag-drop upload" --goal board-ux
$AGENT goal add-thread --name "notification system" --goal board-ux
$AGENT goal add-thread --name "auth middleware" --goal api-refactor
# Threads (unassigned, top-level)
$AGENT goal add-thread --name "Standalone thread A"
$AGENT goal add-thread --name "Standalone thread B"
# Mark one as done
DONE_ID=$($AGENT goal list 2>/dev/null | grep 'drag-drop' | grep -o 'th_[a-f0-9]*' | head -1)
if [ -n "$DONE_ID" ]; then
$AGENT goal done --thread "$DONE_ID" 2>/dev/null || true
fi
echo "=== Creating fake drafts ==="
# Kill any existing tmux session
tmux kill-session -t test 2>/dev/null || true
# Create session
tmux new-session -d -s test -x 120 -y 55 -n main
sleep 0.3
# Create windows to simulate agent sessions (drafts)
declare -A WINDOWS
WINDOWS=(
["fix-bug"]="Fix the login page redirect bug"
["add-tests"]="Add unit tests for payment module"
["refactor-nav"]="Refactor the navigation component"
["docs-readme"]="Update README with new install steps"
["perf-query"]="Optimize the slow database query"
)
for wname in "${!WINDOWS[@]}"; do
msg="${WINDOWS[$wname]}"
tmux new-window -t test -n "$wname"
sleep 0.1
wid=$(tmux display-message -p -t test "=#{window_id}")
/home/testuser/fake-agent.sh "$wid" "$msg" 2>/dev/null || true
done
# Go back to first window
tmux select-window -t test:0
echo ""
echo "=== Seed data complete ==="
$AGENT goal list 2>/dev/null
echo ""
echo "Drafts (unassigned windows):"
echo " fix-bug, add-tests, refactor-nav, docs-readme, perf-query"

View file

@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""Scenario: cursor on Goal B, press m, u, e.
Setup:
Goal A
Goal B
Thread C
-----------------------
Draft 1
Draft 2
Draft 3
Prints the panel after each keypress so we can observe the ghost position.
"""
import sys, os, subprocess, time
sys.path.insert(0, os.path.dirname(__file__))
from visual_lib import *
TARGET = "test:0.0"
AGENT = os.environ.get("AGENT_BIN", "/home/testuser/bin/agent")
TRACKER_SERVER = "/home/testuser/bin/tracker-server"
ROWS, COLS = 50, 100
def dump(label):
g = capture(TARGET, ROWS, COLS)
print(f"\n===== {label} =====")
for r in range(ROWS):
rt = row_text(r)
if rt.strip():
print(f" {r:2d}: |{rt}|")
# locate ghost
for r in range(ROWS):
if "(moving)" in row_text(r):
print(f" >> ghost at row {r}: |{row_text(r)}|")
break
def navigate_to(text):
"""Move cursor until the selected (bg 238) row contains text. Down then up."""
for _ in range(60):
capture(TARGET, ROWS, COLS)
for r in range(ROWS):
if row_has_bg(r, "238") > 5:
if text in row_text(r):
return True
break
send_keys(TARGET, "e", 0.08)
for _ in range(60):
capture(TARGET, ROWS, COLS)
for r in range(ROWS):
if row_has_bg(r, "238") > 5:
if text in row_text(r):
return True
break
send_keys(TARGET, "u", 0.08)
return False
def setup():
subprocess.run(["tmux", "kill-session", "-t", "test"], capture_output=True)
time.sleep(0.2)
subprocess.run(["go", "build", "-o", TRACKER_SERVER, "./cmd/tracker-server"],
cwd="/home/testuser/agent-tracker", capture_output=True)
subprocess.run(["pkill", "-f", "tracker-server"], capture_output=True)
time.sleep(0.2)
subprocess.Popen([TRACKER_SERVER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(0.5)
subprocess.run(["tmux", "new-session", "-d", "-s", "test",
"-x", str(COLS), "-y", str(ROWS)], capture_output=True)
time.sleep(0.3)
cache = os.path.expanduser("~/.cache/agent")
subprocess.run(["rm", "-rf", cache], capture_output=True)
subprocess.run(["mkdir", "-p", cache], capture_output=True)
ga = subprocess.run([AGENT, "goal", "add-goal", "--title", "Goal A"],
capture_output=True, text=True).stdout.strip()
gb = subprocess.run([AGENT, "goal", "add-goal", "--title", "Goal B"],
capture_output=True, text=True).stdout.strip()
print(f"goal ids: A={ga} B={gb}")
subprocess.run([AGENT, "goal", "add-thread", "--name", "Thread C", "--goal", gb],
capture_output=True)
for name in ["Draft 1", "Draft 2", "Draft 3"]:
result = subprocess.run(["tmux", "new-window", "-d", "-t", "test", "-n", name,
"-P", "-F", "#{window_id}"], capture_output=True, text=True)
wid = result.stdout.strip()
if wid:
subprocess.run([AGENT, "tracker", "command", "--window-id", wid,
"--session", "test", "--window", name, "start_task", name],
capture_output=True)
time.sleep(0.3)
subprocess.run(["tmux", "select-window", "-t", "test:0"], capture_output=True)
subprocess.run(
["tmux", "send-keys", "-t", TARGET,
f"{AGENT} palette --mode goals --window=@0 --path=/tmp --session-name=test --window-name=test0",
"Enter"],
capture_output=True)
time.sleep(1.5)
capture(TARGET, ROWS, COLS)
def main():
setup()
dump("initial")
if not navigate_to("Goal B"):
print("FAIL: could not navigate cursor to Goal B")
return 1
dump("cursor on Goal B")
send_keys(TARGET, "m", 0.5)
dump("after m (enter move mode)")
send_keys(TARGET, "u", 0.4)
dump("after u (expect: nest under Goal A)")
send_keys(TARGET, "e", 0.4)
dump("after e (expect: pop back to root; NOT in draft area)")
# Assertions
g = capture(TARGET, ROWS, COLS)
ghost_row = -1
first_draft_row = -1
for r in range(ROWS):
rt = row_text(r)
if "(moving)" in rt and ghost_row < 0:
ghost_row = r
if "Draft" in rt and first_draft_row < 0:
first_draft_row = r
print(f"\nghost_row={ghost_row} first_draft_row={first_draft_row}")
check("ghost still visible after e", ghost_row >= 0)
if ghost_row >= 0 and first_draft_row >= 0:
check("ghost above draft section (not in draft area)",
ghost_row < first_draft_row,
f"ghost row {ghost_row} >= first draft row {first_draft_row}")
if ghost_row >= 0:
gt = row_text(ghost_row)
ghost_lead = len(gt) - len(gt.lstrip())
# root rows carry 1 leading space (panel padding); find Goal A's indent as the root reference
root_lead = 1
for r in range(ROWS):
if "Goal A" in row_text(r):
root_lead = len(row_text(r)) - len(row_text(r).lstrip())
break
check("ghost at root depth (matches Goal A indent)", ghost_lead == root_lead,
f"ghost leading={ghost_lead}, root leading={root_lead}: |{gt}|")
print(summary())
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Scenario: cursor on a thread, press m, move ghost into the draft section,
press Enter to demote the thread to a draft. The cursor should land on the
new draft after Enter.
Setup:
Goal A
Thread T (bound to window "AlphaWin")
-----------------------
Draft 1
Draft 2
"""
import sys, os, subprocess, time
sys.path.insert(0, os.path.dirname(__file__))
from visual_lib import *
TARGET = "test:0.0"
AGENT = os.environ.get("AGENT_BIN", "/home/testuser/bin/agent")
TRACKER_SERVER = "/home/testuser/bin/tracker-server"
ROWS, COLS = 50, 100
DRAFT_WINDOW = "AlphaWin"
def navigate_to(text):
for _ in range(60):
capture(TARGET, ROWS, COLS)
for r in range(ROWS):
if row_has_bg(r, "238") > 5:
if text in row_text(r):
return True
break
send_keys(TARGET, "e", 0.08)
for _ in range(60):
capture(TARGET, ROWS, COLS)
for r in range(ROWS):
if row_has_bg(r, "238") > 5:
if text in row_text(r):
return True
break
send_keys(TARGET, "u", 0.08)
return False
def find_row(substring):
capture(TARGET, ROWS, COLS)
for r in range(ROWS):
if substring in row_text(r):
return r
return -1
def ghost_below_separator():
"""Return True if the (moving) ghost row is below the separator row."""
capture(TARGET, ROWS, COLS)
sep_row = -1
ghost_row = -1
for r in range(ROWS):
rt = row_text(r)
if sep_row < 0 and "───" in rt:
sep_row = r
if "(moving)" in rt:
ghost_row = r
return ghost_row > sep_row >= 0
def setup():
subprocess.run(["tmux", "kill-session", "-t", "test"], capture_output=True)
time.sleep(0.2)
subprocess.run(["go", "build", "-o", TRACKER_SERVER, "./cmd/tracker-server"],
cwd="/home/testuser/agent-tracker", capture_output=True)
subprocess.run(["pkill", "-f", "tracker-server"], capture_output=True)
time.sleep(0.2)
subprocess.Popen([TRACKER_SERVER], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
time.sleep(0.5)
subprocess.run(["tmux", "new-session", "-d", "-s", "test", "-x", str(COLS), "-y", str(ROWS)],
capture_output=True)
time.sleep(0.3)
cache = os.path.expanduser("~/.cache/agent")
subprocess.run(["rm", "-rf", cache], capture_output=True)
subprocess.run(["mkdir", "-p", cache], capture_output=True)
ga = subprocess.run([AGENT, "goal", "add-goal", "--title", "Goal A"],
capture_output=True, text=True).stdout.strip()
# Create the thread's window + tracker task, then promote it to a thread under Goal A
res = subprocess.run(["tmux", "new-window", "-d", "-t", "test", "-n", DRAFT_WINDOW,
"-P", "-F", "#{window_id}"], capture_output=True, text=True)
wid = res.stdout.strip()
subprocess.run([AGENT, "tracker", "command", "--window-id", wid, "--session", "test",
"--window", DRAFT_WINDOW, "start_task", DRAFT_WINDOW], capture_output=True)
subprocess.run([AGENT, "goal", "promote", "--window", wid, "--name", "Thread T",
"--goal", ga], capture_output=True)
# Two existing drafts (so there is a draft section to move into)
for name in ["Draft 1", "Draft 2"]:
r = subprocess.run(["tmux", "new-window", "-d", "-t", "test", "-n", name,
"-P", "-F", "#{window_id}"], capture_output=True, text=True)
w = r.stdout.strip()
if w:
subprocess.run([AGENT, "tracker", "command", "--window-id", w, "--session", "test",
"--window", name, "start_task", name], capture_output=True)
time.sleep(0.3)
subprocess.run(["tmux", "select-window", "-t", "test:0"], capture_output=True)
subprocess.run(
["tmux", "send-keys", "-t", TARGET,
f"{AGENT} palette --mode goals --window=@0 --path=/tmp --session-name=test --window-name=test0",
"Enter"],
capture_output=True)
time.sleep(1.5)
capture(TARGET, ROWS, COLS)
def dump(label):
g = capture(TARGET, ROWS, COLS)
print(f"\n--- {label} ---")
for r in range(ROWS):
rt = row_text(r)
if rt.strip():
print(f" {r:2d}: |{rt}|")
def main():
setup()
dump("initial")
if not navigate_to("Thread T"):
print("FAIL: could not navigate to Thread T")
return 1
dump("cursor on Thread T")
send_keys(TARGET, "m", 0.5)
dump("after m")
# Move ghost down until it is in the draft section
moved = False
for _ in range(12):
if ghost_below_separator():
moved = True
break
send_keys(TARGET, "e", 0.3)
dump("ghost in draft section" if moved else "ghost NEVER reached draft section")
if not moved:
print("FAIL: ghost did not reach the draft section")
return 1
# Commit demotion
send_keys(TARGET, "Enter", 0.6)
dump("after Enter (demote)")
# Assertions: cursor (selected row) should be on the new draft (DRAFT_WINDOW)
g = capture(TARGET, ROWS, COLS)
sel_row = -1
for r in range(ROWS):
if row_has_bg(r, "238") > 5:
sel_row = r
break
check("a row is selected after Enter", sel_row >= 0)
if sel_row >= 0:
sel_text = row_text(sel_row)
check("cursor on the demoted draft", DRAFT_WINDOW in sel_text,
f"selected row {sel_row}: |{sel_text}|")
check("Thread T demoted (no longer a thread row)", find_row("Thread T") < 0)
print(summary())
return 0 if FAIL == 0 else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,217 @@
#!/usr/bin/env python3
"""Visual test framework for agent-tracker TUI.
Captures tmux pane cells with full ANSI attribute info and runs assertions
on background colors, foreground colors, bold, and spatial layout.
"""
import sys, json, subprocess, time, os
GRID = [] # type: list
def send_keys(target, keys, delay=0.3):
subprocess.run(["tmux", "send-keys", "-t", target] + keys.split(),
capture_output=True)
time.sleep(delay)
def send_keys_literal(target, text, delay=0.2):
"""Send text literally, char by char, preserving spaces."""
for ch in text:
subprocess.run(["tmux", "send-keys", "-t", target, "-l", ch],
capture_output=True)
time.sleep(0.03)
time.sleep(delay)
def capture(target, rows=50, cols=100):
result = subprocess.run(
["python3", os.path.join(os.path.dirname(__file__), "capture_grid.py"),
target, str(rows), str(cols)],
capture_output=True, text=True
)
global GRID
new_grid = json.loads(result.stdout)
GRID.clear()
GRID.extend(new_grid)
return GRID
def cell(row, col):
if row < 0 or row >= len(GRID):
return {"ch": " ", "fg": None, "bg": None, "bold": False, "dim": False}
r = GRID[row]
if col < 0 or col >= len(r):
return {"ch": " ", "fg": None, "bg": None, "bold": False, "dim": False}
return r[col]
def row_text(row, cols=None):
if row < 0 or row >= len(GRID):
return ""
r = GRID[row]
if cols:
r = r[:cols]
return "".join(c["ch"] for c in r).rstrip()
def row_has_bg(row, bg_val, min_cols=1, max_col=None):
"""Count cells in a row that have a specific background."""
if row < 0 or row >= len(GRID):
return 0
r = GRID[row]
if max_col is None:
max_col = len(r)
count = 0
for c in r[:max_col]:
if str(c.get("bg")) == str(bg_val):
count += 1
return count
def row_bg_extent(row):
"""Return (start_col, end_col) of contiguous bg on a row, or (0,0) if none."""
if row < 0 or row >= len(GRID):
return (0, 0)
r = GRID[row]
bgs = set()
for c in r:
bg = c.get("bg")
if bg is not None and str(bg) not in ("None", "none"):
bgs.add(str(bg))
if not bgs:
return (0, 0)
# find first and last cell with any bg
first = None
last = None
for i, c in enumerate(r):
bg = c.get("bg")
if bg is not None and str(bg) not in ("None", "none"):
if first is None:
first = i
last = i
return (first if first else 0, last if last else 0)
def find_row_with_text(text, start=0, end=None):
"""Find row index containing text."""
if end is None:
end = len(GRID)
for r in range(start, end):
if text in row_text(r):
return r
return -1
PASS = 0
FAIL = 0
FAILURES = []
def check(name, condition, detail=""):
global PASS, FAIL
if condition:
PASS += 1
print(f" PASS: {name}")
else:
FAIL += 1
FAILURES.append(name)
print(f" FAIL: {name}" + (f"{detail}" if detail else ""))
def check_cell_bg(name, row, col, expected_bg):
c = cell(row, col)
actual = c.get("bg")
ok = str(actual) == str(expected_bg)
check(name, ok,
f"row {row} col {col}: expected bg={expected_bg}, got bg={actual} ch={repr(c['ch'])}")
def check_cell_fg(name, row, col, expected_fg):
c = cell(row, col)
actual = c.get("fg")
ok = str(actual) == str(expected_fg)
check(name, ok,
f"row {row} col {col}: expected fg={expected_fg}, got fg={actual} ch={repr(c['ch'])}")
def check_cell_bold(name, row, col, expected=True):
c = cell(row, col)
actual = c.get("bold", False)
ok = actual == expected
check(name, ok,
f"row {row} col {col}: expected bold={expected}, got bold={actual} ch={repr(c['ch'])}")
def check_row_bg_full(name, row, expected_bg, width, min_pct=0.9):
"""Check that a row has bg color extending at least min_pct of width."""
count = row_has_bg(row, expected_bg, max_col=width)
pct = count / width if width > 0 else 0
ok = pct >= min_pct
check(name, ok,
f"row {row}: expected bg={expected_bg} covering >= {min_pct*100:.0f}% of {width} cols, "
f"got {count}/{width} ({pct*100:.0f}%)")
def check_row_bg_continuous(name, row, expected_bg, expected_width):
"""Check that bg extends continuously from first cell to at least expected_width."""
if row >= len(GRID):
check(name, False, f"row {row} out of bounds")
return
r = GRID[row]
first_bg = None
for i, c in enumerate(r):
if str(c.get("bg")) == str(expected_bg):
first_bg = i
break
if first_bg is None:
check(name, False, f"row {row}: no cell with bg={expected_bg} found")
return
# check continuity from first_bg
continuous = 0
for i in range(first_bg, len(r)):
if str(r[i].get("bg")) == str(expected_bg):
continuous += 1
else:
break
ok = continuous >= expected_width
check(name, ok,
f"row {row}: expected continuous bg={expected_bg} for >= {expected_width} cols from col {first_bg}, "
f"got {continuous}")
def dump_row(row, cols=None):
"""Pretty-print a row with cell attributes."""
if row < 0 or row >= len(GRID):
print(f" [row {row}: out of bounds]")
return
r = GRID[row]
if cols:
r = r[:cols]
text = "".join(c["ch"] for c in r)
print(f" row {row}: |{text}|")
for i, c in enumerate(r):
attrs = []
if c.get("bg") and str(c["bg"]) not in ("None", "none"):
attrs.append(f"bg={c['bg']}")
if c.get("fg") and str(c["fg"]) not in ("None", "none"):
attrs.append(f"fg={c['fg']}")
if c.get("bold"):
attrs.append("bold")
if attrs and c["ch"] != " ":
print(f" col {i}: {repr(c['ch'])} [{', '.join(attrs)}]")
elif attrs and c["ch"] == " ":
# only print first few bg-only cells to avoid noise
pass
def dump_row_bg(row, cols=None):
"""Show only bg coverage for a row."""
if row < 0 or row >= len(GRID):
print(f" [row {row}: out of bounds]")
return
r = GRID[row]
if cols:
r = r[:cols]
parts = []
for i, c in enumerate(r):
bg = c.get("bg")
if bg and str(bg) not in ("None", "none"):
parts.append(str(bg))
else:
parts.append("·")
bg_str = " ".join(parts[:60])
text = "".join(c["ch"] for c in r).rstrip()
print(f" row {row} text: |{text}|")
print(f" row {row} bg: {bg_str}")
def summary():
print(f"\n{'='*40}")
print(f"Visual Results: {PASS} passed, {FAIL} failed")
print(f"{'='*40}")
if FAILURES:
print(f"Failed: {', '.join(FAILURES)}")
return FAIL

File diff suppressed because it is too large Load diff

View file

@ -108,6 +108,7 @@ export const TrackerNotifyPlugin = async ({ client, directory, $ }) => {
let taskActive = false; let taskActive = false;
let currentSessionID = null; let currentSessionID = null;
let currentPhase = "";
let lastUserMessage = ""; let lastUserMessage = "";
let rootSessionID = loadPersistedPaneSessionID(); let rootSessionID = loadPersistedPaneSessionID();
let questionPending: boolean | null = null; let questionPending: boolean | null = null;
@ -199,8 +200,22 @@ export const TrackerNotifyPlugin = async ({ client, directory, $ }) => {
if (!(await trackerReady())) return; if (!(await trackerReady())) return;
taskActive = true; taskActive = true;
currentSessionID = sessionID; currentSessionID = sessionID;
currentPhase = "";
const args = buildTrackerArgs(); const args = buildTrackerArgs();
await $`${TRACKER_BIN} command ${args} -summary ${summary} start_task`.nothrow(); await $`${TRACKER_BIN} command ${args} -summary ${summary} start_task`.nothrow();
const agentBin = `${process.env.HOME || ""}/.config/agent-tracker/bin/agent`;
if (tmuxContext?.windowId) {
await $`${agentBin} goal revive --window ${tmuxContext.windowId} --name ${summary}`.nothrow();
}
};
const updatePhase = async (phase: string) => {
if (!taskActive) return;
if (currentPhase === phase) return;
currentPhase = phase;
if (!(await trackerReady())) return;
const args = buildTrackerArgs();
await $`${TRACKER_BIN} command ${args} -phase ${phase} update_phase`.nothrow();
}; };
const finishTask = async (summary) => { const finishTask = async (summary) => {
@ -208,6 +223,7 @@ export const TrackerNotifyPlugin = async ({ client, directory, $ }) => {
if (!(await trackerReady())) return; if (!(await trackerReady())) return;
taskActive = false; taskActive = false;
currentSessionID = null; currentSessionID = null;
currentPhase = "";
const args = buildTrackerArgs(); const args = buildTrackerArgs();
await $`${TRACKER_BIN} command ${args} -summary ${summary || "done"} finish_task`.nothrow(); await $`${TRACKER_BIN} command ${args} -summary ${summary || "done"} finish_task`.nothrow();
}; };
@ -280,16 +296,20 @@ export const TrackerNotifyPlugin = async ({ client, directory, $ }) => {
rootSessionID = input.sessionID; rootSessionID = input.sessionID;
} }
await applyQuestionPending(true); await applyQuestionPending(true);
await updatePhase("question");
log("Question tool called:", { log("Question tool called:", {
questions: output.args?.questions || "no questions", questions: output.args?.questions || "no questions",
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}); });
} else {
await updatePhase("tool");
} }
}, },
event: async ({ event }) => { event: async ({ event }) => {
if (event?.type === "question.asked") { if (event?.type === "question.asked") {
await applyQuestionPending(true); await applyQuestionPending(true);
await updatePhase("question");
return; return;
} }
@ -312,13 +332,15 @@ export const TrackerNotifyPlugin = async ({ client, directory, $ }) => {
const part = event?.properties?.part; const part = event?.properties?.part;
if (part?.type === "text" && part?.text && part?.messageID) { if (part?.type === "text" && part?.text && part?.messageID) {
const role = messageRoles.get(part.messageID); const role = messageRoles.get(part.messageID);
// Capture if it's a user message, or if we're not yet in a task (user input comes first)
if (role === "user" || (!role && !taskActive)) { if (role === "user" || (!role && !taskActive)) {
const text = part.text?.trim(); const text = part.text?.trim();
if (text && text.length > 0) { if (text && text.length > 0) {
lastUserMessage = text.slice(0, MAX_SUMMARY_CHARS); lastUserMessage = text.slice(0, MAX_SUMMARY_CHARS);
} }
} }
if (role === "assistant") {
await updatePhase("responding");
}
} }
} }
@ -348,12 +370,12 @@ export const TrackerNotifyPlugin = async ({ client, directory, $ }) => {
if (!status) return; if (!status) return;
if (status.type === "busy" && !taskActive) { if (status.type === "busy" && !taskActive) {
// Use captured message first, then fall back to API
let text = lastUserMessage; let text = lastUserMessage;
if (!text) { if (!text) {
text = await getLastMessageText(sessionID, "user"); text = await getLastMessageText(sessionID, "user");
} }
await startTask(text || "working...", sessionID); await startTask(text || "working...", sessionID);
await updatePhase("waiting");
lastUserMessage = ""; lastUserMessage = "";
} else if (status.type === "idle" && taskActive) { } else if (status.type === "idle" && taskActive) {
if (currentSessionID && sessionID !== currentSessionID) return; if (currentSessionID && sessionID !== currentSessionID) return;

View file

@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Fuzzy blocker picker for the focused window's thread.
# Bound to tmux prefix-b.
set -euo pipefail
AGENT_BIN="$HOME/.config/agent-tracker/bin/agent"
WINDOW="$(tmux display-message -p '#{window_id}' 2>/dev/null || true)"
CURRENT="$("$AGENT_BIN" goal current-thread --window="$WINDOW" 2>/dev/null || true)"
LINE="$("$AGENT_BIN" goal pick threads | fzf --prompt='blocked by> ' --height=40% --reverse --ansi 2>/dev/null || true)"
[ -z "$LINE" ] && exit 0
ID="${LINE%% *}"
[ -n "$CURRENT" ] && [ "$ID" = "$CURRENT" ] && exit 0
exec "$AGENT_BIN" goal set-blocker-current --by="$ID" --window="$WINDOW"

18
tmux/scripts/goal_pick_goal.sh Executable file
View file

@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Fuzzy goal picker for the focused window's thread.
# Bound to tmux prefix-g.
set -euo pipefail
AGENT_BIN="$HOME/.config/agent-tracker/bin/agent"
WINDOW="$(tmux display-message -p '#{window_id}' 2>/dev/null || true)"
LINE="$("$AGENT_BIN" goal pick goals | fzf --prompt='goal> ' --height=40% --reverse --ansi 2>/dev/null || true)"
[ -z "$LINE" ] && exit 0
ID="${LINE%% *}"
if [ "$ID" = "__new__" ]; then
exec tmux command-prompt -p "New goal title:" \
"run-shell \"NEWID=\$($AGENT_BIN goal add-goal --title '%%'); $AGENT_BIN goal set-goal-current --goal=\$NEWID --window=$WINDOW\""
fi
exec "$AGENT_BIN" goal set-goal-current --goal="$ID" --window="$WINDOW"

View file

@ -196,6 +196,20 @@ def main() -> int:
if migrated_window_todos or migrated_session_todos: if migrated_window_todos or migrated_session_todos:
save_json(TODOS_PATH, todos_payload) save_json(TODOS_PATH, todos_payload)
# ── goals.json: re-point thread window_id to new ids ──────
GOALS_PATH = HOME / ".cache" / "agent" / "goals.json"
migrated_threads = 0
if GOALS_PATH.exists() and (window_migrations or session_migrations):
goals_payload = load_json(GOALS_PATH)
threads = goals_payload.get("threads", {})
for thread in threads.values():
old_wid = str(thread.get("window_id", "")).strip()
if old_wid and old_wid in window_migrations:
thread["window_id"] = window_migrations[old_wid]
migrated_threads += 1
if migrated_threads:
save_json(GOALS_PATH, goals_payload)
summary = [] summary = []
if changed_agents: if changed_agents:
summary.append(f"{changed_agents} agent windows") summary.append(f"{changed_agents} agent windows")
@ -203,6 +217,8 @@ def main() -> int:
summary.append(f"{migrated_window_todos} window todos") summary.append(f"{migrated_window_todos} window todos")
if migrated_session_todos: if migrated_session_todos:
summary.append(f"{migrated_session_todos} session todos") summary.append(f"{migrated_session_todos} session todos")
if migrated_threads:
summary.append(f"{migrated_threads} goal threads")
if summary: if summary:
print("restored " + ", ".join(summary)) print("restored " + ", ".join(summary))
return 0 return 0

View file

@ -6,6 +6,20 @@ import sys
from typing import List, Dict from typing import List, Dict
HIDDEN_SESSION_LABELS = {"Scratch", "scratch"}
def session_label(name: str) -> str:
match = re.match(r"^(\d+)-(.*)$", name)
if match:
return match.group(2)
return name
def is_hidden_session(name: str) -> bool:
return session_label(name) in HIDDEN_SESSION_LABELS
def run_tmux(args: List[str], check: bool = True, capture: bool = False) -> str: def run_tmux(args: List[str], check: bool = True, capture: bool = False) -> str:
kwargs = { kwargs = {
"check": check, "check": check,
@ -31,6 +45,8 @@ def list_sessions() -> List[Dict[str, object]]:
sessions = [] sessions = []
for line in output.splitlines(): for line in output.splitlines():
session_id, name, created_str = line.split("\t") session_id, name, created_str = line.split("\t")
if is_hidden_session(name):
continue
created = int(created_str) created = int(created_str)
match = re.match(r"^(\d+)-(.*)$", name) match = re.match(r"^(\d+)-(.*)$", name)
if match: if match:

View file

@ -6,8 +6,12 @@ if [[ -z "$index" || ! "$index" =~ ^[0-9]+$ ]]; then
exit 0 exit 0
fi fi
# Get session matching index prefix (e.g., "1-" for index 1) current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || true)
target=$(tmux list-sessions -F '#{session_id} #{session_name}' 2>/dev/null | awk -v idx="$index" '$2 ~ "^"idx"-" {print $1; exit}') if [[ "$current_session" =~ ^([0-9]+-)?[sS]cratch$ ]]; then
exit 0
fi
target=$(tmux list-sessions -F '#{session_id} #{session_name}' 2>/dev/null | awk -v idx="$index" '$2 !~ "^([0-9]+-)?[sS]cratch$" && $2 ~ "^"idx"-" {print $1; exit}')
if [[ -n "$target" ]]; then if [[ -n "$target" ]]; then
tmux switch-client -t "$target" tmux switch-client -t "$target"

View file

@ -53,6 +53,11 @@ if [[ -z "$sessions" ]]; then
exit 0 exit 0
fi fi
sessions=$(printf '%s\n' "$sessions" | grep -Evi '^([^:]+)::([0-9]+-)?scratch$' || true)
if [[ -z "$sessions" ]]; then
exit 0
fi
"$HOME/.config/tmux/tmux-status/tracker_cache.sh" 2>/dev/null || true "$HOME/.config/tmux/tmux-status/tracker_cache.sh" 2>/dev/null || true
CACHE_FILE="/tmp/tmux-tracker-cache.json" CACHE_FILE="/tmp/tmux-tracker-cache.json"
@ -86,11 +91,12 @@ get_session_icon() {
if [[ -n "$tracker_state" ]]; then if [[ -n "$tracker_state" ]]; then
local result local result
result=$(echo "$tracker_state" | jq -r --arg sid "$sid" ' result=$(echo "$tracker_state" | jq -r --arg sid "$sid" '
.tasks // [] | .[] | select(.session_id == $sid) | .tasks // []
if .status == "completed" and .acknowledged != true then "waiting" | map(select(.session_id == $sid))
elif .status == "in_progress" then "in_progress" | if any(.status == "completed" and .acknowledged != true) then "waiting"
else empty end elif any(.status == "in_progress") then "in_progress"
' 2>/dev/null | head -1 || true) else empty end
' 2>/dev/null || true)
case "$result" in case "$result" in
waiting) has_bell=1 ;; waiting) has_bell=1 ;;
in_progress) has_watch=1 ;; in_progress) has_watch=1 ;;

View file

@ -12,11 +12,12 @@ state=$("$agent_bin" tracker state 2>/dev/null || true)
# Check for in_progress or unacknowledged completed tasks in this session # Check for in_progress or unacknowledged completed tasks in this session
result=$(echo "$state" | jq -r --arg sid "$session_id" ' result=$(echo "$state" | jq -r --arg sid "$session_id" '
.tasks // [] | .[] | select(.session_id == $sid) | .tasks // []
if .status == "in_progress" then "in_progress" | map(select(.session_id == $sid))
elif .status == "completed" and .acknowledged != true then "waiting" | if any(.status == "completed" and .acknowledged != true) then "waiting"
else empty end elif any(.status == "in_progress") then "in_progress"
' 2>/dev/null | head -1 || true) else empty end
' 2>/dev/null || true)
case "$result" in case "$result" in
in_progress) printf '⏳' ;; in_progress) printf '⏳' ;;

View file

@ -25,11 +25,12 @@ if [[ -f "$CACHE_FILE" ]]; then
state=$(cat "$CACHE_FILE" 2>/dev/null || true) state=$(cat "$CACHE_FILE" 2>/dev/null || true)
if [[ -n "$state" ]]; then if [[ -n "$state" ]]; then
result=$(echo "$state" | jq -r --arg wid "$window_id" ' result=$(echo "$state" | jq -r --arg wid "$window_id" '
.tasks // [] | .[] | select(.window_id == $wid) | .tasks // []
if .status == "completed" and .acknowledged != true then "waiting" | map(select(.window_id == $wid))
elif .status == "in_progress" then "in_progress" | if any(.status == "completed" and .acknowledged != true) then "waiting"
else empty end elif any(.status == "in_progress") then "in_progress"
' 2>/dev/null | head -1 || true) else empty end
' 2>/dev/null || true)
case "$result" in case "$result" in
waiting) has_bell=1 ;; waiting) has_bell=1 ;;
in_progress) has_watch=1 ;; in_progress) has_watch=1 ;;