opencode-sesh 1.1.0 → 2.0.1

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
@@ -11,10 +11,10 @@ territory: read this first, then the specific file you need.
11
11
  any terminal. Lists **all** sessions across **all** project directories,
12
12
  full-text searchable, with transcript preview and resume/fork/delete.
13
13
  2. **`tui-plugins/sesh-panel/tui.tsx`** — a SolidJS/OpenTUI plugin for the opencode TUI: a
14
- recent-sessions section in the sidebar plus an `alt+o` / `/sesh` picker.
14
+ sessions section in the sidebar plus an `option+o` / `/sesh` picker.
15
15
  3. **`plugins/sesh-list.ts`** — the `sesh_list` custom tool, so the agent
16
- can list sessions across every directory by querying the global `opencode
17
- db` store (the native `opencode session list` is project-scoped).
16
+ can list sessions across every directory by querying the global opencode
17
+ SQLite store (the native `opencode session list` is project-scoped).
18
18
 
19
19
  No iTerm2, no AppleScript, no panes, no window management — **never add any.**
20
20
  The one exception is the TUI's right-click action, which shells out to
@@ -39,11 +39,10 @@ HOME=/tmp/fakehome bash install.sh # installer smoke test (never touches ~/.co
39
39
  - `bin/` — flat; no subdirs. Scripts resolve siblings from their own location.
40
40
  Never hardcode install paths. `sesh-waiting.sh` owns the needs-input query
41
41
  (shared heuristic with the TUI sidebar copy, marked `NEEDS_INPUT_SQL` in
42
- both); `sesh-prune.sh` archives stale sessions; `sesh-costs.sh` sums
42
+ both); `sesh-prune.sh` deletes stale sessions; `sesh-costs.sh` sums
43
43
  assistant-message cost per directory. Reads go through `sqlite3 -readonly`
44
- (required; `opencode db` no longer exists); the direct `UPDATE` in
45
- `sesh-prune.sh` (archive) is the only sanctioned database write — the API has
46
- no archive endpoint — and hard deletes stay on `opencode session delete`.
44
+ (required; `opencode db` no longer exists). Sesh never writes the database
45
+ directly; deletes go through `opencode session delete`.
47
46
  - `contrib/` — caller-side integrations (currently cmux). Nothing here may add
48
47
  window management to sesh itself.
49
48
  - `tui-plugins/sesh-panel/tui.tsx` — SolidJS panel, `/** @jsxImportSource @opentui/solid */`.
@@ -58,7 +57,7 @@ The tool name in opencode is `sesh_list`. Do not rename it without updating the
58
57
  ## Data flow (fzf picker)
59
58
 
60
59
  `bin/sesh-list.sh` owns the fzf picker's session store access (opencode SQLite
61
- DB: `session_v2` / `session_message` tables); the `sesh-list` agent tool queries
60
+ DB: `session_v2` / `session_message` tables); the `sesh_list` agent tool queries
62
61
  the same store read-only through `bun:sqlite`. V2 discovers the tool from the
63
62
  global config directory's `plugins/`. Keep its SQL column names in sync with
64
63
  the schema below. Keystrokes never touch the database:
@@ -76,7 +75,8 @@ the schema below. Keystrokes never touch the database:
76
75
  the whole DB and pruning is always safe.
77
76
  - render (cheap, pure `jq`): reads the snapshot only. Filters on `searchText`
78
77
  (title + `fulltextLower`, both lowercased at extraction), groups by `cwd`,
79
- sorts groups/items by recency, renders the TSV. Notice rows (headers, a
78
+ sorts groups/items by pins and recency, with current-project and title-match
79
+ priority while searching, and renders the TSV. Notice rows (headers, a
80
80
  vanished selection, an empty store, a query matching nothing) carry an empty
81
81
  `sessionId` and a unique `trackingId`. `--header` prints the one-line
82
82
  scope/count/status that the picker uses as fzf's header, and a non-actionable
@@ -90,7 +90,9 @@ the schema below. Keystrokes never touch the database:
90
90
  The sidebar and home lists use Option-S / Option-R instead: opencode binds
91
91
  `ctrl+d` itself (composer delete-char, session delete), so ctrl pins would
92
92
  shadow the composer. Pinned directories sort before groups
93
- containing pinned sessions, with pinned sessions first inside each group.
93
+ containing pinned sessions. The terminal picker puts pinned sessions first
94
+ inside each group; the TUI picker and sidebar keep sessions recency-ordered
95
+ within each directory. The home list puts pinned sessions first.
94
96
 
95
97
  **Output contract** (TSV, 6 fields):
96
98
  `agentId sessionId liveState display cwd trackingId`. `display` is the only
@@ -106,7 +108,8 @@ live-agent API, so `liveState` is always `unknown` and rows render gray unless
106
108
  they are archived (opt-in, tagged `· archived`);
107
109
  dispatch is always `opencode --session <id>`; Ctrl-F or `--fork` first creates the
108
110
  fork with `opencode api session.fork` (the CLI has no fork flag) and resumes the new id.
109
- Resume happens in place (`exec` after `cd` to the session's cwd).
111
+ Resume runs opencode in a subshell at the session's cwd in the same terminal;
112
+ when opencode exits, the picker reopens with the query preserved.
110
113
 
111
114
  ## Conventions
112
115
 
@@ -130,7 +133,7 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
130
133
  - Destructive actions confirm. `sesh-delete.sh` prompts on a TTY and refuses
131
134
  without `--yes` when stdin is not one; the TUI arms on the first Option-D and
132
135
  commits on the second (or `y`).
133
- - The installer prefers the local opencode version for `@opencode-ai/*` and
136
+ - The installer prefers the local opencode version for `@opencode/*` and
134
137
  merges (never clobbers) the config `package.json`; opencode runs `bun install`
135
138
  at startup.
136
139
  - Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/)
package/CHANGELOG.md CHANGED
@@ -5,6 +5,22 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.0.1] - 2026-10-10
9
+
10
+ ### Fixed
11
+
12
+ - **packaging:** Add root export for opencode plugin and broaden opentui peer range
13
+
14
+ ## [2.0.0] - 2026-10-10
15
+
16
+ ### Breaking changes
17
+
18
+ - Finish V2 session compatibility and prune semantics _(breaking)_
19
+
20
+ ### Fixed
21
+
22
+ - **tui:** Align sidebar search with Sessions heading
23
+
8
24
  ## [1.1.0] - 2026-10-09
9
25
 
10
26
  ### Fixed
@@ -472,6 +488,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
472
488
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
473
489
  CI.
474
490
 
491
+ [2.0.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.0.0...v2.0.1
492
+ [2.0.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.1.0...v2.0.0
475
493
  [1.1.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.8...v1.1.0
476
494
  [1.0.8]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.7...v1.0.8
477
495
  [1.0.7]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.6...v1.0.7
package/README.md CHANGED
@@ -12,14 +12,13 @@ The TUI plugin and agent tool require OpenCode V2 and are not compatible with V1
12
12
  `/sessions`) are scoped to the current project. `sesh` shows all of them —
13
13
  grouped by directory, full-text searchable, and previewable from anywhere.
14
14
 
15
- [![CI](https://github.com/ozandogrultan/opencode-sesh/actions/workflows/ci.yml/badge.svg)](https://github.com/ozandogrultan/opencode-sesh/actions/workflows/ci.yml)
16
15
  [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
17
16
  [![PRs welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](#contributing)
18
17
  [![GitHub stars](https://img.shields.io/github/stars/ozandogrultan/opencode-sesh?style=social)](https://github.com/ozandogrultan/opencode-sesh/stargazers)
19
18
 
20
19
  ```text
21
20
  Sessions (8) ▾ hide
22
- search…
21
+ search…
23
22
  ▾ …/Projects/portal (5)
24
23
  ├○ Retry the OAuth token refr… 2h
25
24
  ├○ Login rate limiting notes 3d
@@ -35,26 +34,49 @@ Sessions (8) ▾ hide
35
34
  ## Why sesh
36
35
 
37
36
  - **Every session, everywhere.** Sessions are grouped by project directory and
38
- sorted by pins, then recency — across *all* your projects, not just the current one.
37
+ shown across *all* your projects, not just the current one. Archived and
38
+ subagent child sessions are hidden by default.
39
39
  - **Pin what matters.** Ctrl-S pins a session and Ctrl-D pins its directory in
40
- either picker. Pins are shared across the terminal and opencode TUI and
41
- persist across restarts; pinned directories and sessions sort first.
40
+ either picker; the TUI sidebar and home list use Option-S and Option-R.
41
+ Pins are shared and persist across restarts. Pinned directories rise first;
42
+ pinned sessions sort first in the terminal picker and home list. The TUI picker
43
+ and sidebar keep sessions within each directory recency-ordered.
42
44
  - **Full-text search.** Type to match against session titles *and* the text of
43
45
  the conversation itself. Reasoning and tool output are excluded, so the index
44
46
  stays clean.
45
- - **Transcript preview.** Space shows the most recent messages first, rendered as
46
- Markdown (with `glow` if you have it).
47
- - **Resume in place.** Enter `exec`s `opencode --session <id>` in the session's
48
- own directory. Ctrl-F forks instead. Your terminal becomes the session — no
49
- tabs, no panes, no window management.
50
- - **Native TUI, too.** A recent-sessions section in the opencode sidebar plus an
47
+ - **Transcript preview.** Ctrl-P in the terminal picker or Option-P in the TUI
48
+ shows the most recent messages first, rendered as Markdown (with `glow` in
49
+ the terminal if you have it).
50
+ - **Resume in place.** Enter runs `opencode --session <id>` in the session's
51
+ own directory. Ctrl-F forks instead. The session opens in the same terminal,
52
+ and exiting opencode returns to the picker — no tabs, panes, or window management.
53
+ - **Native TUI, too.** A sessions section in the opencode sidebar plus an
51
54
  `option+o` picker, both themed by your opencode theme.
52
55
  - **Fast.** The database is scanned once per refresh and cached; keystrokes only
53
56
  re-render the last snapshot, so typing never triggers a query storm.
54
57
 
55
58
  ## Install
56
59
 
57
- With npm:
60
+ ### OpenCode plugin
61
+
62
+ Add the package to `plugins` in your OpenCode V2 `opencode.json` or
63
+ `opencode.jsonc`:
64
+
65
+ ```json
66
+ {
67
+ "$schema": "https://opencode.ai/config.json",
68
+ "plugins": ["opencode-sesh"]
69
+ }
70
+ ```
71
+
72
+ OpenCode installs the package and loads its server tool and TUI panel. Merge
73
+ this entry with your existing plugins. Remove the copied
74
+ `./tui-plugins/sesh-panel` registration and `plugins/sesh-list.ts` tool before
75
+ switching to avoid loading them twice.
76
+
77
+ ### Terminal picker
78
+
79
+ Install the CLI with npm:
58
80
 
59
81
  ```bash
60
82
  npm install -g opencode-sesh
@@ -71,7 +93,7 @@ bash install.sh
71
93
 
72
94
  `sesh install` (or `bash install.sh`) links `sesh` into `$XDG_BIN_HOME`
73
95
  (default `~/.local/bin`), copies
74
- the `sesh-list` tool and the TUI panel into
96
+ the `sesh_list` tool and the TUI panel into
75
97
  `${XDG_CONFIG_HOME:-~/.config}/opencode`, registers the panel in `opencode.json`, and
76
98
  declares the plugin dependencies (opencode installs them on next start). The
77
99
  panel registers the `/sesh` slash command. It never edits your shell rc, and any
@@ -137,23 +159,26 @@ Flags: `--cwd` (current directory only), `--limit N` (default: all),
137
159
  `--json` (with `--print`, emit JSON), `--query TEXT`, `--check`,
138
160
  `--needs-input` (list sessions waiting on you instead of opening the picker).
139
161
 
140
- `sesh prune [--older-than 30d] [--dry-run] [--yes] [--delete]` archives stale
141
- sessions (not updated within the threshold) so the list stays triageable.
142
- Pinned sessions, sessions waiting on you, subagent child sessions and already-archived
143
- sessions are never touched; archiving is reversible, while `--delete`
144
- hard-deletes through `opencode session delete`. Without `--dry-run`, a TTY run
162
+ `sesh prune [--older-than 30d] [--dry-run] [--yes]` deletes stale
163
+ sessions (not updated within the threshold) through `opencode session delete`, including their children.
164
+ Pinned sessions and directories, sessions waiting on you, subagent child sessions and already-archived
165
+ sessions are never selected. Without `--dry-run`, a TTY run
145
166
  confirms first and a non-TTY run needs `--yes`.
146
167
 
147
168
  `sesh costs [--days N] [--json]` sums assistant-message cost per project, with a
148
169
  recent window beside the lifetime total, so a day's work reads as a
149
- per-project record.
170
+ per-project record. Legacy V1 history is included when available; sessions
171
+ already present in V2 are counted only once.
172
+
173
+ Session browsing uses the V2 store. Sessions remaining only in the legacy V1
174
+ tables are not listed or resumable through sesh.
150
175
 
151
176
  When searching, title matches outrank transcript-only matches and the current
152
177
  project rises, so the list answers "where was that thing I worked on" before it
153
178
  answers "what is newest". Pins still win outright.
154
179
 
155
- Because `--print` just emits the id and directory, `sesh` doubles as a scriptable
156
- session lookup:
180
+ `--print` still opens the interactive picker, but emits the selected id and
181
+ directory instead of resuming. `--query` supplies the initial search text:
157
182
 
158
183
  ```bash
159
184
  read -r id cwd < <(sesh --print --query "auth")
@@ -163,7 +188,7 @@ sesh --print --json --query "auth" | jq -r .cwd
163
188
  Deleting is irreversible, so the picker asks before dispatching it, and a
164
189
  non-interactive `sesh-delete.sh` refuses unless given `--yes`.
165
190
 
166
- ### TUI panel
191
+ ### TUI picker
167
192
 
168
193
  | Key | Action |
169
194
  | --- | --- |
@@ -175,13 +200,16 @@ non-interactive `sesh-delete.sh` refuses unless given `--yes`.
175
200
  | `↑`/`↓`, `PgUp`/`PgDn`, `Home`/`End` | Move the selection |
176
201
  | Option-P | Toggle the transcript preview |
177
202
  | Enter | Open the selected session |
178
- | Option-D | Delete the selected session (asks to confirm) |
203
+ | Option-D | Arm deletion; press Option-D again, `y`, or Enter to confirm |
179
204
  | Ctrl-F | Fork the selected session |
180
205
  | Ctrl-G | Toggle scope: the selected session's project, or every project |
181
206
  | Option-W / Option-S | Show only sessions needing input / pinned sessions |
182
207
  | Ctrl-S / Ctrl-D | Pin or unpin the selected session / directory |
183
208
  | Esc | Close the preview first, then the picker |
184
209
 
210
+ While deletion is armed, `n` or Esc cancels; it also cancels after five seconds.
211
+ Option-O opens the picker globally; the other keys above apply inside it.
212
+
185
213
  The picker groups sessions under collapsible directory headings (click ▾/▸).
186
214
  Mouse-wheel scrolling moves through the tree without changing the selection
187
215
  under a stationary pointer. Option-P opens a full-size transcript preview over
@@ -193,10 +221,14 @@ action, or Esc to dismiss the menu.
193
221
  Right-click a session in the home list or picker to open it in a new cmux
194
222
  workspace at its directory; outside cmux it shows a toast instead.
195
223
 
196
- Click the sidebar's `search…` box to filter recent sessions in place; click
224
+ ### TUI sidebar and home list
225
+
226
+ Click the sidebar's `search…` box to filter sessions in place; click
197
227
  elsewhere or press Esc to leave search. Click the **Sessions** heading to drive
198
228
  the list from the keyboard instead of the mouse (`↑`/`↓` to move, Enter to open,
199
- Option-P to preview, Option-S/Option-R to pin, Option-D to delete, Esc to leave). Pinned
229
+ Option-P to preview, Option-S to pin a session, Option-R to pin its directory,
230
+ Option-D to arm deletion, Esc to leave). Press Option-D again or `y` to confirm
231
+ an armed deletion; `n` or Esc cancels. Pinned
200
232
  directories rise first, while sessions within each directory are ordered by
201
233
  last update, newest first. The section lists every session and scrolls to fill
202
234
  the sidebar, so click **▾ hide** to collapse it when you need the room.
@@ -209,6 +241,12 @@ Hovering a row while keyboard navigation is active selects it for the next
209
241
  shortcut. Clicking (or Enter on) a session opens it here. The full picker is one
210
242
  keystroke away.
211
243
 
244
+ The home list also supports search and the Option-P, Option-S, Option-R, and
245
+ Option-D actions on the hovered session. These Option pin shortcuts do not
246
+ replace the picker's Ctrl-S/Ctrl-D bindings; Option-S in the picker filters pins.
247
+
248
+ ### Transcript search and needs-input triage
249
+
212
250
  The full picker pages through your whole global session list (no fixed window)
213
251
  and indexes transcript text for every session in the background, showing
214
252
  indexing progress under the search box. It only stops at a safety cap of 5,000
@@ -225,12 +263,12 @@ All variables are optional.
225
263
 
226
264
  | Variable | Default | Effect |
227
265
  | --- | --- | --- |
228
- | `SESH_DB` | `opencode debug paths` | opencode SQLite database |
266
+ | `SESH_DB` | `${XDG_DATA_HOME:-~/.local/share}/opencode/opencode.db`, falling back to `opencode debug paths` | opencode SQLite database |
229
267
  | `SESH_SQLITE` | `sqlite3` | query tool |
230
268
  | `SESH_JQ` / `SESH_FZF` | `PATH` | explicit executable overrides |
231
269
  | `SESH_GLOW` | first `glow` on `PATH` | optional Markdown preview renderer |
232
270
  | `SESH_OPENCODE` | `opencode` | opencode executable (resume / delete) |
233
- | `SESH_CACHE_DIR` | `~/.cache/sesh` | persistent search-index cache |
271
+ | `SESH_CACHE_DIR` | `${XDG_CACHE_HOME:-~/.cache}/sesh` | persistent search-index cache |
234
272
  | `SESH_PINS_FILE` | `${XDG_DATA_HOME:-~/.local/share}/sesh/pins.json` | shared persistent session/directory pins |
235
273
 
236
274
  ## Integrations
@@ -256,7 +294,7 @@ Flat scripts back the terminal UI — the picker (`sesh.sh`), the refresh engine
256
294
  scans), preview renderer (`sesh-preview.sh`), deleter (`sesh-delete.sh`) and
257
295
  shortcut help (`sesh-shortcuts.sh`). The TUI panel (`tui-plugins/sesh-panel/tui.tsx`) is a
258
296
  SolidJS OpenTUI plugin that talks to the opencode SDK over the same store; the
259
- `sesh-list` agent tool reads it read-only through `bun:sqlite`. V2 discovers
297
+ `sesh_list` agent tool reads it read-only through `bun:sqlite`. V2 discovers
260
298
  the installed tool automatically in the global config directory's `plugins/`.
261
299
 
262
300
  ## FAQ
@@ -264,12 +302,13 @@ the installed tool automatically in the global config directory's `plugins/`.
264
302
  **Does it replace opencode's native session list?** No. `<leader>l` and the
265
303
  native `/sessions` command are untouched; `sesh` is additive.
266
304
 
267
- **Is there an agent-facing list?** Yes — the `sesh-list` tool lets the model list
305
+ **Is there an agent-facing list?** Yes — the `sesh_list` tool lets the model list
268
306
  every session across all directories (the native `opencode session list` only
269
307
  covers the current project) and offer to resume one.
270
308
 
271
309
  **Where is my data?** It reads the opencode database read-only and caches
272
- extracted text under `~/.cache/sesh`. Nothing is uploaded anywhere.
310
+ extracted text under `${XDG_CACHE_HOME:-~/.cache}/sesh` (or `SESH_CACHE_DIR`).
311
+ Nothing is uploaded anywhere.
273
312
 
274
313
  **Does it work on Windows?** It targets macOS and Linux. WSL should work; native
275
314
  Windows is untested.
package/bin/sesh-costs.sh CHANGED
@@ -32,7 +32,7 @@ resolve_db() {
32
32
  local default="${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db" from_cli=''
33
33
  if [ -f "$default" ]; then printf '%s\n' "$default"; return 0; fi
34
34
  if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
35
- from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { print $2; exit }' || true)
35
+ from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { sub(/^db[ \t]+/, ""); print; exit }' || true)
36
36
  fi
37
37
  if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
38
38
  return 1
@@ -51,19 +51,33 @@ DB_PATH=$(resolve_db) || { echo 'sesh: session database unavailable' >&2; exit 1
51
51
  NOW_MS=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || echo "$(( $(date +%s) * 1000 ))")
52
52
  CUTOFF=$((NOW_MS - days * 86400000))
53
53
 
54
+ [ -n "$SQLITE_BIN" ] || { echo 'sesh: sqlite3 is required' >&2; exit 1; }
55
+ LEGACY_QUERY=''
56
+ if [ "$("$SQLITE_BIN" -readonly "$DB_PATH" "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name IN ('session', 'message');")" = 2 ]; then
57
+ LEGACY_QUERY="UNION ALL
58
+ SELECT s.directory, s.id AS session_id, m.time_created, m.data
59
+ FROM message m JOIN session s ON s.id = m.session_id
60
+ WHERE json_extract(m.data, '\$.role') = 'assistant'
61
+ AND NOT EXISTS (SELECT 1 FROM session_v2 v WHERE v.id = s.id)"
62
+ fi
63
+
54
64
  # One pass over assistant messages; the window filter is a cheap CASE so the
55
65
  # lifetime total and the window total come back together.
56
66
  ROWS_QUERY="
57
- SELECT s.directory AS directory,
67
+ WITH costs AS (
68
+ SELECT s.directory, s.id AS session_id, m.time_created, m.data
69
+ FROM session_message m JOIN session_v2 s ON s.id = m.session_id
70
+ WHERE m.type = 'assistant'
71
+ $LEGACY_QUERY
72
+ )
73
+ SELECT m.directory AS directory,
58
74
  ROUND(SUM(CASE WHEN m.time_created >= $CUTOFF THEN COALESCE(json_extract(m.data, '\$.cost'), 0) ELSE 0 END), 4) AS window_cost,
59
75
  ROUND(SUM(COALESCE(json_extract(m.data, '\$.cost'), 0)), 4) AS lifetime_cost,
60
- COUNT(DISTINCT s.id) AS sessions,
76
+ COUNT(DISTINCT m.session_id) AS sessions,
61
77
  SUM(CASE WHEN m.time_created >= $CUTOFF THEN COALESCE(json_extract(m.data, '\$.tokens.input'), 0) + COALESCE(json_extract(m.data, '\$.tokens.output'), 0) ELSE 0 END) AS window_tokens
62
- FROM session_message m
63
- JOIN session_v2 s ON s.id = m.session_id
64
- WHERE m.type = 'assistant'
65
- AND json_extract(m.data, '\$.cost') IS NOT NULL
66
- GROUP BY s.directory
78
+ FROM costs m
79
+ WHERE json_extract(m.data, '\$.cost') IS NOT NULL
80
+ GROUP BY m.directory
67
81
  ORDER BY lifetime_cost DESC;"
68
82
 
69
83
  [ -n "$SQLITE_BIN" ] || { echo 'sesh: sqlite3 is required' >&2; exit 1; }
package/bin/sesh-list.sh CHANGED
@@ -79,7 +79,7 @@ resolve_db() {
79
79
  local default="${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db" from_cli=''
80
80
  if [ -f "$default" ]; then printf '%s\n' "$default"; return 0; fi
81
81
  if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
82
- from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { print $2; exit }' || true)
82
+ from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { sub(/^db[ \t]+/, ""); print; exit }' || true)
83
83
  fi
84
84
  if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
85
85
  return 1
@@ -279,7 +279,7 @@ refresh() {
279
279
  db_json "SELECT session_id, COUNT(*) AS n, MAX(time_updated) AS m FROM session_message GROUP BY session_id;" > "$parts" 2>/dev/null || printf '[]\n' > "$parts"
280
280
  "$JQ_BIN" -e 'type == "array"' "$parts" >/dev/null 2>&1 || printf '[]\n' > "$parts"
281
281
 
282
- # Same bulk cache validation as the Claude list: every cache arrives as its
282
+ # Same bulk cache validation as the full list: every cache arrives as its
283
283
  # own file argument (ARG_MAX), records match on session id plus the two
284
284
  # change signals, and only vanished sessions are pruned — metadata always
285
285
  # covers the whole database, so pruning is safe under any scope or limit.
@@ -378,8 +378,8 @@ refresh() {
378
378
  | ($hist | map({key: .sessionId, value: .}) | from_entries) as $histById
379
379
  | [$meta[]
380
380
  | select((.id | type) == "string" and (.id | test("^ses_[A-Za-z0-9]+$")))
381
- # Child sessions (fork children, spawned subagents) surface only with
382
- # --archived; metadata still sees them, so cache pruning stays safe.
381
+ # Child sessions (spawned subagents) surface only with --archived;
382
+ # metadata still sees them, so cache pruning stays safe.
383
383
  | select($archived == 1 or (.archived == 0 and .parent == ""))
384
384
  | select($scope == "" or .directory == $scope)
385
385
  | . as $m | ($histById[$m.id] // {}) as $h
@@ -17,7 +17,7 @@ JQ_BIN=${SESH_JQ:-jq}
17
17
  OPENCODE_BIN=${SESH_OPENCODE:-opencode}
18
18
  DB_PATH=${SESH_DB:-"${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db"}
19
19
  if [ -z "${SESH_DB:-}" ] && [ ! -f "$DB_PATH" ] && command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
20
- DB_PATH=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { print $2; exit }' || true)
20
+ DB_PATH=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { sub(/^db[ \t]+/, ""); print; exit }' || true)
21
21
  fi
22
22
  [ -f "$DB_PATH" ] || { echo "(opencode database is unavailable)"; exit 0; }
23
23
 
package/bin/sesh-prune.sh CHANGED
@@ -1,15 +1,9 @@
1
1
  #!/usr/bin/env bash
2
- # Archive (or, with --delete, hard-delete) stale sessions: not updated within
2
+ # Delete stale sessions: not updated within
3
3
  # the threshold, never pinned, never waiting on the user, never already
4
4
  # archived, never a child (subagent) session.
5
5
  #
6
- # Archive is the default because it is reversible; hard deletes go through
7
- # `opencode session delete` so its own safeguards apply. The archive itself
8
- # is a single atomic UPDATE through sqlite3 — the opencode server API has no
9
- # archive endpoint, so a direct write is the only route (deletes stay on the
10
- # CLI).
11
- #
12
- # Usage: sesh-prune.sh [--older-than 30d] [--dry-run] [--yes] [--delete]
6
+ # Usage: sesh-prune.sh [--older-than 30d] [--dry-run] [--yes]
13
7
  # --older-than Nd|Nh|Nw (bare N means days; default 30d)
14
8
  # --dry-run lists candidates and changes nothing
15
9
  # without --dry-run, a TTY run confirms first; a non-TTY run needs --yes
@@ -21,15 +15,13 @@ OPENCODE_BIN=${SESH_OPENCODE:-opencode}
21
15
  older_than=30d
22
16
  dry_run=0
23
17
  assume_yes=0
24
- hard_delete=0
25
18
  while [ "$#" -gt 0 ]; do
26
19
  case "$1" in
27
20
  --older-than) older_than=${2:-}; shift ;;
28
21
  --older-than=*) older_than=${1#--older-than=} ;;
29
22
  --dry-run) dry_run=1 ;;
30
23
  --yes|-y) assume_yes=1 ;;
31
- --delete) hard_delete=1 ;;
32
- --help) echo "usage: ${0##*/} [--older-than 30d] [--dry-run] [--yes] [--delete]"; exit 0 ;;
24
+ --help) echo "usage: ${0##*/} [--older-than 30d] [--dry-run] [--yes]"; exit 0 ;;
33
25
  *) echo "unknown option: $1" >&2; exit 2 ;;
34
26
  esac
35
27
  shift
@@ -56,7 +48,7 @@ resolve_db() {
56
48
  local default="${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db" from_cli=''
57
49
  if [ -f "$default" ]; then printf '%s\n' "$default"; return 0; fi
58
50
  if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
59
- from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { print $2; exit }' || true)
51
+ from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { sub(/^db[ \t]+/, ""); print; exit }' || true)
60
52
  fi
61
53
  if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
62
54
  return 1
@@ -74,7 +66,6 @@ DB_PATH=$(resolve_db) || { echo 'sesh: session database unavailable' >&2; exit 1
74
66
 
75
67
  [ -n "$SQLITE_BIN" ] || { echo 'sesh: sqlite3 is required' >&2; exit 1; }
76
68
  query() { "$SQLITE_BIN" -json -readonly "$DB_PATH" "$1"; }
77
- write_db() { "$SQLITE_BIN" "$DB_PATH" "$1"; }
78
69
 
79
70
  JQ_BIN=${SESH_JQ:-$(command -v jq || true)}
80
71
  [ -n "$JQ_BIN" ] || { echo 'sesh: jq is required' >&2; exit 1; }
@@ -98,8 +89,7 @@ ELIGIBLE=$(printf '%s' "$CANDIDATES" | "$JQ_BIN" -c --slurpfile _pins <(printf '
98
89
  ]
99
90
  ')
100
91
  COUNT=$(printf '%s' "$ELIGIBLE" | "$JQ_BIN" -r 'length')
101
- VERB=Archive
102
- [ "$hard_delete" = 0 ] || VERB=Delete
92
+ VERB=Delete
103
93
 
104
94
  if [ "$COUNT" = 0 ]; then
105
95
  echo "Nothing to prune older than $older_than."
@@ -137,29 +127,17 @@ if [ "$assume_yes" = 0 ]; then
137
127
  fi
138
128
  fi
139
129
 
140
- if [ "$hard_delete" = 0 ]; then
141
- IDS=$(printf '%s' "$ELIGIBLE" | "$JQ_BIN" -r '.[].id')
142
- IN_LIST=''
143
- for id in $IDS; do
144
- [[ "$id" =~ ^ses_[A-Za-z0-9]+$ ]] || { echo "Refusing: unexpected session id shape." >&2; exit 1; }
145
- IN_LIST="$IN_LIST'$id',"
146
- done
147
- IN_LIST=${IN_LIST%,}
148
- write_db "UPDATE session_v2 SET time_archived = $NOW_MS WHERE id IN ($IN_LIST);"
149
- echo "Archived $COUNT session(s) older than $older_than."
150
- else
151
- command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'sesh: opencode executable is unavailable for deletes.' >&2; exit 1; }
152
- failed=0
153
- done_count=0
154
- for id in $(printf '%s' "$ELIGIBLE" | "$JQ_BIN" -r '.[].id'); do
155
- [[ "$id" =~ ^ses_[A-Za-z0-9]+$ ]] || { echo "Refusing: unexpected session id shape." >&2; exit 1; }
156
- if "$OPENCODE_BIN" session delete "$id" >/dev/null 2>&1; then
157
- done_count=$((done_count + 1))
158
- else
159
- echo "Could not delete $id." >&2
160
- failed=$((failed + 1))
161
- fi
162
- done
163
- echo "Deleted $done_count session(s) older than $older_than."
164
- [ "$failed" = 0 ] || exit 1
165
- fi
130
+ command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'sesh: opencode executable is unavailable for deletes.' >&2; exit 1; }
131
+ failed=0
132
+ done_count=0
133
+ for id in $(printf '%s' "$ELIGIBLE" | "$JQ_BIN" -r '.[].id'); do
134
+ [[ "$id" =~ ^ses_[A-Za-z0-9]+$ ]] || { echo "Refusing: unexpected session id shape." >&2; exit 1; }
135
+ if "$OPENCODE_BIN" session delete "$id" >/dev/null 2>&1; then
136
+ done_count=$((done_count + 1))
137
+ else
138
+ echo "Could not delete $id." >&2
139
+ failed=$((failed + 1))
140
+ fi
141
+ done
142
+ echo "Deleted $done_count session(s) older than $older_than."
143
+ [ "$failed" = 0 ] || exit 1
@@ -15,6 +15,11 @@ sesh shortcuts
15
15
  Escape Exit the picker
16
16
 
17
17
  Flags: --cwd (current directory only), --limit N (default: all),
18
- --archived (include archived), --print (print id instead of resuming),
19
- --json (with --print, emit JSON)
18
+ --archived (include archived and child sessions),
19
+ --print (print selected id<TAB>cwd instead of resuming),
20
+ --json (with --print, emit JSON), --fork (resume as a fork),
21
+ --query TEXT (initial search), --check (check dependencies),
22
+ --needs-input [--json] (list sessions waiting on you)
23
+
24
+ --print still opens the picker; select a session with Enter or Ctrl-F.
20
25
  HELP
@@ -5,12 +5,16 @@
5
5
  # owned by a running TUI server, whose in-memory permission/question state
6
6
  # is invisible cross-project.
7
7
  #
8
- # A session waits when it is not archived, is not a fork child, and either
8
+ # A session waits when it is not archived, is not a child (subagent) session,
9
+ # and either
9
10
  # - has a `question` tool part still pending or running with no later user
10
11
  # message in the same session (awaiting an answer; a dismissed question
11
12
  # that errored is not waiting), or
12
- # - has a `tool` part still marked `running` older than STUCK_AFTER_MS
13
- # (awaiting approval, or orphaned by a dead server).
13
+ # - has a `tool` part still marked `running` older than STUCK_AFTER_MS with
14
+ # no message activity in the session or its direct children since then
15
+ # (a long-running subagent/shell tool with recent activity is not stuck;
16
+ # V2 resumes interrupted runs server-side, so `running` with no activity
17
+ # means orphaned or awaiting approval).
14
18
  #
15
19
  # Usage: sesh-waiting.sh [--json]
16
20
  # default prints a human table; --json prints [{id,reason,title,directory,updated}]
@@ -34,7 +38,7 @@ resolve_db() {
34
38
  local default="${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db" from_cli=''
35
39
  if [ -f "$default" ]; then printf '%s\n' "$default"; return 0; fi
36
40
  if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
37
- from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { print $2; exit }' || true)
41
+ from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { sub(/^db[ \t]+/, ""); print; exit }' || true)
38
42
  fi
39
43
  if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
40
44
  return 1
@@ -83,9 +87,12 @@ WHERE COALESCE(s.time_archived, 0) = 0
83
87
  AND NOT EXISTS (
84
88
  SELECT 1 FROM session_message u
85
89
  WHERE u.session_id = live.session_id AND u.type = 'user' AND u.seq > live.seq)
86
- ) OR EXISTS (
90
+ ) OR (EXISTS (
87
91
  SELECT 1 FROM live
88
- WHERE live.session_id = s.id AND live.status = 'running' AND live.created < $STUCK_BEFORE))
92
+ WHERE live.session_id = s.id AND live.status = 'running' AND live.created < $STUCK_BEFORE)
93
+ AND NOT EXISTS (
94
+ SELECT 1 FROM session_message r JOIN session_v2 f ON f.id = r.session_id
95
+ WHERE (f.id = s.id OR f.parent_id = s.id) AND r.time_updated >= $STUCK_BEFORE)))
89
96
  ORDER BY s.time_updated DESC;
90
97
  SQL
91
98
 
package/bin/sesh.sh CHANGED
@@ -203,11 +203,15 @@ fi
203
203
  command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'sesh: opencode executable unavailable' >&2; exit 1; }
204
204
  # Resume in place: this terminal becomes the session. No tabs, no panes.
205
205
  if [ "$fork" = 1 ]; then
206
- forked=$(cd "$cwd" && "$OPENCODE_BIN" api session.fork --param "sessionID=$session_id" -d '{}' 2>/dev/null | "$SESH_JQ" -r '.data.id // empty' 2>/dev/null || true)
206
+ fork_err="$state_dir/fork-err"
207
+ forked=$(cd "$cwd" && "$OPENCODE_BIN" api session.fork --param "sessionID=$session_id" -d '{}' 2>"$fork_err" | "$SESH_JQ" -r '.data.id // empty' 2>/dev/null || true)
207
208
  if [[ "$forked" =~ ^ses_[A-Za-z0-9]+$ ]]; then
208
209
  session_id=$forked
210
+ rm -f "$fork_err"
209
211
  else
210
212
  echo 'sesh: could not fork the session; returning to the picker.' >&2
213
+ [ -s "$fork_err" ] && sed 's/^/sesh: /' "$fork_err" >&2
214
+ rm -f "$fork_err"
211
215
  continue
212
216
  fi
213
217
  fi
@@ -20,7 +20,8 @@ Paste only the actions you want; the `ui.newWorkspace` block is optional.
20
20
 
21
21
  ## Resume a session in its own cmux workspace
22
22
 
23
- The picker resumes in place (it `cd`s and `exec`s opencode). To instead open a
23
+ The picker runs opencode in the selected session's directory and returns to the
24
+ picker when opencode exits. To instead open a
24
25
  session in a **new cmux workspace** at its directory, shell out to cmux after
25
26
  resolving the session with sesh's scriptable lookup:
26
27
 
@@ -57,9 +58,10 @@ sesh is a normal CLI, so any system-wide launcher works — a cmux `shortcut`, a
57
58
  cmd + shift - s : cmux new-workspace --command "sesh"
58
59
  ```
59
60
 
60
- Because `--print --json` resolves a session non-interactively, the same hook can
61
- resume a specific session without any UI:
61
+ `--print --json` still opens the interactive picker; `--query` initializes its
62
+ search and `--limit` caps the list rather than automatically accepting a result.
63
+ To resume a known session without the picker, use its id and directory directly:
62
64
 
63
65
  ```bash
64
- cmd + alt - r : cmux new-workspace --command "opencode --session $(sesh --print --json --limit 1 | jq -r .sessionId)"
66
+ cmd + alt - r : cmux new-workspace --cwd /path/to/project --command "opencode --session ses_EXAMPLE"
65
67
  ```
package/install.sh CHANGED
@@ -24,7 +24,7 @@ SYNC_PANEL=0
24
24
 
25
25
  # Fallback plugin version for when the opencode CLI is unavailable; the
26
26
  # installer prefers the local opencode version so the panel matches its SDK.
27
- PLUGIN_VERSION_FALLBACK="2.0.18"
27
+ PLUGIN_VERSION_FALLBACK="2.0.25"
28
28
  # Peer line the panel was developed and typechecked against. A checkout's
29
29
  # package.json overrides these, so dependency bumps cannot drift the installed
30
30
  # config out of sync (solid-js moves with OpenTUI via @opentui/keymap's peer).
@@ -42,7 +42,7 @@ usage() {
42
42
  Usage: bash install.sh [--bin-dir DIR] [--no-tool] [--no-tui] [--uninstall]
43
43
 
44
44
  --bin-dir DIR Directory for the sesh launcher (default: ~/.local/bin).
45
- --no-tool Skip installing the sesh-list custom tool.
45
+ --no-tool Skip installing the sesh_list custom tool.
46
46
  --no-tui Skip installing the in-TUI sessions panel plugin.
47
47
  --uninstall Remove everything this script installed.
48
48
  --sync-panel Refresh an already-installed TUI panel only (used by postinstall).
@@ -314,7 +314,7 @@ fi
314
314
  "$LAUNCHER" --check || fail "post-install check failed."
315
315
  note "done"
316
316
  note " picker: run 'sesh' in any terminal, or '/sesh' inside opencode"
317
- note " sidebar: alt+o opens the full picker"
317
+ note " TUI: Option-O opens the full picker"
318
318
  note_restart
319
319
  case ":$PATH:" in
320
320
  *":$BIN_DIR:"*) ;;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "1.1.0",
3
+ "version": "2.0.1",
4
4
  "description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -22,6 +22,7 @@
22
22
  "cli"
23
23
  ],
24
24
  "exports": {
25
+ ".": "./plugins/sesh-list.ts",
25
26
  "./tui": "./tui-plugins/sesh-panel/tui.tsx",
26
27
  "./tool": "./plugins/sesh-list.ts"
27
28
  },
@@ -74,7 +75,7 @@
74
75
  "@opentui/core": "^0.5.11",
75
76
  "@opentui/keymap": "^0.5.11",
76
77
  "@opentui/solid": "^0.5.11",
77
- "@types/node": "26.5.1",
78
+ "@types/node": "26.6.4",
78
79
  "husky": "^9.1.7",
79
80
  "solid-js": "1.9.15",
80
81
  "typescript": "7.0.2"
@@ -1,9 +1,19 @@
1
1
  import { Plugin } from "@opencode/plugin"
2
+ import { spawnSync } from "node:child_process"
3
+ import { existsSync } from "node:fs"
2
4
  import { homedir } from "node:os"
3
5
  import { join } from "node:path"
4
6
 
5
7
  function dbPath(): string {
6
- return process.env.SESH_DB ?? join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "opencode", "opencode.db")
8
+ if (process.env.SESH_DB) return process.env.SESH_DB
9
+ const xdgPath = join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "opencode", "opencode.db")
10
+ if (existsSync(xdgPath)) return xdgPath
11
+ try {
12
+ const result = spawnSync(process.env.SESH_OPENCODE ?? "opencode", ["debug", "paths"], { encoding: "utf8" })
13
+ const match = result.stdout?.match(/^db\s+(.+)$/m)
14
+ if (match) return match[1].trimEnd()
15
+ } catch {}
16
+ return xdgPath
7
17
  }
8
18
 
9
19
  const input = {
@@ -7,8 +7,9 @@ type TuiPluginApi = ReturnType<typeof createApi>
7
7
  type TuiPlugin = (api: TuiPluginApi) => Promise<void>
8
8
  import { createEffect, createMemo, createRoot, createSignal, For, getOwner, on, onCleanup, onMount, runWithOwner, Show } from "solid-js"
9
9
  import { mkdir, readFile, rename, rmdir, unlink, writeFile } from "node:fs/promises"
10
+ import { existsSync } from "node:fs"
10
11
  import { dirname, join } from "node:path"
11
- import { spawn } from "node:child_process"
12
+ import { spawn, spawnSync } from "node:child_process"
12
13
  import { getTreeSitterClient, RGBA, SyntaxStyle, TextAttributes } from "@opentui/core"
13
14
  import { Portal } from "@opentui/solid"
14
15
 
@@ -16,8 +17,8 @@ type ThemeColors = TuiPluginApi["theme"]["current"]
16
17
 
17
18
  let markdownStyleCache: { theme: ThemeColors; style: SyntaxStyle } | undefined
18
19
 
19
- function buildMarkdownStyle(theme: ThemeColors): SyntaxStyle {
20
- return SyntaxStyle.fromTheme([
20
+ function buildMarkdownStyle(theme: ThemeColors, owner?: unknown): SyntaxStyle {
21
+ return (SyntaxStyle as any).fromTheme([
21
22
  { scope: ["default"], style: { foreground: theme.text } },
22
23
  { scope: ["comment", "comment.documentation"], style: { foreground: theme.syntaxComment, italic: true } },
23
24
  { scope: ["string", "symbol", "character"], style: { foreground: theme.syntaxString } },
@@ -80,17 +81,17 @@ function buildMarkdownStyle(theme: ThemeColors): SyntaxStyle {
80
81
  },
81
82
  { scope: ["markup.link.label", "label"], style: { foreground: theme.markdownLinkText, underline: true } },
82
83
  { scope: ["conceal"], style: { foreground: theme.textMuted } },
83
- ])
84
+ ], owner)
84
85
  }
85
86
 
86
- function getMarkdownStyle(theme: ThemeColors): SyntaxStyle | undefined {
87
+ function getMarkdownStyle(theme: ThemeColors, owner?: unknown): SyntaxStyle | undefined {
87
88
  if (markdownStyleCache?.theme === theme) return markdownStyleCache?.style
88
89
  try {
89
- markdownStyleCache = { theme, style: buildMarkdownStyle(theme) }
90
+ markdownStyleCache = { theme, style: buildMarkdownStyle(theme, owner) }
90
91
  return markdownStyleCache?.style
91
92
  } catch {
92
93
  try {
93
- return SyntaxStyle.create()
94
+ return (SyntaxStyle as any).create(owner)
94
95
  } catch {
95
96
  return undefined
96
97
  }
@@ -502,26 +503,63 @@ type IndexProgress = { indexed: number; total: number; complete: boolean }
502
503
 
503
504
  const nextTick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
504
505
 
506
+ function resolveDbPath(): string {
507
+ if (process.env.SESH_DB) return process.env.SESH_DB
508
+ const home = process.env.HOME ?? ""
509
+ const dataHome = process.env.XDG_DATA_HOME ?? `${home}/.local/share`
510
+ const xdgPath = `${dataHome}/opencode/opencode.db`
511
+ if (existsSync(xdgPath)) return xdgPath
512
+ try {
513
+ const result = spawnSync(process.env.SESH_OPENCODE ?? "opencode", ["debug", "paths"], { encoding: "utf8" })
514
+ const match = result.stdout?.match(/^db\s+(.+)$/m)
515
+ if (match) return match[1].trimEnd()
516
+ } catch {}
517
+ return xdgPath
518
+ }
519
+
505
520
  async function openTranscriptDb(): Promise<any | undefined> {
506
521
  try {
507
- const home = process.env.HOME ?? ""
508
- const dataHome = process.env.XDG_DATA_HOME ?? `${home}/.local/share`
509
522
  // @ts-ignore - bun:sqlite ships with the Bun runtime (verified); no type package installed
510
523
  const sqlite: any = await import("bun:sqlite")
511
524
  if (typeof sqlite?.Database !== "function") return undefined
512
- return new sqlite.Database(process.env.SESH_DB ?? `${dataHome}/opencode/opencode.db`, { readonly: true })
525
+ return new sqlite.Database(resolveDbPath(), { readonly: true })
513
526
  } catch {
514
527
  return undefined
515
528
  }
516
529
  }
517
530
 
531
+ function costRowsQuery(cutoff: number, includeLegacy: boolean): string {
532
+ const legacy = includeLegacy ? `UNION ALL
533
+ SELECT s.directory, s.id AS session_id, m.time_created, m.data
534
+ FROM message m JOIN session s ON s.id = m.session_id
535
+ WHERE json_extract(m.data, '$.role') = 'assistant'
536
+ AND NOT EXISTS (SELECT 1 FROM session_v2 v WHERE v.id = s.id)` : ""
537
+ return `WITH costs AS (
538
+ SELECT s.directory, s.id AS session_id, m.time_created, m.data
539
+ FROM session_message m JOIN session_v2 s ON s.id = m.session_id
540
+ WHERE m.type = 'assistant'
541
+ ${legacy}
542
+ )
543
+ SELECT m.directory AS directory,
544
+ ROUND(SUM(CASE WHEN m.time_created >= ${cutoff} THEN COALESCE(json_extract(m.data, '$.cost'), 0) ELSE 0 END), 4) AS window_cost,
545
+ ROUND(SUM(COALESCE(json_extract(m.data, '$.cost'), 0)), 4) AS lifetime_cost,
546
+ COUNT(DISTINCT m.session_id) AS sessions
547
+ FROM costs m
548
+ WHERE json_extract(m.data, '$.cost') IS NOT NULL
549
+ GROUP BY m.directory
550
+ ORDER BY lifetime_cost DESC`
551
+ }
552
+
518
553
  // NEEDS_INPUT_SQL: sessions waiting on the user (shared heuristic — keep in
519
554
  // sync with bin/sesh-waiting.sh). A session waits when it is not archived,
520
- // is not a fork child, and either has an unanswered `question` tool part or
521
- // a `tool` part still marked running older than STUCK_AFTER_MS (awaiting
522
- // approval, or orphaned by a dead server). Server-independent on purpose:
523
- // the TUI sync layer only sees permission/question state for sessions owned
524
- // by the current server, which is invisible cross-project.
555
+ // is not a child (subagent) session, and either has an unanswered `question`
556
+ // tool part or a `tool` part still marked running older than STUCK_AFTER_MS
557
+ // with no message activity in the session or its direct children since then (a
558
+ // long-running subagent/shell tool with recent activity is not stuck; V2
559
+ // resumes interrupted runs server-side, so `running` with no activity means
560
+ // orphaned or awaiting approval). Server-independent on purpose: the TUI
561
+ // sync layer only sees permission/question state for sessions owned by the
562
+ // current server, which is invisible cross-project.
525
563
  const STUCK_AFTER_MS = 600000
526
564
  const LIVE_TOOLS_CTE = (sessionFilter: string) => `
527
565
  WITH live AS (
@@ -539,6 +577,9 @@ const QUESTION_OPEN = `live.name = 'question'
539
577
  AND NOT EXISTS (
540
578
  SELECT 1 FROM session_message u
541
579
  WHERE u.session_id = live.session_id AND u.type = 'user' AND u.seq > live.seq)`
580
+ const NO_RECENT_ACTIVITY = (sessionIdExpr: string, stuckBefore: number) => `NOT EXISTS (
581
+ SELECT 1 FROM session_message r JOIN session_v2 f ON f.id = r.session_id
582
+ WHERE (f.id = ${sessionIdExpr} OR f.parent_id = ${sessionIdExpr}) AND r.time_updated >= ${stuckBefore})`
542
583
  const NEEDS_INPUT_SQL = (stuckBefore: number) => `${LIVE_TOOLS_CTE("")}
543
584
  SELECT s.id AS id,
544
585
  CASE WHEN EXISTS (SELECT 1 FROM live WHERE live.session_id = s.id AND ${QUESTION_OPEN})
@@ -547,9 +588,10 @@ FROM session_v2 s
547
588
  WHERE COALESCE(s.time_archived, 0) = 0
548
589
  AND s.parent_id IS NULL
549
590
  AND (EXISTS (SELECT 1 FROM live WHERE live.session_id = s.id AND ${QUESTION_OPEN})
550
- OR EXISTS (
591
+ OR (EXISTS (
551
592
  SELECT 1 FROM live
552
- WHERE live.session_id = s.id AND live.status = 'running' AND live.created < ${stuckBefore}))
593
+ WHERE live.session_id = s.id AND live.status = 'running' AND live.created < ${stuckBefore})
594
+ AND ${NO_RECENT_ACTIVITY("s.id", stuckBefore)}))
553
595
  ORDER BY s.time_updated DESC`
554
596
 
555
597
  async function queryWaitingIds(db: any): Promise<Map<string, string>> {
@@ -679,7 +721,7 @@ async function buildSearchIndexRemote(
679
721
  onEach: () => void,
680
722
  ): Promise<void> {
681
723
  const home = process.env.HOME ?? ""
682
- const cacheDir = process.env.SESH_CACHE_DIR ?? `${home}/.cache/sesh`
724
+ const cacheDir = process.env.SESH_CACHE_DIR ?? `${process.env.XDG_CACHE_HOME ?? `${home}/.cache`}/sesh`
683
725
  let cursor = 0
684
726
  // Bounded concurrency: a fixed worker pool instead of one request per session.
685
727
  const worker = async () => {
@@ -865,10 +907,10 @@ function createTranscriptPreview(api: TuiPluginApi) {
865
907
  scrollbarOptions={{ visible: false }}
866
908
  maxHeight={Math.max(6, Math.floor(api.renderer.height / 2))}
867
909
  >
868
- {getMarkdownStyle(api.theme.current) ? (
910
+ {getMarkdownStyle(api.theme.current, (api.renderer as any)?.nativeSession) ? (
869
911
  <markdown
870
912
  content={previewText()}
871
- syntaxStyle={getMarkdownStyle(api.theme.current)!}
913
+ syntaxStyle={getMarkdownStyle(api.theme.current, (api.renderer as any)?.nativeSession)!}
872
914
  treeSitterClient={getTreeSitterClient()}
873
915
  />
874
916
  ) : (
@@ -1558,7 +1600,6 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1558
1600
  <box
1559
1601
  flexDirection="row"
1560
1602
  gap={1}
1561
- paddingLeft={1}
1562
1603
  paddingRight={1}
1563
1604
  backgroundColor={searching() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
1564
1605
  onMouseDown={(event: { stopPropagation: () => void }) => {
@@ -1576,7 +1617,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1576
1617
  wrapMode="none"
1577
1618
  style={{ fg: query() ? theme().text : theme().textMuted }}
1578
1619
  >
1579
- {query() || "search…"}
1620
+ {query() || (searching() ? "" : "search…")}
1580
1621
  {searching() ? "▏" : ""}
1581
1622
  </text>
1582
1623
  <Show when={query()}>
@@ -2001,7 +2042,7 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
2001
2042
  wrapMode="none"
2002
2043
  style={{ fg: query() ? theme().text : theme().textMuted }}
2003
2044
  >
2004
- {query() || "search…"}
2045
+ {query() || (searching() ? "" : "search…")}
2005
2046
  {searching() ? "▏" : ""}
2006
2047
  </text>
2007
2048
  </box>
@@ -2574,6 +2615,7 @@ const tui: TuiPlugin = async (api) => {
2574
2615
  focusedBackgroundColor={api.theme.current.backgroundPanel}
2575
2616
  focusedTextColor={api.theme.current.text}
2576
2617
  cursorColor={api.theme.current.primary}
2618
+ cursorStyle={{ style: "line", blinking: false }}
2577
2619
  onInput={(value) => {
2578
2620
  if (pendingDelete()) return
2579
2621
  setQuery(value)
@@ -2812,10 +2854,10 @@ const tui: TuiPlugin = async (api) => {
2812
2854
  <Show when={previewID()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Select a session to preview</text>}>
2813
2855
  <Show when={previewText()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Loading…</text>}>
2814
2856
  {(text: Accessor<string>) =>
2815
- getMarkdownStyle(api.theme.current) ? (
2857
+ getMarkdownStyle(api.theme.current, (api.renderer as any)?.nativeSession) ? (
2816
2858
  <markdown
2817
2859
  content={text()}
2818
- syntaxStyle={getMarkdownStyle(api.theme.current)!}
2860
+ syntaxStyle={getMarkdownStyle(api.theme.current, (api.renderer as any)?.nativeSession)!}
2819
2861
  treeSitterClient={getTreeSitterClient()}
2820
2862
  />
2821
2863
  ) : (
@@ -2851,17 +2893,8 @@ const tui: TuiPlugin = async (api) => {
2851
2893
  else {
2852
2894
  try {
2853
2895
  const cutoff = Date.now() - 86400000
2854
- const raw = (db.query(`
2855
- SELECT s.directory AS directory,
2856
- ROUND(SUM(CASE WHEN m.time_created >= ${cutoff} THEN COALESCE(json_extract(m.data, '$.cost'), 0) ELSE 0 END), 4) AS window_cost,
2857
- ROUND(SUM(COALESCE(json_extract(m.data, '$.cost'), 0)), 4) AS lifetime_cost,
2858
- COUNT(DISTINCT s.id) AS sessions
2859
- FROM session_message m
2860
- JOIN session_v2 s ON s.id = m.session_id
2861
- WHERE m.type = 'assistant'
2862
- AND json_extract(m.data, '$.cost') IS NOT NULL
2863
- GROUP BY s.directory
2864
- ORDER BY lifetime_cost DESC`).all() ?? []) as {
2896
+ const legacyTables = db.query("SELECT count(*) AS count FROM sqlite_master WHERE type = 'table' AND name IN ('session', 'message')").get()
2897
+ const raw = (db.query(costRowsQuery(cutoff, legacyTables?.count === 2)).all() ?? []) as {
2865
2898
  directory: unknown
2866
2899
  window_cost: unknown
2867
2900
  lifetime_cost: unknown
@@ -3261,6 +3294,9 @@ function createApi(ctx: Plugin.Context) {
3261
3294
  get currentFocusedEditor() {
3262
3295
  return ctx.renderer.currentFocusedEditor
3263
3296
  },
3297
+ get nativeSession() {
3298
+ return (ctx.renderer as any)?.nativeSession
3299
+ },
3264
3300
  on(event: string, handler: (...args: any[]) => void) {
3265
3301
  ctx.renderer.on(event, handler)
3266
3302
  },