opencode-sesh 0.1.3

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.
@@ -0,0 +1,294 @@
1
+ #!/usr/bin/env bash
2
+ # Build and render a picker-local opencode session snapshot.
3
+ # Source of truth is the opencode SQLite database: no transcript files to
4
+ # walk, no live-agent poller to consult. Only --refresh publishes
5
+ # snapshot.jsonl; readers only render it.
6
+ # TSV: agentId, sessionId, liveState, display, cwd, trackingId.
7
+ # Every row, including headers/notices, requires a unique nonempty trackingId.
8
+ set -euo pipefail
9
+
10
+ LIMIT=0
11
+ LIMIT_SET=0
12
+ SCOPE_SET=0
13
+ REFRESH=0
14
+ WAIT_LOCK=0
15
+ TOGGLE_SCOPE=0
16
+ INCLUDE_ARCHIVED=0
17
+ ARCHIVED_SET=0
18
+ STATE_DIR=''
19
+ QUERY=''
20
+ SCOPE_CWD=''
21
+ usage() { echo "usage: ${0##*/} [--refresh] [--wait-lock SECONDS] --state-dir DIR [--cwd] [--limit N] [--archived] [--toggle-scope] [--query TEXT]" >&2; }
22
+ while [ "$#" -gt 0 ]; do
23
+ case "$1" in
24
+ --refresh) REFRESH=1 ;;
25
+ --wait-lock) WAIT_LOCK=${2:-}; shift ;;
26
+ --state-dir) STATE_DIR=${2:-}; shift ;;
27
+ --cwd) SCOPE_CWD=$PWD; SCOPE_SET=1 ;;
28
+ --limit) LIMIT=${2:-}; LIMIT_SET=1; shift ;;
29
+ --archived) INCLUDE_ARCHIVED=1; ARCHIVED_SET=1 ;;
30
+ --toggle-scope) TOGGLE_SCOPE=1 ;;
31
+ --query) QUERY=${2:-}; shift ;;
32
+ --help) usage; exit 0 ;;
33
+ *) echo "unknown option: $1" >&2; usage; exit 2 ;;
34
+ esac
35
+ shift
36
+ done
37
+ case "$LIMIT" in ''|*[!0-9]*) echo "--limit must be a non-negative integer" >&2; exit 2;; esac
38
+ case "$WAIT_LOCK" in ''|*[!0-9]*) echo "--wait-lock must be a non-negative integer" >&2; exit 2;; esac
39
+ [ -n "$STATE_DIR" ] || { usage; exit 2; }
40
+
41
+ SNAPSHOT="$STATE_DIR/snapshot.jsonl"
42
+ STATUS="$STATE_DIR/status"
43
+ LOCK="$STATE_DIR/refresh.lock"
44
+ SETTINGS="$STATE_DIR/settings.json"
45
+ JQ_BIN=${SESH_JQ:-jq}
46
+ OPENCODE_BIN=${SESH_OPENCODE:-opencode}
47
+ CACHE_DIR=${SESH_CACHE_DIR:-"${XDG_CACHE_HOME:-"$HOME/.cache"}/sesh"}
48
+ EXTRACTIONS="$CACHE_DIR/extractions"
49
+
50
+ # The picker shows ALL sessions across every directory by default; --cwd (or
51
+ # the Ctrl-G toggle) narrows to one. Scope and limit apply at assembly time,
52
+ # so metadata always covers the whole database and cache pruning is safe.
53
+ SCOPE_CWD_SETTING=''
54
+ if [ -f "$SETTINGS" ]; then
55
+ [ "$LIMIT_SET" = 1 ] || LIMIT=$("$JQ_BIN" -r '.limit' "$SETTINGS")
56
+ [ "$SCOPE_SET" = 1 ] || SCOPE_CWD=$("$JQ_BIN" -r '.scope' "$SETTINGS")
57
+ [ "$ARCHIVED_SET" = 1 ] || INCLUDE_ARCHIVED=$("$JQ_BIN" -r '.archived // 0' "$SETTINGS" 2>/dev/null || printf '0')
58
+ SCOPE_CWD_SETTING=$("$JQ_BIN" -r '.cwd // ""' "$SETTINGS" 2>/dev/null || true)
59
+ fi
60
+ [ "$SCOPE_SET" = 1 ] || [ -n "$SCOPE_CWD_SETTING" ] || SCOPE_CWD_SETTING=$PWD
61
+ # An explicit --cwd both scopes and remembers the directory, so a later
62
+ # scope toggle can return to it. A toggle back to global leaves it intact.
63
+ if [ "$SCOPE_SET" = 1 ] && [ -n "$SCOPE_CWD" ] && [ "$TOGGLE_SCOPE" = 0 ]; then SCOPE_CWD_SETTING=$SCOPE_CWD; fi
64
+
65
+ if [ "$TOGGLE_SCOPE" = 1 ]; then
66
+ if [ -n "$SCOPE_CWD" ]; then SCOPE_CWD=''; else SCOPE_CWD=$SCOPE_CWD_SETTING; fi
67
+ SCOPE_SET=1
68
+ fi
69
+
70
+ GREEN=$'\033[32m'; CYAN=$'\033[36m'; GRAY=$'\033[90m'; MAGENTA=$'\033[35m'; RED=$'\033[31m'; BOLD=$'\033[1m'; YELLOW=$'\033[33m'; RESET=$'\033[0m'
71
+
72
+ resolve_db() {
73
+ if [ -n "${SESH_DB:-}" ]; then printf '%s\n' "$SESH_DB"; return 0; fi
74
+ local from_cli=''
75
+ if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
76
+ from_cli=$("$OPENCODE_BIN" db path 2>/dev/null || true)
77
+ fi
78
+ if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
79
+ if [ -f "$HOME/.local/share/opencode/opencode.db" ]; then printf '%s\n' "$HOME/.local/share/opencode/opencode.db"; return 0; fi
80
+ return 1
81
+ }
82
+
83
+ SQLITE_BIN=''
84
+ if [ -n "${SESH_SQLITE:-}" ]; then
85
+ SQLITE_BIN=$SESH_SQLITE
86
+ elif command -v sqlite3 >/dev/null 2>&1; then
87
+ SQLITE_BIN=sqlite3
88
+ fi
89
+
90
+ # db_json SQL → JSON array on stdout. Direct sqlite3 is preferred: it answers
91
+ # in milliseconds, while `opencode db` pays a full CLI startup per call and
92
+ # would make every preview keystroke laggy.
93
+ db_json() {
94
+ if [ -n "$SQLITE_BIN" ]; then
95
+ "$SQLITE_BIN" -json "$DB_PATH" "$1"
96
+ else
97
+ "$OPENCODE_BIN" db "$1" --format json
98
+ fi
99
+ }
100
+
101
+ valid_session_id() { [[ "$1" =~ ^ses_[A-Za-z0-9]+$ ]]; }
102
+
103
+ atomic_text() {
104
+ local dest=$1 contents=$2 tmp
105
+ tmp=$(mktemp "$STATE_DIR/.tmp.XXXXXX") || return 1
106
+ printf '%s\n' "$contents" > "$tmp"
107
+ mv -f "$tmp" "$dest"
108
+ }
109
+ set_status() { atomic_text "$STATUS" "$1"; }
110
+
111
+ render() {
112
+ local state=''
113
+ [ -f "$STATUS" ] && state=$(cat "$STATUS" 2>/dev/null || true)
114
+ if [ ! -s "$SNAPSHOT" ]; then
115
+ [ -n "$state" ] && [ "$state" != fresh ] && printf '\t\tunknown\t%s%s%s\t\tnotice:live-state\n' "$YELLOW" "$state" "$RESET"
116
+ return 0
117
+ fi
118
+ {
119
+ [ -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" '
121
+ def ago($epoch):
122
+ ((now - ($epoch | tonumber)) | floor) as $s
123
+ | 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
+ def homepath:
125
+ . as $p | if $p == "" then "?" elif $p == $home then "~" elif ($home != "" and startswith($home + "/")) then "~" + .[($home|length):] else . end;
126
+ (($query | ascii_downcase) as $q
127
+ | map(select($q == "" or (.searchText | contains($q))))
128
+ | group_by(.cwd)
129
+ | map({cwd: .[0].cwd, updated: (map(.updatedEpoch) | max), items: (sort_by(.updatedEpoch) | reverse)})
130
+ | sort_by(.updated) | reverse
131
+ | .[] as $g
132
+ | (["", "", "unknown", ($magenta + ($g.cwd | homepath | gsub("[\u0000-\u001f\u007f-\u009f]";" ")) + $reset), $g.cwd, ("hdr:" + $g.cwd)] | @tsv),
133
+ ($g.items | to_entries[] | .key as $i | .value as $s
134
+ | (if $s.liveState == "running" then ($bold + $green) elif $s.agentId != "" and $s.agentState == "failed" then $red elif $s.agentId != "" then $cyan else $gray end) as $c
135
+ | ($gray + (if $i == (($g.items|length)-1) then "└" else "├" end) + $reset) as $branch
136
+ | [$s.agentId, $s.sessionId, $s.liveState, ($branch + " " + $c + ($s.title | gsub("[\u0000-\u001f\u007f-\u009f]";" ")) + $reset + " " + $gray + ago($s.updatedEpoch) + $reset), $s.cwd, $s.sessionId] | @tsv))
137
+ ' "$SNAPSHOT"
138
+ } || printf '\t\tunknown\t%sSnapshot rendering failed; retry refresh%s\t\tnotice:render-error\n' "$YELLOW" "$RESET"
139
+ }
140
+
141
+ release_lock() { rm -f "$LOCK/pid" 2>/dev/null || true; rmdir "$LOCK" 2>/dev/null || true; }
142
+ acquire_lock() {
143
+ if mkdir "$LOCK" 2>/dev/null; then printf '%s\n' "$$" > "$LOCK/pid"; return 0; fi
144
+ local owner=''; [ -f "$LOCK/pid" ] && owner=$(cat "$LOCK/pid" 2>/dev/null || true)
145
+ case "$owner" in ''|*[!0-9]*) return 1;; esac
146
+ kill -0 "$owner" 2>/dev/null && return 1
147
+ rm -f "$LOCK/pid" 2>/dev/null || return 1
148
+ rmdir "$LOCK" 2>/dev/null || return 1
149
+ mkdir "$LOCK" 2>/dev/null || return 1
150
+ printf '%s\n' "$$" > "$LOCK/pid"
151
+ }
152
+
153
+ acquire_lock_wait() {
154
+ local remaining=$WAIT_LOCK
155
+ while ! acquire_lock; do
156
+ [ "$remaining" -gt 0 ] || return 75
157
+ sleep 1
158
+ remaining=$((remaining - 1))
159
+ done
160
+ }
161
+
162
+ refresh() {
163
+ mkdir -p "$STATE_DIR"
164
+ if [ "${SESH_LOCK_HELD:-}" != "$STATE_DIR" ]; then
165
+ if [ "$WAIT_LOCK" -gt 0 ]; then acquire_lock_wait || return $?; else acquire_lock || return 0; fi
166
+ fi
167
+ # Persist the effective scope (after any toggle) so renders and workers agree.
168
+ if [ "$SCOPE_SET" = 1 ] || [ ! -f "$SETTINGS" ]; then
169
+ atomic_text "$SETTINGS" "$("$JQ_BIN" -cn --arg scope "$SCOPE_CWD" --argjson limit "$LIMIT" --arg cwd "$SCOPE_CWD_SETTING" --argjson archived "$INCLUDE_ARCHIVED" '{scope:$scope,limit:$limit,cwd:$cwd,archived:$archived}')"
170
+ fi
171
+ DB_PATH=$(resolve_db) || {
172
+ if [ -f "$SNAPSHOT" ]; then set_status 'stale: opencode database is unavailable; showing last good snapshot'; else set_status 'unavailable: opencode database is unavailable'; fi
173
+ return 0
174
+ }
175
+ [ -f "$DB_PATH" ] || {
176
+ if [ -f "$SNAPSHOT" ]; then set_status 'stale: opencode database is unavailable; showing last good snapshot'; else set_status 'unavailable: opencode database is unavailable'; fi
177
+ return 0
178
+ }
179
+ if [ -z "$SQLITE_BIN" ] && ! command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
180
+ 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
+ return 0
182
+ fi
183
+ mkdir -p "$EXTRACTIONS"
184
+ local scratch sessions parts cache_records cache_state historical needs_extract out
185
+ scratch=$(mktemp -d "$STATE_DIR/.refresh.XXXXXX")
186
+ refresh_scratch=$scratch
187
+ if [ "${SESH_LOCK_HELD:-}" = "$STATE_DIR" ]; then
188
+ trap 'rm -rf "$refresh_scratch"' EXIT
189
+ else
190
+ trap 'rm -rf "$refresh_scratch"; release_lock' EXIT
191
+ fi
192
+ trap 'exit 130' HUP INT TERM
193
+ sessions="$scratch/sessions.json"; parts="$scratch/parts.json"
194
+ cache_records="$scratch/caches.json"; cache_state="$scratch/cache-state.json"
195
+ historical="$scratch/historical.jsonl"; needs_extract="$scratch/needs-extract.json"
196
+ out="$scratch/snapshot.jsonl"
197
+
198
+ db_json "SELECT id, directory, title, time_created, time_updated, COALESCE(time_archived, 0) AS archived FROM session ORDER BY time_updated DESC;" > "$sessions" 2>/dev/null || {
199
+ 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
+ return 0
201
+ }
202
+ "$JQ_BIN" -e 'type == "array"' "$sessions" >/dev/null 2>&1 || {
203
+ 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
+ return 0
205
+ }
206
+ db_json "SELECT session_id, COUNT(*) AS n, MAX(time_updated) AS m FROM part GROUP BY session_id;" > "$parts" 2>/dev/null || printf '[]\n' > "$parts"
207
+ "$JQ_BIN" -e 'type == "array"' "$parts" >/dev/null 2>&1 || printf '[]\n' > "$parts"
208
+
209
+ # Same bulk cache validation as the Claude list: every cache arrives as its
210
+ # own file argument (ARG_MAX), records match on session id plus the two
211
+ # change signals, and only vanished sessions are pruned — metadata always
212
+ # covers the whole database, so pruning is safe under any scope or limit.
213
+ find "$EXTRACTIONS" -maxdepth 1 -type f -name '*.json' -print0 2>/dev/null \
214
+ | xargs -0 "$JQ_BIN" -c '. + {cachePath: input_filename}' 2>/dev/null \
215
+ | "$JQ_BIN" -s '.' > "$cache_records" || printf '[]\n' > "$cache_records"
216
+ "$JQ_BIN" -s --slurpfile sessions "$sessions" --slurpfile parts "$parts" --slurpfile caches "$cache_records" '
217
+ ($sessions[0]) as $all
218
+ | ($parts[0] | map({key:.session_id, value:.}) | from_entries) as $stats
219
+ | [$sessions[0][] | . + {partCount: ($stats[.id].n // 0), partMax: ($stats[.id].m // 0)}] as $meta
220
+ | ($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
224
+ | {cached: $cached,
225
+ stale: [$meta[] | select($cachedIds[.id] != true) | {id, directory, title, time_created, time_updated, archived, partCount}],
226
+ prune: [$caches[0][]? | select(.sessionId? as $s | $byId[$s] == null) | .cachePath],
227
+ meta: $meta}
228
+ ' "$cache_records" > "$cache_state" || {
229
+ set_status 'stale: snapshot construction failed; showing last good snapshot'
230
+ return 1
231
+ }
232
+ "$JQ_BIN" -c '.cached[]? | del(.cachePath)' "$cache_state" > "$historical"
233
+ "$JQ_BIN" -c '.stale[]?' "$cache_state" > "$needs_extract"
234
+ while IFS= read -r cache; do [ -n "$cache" ] && rm -f "$cache"; done < <("$JQ_BIN" -r '.prune[]?' "$cache_state")
235
+
236
+ local sid dir title created updated archived partCount text fulltext record cache
237
+ while IFS= read -r row; do
238
+ [ -n "$row" ] || continue
239
+ sid=$(printf '%s' "$row" | "$JQ_BIN" -r '.id')
240
+ valid_session_id "$sid" || continue
241
+ dir=$(printf '%s' "$row" | "$JQ_BIN" -r '.directory // ""')
242
+ title=$(printf '%s' "$row" | "$JQ_BIN" -r '.title // ""')
243
+ created=$(printf '%s' "$row" | "$JQ_BIN" -r '.time_created // 0')
244
+ updated=$(printf '%s' "$row" | "$JQ_BIN" -r '.time_updated // 0')
245
+ partCount=$(printf '%s' "$row" | "$JQ_BIN" -r '.partCount // 0')
246
+ 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=''
252
+ 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)'
254
+ [ -n "$title" ] || title='(untitled)'
255
+ fi
256
+ 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,
259
+ sessionId: $sid, transcriptPath: "", source: "opencode",
260
+ cwd: $cwd, title: ($title | gsub("[\r\n]+"; " ") | if length > 70 then .[0:67] + "..." else . end),
261
+ updatedEpoch: (($updated / 1000 | floor) | if . > 0 then . else ($created / 1000 | floor) end),
262
+ fulltextLower: $fulltext}') || record=''
263
+ if [ -n "$record" ]; then
264
+ printf '%s\n' "$record" > "$cache.tmp"
265
+ mv "$cache.tmp" "$cache"
266
+ printf '%s\n' "$record" >> "$historical"
267
+ fi
268
+ done < "$needs_extract"
269
+
270
+ if ! "$JQ_BIN" -c --slurpfile hist "$historical" --arg scope "$SCOPE_CWD" --argjson limit "$LIMIT" --argjson archived "$INCLUDE_ARCHIVED" '
271
+ .meta as $meta
272
+ | ($hist | map({key: .sessionId, value: .}) | from_entries) as $histById
273
+ | [$meta[]
274
+ | select($archived == 1 or .archived == 0)
275
+ | select($scope == "" or .directory == $scope)
276
+ | . as $m | ($histById[$m.id] // {}) as $h
277
+ | {sessionId: $m.id, malformedRecords: 0, agentId: "", agentState: "",
278
+ liveState: "unknown", transcriptPath: "", source: "opencode",
279
+ cwd: (if ($h.cwd // "") != "" then $h.cwd else $m.directory end),
280
+ updatedEpoch: ($h.updatedEpoch // (($m.time_updated / 1000 | floor))),
281
+ title: (if ($h.title // "") != "" then $h.title else ($m.title // "(untitled)") end)}
282
+ | .searchText = ((.title | ascii_downcase) + " " + ($histById[.sessionId].fulltextLower // ""))]
283
+ | sort_by(.updatedEpoch) | reverse
284
+ | (if $limit == 0 then . else .[:$limit] end)[]
285
+ ' "$cache_state" > "$out"; then
286
+ set_status 'stale: snapshot construction failed; showing last good snapshot'
287
+ return 1
288
+ fi
289
+ mv -f "$out" "$SNAPSHOT"
290
+ set_status fresh || true
291
+ }
292
+
293
+ if [ "$REFRESH" = 1 ] || [ "$TOGGLE_SCOPE" = 1 ]; then refresh; fi
294
+ render
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env bash
2
+ # Usage: sesh-preview.sh STATE_DIR SESSION_ID
3
+ # Renders an opencode session transcript, newest messages first.
4
+ set -euo pipefail
5
+
6
+ state_dir=${1:-}
7
+ session_id=${2:-}
8
+ snapshot="$state_dir/snapshot.jsonl"
9
+ JQ_BIN=${SESH_JQ:-jq}
10
+
11
+ [ -n "$state_dir" ] && [ -n "$session_id" ] || { echo "(no session selected)"; exit 0; }
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
14
+ "$JQ_BIN" -se --arg sid "$session_id" 'any(.sessionId == $sid)' "$snapshot" >/dev/null 2>&1 \
15
+ || { echo "(selected session is no longer in this snapshot)"; exit 0; }
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"
24
+ fi
25
+ [ -f "$DB_PATH" ] || { echo "(opencode database is unavailable)"; exit 0; }
26
+
27
+ SQLITE_BIN=${SESH_SQLITE:-}
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;"
30
+ if [ -n "$SQLITE_BIN" ]; then
31
+ rows=$("$SQLITE_BIN" -json "$DB_PATH" "$SQL" 2>/dev/null) || rows=''
32
+ else
33
+ OPENCODE_BIN=${SESH_OPENCODE:-opencode}
34
+ rows=$("$OPENCODE_BIN" db "$SQL" --format json 2>/dev/null) || rows=''
35
+ fi
36
+ [ -n "$rows" ] || { echo "(no displayable transcript messages)"; exit 0; }
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).
40
+ rendered=$(printf '%s' "$rows" | "$JQ_BIN" -r '
41
+ (if type == "array" then . else [] end)
42
+ | .[]
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=''
49
+ [ -n "$rendered" ] || { echo "(no displayable transcript messages)"; exit 0; }
50
+
51
+ resolve_glow() {
52
+ local configured=${SESH_GLOW:-} candidate
53
+ if [ -n "$configured" ]; then
54
+ case "$configured" in /*) [ -x "$configured" ] && printf '%s\n' "$configured" ;; esac
55
+ return 0
56
+ fi
57
+ candidate=$(command -v glow 2>/dev/null || true)
58
+ case "$candidate" in /*) [ -x "$candidate" ] && printf '%s\n' "$candidate" ;; esac
59
+ return 0
60
+ }
61
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
62
+
63
+ GLOW_BIN="$(resolve_glow)"
64
+ if [ -n "$GLOW_BIN" ] && [ -f "$SCRIPT_DIR/../themes/glow-dark-clean.json" ]; then
65
+ glow_rendered=$(printf '%s\n' "$rendered" | "$GLOW_BIN" -s "$SCRIPT_DIR/../themes/glow-dark-clean.json" -w "${FZF_PREVIEW_COLUMNS:-80}" - 2>/dev/null) && printf '%s\n' "$glow_rendered" || printf '%s\n' "$rendered"
66
+ else
67
+ printf '%s\n' "$rendered"
68
+ fi
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env bash
2
+ # A short-lived worker owned by one picker. It serializes expensive refreshes;
3
+ # fzf only ever asks the list script to render the last published snapshot.
4
+ set -euo pipefail
5
+
6
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
7
+ # The list script can be overridden for reuse; default is the opencode list.
8
+ LIST="${SESH_LIST:-$SCRIPT_DIR/sesh-list.sh}"
9
+ JQ_BIN=${SESH_JQ:-jq}
10
+ STATE_DIR=''
11
+ MODE=${1:-}
12
+ [ "$#" -gt 0 ] && shift
13
+ SCOPE=''
14
+ LIMIT=60
15
+ TARGET=''
16
+ TIMEOUT=12
17
+
18
+ usage() { echo "usage: ${0##*/} init|worker|render|request|wait --state-dir DIR [options]" >&2; exit 2; }
19
+ while [ "$#" -gt 0 ]; do
20
+ case "$1" in
21
+ --state-dir) [ "$#" -ge 2 ] || usage; STATE_DIR=$2; shift ;;
22
+ --scope) [ "$#" -ge 2 ] || usage; SCOPE=$2; shift ;;
23
+ --limit) [ "$#" -ge 2 ] || usage; LIMIT=$2; shift ;;
24
+ --query) [ "$#" -ge 2 ] || usage; QUERY=$2; shift ;;
25
+ --target) [ "$#" -ge 2 ] || usage; TARGET=$2; shift ;;
26
+ --timeout) [ "$#" -ge 2 ] || usage; TIMEOUT=$2; shift ;;
27
+ *) usage ;;
28
+ esac
29
+ shift
30
+ done
31
+ [ -n "$STATE_DIR" ] || usage
32
+ case "$LIMIT" in ''|*[!0-9]*) usage;; esac
33
+
34
+ atomic_text() {
35
+ local dest=$1 contents=$2 tmp
36
+ tmp=$(mktemp "$STATE_DIR/.worker.XXXXXX") || return 1
37
+ printf '%s\n' "$contents" > "$tmp"
38
+ mv -f "$tmp" "$dest"
39
+ }
40
+ request_number() { [ -f "$STATE_DIR/worker-request" ] && cat "$STATE_DIR/worker-request" 2>/dev/null || printf '0\n'; }
41
+
42
+ case "$MODE" in
43
+ init)
44
+ mkdir -p "$STATE_DIR"
45
+ # Settings exist before the first concurrent cache render.
46
+ settings=$("$JQ_BIN" -cn --arg scope "$SCOPE" --argjson limit "$LIMIT" '{scope:$scope,limit:$limit}')
47
+ atomic_text "$STATE_DIR/settings.json" "$settings"
48
+ atomic_text "$STATE_DIR/worker-request" 1
49
+ atomic_text "$STATE_DIR/worker-complete" 0
50
+ ;;
51
+ render)
52
+ "$LIST" --state-dir "$STATE_DIR" --query "${QUERY:-}"
53
+ # An empty, first snapshot should not look like a broken picker.
54
+ if [ ! -f "$STATE_DIR/status" ] && [ -f "$STATE_DIR/worker.pid" ]; then
55
+ printf '\t\tunknown\tLoading sessions…\t\tnotice:loading\n'
56
+ fi
57
+ ;;
58
+ request)
59
+ current=$(request_number)
60
+ case "$current" in ''|*[!0-9]*) current=0;; esac
61
+ current=$((current + 1))
62
+ atomic_text "$STATE_DIR/worker-request" "$current"
63
+ printf '%s\n' "$current"
64
+ ;;
65
+ wait)
66
+ case "$TARGET" in ''|*[!0-9]*) usage;; esac
67
+ case "$TIMEOUT" in ''|*[!0-9]*) usage;; esac
68
+ deadline=$((SECONDS + TIMEOUT))
69
+ while [ "$SECONDS" -lt "$deadline" ]; do
70
+ complete=$(cat "$STATE_DIR/worker-complete" 2>/dev/null || printf 0)
71
+ case "$complete" in ''|*[!0-9]*) complete=0;; esac
72
+ if [ "$complete" -ge "$TARGET" ]; then
73
+ [ "$(cat "$STATE_DIR/status" 2>/dev/null || true)" = fresh ] || exit 1
74
+ exit 0
75
+ fi
76
+ worker=$(cat "$STATE_DIR/worker.pid" 2>/dev/null || true)
77
+ case "$worker" in ''|*[!0-9]*) exit 1;; esac
78
+ kill -0 "$worker" 2>/dev/null || exit 1
79
+ sleep 1
80
+ done
81
+ exit 1
82
+ ;;
83
+ worker)
84
+ # Monitor mode gives each background scan its own process group, so cleanup
85
+ # can terminate exactly the list process and its foreground children. It is
86
+ # enabled only across that fork: while job control is on, Bash hands the
87
+ # controlling terminal to every foreground child's new process group and
88
+ # never hands it back, which would take the terminal away from the picker's
89
+ # fzf and kill it as soon as this worker ran its next ordinary command.
90
+ scan_pid=''; sleep_pid=''
91
+ cleanup() {
92
+ # Only signal and reap children started by this worker; never search for
93
+ # processes by name or touch another picker's state directory.
94
+ if [ -n "$scan_pid" ]; then kill -TERM -- "-$scan_pid" 2>/dev/null || kill -TERM "$scan_pid" 2>/dev/null || true; wait "$scan_pid" 2>/dev/null || true; fi
95
+ if [ -n "$sleep_pid" ]; then kill -TERM "$sleep_pid" 2>/dev/null || true; wait "$sleep_pid" 2>/dev/null || true; fi
96
+ rm -f "$STATE_DIR/worker-scan.pid" "$STATE_DIR/worker-sleep.pid" "$STATE_DIR/worker.pid"
97
+ }
98
+ trap cleanup EXIT
99
+ trap 'exit 130' HUP INT TERM
100
+ atomic_text "$STATE_DIR/worker.pid" "$$"
101
+ expedited=0
102
+ while :; do
103
+ # Capture the request before the scan. A request arriving during it is
104
+ # deliberately served by the next scan, never mistaken for this one.
105
+ serving=$(request_number)
106
+ case "$serving" in ''|*[!0-9]*) serving=0;; esac
107
+ set -m
108
+ "$LIST" --refresh --wait-lock 3 --state-dir "$STATE_DIR" >/dev/null &
109
+ scan_pid=$!
110
+ set +m
111
+ atomic_text "$STATE_DIR/worker-scan.pid" "$scan_pid"
112
+ scan_rc=0
113
+ wait "$scan_pid" || scan_rc=$?
114
+ if [ "$scan_rc" = 0 ]; then
115
+ atomic_text "$STATE_DIR/worker-complete" "$serving"
116
+ else
117
+ # Nothing was published, so no waiting dispatcher can be satisfied by a
118
+ # later scan either: answer every outstanding request now. Discredit the
119
+ # previous "fresh" marker FIRST -- a reader that sees the advanced
120
+ # counter must never still see the old status -- and only release the
121
+ # counter once that stale marker is actually on disk.
122
+ if [ "$scan_rc" = 75 ]; then
123
+ scan_status='stale: refresh deferred; another action holds the refresh lock'
124
+ else
125
+ scan_status='stale: refresh worker scan failed'
126
+ fi
127
+ answered=$(request_number)
128
+ case "$answered" in ''|*[!0-9]*) answered=$serving;; esac
129
+ [ "$answered" -ge "$serving" ] || answered=$serving
130
+ if atomic_text "$STATE_DIR/status" "$scan_status"; then
131
+ atomic_text "$STATE_DIR/worker-complete" "$answered"
132
+ fi
133
+ fi
134
+ scan_pid=''
135
+ rm -f "$STATE_DIR/worker-scan.pid"
136
+ # A request that arrived during the scan just missed it, and making it
137
+ # wait out the idle sleep first costs (rest of that scan) + 3 + (a full
138
+ # scan) -- past the dispatcher's 12s budget on a cold history, where a
139
+ # scan alone measures ~4.6s. Serve it immediately instead.
140
+ #
141
+ # Bounded twice over, so this cannot become a busy loop: the next scan
142
+ # answers the request whether it succeeds or fails, and each request
143
+ # number can skip the sleep only once, so a request that somehow stays
144
+ # outstanding (an unwritable status, say) still falls back to the sleep.
145
+ pending=$(request_number)
146
+ case "$pending" in ''|*[!0-9]*) pending=0;; esac
147
+ served=$(cat "$STATE_DIR/worker-complete" 2>/dev/null || printf 0)
148
+ case "$served" in ''|*[!0-9]*) served=0;; esac
149
+ if [ "$pending" -gt "$served" ] && [ "$pending" != "$expedited" ]; then
150
+ expedited=$pending
151
+ continue
152
+ fi
153
+ sleep 3 &
154
+ sleep_pid=$!
155
+ atomic_text "$STATE_DIR/worker-sleep.pid" "$sleep_pid"
156
+ wait "$sleep_pid" || true
157
+ sleep_pid=''
158
+ rm -f "$STATE_DIR/worker-sleep.pid"
159
+ done
160
+ ;;
161
+ *) usage ;;
162
+ esac
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env bash
2
+ # Shortcut help shown in the sesh preview pane.
3
+ cat <<'HELP'
4
+ sesh shortcuts
5
+
6
+ Type Search session titles and transcript text
7
+ Enter Resume the selected session here
8
+ Ctrl-F Enter Resume the selected session as a fork
9
+ Ctrl-G Toggle current-directory scope / all sessions
10
+ Space Toggle transcript preview
11
+ ? Toggle this help
12
+ Ctrl-X Delete the selected session
13
+ Escape Exit the picker
14
+
15
+ Flags: --cwd (current directory only), --limit N (default: all),
16
+ --archived (include archived), --print (print id instead of resuming)
17
+ HELP