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.
- package/AGENTS.md +129 -0
- package/CHANGELOG.md +55 -0
- package/LICENSE +21 -0
- package/README.md +207 -0
- package/bin/sesh +26 -0
- package/bin/sesh-delete.sh +66 -0
- package/bin/sesh-list.sh +294 -0
- package/bin/sesh-preview.sh +68 -0
- package/bin/sesh-refresh-worker.sh +162 -0
- package/bin/sesh-shortcuts.sh +17 -0
- package/bin/sesh.sh +150 -0
- package/install.sh +241 -0
- package/opencode/tools/sesh-list.ts +42 -0
- package/package.json +57 -0
- package/themes/glow-dark-clean.json +191 -0
- package/tui/sesh-panel.tsx +1177 -0
package/bin/sesh-list.sh
ADDED
|
@@ -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
|