opencode-sesh 1.0.8 → 2.0.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
@@ -39,12 +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
43
- assistant-message cost per directory; `sesh-retitle.sh` renames placeholder
44
- titles. Reads go through `sqlite3 -readonly` (or `opencode db`); the direct
45
- `UPDATE`s in `sesh-prune.sh` (archive) and `sesh-retitle.sh` (title) are the
46
- only sanctioned writes — neither has a CLI or API endpoint, and hard deletes
47
- stay on `opencode session delete`.
42
+ both); `sesh-prune.sh` deletes stale sessions; `sesh-costs.sh` sums
43
+ assistant-message cost per directory. Reads go through `sqlite3 -readonly`
44
+ (required; `opencode db` no longer exists). Sesh never writes the database
45
+ directly; deletes go through `opencode session delete`.
48
46
  - `contrib/` — caller-side integrations (currently cmux). Nothing here may add
49
47
  window management to sesh itself.
50
48
  - `tui-plugins/sesh-panel/tui.tsx` — SolidJS panel, `/** @jsxImportSource @opentui/solid */`.
@@ -105,19 +103,21 @@ which is a pure snapshot lookup — and prints a progress line to stderr while i
105
103
  waits, because fzf has already exited and the terminal is idle. opencode has no
106
104
  live-agent API, so `liveState` is always `unknown` and rows render gray unless
107
105
  they are archived (opt-in, tagged `· archived`);
108
- dispatch is always `opencode --session <id>` (`--fork` with Ctrl-F or --fork).
106
+ dispatch is always `opencode --session <id>`; Ctrl-F or `--fork` first creates the
107
+ fork with `opencode api session.fork` (the CLI has no fork flag) and resumes the new id.
109
108
  Resume happens in place (`exec` after `cd` to the session's cwd).
110
109
 
111
110
  ## Conventions
112
111
 
113
112
  - Default scope is **all** sessions, every directory. `--cwd`/Ctrl-G narrows.
114
- Archived sessions and child sessions (fork children, spawned subagents) are
113
+ Archived sessions and child sessions (spawned subagents) are
115
114
  hidden unless `--archived`; metadata and cache pruning still see them.
116
115
  - **Text parts only** in search/preview; reasoning and tool payloads are
117
116
  excluded (asserted in tests).
118
117
  - Validate session ids (`^ses_[A-Za-z0-9]+$`) before SQL interpolation — never
119
118
  interpolate unvalidated input. Open SQLite read-only (`sqlite3 -readonly`).
120
- - Prefer direct `sqlite3` over `opencode db` (startup latency). Never parse
119
+ - Resolve the database as `SESH_DB`, then `$XDG_DATA_HOME/opencode/opencode.db`, then the
120
+ `db` line of `opencode debug paths`. Never parse
121
121
  `sqlite3 -json` in `-R` mode; it pretty-prints — parse as JSON.
122
122
  - `jq -R` output stays JSON-quoted: use `-Rr` when a shell variable needs the
123
123
  raw string.
@@ -129,7 +129,7 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
129
129
  - Destructive actions confirm. `sesh-delete.sh` prompts on a TTY and refuses
130
130
  without `--yes` when stdin is not one; the TUI arms on the first Option-D and
131
131
  commits on the second (or `y`).
132
- - The installer prefers the local opencode version for `@opencode-ai/*` and
132
+ - The installer prefers the local opencode version for `@opencode/*` and
133
133
  merges (never clobbers) the config `package.json`; opencode runs `bun install`
134
134
  at startup.
135
135
  - 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.0] - 2026-10-10
9
+
10
+ ### Breaking changes
11
+
12
+ - Finish V2 session compatibility and prune semantics _(breaking)_
13
+
14
+ ### Fixed
15
+
16
+ - **tui:** Align sidebar search with Sessions heading
17
+
18
+ ## [1.1.0] - 2026-10-09
19
+
20
+ ### Fixed
21
+
22
+ - Align database access, fork and writes with the OpenCode V2 CLI
23
+
8
24
  ## [1.0.8] - 2026-10-08
9
25
 
10
26
  ### Fixed
@@ -466,6 +482,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
466
482
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
467
483
  CI.
468
484
 
485
+ [2.0.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.1.0...v2.0.0
486
+ [1.1.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.8...v1.1.0
469
487
  [1.0.8]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.7...v1.0.8
470
488
  [1.0.7]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.6...v1.0.7
471
489
  [1.0.6]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.5...v1.0.6
package/README.md CHANGED
@@ -19,7 +19,7 @@ grouped by directory, full-text searchable, and previewable from anywhere.
19
19
 
20
20
  ```text
21
21
  Sessions (8) ▾ hide
22
- search…
22
+ search…
23
23
  ▾ …/Projects/portal (5)
24
24
  ├○ Retry the OAuth token refr… 2h
25
25
  ├○ Login rate limiting notes 3d
@@ -100,8 +100,7 @@ option+o # inside opencode — picker
100
100
 
101
101
  - [opencode](https://opencode.ai), used at least once
102
102
  - `bash`, `jq`, and [`fzf`](https://github.com/junegunn/fzf) `>= 0.73`
103
- - `sqlite3` recommended — the picker queries the session DB directly in
104
- milliseconds and falls back to the slower `opencode db` CLI without it
103
+ - `sqlite3` — the picker queries the session DB directly
105
104
  - [`glow`](https://github.com/charmbracelet/glow) optional — styles the
106
105
  transcript preview as Markdown; without it the preview shows the plain
107
106
  Markdown, unchanged otherwise
@@ -138,20 +137,19 @@ Flags: `--cwd` (current directory only), `--limit N` (default: all),
138
137
  `--json` (with `--print`, emit JSON), `--query TEXT`, `--check`,
139
138
  `--needs-input` (list sessions waiting on you instead of opening the picker).
140
139
 
141
- `sesh prune [--older-than 30d] [--dry-run] [--yes] [--delete]` archives stale
142
- sessions (not updated within the threshold) so the list stays triageable.
143
- Pinned sessions, sessions waiting on you, fork children and already-archived
144
- sessions are never touched; archiving is reversible, while `--delete`
145
- hard-deletes through `opencode session delete`. Without `--dry-run`, a TTY run
140
+ `sesh prune [--older-than 30d] [--dry-run] [--yes]` deletes stale
141
+ sessions (not updated within the threshold) through `opencode session delete`, including their children.
142
+ Pinned sessions, sessions waiting on you, subagent child sessions and already-archived
143
+ sessions are never selected. Without `--dry-run`, a TTY run
146
144
  confirms first and a non-TTY run needs `--yes`.
147
145
 
148
146
  `sesh costs [--days N] [--json]` sums assistant-message cost per project, with a
149
147
  recent window beside the lifetime total, so a day's work reads as a
150
- per-project record.
148
+ per-project record. Legacy V1 history is included when available; sessions
149
+ already present in V2 are counted only once.
151
150
 
152
- `sesh retitle [--dry-run] [--yes]` replaces auto-generated placeholder titles
153
- (`New session - …`) with the start of the session's first user message. Only
154
- placeholder titles are touched, and the same dry-run/`--yes` gating applies.
151
+ Session browsing uses the V2 store. Sessions remaining only in the legacy V1
152
+ tables are not listed or resumable through sesh.
155
153
 
156
154
  When searching, title matches outrank transcript-only matches and the current
157
155
  project rises, so the list answers "where was that thing I worked on" before it
@@ -230,8 +228,8 @@ All variables are optional.
230
228
 
231
229
  | Variable | Default | Effect |
232
230
  | --- | --- | --- |
233
- | `SESH_DB` | `opencode db path` | opencode SQLite database |
234
- | `SESH_SQLITE` | `sqlite3` | query tool (`opencode db` is ~300 ms/call) |
231
+ | `SESH_DB` | `${XDG_DATA_HOME:-~/.local/share}/opencode/opencode.db`, falling back to `opencode debug paths` | opencode SQLite database |
232
+ | `SESH_SQLITE` | `sqlite3` | query tool |
235
233
  | `SESH_JQ` / `SESH_FZF` | `PATH` | explicit executable overrides |
236
234
  | `SESH_GLOW` | first `glow` on `PATH` | optional Markdown preview renderer |
237
235
  | `SESH_OPENCODE` | `opencode` | opencode executable (resume / delete) |
package/bin/sesh CHANGED
@@ -30,9 +30,5 @@ case ${1:-} in
30
30
  shift
31
31
  exec bash "$SCRIPT_DIR/sesh-costs.sh" "$@"
32
32
  ;;
33
- retitle)
34
- shift
35
- exec bash "$SCRIPT_DIR/sesh-retitle.sh" "$@"
36
- ;;
37
33
  esac
38
34
  exec "$SCRIPT_DIR/sesh.sh" "$@"
package/bin/sesh-costs.sh CHANGED
@@ -29,12 +29,12 @@ case "$days" in ''|*[!0-9]*) echo "--days must be a non-negative integer" >&2; e
29
29
 
30
30
  resolve_db() {
31
31
  if [ -n "${SESH_DB:-}" ]; then printf '%s\n' "$SESH_DB"; return 0; fi
32
- local from_cli=''
32
+ local default="${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db" from_cli=''
33
+ if [ -f "$default" ]; then printf '%s\n' "$default"; return 0; fi
33
34
  if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
34
- from_cli=$("$OPENCODE_BIN" db path 2>/dev/null || true)
35
+ from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { sub(/^db[ \t]+/, ""); print; exit }' || true)
35
36
  fi
36
37
  if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
37
- if [ -f "$HOME/.local/share/opencode/opencode.db" ]; then printf '%s\n' "$HOME/.local/share/opencode/opencode.db"; return 0; fi
38
38
  return 1
39
39
  }
40
40
 
@@ -51,27 +51,37 @@ 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
- if [ -n "$SQLITE_BIN" ]; then
70
- ROWS=$("$SQLITE_BIN" -json -readonly "$DB_PATH" "$ROWS_QUERY")
71
- else
72
- command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'sesh: neither sqlite3 nor opencode is available' >&2; exit 1; }
73
- ROWS=$("$OPENCODE_BIN" db "$ROWS_QUERY" --format json)
74
- fi
83
+ [ -n "$SQLITE_BIN" ] || { echo 'sesh: sqlite3 is required' >&2; exit 1; }
84
+ ROWS=$("$SQLITE_BIN" -json -readonly "$DB_PATH" "$ROWS_QUERY")
75
85
  [ -n "$ROWS" ] || ROWS='[]'
76
86
 
77
87
  if [ "$emit_json" = 1 ]; then
package/bin/sesh-list.sh CHANGED
@@ -76,12 +76,12 @@ GREEN=$'\033[32m'; CYAN=$'\033[36m'; GRAY=$'\033[90m'; MAGENTA=$'\033[35m'; RED=
76
76
 
77
77
  resolve_db() {
78
78
  if [ -n "${SESH_DB:-}" ]; then printf '%s\n' "$SESH_DB"; return 0; fi
79
- local from_cli=''
79
+ local default="${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db" from_cli=''
80
+ if [ -f "$default" ]; then printf '%s\n' "$default"; return 0; fi
80
81
  if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
81
- from_cli=$("$OPENCODE_BIN" db path 2>/dev/null || true)
82
+ from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { sub(/^db[ \t]+/, ""); print; exit }' || true)
82
83
  fi
83
84
  if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
84
- if [ -f "$HOME/.local/share/opencode/opencode.db" ]; then printf '%s\n' "$HOME/.local/share/opencode/opencode.db"; return 0; fi
85
85
  return 1
86
86
  }
87
87
 
@@ -92,18 +92,7 @@ elif command -v sqlite3 >/dev/null 2>&1; then
92
92
  SQLITE_BIN=sqlite3
93
93
  fi
94
94
 
95
- # db_json SQL → JSON array on stdout. Direct sqlite3 is preferred: it answers
96
- # in milliseconds, while `opencode db` pays a full CLI startup per call and
97
- # would make every preview keystroke laggy.
98
- db_json() {
99
- if [ -n "$SQLITE_BIN" ]; then
100
- # Read-only: sesh never writes the store directly (deletes go through
101
- # `opencode session delete`).
102
- "$SQLITE_BIN" -json -readonly "$DB_PATH" "$1"
103
- else
104
- "$OPENCODE_BIN" db "$1" --format json
105
- fi
106
- }
95
+ db_json() { "$SQLITE_BIN" -json -readonly "$DB_PATH" "$1"; }
107
96
 
108
97
  valid_session_id() { [[ "$1" =~ ^ses_[A-Za-z0-9]+$ ]]; }
109
98
 
@@ -258,8 +247,8 @@ refresh() {
258
247
  if [ -f "$SNAPSHOT" ]; then set_status 'stale: opencode database is unavailable; showing last good snapshot'; else set_status 'unavailable: opencode database is unavailable'; fi
259
248
  return 0
260
249
  }
261
- if [ -z "$SQLITE_BIN" ] && ! command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
262
- if [ -f "$SNAPSHOT" ]; then set_status 'stale: neither sqlite3 nor opencode is available; showing last good snapshot'; else set_status 'unavailable: neither sqlite3 nor opencode is available'; fi
250
+ if [ -z "$SQLITE_BIN" ]; then
251
+ if [ -f "$SNAPSHOT" ]; then set_status 'stale: sqlite3 is not available; showing last good snapshot'; else set_status 'unavailable: sqlite3 is not available'; fi
263
252
  return 0
264
253
  fi
265
254
  local scratch sessions parts cache_records cache_state historical needs_extract out
@@ -290,7 +279,7 @@ refresh() {
290
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"
291
280
  "$JQ_BIN" -e 'type == "array"' "$parts" >/dev/null 2>&1 || printf '[]\n' > "$parts"
292
281
 
293
- # 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
294
283
  # own file argument (ARG_MAX), records match on session id plus the two
295
284
  # change signals, and only vanished sessions are pruned — metadata always
296
285
  # covers the whole database, so pruning is safe under any scope or limit.
@@ -389,8 +378,8 @@ refresh() {
389
378
  | ($hist | map({key: .sessionId, value: .}) | from_entries) as $histById
390
379
  | [$meta[]
391
380
  | select((.id | type) == "string" and (.id | test("^ses_[A-Za-z0-9]+$")))
392
- # Child sessions (fork children, spawned subagents) surface only with
393
- # --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.
394
383
  | select($archived == 1 or (.archived == 0 and .parent == ""))
395
384
  | select($scope == "" or .directory == $scope)
396
385
  | . as $m | ($histById[$m.id] // {}) as $h
@@ -14,13 +14,10 @@ JQ_BIN=${SESH_JQ:-jq}
14
14
  "$JQ_BIN" -se --arg sid "$session_id" 'any(.sessionId == $sid)' "$snapshot" >/dev/null 2>&1 \
15
15
  || { echo "(selected session is no longer in this snapshot)"; exit 0; }
16
16
 
17
- DB_PATH=${SESH_DB:-}
18
- if [ -z "$DB_PATH" ]; then
19
- OPENCODE_BIN=${SESH_OPENCODE:-opencode}
20
- if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
21
- DB_PATH=$("$OPENCODE_BIN" db path 2>/dev/null || true)
22
- fi
23
- [ -n "$DB_PATH" ] || DB_PATH="$HOME/.local/share/opencode/opencode.db"
17
+ OPENCODE_BIN=${SESH_OPENCODE:-opencode}
18
+ DB_PATH=${SESH_DB:-"${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db"}
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" { sub(/^db[ \t]+/, ""); print; exit }' || true)
24
21
  fi
25
22
  [ -f "$DB_PATH" ] || { echo "(opencode database is unavailable)"; exit 0; }
26
23
 
@@ -28,12 +25,8 @@ SQLITE_BIN=${SESH_SQLITE:-}
28
25
  if [ -z "$SQLITE_BIN" ] && command -v sqlite3 >/dev/null 2>&1; then SQLITE_BIN=sqlite3; fi
29
26
  SQL="SELECT json_object('role', type) AS msg, json_object('type', 'text', 'text', json_extract(data, '\$.text')) AS part, seq, -1 AS ordinal FROM session_message WHERE session_id = '$session_id' AND type = 'user' AND json_type(data, '\$.text') = 'text'
30
27
  UNION ALL SELECT json_object('role', m.type) AS msg, c.value AS part, m.seq, CAST(c.key AS INTEGER) AS ordinal FROM session_message m, json_each(m.data, '\$.content') c WHERE m.session_id = '$session_id' AND m.type = 'assistant' AND json_extract(c.value, '\$.type') = 'text' ORDER BY seq DESC, ordinal DESC LIMIT 200;"
31
- if [ -n "$SQLITE_BIN" ]; then
32
- rows=$("$SQLITE_BIN" -json -readonly "$DB_PATH" "$SQL" 2>/dev/null) || rows=''
33
- else
34
- OPENCODE_BIN=${SESH_OPENCODE:-opencode}
35
- rows=$("$OPENCODE_BIN" db "$SQL" --format json 2>/dev/null) || rows=''
36
- fi
28
+ [ -n "$SQLITE_BIN" ] || { echo "(sqlite3 is required for previews)"; exit 0; }
29
+ rows=$("$SQLITE_BIN" -json -readonly "$DB_PATH" "$SQL" 2>/dev/null) || rows=''
37
30
  [ -n "$rows" ] || { echo "(no displayable transcript messages)"; exit 0; }
38
31
 
39
32
  # Text parts only, newest first, with role headers. The 120-block cap is applied
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
- # archived, never a fork child.
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 the same sqlite3-or-`opencode db` read
9
- # path the list commands use — there is no archive endpoint in the opencode
10
- # API or CLI, so a direct write is the only route (deletes stay on the 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
@@ -53,12 +45,12 @@ CUTOFF=$((NOW_MS - threshold_ms))
53
45
 
54
46
  resolve_db() {
55
47
  if [ -n "${SESH_DB:-}" ]; then printf '%s\n' "$SESH_DB"; return 0; fi
56
- local from_cli=''
48
+ local default="${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db" from_cli=''
49
+ if [ -f "$default" ]; then printf '%s\n' "$default"; return 0; fi
57
50
  if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
58
- from_cli=$("$OPENCODE_BIN" db path 2>/dev/null || true)
51
+ from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { sub(/^db[ \t]+/, ""); print; exit }' || true)
59
52
  fi
60
53
  if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
61
- if [ -f "$HOME/.local/share/opencode/opencode.db" ]; then printf '%s\n' "$HOME/.local/share/opencode/opencode.db"; return 0; fi
62
54
  return 1
63
55
  }
64
56
 
@@ -72,14 +64,8 @@ fi
72
64
  DB_PATH=$(resolve_db) || { echo 'sesh: session database unavailable' >&2; exit 1; }
73
65
  [ -f "$DB_PATH" ] || { echo 'sesh: session database unavailable' >&2; exit 1; }
74
66
 
75
- if [ -n "$SQLITE_BIN" ]; then
76
- query() { "$SQLITE_BIN" -json -readonly "$DB_PATH" "$1"; }
77
- write_db() { "$SQLITE_BIN" "$DB_PATH" "$1"; }
78
- else
79
- command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'sesh: neither sqlite3 nor opencode is available' >&2; exit 1; }
80
- query() { "$OPENCODE_BIN" db "$1" --format json; }
81
- write_db() { "$OPENCODE_BIN" db "$1" --format json > /dev/null; }
82
- fi
67
+ [ -n "$SQLITE_BIN" ] || { echo 'sesh: sqlite3 is required' >&2; exit 1; }
68
+ query() { "$SQLITE_BIN" -json -readonly "$DB_PATH" "$1"; }
83
69
 
84
70
  JQ_BIN=${SESH_JQ:-$(command -v jq || true)}
85
71
  [ -n "$JQ_BIN" ] || { echo 'sesh: jq is required' >&2; exit 1; }
@@ -103,8 +89,7 @@ ELIGIBLE=$(printf '%s' "$CANDIDATES" | "$JQ_BIN" -c --slurpfile _pins <(printf '
103
89
  ]
104
90
  ')
105
91
  COUNT=$(printf '%s' "$ELIGIBLE" | "$JQ_BIN" -r 'length')
106
- VERB=Archive
107
- [ "$hard_delete" = 0 ] || VERB=Delete
92
+ VERB=Delete
108
93
 
109
94
  if [ "$COUNT" = 0 ]; then
110
95
  echo "Nothing to prune older than $older_than."
@@ -142,34 +127,17 @@ if [ "$assume_yes" = 0 ]; then
142
127
  fi
143
128
  fi
144
129
 
145
- if [ "$hard_delete" = 0 ]; then
146
- IDS=$(printf '%s' "$ELIGIBLE" | "$JQ_BIN" -r '.[].id')
147
- IN_LIST=''
148
- for id in $IDS; do
149
- case "$id" in
150
- ses_[A-Za-z0-9]*) IN_LIST="$IN_LIST'$id'," ;;
151
- *) echo "Refusing: unexpected session id shape." >&2; exit 1 ;;
152
- esac
153
- done
154
- IN_LIST=${IN_LIST%,}
155
- write_db "UPDATE session_v2 SET time_archived = $NOW_MS WHERE id IN ($IN_LIST);"
156
- echo "Archived $COUNT session(s) older than $older_than."
157
- else
158
- command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'sesh: opencode executable is unavailable for deletes.' >&2; exit 1; }
159
- failed=0
160
- done_count=0
161
- for id in $(printf '%s' "$ELIGIBLE" | "$JQ_BIN" -r '.[].id'); do
162
- case "$id" in
163
- ses_[A-Za-z0-9]*) ;;
164
- *) echo "Refusing: unexpected session id shape." >&2; exit 1 ;;
165
- esac
166
- if "$OPENCODE_BIN" session delete "$id" >/dev/null 2>&1; then
167
- done_count=$((done_count + 1))
168
- else
169
- echo "Could not delete $id." >&2
170
- failed=$((failed + 1))
171
- fi
172
- done
173
- echo "Deleted $done_count session(s) older than $older_than."
174
- [ "$failed" = 0 ] || exit 1
175
- 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
@@ -5,11 +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
9
- # - has a `question` tool part that never completed with no later user
10
- # message in the same session (awaiting an answer, errored included), or
11
- # - has a `tool` part still marked `running` older than STUCK_AFTER_MS
12
- # (awaiting approval, or orphaned by a dead server).
8
+ # A session waits when it is not archived, is not a child (subagent) session,
9
+ # and either
10
+ # - has a `question` tool part still pending or running with no later user
11
+ # message in the same session (awaiting an answer; a dismissed question
12
+ # that errored is not waiting), or
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).
13
18
  #
14
19
  # Usage: sesh-waiting.sh [--json]
15
20
  # default prints a human table; --json prints [{id,reason,title,directory,updated}]
@@ -30,12 +35,12 @@ done
30
35
 
31
36
  resolve_db() {
32
37
  if [ -n "${SESH_DB:-}" ]; then printf '%s\n' "$SESH_DB"; return 0; fi
33
- local from_cli=''
38
+ local default="${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db" from_cli=''
39
+ if [ -f "$default" ]; then printf '%s\n' "$default"; return 0; fi
34
40
  if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
35
- from_cli=$("$OPENCODE_BIN" db path 2>/dev/null || true)
41
+ from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { sub(/^db[ \t]+/, ""); print; exit }' || true)
36
42
  fi
37
43
  if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
38
- if [ -f "$HOME/.local/share/opencode/opencode.db" ]; then printf '%s\n' "$HOME/.local/share/opencode/opencode.db"; return 0; fi
39
44
  return 1
40
45
  }
41
46
 
@@ -82,20 +87,17 @@ WHERE COALESCE(s.time_archived, 0) = 0
82
87
  AND NOT EXISTS (
83
88
  SELECT 1 FROM session_message u
84
89
  WHERE u.session_id = live.session_id AND u.type = 'user' AND u.seq > live.seq)
85
- ) OR EXISTS (
90
+ ) OR (EXISTS (
86
91
  SELECT 1 FROM live
87
- 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)))
88
96
  ORDER BY s.time_updated DESC;
89
97
  SQL
90
98
 
91
- if [ -n "$SQLITE_BIN" ]; then
92
- # Read-only: sesh never writes the store on this path (archive writes go
93
- # through `opencode db`, hard deletes through `opencode session delete`).
94
- ROWS=$("$SQLITE_BIN" -json -readonly "$DB_PATH" "$QUERY")
95
- else
96
- command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'sesh: neither sqlite3 nor opencode is available' >&2; exit 1; }
97
- ROWS=$("$OPENCODE_BIN" db "$QUERY" --format json)
98
- fi
99
+ [ -n "$SQLITE_BIN" ] || { echo 'sesh: sqlite3 is required' >&2; exit 1; }
100
+ ROWS=$("$SQLITE_BIN" -json -readonly "$DB_PATH" "$QUERY")
99
101
 
100
102
  # sqlite3 -json prints nothing (not []) when no rows match.
101
103
  [ -n "$ROWS" ] || ROWS='[]'
package/bin/sesh.sh CHANGED
@@ -37,7 +37,7 @@ fi
37
37
 
38
38
  if [ "${1:-}" = --check ]; then
39
39
  "$SESH_JQ" -en '"2026-01-01T00:00:00Z" | fromdateiso8601 | type == "number"' >/dev/null || { echo 'jq capability check failed' >&2; exit 1; }
40
- command -v sqlite3 >/dev/null 2>&1 || command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'neither sqlite3 nor opencode is available' >&2; exit 1; }
40
+ command -v "${SESH_SQLITE:-sqlite3}" >/dev/null 2>&1 || { echo 'sqlite3 is not available' >&2; exit 1; }
41
41
  # The TUI panel is a copied file that opencode imports once at startup. Report
42
42
  # when the installed copy has fallen behind this package so upgrades are not
43
43
  # silently stale.
@@ -203,9 +203,18 @@ 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
- ( cd "$cwd" && exec "$OPENCODE_BIN" --session "$session_id" --fork )
207
- else
208
- ( cd "$cwd" && exec "$OPENCODE_BIN" --session "$session_id" )
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)
208
+ if [[ "$forked" =~ ^ses_[A-Za-z0-9]+$ ]]; then
209
+ session_id=$forked
210
+ rm -f "$fork_err"
211
+ else
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"
215
+ continue
216
+ fi
209
217
  fi
218
+ ( cd "$cwd" && exec "$OPENCODE_BIN" --session "$session_id" )
210
219
  echo 'sesh: opencode exited; returning to the picker.' >&2
211
220
  done
package/install.sh CHANGED
@@ -2,8 +2,9 @@
2
2
  # Install sesh for the current user.
3
3
  # Terminal- and OS-agnostic: macOS and Linux, any terminal. No administrator
4
4
  # privileges, downloads, or shell-rc edits. Runtime requires jq, fzf >= 0.73
5
- # and sqlite3 or the opencode CLI; the in-TUI panel additionally needs the
6
- # packages opencode installs for local plugins (handled below).
5
+ # and sqlite3; resuming and deleting need the opencode CLI. The in-TUI panel
6
+ # additionally needs the packages opencode installs for local plugins (handled
7
+ # below).
7
8
  set -euo pipefail
8
9
 
9
10
  fail() { printf 'sesh install: %s\n' "$*" >&2; exit 1; }
@@ -23,7 +24,7 @@ SYNC_PANEL=0
23
24
 
24
25
  # Fallback plugin version for when the opencode CLI is unavailable; the
25
26
  # installer prefers the local opencode version so the panel matches its SDK.
26
- PLUGIN_VERSION_FALLBACK="2.0.18"
27
+ PLUGIN_VERSION_FALLBACK="2.0.25"
27
28
  # Peer line the panel was developed and typechecked against. A checkout's
28
29
  # package.json overrides these, so dependency bumps cannot drift the installed
29
30
  # config out of sync (solid-js moves with OpenTUI via @opentui/keymap's peer).
@@ -251,7 +252,6 @@ for path in \
251
252
  bin/sesh-delete.sh \
252
253
  bin/sesh-prune.sh \
253
254
  bin/sesh-refresh-worker.sh \
254
- bin/sesh-retitle.sh \
255
255
  bin/sesh-waiting.sh \
256
256
  bin/sesh-shortcuts.sh \
257
257
  plugins/sesh-list.ts \
@@ -284,8 +284,7 @@ if [[ "$fzf_version" =~ ^([0-9]+)\.([0-9]+)(\.([0-9]+))? ]]; then
284
284
  else
285
285
  fail "cannot parse fzf version from: $fzf_version."
286
286
  fi
287
- command -v sqlite3 >/dev/null 2>&1 || command -v opencode >/dev/null 2>&1 \
288
- || fail "either sqlite3 or the opencode CLI is required."
287
+ command -v sqlite3 >/dev/null 2>&1 || fail "sqlite3 is required."
289
288
  command -v opencode >/dev/null 2>&1 || note "opencode CLI not found: listing and previews work via sqlite3, but resume and delete need opencode."
290
289
 
291
290
  umask 077
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "1.0.8",
3
+ "version": "2.0.0",
4
4
  "description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,8 +38,7 @@
38
38
  "AGENTS.md",
39
39
  "CHANGELOG.md",
40
40
  "LICENSE",
41
- "README.md",
42
- "ROADMAP.md"
41
+ "README.md"
43
42
  ],
44
43
  "publishConfig": {
45
44
  "access": "public"
@@ -62,8 +61,8 @@
62
61
  },
63
62
  "peerDependencies": {
64
63
  "@opencode/plugin": ">=2.0.0",
65
- "@opentui/core": ">=0.5.8",
66
- "@opentui/solid": ">=0.5.8",
64
+ "@opentui/core": "^0.5.8",
65
+ "@opentui/solid": "^0.5.8",
67
66
  "solid-js": ">=1.9.0"
68
67
  },
69
68
  "devDependencies": {
@@ -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
- function dataHome(): string {
6
- return process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share")
7
+ function dbPath(): string {
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 = {
@@ -44,12 +54,12 @@ export default Plugin.define({
44
54
  throw new Error("sesh_list requires the Bun runtime (bun:sqlite)")
45
55
  }
46
56
 
47
- const db = new sqlite.Database(join(dataHome(), "opencode", "opencode.db"), { readonly: true })
57
+ const db = new sqlite.Database(dbPath(), { readonly: true })
48
58
  try {
49
59
  const scoped = typeof args.directory === "string" && args.directory.length > 0
50
60
  const query = `SELECT id, title, directory, time_updated AS updated, time_created AS created
51
61
  FROM session_v2
52
- WHERE parent_id IS NULL AND time_archived IS NULL${scoped ? " AND directory = ?" : ""}
62
+ WHERE parent_id IS NULL AND COALESCE(time_archived, 0) = 0${scoped ? " AND directory = ?" : ""}
53
63
  ORDER BY time_updated DESC, id DESC
54
64
  LIMIT ${limit}`
55
65
  const rows = scoped ? db.query(query).all(args.directory) : db.query(query).all()
@@ -1,9 +1,8 @@
1
1
  {
2
2
  "name": "opencode-tui-sesh-panel",
3
- "version": "1.0.0",
4
3
  "type": "module",
5
4
  "exports": {
6
5
  ".": "./server.ts",
7
6
  "./tui": "./tui.tsx"
8
7
  }
9
- }
8
+ }
@@ -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
 
@@ -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(`${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 () => {
@@ -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)
@@ -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
package/ROADMAP.md DELETED
@@ -1,60 +0,0 @@
1
- # Roadmap
2
-
3
- Productivity bets for sesh, roughly ordered by payoff. Status is tracked here
4
- as items land.
5
-
6
- ## 1. Needs-input triage (done)
7
-
8
- **Problem:** with 100+ sessions, the bottleneck is not finding a session but
9
- knowing which ones are *waiting on you* (unanswered agent questions, stale
10
- runs awaiting approval) versus done versus dead. Today that means polling rows
11
- by hand.
12
-
13
- **Approach:** server-independent detection straight from the opencode SQLite
14
- store, so it works for every session, not just ones owned by the current TUI
15
- server (whose in-memory permission/question state is invisible cross-project):
16
-
17
- - a `question` tool part that is not `completed` with no later user message in
18
- the same session → awaiting an answer;
19
- - a `tool` part stuck in `running` older than ~10 minutes → awaiting approval
20
- or orphaned by a dead server.
21
-
22
- **Surfaces:** a "Needs input" section pinned above the directory groups in the
23
- TUI sidebar, plus a terminal listing (`sesh --needs-input`).
24
-
25
- ## 2. Stale-session pruning (done)
26
-
27
- **Problem:** the list grows without bound (127+ rows), which slows scanning
28
- and bloats the transcript search index with archaeology.
29
-
30
- **Approach:** `sesh prune --older-than 30d` archives (never deletes) sessions
31
- older than the threshold: never pinned ones, never ones flagged as needing
32
- input. Archive is reversible; `--delete` stays the explicit hard-delete path
33
- through the existing two-step confirm. Interactive runs confirm with a
34
- candidate summary; non-TTY runs require `--yes`; `--dry-run` lists only.
35
-
36
- ## 3. sesh ↔ cmux bridge (done)
37
-
38
- **Approach:** [`contrib/cmux/`](contrib/cmux/README.md) ships the cmux
39
- actions, shortcuts and a `--print --json` recipe that resumes a session in its
40
- own workspace; the TUI's right-click action opens one directly.
41
-
42
- ## 4. Per-project cost digest (done)
43
-
44
- `sesh costs [--days N] [--json]` sums assistant-message cost per directory with
45
- a recent window beside the lifetime total, so a day's work reads as a
46
- per-project record. Message-level summing (not `session.cost`) keeps a
47
- half-finished day from dragging in a session's earlier history.
48
-
49
- ## 5. Resume from anywhere (done)
50
-
51
- Covered by the same contrib recipes: `sesh --print --json` resolves a session
52
- non-interactively, so a cmux action, skhd binding or launcher can resume a
53
- specific session — or open the picker — from any context.
54
-
55
- ## Smaller items (done)
56
-
57
- - Search weighting: title matches outrank transcript-only matches and the
58
- current project rises while a query is active; pins still win outright.
59
- - `sesh retitle [--dry-run] [--yes]` replaces placeholder titles
60
- (`New session - …`) with the start of the first user message.
@@ -1,139 +0,0 @@
1
- #!/usr/bin/env bash
2
- # Give auto-generated session titles a readable first line.
3
- #
4
- # opencode titles most sessions itself; a few keep their placeholder
5
- # ("New session - <ISO>") because they ended before a title was derived. This
6
- # replaces just those placeholders with the start of the session's first user
7
- # message, so the row scans like the rest of the list.
8
- #
9
- # It only ever touches titles matching the placeholder pattern, and the value
10
- # it overwrites carries no information (a timestamp), so nothing recoverable is
11
- # lost. The write is a single direct UPDATE through the same sqlite3-or-
12
- # `opencode db` path as `sesh prune` — opencode exposes no title endpoint in
13
- # the CLI, so a direct write is the only route.
14
- #
15
- # Usage: sesh-retitle.sh [--dry-run] [--yes] [--max-words N]
16
- set -euo pipefail
17
-
18
- SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
- OPENCODE_BIN=${SESH_OPENCODE:-opencode}
20
- dry_run=0
21
- assume_yes=0
22
- max_words=8
23
- while [ "$#" -gt 0 ]; do
24
- case "$1" in
25
- --dry-run) dry_run=1 ;;
26
- --yes|-y) assume_yes=1 ;;
27
- --max-words) max_words=${2:-}; shift ;;
28
- --max-words=*) max_words=${1#--max-words=} ;;
29
- --help) echo "usage: ${0##*/} [--dry-run] [--yes] [--max-words N]"; exit 0 ;;
30
- *) echo "unknown option: $1" >&2; exit 2 ;;
31
- esac
32
- shift
33
- done
34
- case "$max_words" in ''|*[!0-9]*) echo "--max-words must be a positive integer" >&2; exit 2;; esac
35
- [ "$max_words" -gt 0 ] || { echo "--max-words must be positive" >&2; exit 2; }
36
-
37
- resolve_db() {
38
- if [ -n "${SESH_DB:-}" ]; then printf '%s\n' "$SESH_DB"; return 0; fi
39
- local from_cli=''
40
- if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
41
- from_cli=$("$OPENCODE_BIN" db path 2>/dev/null || true)
42
- fi
43
- if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
44
- if [ -f "$HOME/.local/share/opencode/opencode.db" ]; then printf '%s\n' "$HOME/.local/share/opencode/opencode.db"; return 0; fi
45
- return 1
46
- }
47
-
48
- SQLITE_BIN=''
49
- if [ -n "${SESH_SQLITE:-}" ]; then
50
- SQLITE_BIN=$SESH_SQLITE
51
- elif command -v sqlite3 >/dev/null 2>&1; then
52
- SQLITE_BIN=sqlite3
53
- fi
54
-
55
- DB_PATH=$(resolve_db) || { echo 'sesh: session database unavailable' >&2; exit 1; }
56
- [ -f "$DB_PATH" ] || { echo 'sesh: session database unavailable' >&2; exit 1; }
57
-
58
- if [ -n "$SQLITE_BIN" ]; then
59
- query() { "$SQLITE_BIN" -json -readonly "$DB_PATH" "$1"; }
60
- write_db() { "$SQLITE_BIN" "$DB_PATH" "$1"; }
61
- else
62
- command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'sesh: neither sqlite3 nor opencode is available' >&2; exit 1; }
63
- query() { "$OPENCODE_BIN" db "$1" --format json; }
64
- write_db() { "$OPENCODE_BIN" db "$1" --format json > /dev/null; }
65
- fi
66
-
67
- JQ_BIN=${SESH_JQ:-$(command -v jq || true)}
68
- [ -n "$JQ_BIN" ] || { echo 'sesh: jq is required' >&2; exit 1; }
69
-
70
- # The first user text part per placeholder-titled session, cleaned into a
71
- # candidate: image chips and markdown noise dropped, whitespace collapsed,
72
- # first `max_words` words kept.
73
- CANDIDATES=$(query "
74
- SELECT s.id AS id, s.directory AS directory, s.title AS old_title,
75
- (SELECT m.data FROM session_message m
76
- WHERE m.session_id = s.id AND m.type = 'user'
77
- AND json_type(m.data, '\$.text') = 'text'
78
- ORDER BY m.seq ASC LIMIT 1) AS first_part
79
- FROM session_v2 s
80
- WHERE s.title LIKE 'New session - %'
81
- OR TRIM(COALESCE(s.title, '')) = ''
82
- ORDER BY s.time_updated DESC;")
83
- [ -n "$CANDIDATES" ] || CANDIDATES='[]'
84
-
85
- PLAN=$(printf '%s' "$CANDIDATES" | "$JQ_BIN" -c --argjson words "$max_words" '
86
- def derive:
87
- (.first_part // "")
88
- | (try (fromjson | .text) catch "")
89
- | gsub("\\[Image #[0-9]+\\]"; " ")
90
- | gsub("[#*_`>]"; " ")
91
- | gsub("\\s+"; " ")
92
- | sub("^ +"; "") | sub(" +$"; "")
93
- | (split(" ") | map(select(. != "")))
94
- | (if length == 0 then "" else .[0:$words] | join(" ") end)
95
- | if length > 60 then .[0:57] + "..." else . end;
96
- [ .[] | .new_title = derive | select(.new_title != "") ]')
97
- COUNT=$(printf '%s' "$PLAN" | "$JQ_BIN" -r 'length')
98
-
99
- if [ "$COUNT" = 0 ]; then
100
- echo "No placeholder-titled sessions with a usable first message."
101
- exit 0
102
- fi
103
-
104
- printf '%s' "$PLAN" | "$JQ_BIN" -r '
105
- .[] | "\(.id)\t\(.new_title)\t\(.old_title)"' | while IFS=$'\t' read -r id new old; do
106
- printf '%s\n \033[90m%s\033[0m\n → %s\n' "$id" "$old" "$new"
107
- done
108
-
109
- if [ "$dry_run" = 1 ]; then
110
- printf 'Retitle %s session(s) (--dry-run: nothing changed).\n' "$COUNT" >&2
111
- exit 0
112
- fi
113
-
114
- if [ "$assume_yes" = 0 ]; then
115
- if [ -t 0 ]; then
116
- printf 'Retitle %s session(s)? [y/N] ' "$COUNT"
117
- reply=''
118
- IFS= read -r -n 1 reply || reply=''
119
- printf '\n'
120
- case "$reply" in
121
- y|Y) ;;
122
- *) echo 'Cancelled; nothing was changed.'; exit 0 ;;
123
- esac
124
- else
125
- echo "Refusing to retitle $COUNT session(s): pass --yes when stdin is not a terminal." >&2
126
- exit 2
127
- fi
128
- fi
129
-
130
- # Escape single quotes for the SQL string literal; ids are validated by shape.
131
- sql_escape() { printf '%s' "$1" | sed "s/'/''/g"; }
132
- printf '%s' "$PLAN" | "$JQ_BIN" -r '.[] | [.id, .new_title] | @tsv' | while IFS=$'\t' read -r id new; do
133
- case "$id" in
134
- ses_[A-Za-z0-9]*) ;;
135
- *) echo "Refusing: unexpected session id shape." >&2; exit 1 ;;
136
- esac
137
- write_db "UPDATE session_v2 SET title = '$(sql_escape "$new")' WHERE id = '$id' AND (title LIKE 'New session - %' OR TRIM(COALESCE(title, '')) = '');"
138
- done
139
- echo "Retitled $COUNT session(s)."