opencode-sesh 0.1.4 → 0.1.6

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
@@ -50,9 +50,12 @@ the schema below. Keystrokes never touch the database:
50
50
 
51
51
  - `--refresh` (expensive): bulk-reads session metadata and per-session text
52
52
  parts, validates the persistent extraction cache
53
- (`$SESH_CACHE_DIR/extractions/<ses_*.json>`) against `time_updated` + part
54
- count, re-extracts only changed sessions, prunes vanished ones, and atomically
55
- 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
56
59
  (`--cwd`/toggle) and `--limit` apply at assembly, so metadata always covers
57
60
  the whole DB and pruning is always safe.
58
61
  - render (cheap, pure `jq`): reads the snapshot only. Filters on `searchText`
@@ -78,7 +81,7 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
78
81
  - **Text parts only** in search/preview; reasoning and tool payloads are
79
82
  excluded (asserted in tests).
80
83
  - Validate session ids (`^ses_[A-Za-z0-9]+$`) before SQL interpolation — never
81
- interpolate unvalidated input.
84
+ interpolate unvalidated input. Open SQLite read-only (`sqlite3 -readonly`).
82
85
  - Prefer direct `sqlite3` over `opencode db` (startup latency). Never parse
83
86
  `sqlite3 -json` in `-R` mode; it pretty-prints — parse as JSON.
84
87
  - `jq -R` output stays JSON-quoted: use `-Rr` when a shell variable needs the
@@ -105,6 +108,11 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
105
108
  `api.route.navigate("session", …)`. `ctrl+o` and the command palette open it;
106
109
  Ctrl-X deletes via the opencode CLI and removes the row only after the server
107
110
  confirms the deletion.
111
+ - **Picker search:** pages the global session list (`SESSION_PAGE_LIMIT`, capped
112
+ at `SESSION_MAX` and reported when truncated) instead of a fixed 500-row
113
+ window, then indexes transcript text for **every** session in batches
114
+ (`TRANSCRIPT_BATCH`) with bounded remote concurrency (`REMOTE_CONCURRENCY`).
115
+ The header shows indexing progress/coverage. `tests/tui.mjs` locks this.
108
116
 
109
117
  ### Hard-won rules — do not regress
110
118
 
package/CHANGELOG.md CHANGED
@@ -7,6 +7,51 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.1.6] - 2026-09-13
11
+
12
+ ### Changed
13
+
14
+ - The TUI picker pages through the entire global session list instead of a
15
+ fixed 500-session window, indexes transcript text for every session (not just
16
+ the newest 150) in batches, bounds remote transcript fetching, and shows
17
+ indexing progress/coverage in the search header.
18
+ - Transcript extraction reads parts in batches and assembles all records in a
19
+ single `jq` pass. A 250-session cold index dropped from about 28 s to about
20
+ 7 s locally (warm ~0.3 s).
21
+
22
+ ### Added
23
+
24
+ - TUI data-layer contract tests (pagination, full index coverage, bounded
25
+ concurrency, progress, and the truncated cap), run on Node 24 in CI.
26
+
27
+ ## [0.1.5] - 2026-09-13
28
+
29
+ ### Fixed
30
+
31
+ - Transcript indexing no longer puts the whole conversation on the command line,
32
+ so large sessions (over a megabyte of text) are indexed instead of silently
33
+ dropped. A failed extraction now reports the snapshot as `stale` and retries
34
+ on the next refresh rather than certifying an incomplete index as fresh.
35
+ - Transcript preview renders text parts even when newer tool parts outnumber
36
+ them, and a long message no longer blanks the preview when the output pipe
37
+ closes early.
38
+ - Warm extraction caches are invalidated on any part change (text or timestamp),
39
+ not only when the session timestamp or part count moves.
40
+ - The installer refuses to replace a symlinked `sesh` launcher that is not a
41
+ sesh install, and uninstall leaves foreign launcher symlinks untouched.
42
+
43
+ ### Security
44
+
45
+ - Session ids are validated with the anchored `^ses_[A-Za-z0-9]+$` pattern at
46
+ the preview and deletion boundaries before any SQL, and the local SQLite store
47
+ is opened read-only.
48
+
49
+ ### Added
50
+
51
+ - Regression coverage for large transcripts, part-only cache invalidation,
52
+ preview text/tool ordering and long previews, malformed session ids, empty
53
+ stores, and installer launcher ownership.
54
+
10
55
  ## [0.1.4] - 2026-09-13
11
56
 
12
57
  ### Fixed
@@ -74,7 +119,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
74
119
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
75
120
  CI.
76
121
 
77
- [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.4...HEAD
122
+ [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.6...HEAD
123
+ [0.1.6]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.5...v0.1.6
124
+ [0.1.5]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.4...v0.1.5
78
125
  [0.1.4]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.3...v0.1.4
79
126
  [0.1.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.2...v0.1.3
80
127
  [0.1.2]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.1...v0.1.2
package/README.md CHANGED
@@ -137,6 +137,11 @@ read -r id cwd < <(sesh --print --query "auth")
137
137
  Click the sidebar's `search…` box to filter recent sessions in place; click
138
138
  elsewhere or press Esc to leave search.
139
139
 
140
+ The full picker pages through your whole global session list (no fixed window)
141
+ and indexes transcript text for every session in the background, showing
142
+ indexing progress under the search box. It only stops at a safety cap of 5,000
143
+ sessions, and says so when it does.
144
+
140
145
  ## Configuration
141
146
 
142
147
  All variables are optional.
@@ -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
@@ -95,7 +95,9 @@ fi
95
95
  # would make every preview keystroke laggy.
96
96
  db_json() {
97
97
  if [ -n "$SQLITE_BIN" ]; then
98
- "$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"
99
101
  else
100
102
  "$OPENCODE_BIN" db "$1" --format json
101
103
  fi
@@ -243,11 +245,11 @@ refresh() {
243
245
  | ($parts[0] | map({key:.session_id, value:.}) | from_entries) as $stats
244
246
  | [$sessions[0][] | . + {partCount: ($stats[.id].n // 0), partMax: ($stats[.id].m // 0)}] as $meta
245
247
  | ($meta | map({key:.id, value:.}) | from_entries) as $byId
246
- | [$caches[0][]? | select(.cacheSchema == 1 and (.sessionId | type) == "string")
247
- | select($byId[.sessionId] as $m | $m != null and .cacheUpdated == $m.time_updated and .cacheParts == $m.partCount)] as $cached
248
- | ($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
249
251
  | {cached: $cached,
250
- 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}],
251
253
  prune: [$caches[0][]? | select(.sessionId? as $s | $byId[$s] == null) | .cachePath],
252
254
  meta: $meta}
253
255
  ' "$cache_records" > "$cache_state" || {
@@ -258,45 +260,77 @@ refresh() {
258
260
  "$JQ_BIN" -c '.stale[]?' "$cache_state" > "$needs_extract"
259
261
  while IFS= read -r cache; do [ -n "$cache" ] && rm -f "$cache"; done < <("$JQ_BIN" -r '.prune[]?' "$cache_state")
260
262
 
261
- local sid dir title created updated archived partCount text fulltext record cache
263
+ local stale_ids=() sid chunk j i
262
264
  while IFS= read -r row; do
263
265
  [ -n "$row" ] || continue
264
266
  sid=$(printf '%s' "$row" | "$JQ_BIN" -r '.id')
265
267
  valid_session_id "$sid" || continue
266
- dir=$(printf '%s' "$row" | "$JQ_BIN" -r '.directory // ""')
267
- title=$(printf '%s' "$row" | "$JQ_BIN" -r '.title // ""')
268
- created=$(printf '%s' "$row" | "$JQ_BIN" -r '.time_created // 0')
269
- updated=$(printf '%s' "$row" | "$JQ_BIN" -r '.time_updated // 0')
270
- partCount=$(printf '%s' "$row" | "$JQ_BIN" -r '.partCount // 0')
271
- cache="$EXTRACTIONS/$sid.json"
272
- # Text parts only: reasoning blobs and tool payloads would bloat the
273
- # search index without helping anyone find a session.
274
- text=$(db_json "SELECT data FROM part WHERE session_id = '$sid' AND json_extract(data, '\$.type') = 'text' ORDER BY time_created;" 2>/dev/null \
275
- | "$JQ_BIN" -r '[.[]? | (.data? // empty) | (try fromjson catch empty) | select(type == "object" and .type == "text") | .text // empty] | join(" ")' 2>/dev/null) || text=''
276
- fulltext=$(printf '%s' "$title $text" | "$JQ_BIN" -Rrs 'ascii_downcase' 2>/dev/null) || fulltext=''
277
- if [ -z "$title" ]; then
278
- 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)'
279
- [ -n "$title" ] || title='(untitled)'
268
+ stale_ids+=("$sid")
269
+ done < "$needs_extract"
270
+
271
+ # Batch the transcript read: one SQL query per chunk of sessions and a single
272
+ # jq pass to assemble every record, instead of a query plus three jq processes
273
+ # per session. Text still travels through files, never argv. Any failure leaves
274
+ # every stale session uncached, so the next refresh retries them.
275
+ local extract_failed=0 parts_raw="$scratch/parts.jsonl" records="$scratch/records.jsonl"
276
+ : > "$parts_raw"
277
+ j=0
278
+ while [ "$j" -lt "${#stale_ids[@]}" ]; do
279
+ chunk=''
280
+ for ((i = j; i < j + 200 && i < ${#stale_ids[@]}; i++)); do
281
+ chunk="${chunk:+$chunk,}'${stale_ids[$i]}'"
282
+ done
283
+ if ! db_json "SELECT session_id, data FROM part WHERE session_id IN ($chunk) AND json_extract(data, '\$.type') = 'text' ORDER BY session_id, time_created;" 2>/dev/null \
284
+ | "$JQ_BIN" -c '.[]?' >> "$parts_raw"; then
285
+ extract_failed=1
286
+ break
280
287
  fi
281
- record=$("$JQ_BIN" -cn --arg sid "$sid" --arg cwd "$dir" --arg title "$title" \
282
- --argjson created "$created" --argjson updated "$updated" --argjson parts "$partCount" --arg fulltext "$fulltext" '
283
- {cacheSchema: 1, cacheSession: $sid, cacheUpdated: $updated, cacheParts: $parts,
284
- sessionId: $sid, transcriptPath: "", source: "opencode",
285
- cwd: $cwd, title: ($title | gsub("[\r\n]+"; " ") | if length > 70 then .[0:67] + "..." else . end),
286
- updatedEpoch: (($updated / 1000 | floor) | if . > 0 then . else ($created / 1000 | floor) end),
287
- fulltextLower: $fulltext}') || record=''
288
- if [ -n "$record" ]; then
288
+ j=$((j + 200))
289
+ done
290
+
291
+ if [ "$extract_failed" = 0 ]; then
292
+ "$JQ_BIN" -cnr --arg extractions "$EXTRACTIONS" --slurpfile stale "$needs_extract" --slurpfile parts "$parts_raw" '
293
+ ($parts
294
+ | map({sid: .session_id,
295
+ text: ((.data | try fromjson catch null)
296
+ | if type == "object" and .type == "text" and (.text | type) == "string" then .text else empty end)})
297
+ | group_by(.sid)
298
+ | map({key: .[0].sid, value: ([.[].text] | join(" "))})
299
+ | from_entries) as $bySid
300
+ | $stale[]
301
+ | select((.id | type) == "string" and (.id | test("^ses_[A-Za-z0-9]+$")))
302
+ | . as $s
303
+ | ($bySid[$s.id] // "") as $text
304
+ | (if ($s.title // "") == ""
305
+ then (($text | gsub("[\r\n]+"; " ") | if length > 70 then .[0:67] + "..." else . end) | select(length > 0) // "(untitled)")
306
+ else $s.title end) as $title
307
+ | {sessionId: $s.id,
308
+ cachePath: ($extractions + "/" + $s.id + ".json"),
309
+ record: {cacheSchema: 2, cacheSession: $s.id,
310
+ cacheUpdated: ($s.time_updated // 0), cacheParts: ($s.partCount // 0), cacheMax: ($s.partMax // 0),
311
+ sessionId: $s.id, transcriptPath: "", source: "opencode",
312
+ cwd: ($s.directory // ""),
313
+ title: ($title | gsub("[\r\n]+"; " ") | if length > 70 then .[0:67] + "..." else . end),
314
+ updatedEpoch: ((($s.time_updated // 0) / 1000 | floor) | if . > 0 then . else ((($s.time_created // 0) / 1000) | floor) end),
315
+ fulltextLower: (($title + " " + $text) | ascii_downcase)}}
316
+ | "\(.sessionId)\n\(.cachePath)\n\(.record | tojson)"' > "$records" || extract_failed=1
317
+ fi
318
+
319
+ if [ "$extract_failed" = 0 ]; then
320
+ while IFS= read -r sid && IFS= read -r cache_path && IFS= read -r record; do
321
+ [ -n "$sid" ] || continue
289
322
  cache_tmp=$(mktemp "$EXTRACTIONS/$sid.json.XXXXXX")
290
323
  printf '%s\n' "$record" > "$cache_tmp"
291
- mv "$cache_tmp" "$cache"
324
+ mv "$cache_tmp" "$cache_path"
292
325
  printf '%s\n' "$record" >> "$historical"
293
- fi
294
- done < "$needs_extract"
326
+ done < "$records"
327
+ fi
295
328
 
296
329
  if ! "$JQ_BIN" -c --slurpfile hist "$historical" --arg scope "$SCOPE_CWD" --argjson limit "$LIMIT" --argjson archived "$INCLUDE_ARCHIVED" '
297
330
  .meta as $meta
298
331
  | ($hist | map({key: .sessionId, value: .}) | from_entries) as $histById
299
332
  | [$meta[]
333
+ | select((.id | type) == "string" and (.id | test("^ses_[A-Za-z0-9]+$")))
300
334
  | select($archived == 1 or .archived == 0)
301
335
  | select($scope == "" or .directory == $scope)
302
336
  | . as $m | ($histById[$m.id] // {}) as $h
@@ -313,7 +347,13 @@ refresh() {
313
347
  return 1
314
348
  fi
315
349
  mv -f "$out" "$SNAPSHOT"
316
- set_status fresh || true
350
+ if [ "$extract_failed" = 1 ]; then
351
+ # Publish what we have, but say the index is incomplete and keep the
352
+ # failed sessions uncached so the next refresh retries them.
353
+ set_status 'stale: transcript extraction incomplete; refresh to retry' || true
354
+ else
355
+ set_status fresh || true
356
+ fi
317
357
  }
318
358
 
319
359
  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() {
package/install.sh CHANGED
@@ -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"
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
6
6
  "license": "MIT",
@@ -159,20 +159,47 @@ function ensureRootMouse(renderer: TuiPluginApi["renderer"]) {
159
159
  }
160
160
 
161
161
  type Entry = { id: string; title: string; dir: string; group: string; updated: number }
162
+ type EntryResult = { entries: Entry[]; truncated: boolean }
162
163
 
163
- async function fetchEntries(api: TuiPluginApi): Promise<Entry[]> {
164
+ const SESSION_PAGE_LIMIT = 200
165
+ const SESSION_MAX = 5000
166
+
167
+ async function fetchEntries(api: TuiPluginApi): Promise<EntryResult> {
164
168
  const home = process.env.HOME ?? ""
165
- const [sessionResult, projectResult] = await Promise.all([
166
- api.client.experimental.session.list({ limit: 500, roots: true, directory: "" }),
167
- api.client.project.list({}),
168
- ])
169
- const sessions = ((sessionResult?.data ?? sessionResult) as Session[]) ?? []
169
+ const projectResult = await api.client.project.list({})
170
170
  const projects = ((projectResult?.data ?? projectResult) as Project[]) ?? []
171
- if (!Array.isArray(sessions)) return []
172
171
  const projectName = new Map<string, string>(
173
172
  projects.map((p) => [p.id, p.name?.trim() || shortDir(p.worktree ?? "", home)]),
174
173
  )
175
- return sessions
174
+ const sessions: Session[] = []
175
+ let cursor: number | undefined
176
+ let truncated = false
177
+ // Page through the global list instead of one fixed window, so sessions in
178
+ // older directories stay searchable. `cursor` is the previous page's oldest
179
+ // `time.updated`; a repeated or missing cursor stops the walk.
180
+ for (;;) {
181
+ const result = await api.client.experimental.session.list({
182
+ limit: SESSION_PAGE_LIMIT,
183
+ roots: true,
184
+ directory: "",
185
+ ...(cursor === undefined ? {} : { cursor }),
186
+ })
187
+ const page = ((result?.data ?? result) as Session[]) ?? []
188
+ if (!Array.isArray(page) || page.length === 0) break
189
+ sessions.push(...page)
190
+ if (page.length < SESSION_PAGE_LIMIT) break
191
+ if (sessions.length >= SESSION_MAX) {
192
+ truncated = true
193
+ break
194
+ }
195
+ const next = page[page.length - 1]?.time?.updated
196
+ if (typeof next !== "number" || next === cursor) {
197
+ truncated = true
198
+ break
199
+ }
200
+ cursor = next
201
+ }
202
+ const entries = sessions
176
203
  .filter((s) => !s.time?.archived && !s.parentID)
177
204
  .sort((a, b) => b.time.updated - a.time.updated)
178
205
  .map((s) => {
@@ -187,76 +214,118 @@ async function fetchEntries(api: TuiPluginApi): Promise<Entry[]> {
187
214
  updated: s.time.updated,
188
215
  }
189
216
  })
217
+ return { entries, truncated }
190
218
  }
191
219
 
192
- const TRANSCRIPT_INDEX_LIMIT = 150
193
220
  const SESSION_ID_PATTERN = /^ses_[A-Za-z0-9]+$/
221
+ const TRANSCRIPT_BATCH = 64
222
+ const REMOTE_CONCURRENCY = 8
194
223
 
195
- async function buildSearchIndex(api: TuiPluginApi, entries: Entry[]): Promise<Map<string, string>> {
196
- const index = new Map<string, string>()
197
- const targets = entries.slice(0, TRANSCRIPT_INDEX_LIMIT)
198
- for (const entry of targets) {
199
- index.set(entry.id, `${entry.title.toLowerCase()} ${entry.dir.toLowerCase()}`)
200
- }
201
- const ids = targets.map((entry) => entry.id).filter((id) => SESSION_ID_PATTERN.test(id))
202
- if (ids.length === 0) return index
224
+ type IndexProgress = { indexed: number; total: number; complete: boolean }
225
+
226
+ const nextTick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
227
+
228
+ async function openTranscriptDb(): Promise<any | undefined> {
203
229
  try {
204
230
  const home = process.env.HOME ?? ""
205
231
  const dataHome = process.env.XDG_DATA_HOME ?? `${home}/.local/share`
206
232
  // @ts-ignore - bun:sqlite ships with the Bun runtime (verified); no type package installed
207
233
  const sqlite: any = await import("bun:sqlite")
208
- if (typeof sqlite?.Database !== "function") throw new Error("bun:sqlite unavailable")
209
- const db = new sqlite.Database(`${dataHome}/opencode/opencode.db`, { readonly: true })
234
+ if (typeof sqlite?.Database !== "function") return undefined
235
+ return new sqlite.Database(`${dataHome}/opencode/opencode.db`, { readonly: true })
236
+ } catch {
237
+ return undefined
238
+ }
239
+ }
240
+
241
+ function addTranscript(index: Map<string, string>, sid: string, data: string): void {
242
+ try {
243
+ const part = JSON.parse(data) as { type?: unknown; text?: unknown }
244
+ if (part?.type !== "text" || typeof part.text !== "string") return
245
+ const base = index.get(sid) ?? ""
246
+ index.set(sid, `${base} ${part.text.toLowerCase()}`.trim())
247
+ } catch {
248
+ // ignore malformed parts
249
+ }
250
+ }
251
+
252
+ // Index titles/directories immediately, then add transcript text progressively
253
+ // in batches so the render thread can breathe. Covers every entry, not a fixed
254
+ // newest window; `onProgress` lets the picker show coverage.
255
+ async function buildSearchIndex(
256
+ api: TuiPluginApi,
257
+ entries: Entry[],
258
+ onProgress?: (progress: IndexProgress) => void,
259
+ ): Promise<Map<string, string>> {
260
+ const index = new Map<string, string>()
261
+ for (const entry of entries) {
262
+ index.set(entry.id, `${entry.title.toLowerCase()} ${entry.dir.toLowerCase()}`)
263
+ }
264
+ const ids = entries.map((entry) => entry.id).filter((id) => SESSION_ID_PATTERN.test(id))
265
+ const remaining = new Set(ids)
266
+ const total = ids.length
267
+ let indexed = 0
268
+ const report = (complete: boolean) => onProgress?.({ indexed, total, complete })
269
+ report(false)
270
+
271
+ const db = await openTranscriptDb()
272
+ if (db) {
210
273
  try {
211
- const placeholders = ids.map(() => "?").join(",")
212
- const rows = (db
213
- .query(
214
- `SELECT session_id AS sid, data FROM part WHERE session_id IN (${placeholders}) AND json_extract(data, '$.type') = 'text' ORDER BY time_created`,
215
- )
216
- .all(...ids) ?? []) as { sid: string; data: string }[]
217
- const byId = new Map<string, string[]>()
218
- for (const row of rows) {
219
- try {
220
- const part = JSON.parse(row.data) as { type?: unknown; text?: unknown }
221
- if (part?.type === "text" && typeof part.text === "string") {
222
- const list = byId.get(row.sid) ?? []
223
- list.push(part.text)
224
- byId.set(row.sid, list)
225
- }
226
- } catch {}
227
- }
228
- for (const entry of targets) {
229
- const text = (byId.get(entry.id) ?? []).join(" ").toLowerCase()
230
- if (text) index.set(entry.id, `${index.get(entry.id)} ${text}`)
274
+ for (let i = 0; i < ids.length; i += TRANSCRIPT_BATCH) {
275
+ const batch = ids.slice(i, i + TRANSCRIPT_BATCH)
276
+ const placeholders = batch.map(() => "?").join(",")
277
+ const rows = (db
278
+ .query(
279
+ `SELECT session_id AS sid, data FROM part WHERE session_id IN (${placeholders}) AND json_extract(data, '$.type') = 'text' ORDER BY time_created`,
280
+ )
281
+ .all(...batch) ?? []) as { sid: string; data: string }[]
282
+ for (const row of rows) addTranscript(index, row.sid, row.data)
283
+ for (const id of batch) remaining.delete(id)
284
+ indexed += batch.length
285
+ report(false)
286
+ await nextTick()
231
287
  }
288
+ } catch {
289
+ // local store unavailable mid-walk; fall back to the remote path below
232
290
  } finally {
233
291
  db.close()
234
292
  }
235
- } catch {
236
- await buildSearchIndexRemote(api, targets, index)
237
293
  }
294
+
295
+ if (remaining.size > 0) {
296
+ await buildSearchIndexRemote(api, [...remaining], index, () => {
297
+ indexed += 1
298
+ report(false)
299
+ })
300
+ }
301
+ report(true)
238
302
  return index
239
303
  }
240
304
 
241
305
  async function buildSearchIndexRemote(
242
306
  api: TuiPluginApi,
243
- targets: Entry[],
307
+ ids: string[],
244
308
  index: Map<string, string>,
309
+ onEach: () => void,
245
310
  ): Promise<void> {
246
311
  const home = process.env.HOME ?? ""
247
312
  const cacheDir = process.env.SESH_CACHE_DIR ?? `${home}/.cache/sesh`
248
- await Promise.all(
249
- targets.map(async (entry) => {
250
- const base = index.get(entry.id) ?? ""
251
- if (!SESSION_ID_PATTERN.test(entry.id)) return
313
+ let cursor = 0
314
+ // Bounded concurrency: a fixed worker pool instead of one request per session.
315
+ const worker = async () => {
316
+ for (;;) {
317
+ const i = cursor++
318
+ if (i >= ids.length) return
319
+ const id = ids[i]
320
+ const base = index.get(id) ?? ""
252
321
  try {
253
- const raw = await readFile(`${cacheDir}/extractions/${entry.id}.json`, "utf8")
322
+ const raw = await readFile(`${cacheDir}/extractions/${id}.json`, "utf8")
254
323
  const record = JSON.parse(raw) as { fulltextLower?: unknown }
255
324
  const fulltext = typeof record?.fulltextLower === "string" ? record.fulltextLower : ""
256
- index.set(entry.id, `${base} ${fulltext}`)
325
+ index.set(id, `${base} ${fulltext}`.trim())
257
326
  } catch {
258
327
  try {
259
- const result = await api.client.session.messages({ sessionID: entry.id })
328
+ const result = await api.client.session.messages({ sessionID: id })
260
329
  const messages = result.data ?? []
261
330
  const text = messages
262
331
  .flatMap((message) =>
@@ -266,11 +335,13 @@ async function buildSearchIndexRemote(
266
335
  )
267
336
  .join(" ")
268
337
  .toLowerCase()
269
- index.set(entry.id, `${base} ${text}`)
338
+ index.set(id, `${base} ${text}`.trim())
270
339
  } catch {}
271
340
  }
272
- }),
273
- )
341
+ onEach()
342
+ }
343
+ }
344
+ await Promise.all(Array.from({ length: Math.min(REMOTE_CONCURRENCY, ids.length) }, worker))
274
345
  }
275
346
 
276
347
  const TRANSCRIPT_PREVIEW_TURNS = 8
@@ -397,7 +468,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
397
468
  let alive = true
398
469
  const load = async () => {
399
470
  try {
400
- const all = await fetchEntries(props.api)
471
+ const { entries: all } = await fetchEntries(props.api)
401
472
  if (alive) setEntries(all)
402
473
  } catch {
403
474
  // leave the previous list in place rather than flashing to blank
@@ -469,7 +540,7 @@ function SidebarSessions(props: { api: TuiPluginApi }) {
469
540
  directory: entry.dir || undefined,
470
541
  })
471
542
  if (result && "error" in result && result.error) throw result.error
472
- const remaining = await fetchEntries(props.api)
543
+ const { entries: remaining } = await fetchEntries(props.api)
473
544
  if (remaining.some((item) => item.id === entry.id)) {
474
545
  setEntries(remaining)
475
546
  props.api.ui.toast({ message: "Session was not deleted", variant: "error" })
@@ -723,7 +794,7 @@ function HomeSessions(props: { api: TuiPluginApi }) {
723
794
  let alive = true
724
795
  const load = async () => {
725
796
  try {
726
- const all = await fetchEntries(props.api)
797
+ const { entries: all } = await fetchEntries(props.api)
727
798
  if (alive) setEntries(all)
728
799
  } catch {}
729
800
  }
@@ -827,13 +898,14 @@ const tui: TuiPlugin = async (api) => {
827
898
  if (api.mode.current() !== BASE_MODE) return
828
899
  if (api.renderer.currentFocusedEditor === null) return
829
900
 
830
- let entries: Entry[]
901
+ let entriesResult: EntryResult
831
902
  try {
832
- entries = await fetchEntries(api)
903
+ entriesResult = await fetchEntries(api)
833
904
  } catch {
834
905
  api.ui.toast({ message: "Could not load sessions", variant: "error" })
835
906
  return
836
907
  }
908
+ const entries = entriesResult.entries
837
909
  if (entries.length === 0) {
838
910
  api.ui.toast({ message: "No sessions found", variant: "warning" })
839
911
  return
@@ -844,7 +916,12 @@ const tui: TuiPlugin = async (api) => {
844
916
  const [query, setQuery] = createSignal("")
845
917
  const [cursor, setCursor] = createSignal(0)
846
918
  const [searchIndex, setSearchIndex] = createSignal<Map<string, string>>(new Map())
847
- void buildSearchIndex(api, entries).then(setSearchIndex)
919
+ const [indexProgress, setIndexProgress] = createSignal<IndexProgress>({
920
+ indexed: 0,
921
+ total: 0,
922
+ complete: false,
923
+ })
924
+ void buildSearchIndex(api, entries, setIndexProgress).then(setSearchIndex)
848
925
  let pickerInput: { value: string; isDestroyed?: boolean } | undefined
849
926
 
850
927
  type DialogRow = { kind: "group"; label: string; count: number } | { kind: "item"; entry: Entry }
@@ -868,6 +945,22 @@ const tui: TuiPlugin = async (api) => {
868
945
 
869
946
  const selectableEntries = createMemo(() => matchedGroups().flatMap((g) => g.list))
870
947
 
948
+ // Visible bounding: say how much transcript text search actually covers and
949
+ // when the metadata walk hit its cap, instead of silently under-reporting.
950
+ const coverageLabel = createMemo(() => {
951
+ const progress = indexProgress()
952
+ const bits: string[] = []
953
+ if (progress.total > 0) {
954
+ bits.push(
955
+ progress.complete
956
+ ? `transcripts ${progress.total}`
957
+ : `indexing transcripts ${progress.indexed}/${progress.total}`,
958
+ )
959
+ }
960
+ if (entriesResult.truncated) bits.push(`showing newest ${entries.length}`)
961
+ return bits.join(" · ")
962
+ })
963
+
871
964
  createEffect(() => {
872
965
  const count = selectableEntries().length
873
966
  if (cursor() >= count) setCursor(Math.max(0, count - 1))
@@ -1041,6 +1134,11 @@ const tui: TuiPlugin = async (api) => {
1041
1134
  }}
1042
1135
  />
1043
1136
  </box>
1137
+ <Show when={coverageLabel()}>
1138
+ <box paddingTop={1}>
1139
+ <text style={{ fg: api.theme.current.textMuted }}>{coverageLabel()}</text>
1140
+ </box>
1141
+ </Show>
1044
1142
  </box>
1045
1143
  <box
1046
1144
  flexDirection="column"