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