opencode-sesh 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -23,10 +23,12 @@ slot section plus an xlarge modal picker; the terminal UI is fullscreen fzf.
23
23
  ## Start here
24
24
 
25
25
  ```bash
26
- npm install # dev deps (TypeScript, opencode/OpenTUI types)
27
- npm test # fixture-DB regression suite — must stay green
28
- npm run typecheck # tsc over tui/ and opencode/
29
- npm run lint:sh # bash -n on every script
26
+ bun install # dev deps (TypeScript, opencode/OpenTUI types)
27
+ bun run test # fixture-DB regression suite — must stay green
28
+ bun run test:changelog # release-tooling regressions (scripts/changelog.sh)
29
+ bun run test:picker # PTY picker suite (needs fzf >= 0.73)
30
+ bun run typecheck # tsc over tui/ and opencode/
31
+ bun run lint:sh # bash -n on every script
30
32
  HOME=/tmp/fakehome bash install.sh # installer smoke test (never touches ~/.config)
31
33
  ```
32
34
 
@@ -36,6 +38,9 @@ HOME=/tmp/fakehome bash install.sh # installer smoke test (never touches ~/.co
36
38
  Never hardcode install paths.
37
39
  - `tui/sesh-panel.tsx` — SolidJS panel, `/** @jsxImportSource @opentui/solid */`.
38
40
  - `opencode/tools/sesh-list.ts` — the agent-facing `sesh-list` tool.
41
+ - `scripts/changelog.sh` — release tooling: drafts `[Unreleased]` from
42
+ conventional commits, promotes it to a dated version, prints release notes and
43
+ checks the compare links. `tests/changelog.sh` covers it in a throwaway repo.
39
44
  - `themes/`, `tests/`, `install.sh`, `README.md`.
40
45
 
41
46
  The **filename becomes the tool name** in opencode, so `sesh-list.ts` is the
@@ -60,7 +65,13 @@ the schema below. Keystrokes never touch the database:
60
65
  the whole DB and pruning is always safe.
61
66
  - render (cheap, pure `jq`): reads the snapshot only. Filters on `searchText`
62
67
  (title + `fulltextLower`, both lowercased at extraction), groups by `cwd`,
63
- sorts groups/items by recency, renders the TSV.
68
+ sorts groups/items by recency, renders the TSV. Notice rows (headers, a
69
+ vanished selection, an empty store, a query matching nothing) carry an empty
70
+ `sessionId` and a unique `trackingId`. `--header` prints the one-line
71
+ scope/count/status that the picker uses as fzf's header, and a non-actionable
72
+ selection leaves a one-shot `STATE_DIR/action-notice` the next render shows
73
+ and consumes. Snapshot records carry `archived` as 0/1 (the DB column is an
74
+ epoch, not a boolean).
64
75
 
65
76
  **Output contract** (TSV, 6 fields):
66
77
  `agentId sessionId liveState display cwd trackingId`. `display` is the only
@@ -69,8 +80,11 @@ empty `sessionId` and are a selection no-op — keep that guard.
69
80
 
70
81
  One worker (`sesh-refresh-worker.sh`, per picker, TERMed on exit) serializes
71
82
  scans; fzf's `start`/`every(3)`/`change` bindings render only. Selection requests
72
- a fresh poll (`request`/`wait`, ≤12 s) before dispatch. opencode has no
73
- live-agent API, so `liveState` is always `unknown` and rows render gray;
83
+ a fresh poll (`request`/`wait`, ≤12 s) before dispatch — skipped for `--print`,
84
+ which is a pure snapshot lookup — and prints a progress line to stderr while it
85
+ waits, because fzf has already exited and the terminal is idle. opencode has no
86
+ live-agent API, so `liveState` is always `unknown` and rows render gray unless
87
+ they are archived (opt-in, tagged `· archived`);
74
88
  dispatch is always `opencode --session <id>` (`--fork` with Ctrl-F or --fork).
75
89
  Resume happens in place (`exec` after `cd` to the session's cwd).
76
90
 
@@ -88,7 +102,12 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
88
102
  raw string.
89
103
  - fzf: stock only, `>= 0.73` (`transform:`, `every():`). `{q}` in binds is
90
104
  shell-quoted by fzf — don't add quoting. Ctrl-G is bind-only (stays open);
91
- Ctrl-F is `--expect` (accepts with fork).
105
+ Ctrl-F is `--expect` (accepts with fork). A `transform-header` inside a chained
106
+ action must come **before** `reload`/`reload-sync`: placed after, fzf silently
107
+ publishes an empty list (verified on 0.74.3).
108
+ - Destructive actions confirm. `sesh-delete.sh` prompts on a TTY and refuses
109
+ without `--yes` when stdin is not one; the TUI arms on the first Ctrl-X and
110
+ commits on the second (or `y`).
92
111
  - The installer prefers the local opencode version for `@opencode-ai/*` and
93
112
  merges (never clobbers) the config `package.json`; opencode runs `bun install`
94
113
  at startup.
@@ -96,23 +115,37 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
96
115
  (`feat`, `fix`, `docs`, `ci`, …). See
97
116
  [CONTRIBUTING.md](CONTRIBUTING.md#commit-messages) for the types and the
98
117
  version mapping.
118
+ - `CHANGELOG.md` follows Keep a Changelog: notable changes are written under
119
+ `[Unreleased]` as the work lands, and the Release workflow promotes that
120
+ section (dated, relinked) into the published version, using it verbatim as the
121
+ GitHub release body. `scripts/changelog.sh draft` can seed entries from
122
+ conventional commits, but hand-written prose is the expected form — never let
123
+ a draft overwrite curated entries.
99
124
 
100
125
  ## TUI panel (`tui/sesh-panel.tsx`)
101
126
 
102
127
  - **Sidebar:** `api.slots.register` on `sidebar_content` (append mode — native
103
- sidebar content stays). Renders the most recent unarchived sessions with the
104
- current one highlighted, polled every 15 s. Display-only; the picker does
105
- actions.
128
+ sidebar content stays). Renders the newest `SIDEBAR_LIMIT` unarchived sessions
129
+ with the current one highlighted, polled every 15 s; space previews a row and
130
+ ctrl+x deletes one (armed, then confirmed). Anything bulkier belongs to the
131
+ picker.
106
132
  - **Picker:** a custom dialog (not `DialogSelect`) grouped by
107
133
  project/directory, recency-ordered, resume via
108
134
  `api.route.navigate("session", …)`. `ctrl+o` and the command palette open it;
109
- Ctrl-X deletes via the opencode CLI and removes the row only after the server
110
- confirms the deletion.
135
+ Ctrl-X arms and then confirms a delete through `api.client.session.delete` and
136
+ removes the row only after the server confirms; Ctrl-F forks through
137
+ `api.client.session.fork`; Ctrl-G scopes the list to the selected session's
138
+ project.
111
139
  - **Picker search:** pages the global session list (`SESSION_PAGE_LIMIT`, capped
112
140
  at `SESSION_MAX` and reported when truncated) instead of a fixed 500-row
113
141
  window, then indexes transcript text for **every** session in batches
114
142
  (`TRANSCRIPT_BATCH`) with bounded remote concurrency (`REMOTE_CONCURRENCY`).
115
143
  The header shows indexing progress/coverage. `tests/tui.mjs` locks this.
144
+ - **Durable install:** opencode imports plugins once at startup and never hot
145
+ reloads, so panel changes need a full quit/reopen. `install.sh --sync-panel`
146
+ (run by the `postinstall`) refreshes an already-installed panel and is a
147
+ no-op when absent, and `sesh --check` reports when the installed copy differs
148
+ from the bundled one.
116
149
 
117
150
  ### Hard-won rules — do not regress
118
151
 
@@ -131,12 +164,32 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
131
164
  removes any leftover `commands/sesh.md`.
132
165
  - Do not bind `<leader>l` (native `session_list`) or shadow the native
133
166
  `/sessions` command.
167
+ - **The sidebar never binds arrow keys globally.** Keyboard navigation is
168
+ opt-in: clicking the *Sessions* heading (or its search box) activates a
169
+ priority-20 layer, because a global `↑`/`↓` would hijack the main prompt.
170
+ Cursor state is derived from `itemRows()`, so hover and cursor resolve to the
171
+ same row, and the section is capped at `SIDEBAR_LIMIT` (`… N more · ctrl+o for
172
+ all` is the escape hatch) — it must stay a glanceable recent list.
173
+ - **Delete in the TUI is two-step.** The first `ctrl+x` arms `pendingDelete`,
174
+ the second (or `y`, or Enter in the picker footer) commits, and `n`/Esc/moving
175
+ cancels. The `y`/`n` layer is registered *only while armed*, so it can never
176
+ shadow the search box's typing.
177
+ - **Picker `ctrl+g` is project scope**, not close (parity with the fzf picker's
178
+ `Ctrl-G`); Esc is the only close key. `ctrl+f` forks through
179
+ `api.client.session.fork` and navigates to the returned session.
180
+ - Row titles render through `<Highlighted>`, and JSX must stay out of the
181
+ `function shortDir` … `const TRANSCRIPT_PREVIEW_TURNS` window that
182
+ `tests/tui.mjs` extracts and evaluates as the data layer.
134
183
 
135
184
  ## Verifying
136
185
 
137
- - `npm test` — fixture-DB regression suite, hermetic via `SESH_*` overrides.
138
- - `npm run typecheck` — `tsc` over `tui/` and `opencode/`.
139
- - `npm run lint:sh` — `bash -n` on every script.
186
+ - `bun run test` — fixture-DB regression suite, hermetic via `SESH_*` overrides.
187
+ - `bun run test:changelog` — release-tooling regressions; builds its own git
188
+ history in a temp dir (included in `bun run test`).
189
+ - `bun run test:picker` — PTY suite driving the real fzf picker (needs fzf
190
+ `>= 0.73` and Python 3).
191
+ - `bun run typecheck` — `tsc` over `tui/` and `opencode/`.
192
+ - `bun run lint:sh` — `bash -n` on every script.
140
193
  - `HOME=/tmp/fakehome bash install.sh` — installer smoke test.
141
194
  - Keep `tui/sesh-panel.tsx` and any installed copy byte-identical when testing
142
195
  the panel locally.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2026-09-21
11
+
12
+ ### Added
13
+
14
+ - The picker header always names the effective scope, the session count and
15
+ whether the index is fresh or stale (`sesh-list.sh --header`), so a `Ctrl-G`
16
+ scope toggle or a degraded snapshot is no longer invisible.
17
+ - `sesh --print --json` emits `{"sessionId","cwd","fork"}` for scripting.
18
+ - Archived sessions are tagged `· archived` when included with `--archived`,
19
+ replacing the agent-state colours that could never fire (opencode exposes no
20
+ live agent state, so every row rendered grey either way).
21
+ - The TUI picker takes `Ctrl-X` (delete), `Ctrl-F` (fork) and `Ctrl-G` (scope to
22
+ the selected session's project), so both UIs now agree on the same keys.
23
+ - The TUI sidebar is capped at the 15 most recent sessions with
24
+ `… N more · ctrl+o for all`, and clicking the **Sessions** heading drives it
25
+ from the keyboard (`↑`/`↓`, Enter, Space, `Ctrl-X`, Esc).
26
+ - Empty stores, queries that match nothing, vanished selections and an
27
+ unavailable session list now say so instead of rendering a blank screen.
28
+ - Session titles highlight the part of the row the search matched, and the
29
+ sidebar search accepts uppercase and punctuation.
30
+
31
+ ### Changed
32
+
33
+ - Deleting a session asks first. The picker prompts `[y/N]` before dispatching,
34
+ the TUI arms on the first `Ctrl-X` and commits on a second `Ctrl-X` (or `y`;
35
+ `n`, Esc or moving cancels), and `sesh-delete.sh` refuses a non-terminal stdin
36
+ unless given `--yes` — a piped newline can no longer authorize an irreversible
37
+ delete.
38
+ - Resuming no longer stalls silently while the picker waits for a fresh scan: it
39
+ prints a progress line first, and `--print` lookups skip that poll entirely.
40
+ - Selecting a directory header or a notice row explains itself with a one-shot
41
+ notice row instead of repainting the same list as if the key had been ignored.
42
+ - Reconciled README, AGENTS.md and SECURITY.md with current behavior: the
43
+ terminal UI is described as the full set of scripts (including the refresh
44
+ worker and shortcut help), the extraction cache key documents the latest part
45
+ timestamp, the PTY picker suite (`npm run test:picker`) is listed, and the
46
+ npm distribution and supported-version policy are stated.
10
47
  ## [0.1.7] - 2026-09-13
11
48
 
12
49
  ### Fixed
@@ -141,7 +178,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
141
178
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
142
179
  CI.
143
180
 
144
- [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.7...HEAD
181
+ [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.2.0...HEAD
182
+ [0.2.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.7...v0.2.0
145
183
  [0.1.7]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.6...v0.1.7
146
184
  [0.1.6]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.5...v0.1.6
147
185
  [0.1.5]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.4...v0.1.5
package/README.md CHANGED
@@ -58,7 +58,8 @@ cd opencode-sesh
58
58
  bash install.sh
59
59
  ```
60
60
 
61
- `sesh install` (or `bash install.sh`) links `sesh` into `~/.local/bin`, copies
61
+ `sesh install` (or `bash install.sh`) links `sesh` into `$XDG_BIN_HOME`
62
+ (default `~/.local/bin`), copies
62
63
  the `sesh-list` tool and the TUI panel into
63
64
  `${XDG_CONFIG_HOME:-~/.config}/opencode`, registers the panel in `tui.json`, and
64
65
  declares the plugin dependencies (opencode installs them on next start). The
@@ -113,20 +114,28 @@ bash install.sh --uninstall # from source
113
114
  | Ctrl-G | Toggle current-directory scope / all sessions |
114
115
  | Space (empty query) | Toggle the transcript preview |
115
116
  | `?` (empty query) | Toggle shortcut help |
116
- | Ctrl-X | Delete the selected session |
117
+ | Ctrl-X | Delete the selected session (asks to confirm) |
117
118
  | Escape | Exit |
118
119
 
120
+ The header line always shows the effective scope, the session count and whether
121
+ the index is fresh or stale, so a `Ctrl-G` toggle is never silent. A query that
122
+ matches nothing says so instead of showing an empty screen.
123
+
119
124
  Flags: `--cwd` (current directory only), `--limit N` (default: all),
120
125
  `--archived`, `--print` (print `id<TAB>cwd` instead of resuming), `--fork`,
121
- `--query TEXT`, `--check`.
126
+ `--json` (with `--print`, emit JSON), `--query TEXT`, `--check`.
122
127
 
123
128
  Because `--print` just emits the id and directory, `sesh` doubles as a scriptable
124
129
  session lookup:
125
130
 
126
131
  ```bash
127
132
  read -r id cwd < <(sesh --print --query "auth")
133
+ sesh --print --json --query "auth" | jq -r .cwd
128
134
  ```
129
135
 
136
+ Deleting is irreversible, so the picker asks before dispatching it, and a
137
+ non-interactive `sesh-delete.sh` refuses unless given `--yes`.
138
+
130
139
  ### TUI panel
131
140
 
132
141
  | Key | Action |
@@ -137,10 +146,16 @@ read -r id cwd < <(sesh --print --query "auth")
137
146
  | `↑`/`↓`, `PgUp`/`PgDn`, `Home`/`End` | Move the selection |
138
147
  | Space | Toggle the transcript preview |
139
148
  | Enter | Open the selected session |
149
+ | Ctrl-X | Delete the selected session (asks to confirm) |
150
+ | Ctrl-F | Fork the selected session |
151
+ | Ctrl-G | Toggle scope: the selected session's project, or every project |
140
152
  | Esc | Close |
141
153
 
142
154
  Click the sidebar's `search…` box to filter recent sessions in place; click
143
- elsewhere or press Esc to leave search.
155
+ elsewhere or press Esc to leave search. Click the **Sessions** heading to drive
156
+ the list from the keyboard instead of the mouse (`↑`/`↓` to move, Enter to open,
157
+ Space to preview, Ctrl-X to delete, Esc to leave). The sidebar shows the 15 most
158
+ recent sessions — the full picker is one keystroke away.
144
159
 
145
160
  The full picker pages through your whole global session list (no fixed window)
146
161
  and indexes transcript text for every session in the background, showing
@@ -162,19 +177,21 @@ All variables are optional.
162
177
 
163
178
  ## How it works
164
179
 
165
- `bin/sesh-list.sh` is the only place that reads the opencode session store
166
- (SQLite: `session` / `message` / `part`). Each refresh extracts session metadata
167
- and text-only parts into a persistent per-session cache keyed on `time_updated`
168
- and part count, then atomically publishes a `snapshot.jsonl`. Every keystroke
169
- re-renders that snapshot with `jq` alone, so typing never starts competing
170
- database scans. Reasoning and tool payloads are never indexed or previewed, and
171
- session ids are validated before any SQL interpolation.
172
-
173
- Four flat scripts back the terminal UI — the picker (`sesh.sh`), snapshot
174
- builder (`sesh-list.sh`), preview renderer (`sesh-preview.sh`) and deleter
175
- (`sesh-delete.sh`) — with one worker process per picker serializing refreshes.
176
- The TUI panel (`tui/sesh-panel.tsx`) is a SolidJS OpenTUI plugin that talks to
177
- the opencode SDK over the same store.
180
+ `bin/sesh-list.sh` is the only `bin/` script that reads the opencode session
181
+ store (SQLite: `session` / `message` / `part`). Each refresh extracts session
182
+ metadata and text-only parts into a persistent per-session cache keyed on
183
+ `time_updated`, part count and the latest part timestamp, then atomically
184
+ publishes a `snapshot.jsonl`. Every keystroke re-renders that snapshot with `jq`
185
+ alone, so typing never starts competing database scans. Reasoning and tool
186
+ payloads are never indexed or previewed, and session ids are validated before
187
+ any SQL interpolation.
188
+
189
+ Flat scripts back the terminal UI — the picker (`sesh.sh`), the refresh engine
190
+ (`sesh-list.sh`, driven by a per-picker `sesh-refresh-worker.sh` that serializes
191
+ scans), preview renderer (`sesh-preview.sh`), deleter (`sesh-delete.sh`) and
192
+ shortcut help (`sesh-shortcuts.sh`). The TUI panel (`tui/sesh-panel.tsx`) is a
193
+ SolidJS OpenTUI plugin that talks to the opencode SDK over the same store; the
194
+ `sesh-list` agent tool reads it through `opencode db`.
178
195
 
179
196
  ## FAQ
180
197
 
@@ -202,10 +219,11 @@ guide; this project follows the [Code of Conduct](CODE_OF_CONDUCT.md). Run the
202
219
  checks before opening a PR:
203
220
 
204
221
  ```bash
205
- npm install
206
- npm test # fixture-database regression suite
207
- npm run typecheck # tsc over tui/ and opencode/
208
- npm run lint:sh # bash -n on every script
222
+ bun install
223
+ bun run test # fixture-database regression suite
224
+ bun run test:picker # PTY picker suite (needs fzf >= 0.73)
225
+ bun run typecheck # tsc over tui/ and opencode/
226
+ bun run lint:sh # bash -n on every script
209
227
  ```
210
228
 
211
229
  `HOME=/tmp/fakehome bash install.sh` smoke-tests the installer without touching
@@ -1,12 +1,25 @@
1
1
  #!/usr/bin/env bash
2
- # Usage: sesh-delete.sh STATE_DIR SESSION_ID
2
+ # Usage: sesh-delete.sh [--yes] STATE_DIR SESSION_ID
3
3
  # Ctrl-X deletes through the opencode CLI so its own safeguards apply.
4
- # There is no confirmation prompt, matching the Claude picker.
4
+ # Deletion is irreversible, so this confirms first: an interactive run asks for
5
+ # a y, and a non-interactive run must pass --yes rather than inherit whatever
6
+ # stdin happens to be. Rows with no session id (headers, notices) stay silent
7
+ # no-ops.
5
8
  set -euo pipefail
6
9
 
7
10
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
8
- state_dir=${1:-}
9
- session_id=${2:-}
11
+ assume_yes=0
12
+ positional=()
13
+ while [ "$#" -gt 0 ]; do
14
+ case "$1" in
15
+ --yes|-y) assume_yes=1 ;;
16
+ --help) echo "usage: ${0##*/} [--yes] STATE_DIR SESSION_ID"; exit 0 ;;
17
+ *) positional+=("$1") ;;
18
+ esac
19
+ shift
20
+ done
21
+ state_dir=${positional[0]:-}
22
+ session_id=${positional[1]:-}
10
23
  snapshot="$state_dir/snapshot.jsonl"
11
24
  lock="$state_dir/refresh.lock"
12
25
  OPENCODE_BIN=${SESH_OPENCODE:-opencode}
@@ -40,6 +53,27 @@ acquire_lock() {
40
53
  [[ "$session_id" =~ ^ses_[A-Za-z0-9]+$ ]] || { echo "Invalid session identity."; pause; exit 0; }
41
54
  [ -n "$state_dir" ] && [ -f "$snapshot" ] || { echo "No session snapshot available."; pause; exit 0; }
42
55
 
56
+ # Confirm before taking the lock: a human can sit on the prompt for as long as
57
+ # they like without blocking the snapshot publisher. The row is re-checked
58
+ # against the snapshot below, so a session that vanishes meanwhile still loses.
59
+ if [ "$assume_yes" = 0 ]; then
60
+ if [ -t 0 ]; then
61
+ title=$("$JQ_BIN" -r --arg sid "$session_id" 'select(.sessionId == $sid) | .title // ""' "$snapshot" 2>/dev/null || true)
62
+ [ -n "$title" ] || title='untitled'
63
+ printf '%s' "Delete \"$title\" ($session_id)? [y/N] "
64
+ reply=''
65
+ IFS= read -r -n 1 reply || reply=''
66
+ printf '\n'
67
+ case "$reply" in
68
+ y|Y) ;;
69
+ *) echo "Cancelled; nothing was deleted."; pause; exit 0 ;;
70
+ esac
71
+ else
72
+ echo "Refusing to delete $session_id: pass --yes when stdin is not a terminal." >&2
73
+ exit 2
74
+ fi
75
+ fi
76
+
43
77
  # Serialize this action with the sole snapshot publisher. A short collision
44
78
  # is safer than deleting against a moving snapshot.
45
79
  if ! acquire_lock; then
package/bin/sesh-list.sh CHANGED
@@ -12,6 +12,7 @@ LIMIT=0
12
12
  LIMIT_SET=0
13
13
  SCOPE_SET=0
14
14
  REFRESH=0
15
+ HEADER=0
15
16
  WAIT_LOCK=0
16
17
  TOGGLE_SCOPE=0
17
18
  INCLUDE_ARCHIVED=0
@@ -20,10 +21,11 @@ STATE_DIR=''
20
21
  QUERY=''
21
22
  SELECTED_ID=''
22
23
  SCOPE_CWD=''
23
- usage() { echo "usage: ${0##*/} [--refresh] [--wait-lock SECONDS] --state-dir DIR [--cwd] [--limit N] [--archived] [--toggle-scope] [--query TEXT]" >&2; }
24
+ usage() { echo "usage: ${0##*/} [--refresh] [--header] [--wait-lock SECONDS] --state-dir DIR [--cwd] [--limit N] [--archived] [--toggle-scope] [--query TEXT]" >&2; }
24
25
  while [ "$#" -gt 0 ]; do
25
26
  case "$1" in
26
27
  --refresh) REFRESH=1 ;;
28
+ --header) HEADER=1 ;;
27
29
  --wait-lock) WAIT_LOCK=${2:-}; shift ;;
28
30
  --state-dir) STATE_DIR=${2:-}; shift ;;
29
31
  --cwd) SCOPE_CWD=$PWD; SCOPE_SET=1 ;;
@@ -113,14 +115,47 @@ atomic_text() {
113
115
  }
114
116
  set_status() { atomic_text "$STATUS" "$1"; }
115
117
 
118
+ # One-line fzf --header: the effective scope, how many sessions the snapshot
119
+ # holds and whether it is fresh. Reads published state only, so it stays cheap
120
+ # enough to run on fzf's 3s poll without touching the database.
121
+ header() {
122
+ local state='' count=0 scope='all dirs' line=''
123
+ [ -f "$STATUS" ] && state=$(cat "$STATUS" 2>/dev/null || true)
124
+ if [ -f "$SNAPSHOT" ]; then
125
+ count=$(wc -l < "$SNAPSHOT" 2>/dev/null || printf '0')
126
+ count=${count//[!0-9]/}
127
+ [ -n "$count" ] || count=0
128
+ fi
129
+ if [ -n "$SCOPE_CWD" ]; then
130
+ case "$SCOPE_CWD" in
131
+ "$HOME") scope='~' ;;
132
+ "$HOME"/*) scope="~${SCOPE_CWD#"$HOME"}" ;;
133
+ *) scope=$SCOPE_CWD ;;
134
+ esac
135
+ fi
136
+ line="${CYAN}${scope}${RESET} ${GRAY}·${RESET} ${GRAY}${count} sessions${RESET}"
137
+ [ "$INCLUDE_ARCHIVED" = 1 ] && line="${line} ${GRAY}·${RESET} ${YELLOW}archived shown${RESET}"
138
+ [ -n "$state" ] && [ "$state" != fresh ] && line="${line} ${GRAY}·${RESET} ${YELLOW}${state}${RESET}"
139
+ printf '%s' "$line"
140
+ }
141
+
116
142
  render() {
117
- local state=''
143
+ local state='' notice=''
118
144
  [ -f "$STATUS" ] && state=$(cat "$STATUS" 2>/dev/null || true)
145
+ # A non-actionable selection (a directory header, a stale notice) leaves a
146
+ # one-shot message here so the reopened picker explains itself instead of
147
+ # repainting the same list as if the key had been ignored.
148
+ if [ -f "$STATE_DIR/action-notice" ]; then
149
+ notice=$(cat "$STATE_DIR/action-notice" 2>/dev/null || true)
150
+ rm -f "$STATE_DIR/action-notice" 2>/dev/null || true
151
+ fi
119
152
  if [ ! -f "$SNAPSHOT" ]; then
153
+ [ -n "$notice" ] && printf '\t\tunknown\t%s%s%s\t\tnotice:action\n' "$YELLOW" "$notice" "$RESET"
120
154
  [ -n "$state" ] && [ "$state" != fresh ] && printf '\t\tunknown\t%s%s%s\t\tnotice:live-state\n' "$YELLOW" "$state" "$RESET"
121
155
  return 0
122
156
  fi
123
157
  {
158
+ [ -n "$notice" ] && printf '\t\tunknown\t%s%s%s\t\tnotice:action\n' "$YELLOW" "$notice" "$RESET"
124
159
  [ -n "$state" ] && [ "$state" != fresh ] && printf '\t\tunknown\t%s%s%s\t\tnotice:live-state\n' "$YELLOW" "$state" "$RESET"
125
160
  "$JQ_BIN" -sr --arg green "$GREEN" --arg cyan "$CYAN" --arg gray "$GRAY" --arg magenta "$MAGENTA" --arg red "$RED" --arg bold "$BOLD" --arg yellow "$YELLOW" --arg reset "$RESET" --arg query "$QUERY" --arg home "$HOME" --arg selected "$SELECTED_ID" '
126
161
  def ago($epoch):
@@ -128,23 +163,34 @@ render() {
128
163
  | if $s < 60 then "now" elif $s < 3600 then "\($s / 60 | floor)m" elif $s < 86400 then "\($s / 3600 | floor)h" else "\($s / 86400 | floor)d" end;
129
164
  def homepath:
130
165
  . as $p | if $p == "" then "?" elif $p == $home then "~" elif ($home != "" and startswith($home + "/")) then "~" + .[($home|length):] else . end;
131
- # Preserve a vanished selection as a non-actionable row with its original
132
- # tracking key. Otherwise fzf falls back to a different session. Keep it
133
- # until the user moves away; empty field 2 makes Enter/Ctrl-F/Ctrl-X no-ops.
166
+ # Non-actionable rows: an empty field 2 makes Enter/Ctrl-F/Ctrl-X no-ops,
167
+ # and the unique id keeps fzf from falling back to a neighbouring session.
168
+ def notice_row($msg; $id): (["", "", "unknown", ($yellow + $msg + $reset), "", $id] | @tsv);
169
+ # Preserve a vanished selection so the picker keeps its identity instead
170
+ # of jumping to a different session; keep it until the user moves away.
134
171
  (if ($selected | test("^ses_[A-Za-z0-9]+$")) and (any(.sessionId == $selected) | not)
135
- then (["", "", "unknown", ($yellow + "Selected session is no longer available; choose another session" + $reset), "", $selected] | @tsv)
172
+ then notice_row("Selected session is no longer available; choose another session"; $selected)
136
173
  else empty end),
174
+ # An empty result set must say so: with the info line hidden and an empty
175
+ # preview, a filtered-to-nothing list is otherwise an indistinguishable
176
+ # blank screen.
137
177
  (($query | ascii_downcase) as $q
138
- | map(select($q == "" or (.searchText | contains($q))))
178
+ | [.[] | select($q == "" or (.searchText | contains($q)))] as $matched
179
+ | (if ($matched | length) == 0
180
+ then notice_row(if $q == "" then "No sessions in this store" else "No sessions match \"" + $query + "\"" end; "notice:no-match")
181
+ else empty end),
182
+ ($matched
139
183
  | group_by(.cwd)
140
184
  | map({cwd: .[0].cwd, updated: (map(.updatedEpoch) | max), items: (sort_by(.updatedEpoch) | reverse)})
141
185
  | sort_by(.updated) | reverse
142
186
  | .[] as $g
143
187
  | (["", "", "unknown", ($magenta + ($g.cwd | homepath | gsub("[\u0000-\u001f\u007f-\u009f]";" ")) + $reset), $g.cwd, ("hdr:" + $g.cwd)] | @tsv),
144
188
  ($g.items | to_entries[] | .key as $i | .value as $s
145
- | (if $s.liveState == "running" then ($bold + $green) elif $s.agentId != "" and $s.agentState == "failed" then $red elif $s.agentId != "" then $cyan else $gray end) as $c
189
+ # Archived rows are opt-in and need to look like it; nothing else about
190
+ # a row is knowable here (live agent state is not exposed by opencode).
191
+ | (if $s.archived == 1 then $yellow else $gray end) as $c
146
192
  | ($gray + (if $i == (($g.items|length)-1) then "└" else "├" end) + $reset) as $branch
147
- | [$s.agentId, $s.sessionId, $s.liveState, ($branch + " " + $c + ($s.title | gsub("[\u0000-\u001f\u007f-\u009f]";" ")) + $reset + " " + $gray + ago($s.updatedEpoch) + $reset), $s.cwd, $s.sessionId] | @tsv))
193
+ | [$s.agentId, $s.sessionId, $s.liveState, ($branch + " " + $c + ($s.title | gsub("[\u0000-\u001f\u007f-\u009f]";" ")) + (if $s.archived == 1 then " · archived" else "" end) + $reset + " " + $gray + ago($s.updatedEpoch) + $reset), $s.cwd, $s.sessionId] | @tsv)))
148
194
  ' "$SNAPSHOT"
149
195
  } || printf '\t\tunknown\t%sSnapshot rendering failed; retry refresh%s\t\tnotice:render-error\n' "$YELLOW" "$RESET"
150
196
  }
@@ -336,6 +382,7 @@ refresh() {
336
382
  | . as $m | ($histById[$m.id] // {}) as $h
337
383
  | {sessionId: $m.id, malformedRecords: 0, agentId: "", agentState: "",
338
384
  liveState: "unknown", transcriptPath: "", source: "opencode",
385
+ archived: (if (($m.archived // 0) | tonumber) == 0 then 0 else 1 end),
339
386
  cwd: (if ($h.cwd // "") != "" then $h.cwd else $m.directory end),
340
387
  updatedEpoch: ($h.updatedEpoch // (($m.time_updated / 1000 | floor))),
341
388
  title: (if ($h.title // "") != "" then $h.title else ($m.title // "(untitled)") end)}
@@ -356,5 +403,6 @@ refresh() {
356
403
  fi
357
404
  }
358
405
 
406
+ if [ "$HEADER" = 1 ]; then header; exit 0; fi
359
407
  if [ "$REFRESH" = 1 ] || [ "$TOGGLE_SCOPE" = 1 ]; then refresh; fi
360
408
  render
@@ -9,9 +9,10 @@ sesh shortcuts
9
9
  Ctrl-G Toggle current-directory scope / all sessions
10
10
  Space Toggle transcript preview
11
11
  ? Toggle this help
12
- Ctrl-X Delete the selected session
12
+ Ctrl-X Delete the selected session (asks to confirm)
13
13
  Escape Exit the picker
14
14
 
15
15
  Flags: --cwd (current directory only), --limit N (default: all),
16
- --archived (include archived), --print (print id instead of resuming)
16
+ --archived (include archived), --print (print id instead of resuming),
17
+ --json (with --print, emit JSON)
17
18
  HELP
package/bin/sesh.sh CHANGED
@@ -59,6 +59,7 @@ picker_limit=0
59
59
  picker_archived=0
60
60
  picker_print=0
61
61
  picker_fork=0
62
+ picker_json=0
62
63
  picker_query=''
63
64
  while [ "$#" -gt 0 ]; do
64
65
  case "$1" in
@@ -67,12 +68,14 @@ while [ "$#" -gt 0 ]; do
67
68
  --archived) picker_archived=1 ;;
68
69
  --print) picker_print=1 ;;
69
70
  --fork) picker_fork=1 ;;
71
+ --json) picker_json=1 ;;
70
72
  --query) picker_query=${2:-}; shift ;;
71
73
  *) echo "unknown option: $1" >&2; exit 2 ;;
72
74
  esac
73
75
  shift
74
76
  done
75
77
  case "$picker_limit" in ''|*[!0-9]*) echo "--limit must be a non-negative integer" >&2; exit 2;; esac
78
+ [ "$picker_json" = 0 ] || [ "$picker_print" = 1 ] || { echo '--json requires --print' >&2; exit 2; }
76
79
 
77
80
  shell_quote() {
78
81
  # Quotes fixed command arguments embedded in fzf's shell action strings.
@@ -106,6 +109,12 @@ trap cleanup EXIT
106
109
  trap 'exit 130' HUP INT TERM
107
110
 
108
111
  cache_cmd="$q_worker render --state-dir $q_state --query {q} --selected-id {6}"
112
+ # fzf's --header is the only place a persistent scope/status line can live
113
+ # without becoming a selectable row, so it is refreshed by transform-header on
114
+ # start, on the 3s poll and after either action that can change it. Order
115
+ # matters: a transform-* action placed AFTER reload/reload-sync in the chain
116
+ # leaves the list empty, so the header is always transformed first.
117
+ header_cmd="$q_list --header --state-dir $q_state"
109
118
  preview_cmd="if [ -f $q_state/help ]; then $q_shortcuts; else $q_preview $q_state {2}; fi"
110
119
  help_bind="?:transform:[ -z {q} ] && ( [ -f $q_state/help ] && rm -f $q_state/help && echo hide-preview || ( : > $q_state/help && echo show-preview+refresh-preview ) ) || echo 'put(?)'"
111
120
  toggle_cmd="$q_list --toggle-scope --state-dir $q_state >/dev/null"
@@ -113,16 +122,18 @@ delete_cmd="$q_delete $q_state {2}"
113
122
 
114
123
  query="$picker_query"
115
124
  while :; do
116
- selection=''
117
- set +e
118
- selection=$(FZF_DEFAULT_COMMAND='printf ""' FZF_DEFAULT_OPTS='' FZF_DEFAULT_OPTS_FILE='' "$SESH_FZF" --print-query --expect=ctrl-f --query="$query" --ansi --delimiter=$'\t' --with-nth=4 --track --id-nth=6 --disabled --layout=reverse --info=hidden --prompt='❯ ' \
119
- --preview="$preview_cmd" --preview-window='right,50%,wrap,hidden' \
120
- --bind='space:transform:[ -z {q} ] && echo toggle-preview || echo "put( )"' \
121
- --bind="$help_bind" \
122
- --bind="ctrl-x:execute($delete_cmd)+reload($cache_cmd)" \
123
- --bind="ctrl-g:execute($toggle_cmd)+reload($cache_cmd)" \
124
- --bind="change:reload:$cache_cmd" \
125
- --bind="start,every(3):reload-sync:$cache_cmd")
125
+ header_line=$("$SCRIPT_DIR/sesh-list.sh" --header --state-dir "$state_dir" 2>/dev/null || true)
126
+ selection=''
127
+ set +e
128
+ selection=$(FZF_DEFAULT_COMMAND='printf ""' FZF_DEFAULT_OPTS='' FZF_DEFAULT_OPTS_FILE='' "$SESH_FZF" --print-query --expect=ctrl-f --query="$query" --ansi --delimiter=$'\t' --with-nth=4 --track --id-nth=6 --disabled --layout=reverse --info=hidden --prompt='❯ ' \
129
+ --preview="$preview_cmd" --preview-window='right,50%,wrap,hidden' \
130
+ --header-first --header="$header_line" \
131
+ --bind='space:transform:[ -z {q} ] && echo toggle-preview || echo "put( )"' \
132
+ --bind="$help_bind" \
133
+ --bind="ctrl-x:execute($delete_cmd)+transform-header($header_cmd)+reload($cache_cmd)" \
134
+ --bind="ctrl-g:execute($toggle_cmd)+transform-header($header_cmd)+reload($cache_cmd)" \
135
+ --bind="change:reload:$cache_cmd" \
136
+ --bind="start,every(3):transform-header($header_cmd)+reload-sync:$cache_cmd")
126
137
  status=$?
127
138
  set -e
128
139
  case "$status" in 0) ;; 1|130) exit 0;; *) echo "sesh: picker failed ($status)" >&2; exit "$status";; esac
@@ -132,13 +143,30 @@ selection=${selection#*$'\n'}
132
143
  key=${selection%%$'\n'*}
133
144
  selection=${selection#*$'\n'}
134
145
  session_id=$(printf '%s\n' "$selection" | cut -f2)
135
- [ -n "$session_id" ] || continue
146
+ tracking_id=$(printf '%s\n' "$selection" | cut -f6)
147
+ if [ -z "$session_id" ]; then
148
+ # Enter/Ctrl-F on a directory header or a notice row. Leave a one-shot
149
+ # message for the reopened picker so the keypress visibly did something,
150
+ # and keep the query so the user has not lost their place.
151
+ case "$tracking_id" in
152
+ hdr:*) printf '%s' 'That row is a directory header, not a session.' > "$state_dir/action-notice" ;;
153
+ notice:*) printf '%s' 'That row is a notice, not a session; pick a session row.' > "$state_dir/action-notice" ;;
154
+ esac
155
+ continue
156
+ fi
136
157
  # Dispatch queues a new worker scan and waits for that exact serialized poll.
137
158
  # It cannot accept a previously fresh status while the worker owns its lock.
138
- poll_target=$("$SCRIPT_DIR/sesh-refresh-worker.sh" request --state-dir "$state_dir")
139
- if ! "$SCRIPT_DIR/sesh-refresh-worker.sh" wait --state-dir "$state_dir" --target "$poll_target" --timeout 12; then
140
- echo 'Live state unavailable; retry selection after refresh' >&2
141
- continue
159
+ # A --print lookup reads the published snapshot, so it skips the wait entirely
160
+ # and stays usable from scripts without paying a rescan.
161
+ if [ "$picker_print" = 0 ]; then
162
+ printf 'sesh: refreshing session state…' >&2
163
+ poll_target=$("$SCRIPT_DIR/sesh-refresh-worker.sh" request --state-dir "$state_dir")
164
+ if ! "$SCRIPT_DIR/sesh-refresh-worker.sh" wait --state-dir "$state_dir" --target "$poll_target" --timeout 12; then
165
+ printf '\r\033[K' >&2
166
+ echo 'sesh: session state unavailable; selection cancelled, retry after refresh' >&2
167
+ continue
168
+ fi
169
+ printf '\r\033[K' >&2
142
170
  fi
143
171
  record=$("$SESH_JQ" -c --arg sid "$session_id" 'select(.sessionId==$sid)' "$state_dir/snapshot.jsonl")
144
172
  [ -n "$record" ] || continue
@@ -146,7 +174,10 @@ cwd=$(printf '%s' "$record" | "$SESH_JQ" -r '.cwd')
146
174
  fork=0
147
175
  if [ "$picker_fork" = 1 ] || [ "$key" = ctrl-f ]; then fork=1; fi
148
176
  if [ "$picker_print" = 1 ]; then
149
- if [ "$fork" = 1 ]; then printf '%s\t%s\tfork\n' "$session_id" "$cwd"; else printf '%s\t%s\n' "$session_id" "$cwd"; fi
177
+ if [ "$picker_json" = 1 ]; then
178
+ "$SESH_JQ" -cn --arg sessionId "$session_id" --arg cwd "$cwd" --argjson fork "$fork" \
179
+ '{sessionId: $sessionId, cwd: $cwd, fork: ($fork == 1)}'
180
+ elif [ "$fork" = 1 ]; then printf '%s\t%s\tfork\n' "$session_id" "$cwd"; else printf '%s\t%s\n' "$session_id" "$cwd"; fi
150
181
  exit 0
151
182
  fi
152
183
  if [ ! -d "$cwd" ]; then