opencode-sesh 1.0.8 → 1.1.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
@@ -40,11 +40,10 @@ HOME=/tmp/fakehome bash install.sh # installer smoke test (never touches ~/.co
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
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`.
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`.
48
47
  - `contrib/` — caller-side integrations (currently cmux). Nothing here may add
49
48
  window management to sesh itself.
50
49
  - `tui-plugins/sesh-panel/tui.tsx` — SolidJS panel, `/** @jsxImportSource @opentui/solid */`.
@@ -105,19 +104,21 @@ which is a pure snapshot lookup — and prints a progress line to stderr while i
105
104
  waits, because fzf has already exited and the terminal is idle. opencode has no
106
105
  live-agent API, so `liveState` is always `unknown` and rows render gray unless
107
106
  they are archived (opt-in, tagged `· archived`);
108
- dispatch is always `opencode --session <id>` (`--fork` with Ctrl-F or --fork).
107
+ dispatch is always `opencode --session <id>`; Ctrl-F or `--fork` first creates the
108
+ fork with `opencode api session.fork` (the CLI has no fork flag) and resumes the new id.
109
109
  Resume happens in place (`exec` after `cd` to the session's cwd).
110
110
 
111
111
  ## Conventions
112
112
 
113
113
  - Default scope is **all** sessions, every directory. `--cwd`/Ctrl-G narrows.
114
- Archived sessions and child sessions (fork children, spawned subagents) are
114
+ Archived sessions and child sessions (spawned subagents) are
115
115
  hidden unless `--archived`; metadata and cache pruning still see them.
116
116
  - **Text parts only** in search/preview; reasoning and tool payloads are
117
117
  excluded (asserted in tests).
118
118
  - Validate session ids (`^ses_[A-Za-z0-9]+$`) before SQL interpolation — never
119
119
  interpolate unvalidated input. Open SQLite read-only (`sqlite3 -readonly`).
120
- - Prefer direct `sqlite3` over `opencode db` (startup latency). Never parse
120
+ - Resolve the database as `SESH_DB`, then `$XDG_DATA_HOME/opencode/opencode.db`, then the
121
+ `db` line of `opencode debug paths`. Never parse
121
122
  `sqlite3 -json` in `-R` mode; it pretty-prints — parse as JSON.
122
123
  - `jq -R` output stays JSON-quoted: use `-Rr` when a shell variable needs the
123
124
  raw string.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ 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
+ ## [1.1.0] - 2026-10-09
9
+
10
+ ### Fixed
11
+
12
+ - Align database access, fork and writes with the OpenCode V2 CLI
13
+
8
14
  ## [1.0.8] - 2026-10-08
9
15
 
10
16
  ### Fixed
@@ -466,6 +472,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
466
472
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
467
473
  CI.
468
474
 
475
+ [1.1.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.8...v1.1.0
469
476
  [1.0.8]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.7...v1.0.8
470
477
  [1.0.7]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.6...v1.0.7
471
478
  [1.0.6]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.5...v1.0.6
package/README.md CHANGED
@@ -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
@@ -140,7 +139,7 @@ Flags: `--cwd` (current directory only), `--limit N` (default: all),
140
139
 
141
140
  `sesh prune [--older-than 30d] [--dry-run] [--yes] [--delete]` archives stale
142
141
  sessions (not updated within the threshold) so the list stays triageable.
143
- Pinned sessions, sessions waiting on you, fork children and already-archived
142
+ Pinned sessions, sessions waiting on you, subagent child sessions and already-archived
144
143
  sessions are never touched; archiving is reversible, while `--delete`
145
144
  hard-deletes through `opencode session delete`. Without `--dry-run`, a TTY run
146
145
  confirms first and a non-TTY run needs `--yes`.
@@ -149,10 +148,6 @@ confirms first and a non-TTY run needs `--yes`.
149
148
  recent window beside the lifetime total, so a day's work reads as a
150
149
  per-project record.
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.
155
-
156
151
  When searching, title matches outrank transcript-only matches and the current
157
152
  project rises, so the list answers "where was that thing I worked on" before it
158
153
  answers "what is newest". Pins still win outright.
@@ -230,8 +225,8 @@ All variables are optional.
230
225
 
231
226
  | Variable | Default | Effect |
232
227
  | --- | --- | --- |
233
- | `SESH_DB` | `opencode db path` | opencode SQLite database |
234
- | `SESH_SQLITE` | `sqlite3` | query tool (`opencode db` is ~300 ms/call) |
228
+ | `SESH_DB` | `opencode debug paths` | opencode SQLite database |
229
+ | `SESH_SQLITE` | `sqlite3` | query tool |
235
230
  | `SESH_JQ` / `SESH_FZF` | `PATH` | explicit executable overrides |
236
231
  | `SESH_GLOW` | first `glow` on `PATH` | optional Markdown preview renderer |
237
232
  | `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" { print $2; 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
 
@@ -66,12 +66,8 @@ WHERE m.type = 'assistant'
66
66
  GROUP BY s.directory
67
67
  ORDER BY lifetime_cost DESC;"
68
68
 
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
69
+ [ -n "$SQLITE_BIN" ] || { echo 'sesh: sqlite3 is required' >&2; exit 1; }
70
+ ROWS=$("$SQLITE_BIN" -json -readonly "$DB_PATH" "$ROWS_QUERY")
75
71
  [ -n "$ROWS" ] || ROWS='[]'
76
72
 
77
73
  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" { print $2; 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
@@ -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" { print $2; 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,13 +1,13 @@
1
1
  #!/usr/bin/env bash
2
2
  # Archive (or, with --delete, hard-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
6
  # Archive is the default because it is reversible; hard deletes go through
7
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).
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
11
  #
12
12
  # Usage: sesh-prune.sh [--older-than 30d] [--dry-run] [--yes] [--delete]
13
13
  # --older-than Nd|Nh|Nw (bare N means days; default 30d)
@@ -53,12 +53,12 @@ CUTOFF=$((NOW_MS - threshold_ms))
53
53
 
54
54
  resolve_db() {
55
55
  if [ -n "${SESH_DB:-}" ]; then printf '%s\n' "$SESH_DB"; return 0; fi
56
- local from_cli=''
56
+ local default="${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db" from_cli=''
57
+ if [ -f "$default" ]; then printf '%s\n' "$default"; return 0; fi
57
58
  if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
58
- from_cli=$("$OPENCODE_BIN" db path 2>/dev/null || true)
59
+ from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { print $2; exit }' || true)
59
60
  fi
60
61
  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
62
  return 1
63
63
  }
64
64
 
@@ -72,14 +72,9 @@ fi
72
72
  DB_PATH=$(resolve_db) || { echo 'sesh: session database unavailable' >&2; exit 1; }
73
73
  [ -f "$DB_PATH" ] || { echo 'sesh: session database unavailable' >&2; exit 1; }
74
74
 
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
75
+ [ -n "$SQLITE_BIN" ] || { echo 'sesh: sqlite3 is required' >&2; exit 1; }
76
+ query() { "$SQLITE_BIN" -json -readonly "$DB_PATH" "$1"; }
77
+ write_db() { "$SQLITE_BIN" "$DB_PATH" "$1"; }
83
78
 
84
79
  JQ_BIN=${SESH_JQ:-$(command -v jq || true)}
85
80
  [ -n "$JQ_BIN" ] || { echo 'sesh: jq is required' >&2; exit 1; }
@@ -146,10 +141,8 @@ if [ "$hard_delete" = 0 ]; then
146
141
  IDS=$(printf '%s' "$ELIGIBLE" | "$JQ_BIN" -r '.[].id')
147
142
  IN_LIST=''
148
143
  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
144
+ [[ "$id" =~ ^ses_[A-Za-z0-9]+$ ]] || { echo "Refusing: unexpected session id shape." >&2; exit 1; }
145
+ IN_LIST="$IN_LIST'$id',"
153
146
  done
154
147
  IN_LIST=${IN_LIST%,}
155
148
  write_db "UPDATE session_v2 SET time_archived = $NOW_MS WHERE id IN ($IN_LIST);"
@@ -159,10 +152,7 @@ else
159
152
  failed=0
160
153
  done_count=0
161
154
  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
155
+ [[ "$id" =~ ^ses_[A-Za-z0-9]+$ ]] || { echo "Refusing: unexpected session id shape." >&2; exit 1; }
166
156
  if "$OPENCODE_BIN" session delete "$id" >/dev/null 2>&1; then
167
157
  done_count=$((done_count + 1))
168
158
  else
@@ -6,8 +6,9 @@
6
6
  # is invisible cross-project.
7
7
  #
8
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
9
+ # - has a `question` tool part still pending or running with no later user
10
+ # message in the same session (awaiting an answer; a dismissed question
11
+ # that errored is not waiting), or
11
12
  # - has a `tool` part still marked `running` older than STUCK_AFTER_MS
12
13
  # (awaiting approval, or orphaned by a dead server).
13
14
  #
@@ -30,12 +31,12 @@ done
30
31
 
31
32
  resolve_db() {
32
33
  if [ -n "${SESH_DB:-}" ]; then printf '%s\n' "$SESH_DB"; return 0; fi
33
- local from_cli=''
34
+ local default="${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db" from_cli=''
35
+ if [ -f "$default" ]; then printf '%s\n' "$default"; return 0; fi
34
36
  if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
35
- from_cli=$("$OPENCODE_BIN" db path 2>/dev/null || true)
37
+ from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { print $2; exit }' || true)
36
38
  fi
37
39
  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
40
  return 1
40
41
  }
41
42
 
@@ -88,14 +89,8 @@ WHERE COALESCE(s.time_archived, 0) = 0
88
89
  ORDER BY s.time_updated DESC;
89
90
  SQL
90
91
 
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
92
+ [ -n "$SQLITE_BIN" ] || { echo 'sesh: sqlite3 is required' >&2; exit 1; }
93
+ ROWS=$("$SQLITE_BIN" -json -readonly "$DB_PATH" "$QUERY")
99
94
 
100
95
  # sqlite3 -json prints nothing (not []) when no rows match.
101
96
  [ -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,14 @@ 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
+ 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)
207
+ if [[ "$forked" =~ ^ses_[A-Za-z0-9]+$ ]]; then
208
+ session_id=$forked
209
+ else
210
+ echo 'sesh: could not fork the session; returning to the picker.' >&2
211
+ continue
212
+ fi
209
213
  fi
214
+ ( cd "$cwd" && exec "$OPENCODE_BIN" --session "$session_id" )
210
215
  echo 'sesh: opencode exited; returning to the picker.' >&2
211
216
  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; }
@@ -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": "1.1.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"
@@ -2,8 +2,8 @@ import { Plugin } from "@opencode/plugin"
2
2
  import { homedir } from "node:os"
3
3
  import { join } from "node:path"
4
4
 
5
- function dataHome(): string {
6
- return process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share")
5
+ function dbPath(): string {
6
+ return process.env.SESH_DB ?? join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "opencode", "opencode.db")
7
7
  }
8
8
 
9
9
  const input = {
@@ -44,12 +44,12 @@ export default Plugin.define({
44
44
  throw new Error("sesh_list requires the Bun runtime (bun:sqlite)")
45
45
  }
46
46
 
47
- const db = new sqlite.Database(join(dataHome(), "opencode", "opencode.db"), { readonly: true })
47
+ const db = new sqlite.Database(dbPath(), { readonly: true })
48
48
  try {
49
49
  const scoped = typeof args.directory === "string" && args.directory.length > 0
50
50
  const query = `SELECT id, title, directory, time_updated AS updated, time_created AS created
51
51
  FROM session_v2
52
- WHERE parent_id IS NULL AND time_archived IS NULL${scoped ? " AND directory = ?" : ""}
52
+ WHERE parent_id IS NULL AND COALESCE(time_archived, 0) = 0${scoped ? " AND directory = ?" : ""}
53
53
  ORDER BY time_updated DESC, id DESC
54
54
  LIMIT ${limit}`
55
55
  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
+ }
@@ -509,7 +509,7 @@ async function openTranscriptDb(): Promise<any | undefined> {
509
509
  // @ts-ignore - bun:sqlite ships with the Bun runtime (verified); no type package installed
510
510
  const sqlite: any = await import("bun:sqlite")
511
511
  if (typeof sqlite?.Database !== "function") return undefined
512
- return new sqlite.Database(`${dataHome}/opencode/opencode.db`, { readonly: true })
512
+ return new sqlite.Database(process.env.SESH_DB ?? `${dataHome}/opencode/opencode.db`, { readonly: true })
513
513
  } catch {
514
514
  return undefined
515
515
  }
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)."