opencode-sesh 0.1.3 → 0.1.5

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
@@ -13,8 +13,8 @@ territory: read this first, then the specific file you need.
13
13
  2. **`tui/sesh-panel.tsx`** — a SolidJS/OpenTUI plugin for the opencode TUI: a
14
14
  recent-sessions section in the sidebar plus a `ctrl+o` / `/sesh` picker.
15
15
  3. **`opencode/tools/sesh-list.ts`** — the `sesh-list` custom tool, so the agent
16
- can list sessions across every directory (the native `opencode session list`
17
- is project-scoped).
16
+ can list sessions across every directory by querying the global `opencode
17
+ db` 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
  opencode has no persistent-sidebar API, so the "sidebar" is a `sidebar_content`
@@ -43,15 +43,19 @@ The **filename becomes the tool name** in opencode, so `sesh-list.ts` is the
43
43
 
44
44
  ## Data flow (fzf picker)
45
45
 
46
- `bin/sesh-list.sh` is the only place that knows the session store (opencode
47
- SQLite DB: `session` / `message` / `part` tables). Keystrokes never touch the
48
- database:
46
+ `bin/sesh-list.sh` owns the fzf picker's session store access (opencode SQLite
47
+ DB: `session` / `message` / `part` tables); the `sesh-list` agent tool queries
48
+ the same store through `opencode db`. Keep its SQL column names in sync with
49
+ the schema below. Keystrokes never touch the database:
49
50
 
50
51
  - `--refresh` (expensive): bulk-reads session metadata and per-session text
51
52
  parts, validates the persistent extraction cache
52
- (`$SESH_CACHE_DIR/extractions/<ses_*.json>`) against `time_updated` + part
53
- count, re-extracts only changed sessions, prunes vanished ones, and atomically
54
- publishes `STATE_DIR/snapshot.jsonl` with `status = fresh`. Scope
53
+ (`$SESH_CACHE_DIR/extractions/<ses_*.json>`, schema 2) against `time_updated`,
54
+ part count and the latest part timestamp, re-extracts only changed sessions,
55
+ prunes vanished ones, and atomically publishes `STATE_DIR/snapshot.jsonl`.
56
+ Extraction moves transcripts through files, never argv (no ARG_MAX limit); if
57
+ any session fails, the published status is `stale` and that session stays
58
+ uncached so the next refresh retries, otherwise `status = fresh`. Scope
55
59
  (`--cwd`/toggle) and `--limit` apply at assembly, so metadata always covers
56
60
  the whole DB and pruning is always safe.
57
61
  - render (cheap, pure `jq`): reads the snapshot only. Filters on `searchText`
@@ -67,7 +71,7 @@ One worker (`sesh-refresh-worker.sh`, per picker, TERMed on exit) serializes
67
71
  scans; fzf's `start`/`every(3)`/`change` bindings render only. Selection requests
68
72
  a fresh poll (`request`/`wait`, ≤12 s) before dispatch. opencode has no
69
73
  live-agent API, so `liveState` is always `unknown` and rows render gray;
70
- dispatch is always `opencode --session <id>` (`--fork` with Ctrl-F/Enter).
74
+ dispatch is always `opencode --session <id>` (`--fork` with Ctrl-F or --fork).
71
75
  Resume happens in place (`exec` after `cd` to the session's cwd).
72
76
 
73
77
  ## Conventions
@@ -77,17 +81,21 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
77
81
  - **Text parts only** in search/preview; reasoning and tool payloads are
78
82
  excluded (asserted in tests).
79
83
  - Validate session ids (`^ses_[A-Za-z0-9]+$`) before SQL interpolation — never
80
- interpolate unvalidated input.
84
+ interpolate unvalidated input. Open SQLite read-only (`sqlite3 -readonly`).
81
85
  - Prefer direct `sqlite3` over `opencode db` (startup latency). Never parse
82
86
  `sqlite3 -json` in `-R` mode; it pretty-prints — parse as JSON.
83
87
  - `jq -R` output stays JSON-quoted: use `-Rr` when a shell variable needs the
84
88
  raw string.
85
- - fzf: stock only, `>= 0.52` (`transform:`, `every():`). `{q}` in binds is
89
+ - fzf: stock only, `>= 0.73` (`transform:`, `every():`). `{q}` in binds is
86
90
  shell-quoted by fzf — don't add quoting. Ctrl-G is bind-only (stays open);
87
91
  Ctrl-F is `--expect` (accepts with fork).
88
92
  - The installer prefers the local opencode version for `@opencode-ai/*` and
89
93
  merges (never clobbers) the config `package.json`; opencode runs `bun install`
90
94
  at startup.
95
+ - Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/)
96
+ (`feat`, `fix`, `docs`, `ci`, …). See
97
+ [CONTRIBUTING.md](CONTRIBUTING.md#commit-messages) for the types and the
98
+ version mapping.
91
99
 
92
100
  ## TUI panel (`tui/sesh-panel.tsx`)
93
101
 
package/CHANGELOG.md CHANGED
@@ -7,6 +7,60 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.5] - 2026-09-13
11
+
12
+ ### Fixed
13
+
14
+ - Transcript indexing no longer puts the whole conversation on the command line,
15
+ so large sessions (over a megabyte of text) are indexed instead of silently
16
+ dropped. A failed extraction now reports the snapshot as `stale` and retries
17
+ on the next refresh rather than certifying an incomplete index as fresh.
18
+ - Transcript preview renders text parts even when newer tool parts outnumber
19
+ them, and a long message no longer blanks the preview when the output pipe
20
+ closes early.
21
+ - Warm extraction caches are invalidated on any part change (text or timestamp),
22
+ not only when the session timestamp or part count moves.
23
+ - The installer refuses to replace a symlinked `sesh` launcher that is not a
24
+ sesh install, and uninstall leaves foreign launcher symlinks untouched.
25
+
26
+ ### Security
27
+
28
+ - Session ids are validated with the anchored `^ses_[A-Za-z0-9]+$` pattern at
29
+ the preview and deletion boundaries before any SQL, and the local SQLite store
30
+ is opened read-only.
31
+
32
+ ### Added
33
+
34
+ - Regression coverage for large transcripts, part-only cache invalidation,
35
+ preview text/tool ordering and long previews, malformed session ids, empty
36
+ stores, and installer launcher ownership.
37
+
38
+ ## [0.1.4] - 2026-09-13
39
+
40
+ ### Fixed
41
+
42
+ - The picker keeps the selected session across automatic refreshes and no
43
+ longer retargets Enter, Ctrl-F, or Ctrl-X when the selected session
44
+ disappears (`--track`/`--id-nth` plus a non-actionable placeholder row).
45
+ - Ctrl-F now forks: fzf's `--print-query`/`--expect` output (query, key, row)
46
+ was parsed as key, query, row, so forking resumed the original session.
47
+ - Require fzf >= 0.73 in the launcher, installer, README, and AGENTS — the
48
+ `every()` refresh event the picker relies on was added in 0.73.
49
+ - Transcript extraction caches are written 0700/0600 regardless of the caller's
50
+ umask, and existing caches are tightened on refresh.
51
+
52
+ ### Changed
53
+
54
+ - The `sesh-list` agent tool queries the global session store, so it lists
55
+ every project directory (roots, non-archived) instead of only the current
56
+ project; directory filtering now happens before the limit.
57
+
58
+ ### Added
59
+
60
+ - Real-fzf PTY regressions for selection tracking and fork parsing
61
+ (`npm run test:picker`), an agent-tool contract/SQL suite, and fzf
62
+ version-gate and cache-permission regressions.
63
+
10
64
  ## [0.1.3] - 2026-09-12
11
65
 
12
66
  ### Added
@@ -48,7 +102,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
48
102
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
49
103
  CI.
50
104
 
51
- [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.3...HEAD
105
+ [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.5...HEAD
106
+ [0.1.5]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.4...v0.1.5
107
+ [0.1.4]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.3...v0.1.4
52
108
  [0.1.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.2...v0.1.3
53
109
  [0.1.2]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.1...v0.1.2
54
110
  [0.1.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.0...v0.1.1
package/README.md CHANGED
@@ -82,7 +82,7 @@ ctrl+o # inside opencode — picker
82
82
  ### Requirements
83
83
 
84
84
  - [opencode](https://opencode.ai), used at least once
85
- - `bash`, `jq`, and [`fzf`](https://github.com/junegunn/fzf) `>= 0.52`
85
+ - `bash`, `jq`, and [`fzf`](https://github.com/junegunn/fzf) `>= 0.73`
86
86
  - `sqlite3` recommended — the picker queries the session DB directly in
87
87
  milliseconds and falls back to the slower `opencode db` CLI without it
88
88
  - [`glow`](https://github.com/charmbracelet/glow) optional — styles the
@@ -104,7 +104,7 @@ bash install.sh --uninstall # from source
104
104
  | --- | --- |
105
105
  | Type | Search titles and transcript text across all directories |
106
106
  | Enter | Resume the selected session in this terminal |
107
- | Ctrl-F Enter | Resume as a fork (the original is untouched) |
107
+ | Ctrl-F | Resume as a fork (the original is untouched) |
108
108
  | Ctrl-G | Toggle current-directory scope / all sessions |
109
109
  | Space (empty query) | Toggle the transcript preview |
110
110
  | `?` (empty query) | Toggle shortcut help |
@@ -37,7 +37,7 @@ acquire_lock() {
37
37
  # fzf passes an empty second field for headers and notices. They are never
38
38
  # actions, and should not surface a misleading failure prompt.
39
39
  [ -n "$session_id" ] || exit 0
40
- case "$session_id" in ses_[A-Za-z0-9]*) ;; *) echo "Invalid session identity."; pause; exit 0;; esac
40
+ [[ "$session_id" =~ ^ses_[A-Za-z0-9]+$ ]] || { echo "Invalid session identity."; pause; exit 0; }
41
41
  [ -n "$state_dir" ] && [ -f "$snapshot" ] || { echo "No session snapshot available."; pause; exit 0; }
42
42
 
43
43
  # Serialize this action with the sole snapshot publisher. A short collision
package/bin/sesh-list.sh CHANGED
@@ -6,6 +6,7 @@
6
6
  # TSV: agentId, sessionId, liveState, display, cwd, trackingId.
7
7
  # Every row, including headers/notices, requires a unique nonempty trackingId.
8
8
  set -euo pipefail
9
+ umask 077
9
10
 
10
11
  LIMIT=0
11
12
  LIMIT_SET=0
@@ -17,6 +18,7 @@ INCLUDE_ARCHIVED=0
17
18
  ARCHIVED_SET=0
18
19
  STATE_DIR=''
19
20
  QUERY=''
21
+ SELECTED_ID=''
20
22
  SCOPE_CWD=''
21
23
  usage() { echo "usage: ${0##*/} [--refresh] [--wait-lock SECONDS] --state-dir DIR [--cwd] [--limit N] [--archived] [--toggle-scope] [--query TEXT]" >&2; }
22
24
  while [ "$#" -gt 0 ]; do
@@ -29,6 +31,7 @@ while [ "$#" -gt 0 ]; do
29
31
  --archived) INCLUDE_ARCHIVED=1; ARCHIVED_SET=1 ;;
30
32
  --toggle-scope) TOGGLE_SCOPE=1 ;;
31
33
  --query) QUERY=${2:-}; shift ;;
34
+ --selected-id) SELECTED_ID=${2:-}; shift ;;
32
35
  --help) usage; exit 0 ;;
33
36
  *) echo "unknown option: $1" >&2; usage; exit 2 ;;
34
37
  esac
@@ -92,7 +95,9 @@ fi
92
95
  # would make every preview keystroke laggy.
93
96
  db_json() {
94
97
  if [ -n "$SQLITE_BIN" ]; then
95
- "$SQLITE_BIN" -json "$DB_PATH" "$1"
98
+ # Read-only: sesh never writes the store directly (deletes go through
99
+ # `opencode session delete`).
100
+ "$SQLITE_BIN" -json -readonly "$DB_PATH" "$1"
96
101
  else
97
102
  "$OPENCODE_BIN" db "$1" --format json
98
103
  fi
@@ -111,18 +116,24 @@ set_status() { atomic_text "$STATUS" "$1"; }
111
116
  render() {
112
117
  local state=''
113
118
  [ -f "$STATUS" ] && state=$(cat "$STATUS" 2>/dev/null || true)
114
- if [ ! -s "$SNAPSHOT" ]; then
119
+ if [ ! -f "$SNAPSHOT" ]; then
115
120
  [ -n "$state" ] && [ "$state" != fresh ] && printf '\t\tunknown\t%s%s%s\t\tnotice:live-state\n' "$YELLOW" "$state" "$RESET"
116
121
  return 0
117
122
  fi
118
123
  {
119
124
  [ -n "$state" ] && [ "$state" != fresh ] && printf '\t\tunknown\t%s%s%s\t\tnotice:live-state\n' "$YELLOW" "$state" "$RESET"
120
- "$JQ_BIN" -sr --arg green "$GREEN" --arg cyan "$CYAN" --arg gray "$GRAY" --arg magenta "$MAGENTA" --arg red "$RED" --arg bold "$BOLD" --arg reset "$RESET" --arg query "$QUERY" --arg home "$HOME" '
125
+ "$JQ_BIN" -sr --arg green "$GREEN" --arg cyan "$CYAN" --arg gray "$GRAY" --arg magenta "$MAGENTA" --arg red "$RED" --arg bold "$BOLD" --arg yellow "$YELLOW" --arg reset "$RESET" --arg query "$QUERY" --arg home "$HOME" --arg selected "$SELECTED_ID" '
121
126
  def ago($epoch):
122
127
  ((now - ($epoch | tonumber)) | floor) as $s
123
128
  | if $s < 60 then "now" elif $s < 3600 then "\($s / 60 | floor)m" elif $s < 86400 then "\($s / 3600 | floor)h" else "\($s / 86400 | floor)d" end;
124
129
  def homepath:
125
130
  . as $p | if $p == "" then "?" elif $p == $home then "~" elif ($home != "" and startswith($home + "/")) then "~" + .[($home|length):] else . end;
131
+ # Preserve a vanished selection as a non-actionable row with its original
132
+ # tracking key. Otherwise fzf falls back to a different session. Keep it
133
+ # until the user moves away; empty field 2 makes Enter/Ctrl-F/Ctrl-X no-ops.
134
+ (if ($selected | test("^ses_[A-Za-z0-9]+$")) and (any(.sessionId == $selected) | not)
135
+ then (["", "", "unknown", ($yellow + "Selected session is no longer available; choose another session" + $reset), "", $selected] | @tsv)
136
+ else empty end),
126
137
  (($query | ascii_downcase) as $q
127
138
  | map(select($q == "" or (.searchText | contains($q))))
128
139
  | group_by(.cwd)
@@ -159,8 +170,22 @@ acquire_lock_wait() {
159
170
  done
160
171
  }
161
172
 
173
+ private_dir() {
174
+ # These are dedicated sesh directories, never the shared XDG parent. Refuse
175
+ # symlinks/foreign ownership rather than changing permissions on their target.
176
+ [ ! -L "$1" ] || { echo "sesh: private directory is a symlink: $1" >&2; return 1; }
177
+ mkdir -p "$1"
178
+ [ -O "$1" ] || { echo "sesh: private directory is not owned by this user: $1" >&2; return 1; }
179
+ chmod 700 "$1"
180
+ }
181
+
162
182
  refresh() {
163
- mkdir -p "$STATE_DIR"
183
+ private_dir "$STATE_DIR"
184
+ private_dir "$CACHE_DIR"
185
+ private_dir "$EXTRACTIONS"
186
+ # Migrate warm caches too, including temporary files left by older versions.
187
+ # Do not follow symlinks or chmod unrelated files outside our extraction store.
188
+ find "$EXTRACTIONS" -maxdepth 1 -type f -user "$(id -u)" \( -name 'ses_*.json' -o -name 'ses_*.json.tmp' \) -exec chmod 600 {} +
164
189
  if [ "${SESH_LOCK_HELD:-}" != "$STATE_DIR" ]; then
165
190
  if [ "$WAIT_LOCK" -gt 0 ]; then acquire_lock_wait || return $?; else acquire_lock || return 0; fi
166
191
  fi
@@ -180,7 +205,6 @@ refresh() {
180
205
  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
181
206
  return 0
182
207
  fi
183
- mkdir -p "$EXTRACTIONS"
184
208
  local scratch sessions parts cache_records cache_state historical needs_extract out
185
209
  scratch=$(mktemp -d "$STATE_DIR/.refresh.XXXXXX")
186
210
  refresh_scratch=$scratch
@@ -199,6 +223,9 @@ refresh() {
199
223
  if [ -f "$SNAPSHOT" ]; then set_status 'stale: opencode database query failed; showing last good snapshot'; else set_status 'unavailable: opencode database query failed'; fi
200
224
  return 0
201
225
  }
226
+ # sqlite3 emits no bytes for zero rows. Publish an empty snapshot so removal
227
+ # of the final session also produces the non-actionable selection notice.
228
+ [ -s "$sessions" ] || printf '[]\n' > "$sessions"
202
229
  "$JQ_BIN" -e 'type == "array"' "$sessions" >/dev/null 2>&1 || {
203
230
  if [ -f "$SNAPSHOT" ]; then set_status 'stale: opencode database query failed; showing last good snapshot'; else set_status 'unavailable: opencode database query failed'; fi
204
231
  return 0
@@ -218,11 +245,11 @@ refresh() {
218
245
  | ($parts[0] | map({key:.session_id, value:.}) | from_entries) as $stats
219
246
  | [$sessions[0][] | . + {partCount: ($stats[.id].n // 0), partMax: ($stats[.id].m // 0)}] as $meta
220
247
  | ($meta | map({key:.id, value:.}) | from_entries) as $byId
221
- | [$caches[0][]? | select(.cacheSchema == 1 and (.sessionId | type) == "string")
222
- | select($byId[.sessionId] as $m | $m != null and .cacheUpdated == $m.time_updated and .cacheParts == $m.partCount)] as $cached
223
- | ($cached | map({key:.sessionId, value:true}) | from_entries) as $cachedIds
248
+ | [$caches[0][]? | select(.cacheSchema == 2 and (.sessionId | type) == "string")
249
+ | select($byId[.sessionId] as $m | $m != null and .cacheUpdated == $m.time_updated and .cacheParts == $m.partCount and .cacheMax == $m.partMax)] as $cached
250
+ | ($cached | map({key: .sessionId, value:true}) | from_entries) as $cachedIds
224
251
  | {cached: $cached,
225
- stale: [$meta[] | select($cachedIds[.id] != true) | {id, directory, title, time_created, time_updated, archived, partCount}],
252
+ stale: [$meta[] | select($cachedIds[.id] != true) | {id, directory, title, time_created, time_updated, archived, partCount, partMax}],
226
253
  prune: [$caches[0][]? | select(.sessionId? as $s | $byId[$s] == null) | .cachePath],
227
254
  meta: $meta}
228
255
  ' "$cache_records" > "$cache_state" || {
@@ -233,7 +260,8 @@ refresh() {
233
260
  "$JQ_BIN" -c '.stale[]?' "$cache_state" > "$needs_extract"
234
261
  while IFS= read -r cache; do [ -n "$cache" ] && rm -f "$cache"; done < <("$JQ_BIN" -r '.prune[]?' "$cache_state")
235
262
 
236
- local sid dir title created updated archived partCount text fulltext record cache
263
+ local sid dir title created updated partCount partMax raw_file text_file record cache
264
+ local extract_failed=0
237
265
  while IFS= read -r row; do
238
266
  [ -n "$row" ] || continue
239
267
  sid=$(printf '%s' "$row" | "$JQ_BIN" -r '.id')
@@ -243,27 +271,40 @@ refresh() {
243
271
  created=$(printf '%s' "$row" | "$JQ_BIN" -r '.time_created // 0')
244
272
  updated=$(printf '%s' "$row" | "$JQ_BIN" -r '.time_updated // 0')
245
273
  partCount=$(printf '%s' "$row" | "$JQ_BIN" -r '.partCount // 0')
274
+ partMax=$(printf '%s' "$row" | "$JQ_BIN" -r '.partMax // 0')
246
275
  cache="$EXTRACTIONS/$sid.json"
247
- # Text parts only: reasoning blobs and tool payloads would bloat the
248
- # search index without helping anyone find a session.
249
- text=$(db_json "SELECT data FROM part WHERE session_id = '$sid' AND json_extract(data, '\$.type') = 'text' ORDER BY time_created;" 2>/dev/null \
250
- | "$JQ_BIN" -r '[.[]? | (.data? // empty) | (try fromjson catch empty) | select(type == "object" and .type == "text") | .text // empty] | join(" ")' 2>/dev/null) || text=''
251
- fulltext=$(printf '%s' "$title $text" | "$JQ_BIN" -Rrs 'ascii_downcase' 2>/dev/null) || fulltext=''
276
+ raw_file="$scratch/$sid.parts.json"
277
+ text_file="$scratch/$sid.text"
278
+ # Text parts only: reasoning blobs and tool payloads would bloat the search
279
+ # index. The transcript travels through files, never argv, so a large
280
+ # conversation cannot hit ARG_MAX; a failure leaves the session uncached so
281
+ # the next refresh retries instead of certifying it fresh.
282
+ if db_json "SELECT data FROM part WHERE session_id = '$sid' AND json_extract(data, '\$.type') = 'text' ORDER BY time_created;" > "$raw_file" 2>/dev/null \
283
+ && "$JQ_BIN" -r '[.[]? | (.data? // empty) | (try fromjson catch empty) | select(type == "object" and .type == "text") | .text // empty] | join(" ")' "$raw_file" > "$text_file" 2>/dev/null; then
284
+ :
285
+ else
286
+ extract_failed=1
287
+ continue
288
+ fi
252
289
  if [ -z "$title" ]; then
253
- title=$(printf '%s' "$text" | "$JQ_BIN" -Rrs 'gsub("[\r\n]+"; " ") | if length > 70 then .[0:67] + "..." else . end | select(length > 0) // "(untitled)"' 2>/dev/null) || title='(untitled)'
290
+ title=$("$JQ_BIN" -Rrs 'gsub("[\r\n]+"; " ") | if length > 70 then .[0:67] + "..." else . end | select(length > 0) // "(untitled)"' "$text_file" 2>/dev/null) || title='(untitled)'
254
291
  [ -n "$title" ] || title='(untitled)'
255
292
  fi
256
293
  record=$("$JQ_BIN" -cn --arg sid "$sid" --arg cwd "$dir" --arg title "$title" \
257
- --argjson created "$created" --argjson updated "$updated" --argjson parts "$partCount" --arg fulltext "$fulltext" '
258
- {cacheSchema: 1, cacheSession: $sid, cacheUpdated: $updated, cacheParts: $parts,
294
+ --argjson created "$created" --argjson updated "$updated" --argjson parts "$partCount" --argjson maxpart "$partMax" \
295
+ --rawfile text "$text_file" '
296
+ {cacheSchema: 2, cacheSession: $sid, cacheUpdated: $updated, cacheParts: $parts, cacheMax: $maxpart,
259
297
  sessionId: $sid, transcriptPath: "", source: "opencode",
260
298
  cwd: $cwd, title: ($title | gsub("[\r\n]+"; " ") | if length > 70 then .[0:67] + "..." else . end),
261
299
  updatedEpoch: (($updated / 1000 | floor) | if . > 0 then . else ($created / 1000 | floor) end),
262
- fulltextLower: $fulltext}') || record=''
300
+ fulltextLower: (($title + " " + $text) | sub("\n$"; "") | ascii_downcase)}') || record=''
263
301
  if [ -n "$record" ]; then
264
- printf '%s\n' "$record" > "$cache.tmp"
265
- mv "$cache.tmp" "$cache"
302
+ cache_tmp=$(mktemp "$EXTRACTIONS/$sid.json.XXXXXX")
303
+ printf '%s\n' "$record" > "$cache_tmp"
304
+ mv "$cache_tmp" "$cache"
266
305
  printf '%s\n' "$record" >> "$historical"
306
+ else
307
+ extract_failed=1
267
308
  fi
268
309
  done < "$needs_extract"
269
310
 
@@ -271,6 +312,7 @@ refresh() {
271
312
  .meta as $meta
272
313
  | ($hist | map({key: .sessionId, value: .}) | from_entries) as $histById
273
314
  | [$meta[]
315
+ | select((.id | type) == "string" and (.id | test("^ses_[A-Za-z0-9]+$")))
274
316
  | select($archived == 1 or .archived == 0)
275
317
  | select($scope == "" or .directory == $scope)
276
318
  | . as $m | ($histById[$m.id] // {}) as $h
@@ -287,7 +329,13 @@ refresh() {
287
329
  return 1
288
330
  fi
289
331
  mv -f "$out" "$SNAPSHOT"
290
- set_status fresh || true
332
+ if [ "$extract_failed" = 1 ]; then
333
+ # Publish what we have, but say the index is incomplete and keep the
334
+ # failed sessions uncached so the next refresh retries them.
335
+ set_status 'stale: transcript extraction incomplete; refresh to retry' || true
336
+ else
337
+ set_status fresh || true
338
+ fi
291
339
  }
292
340
 
293
341
  if [ "$REFRESH" = 1 ] || [ "$TOGGLE_SCOPE" = 1 ]; then refresh; fi
@@ -10,7 +10,7 @@ JQ_BIN=${SESH_JQ:-jq}
10
10
 
11
11
  [ -n "$state_dir" ] && [ -n "$session_id" ] || { echo "(no session selected)"; exit 0; }
12
12
  [ -f "$snapshot" ] || { echo "(no session snapshot available)"; exit 0; }
13
- case "$session_id" in ses_[A-Za-z0-9]*) ;; *) echo "(no transcript for this row)"; exit 0;; esac
13
+ [[ "$session_id" =~ ^ses_[A-Za-z0-9]+$ ]] || { echo "(no transcript for this row)"; exit 0; }
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
 
@@ -26,26 +26,31 @@ fi
26
26
 
27
27
  SQLITE_BIN=${SESH_SQLITE:-}
28
28
  if [ -z "$SQLITE_BIN" ] && command -v sqlite3 >/dev/null 2>&1; then SQLITE_BIN=sqlite3; fi
29
- SQL="SELECT m.data AS msg, p.data AS part FROM part p JOIN message m ON m.id = p.message_id WHERE p.session_id = '$session_id' ORDER BY p.time_created DESC LIMIT 200;"
29
+ SQL="SELECT m.data AS msg, p.data AS part FROM part p JOIN message m ON m.id = p.message_id WHERE p.session_id = '$session_id' AND json_extract(p.data, '\$.type') = 'text' ORDER BY p.time_created DESC LIMIT 200;"
30
30
  if [ -n "$SQLITE_BIN" ]; then
31
- rows=$("$SQLITE_BIN" -json "$DB_PATH" "$SQL" 2>/dev/null) || rows=''
31
+ rows=$("$SQLITE_BIN" -json -readonly "$DB_PATH" "$SQL" 2>/dev/null) || rows=''
32
32
  else
33
33
  OPENCODE_BIN=${SESH_OPENCODE:-opencode}
34
34
  rows=$("$OPENCODE_BIN" db "$SQL" --format json 2>/dev/null) || rows=''
35
35
  fi
36
36
  [ -n "$rows" ] || { echo "(no displayable transcript messages)"; exit 0; }
37
37
 
38
- # Text parts only, newest first, with role headers. Control bytes are stripped
39
- # with tr (byte-safe for UTF-8: only 0x00-0x1F and 0x7F are removed).
38
+ # Text parts only, newest first, with role headers. The 120-block cap is applied
39
+ # inside jq so nothing closes the pipe early (a `head` here would trip pipefail
40
+ # with SIGPIPE and blank the preview). Control bytes are stripped with tr
41
+ # (byte-safe for UTF-8: only 0x00-0x1F and 0x7F are removed).
40
42
  rendered=$(printf '%s' "$rows" | "$JQ_BIN" -r '
41
43
  (if type == "array" then . else [] end)
44
+ | map(
45
+ ((.msg | try fromjson catch {}) | .role? // "") as $role
46
+ | (.part | try fromjson catch {}) as $p
47
+ | select(($p.type? // "") == "text")
48
+ | ($p.text? // "") | select(type == "string" and length > 0)
49
+ | (if $role == "user" then "# You" elif $role == "assistant" then "# Opencode" else "# Message" end) + "\n\n" + .
50
+ )
51
+ | .[0:120]
42
52
  | .[]
43
- | ((.msg | try fromjson catch {}) | .role? // "") as $role
44
- | (.part | try fromjson catch {}) as $p
45
- | select(($p.type? // "") == "text")
46
- | ($p.text? // "") | select(type == "string" and length > 0)
47
- | (if $role == "user" then "# You" elif $role == "assistant" then "# Opencode" else "# Message" end) + "\n\n" + .
48
- ' 2>/dev/null | tr -d '\000-\010\013\014\016-\037\177' | head -120) || rendered=''
53
+ ' 2>/dev/null | tr -d '\000-\010\013\014\016-\037\177') || rendered=''
49
54
  [ -n "$rendered" ] || { echo "(no displayable transcript messages)"; exit 0; }
50
55
 
51
56
  resolve_glow() {
@@ -2,6 +2,7 @@
2
2
  # A short-lived worker owned by one picker. It serializes expensive refreshes;
3
3
  # fzf only ever asks the list script to render the last published snapshot.
4
4
  set -euo pipefail
5
+ umask 077
5
6
 
6
7
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
8
  # The list script can be overridden for reuse; default is the opencode list.
@@ -14,6 +15,7 @@ SCOPE=''
14
15
  LIMIT=60
15
16
  TARGET=''
16
17
  TIMEOUT=12
18
+ SELECTED_ID=''
17
19
 
18
20
  usage() { echo "usage: ${0##*/} init|worker|render|request|wait --state-dir DIR [options]" >&2; exit 2; }
19
21
  while [ "$#" -gt 0 ]; do
@@ -22,6 +24,7 @@ while [ "$#" -gt 0 ]; do
22
24
  --scope) [ "$#" -ge 2 ] || usage; SCOPE=$2; shift ;;
23
25
  --limit) [ "$#" -ge 2 ] || usage; LIMIT=$2; shift ;;
24
26
  --query) [ "$#" -ge 2 ] || usage; QUERY=$2; shift ;;
27
+ --selected-id) [ "$#" -ge 2 ] || usage; SELECTED_ID=$2; shift ;;
25
28
  --target) [ "$#" -ge 2 ] || usage; TARGET=$2; shift ;;
26
29
  --timeout) [ "$#" -ge 2 ] || usage; TIMEOUT=$2; shift ;;
27
30
  *) usage ;;
@@ -49,7 +52,7 @@ case "$MODE" in
49
52
  atomic_text "$STATE_DIR/worker-complete" 0
50
53
  ;;
51
54
  render)
52
- "$LIST" --state-dir "$STATE_DIR" --query "${QUERY:-}"
55
+ "$LIST" --state-dir "$STATE_DIR" --query "${QUERY:-}" --selected-id "$SELECTED_ID"
53
56
  # An empty, first snapshot should not look like a broken picker.
54
57
  if [ ! -f "$STATE_DIR/status" ] && [ -f "$STATE_DIR/worker.pid" ]; then
55
58
  printf '\t\tunknown\tLoading sessions…\t\tnotice:loading\n'
@@ -5,7 +5,7 @@ sesh shortcuts
5
5
 
6
6
  Type Search session titles and transcript text
7
7
  Enter Resume the selected session here
8
- Ctrl-F Enter Resume the selected session as a fork
8
+ Ctrl-F Resume the selected session as a fork
9
9
  Ctrl-G Toggle current-directory scope / all sessions
10
10
  Space Toggle transcript preview
11
11
  ? Toggle this help
package/bin/sesh.sh CHANGED
@@ -4,6 +4,7 @@
4
4
  # Refreshes publish one state-dir snapshot; fzf query changes render that
5
5
  # snapshot only, so typing never starts competing scans.
6
6
  set -euo pipefail
7
+ umask 077
7
8
 
8
9
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
9
10
 
@@ -25,8 +26,8 @@ version=$("$SESH_FZF" --version 2>/dev/null || true)
25
26
  if [[ "$version" =~ ^([0-9]+)\.([0-9]+)(\.([0-9]+))? ]]; then
26
27
  fzf_major=$((10#${BASH_REMATCH[1]}))
27
28
  fzf_minor=$((10#${BASH_REMATCH[2]}))
28
- if [ "$fzf_major" -eq 0 ] && [ "$fzf_minor" -lt 52 ]; then
29
- echo "sesh: fzf >= 0.52.0 is required (found $version)" >&2
29
+ if [ "$fzf_major" -eq 0 ] && [ "$fzf_minor" -lt 73 ]; then
30
+ echo "sesh: fzf >= 0.73.0 is required (found $version)" >&2
30
31
  exit 1
31
32
  fi
32
33
  else
@@ -92,7 +93,7 @@ cleanup() {
92
93
  trap cleanup EXIT
93
94
  trap 'exit 130' HUP INT TERM
94
95
 
95
- cache_cmd="$q_worker render --state-dir $q_state --query {q}"
96
+ cache_cmd="$q_worker render --state-dir $q_state --query {q} --selected-id {6}"
96
97
  preview_cmd="if [ -f $q_state/help ]; then $q_shortcuts; else $q_preview $q_state {2}; fi"
97
98
  help_bind="?:transform:[ -z {q} ] && ( [ -f $q_state/help ] && rm -f $q_state/help && echo hide-preview || ( : > $q_state/help && echo show-preview+refresh-preview ) ) || echo 'put(?)'"
98
99
  toggle_cmd="$q_list --toggle-scope --state-dir $q_state >/dev/null"
@@ -102,7 +103,7 @@ query="$picker_query"
102
103
  while :; do
103
104
  selection=''
104
105
  set +e
105
- selection=$(FZF_DEFAULT_COMMAND='printf ""' FZF_DEFAULT_OPTS='' FZF_DEFAULT_OPTS_FILE='' "$SESH_FZF" --print-query --expect=ctrl-f --query="$query" --ansi --delimiter=$'\t' --with-nth=4 --disabled --layout=reverse --info=hidden --prompt='❯ ' \
106
+ selection=$(FZF_DEFAULT_COMMAND='printf ""' FZF_DEFAULT_OPTS='' FZF_DEFAULT_OPTS_FILE='' "$SESH_FZF" --print-query --expect=ctrl-f --query="$query" --ansi --delimiter=$'\t' --with-nth=4 --track --id-nth=6 --disabled --layout=reverse --info=hidden --prompt='❯ ' \
106
107
  --preview="$preview_cmd" --preview-window='right,50%,wrap,hidden' \
107
108
  --bind='space:transform:[ -z {q} ] && echo toggle-preview || echo "put( )"' \
108
109
  --bind="$help_bind" \
@@ -113,10 +114,11 @@ selection=$(FZF_DEFAULT_COMMAND='printf ""' FZF_DEFAULT_OPTS='' FZF_DEFAULT_OPTS
113
114
  status=$?
114
115
  set -e
115
116
  case "$status" in 0) ;; 1|130) exit 0;; *) echo "sesh: picker failed ($status)" >&2; exit "$status";; esac
116
- key=${selection%%$'\n'*}
117
- selection=${selection#*$'\n'}
117
+ # --print-query precedes the --expect key (empty for Enter), then the row.
118
118
  query=${selection%%$'\n'*}
119
119
  selection=${selection#*$'\n'}
120
+ key=${selection%%$'\n'*}
121
+ selection=${selection#*$'\n'}
120
122
  session_id=$(printf '%s\n' "$selection" | cut -f2)
121
123
  [ -n "$session_id" ] || continue
122
124
  # Dispatch queues a new worker scan and waits for that exact serialized poll.
package/install.sh CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bash
2
2
  # Install sesh for the current user.
3
3
  # Terminal- and OS-agnostic: macOS and Linux, any terminal. No administrator
4
- # privileges, downloads, or shell-rc edits. Runtime requires jq, fzf >= 0.52
4
+ # privileges, downloads, or shell-rc edits. Runtime requires jq, fzf >= 0.73
5
5
  # and sqlite3 or the opencode CLI; the in-TUI panel additionally needs the
6
6
  # packages opencode installs for local plugins (handled below).
7
7
  set -euo pipefail
@@ -91,8 +91,33 @@ remove_tui_entry() {
91
91
  fi
92
92
  }
93
93
 
94
+ # The launcher is a symlink we create. Treat it as ours only when it resolves to
95
+ # a sesh launcher, so a foreign symlink is never replaced on install nor deleted
96
+ # on uninstall. Recognized by the exact source path, a `.../bin/sesh` target, or
97
+ # the marker in the target file (covers npm upgrades and a moved checkout).
98
+ launcher_is_ours() {
99
+ local link=$1 target
100
+ [ -L "$link" ] || return 1
101
+ target=$(readlink "$link" 2>/dev/null) || return 1
102
+ [ -n "$target" ] || return 1
103
+ case "$target" in
104
+ /*) ;;
105
+ *) target="$(cd "$(dirname "$link")" && pwd -P)/$target" ;;
106
+ esac
107
+ case "$target" in
108
+ "$SOURCE_DIR/bin/sesh"|*/bin/sesh) return 0 ;;
109
+ esac
110
+ [ -f "$target" ] && grep -qF 'Pick an opencode session' "$target" 2>/dev/null
111
+ }
112
+
94
113
  if [ "$UNINSTALL" = 1 ]; then
95
- [ -L "$LAUNCHER" ] && rm -f "$LAUNCHER" && note "removed $LAUNCHER"
114
+ if [ -L "$LAUNCHER" ]; then
115
+ if launcher_is_ours "$LAUNCHER"; then
116
+ rm -f "$LAUNCHER" && note "removed $LAUNCHER"
117
+ else
118
+ note "left $LAUNCHER untouched (not a sesh launcher: $(readlink "$LAUNCHER" 2>/dev/null))"
119
+ fi
120
+ fi
96
121
  remove_if_marker "$COMMAND_DST" "rich interactive picker" && note "removed $COMMAND_DST"
97
122
  remove_if_marker "$TOOL_DST" "list opencode sessions for the agent" && note "removed $TOOL_DST"
98
123
  remove_if_marker "$PANEL_DST" "@jsxImportSource @opentui/solid" && note "removed $PANEL_DST"
@@ -120,8 +145,8 @@ for tool in bash jq fzf; do
120
145
  done
121
146
  fzf_version=$(fzf --version 2>/dev/null || true)
122
147
  if [[ "$fzf_version" =~ ^([0-9]+)\.([0-9]+)(\.([0-9]+))? ]]; then
123
- if [ "$((10#${BASH_REMATCH[1]}))" -eq 0 ] && [ "$((10#${BASH_REMATCH[2]}))" -lt 52 ]; then
124
- fail "fzf >= 0.52.0 is required (found $fzf_version)."
148
+ if [ "$((10#${BASH_REMATCH[1]}))" -eq 0 ] && [ "$((10#${BASH_REMATCH[2]}))" -lt 73 ]; then
149
+ fail "fzf >= 0.73.0 is required (found $fzf_version)."
125
150
  fi
126
151
  else
127
152
  fail "cannot parse fzf version from: $fzf_version."
@@ -135,6 +160,9 @@ mkdir -p "$BIN_DIR"
135
160
  if [ -e "$LAUNCHER" ] && [ ! -L "$LAUNCHER" ]; then
136
161
  fail "$LAUNCHER already exists and is not a symlink. Move it aside, then retry."
137
162
  fi
163
+ if [ -L "$LAUNCHER" ] && ! launcher_is_ours "$LAUNCHER"; then
164
+ fail "$LAUNCHER is a symlink to $(readlink "$LAUNCHER" 2>/dev/null), which is not a sesh install. Move it aside, then retry."
165
+ fi
138
166
  ln -sfn "$SOURCE_DIR/bin/sesh" "$LAUNCHER"
139
167
  note "linked $LAUNCHER -> $SOURCE_DIR/bin/sesh"
140
168
 
@@ -12,7 +12,10 @@ export default tool({
12
12
  args: {
13
13
  limit: tool.schema
14
14
  .number()
15
- .describe("Maximum sessions to return")
15
+ .int()
16
+ .min(0)
17
+ .max(1000)
18
+ .describe("Maximum sessions to return (0–1000; 0 returns none)")
16
19
  .default(50),
17
20
  directory: tool.schema
18
21
  .string()
@@ -20,23 +23,29 @@ export default tool({
20
23
  .optional(),
21
24
  },
22
25
  async execute(args) {
26
+ // Validate here as well as in the schema for direct/programmatic callers.
27
+ if (!Number.isInteger(args.limit) || args.limit < 0 || args.limit > 1000) {
28
+ throw new Error("limit must be an integer between 0 and 1000")
29
+ }
23
30
  const bun = (globalThis as any).Bun
24
31
  if (!bun || typeof bun.$ !== "function") {
25
32
  throw new Error("This tool requires the Bun runtime (globalThis.Bun.$)")
26
33
  }
27
34
 
28
- const out = await bun.$`opencode session list -n ${args.limit} --format json`.json()
29
- const sessions = (Array.isArray(out) ? out : []).filter(
30
- (s) => !args.directory || s.directory === args.directory,
31
- )
32
- return JSON.stringify(
33
- sessions.map((s) => ({
34
- id: s.id,
35
- title: s.title,
36
- directory: s.directory,
37
- updated: s.updated,
38
- created: s.created,
39
- })),
40
- )
35
+ // `session list` is project-scoped and hides only children of forks. The
36
+ // global store has no project instance, so query the SQLite session table
37
+ // directly (OpenCode 1.18.30). Match `Session.listGlobal`/the picker: roots
38
+ // only, non-archived. Bun quotes the SQL as one shell argument; SQL string
39
+ // literals need their own escaping as well.
40
+ const where = args.directory === undefined
41
+ ? ""
42
+ : ` AND directory = '${args.directory.replaceAll("'", "''")}'`
43
+ const query = `SELECT id, title, directory, time_updated AS updated, time_created AS created
44
+ FROM session WHERE parent_id IS NULL AND time_archived IS NULL${where}
45
+ ORDER BY time_updated DESC, id DESC LIMIT ${args.limit}`
46
+ const text = await bun.$`opencode db ${query} --format json`.text()
47
+ const sessions = JSON.parse(text.trim() || "[]")
48
+ if (!Array.isArray(sessions)) throw new Error("Unexpected opencode db result: expected an array")
49
+ return JSON.stringify(sessions)
41
50
  },
42
51
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
6
6
  "license": "MIT",
@@ -40,17 +40,23 @@
40
40
  "node": ">=20"
41
41
  },
42
42
  "scripts": {
43
+ "prepare": "husky",
43
44
  "test": "bash tests/sesh.sh",
45
+ "test:picker": "python3 tests/picker.py",
44
46
  "typecheck": "tsc -p tsconfig.json",
45
- "lint:sh": "bash -n bin/*.sh bin/sesh install.sh tests/sesh.sh"
47
+ "lint:sh": "for script in bin/*.sh bin/sesh install.sh tests/*.sh; do bash -n \"$script\" || exit; done",
48
+ "prepublishOnly": "npm test && npm run typecheck"
46
49
  },
47
50
  "devDependencies": {
51
+ "@commitlint/cli": "^21.2.2",
52
+ "@commitlint/config-conventional": "^21.2.2",
48
53
  "@opencode-ai/plugin": "1.18.30",
49
54
  "@opencode-ai/sdk": "1.18.30",
50
55
  "@opentui/core": "^0.5.11",
51
56
  "@opentui/keymap": "^0.5.11",
52
57
  "@opentui/solid": "^0.5.11",
53
58
  "@types/node": "26.5.1",
59
+ "husky": "^9.1.7",
54
60
  "solid-js": "1.9.12",
55
61
  "typescript": "7.0.2"
56
62
  }