opencode-sesh 0.1.4 → 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
@@ -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
package/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ 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
+
10
38
  ## [0.1.4] - 2026-09-13
11
39
 
12
40
  ### Fixed
@@ -74,7 +102,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
74
102
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
75
103
  CI.
76
104
 
77
- [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.4...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
78
107
  [0.1.4]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.3...v0.1.4
79
108
  [0.1.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.2...v0.1.3
80
109
  [0.1.2]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.1...v0.1.2
@@ -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,7 +260,8 @@ 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 sid dir title created updated partCount partMax raw_file text_file record cache
264
+ local extract_failed=0
262
265
  while IFS= read -r row; do
263
266
  [ -n "$row" ] || continue
264
267
  sid=$(printf '%s' "$row" | "$JQ_BIN" -r '.id')
@@ -268,28 +271,40 @@ refresh() {
268
271
  created=$(printf '%s' "$row" | "$JQ_BIN" -r '.time_created // 0')
269
272
  updated=$(printf '%s' "$row" | "$JQ_BIN" -r '.time_updated // 0')
270
273
  partCount=$(printf '%s' "$row" | "$JQ_BIN" -r '.partCount // 0')
274
+ partMax=$(printf '%s' "$row" | "$JQ_BIN" -r '.partMax // 0')
271
275
  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=''
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
277
289
  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)'
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)'
279
291
  [ -n "$title" ] || title='(untitled)'
280
292
  fi
281
293
  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,
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,
284
297
  sessionId: $sid, transcriptPath: "", source: "opencode",
285
298
  cwd: $cwd, title: ($title | gsub("[\r\n]+"; " ") | if length > 70 then .[0:67] + "..." else . end),
286
299
  updatedEpoch: (($updated / 1000 | floor) | if . > 0 then . else ($created / 1000 | floor) end),
287
- fulltextLower: $fulltext}') || record=''
300
+ fulltextLower: (($title + " " + $text) | sub("\n$"; "") | ascii_downcase)}') || record=''
288
301
  if [ -n "$record" ]; then
289
302
  cache_tmp=$(mktemp "$EXTRACTIONS/$sid.json.XXXXXX")
290
303
  printf '%s\n' "$record" > "$cache_tmp"
291
304
  mv "$cache_tmp" "$cache"
292
305
  printf '%s\n' "$record" >> "$historical"
306
+ else
307
+ extract_failed=1
293
308
  fi
294
309
  done < "$needs_extract"
295
310
 
@@ -297,6 +312,7 @@ refresh() {
297
312
  .meta as $meta
298
313
  | ($hist | map({key: .sessionId, value: .}) | from_entries) as $histById
299
314
  | [$meta[]
315
+ | select((.id | type) == "string" and (.id | test("^ses_[A-Za-z0-9]+$")))
300
316
  | select($archived == 1 or .archived == 0)
301
317
  | select($scope == "" or .directory == $scope)
302
318
  | . as $m | ($histById[$m.id] // {}) as $h
@@ -313,7 +329,13 @@ refresh() {
313
329
  return 1
314
330
  fi
315
331
  mv -f "$out" "$SNAPSHOT"
316
- 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
317
339
  }
318
340
 
319
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() {
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.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",