mirror of
https://github.com/theniceboy/.config.git
synced 2026-07-16 22:01:21 +08:00
agent tracker update
This commit is contained in:
parent
816bf84824
commit
f667ba7225
39 changed files with 8408 additions and 104 deletions
10
.tmux.conf
10
.tmux.conf
|
|
@ -84,6 +84,7 @@ set-hook -g session-renamed 'run -b "tmux refresh-client -S"'
|
|||
|
||||
set-hook -gu session-closed
|
||||
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"
|
||||
|
||||
|
|
@ -109,6 +110,12 @@ set -g display-time 2000
|
|||
# create session
|
||||
bind C-c new-session
|
||||
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-O break-pane
|
||||
|
||||
|
|
@ -189,9 +196,10 @@ bind -n M-e select-pane -D
|
|||
bind -n M-u select-pane -U
|
||||
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-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-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 -U
|
||||
bind | swap-pane
|
||||
|
|
|
|||
20
agent-tracker/AGENTS.md
Normal file
20
agent-tracker/AGENTS.md
Normal 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
|
||||
```
|
||||
174
agent-tracker/REQUIREMENTS.md
Normal file
174
agent-tracker/REQUIREMENTS.md
Normal 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.
|
||||
|
|
@ -19,16 +19,17 @@
|
|||
},
|
||||
"devices": [
|
||||
"web-server",
|
||||
"ipm",
|
||||
"opm",
|
||||
"macos",
|
||||
"ipp"
|
||||
"ipp",
|
||||
"ip17"
|
||||
],
|
||||
"status_right": {
|
||||
"cpu": false,
|
||||
"network": false,
|
||||
"memory": false,
|
||||
"memory_totals": false,
|
||||
"flash_moe": false
|
||||
"flash_moe": false,
|
||||
"host": false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
468
agent-tracker/cmd/agent/goal_cli.go
Normal file
468
agent-tracker/cmd/agent/goal_cli.go
Normal 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
|
||||
}
|
||||
2566
agent-tracker/cmd/agent/goal_panel.go
Normal file
2566
agent-tracker/cmd/agent/goal_panel.go
Normal file
File diff suppressed because it is too large
Load diff
1051
agent-tracker/cmd/agent/goals.go
Normal file
1051
agent-tracker/cmd/agent/goals.go
Normal file
File diff suppressed because it is too large
Load diff
306
agent-tracker/cmd/agent/goals_display.go
Normal file
306
agent-tracker/cmd/agent/goals_display.go
Normal 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
|
||||
}
|
||||
|
|
@ -83,6 +83,7 @@ type statusRightConfig struct {
|
|||
Todos *bool `json:"todos,omitempty"`
|
||||
FlashMoe *bool `json:"flash_moe,omitempty"`
|
||||
Host *bool `json:"host,omitempty"`
|
||||
Goal *bool `json:"goal,omitempty"`
|
||||
}
|
||||
|
||||
type keyConfig struct {
|
||||
|
|
@ -105,10 +106,10 @@ type keyConfig struct {
|
|||
}
|
||||
|
||||
type repoConfig struct {
|
||||
BaseBranch string `yaml:"base_branch,omitempty"`
|
||||
DefaultDevice string `yaml:"default_device,omitempty"`
|
||||
CopyIgnore []string `yaml:"copy_ignore,omitempty"`
|
||||
AgentKeyPaths []string `yaml:"agent_key_paths,omitempty"`
|
||||
BaseBranch string `yaml:"base_branch,omitempty"`
|
||||
DefaultDevice string `yaml:"default_device,omitempty"`
|
||||
CopyIgnore []string `yaml:"copy_ignore,omitempty"`
|
||||
AgentKeyPaths []string `yaml:"agent_key_paths,omitempty"`
|
||||
}
|
||||
|
||||
type featureConfig struct {
|
||||
|
|
@ -159,7 +160,7 @@ func main() {
|
|||
|
||||
func run(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: agent <start|resume|list|destroy|init|config|setup|tmux|tracker|browser|feature>")
|
||||
return fmt.Errorf("usage: agent <start|resume|list|destroy|init|config|setup|tmux|tracker|goal|browser|feature|update-hot-reload>")
|
||||
}
|
||||
switch args[0] {
|
||||
case "start":
|
||||
|
|
@ -182,10 +183,14 @@ func run(args []string) error {
|
|||
return runTmuxCommand(args[1:])
|
||||
case "tracker":
|
||||
return runTracker(args[1:])
|
||||
case "goal":
|
||||
return runGoal(args[1:])
|
||||
case "browser":
|
||||
return runBrowserCommand(args[1:])
|
||||
case "feature":
|
||||
return runFeatureCommand(args[1:])
|
||||
case "update-hot-reload":
|
||||
return runUpdateHotReload(args[1:])
|
||||
case "bootstrap":
|
||||
return runBootstrap(args[1:])
|
||||
default:
|
||||
|
|
@ -1052,7 +1057,7 @@ func destroyRequiresExplicitConfirm(record *agentRecord) (bool, error) {
|
|||
|
||||
func runTmuxCommand(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("usage: agent tmux <on-focus|focus|palette|right-status>")
|
||||
return fmt.Errorf("usage: agent tmux <on-focus|focus|palette|right-status|scratch>")
|
||||
}
|
||||
switch args[0] {
|
||||
case "on-focus":
|
||||
|
|
@ -1063,6 +1068,8 @@ func runTmuxCommand(args []string) error {
|
|||
return runTmuxPalette(args[1:])
|
||||
case "right-status":
|
||||
return runTmuxRightStatus(args[1:])
|
||||
case "scratch":
|
||||
return runTmuxScratch(args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown tmux subcommand: %s", args[0])
|
||||
}
|
||||
|
|
@ -1366,6 +1373,298 @@ func runTmuxFocus(args []string) error {
|
|||
return runTmux("select-pane", "-t", target)
|
||||
}
|
||||
|
||||
func runTmuxScratch(args []string) error {
|
||||
fs := flag.NewFlagSet("agent tmux scratch", flag.ContinueOnError)
|
||||
var path string
|
||||
var client string
|
||||
var warm bool
|
||||
fs.StringVar(&path, "path", "", "directory for creating the scratch terminal")
|
||||
fs.StringVar(&client, "client", "", "tmux client tty")
|
||||
fs.BoolVar(&warm, "warm", false, "prepare the scratch terminal without opening it")
|
||||
fs.SetOutput(os.Stderr)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
if warm {
|
||||
return warmScratchSession(path)
|
||||
}
|
||||
current, err := currentTmuxLocation()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
reg, err := loadRegistry()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record := reg.Agents["scratch"]
|
||||
if scratchRecordAlive(record) {
|
||||
_ = runTmux("rename-session", "-t", record.TmuxSessionID, "scratch")
|
||||
_ = configureScratchSession(record.TmuxSessionID, record.TmuxWindowID)
|
||||
if current.SessionID == record.TmuxSessionID {
|
||||
return closeScratchPopup(client)
|
||||
}
|
||||
setScratchOrigin(record.TmuxWindowID, current)
|
||||
if current.WindowID != "" {
|
||||
record.LaunchWindowID = current.WindowID
|
||||
}
|
||||
now := time.Now()
|
||||
record.LastFocusedAt = &now
|
||||
record.UpdatedAt = now
|
||||
reg.FocusedAgentID = record.ID
|
||||
if err := saveRegistry(reg); err != nil {
|
||||
return err
|
||||
}
|
||||
return openScratchPopup(record)
|
||||
}
|
||||
if record == nil {
|
||||
record = &agentRecord{ID: "scratch", Name: "scratch", CreatedAt: time.Now()}
|
||||
}
|
||||
startPath := firstNonEmpty(path, os.Getenv("HOME"))
|
||||
if startPath == "" || !dirExists(startPath) {
|
||||
startPath = os.Getenv("HOME")
|
||||
}
|
||||
windowID, sessionID, sessionName, paneID, err := createScratchTmuxSession(startPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
record.ID = "scratch"
|
||||
record.Name = "scratch"
|
||||
record.RepoRoot = startPath
|
||||
record.WorkspaceRoot = startPath
|
||||
record.RepoCopyPath = startPath
|
||||
record.TmuxSessionID = sessionID
|
||||
record.TmuxSessionName = sessionName
|
||||
record.TmuxWindowID = windowID
|
||||
record.Panes = agentPanes{AI: paneID}
|
||||
record.LaunchWindowID = current.WindowID
|
||||
record.LastFocusedAt = &now
|
||||
record.UpdatedAt = now
|
||||
if record.CreatedAt.IsZero() {
|
||||
record.CreatedAt = now
|
||||
}
|
||||
reg.Agents[record.ID] = record
|
||||
reg.FocusedAgentID = record.ID
|
||||
if err := saveRegistry(reg); err != nil {
|
||||
return err
|
||||
}
|
||||
setScratchOrigin(windowID, current)
|
||||
return openScratchPopup(record)
|
||||
}
|
||||
|
||||
func warmScratchSession(path string) error {
|
||||
reg, err := loadRegistry()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
record := reg.Agents["scratch"]
|
||||
if record == nil {
|
||||
return nil
|
||||
}
|
||||
if scratchRecordAlive(record) {
|
||||
_ = runTmux("rename-session", "-t", record.TmuxSessionID, "scratch")
|
||||
return configureScratchSession(record.TmuxSessionID, record.TmuxWindowID)
|
||||
}
|
||||
startPath := firstNonEmpty(path, record.RepoCopyPath, record.WorkspaceRoot, record.RepoRoot, os.Getenv("HOME"))
|
||||
if startPath == "" || !dirExists(startPath) {
|
||||
startPath = os.Getenv("HOME")
|
||||
}
|
||||
windowID, sessionID, sessionName, paneID, err := createScratchTmuxSession(startPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
record.TmuxSessionID = sessionID
|
||||
record.TmuxSessionName = sessionName
|
||||
record.TmuxWindowID = windowID
|
||||
record.Panes = agentPanes{AI: paneID}
|
||||
record.RepoRoot = startPath
|
||||
record.WorkspaceRoot = startPath
|
||||
record.RepoCopyPath = startPath
|
||||
record.UpdatedAt = now
|
||||
reg.Agents[record.ID] = record
|
||||
return saveRegistry(reg)
|
||||
}
|
||||
|
||||
type tmuxLocation struct {
|
||||
SessionID string
|
||||
WindowID string
|
||||
PaneID string
|
||||
Path string
|
||||
}
|
||||
|
||||
func currentTmuxLocation() (tmuxLocation, error) {
|
||||
out, err := runTmuxOutput("display-message", "-p", "#{session_id}\n#{window_id}\n#{pane_id}\n#{pane_current_path}")
|
||||
if err != nil {
|
||||
return tmuxLocation{}, err
|
||||
}
|
||||
parts := strings.SplitN(strings.TrimRight(out, "\n"), "\n", 4)
|
||||
for len(parts) < 4 {
|
||||
parts = append(parts, "")
|
||||
}
|
||||
return tmuxLocation{
|
||||
SessionID: strings.TrimSpace(parts[0]),
|
||||
WindowID: strings.TrimSpace(parts[1]),
|
||||
PaneID: strings.TrimSpace(parts[2]),
|
||||
Path: strings.TrimSpace(parts[3]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func scratchRecordAlive(record *agentRecord) bool {
|
||||
return record != nil && windowAlive(record.TmuxSessionID, record.TmuxWindowID)
|
||||
}
|
||||
|
||||
func createScratchTmuxSession(path string) (windowID, sessionID, sessionName, paneID string, err error) {
|
||||
sessionName = "Scratch"
|
||||
if existingSessionID, existingSessionName, ok := findTmuxSessionByLabel(sessionName); ok {
|
||||
sessionID = existingSessionID
|
||||
sessionName = existingSessionName
|
||||
windowID, err = runTmuxOutput("new-window", "-P", "-F", "#{window_id}", "-t", sessionID, "-c", path)
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
} else {
|
||||
if err := runTmux("new-session", "-d", "-s", sessionName, "-c", path); err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
sessionID, _ = runTmuxOutput("display-message", "-p", "-t", sessionName+":1", "#{session_id}")
|
||||
windowID, _ = runTmuxOutput("display-message", "-p", "-t", sessionName+":1", "#{window_id}")
|
||||
}
|
||||
windowID = strings.TrimSpace(windowID)
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
sessionName = strings.TrimSpace(sessionName)
|
||||
if windowID == "" || sessionID == "" {
|
||||
return "", "", "", "", fmt.Errorf("unable to create scratch terminal")
|
||||
}
|
||||
paneID, err = currentPane(windowID)
|
||||
if err != nil {
|
||||
return "", "", "", "", err
|
||||
}
|
||||
_ = runTmux("rename-session", "-t", sessionID, "Scratch")
|
||||
_ = runTmux("set-option", "-w", "-t", windowID, "@agent_id", "scratch")
|
||||
_ = runTmux("set-option", "-w", "-t", windowID, "@scratch_window", "1")
|
||||
_ = runTmux("set-option", "-p", "-t", paneID, "@agent_role", "scratch")
|
||||
_ = configureScratchSession(sessionID, windowID)
|
||||
return windowID, sessionID, "Scratch", strings.TrimSpace(paneID), nil
|
||||
}
|
||||
|
||||
func configureScratchSession(sessionID, windowID string) error {
|
||||
sessionID = strings.TrimSpace(sessionID)
|
||||
windowID = strings.TrimSpace(windowID)
|
||||
if sessionID != "" {
|
||||
_ = runTmux("set-option", "-t", sessionID, "status", "on")
|
||||
_ = runTmux("set-option", "-t", sessionID, "status-left", "")
|
||||
_ = runTmux("set-option", "-t", sessionID, "status-right", "")
|
||||
_ = runTmux("set-option", "-t", sessionID, "status-left-length", "0")
|
||||
_ = runTmux("set-option", "-t", sessionID, "status-right-length", "0")
|
||||
_ = runTmux("set-option", "-t", sessionID, "prefix", "None")
|
||||
_ = runTmux("set-option", "-t", sessionID, "detach-on-destroy", "on")
|
||||
}
|
||||
if windowID != "" {
|
||||
_ = runTmux("set-option", "-w", "-t", windowID, "@agent_id", "scratch")
|
||||
_ = runTmux("set-option", "-w", "-t", windowID, "@scratch_window", "1")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func openScratchPopup(record *agentRecord) error {
|
||||
if record == nil {
|
||||
return fmt.Errorf("scratch terminal is not available")
|
||||
}
|
||||
target := strings.TrimSpace(record.TmuxSessionID)
|
||||
if target == "" {
|
||||
target = strings.TrimSpace(record.TmuxWindowID)
|
||||
}
|
||||
if target == "" {
|
||||
return fmt.Errorf("scratch terminal is not available")
|
||||
}
|
||||
cmd := fmt.Sprintf("tmux attach-session -t %s", shellQuote(target))
|
||||
return runTmux("display-popup", "-E", "-w", "85%", "-h", "80%", "-T", "Scratch", cmd)
|
||||
}
|
||||
|
||||
func closeScratchPopup(client string) error {
|
||||
client = strings.TrimSpace(client)
|
||||
if client == "" {
|
||||
client = strings.TrimSpace(os.Getenv("TMUX_CLIENT"))
|
||||
}
|
||||
args := []string{"detach-client"}
|
||||
if client != "" {
|
||||
args = append(args, "-t", client)
|
||||
}
|
||||
return runTmux(args...)
|
||||
}
|
||||
|
||||
func setScratchOrigin(scratchWindowID string, origin tmuxLocation) {
|
||||
scratchWindowID = strings.TrimSpace(scratchWindowID)
|
||||
if scratchWindowID == "" {
|
||||
return
|
||||
}
|
||||
if origin.SessionID != "" {
|
||||
_ = runTmux("set-option", "-w", "-t", scratchWindowID, "@scratch_origin_session", origin.SessionID)
|
||||
}
|
||||
if origin.WindowID != "" {
|
||||
_ = runTmux("set-option", "-w", "-t", scratchWindowID, "@scratch_origin_window", origin.WindowID)
|
||||
}
|
||||
if origin.PaneID != "" {
|
||||
_ = runTmux("set-option", "-w", "-t", scratchWindowID, "@scratch_origin_pane", origin.PaneID)
|
||||
}
|
||||
}
|
||||
|
||||
func focusScratchOrigin(reg *registry) error {
|
||||
if reg == nil {
|
||||
return nil
|
||||
}
|
||||
record := reg.Agents["scratch"]
|
||||
if record == nil {
|
||||
return nil
|
||||
}
|
||||
originSessionID := ""
|
||||
originWindowID := ""
|
||||
originPaneID := ""
|
||||
if out, err := runTmuxOutput("show-options", "-wqv", "-t", record.TmuxWindowID, "@scratch_origin_session"); err == nil {
|
||||
originSessionID = strings.TrimSpace(out)
|
||||
}
|
||||
if out, err := runTmuxOutput("show-options", "-wqv", "-t", record.TmuxWindowID, "@scratch_origin_window"); err == nil {
|
||||
originWindowID = strings.TrimSpace(out)
|
||||
}
|
||||
if out, err := runTmuxOutput("show-options", "-wqv", "-t", record.TmuxWindowID, "@scratch_origin_pane"); err == nil {
|
||||
originPaneID = strings.TrimSpace(out)
|
||||
}
|
||||
if !windowAlive(originSessionID, originWindowID) {
|
||||
originSessionID = ""
|
||||
originWindowID = firstLiveNonScratchWindow(record.TmuxWindowID)
|
||||
}
|
||||
if originWindowID == "" {
|
||||
return nil
|
||||
}
|
||||
reg.FocusedAgentID = ""
|
||||
_ = saveRegistry(reg)
|
||||
if originSessionID != "" {
|
||||
_ = runTmux("switch-client", "-t", originSessionID)
|
||||
}
|
||||
if err := selectTmuxWindow(originWindowID); err != nil {
|
||||
return err
|
||||
}
|
||||
if originPaneID != "" {
|
||||
_ = runTmux("select-pane", "-t", originPaneID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstLiveNonScratchWindow(scratchWindowID string) string {
|
||||
out, err := runTmuxOutput("list-windows", "-a", "-F", "#{window_id}")
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
windowID := strings.TrimSpace(line)
|
||||
if windowID != "" && windowID != strings.TrimSpace(scratchWindowID) {
|
||||
return windowID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func runTmuxPalette(args []string) error {
|
||||
fs := flag.NewFlagSet("agent tmux palette", flag.ContinueOnError)
|
||||
var windowID string
|
||||
|
|
@ -2526,6 +2825,19 @@ exec script -q "$logfile" bash -lc "cd \"$DIR/repo\" && exec flutter run -d \"$d
|
|||
if err := os.WriteFile(ensurePath, []byte(ensureServer), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeHotReloadScript(repoCopyPath); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(filepath.Join(workspaceRoot, "hot-reload.sh"))
|
||||
for _, obsolete := range []string{"open-tab.sh", "refresh-tab.sh", "on-tmux-window-activate.sh"} {
|
||||
_ = os.Remove(filepath.Join(workspaceRoot, obsolete))
|
||||
}
|
||||
_ = url
|
||||
_ = device
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeHotReloadScript(repoCopyPath string) error {
|
||||
hotReload := `#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
|
|
@ -2584,6 +2896,7 @@ find_flutter_pane() {
|
|||
while IFS= read -r child; do
|
||||
[[ -z "$child" ]] && continue
|
||||
if ps -p "$child" -o command= 2>/dev/null | grep -q 'flutter_tools\.snapshot.*run'; then
|
||||
FLUTTER_RUN_PID="$child"
|
||||
return 0
|
||||
fi
|
||||
if has_flutter_run "$child" $((depth + 1)); then
|
||||
|
|
@ -2593,15 +2906,24 @@ find_flutter_pane() {
|
|||
return 1
|
||||
}
|
||||
|
||||
local pane_id pane_pid pane_path
|
||||
while read -r pane_id pane_pid pane_path; do
|
||||
local pane_id pane_pid fpid fcwd
|
||||
local repo_dir="${REPO_DIR%/}"
|
||||
local workspace_dir="${WORKSPACE_DIR%/}"
|
||||
while read -r pane_id pane_pid; do
|
||||
[[ -z "$pane_id" || -z "$pane_pid" ]] && continue
|
||||
[[ "$pane_path" != "$WORKSPACE_DIR" && "$pane_path" != "$REPO_DIR" ]] && continue
|
||||
if has_flutter_run "$pane_pid"; then
|
||||
FLUTTER_RUN_PID=""
|
||||
if ! has_flutter_run "$pane_pid"; then
|
||||
continue
|
||||
fi
|
||||
fpid="$FLUTTER_RUN_PID"
|
||||
[[ -z "$fpid" ]] && continue
|
||||
fcwd="$(lsof -a -d cwd -p "$fpid" 2>/dev/null | tail -n +2 | awk '{print $NF}')"
|
||||
fcwd="${fcwd%/}"
|
||||
if [[ "$fcwd" == "$repo_dir" || "$fcwd" == "$workspace_dir" ]]; then
|
||||
printf "%s\n" "$pane_id"
|
||||
return 0
|
||||
fi
|
||||
done < <(tmux list-panes -a -F '#{pane_id} #{pane_pid} #{pane_current_path}' 2>/dev/null)
|
||||
done < <(tmux list-panes -a -F '#{pane_id} #{pane_pid}' 2>/dev/null)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
|
@ -2655,24 +2977,107 @@ PY
|
|||
exit 0
|
||||
) >/dev/null 2>&1 &
|
||||
|
||||
echo "Hot reload triggered"
|
||||
echo "Reloaded the application"
|
||||
`
|
||||
if err := ensureGeneratedRepoPathIgnored(repoCopyPath, "hot-reload.sh"); err != nil {
|
||||
return err
|
||||
}
|
||||
hotReloadPath := filepath.Join(repoCopyPath, "hot-reload.sh")
|
||||
if err := os.WriteFile(hotReloadPath, []byte(hotReload), 0o755); err != nil {
|
||||
return os.WriteFile(hotReloadPath, []byte(hotReload), 0o755)
|
||||
}
|
||||
|
||||
func runUpdateHotReload(args []string) error {
|
||||
fs := flag.NewFlagSet("agent update-hot-reload", flag.ContinueOnError)
|
||||
var allRepos bool
|
||||
fs.BoolVar(&allRepos, "all", false, "update workspaces across every repo in the registry")
|
||||
fs.SetOutput(os.Stderr)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(filepath.Join(workspaceRoot, "hot-reload.sh"))
|
||||
for _, obsolete := range []string{"open-tab.sh", "refresh-tab.sh", "on-tmux-window-activate.sh"} {
|
||||
_ = os.Remove(filepath.Join(workspaceRoot, obsolete))
|
||||
|
||||
var repoRoots []string
|
||||
if allRepos {
|
||||
reg, err := loadRegistry()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, record := range reg.Agents {
|
||||
root := filepath.Clean(record.RepoRoot)
|
||||
if root == "" || seen[root] {
|
||||
continue
|
||||
}
|
||||
seen[root] = true
|
||||
repoRoots = append(repoRoots, root)
|
||||
}
|
||||
sort.Strings(repoRoots)
|
||||
} else {
|
||||
root, err := repoRoot()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w; run inside a git repo or use --all", err)
|
||||
}
|
||||
repoRoots = []string{root}
|
||||
}
|
||||
_ = url
|
||||
_ = device
|
||||
|
||||
totalUpdated := 0
|
||||
for _, root := range repoRoots {
|
||||
updated, err := updateHotReloadInRepo(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", root, err)
|
||||
}
|
||||
if len(updated) == 0 {
|
||||
continue
|
||||
}
|
||||
totalUpdated += len(updated)
|
||||
fmt.Printf("%s:\n", root)
|
||||
for _, name := range updated {
|
||||
fmt.Printf(" %s\n", name)
|
||||
}
|
||||
}
|
||||
if totalUpdated == 0 {
|
||||
fmt.Println("No Flutter workspaces found.")
|
||||
return nil
|
||||
}
|
||||
fmt.Printf("Updated hot-reload.sh in %d workspace(s).\n", totalUpdated)
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateHotReloadInRepo(repoRoot string) ([]string, error) {
|
||||
agentsRoot := filepath.Join(repoRoot, ".agents")
|
||||
entries, err := os.ReadDir(agentsRoot)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updated []string
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
workspaceRoot := filepath.Join(agentsRoot, entry.Name())
|
||||
featurePath := filepath.Join(workspaceRoot, "agent.json")
|
||||
repoCopyPath := filepath.Join(workspaceRoot, "repo")
|
||||
isFlutter := false
|
||||
if cfg, err := loadFeatureConfig(featurePath); err == nil {
|
||||
isFlutter = cfg.IsFlutter
|
||||
}
|
||||
hotReloadPath := filepath.Join(repoCopyPath, "hot-reload.sh")
|
||||
if !isFlutter && !fileExists(hotReloadPath) {
|
||||
continue
|
||||
}
|
||||
if !pathExists(repoCopyPath) {
|
||||
continue
|
||||
}
|
||||
if err := writeHotReloadScript(repoCopyPath); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", entry.Name(), err)
|
||||
}
|
||||
updated = append(updated, entry.Name())
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func detectDefaultBaseBranch(repoRoot string) string {
|
||||
for _, candidate := range []string{"develop", "main", "master"} {
|
||||
if remoteExists(repoRoot, "origin/"+candidate) || localExists(repoRoot, candidate) {
|
||||
|
|
@ -2768,10 +3173,6 @@ func loadRegistry() (*registry, error) {
|
|||
if decodeErr := dec.Decode(fallback); decodeErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
trailing := strings.TrimSpace(string(data[int(dec.InputOffset()):]))
|
||||
if trailing == "" || strings.Trim(trailing, "}") != "" {
|
||||
return nil, err
|
||||
}
|
||||
reg = fallback
|
||||
_ = saveRegistry(reg)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const (
|
|||
paletteModeDevices
|
||||
paletteModeStatusRight
|
||||
paletteModeTracker
|
||||
paletteModeGoals
|
||||
)
|
||||
|
||||
type palettePromptField int
|
||||
|
|
@ -51,6 +52,8 @@ const (
|
|||
paletteActionOpenTodos
|
||||
paletteActionOpenDevices
|
||||
paletteActionOpenTracker
|
||||
paletteActionOpenGoals
|
||||
paletteActionOpenScratch
|
||||
)
|
||||
|
||||
type paletteAction struct {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ type paletteRuntime struct {
|
|||
currentSessionName string
|
||||
currentWindowName string
|
||||
mainRepoRoot string
|
||||
startMode paletteMode
|
||||
}
|
||||
|
||||
type paletteModel struct {
|
||||
|
|
@ -50,6 +51,7 @@ type paletteModel struct {
|
|||
actions []paletteAction
|
||||
openedAt time.Time
|
||||
quickSecondaryEscCloses bool
|
||||
singlePanelMode bool
|
||||
width int
|
||||
height int
|
||||
result paletteResult
|
||||
|
|
@ -58,6 +60,7 @@ type paletteModel struct {
|
|||
devices *devicePanelModel
|
||||
status *statusRightPanelModel
|
||||
tracker *trackerPanelModel
|
||||
goals *goalPanelModel
|
||||
}
|
||||
|
||||
type paletteStyles struct {
|
||||
|
|
@ -110,7 +113,7 @@ func runBubbleTeaPalette(args []string) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state := paletteUIState{Mode: paletteModeList, Message: runtime.startupMessage}
|
||||
state := paletteUIState{Mode: runtime.startMode, Message: runtime.startupMessage}
|
||||
for {
|
||||
model := newPaletteModel(runtime, state)
|
||||
finalModel, err := tea.NewProgram(model).Run()
|
||||
|
|
@ -169,11 +172,13 @@ func loadPaletteRuntime(args []string) (*paletteRuntime, error) {
|
|||
var currentPath string
|
||||
var currentSessionName string
|
||||
var currentWindowName string
|
||||
var modeFlag string
|
||||
fs.StringVar(&windowID, "window", "", "window id")
|
||||
fs.StringVar(&agentID, "agent-id", "", "agent id")
|
||||
fs.StringVar(¤tPath, "path", "", "current pane path")
|
||||
fs.StringVar(¤tSessionName, "session-name", "", "current session name")
|
||||
fs.StringVar(¤tWindowName, "window-name", "", "current window name")
|
||||
fs.StringVar(&modeFlag, "mode", "", "initial panel mode (goals, tracker, todos, activity)")
|
||||
fs.SetOutput(nil)
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -186,6 +191,18 @@ func loadPaletteRuntime(args []string) (*paletteRuntime, error) {
|
|||
currentSessionName: firstNonEmpty(currentSessionName, os.Getenv("AGENT_PALETTE_SESSION_NAME")),
|
||||
currentWindowName: firstNonEmpty(currentWindowName, os.Getenv("AGENT_PALETTE_WINDOW_NAME")),
|
||||
}
|
||||
switch strings.ToLower(modeFlag) {
|
||||
case "goals":
|
||||
runtime.startMode = paletteModeGoals
|
||||
case "tracker":
|
||||
runtime.startMode = paletteModeTracker
|
||||
case "todos":
|
||||
runtime.startMode = paletteModeTodos
|
||||
case "activity":
|
||||
runtime.startMode = paletteModeActivity
|
||||
default:
|
||||
runtime.startMode = paletteModeList
|
||||
}
|
||||
logPaletteLaunchIfMalformed(runtime)
|
||||
if looksLikeTmuxFormatLiteral(runtime.agentID) {
|
||||
runtime.agentID = ""
|
||||
|
|
@ -351,6 +368,20 @@ func (r *paletteRuntime) buildActions() []paletteAction {
|
|||
})
|
||||
}
|
||||
actions = append(actions,
|
||||
paletteAction{
|
||||
Section: "System",
|
||||
Title: "Scratch terminal",
|
||||
Subtitle: "Open the persistent scratch shell",
|
||||
Keywords: []string{"scratch", "terminal", "shell", "popup", "tmux"},
|
||||
Kind: paletteActionOpenScratch,
|
||||
},
|
||||
paletteAction{
|
||||
Section: "System",
|
||||
Title: "Goals",
|
||||
Subtitle: "Goals, threads and todos",
|
||||
Keywords: []string{"goal", "goals", "thread", "threads", "tracker", "tasks", "status"},
|
||||
Kind: paletteActionOpenGoals,
|
||||
},
|
||||
paletteAction{
|
||||
Section: "System",
|
||||
Title: "Tracker",
|
||||
|
|
@ -582,11 +613,22 @@ func (r *paletteRuntime) execute(result paletteResult) (bool, string, error) {
|
|||
return false, "", nil
|
||||
case paletteActionReloadTmuxConfig:
|
||||
return false, "", paletteTmuxRunner("source-file", os.Getenv("HOME")+"/.config/.tmux.conf")
|
||||
case paletteActionOpenScratch:
|
||||
return false, "", launchScratchTerminalFromPalette(r.currentPath)
|
||||
default:
|
||||
return false, "", nil
|
||||
}
|
||||
}
|
||||
|
||||
func launchScratchTerminalFromPalette(currentPath string) error {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := fmt.Sprintf("sleep 0.1; %s tmux scratch --path %s", shellQuote(exe), shellQuote(currentPath))
|
||||
return runTmux("run-shell", "-b", cmd)
|
||||
}
|
||||
|
||||
func statusRightModuleLabel(module string) string {
|
||||
switch module {
|
||||
case statusRightModuleCPU:
|
||||
|
|
@ -653,7 +695,7 @@ func newPaletteModel(runtime *paletteRuntime, state paletteUIState) *paletteMode
|
|||
if len(state.PromptDevices) > 0 {
|
||||
state.PromptDeviceIndex = clampInt(state.PromptDeviceIndex, 0, len(state.PromptDevices)-1)
|
||||
}
|
||||
model := &paletteModel{runtime: runtime, state: state, actions: runtime.buildActions(), openedAt: time.Now()}
|
||||
model := &paletteModel{runtime: runtime, state: state, actions: runtime.buildActions(), openedAt: time.Now(), singlePanelMode: runtime.startMode != paletteModeList}
|
||||
if state.Mode == paletteModeTodos {
|
||||
_ = model.openTodosPanel()
|
||||
}
|
||||
|
|
@ -669,10 +711,22 @@ func newPaletteModel(runtime *paletteRuntime, state paletteUIState) *paletteMode
|
|||
if state.Mode == paletteModeTracker {
|
||||
_, _ = model.openTrackerPanel()
|
||||
}
|
||||
if state.Mode == paletteModeGoals {
|
||||
_, _ = model.openGoalsPanel()
|
||||
}
|
||||
return model
|
||||
}
|
||||
|
||||
func (m *paletteModel) Init() tea.Cmd {
|
||||
if m.goals != nil {
|
||||
return goalPanelTickCmd()
|
||||
}
|
||||
if m.tracker != nil {
|
||||
return trackerPanelTickCmd()
|
||||
}
|
||||
if m.activity != nil {
|
||||
return activityTickCmd()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -791,6 +845,24 @@ func (m *paletteModel) openTrackerPanel() (tea.Cmd, error) {
|
|||
return m.tracker.activate(), nil
|
||||
}
|
||||
|
||||
func (m *paletteModel) openGoalsPanel() (tea.Cmd, error) {
|
||||
m.noteSecondaryPageOpen()
|
||||
if m.goals == nil {
|
||||
m.goals = newGoalPanelModel(m.runtime)
|
||||
} else {
|
||||
m.goals.runtime = m.runtime
|
||||
m.goals.requestBack = false
|
||||
m.goals.requestClose = false
|
||||
}
|
||||
m.goals.width = m.width
|
||||
m.goals.height = m.height
|
||||
m.goals.showAltHints = false
|
||||
m.state.Mode = paletteModeGoals
|
||||
m.state.Message = ""
|
||||
m.state.ShowAltHints = false
|
||||
return m.goals.activate(), nil
|
||||
}
|
||||
|
||||
func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
|
|
@ -808,12 +880,16 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.tracker.width = msg.Width
|
||||
m.tracker.height = msg.Height
|
||||
}
|
||||
if m.goals != nil {
|
||||
m.goals.width = msg.Width
|
||||
m.goals.height = msg.Height
|
||||
}
|
||||
if m.status != nil {
|
||||
m.status.width = msg.Width
|
||||
m.status.height = msg.Height
|
||||
}
|
||||
case tea.KeyMsg:
|
||||
if m.state.Mode != paletteModeActivity && m.state.Mode != paletteModeTodos && m.state.Mode != paletteModeDevices && m.state.Mode != paletteModeStatusRight && m.state.Mode != paletteModeTracker {
|
||||
if m.state.Mode != paletteModeActivity && m.state.Mode != paletteModeTodos && m.state.Mode != paletteModeDevices && m.state.Mode != paletteModeStatusRight && m.state.Mode != paletteModeTracker && m.state.Mode != paletteModeGoals {
|
||||
if isAltFooterToggleKey(msg) {
|
||||
m.state.ShowAltHints = !m.state.ShowAltHints
|
||||
return m, nil
|
||||
|
|
@ -843,6 +919,10 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m.closePalette()
|
||||
case paletteModeTracker:
|
||||
return m.closePalette()
|
||||
case paletteModeGoals:
|
||||
if m.goals != nil && m.goals.mode == goalModeList {
|
||||
return m.closePalette()
|
||||
}
|
||||
case paletteModeSnippets:
|
||||
return m.closePalette()
|
||||
}
|
||||
|
|
@ -943,6 +1023,10 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m, tea.Quit
|
||||
}
|
||||
if m.tracker.requestBack {
|
||||
if m.singlePanelMode {
|
||||
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.tracker.requestBack = false
|
||||
m.state.Mode = paletteModeList
|
||||
m.state.Message = m.tracker.currentStatus()
|
||||
|
|
@ -950,6 +1034,36 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
return m, cmd
|
||||
}
|
||||
if m.state.Mode == paletteModeGoals {
|
||||
if m.goals == nil {
|
||||
cmd, err := m.openGoalsPanel()
|
||||
if err != nil {
|
||||
m.state.Mode = paletteModeList
|
||||
m.state.Message = err.Error()
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
model, cmd := m.goals.Update(msg)
|
||||
if updated, ok := model.(*goalPanelModel); ok {
|
||||
m.goals = updated
|
||||
}
|
||||
if m.goals.requestClose {
|
||||
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
||||
return m, tea.Quit
|
||||
}
|
||||
if m.goals.requestBack {
|
||||
if m.singlePanelMode {
|
||||
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
||||
return m, tea.Quit
|
||||
}
|
||||
m.goals.requestBack = false
|
||||
m.state.Mode = paletteModeList
|
||||
m.state.Message = m.goals.currentStatus()
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
switch m.state.Mode {
|
||||
case paletteModePrompt:
|
||||
return m.updatePrompt(key)
|
||||
|
|
@ -1030,6 +1144,23 @@ func (m *paletteModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
return m, cmd
|
||||
}
|
||||
if m.state.Mode == paletteModeGoals && m.goals != nil {
|
||||
model, cmd := m.goals.Update(msg)
|
||||
if updated, ok := model.(*goalPanelModel); ok {
|
||||
m.goals = updated
|
||||
}
|
||||
if m.goals.requestClose {
|
||||
m.result = paletteResult{Kind: paletteResultClose, State: m.state}
|
||||
return m, tea.Quit
|
||||
}
|
||||
if m.goals.requestBack {
|
||||
m.goals.requestBack = false
|
||||
m.state.Mode = paletteModeList
|
||||
m.state.Message = m.goals.currentStatus()
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
|
@ -1051,6 +1182,14 @@ func (m *paletteModel) updateList(key string) (tea.Model, tea.Cmd) {
|
|||
return m, nil
|
||||
}
|
||||
if key == "alt+r" {
|
||||
cmd, err := m.openGoalsPanel()
|
||||
if err != nil {
|
||||
m.state.Message = err.Error()
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
if key == "alt+d" {
|
||||
cmd, err := m.openTrackerPanel()
|
||||
if err != nil {
|
||||
m.state.Message = err.Error()
|
||||
|
|
@ -1145,6 +1284,13 @@ func (m *paletteModel) selectAction(action paletteAction) (tea.Model, tea.Cmd) {
|
|||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
case paletteActionOpenGoals:
|
||||
cmd, err := m.openGoalsPanel()
|
||||
if err != nil {
|
||||
m.state.Message = err.Error()
|
||||
return m, nil
|
||||
}
|
||||
return m, cmd
|
||||
case paletteActionOpenTodos:
|
||||
if err := m.openTodosPanel(); err != nil {
|
||||
m.state.Message = err.Error()
|
||||
|
|
@ -1496,6 +1642,14 @@ func (m *paletteModel) View() string {
|
|||
}
|
||||
return styles.muted.Render("Tracker unavailable")
|
||||
}
|
||||
if m.state.Mode == paletteModeGoals {
|
||||
if m.goals != nil {
|
||||
m.goals.width = width
|
||||
m.goals.height = height
|
||||
return m.goals.View()
|
||||
}
|
||||
return styles.muted.Render("Goals unavailable")
|
||||
}
|
||||
return m.renderListView(styles, width, height)
|
||||
}
|
||||
|
||||
|
|
@ -2199,9 +2353,9 @@ func renderPaletteFooter(styles paletteStyles, width int, message string, showAl
|
|||
{{"Enter", "run"}, {"Esc", "close"}, {footerHintToggleKey, "more"}},
|
||||
},
|
||||
[][][2]string{
|
||||
{{"Alt-U/E", "move"}, {"Alt-I", "run"}, {"Alt-C", "create"}, {"Alt-R", "tracker"}, {"Alt-A", "activity"}, {"Alt-P", "snippets"}, {"Alt-T", "todos"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
|
||||
{{"Alt-C", "create"}, {"Alt-R", "tracker"}, {"Alt-A", "activity"}, {"Alt-T", "todos"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
|
||||
{{"Alt-C", "create"}, {"Alt-R", "tracker"}, {"Alt-S", "close"}},
|
||||
{{"Alt-U/E", "move"}, {"Alt-I", "run"}, {"Alt-C", "create"}, {"Alt-R", "goals"}, {"Alt-D", "tracker"}, {"Alt-A", "activity"}, {"Alt-P", "snippets"}, {"Alt-T", "todos"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
|
||||
{{"Alt-C", "create"}, {"Alt-R", "goals"}, {"Alt-D", "tracker"}, {"Alt-A", "activity"}, {"Alt-T", "todos"}, {"Alt-S", "close"}, {footerHintToggleKey, "hide"}},
|
||||
{{"Alt-C", "create"}, {"Alt-R", "goals"}, {"Alt-D", "tracker"}, {"Alt-S", "close"}},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ const (
|
|||
statusRightModuleTodos = "todos"
|
||||
statusRightModuleFlashMoe = "flash_moe"
|
||||
statusRightModuleHost = "host"
|
||||
statusRightModuleGoal = "goal"
|
||||
)
|
||||
|
||||
const (
|
||||
|
|
@ -37,6 +38,7 @@ const (
|
|||
statusIconAgent = ""
|
||||
statusIconTodos = ""
|
||||
statusIconFlashMoe = ""
|
||||
statusIconGoal = "⌖"
|
||||
)
|
||||
|
||||
func statusRightModules() []string {
|
||||
|
|
@ -50,6 +52,7 @@ func statusRightModules() []string {
|
|||
statusRightModuleTodoPreview,
|
||||
statusRightModuleFlashMoe,
|
||||
statusRightModuleHost,
|
||||
statusRightModuleGoal,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -201,6 +204,11 @@ func renderTmuxRightStatus(args tmuxRightStatusArgs) string {
|
|||
segments = append(segments, statusSegment{FG: "#1d1f21", BG: "#cc6666", Text: label, Bold: true})
|
||||
}
|
||||
}
|
||||
if statusRightModuleEnabled(statusRightModuleGoal) {
|
||||
if label := loadGoalStatusLabel(args.WindowID); label != "" {
|
||||
segments = append(segments, statusSegment{FG: "#1d1f21", BG: "#a3be8c", Text: label, Bold: true})
|
||||
}
|
||||
}
|
||||
if statusRightModuleEnabled(statusRightModuleFlashMoe) {
|
||||
if segment, ok := loadFlashMoeStatusSegment(); ok {
|
||||
segments = append(segments, segment)
|
||||
|
|
@ -532,6 +540,48 @@ func loadAgentStatusLabel(windowID string) string {
|
|||
return fmt.Sprintf(" %s %s ", statusIconAgent, device)
|
||||
}
|
||||
|
||||
// loadGoalStatusLabel renders the focused window's goal path + thread name.
|
||||
func loadGoalStatusLabel(windowID string) string {
|
||||
windowID = strings.TrimSpace(windowID)
|
||||
if windowID == "" {
|
||||
return ""
|
||||
}
|
||||
store, err := loadGoalStore()
|
||||
if err != nil || store == nil {
|
||||
return ""
|
||||
}
|
||||
thread := findThreadByWindow(store, windowID)
|
||||
if thread == nil {
|
||||
return ""
|
||||
}
|
||||
path := goalPathTitles(store, thread.GoalID)
|
||||
if len(path) == 0 {
|
||||
return ""
|
||||
}
|
||||
label := strings.Join(path, " › ")
|
||||
return fmt.Sprintf(" %s %s ", statusIconGoal, truncate(label, 40))
|
||||
}
|
||||
|
||||
// goalPathTitles returns ancestor goal titles from root to the given goal.
|
||||
func goalPathTitles(store *GoalStore, goalID string) []string {
|
||||
goalID = strings.TrimSpace(goalID)
|
||||
if goalID == "" || store == nil {
|
||||
return nil
|
||||
}
|
||||
var titles []string
|
||||
seen := map[string]bool{}
|
||||
for goalID != "" && !seen[goalID] {
|
||||
g, ok := store.Goals[goalID]
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
seen[goalID] = true
|
||||
titles = append([]string{strings.TrimSpace(g.Title)}, titles...)
|
||||
goalID = strings.TrimSpace(g.ParentID)
|
||||
}
|
||||
return titles
|
||||
}
|
||||
|
||||
func refreshMemoryUsageCache() {
|
||||
script := statusMemoryCacheRefreshScript()
|
||||
if strings.TrimSpace(script) == "" || !fileExists(script) {
|
||||
|
|
@ -690,7 +740,7 @@ func loadHostStatusLabel() string {
|
|||
|
||||
func defaultStatusRightModuleEnabled(module string) bool {
|
||||
switch module {
|
||||
case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleAgent, statusRightModuleTodoPreview, statusRightModuleTodos, statusRightModuleFlashMoe, statusRightModuleHost:
|
||||
case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleAgent, statusRightModuleTodoPreview, statusRightModuleTodos, statusRightModuleFlashMoe, statusRightModuleHost, statusRightModuleGoal:
|
||||
return true
|
||||
case statusRightModuleMemoryTotals:
|
||||
return true
|
||||
|
|
@ -701,7 +751,7 @@ func defaultStatusRightModuleEnabled(module string) bool {
|
|||
|
||||
func isValidStatusRightModule(module string) bool {
|
||||
switch module {
|
||||
case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleMemoryTotals, statusRightModuleAgent, statusRightModuleTodoPreview, statusRightModuleTodos, statusRightModuleFlashMoe, statusRightModuleHost:
|
||||
case statusRightModuleCPU, statusRightModuleNetwork, statusRightModuleMemory, statusRightModuleMemoryTotals, statusRightModuleAgent, statusRightModuleTodoPreview, statusRightModuleTodos, statusRightModuleFlashMoe, statusRightModuleHost, statusRightModuleGoal:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
|
|
@ -739,6 +789,8 @@ func (cfg statusRightConfig) moduleEnabled(module string) bool {
|
|||
return derefBool(cfg.FlashMoe, defaultStatusRightModuleEnabled(module))
|
||||
case statusRightModuleHost:
|
||||
return derefBool(cfg.Host, defaultStatusRightModuleEnabled(module))
|
||||
case statusRightModuleGoal:
|
||||
return derefBool(cfg.Goal, defaultStatusRightModuleEnabled(module))
|
||||
default:
|
||||
return false
|
||||
}
|
||||
|
|
@ -784,6 +836,8 @@ func (cfg *statusRightConfig) setModuleEnabled(module string, enabled bool) {
|
|||
cfg.FlashMoe = value
|
||||
case statusRightModuleHost:
|
||||
cfg.Host = value
|
||||
case statusRightModuleGoal:
|
||||
cfg.Goal = value
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -791,7 +845,7 @@ func (cfg *statusRightConfig) isDefault() bool {
|
|||
if cfg == nil {
|
||||
return true
|
||||
}
|
||||
return cfg.CPU == nil && cfg.Network == nil && cfg.Memory == nil && cfg.MemoryTotals == nil && cfg.Agent == nil && cfg.TodoPreview == nil && cfg.Todos == nil && cfg.FlashMoe == nil && cfg.Host == nil
|
||||
return cfg.CPU == nil && cfg.Network == nil && cfg.Memory == nil && cfg.MemoryTotals == nil && cfg.Agent == nil && cfg.TodoPreview == nil && cfg.Todos == nil && cfg.FlashMoe == nil && cfg.Host == nil && cfg.Goal == nil
|
||||
}
|
||||
|
||||
func derefBool(value *bool, fallback bool) bool {
|
||||
|
|
|
|||
|
|
@ -30,10 +30,9 @@ type tmuxTodoItem struct {
|
|||
}
|
||||
|
||||
type tmuxTodoStore struct {
|
||||
Version int `json:"version"`
|
||||
Global []tmuxTodoItem `json:"global,omitempty"`
|
||||
Sessions map[string][]tmuxTodoItem `json:"sessions,omitempty"`
|
||||
Windows map[string][]tmuxTodoItem `json:"windows,omitempty"`
|
||||
Version int `json:"version"`
|
||||
Global []tmuxTodoItem `json:"global,omitempty"`
|
||||
Windows map[string][]tmuxTodoItem `json:"windows,omitempty"`
|
||||
}
|
||||
|
||||
type tmuxTodoListFile struct {
|
||||
|
|
@ -78,20 +77,12 @@ func normalizeTmuxTodoStore(store *tmuxTodoStore) *tmuxTodoStore {
|
|||
if store.Global == nil {
|
||||
store.Global = []tmuxTodoItem{}
|
||||
}
|
||||
if store.Sessions == nil {
|
||||
store.Sessions = map[string][]tmuxTodoItem{}
|
||||
}
|
||||
if store.Windows == nil {
|
||||
store.Windows = map[string][]tmuxTodoItem{}
|
||||
}
|
||||
for i := range store.Global {
|
||||
store.Global[i].Priority = normalizeTodoPriority(store.Global[i].Priority)
|
||||
}
|
||||
for key := range store.Sessions {
|
||||
for i := range store.Sessions[key] {
|
||||
store.Sessions[key][i].Priority = normalizeTodoPriority(store.Sessions[key][i].Priority)
|
||||
}
|
||||
}
|
||||
for key := range store.Windows {
|
||||
for i := range store.Windows[key] {
|
||||
store.Windows[key][i].Priority = normalizeTodoPriority(store.Windows[key][i].Priority)
|
||||
|
|
@ -143,8 +134,6 @@ func bootstrapTmuxTodoStore() (*tmuxTodoStore, error) {
|
|||
func todoItemsForScope(store *tmuxTodoStore, scope todoScope, scopeID string) []tmuxTodoItem {
|
||||
store = normalizeTmuxTodoStore(store)
|
||||
switch scope {
|
||||
case todoScopeSession:
|
||||
return store.Sessions[scopeID]
|
||||
case todoScopeWindow:
|
||||
return store.Windows[scopeID]
|
||||
default:
|
||||
|
|
@ -155,8 +144,6 @@ func todoItemsForScope(store *tmuxTodoStore, scope todoScope, scopeID string) []
|
|||
func setTodoItemsForScope(store *tmuxTodoStore, scope todoScope, scopeID string, items []tmuxTodoItem) {
|
||||
store = normalizeTmuxTodoStore(store)
|
||||
switch scope {
|
||||
case todoScopeSession:
|
||||
store.Sessions[scopeID] = items
|
||||
case todoScopeWindow:
|
||||
store.Windows[scopeID] = items
|
||||
default:
|
||||
|
|
@ -203,20 +190,12 @@ func importLegacyYamlTodos(store *tmuxTodoStore) error {
|
|||
if err := yaml.Unmarshal(data, &list); err != nil {
|
||||
continue
|
||||
}
|
||||
scope := todoScopeGlobal
|
||||
scopeID := "global"
|
||||
switch {
|
||||
case name == "global.yaml":
|
||||
scope = todoScopeGlobal
|
||||
case strings.HasPrefix(name, "session_") && strings.HasSuffix(name, ".yaml"):
|
||||
scope = todoScopeSession
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(name, "session_"), ".yaml")
|
||||
if strings.HasPrefix(id, "_") {
|
||||
scopeID = "$" + strings.TrimPrefix(id, "_")
|
||||
} else {
|
||||
scopeID = id
|
||||
}
|
||||
case strings.HasPrefix(name, "window_") && strings.HasSuffix(name, ".yaml"):
|
||||
scope := todoScopeGlobal
|
||||
scopeID := "global"
|
||||
switch {
|
||||
case name == "global.yaml":
|
||||
scope = todoScopeGlobal
|
||||
case strings.HasPrefix(name, "window_") && strings.HasSuffix(name, ".yaml"):
|
||||
scope = todoScopeWindow
|
||||
id := strings.TrimSuffix(strings.TrimPrefix(name, "window_"), ".yaml")
|
||||
if strings.HasPrefix(id, "_") {
|
||||
|
|
@ -252,25 +231,6 @@ func collectAllTmuxTodos(currentSessionID, currentWindowID string) []tmuxTodoEnt
|
|||
ItemIndex: idx,
|
||||
})
|
||||
}
|
||||
sessionIDs := make([]string, 0, len(store.Sessions))
|
||||
for id := range store.Sessions {
|
||||
sessionIDs = append(sessionIDs, id)
|
||||
}
|
||||
sort.Strings(sessionIDs)
|
||||
for _, id := range sessionIDs {
|
||||
for idx, item := range store.Sessions[id] {
|
||||
entries = append(entries, tmuxTodoEntry{
|
||||
Title: item.Title,
|
||||
Done: item.Done,
|
||||
Priority: item.Priority,
|
||||
Scope: todoScopeSession,
|
||||
ScopeID: id,
|
||||
ScopeName: "Session",
|
||||
IsCurrent: strings.TrimSpace(id) == strings.TrimSpace(currentSessionID),
|
||||
ItemIndex: idx,
|
||||
})
|
||||
}
|
||||
}
|
||||
windowIDs := make([]string, 0, len(store.Windows))
|
||||
for id := range store.Windows {
|
||||
windowIDs = append(windowIDs, id)
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ func runTrackerCommand(args []string) error {
|
|||
}
|
||||
command := strings.TrimSpace(rest[0])
|
||||
switch command {
|
||||
case "start_task", "finish_task", "update_task", "acknowledge", "delete_task", "notify":
|
||||
case "start_task", "finish_task", "update_task", "update_phase", "acknowledge", "delete_task", "notify":
|
||||
ctx, err := resolveTrackerContext(env.Session, env.SessionID, env.Window, env.WindowID, env.Pane)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -601,6 +601,7 @@ func sendTrackerCommand(command string, env *ipc.Envelope) error {
|
|||
request.Pane = strings.TrimSpace(env.Pane)
|
||||
request.Summary = strings.TrimSpace(env.Summary)
|
||||
request.Message = strings.TrimSpace(env.Message)
|
||||
request.Phase = strings.TrimSpace(env.Phase)
|
||||
}
|
||||
enc := json.NewEncoder(conn)
|
||||
if err := enc.Encode(&request); err != nil {
|
||||
|
|
|
|||
131
agent-tracker/cmd/tracker-client/main.go
Normal file
131
agent-tracker/cmd/tracker-client/main.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -34,6 +34,7 @@ type taskRecord struct {
|
|||
Pane string
|
||||
Summary string
|
||||
CompletionNote string
|
||||
Phase string
|
||||
StartedAt time.Time
|
||||
CompletedAt *time.Time
|
||||
Status string
|
||||
|
|
@ -234,6 +235,20 @@ func (s *server) handleCommand(env ipc.Envelope) error {
|
|||
s.broadcastStateAsync()
|
||||
s.statusRefreshAsync()
|
||||
return nil
|
||||
case "update_phase":
|
||||
target, err := requireSessionWindow(env)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
phase := strings.TrimSpace(env.Phase)
|
||||
if phase == "" {
|
||||
return fmt.Errorf("update_phase requires phase")
|
||||
}
|
||||
if err := s.updatePhase(target, phase); err != nil {
|
||||
return err
|
||||
}
|
||||
s.broadcastStateAsync()
|
||||
return nil
|
||||
case "notifications_toggle":
|
||||
enabled, err := s.toggleNotifications()
|
||||
if err != nil {
|
||||
|
|
@ -309,6 +324,7 @@ func (s *server) startTask(target tmuxTarget, summary string) error {
|
|||
t.Status = statusInProgress
|
||||
t.CompletedAt = nil
|
||||
t.CompletionNote = ""
|
||||
t.Phase = ""
|
||||
t.Acknowledged = true
|
||||
return nil
|
||||
}
|
||||
|
|
@ -347,6 +363,21 @@ func (s *server) updateTaskSummary(target tmuxTarget, summary string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *server) updatePhase(target tmuxTarget, phase string) error {
|
||||
if target.SessionID == "" || target.WindowID == "" {
|
||||
return fmt.Errorf("cannot update phase: missing session or window ID")
|
||||
}
|
||||
target = normalizeTargetNames(target)
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
key := taskKey(target.SessionID, target.WindowID, target.PaneID)
|
||||
if t, ok := s.tasks[key]; ok {
|
||||
t.Phase = phase
|
||||
mergeTaskNamesFromTarget(t, target)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *server) finishTask(target tmuxTarget, note string) (bool, error) {
|
||||
if target.SessionID == "" || target.WindowID == "" {
|
||||
return false, nil // silently ignore - pane likely died
|
||||
|
|
@ -377,6 +408,7 @@ func (s *server) finishTask(target tmuxTarget, note string) (bool, error) {
|
|||
mergeTaskNamesFromTarget(t, target)
|
||||
t.Status = statusCompleted
|
||||
t.CompletedAt = &now
|
||||
t.Phase = ""
|
||||
if note != "" {
|
||||
t.CompletionNote = note
|
||||
}
|
||||
|
|
@ -722,6 +754,7 @@ func (s *server) buildStateEnvelope() *ipc.Envelope {
|
|||
Status: t.Status,
|
||||
Summary: t.Summary,
|
||||
CompletionNote: t.CompletionNote,
|
||||
Phase: t.Phase,
|
||||
StartedAt: started,
|
||||
CompletedAt: completed,
|
||||
DurationSeconds: duration.Seconds(),
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ type Envelope struct {
|
|||
Pane string `json:"pane,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
Tasks []Task `json:"tasks,omitempty"`
|
||||
}
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ type Task struct {
|
|||
Status string `json:"status"`
|
||||
Summary string `json:"summary"`
|
||||
CompletionNote string `json:"completion_note,omitempty"`
|
||||
Phase string `json:"phase,omitempty"`
|
||||
StartedAt string `json:"started_at,omitempty"`
|
||||
CompletedAt string `json:"completed_at,omitempty"`
|
||||
DurationSeconds float64 `json:"duration_seconds"`
|
||||
|
|
|
|||
20
agent-tracker/test/docker/Dockerfile
Normal file
20
agent-tracker/test/docker/Dockerfile
Normal 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"]
|
||||
184
agent-tracker/test/docker/capture_grid.py
Normal file
184
agent-tracker/test/docker/capture_grid.py
Normal 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))
|
||||
40
agent-tracker/test/docker/debug_grid.py
Normal file
40
agent-tracker/test/docker/debug_grid.py
Normal 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
|
||||
24
agent-tracker/test/docker/entrypoint.sh
Executable file
24
agent-tracker/test/docker/entrypoint.sh
Executable 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))
|
||||
37
agent-tracker/test/docker/fake-agent.sh
Executable file
37
agent-tracker/test/docker/fake-agent.sh
Executable 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"
|
||||
43
agent-tracker/test/docker/interactive-entrypoint.sh
Executable file
43
agent-tracker/test/docker/interactive-entrypoint.sh
Executable 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
|
||||
113
agent-tracker/test/docker/run-tests.sh
Executable file
113
agent-tracker/test/docker/run-tests.sh
Executable 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
|
||||
66
agent-tracker/test/docker/seed-data.sh
Executable file
66
agent-tracker/test/docker/seed-data.sh
Executable 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"
|
||||
153
agent-tracker/test/docker/test_goal_move_ue.py
Normal file
153
agent-tracker/test/docker/test_goal_move_ue.py
Normal 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())
|
||||
170
agent-tracker/test/docker/test_thread_demote_cursor.py
Normal file
170
agent-tracker/test/docker/test_thread_demote_cursor.py
Normal 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())
|
||||
217
agent-tracker/test/docker/visual_lib.py
Normal file
217
agent-tracker/test/docker/visual_lib.py
Normal 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
|
||||
1805
agent-tracker/test/docker/visual_tests.py
Normal file
1805
agent-tracker/test/docker/visual_tests.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -108,6 +108,7 @@ export const TrackerNotifyPlugin = async ({ client, directory, $ }) => {
|
|||
|
||||
let taskActive = false;
|
||||
let currentSessionID = null;
|
||||
let currentPhase = "";
|
||||
let lastUserMessage = "";
|
||||
let rootSessionID = loadPersistedPaneSessionID();
|
||||
let questionPending: boolean | null = null;
|
||||
|
|
@ -199,8 +200,22 @@ export const TrackerNotifyPlugin = async ({ client, directory, $ }) => {
|
|||
if (!(await trackerReady())) return;
|
||||
taskActive = true;
|
||||
currentSessionID = sessionID;
|
||||
currentPhase = "";
|
||||
const args = buildTrackerArgs();
|
||||
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) => {
|
||||
|
|
@ -208,6 +223,7 @@ export const TrackerNotifyPlugin = async ({ client, directory, $ }) => {
|
|||
if (!(await trackerReady())) return;
|
||||
taskActive = false;
|
||||
currentSessionID = null;
|
||||
currentPhase = "";
|
||||
const args = buildTrackerArgs();
|
||||
await $`${TRACKER_BIN} command ${args} -summary ${summary || "done"} finish_task`.nothrow();
|
||||
};
|
||||
|
|
@ -280,16 +296,20 @@ export const TrackerNotifyPlugin = async ({ client, directory, $ }) => {
|
|||
rootSessionID = input.sessionID;
|
||||
}
|
||||
await applyQuestionPending(true);
|
||||
await updatePhase("question");
|
||||
log("Question tool called:", {
|
||||
questions: output.args?.questions || "no questions",
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
} else {
|
||||
await updatePhase("tool");
|
||||
}
|
||||
},
|
||||
|
||||
event: async ({ event }) => {
|
||||
if (event?.type === "question.asked") {
|
||||
await applyQuestionPending(true);
|
||||
await updatePhase("question");
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -312,13 +332,15 @@ export const TrackerNotifyPlugin = async ({ client, directory, $ }) => {
|
|||
const part = event?.properties?.part;
|
||||
if (part?.type === "text" && part?.text && 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)) {
|
||||
const text = part.text?.trim();
|
||||
if (text && text.length > 0) {
|
||||
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.type === "busy" && !taskActive) {
|
||||
// Use captured message first, then fall back to API
|
||||
let text = lastUserMessage;
|
||||
if (!text) {
|
||||
text = await getLastMessageText(sessionID, "user");
|
||||
}
|
||||
await startTask(text || "working...", sessionID);
|
||||
await updatePhase("waiting");
|
||||
lastUserMessage = "";
|
||||
} else if (status.type === "idle" && taskActive) {
|
||||
if (currentSessionID && sessionID !== currentSessionID) return;
|
||||
|
|
|
|||
15
tmux/scripts/goal_pick_blocker.sh
Executable file
15
tmux/scripts/goal_pick_blocker.sh
Executable 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
18
tmux/scripts/goal_pick_goal.sh
Executable 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"
|
||||
|
|
@ -196,6 +196,20 @@ def main() -> int:
|
|||
if migrated_window_todos or migrated_session_todos:
|
||||
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 = []
|
||||
if changed_agents:
|
||||
summary.append(f"{changed_agents} agent windows")
|
||||
|
|
@ -203,6 +217,8 @@ def main() -> int:
|
|||
summary.append(f"{migrated_window_todos} window todos")
|
||||
if migrated_session_todos:
|
||||
summary.append(f"{migrated_session_todos} session todos")
|
||||
if migrated_threads:
|
||||
summary.append(f"{migrated_threads} goal threads")
|
||||
if summary:
|
||||
print("restored " + ", ".join(summary))
|
||||
return 0
|
||||
|
|
|
|||
|
|
@ -6,6 +6,20 @@ import sys
|
|||
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:
|
||||
kwargs = {
|
||||
"check": check,
|
||||
|
|
@ -31,6 +45,8 @@ def list_sessions() -> List[Dict[str, object]]:
|
|||
sessions = []
|
||||
for line in output.splitlines():
|
||||
session_id, name, created_str = line.split("\t")
|
||||
if is_hidden_session(name):
|
||||
continue
|
||||
created = int(created_str)
|
||||
match = re.match(r"^(\d+)-(.*)$", name)
|
||||
if match:
|
||||
|
|
|
|||
|
|
@ -6,8 +6,12 @@ if [[ -z "$index" || ! "$index" =~ ^[0-9]+$ ]]; then
|
|||
exit 0
|
||||
fi
|
||||
|
||||
# Get session matching index prefix (e.g., "1-" for index 1)
|
||||
target=$(tmux list-sessions -F '#{session_id} #{session_name}' 2>/dev/null | awk -v idx="$index" '$2 ~ "^"idx"-" {print $1; exit}')
|
||||
current_session=$(tmux display-message -p '#{session_name}' 2>/dev/null || true)
|
||||
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
|
||||
tmux switch-client -t "$target"
|
||||
|
|
|
|||
|
|
@ -53,6 +53,11 @@ if [[ -z "$sessions" ]]; then
|
|||
exit 0
|
||||
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
|
||||
|
||||
CACHE_FILE="/tmp/tmux-tracker-cache.json"
|
||||
|
|
@ -86,11 +91,12 @@ get_session_icon() {
|
|||
if [[ -n "$tracker_state" ]]; then
|
||||
local result
|
||||
result=$(echo "$tracker_state" | jq -r --arg sid "$sid" '
|
||||
.tasks // [] | .[] | select(.session_id == $sid) |
|
||||
if .status == "completed" and .acknowledged != true then "waiting"
|
||||
elif .status == "in_progress" then "in_progress"
|
||||
else empty end
|
||||
' 2>/dev/null | head -1 || true)
|
||||
.tasks // []
|
||||
| map(select(.session_id == $sid))
|
||||
| if any(.status == "completed" and .acknowledged != true) then "waiting"
|
||||
elif any(.status == "in_progress") then "in_progress"
|
||||
else empty end
|
||||
' 2>/dev/null || true)
|
||||
case "$result" in
|
||||
waiting) has_bell=1 ;;
|
||||
in_progress) has_watch=1 ;;
|
||||
|
|
|
|||
|
|
@ -12,11 +12,12 @@ state=$("$agent_bin" tracker state 2>/dev/null || true)
|
|||
|
||||
# Check for in_progress or unacknowledged completed tasks in this session
|
||||
result=$(echo "$state" | jq -r --arg sid "$session_id" '
|
||||
.tasks // [] | .[] | select(.session_id == $sid) |
|
||||
if .status == "in_progress" then "in_progress"
|
||||
elif .status == "completed" and .acknowledged != true then "waiting"
|
||||
else empty end
|
||||
' 2>/dev/null | head -1 || true)
|
||||
.tasks // []
|
||||
| map(select(.session_id == $sid))
|
||||
| if any(.status == "completed" and .acknowledged != true) then "waiting"
|
||||
elif any(.status == "in_progress") then "in_progress"
|
||||
else empty end
|
||||
' 2>/dev/null || true)
|
||||
|
||||
case "$result" in
|
||||
in_progress) printf '⏳' ;;
|
||||
|
|
|
|||
|
|
@ -25,11 +25,12 @@ if [[ -f "$CACHE_FILE" ]]; then
|
|||
state=$(cat "$CACHE_FILE" 2>/dev/null || true)
|
||||
if [[ -n "$state" ]]; then
|
||||
result=$(echo "$state" | jq -r --arg wid "$window_id" '
|
||||
.tasks // [] | .[] | select(.window_id == $wid) |
|
||||
if .status == "completed" and .acknowledged != true then "waiting"
|
||||
elif .status == "in_progress" then "in_progress"
|
||||
else empty end
|
||||
' 2>/dev/null | head -1 || true)
|
||||
.tasks // []
|
||||
| map(select(.window_id == $wid))
|
||||
| if any(.status == "completed" and .acknowledged != true) then "waiting"
|
||||
elif any(.status == "in_progress") then "in_progress"
|
||||
else empty end
|
||||
' 2>/dev/null || true)
|
||||
case "$result" in
|
||||
waiting) has_bell=1 ;;
|
||||
in_progress) has_watch=1 ;;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue