Skip to content

Commit 2b53ddf

Browse files
wbrezaCopilotjongio
authored
feat: plan indicator dot and preview pane for session plans (#22)
* feat: plan indicator dot and preview pane for session plans (#21) Add cyan dot indicator and plan preview for Copilot CLI plan.md files: - Data layer: ScanPlans() detects plan.md existence, ReadPlanContent() reads plan content with 64KB cap, path traversal protection - Styles: PlanIndicatorStyle (BrightCyan bold), IconPlan() filled dot - Session list: cyan plan dot rendered after attention dot in each row - Preview panel: plan view mode toggled with 'v' key, Esc to return - Integration: plan scan piggybacks on attention scan tick cycle, plan content loaded on session selection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: pin lipgloss to stable release Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: extract sessionIDPattern to internal/validate package Move the security-critical session ID regex to a shared package importable by both data and platform, eliminating the duplicate definition that could silently diverge. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: add plan filter to show only sessions with plans Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: add plan.md to demo sessions and screenshot captures Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add plan indicator to README, web docs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove dead ScanPlans function and align formatting - Remove unused ScanPlans() (deadcode finding) — ScanAllPlans() is the production caller - Remove associated tests (TestScanPlans, TestScanPlans_InvalidSessionID, TestScanPlans_NilStateDir) - Update ScanAllPlans doc comment to remove stale reference - Align struct field comments (gofmt) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add plan indicator and attention screenshots Add plan-indicator, plan-preview, plan-filter, and attention-filtered-interrupted screenshots across all 5 themes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Jon Gallant <2163001+jongio@users.noreply.github.com>
1 parent b7e49dd commit 2b53ddf

43 files changed

Lines changed: 834 additions & 23 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎README.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ Dispatch reads your local Copilot CLI session store and presents every past sess
2727
- **Four launch modes** (`Enter` / `t` / `w` / `e`) — in-place, new tab, new window, split pane (Windows Terminal) with per-session overrides
2828
- **Multi-session open** (`Space` / `O` / `a` / `d`) — select multiple sessions with Space, open all at once with O, select/deselect all with a/d. Ctrl+click and Shift+click for mouse selection
2929
- **Attention indicators** — colored dots showing real-time session status: waiting (purple), active (green), stale (yellow), interrupted (orange ⚡), idle (gray). Jump to next waiting session with `n`, resume interrupted sessions with `R`, filter by status with `!`
30+
- **Plan indicator** (`v` / `M`) — a dot next to sessions that have a `plan.md` file (`~/.copilot/session-state/{session-id}/plan.md`). Press `v` to view the plan in the preview pane, `M` to filter and show only sessions with plans
3031
- **Session hiding** (`h` / `H`) — hide sessions from the list, toggle visibility of hidden sessions, persistent state
3132
- **Session favorites** (`*` / `F`) — star sessions as favorites, filter to show only favorites, persistent state
3233
- **Settings panel** (`,`) — 10 fields: Yolo Mode, Agent, Model, Launch Mode, Pane Direction, Terminal, Shell, Custom Command, Theme, Crash Recovery
@@ -163,6 +164,7 @@ dispatch
163164
| `H` | Toggle visibility of hidden sessions |
164165
| `*` | Toggle favorite on current session |
165166
| `F` | Filter to show only favorites |
167+
| `M` | Filter to show only sessions with plans |
166168

167169
#### Search & Filter
168170

@@ -181,6 +183,7 @@ dispatch
181183
| `Tab` | Cycle grouping mode |
182184
| `p` | Toggle preview panel |
183185
| `P` | Cycle preview position (right → bottom → left → top) |
186+
| `v` | View plan in preview pane |
184187
| `PgUp` / `PgDn` | Scroll preview |
185188
| `r` | Refresh session store |
186189
| `,` | Open settings panel |

‎cmd/dispatch/demo.go‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ func setupDemo() (cleanup func(), err error) {
7979
return nil, err
8080
}
8181

82+
// Write plan.md files for a few sessions so the plan indicator dot
83+
// is visible in demo mode and screenshots.
84+
if err := createDemoPlanFiles(stateDir); err != nil {
85+
return nil, err
86+
}
87+
8288
_ = os.Setenv("DISPATCH_DB", tmpDB)
8389
_ = os.Setenv("DISPATCH_SESSION_STATE", stateDir)
8490

@@ -219,3 +225,36 @@ func createDemoSessionState(stateDir string) error {
219225

220226
return nil
221227
}
228+
229+
// demoPlanSessions lists session IDs that get a plan.md file in demo mode.
230+
// Chosen to overlap with interesting attention states (one waiting, one active,
231+
// one stale) so the dot indicator is clearly visible.
232+
var demoPlanSessions = []string{
233+
"fa800b7b-3a24-4e3b-9f2d-a414198b27ab", // Waiting (purple)
234+
"ses-026", // Active (green)
235+
"ses-003", // Stale (yellow)
236+
}
237+
238+
// createDemoPlanFiles writes minimal plan.md files into session-state
239+
// directories so the plan indicator dot appears in demo mode.
240+
func createDemoPlanFiles(stateDir string) error {
241+
planContent := `# Implementation Plan
242+
243+
## Tasks
244+
- [ ] Design API endpoints
245+
- [ ] Implement database schema
246+
- [x] Set up project structure
247+
`
248+
249+
for _, id := range demoPlanSessions {
250+
sessDir := filepath.Join(stateDir, id)
251+
if err := os.MkdirAll(sessDir, 0o700); err != nil {
252+
return err
253+
}
254+
planPath := filepath.Join(sessDir, "plan.md")
255+
if err := os.WriteFile(planPath, []byte(planContent), 0o600); err != nil {
256+
return err
257+
}
258+
}
259+
return nil
260+
}

‎go.mod‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
github.com/UserExistsError/conpty v0.1.4
77
github.com/charmbracelet/bubbles v1.0.0
88
github.com/charmbracelet/bubbletea v1.3.10
9+
github.com/charmbracelet/glamour v0.9.1
910
github.com/charmbracelet/lipgloss v1.1.0
1011
github.com/creack/pty v1.1.24
1112
github.com/github/copilot-sdk/go v0.1.32
@@ -16,28 +17,38 @@ require (
1617
)
1718

1819
require (
20+
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
1921
github.com/atotto/clipboard v0.1.4 // indirect
2022
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
23+
github.com/aymerick/douceur v0.2.0 // indirect
2124
github.com/charmbracelet/colorprofile v0.4.3 // indirect
2225
github.com/charmbracelet/x/ansi v0.11.6 // indirect
2326
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
2427
github.com/charmbracelet/x/term v0.2.2 // indirect
2528
github.com/clipperhouse/displaywidth v0.11.0 // indirect
2629
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
30+
github.com/dlclark/regexp2 v1.11.5 // indirect
2731
github.com/dustin/go-humanize v1.0.1 // indirect
2832
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
2933
github.com/google/jsonschema-go v0.4.2 // indirect
3034
github.com/google/uuid v1.6.0 // indirect
35+
github.com/gorilla/css v1.0.1 // indirect
3136
github.com/mattn/go-isatty v0.0.20 // indirect
3237
github.com/mattn/go-localereader v0.0.1 // indirect
3338
github.com/mattn/go-runewidth v0.0.21 // indirect
39+
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
3440
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
3541
github.com/muesli/cancelreader v0.2.2 // indirect
42+
github.com/muesli/reflow v0.3.0 // indirect
3643
github.com/ncruces/go-strftime v1.0.0 // indirect
3744
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
3845
github.com/rivo/uniseg v0.4.7 // indirect
3946
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
47+
github.com/yuin/goldmark v1.7.13 // indirect
48+
github.com/yuin/goldmark-emoji v1.0.6 // indirect
4049
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
50+
golang.org/x/net v0.38.0 // indirect
51+
golang.org/x/term v0.36.0 // indirect
4152
golang.org/x/text v0.35.0 // indirect
4253
modernc.org/libc v1.70.0 // indirect
4354
modernc.org/mathutil v1.7.1 // indirect

‎go.sum‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,35 @@
11
github.com/UserExistsError/conpty v0.1.4 h1:+3FhJhiqhyEJa+K5qaK3/w6w+sN3Nh9O9VbJyBS02to=
22
github.com/UserExistsError/conpty v0.1.4/go.mod h1:PDglKIkX3O/2xVk0MV9a6bCWxRmPVfxqZoTG/5sSd9I=
3+
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
4+
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
5+
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
6+
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
7+
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
8+
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
39
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
410
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
511
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
612
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
13+
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
14+
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
15+
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
16+
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
717
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
818
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
919
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
1020
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
1121
github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
1222
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
23+
github.com/charmbracelet/glamour v0.9.1 h1:11dEfiGP8q1BEqvGoIjivuc2rBk+5qEXdPtaQ2WoiCM=
24+
github.com/charmbracelet/glamour v0.9.1/go.mod h1:+SHvIS8qnwhgTpVMiXwn7OfGomSqff1cHBCI8jLOetk=
1325
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
1426
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
1527
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
1628
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
1729
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
1830
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
31+
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
32+
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
1933
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
2034
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
2135
github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
@@ -24,6 +38,8 @@ github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJ
2438
github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
2539
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
2640
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
41+
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
42+
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
2743
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
2844
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
2945
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
@@ -38,41 +54,60 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
3854
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
3955
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
4056
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
57+
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
58+
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
4159
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
4260
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
61+
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
62+
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
4363
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
4464
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
4565
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
4666
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
4767
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
4868
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
69+
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
4970
github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
5071
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
72+
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
73+
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
5174
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
5275
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
5376
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
5477
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
78+
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
79+
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
5580
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
5681
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
5782
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
5883
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
5984
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
6085
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
86+
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
87+
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
6188
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
6289
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
6390
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
6491
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
92+
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
93+
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
94+
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
95+
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
6596
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
6697
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
6798
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
6899
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
100+
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
101+
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
69102
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
70103
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
71104
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
72105
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
73106
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
74107
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
75108
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
109+
golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
110+
golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
76111
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
77112
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
78113
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=

‎internal/data/plans.go‎

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Package data — plans.go provides functions to detect and read Copilot CLI
2+
// plan.md files from session-state directories.
3+
package data
4+
5+
import (
6+
"io"
7+
"os"
8+
"path/filepath"
9+
"runtime"
10+
"strings"
11+
12+
"github.com/jongio/dispatch/internal/validate"
13+
)
14+
15+
// maxPlanFileSize caps the number of bytes read from a plan.md file to
16+
// prevent memory exhaustion from adversarially large files.
17+
const maxPlanFileSize = 64 * 1024 // 64 KB
18+
19+
// ScanAllPlans reads the session-state directory and returns a map of
20+
// session ID → true for every session that has a non-empty plan.md file.
21+
// It discovers sessions from the filesystem directly, matching the
22+
// pattern used by ScanAttention.
23+
func ScanAllPlans() map[string]bool {
24+
stateDir := sessionStatePath()
25+
if stateDir == "" {
26+
return nil
27+
}
28+
29+
entries, err := os.ReadDir(stateDir)
30+
if err != nil {
31+
return nil
32+
}
33+
34+
result := make(map[string]bool, len(entries)/4)
35+
for _, e := range entries {
36+
if !e.IsDir() {
37+
continue
38+
}
39+
id := e.Name()
40+
if !validate.SessionID(id) {
41+
continue
42+
}
43+
planPath := filepath.Join(stateDir, id, "plan.md")
44+
info, err := os.Lstat(planPath)
45+
if err != nil {
46+
continue
47+
}
48+
if info.Mode().IsRegular() && info.Size() > 0 {
49+
result[id] = true
50+
}
51+
}
52+
return result
53+
}
54+
55+
// ReadPlanContent reads the plan.md file for the given session ID.
56+
// Returns the plan content as a string, capped at maxPlanFileSize bytes.
57+
// Returns an empty string and an error if the file cannot be read or the
58+
// session ID is invalid.
59+
func ReadPlanContent(sessionID string) (string, error) {
60+
path, err := PlanFilePath(sessionID)
61+
if err != nil {
62+
return "", err
63+
}
64+
65+
// Use Lstat to reject symlinks before reading.
66+
info, err := os.Lstat(path)
67+
if err != nil {
68+
return "", err
69+
}
70+
if !info.Mode().IsRegular() {
71+
return "", &os.PathError{Op: "read", Path: path, Err: os.ErrInvalid}
72+
}
73+
74+
f, err := os.Open(path)
75+
if err != nil {
76+
return "", err
77+
}
78+
defer f.Close()
79+
80+
// Read up to maxPlanFileSize bytes using LimitReader to handle
81+
// short reads correctly (f.Read is not guaranteed to fill the buffer).
82+
content, err := io.ReadAll(io.LimitReader(f, maxPlanFileSize))
83+
if err != nil {
84+
return "", err
85+
}
86+
return string(content), nil
87+
}
88+
89+
// PlanFilePath returns the absolute path to the plan.md file for the
90+
// given session ID. Returns an error if the session ID is invalid or
91+
// the session-state directory cannot be resolved.
92+
func PlanFilePath(sessionID string) (string, error) {
93+
if !validate.SessionID(sessionID) {
94+
return "", &os.PathError{Op: "open", Path: sessionID, Err: os.ErrInvalid}
95+
}
96+
97+
stateDir := sessionStatePath()
98+
if stateDir == "" {
99+
return "", &os.PathError{Op: "open", Path: sessionID, Err: os.ErrNotExist}
100+
}
101+
102+
path := filepath.Join(stateDir, sessionID, "plan.md")
103+
104+
// Verify the resolved path is still under the session-state directory
105+
// to prevent path traversal via crafted session IDs like "a/../../../etc".
106+
cleaned := filepath.Clean(path)
107+
if runtime.GOOS == "windows" {
108+
if !strings.HasPrefix(strings.ToLower(cleaned), strings.ToLower(stateDir+string(filepath.Separator))) {
109+
return "", &os.PathError{Op: "open", Path: sessionID, Err: os.ErrPermission}
110+
}
111+
} else {
112+
if !strings.HasPrefix(cleaned, stateDir+string(filepath.Separator)) {
113+
return "", &os.PathError{Op: "open", Path: sessionID, Err: os.ErrPermission}
114+
}
115+
}
116+
117+
return path, nil
118+
}

0 commit comments

Comments
 (0)