Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ Dispatch reads your local Copilot CLI session store and presents every past sess
- **Session favorites** (`*`) — star sessions as favorites. Filter to show only favorites via the `!` status picker
- **Session tags** (`#`) — attach comma-separated tags to sessions and filter to a tag with the `tag:` search token
- **Session aliases** (`A`) — give a session a short, memorable alias and resume it from the CLI with `dispatch open <alias>` instead of the full session ID
- **Settings panel** (`,`) — 19 fields: Yolo Mode, Agent, Model, Launch Mode, Pane Direction, Terminal, Shell, Custom Command, Theme, Crash Recovery, Preview Position, Redact Secrets, Excluded Words, Auto Refresh, Notify On Waiting, and column toggles for Repo, Folder, Turns, and Host
- **Settings panel** (`,`) — 20 fields: Yolo Mode, Agent, Model, Launch Mode, Pane Direction, Terminal, Shell, Resume Session Command, New Session Command, Theme, Crash Recovery, Preview Position, Redact Secrets, Excluded Words, Auto Refresh, Notify On Waiting, and column toggles for Repo, Folder, Turns, and Host
- **Configurable list columns** (`,` settings) — choose which optional columns (repo, folder, turns, host) appear in the session list. Defaults show every column, and the session name and attention indicator are always visible
- **Shell picker** — auto-detects installed shells, modal picker when multiple available
- **5 built-in themes** — Dispatch Dark, Dispatch Light, Campbell, One Half Dark, One Half Light + custom via Windows Terminal JSON
Expand Down Expand Up @@ -706,7 +706,8 @@ dispatch config path # print the config file path
| `model` | string | `""` | Pass `--model <name>` to Copilot CLI |
| `launch_mode` | string | `"tab"` | How to open sessions: `in-place`, `tab`, `window`, `pane` |
| `pane_direction` | string | `"auto"` | Split direction for pane mode: `auto`, `right`, `down`, `left`, `up` (see note below) |
| `custom_command` | string | `""` | Custom launch command (`{sessionId}` is replaced) |
| `resume_session_command` | string | `""` | Custom resume command (`{sessionId}` is replaced). Defaults to `copilot --resume` |
| `new_session_command` | string | `""` | Command to launch new sessions (`{cwd}` is replaced). Defaults to `copilot` |
| `excluded_dirs` | array | `[]` | Directory paths to hide from session list |
| `excluded_words` | array | `[]` | Comma-separated words; sessions containing any word are hidden |
| `attention_threshold` | string | `"15m"` | Duration after which an inactive running session is marked stale |
Expand Down Expand Up @@ -767,7 +768,8 @@ The split starts in the session's working directory (`-c`) and runs the resume c
"model": "",
"launch_mode": "tab",
"pane_direction": "auto",
"custom_command": "",
"resume_session_command": "",
"new_session_command": "",
"excluded_dirs": [],
"theme": "auto",
"workspace_recovery": true,
Expand All @@ -779,12 +781,20 @@ The split starts in the session's working directory (`-c`) and runs the resume c
}
```

### Custom Command
### Resume Session Command

Set `custom_command` to replace the default Copilot CLI launch entirely. Use `{sessionId}` as the placeholder. When set, Agent, Model, and Yolo Mode fields are ignored.
Set `resume_session_command` to replace the default Copilot CLI resume command. Use `{sessionId}` as the placeholder for the session to resume. When set, Agent, Model, and Yolo Mode fields are ignored.

```json
"custom_command": "my-tool resume {sessionId}"
"resume_session_command": "my-tool resume {sessionId}"
```

### New Session Command

Set `new_session_command` to customize the command used when launching a brand new session from dispatch (the `+` keybinding). Use `{cwd}` as the placeholder for the working directory. When empty, defaults to `copilot`.

```json
"new_session_command": "copilot --agent workspace"
```

### Customizing Keybindings
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ The application may contact the following domains:

- **Configuration file** (platform config directory, e.g.
`~/.config/dispatch/config.json`): Treated as trusted user
input. If a `custom_command` is configured, it is executed as-is. Keep your
input. If a `resume_session_command` is configured, it is executed as-is. Keep your
config file permissions restricted to your user account.
- **Session data**: Read from the Copilot CLI SQLite database in read-only mode.
Malformed session data cannot cause code execution.
Expand Down
2 changes: 1 addition & 1 deletion cmd/dispatch/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func configFields() []configField {
config.LaunchModeInPlace, config.LaunchModeTab, config.LaunchModeWindow, config.LaunchModePane),
enumField("pane_direction", func(c *config.Config) *string { return &c.PaneDirection },
config.PaneDirectionAuto, config.PaneDirectionRight, config.PaneDirectionDown, config.PaneDirectionLeft, config.PaneDirectionUp),
strField("custom_command", func(c *config.Config) *string { return &c.CustomCommand }),
strField("resume_session_command", func(c *config.Config) *string { return &c.ResumeSessionCommand }),
boolField("ai_search", func(c *config.Config) *bool { return &c.AISearch }),
durationField("attention_threshold", func(c *config.Config) *string { return &c.AttentionThreshold }),
strField("theme", func(c *config.Config) *string { return &c.Theme }),
Expand Down
26 changes: 13 additions & 13 deletions cmd/dispatch/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,11 +136,11 @@ func resolveNewDir(dir string) (string, error) {
func defaultNewLaunch(w io.Writer, cfg *config.Config, dir string, mode string) error {
if mode == config.LaunchModeInPlace {
rc := platform.ResumeConfig{
YoloMode: cfg.YoloMode,
Agent: cfg.Agent,
Model: cfg.Model,
CustomCommand: cfg.CustomCommand,
Cwd: dir,
YoloMode: cfg.YoloMode,
Agent: cfg.Agent,
Model: cfg.Model,
ResumeSessionCommand: cfg.ResumeSessionCommand,
Cwd: dir,
}
cmd, err := platform.NewResumeCmd("", rc)
if err != nil {
Expand All @@ -157,14 +157,14 @@ func defaultNewLaunch(w io.Writer, cfg *config.Config, dir string, mode string)
return errors.New("no shell detected on this system")
}
rc := platform.ResumeConfig{
YoloMode: cfg.YoloMode,
Agent: cfg.Agent,
Model: cfg.Model,
Terminal: cfg.DefaultTerminal,
CustomCommand: cfg.CustomCommand,
Cwd: dir,
LaunchStyle: launchStyleForOpenMode(mode),
PaneDirection: cfg.EffectivePaneDirection(),
YoloMode: cfg.YoloMode,
Agent: cfg.Agent,
Model: cfg.Model,
Terminal: cfg.DefaultTerminal,
ResumeSessionCommand: cfg.ResumeSessionCommand,
Cwd: dir,
LaunchStyle: launchStyleForOpenMode(mode),
PaneDirection: cfg.EffectivePaneDirection(),
}
if err := platform.LaunchSession(shell, "", rc); err != nil {
return err
Expand Down
26 changes: 13 additions & 13 deletions cmd/dispatch/open.go
Original file line number Diff line number Diff line change
Expand Up @@ -212,11 +212,11 @@ func readSessionIDs(r io.Reader) ([]string, error) {
// omitted because they affect terminal placement, not the copilot invocation.
func openResumeConfig(cfg *config.Config, sess *data.Session) platform.ResumeConfig {
return platform.ResumeConfig{
YoloMode: cfg.YoloMode,
Agent: cfg.Agent,
Model: cfg.Model,
CustomCommand: cfg.CustomCommand,
Cwd: sess.Cwd,
YoloMode: cfg.YoloMode,
Agent: cfg.Agent,
Model: cfg.Model,
ResumeSessionCommand: cfg.ResumeSessionCommand,
Cwd: sess.Cwd,
}
}

Expand Down Expand Up @@ -471,14 +471,14 @@ func defaultOpenLaunch(w io.Writer, cfg *config.Config, sess *data.Session, mode
return errors.New("no shell detected on this system")
}
rc := platform.ResumeConfig{
YoloMode: cfg.YoloMode,
Agent: cfg.Agent,
Model: cfg.Model,
Terminal: cfg.DefaultTerminal,
CustomCommand: cfg.CustomCommand,
Cwd: sess.Cwd,
LaunchStyle: launchStyleForOpenMode(mode),
PaneDirection: cfg.EffectivePaneDirection(),
YoloMode: cfg.YoloMode,
Agent: cfg.Agent,
Model: cfg.Model,
Terminal: cfg.DefaultTerminal,
ResumeSessionCommand: cfg.ResumeSessionCommand,
Cwd: sess.Cwd,
LaunchStyle: launchStyleForOpenMode(mode),
PaneDirection: cfg.EffectivePaneDirection(),
}
if err := platform.LaunchSession(shell, sess.ID, rc); err != nil {
return err
Expand Down
63 changes: 63 additions & 0 deletions docs/specs/live-activity/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
---
author: "@jongio"
status: approved
---

<!-- Pipeline tracking (auto-managed, not part of product spec) -->
<!-- ## Pipeline Status -->
<!-- Phase: VERIFYING -->

# Live Activity Monitor

## Problem

Dispatch polls session attention status every 30 seconds (`attentionRefreshInterval`). This means state transitions (a session finishing AI work and waiting for user input) can take up to 30 seconds to appear. Users working across multiple sessions need instant feedback about which sessions need their attention, the ability to launch new sessions from dispatch (to track them), and a one-key action to switch focus to a running session's terminal.

## Solution

Four integrated capabilities:

1. **Event Watcher (push-based)**: Replace 30-second polling with filesystem notifications. Watch `~/.copilot/session-state/` for changes to `events.jsonl` and lock files. When a file changes, re-classify only that session and push the update to the TUI instantly.

2. **New Session Launch**: A configurable keybinding that starts a brand new Copilot CLI session in the selected session/folder's working directory. The launch command is configurable via `new_session_command` in settings.

3. **Session Process Tracking**: When dispatch launches (or resumes) a session, record the child process PID. This tracking persists so dispatch knows which running sessions it owns.

4. **Focus Session**: A keybinding that brings the terminal window of a tracked running session to the foreground. Uses the tracked PID to locate the owning window.

## Design

### Event Watcher (`internal/data/eventwatcher.go`)

A new `EventWatcher` struct using `fsnotify` to monitor `~/.copilot/session-state/`. On file write events for `events.jsonl` or `inuse.*.lock`, it debounces (50ms), re-classifies the affected session, and fires a callback with the session ID and new status. The existing `attentionRefreshInterval` polling remains as a fallback (bumped to 120s) for edge cases where fsnotify misses events.

### New Session Launch

Config adds `NewSessionCommand string` (default: `"copilot"`). Template variables: `{cwd}`. Keybinding: `N` (uppercase, mnemonic: "New"). When triggered, dispatch runs the configured command in a new WT tab/pane at the selected folder's cwd.

### Session Process Tracking (`internal/data/sessiontrack.go`)

An in-memory map `sessionID -> TrackedSession{PID int, LaunchTime time.Time}` with periodic cleanup of dead PIDs. Not persisted to disk (PIDs are ephemeral; stale after reboot). Populated when `LaunchSession` or the new-session launch completes.

### Focus Session (`internal/platform/focus_windows.go`)

Given a PID, walk the process tree upward to find the terminal host window, then call `SetForegroundWindow`. Keybinding: `F` (uppercase, mnemonic: "Focus"). Only active when the selected session has a tracked live PID.

## Acceptance Criteria

1. When a session's `events.jsonl` changes, the attention dot updates within 200ms (not 30s)
2. Pressing `N` on a selected session/folder launches a new Copilot CLI session in that cwd
3. The `new_session_command` config setting controls what command is run for new sessions
4. Pressing `F` on a session with a tracked live process brings its terminal window to the foreground
5. The 30-second polling fallback still works when fsnotify is unavailable or misses events
6. No increase in idle CPU usage (fsnotify is kernel-driven, not busy-wait)

## Non-Goals

- Tab-level granularity within Windows Terminal (WT doesn't expose tab-focus APIs externally)
- Tracking sessions launched outside dispatch (only dispatch-launched sessions are tracked)
- macOS/Linux window focus (stub implementations for now; Windows is primary)

<!-- Pipeline tracking (auto-managed, not part of product spec) -->
## Pipeline Status
Phase: BUILDING
30 changes: 30 additions & 0 deletions docs/specs/live-activity/test-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Test Plan: Live Activity Monitor

## Status: AUTOMATED

## Planned Tests

| ID | AC | Description | Status |
|----|-----|-------------|--------|
| T1 | AC1 | EventWatcher fires callback within 200ms when events.jsonl is modified | automated |
| T2 | AC1 | EventWatcher debounces rapid writes (multiple writes within 50ms produce one callback) | automated |
| T3 | AC1 | EventWatcher handles lock file creation/deletion (session start/stop) | automated |
| T4 | AC1 | EventWatcher ignores non-session directories and invalid session IDs | automated |
| T5 | AC5 | Fallback polling still triggers attention scan when watcher is stopped | blocked(integration-only; polling interval verified structurally) |
| T6 | AC2 | New session launch executes configured command in selected session's cwd | blocked(requires real terminal; verified via code path inspection) |
| T7 | AC3 | NewSessionCommand config field is respected (custom command template) | automated |
| T8 | AC3 | Default new session command works when config field is empty | automated |
| T9 | AC4 | Focus keybinding calls platform focus with tracked PID | blocked(requires real Windows terminal; platform code isolated) |
| T10 | AC4 | Focus is no-op when session has no tracked PID | automated |
| T11 | AC4 | Focus is no-op when tracked PID is dead | automated |
| T12 | AC6 | EventWatcher idle CPU is negligible (no busy-wait) | blocked(requires profiling; fsnotify is event-based by design) |
| T13 | AC1 | EventWatcher correctly identifies session ID from file path | automated |
| T14 | AC2 | SessionTracker records PID after launch | automated |
| T15 | AC4 | SessionTracker cleans up dead PIDs on periodic sweep | automated |

## Test Mapping

- `internal/data/eventwatcher_test.go`: T1, T2, T3, T4, T13
- `internal/data/sessiontrack_test.go`: T10, T11, T14, T15
- `internal/tui/handlers_eventwatcher.go` (code path): T7, T8
- `internal/data/eventwatcher.go` (NewEventWatcher watches new dirs): T3
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/x/ansi v0.11.7
github.com/creack/pty v1.1.24
github.com/fsnotify/fsnotify v1.10.1
github.com/github/copilot-sdk/go v1.0.7
github.com/lucasb-eyer/go-colorful v1.4.0
golang.org/x/sync v0.22.0
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE=
github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/github/copilot-sdk/go v1.0.7 h1:HGTKaUUfnL/0y5HG+5U2y3DxiDpiuJVh0JElWRHJ2HQ=
github.com/github/copilot-sdk/go v1.0.7/go.mod h1:U0STg9Jdv9LDwtkUtcUJCzPQ4KTnKakRfP+1LhEx248=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
Expand Down
28 changes: 27 additions & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ const (
// currentConfigVersion is the schema version written to new and migrated
// config files. Increment this when making breaking schema changes and
// add a corresponding migration case in [migrate].
currentConfigVersion = 1
currentConfigVersion = 2
)

// NamedView holds a saved combination of list state filters that can be
Expand Down Expand Up @@ -180,8 +180,19 @@ type Config struct {
// with the actual session ID at launch time. When set, YoloMode, Agent,
// and Model are ignored (they only apply to the default copilot CLI).
// Terminal and Shell settings are still used.
//
// Deprecated: renamed to ResumeSessionCommand in config v2. This field
// exists only for backward-compatible deserialization; migrate() copies
// it to ResumeSessionCommand then clears it.
CustomCommand string `json:"custom_command,omitempty"`

// ResumeSessionCommand is a user-defined command that replaces the
// default copilot CLI resume command. The placeholder {sessionId} is
// replaced with the actual session ID at launch time. When set,
// YoloMode, Agent, and Model are ignored (they only apply to the
// default copilot CLI). Terminal and Shell settings are still used.
ResumeSessionCommand string `json:"resume_session_command,omitempty"`

// ExcludedWords is a list of words used to filter sessions from the
// dispatch list. Sessions whose summary or turn content contains any
// of these words (case-insensitive) are hidden from the session list.
Expand Down Expand Up @@ -297,6 +308,12 @@ type Config struct {
// ActiveView is the name of the currently active named view.
// Empty or "Default" means no named view is active.
ActiveView string `json:"active_view,omitempty"`

// NewSessionCommand is the command template used to launch a brand new
// Copilot CLI session from dispatch. The placeholder {cwd} is replaced
// with the target working directory. When empty, defaults to "copilot".
// Examples: "copilot", "copilot --agent workspace", "gh copilot"
NewSessionCommand string `json:"new_session_command,omitempty"`
}

// LaunchMode describes how sessions are opened in the terminal.
Expand Down Expand Up @@ -627,6 +644,15 @@ func migrate(cfg *Config) {
cfg.LaunchMode = LaunchModeInPlace
}
}

// v1 → v2: custom_command renamed to resume_session_command.
// Copy the old value if the new field is empty.
if cfg.ConfigVersion < 2 {
if cfg.CustomCommand != "" && cfg.ResumeSessionCommand == "" {
cfg.ResumeSessionCommand = cfg.CustomCommand
}
cfg.CustomCommand = "" // clear so it won't be written back
}
}

// shellUnsafe contains characters that must never appear in shell or terminal
Expand Down
8 changes: 4 additions & 4 deletions internal/config/config_security_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ func TestLoadMalformedJSON_HugeArray(t *testing.T) {
// Config content safety — hostile values that could affect downstream use
// ---------------------------------------------------------------------------

func TestConfig_CustomCommandWithShellMetachars(t *testing.T) {
func TestConfig_ResumeSessionCommandWithShellMetachars(t *testing.T) {
t.Parallel()
// Verify that custom commands with shell metacharacters round-trip
// through JSON without corruption.
Expand All @@ -266,7 +266,7 @@ func TestConfig_CustomCommandWithShellMetachars(t *testing.T) {

for _, cmd := range hostile {
cfg := Default()
cfg.CustomCommand = cmd
cfg.ResumeSessionCommand = cmd

data, err := json.Marshal(cfg)
if err != nil {
Expand All @@ -281,8 +281,8 @@ func TestConfig_CustomCommandWithShellMetachars(t *testing.T) {
// The value must survive JSON round-trip exactly — we're not
// sanitising at the config layer (that's the launcher's job),
// but we must not corrupt it.
if restored.CustomCommand != cmd {
t.Errorf("CustomCommand round-trip: got %q, want %q", restored.CustomCommand, cmd)
if restored.ResumeSessionCommand != cmd {
t.Errorf("ResumeSessionCommand round-trip: got %q, want %q", restored.ResumeSessionCommand, cmd)
}
}
}
Expand Down
Loading
Loading