opencode-sesh 1.0.3 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -59,16 +59,18 @@ The tool name in opencode is `sesh_list`. Do not rename it without updating the
59
59
  ## Data flow (fzf picker)
60
60
 
61
61
  `bin/sesh-list.sh` owns the fzf picker's session store access (opencode SQLite
62
- DB: `session` / `message` / `part` tables); the `sesh-list` agent tool queries
63
- the same store through `opencode db`. Keep its SQL column names in sync with
62
+ DB: `session_v2` / `session_message` tables); the `sesh-list` agent tool queries
63
+ the same store read-only through `bun:sqlite`. V2 discovers the tool from the
64
+ global config directory's `plugins/`. Keep its SQL column names in sync with
64
65
  the schema below. Keystrokes never touch the database:
65
66
 
66
67
  - `--refresh` (expensive): bulk-reads session metadata and per-session text
67
68
  parts, validates the persistent extraction cache
68
- (`$SESH_CACHE_DIR/extractions/<ses_*.json>`, schema 2) against `time_updated`,
69
- part count and the latest part timestamp, re-extracts only changed sessions,
69
+ (`$SESH_CACHE_DIR/extractions/<ses_*.json>`, schema 3) against `time_updated`,
70
+ message count and the latest message timestamp, re-extracts only changed sessions,
70
71
  prunes vanished ones, and atomically publishes `STATE_DIR/snapshot.jsonl`.
71
- Extraction moves transcripts through files, never argv (no ARG_MAX limit); if
72
+ Extraction reads user `data.text` and assistant `data.content` text entries,
73
+ ordered by message `seq`, through files, never argv (no ARG_MAX limit); if
72
74
  any session fails, the published status is `stale` and that session stays
73
75
  uncached so the next refresh retries, otherwise `status = fresh`. Scope
74
76
  (`--cwd`/toggle) and `--limit` apply at assembly, so metadata always covers
@@ -141,7 +143,7 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
141
143
 
142
144
  ## TUI panel (`tui-plugins/sesh-panel/tui.tsx`)
143
145
 
144
- - **Sidebar:** `api.slots.register` on `sidebar_content` (append mode — native
146
+ - **Sidebar:** `api.slots.register` on `sidebar_content` (prepend mode — native
145
147
  sidebar content stays). Pinned directories sort first, then directory groups
146
148
  by recency; sessions within each group sort by last update regardless of pins.
147
149
  Row markers alone signal session state: a running agent animates a loading
@@ -171,8 +173,9 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
171
173
  `/sesh-needs` uses the shared needs-input set plus the oldest unresolved
172
174
  question or running-tool timestamp, lists longest-waiting first, and `n`
173
175
  opens the next waiting session. `tests/tui.mjs` locks the data layer.
174
- - **Durable install:** opencode imports plugins once at startup and never hot
175
- reloads, so panel changes need a full quit/reopen. `install.sh --sync-panel`
176
+ - **Durable install:** V2 reloads plugins on watched configuration changes;
177
+ unwatched dependencies may need a restart. If a panel update does not appear,
178
+ fully quit/reopen opencode. `install.sh --sync-panel`
176
179
  (run by the `postinstall`) refreshes an already-installed panel and is a
177
180
  no-op when absent, and `sesh --check` reports when the installed copy differs
178
181
  from the bundled one.
@@ -234,7 +237,7 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
234
237
  history in a temp dir (included in `bun run test`).
235
238
  - `bun run test:picker` — PTY suite driving the real fzf picker (needs fzf
236
239
  `>= 0.73` and Python 3).
237
- - `bun run typecheck` — `tsc` over `tui/` and `opencode/`.
240
+ - `bun run typecheck` — `tsc` over `tui-plugins/` and `plugins/`.
238
241
  - `bun run lint:sh` — `bash -n` on every script.
239
242
  - `HOME=/tmp/fakehome bash install.sh` — installer smoke test.
240
243
  - Keep `tui-plugins/sesh-panel/tui.tsx` and any installed copy byte-identical when testing
package/CHANGELOG.md CHANGED
@@ -5,6 +5,12 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.0.4] - 2026-10-08
9
+
10
+ ### Fixed
11
+
12
+ - Restore V2 session store, dialog and fork compatibility
13
+
8
14
  ## [1.0.3] - 2026-10-08
9
15
 
10
16
  ### Fixed
@@ -436,6 +442,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
436
442
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
437
443
  CI.
438
444
 
445
+ [1.0.4]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.3...v1.0.4
439
446
  [1.0.3]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.2...v1.0.3
440
447
  [1.0.2]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.1...v1.0.2
441
448
  [1.0.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.0...v1.0.1
package/README.md CHANGED
@@ -72,7 +72,7 @@ bash install.sh
72
72
  `sesh install` (or `bash install.sh`) links `sesh` into `$XDG_BIN_HOME`
73
73
  (default `~/.local/bin`), copies
74
74
  the `sesh-list` tool and the TUI panel into
75
- `${XDG_CONFIG_HOME:-~/.config}/opencode`, registers the panel in `tui.json`, and
75
+ `${XDG_CONFIG_HOME:-~/.config}/opencode`, registers the panel in `opencode.json`, and
76
76
  declares the plugin dependencies (opencode installs them on next start). The
77
77
  panel registers the `/sesh` slash command. It never edits your shell rc, and any
78
78
  file it would overwrite is backed up first.
@@ -80,8 +80,8 @@ file it would overwrite is backed up first.
80
80
  The npm package ships the same scripts and installer; the runtime tools below
81
81
  are still required.
82
82
 
83
- Fully quit opencode and reopen it to load the panel — opencode imports plugins
84
- once at startup, so reloading a window reuses the running process. Upgrades
83
+ V2 reloads plugins when watched configuration files change. If a panel update
84
+ does not appear, fully quit opencode and reopen it. Upgrades
85
85
  refresh an already-installed panel automatically (the npm `postinstall` syncs
86
86
  it), and `sesh --check` reports when the installed panel is out of date.
87
87
 
@@ -247,10 +247,10 @@ workspace, and a global "resume from anywhere" hotkey — all caller-side.
247
247
 
248
248
  ## How it works
249
249
 
250
- `bin/sesh-list.sh` is the only `bin/` script that reads the opencode session
251
- store (SQLite: `session` / `message` / `part`). Each refresh extracts session
250
+ The `bin/` readers use the V2 opencode session
251
+ store (SQLite: `session_v2` / `session_message`). Each refresh extracts session
252
252
  metadata and text-only parts into a persistent per-session cache keyed on
253
- `time_updated`, part count and the latest part timestamp, then atomically
253
+ `time_updated`, message count and the latest message timestamp, then atomically
254
254
  publishes a `snapshot.jsonl`. Every keystroke re-renders that snapshot with `jq`
255
255
  alone, so typing never starts competing database scans. Reasoning and tool
256
256
  payloads are never indexed or previewed, and session ids are validated before
@@ -261,7 +261,8 @@ Flat scripts back the terminal UI — the picker (`sesh.sh`), the refresh engine
261
261
  scans), preview renderer (`sesh-preview.sh`), deleter (`sesh-delete.sh`) and
262
262
  shortcut help (`sesh-shortcuts.sh`). The TUI panel (`tui-plugins/sesh-panel/tui.tsx`) is a
263
263
  SolidJS OpenTUI plugin that talks to the opencode SDK over the same store; the
264
- `sesh-list` agent tool reads it through `opencode db`.
264
+ `sesh-list` agent tool reads it read-only through `bun:sqlite`. V2 discovers
265
+ the installed tool automatically in the global config directory's `plugins/`.
265
266
 
266
267
  ## FAQ
267
268
 
@@ -292,7 +293,7 @@ checks before opening a PR:
292
293
  bun install
293
294
  bun run test # fixture-database regression suite
294
295
  bun run test:picker # PTY picker suite (needs fzf >= 0.73)
295
- bun run typecheck # tsc over tui/ and opencode/
296
+ bun run typecheck # tsc over tui-plugins/ and plugins/
296
297
  bun run lint:sh # bash -n on every script
297
298
  ```
298
299
 
package/bin/sesh-costs.sh CHANGED
@@ -59,9 +59,9 @@ SELECT s.directory AS directory,
59
59
  ROUND(SUM(COALESCE(json_extract(m.data, '\$.cost'), 0)), 4) AS lifetime_cost,
60
60
  COUNT(DISTINCT s.id) AS sessions,
61
61
  SUM(CASE WHEN m.time_created >= $CUTOFF THEN COALESCE(json_extract(m.data, '\$.tokens.input'), 0) + COALESCE(json_extract(m.data, '\$.tokens.output'), 0) ELSE 0 END) AS window_tokens
62
- FROM message m
63
- JOIN session s ON s.id = m.session_id
64
- WHERE json_extract(m.data, '\$.role') = 'assistant'
62
+ FROM session_message m
63
+ JOIN session_v2 s ON s.id = m.session_id
64
+ WHERE m.type = 'assistant'
65
65
  AND json_extract(m.data, '\$.cost') IS NOT NULL
66
66
  GROUP BY s.directory
67
67
  ORDER BY lifetime_cost DESC;"
package/bin/sesh-list.sh CHANGED
@@ -276,7 +276,7 @@ refresh() {
276
276
  historical="$scratch/historical.jsonl"; needs_extract="$scratch/needs-extract.json"
277
277
  out="$scratch/snapshot.jsonl"
278
278
 
279
- db_json "SELECT id, directory, title, time_created, time_updated, COALESCE(time_archived, 0) AS archived, COALESCE(parent_id, '') AS parent FROM session ORDER BY time_updated DESC;" > "$sessions" 2>/dev/null || {
279
+ db_json "SELECT id, directory, title, time_created, time_updated, COALESCE(time_archived, 0) AS archived, COALESCE(parent_id, '') AS parent FROM session_v2 ORDER BY time_updated DESC;" > "$sessions" 2>/dev/null || {
280
280
  if [ -f "$SNAPSHOT" ]; then set_status 'stale: opencode database query failed; showing last good snapshot'; else set_status 'unavailable: opencode database query failed'; fi
281
281
  return 0
282
282
  }
@@ -287,7 +287,7 @@ refresh() {
287
287
  if [ -f "$SNAPSHOT" ]; then set_status 'stale: opencode database query failed; showing last good snapshot'; else set_status 'unavailable: opencode database query failed'; fi
288
288
  return 0
289
289
  }
290
- 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"
290
+ db_json "SELECT session_id, COUNT(*) AS n, MAX(time_updated) AS m FROM session_message GROUP BY session_id;" > "$parts" 2>/dev/null || printf '[]\n' > "$parts"
291
291
  "$JQ_BIN" -e 'type == "array"' "$parts" >/dev/null 2>&1 || printf '[]\n' > "$parts"
292
292
 
293
293
  # Same bulk cache validation as the Claude list: every cache arrives as its
@@ -302,7 +302,7 @@ refresh() {
302
302
  | ($parts[0] | map({key:.session_id, value:.}) | from_entries) as $stats
303
303
  | [$sessions[0][] | . + {partCount: ($stats[.id].n // 0), partMax: ($stats[.id].m // 0)}] as $meta
304
304
  | ($meta | map({key:.id, value:.}) | from_entries) as $byId
305
- | [$caches[0][]? | select(.cacheSchema == 2 and (.sessionId | type) == "string")
305
+ | [$caches[0][]? | select(.cacheSchema == 3 and (.sessionId | type) == "string")
306
306
  | select($byId[.sessionId] as $m | $m != null and .cacheUpdated == $m.time_updated and .cacheParts == $m.partCount and .cacheMax == $m.partMax)] as $cached
307
307
  | ($cached | map({key: .sessionId, value:true}) | from_entries) as $cachedIds
308
308
  | {cached: $cached,
@@ -337,7 +337,8 @@ refresh() {
337
337
  for ((i = j; i < j + 200 && i < ${#stale_ids[@]}; i++)); do
338
338
  chunk="${chunk:+$chunk,}'${stale_ids[$i]}'"
339
339
  done
340
- if ! db_json "SELECT session_id, data FROM part WHERE session_id IN ($chunk) AND json_extract(data, '\$.type') = 'text' ORDER BY session_id, time_created;" 2>/dev/null \
340
+ if ! db_json "SELECT session_id, json_object('type', 'text', 'text', json_extract(data, '\$.text')) AS data, seq, -1 AS ordinal FROM session_message WHERE session_id IN ($chunk) AND type = 'user' AND json_type(data, '\$.text') = 'text'
341
+ UNION ALL SELECT m.session_id, c.value AS data, m.seq, CAST(c.key AS INTEGER) AS ordinal FROM session_message m, json_each(m.data, '\$.content') c WHERE m.session_id IN ($chunk) AND m.type = 'assistant' AND json_extract(c.value, '\$.type') = 'text' ORDER BY session_id, seq, ordinal;" 2>/dev/null \
341
342
  | "$JQ_BIN" -c '.[]?' >> "$parts_raw"; then
342
343
  extract_failed=1
343
344
  break
@@ -363,7 +364,7 @@ refresh() {
363
364
  else $s.title end) as $title
364
365
  | {sessionId: $s.id,
365
366
  cachePath: ($extractions + "/" + $s.id + ".json"),
366
- record: {cacheSchema: 2, cacheSession: $s.id,
367
+ record: {cacheSchema: 3, cacheSession: $s.id,
367
368
  cacheUpdated: ($s.time_updated // 0), cacheParts: ($s.partCount // 0), cacheMax: ($s.partMax // 0),
368
369
  sessionId: $s.id, transcriptPath: "", source: "opencode",
369
370
  cwd: ($s.directory // ""),
@@ -26,7 +26,8 @@ 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' AND json_extract(p.data, '\$.type') = 'text' ORDER BY p.time_created DESC LIMIT 200;"
29
+ SQL="SELECT json_object('role', type) AS msg, json_object('type', 'text', 'text', json_extract(data, '\$.text')) AS part, seq, -1 AS ordinal FROM session_message WHERE session_id = '$session_id' AND type = 'user' AND json_type(data, '\$.text') = 'text'
30
+ UNION ALL SELECT json_object('role', m.type) AS msg, c.value AS part, m.seq, CAST(c.key AS INTEGER) AS ordinal FROM session_message m, json_each(m.data, '\$.content') c WHERE m.session_id = '$session_id' AND m.type = 'assistant' AND json_extract(c.value, '\$.type') = 'text' ORDER BY seq DESC, ordinal DESC LIMIT 200;"
30
31
  if [ -n "$SQLITE_BIN" ]; then
31
32
  rows=$("$SQLITE_BIN" -json -readonly "$DB_PATH" "$SQL" 2>/dev/null) || rows=''
32
33
  else
package/bin/sesh-prune.sh CHANGED
@@ -84,7 +84,7 @@ fi
84
84
  JQ_BIN=${SESH_JQ:-$(command -v jq || true)}
85
85
  [ -n "$JQ_BIN" ] || { echo 'sesh: jq is required' >&2; exit 1; }
86
86
 
87
- CANDIDATES=$(query "SELECT id, directory, title, time_updated AS updated FROM session WHERE time_updated < $CUTOFF AND COALESCE(time_archived, 0) = 0 AND parent_id IS NULL ORDER BY time_updated ASC;")
87
+ CANDIDATES=$(query "SELECT id, directory, title, time_updated AS updated FROM session_v2 WHERE time_updated < $CUTOFF AND COALESCE(time_archived, 0) = 0 AND parent_id IS NULL ORDER BY time_updated ASC;")
88
88
  # sqlite3 -json prints nothing (not []) when no rows match.
89
89
  [ -n "$CANDIDATES" ] || CANDIDATES='[]'
90
90
  PINS=$("$SCRIPT_DIR/sesh-pins.sh" read 2>/dev/null || printf '{"sessions":[],"directories":[]}')
@@ -152,7 +152,7 @@ if [ "$hard_delete" = 0 ]; then
152
152
  esac
153
153
  done
154
154
  IN_LIST=${IN_LIST%,}
155
- write_db "UPDATE session SET time_archived = $NOW_MS WHERE id IN ($IN_LIST);"
155
+ write_db "UPDATE session_v2 SET time_archived = $NOW_MS WHERE id IN ($IN_LIST);"
156
156
  echo "Archived $COUNT session(s) older than $older_than."
157
157
  else
158
158
  command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'sesh: opencode executable is unavailable for deletes.' >&2; exit 1; }
@@ -72,13 +72,11 @@ JQ_BIN=${SESH_JQ:-$(command -v jq || true)}
72
72
  # first `max_words` words kept.
73
73
  CANDIDATES=$(query "
74
74
  SELECT s.id AS id, s.directory AS directory, s.title AS old_title,
75
- (SELECT p.data FROM part p
76
- JOIN message m ON m.id = p.message_id
77
- WHERE p.session_id = s.id
78
- AND json_extract(p.data, '\$.type') = 'text'
79
- AND json_extract(m.data, '\$.role') = 'user'
80
- ORDER BY p.time_created ASC LIMIT 1) AS first_part
81
- FROM session s
75
+ (SELECT m.data FROM session_message m
76
+ WHERE m.session_id = s.id AND m.type = 'user'
77
+ AND json_type(m.data, '\$.text') = 'text'
78
+ ORDER BY m.seq ASC LIMIT 1) AS first_part
79
+ FROM session_v2 s
82
80
  WHERE s.title LIKE 'New session - %'
83
81
  OR TRIM(COALESCE(s.title, '')) = ''
84
82
  ORDER BY s.time_updated DESC;")
@@ -136,6 +134,6 @@ printf '%s' "$PLAN" | "$JQ_BIN" -r '.[] | [.id, .new_title] | @tsv' | while IFS=
136
134
  ses_[A-Za-z0-9]*) ;;
137
135
  *) echo "Refusing: unexpected session id shape." >&2; exit 1 ;;
138
136
  esac
139
- write_db "UPDATE session SET title = '$(sql_escape "$new")' WHERE id = '$id' AND (title LIKE 'New session - %' OR TRIM(COALESCE(title, '')) = '');"
137
+ write_db "UPDATE session_v2 SET title = '$(sql_escape "$new")' WHERE id = '$id' AND (title LIKE 'New session - %' OR TRIM(COALESCE(title, '')) = '');"
140
138
  done
141
139
  echo "Retitled $COUNT session(s)."
@@ -54,39 +54,37 @@ STUCK_BEFORE=$((NOW_MS - 600000))
54
54
 
55
55
  # NEEDS_INPUT_SQL (shared heuristic — keep in sync with tui-plugins/sesh-panel/tui.tsx).
56
56
  read -r -d '' QUERY <<SQL || true
57
+ WITH live AS (
58
+ SELECT m.session_id AS session_id, m.seq AS seq,
59
+ json_extract(c.value, '\$.name') AS name,
60
+ json_extract(c.value, '\$.state.status') AS status,
61
+ COALESCE(json_extract(c.value, '\$.time.created'), m.time_created) AS created
62
+ FROM session_message m, json_each(m.data, '\$.content') c
63
+ WHERE m.type = 'assistant'
64
+ AND (m.data LIKE '%"status":"running"%' OR m.data LIKE '%"status":"pending"%')
65
+ AND json_extract(c.value, '\$.type') = 'tool'
66
+ AND json_extract(c.value, '\$.state.status') IN ('pending', 'running')
67
+ )
57
68
  SELECT s.id AS id, s.directory AS directory, s.title AS title, s.time_updated AS updated,
58
69
  CASE WHEN EXISTS (
59
- SELECT 1 FROM part p
60
- WHERE p.session_id = s.id
61
- AND json_extract(p.data, '\$.type') = 'tool'
62
- AND json_extract(p.data, '\$.tool') = 'question'
63
- AND COALESCE(json_extract(p.data, '\$.state.status'), 'pending') != 'completed'
70
+ SELECT 1 FROM live
71
+ WHERE live.session_id = s.id AND live.name = 'question'
64
72
  AND NOT EXISTS (
65
- SELECT 1 FROM message m
66
- WHERE m.session_id = s.id
67
- AND json_extract(m.data, '\$.role') = 'user'
68
- AND m.time_created > p.time_created)
73
+ SELECT 1 FROM session_message u
74
+ WHERE u.session_id = live.session_id AND u.type = 'user' AND u.seq > live.seq)
69
75
  ) THEN 'question' ELSE 'stuck' END AS reason
70
- FROM session s
76
+ FROM session_v2 s
71
77
  WHERE COALESCE(s.time_archived, 0) = 0
72
78
  AND s.parent_id IS NULL
73
79
  AND (EXISTS (
74
- SELECT 1 FROM part p
75
- WHERE p.session_id = s.id
76
- AND json_extract(p.data, '\$.type') = 'tool'
77
- AND json_extract(p.data, '\$.tool') = 'question'
78
- AND COALESCE(json_extract(p.data, '\$.state.status'), 'pending') != 'completed'
80
+ SELECT 1 FROM live
81
+ WHERE live.session_id = s.id AND live.name = 'question'
79
82
  AND NOT EXISTS (
80
- SELECT 1 FROM message m
81
- WHERE m.session_id = s.id
82
- AND json_extract(m.data, '\$.role') = 'user'
83
- AND m.time_created > p.time_created)
83
+ SELECT 1 FROM session_message u
84
+ WHERE u.session_id = live.session_id AND u.type = 'user' AND u.seq > live.seq)
84
85
  ) OR EXISTS (
85
- SELECT 1 FROM part p2
86
- WHERE p2.session_id = s.id
87
- AND json_extract(p2.data, '\$.type') = 'tool'
88
- AND json_extract(p2.data, '\$.state.status') = 'running'
89
- AND p2.time_created < $STUCK_BEFORE))
86
+ SELECT 1 FROM live
87
+ WHERE live.session_id = s.id AND live.status = 'running' AND live.created < $STUCK_BEFORE))
90
88
  ORDER BY s.time_updated DESC;
91
89
  SQL
92
90
 
package/install.sh CHANGED
@@ -210,11 +210,8 @@ install_tui_panel() {
210
210
  fi
211
211
  }
212
212
 
213
- # opencode imports plugins once at startup and has no hot reload, so a running
214
- # instance keeps the old panel until it is fully quit (reloading a window reuses
215
- # the same process). Say so explicitly and name the processes still holding it.
216
213
  note_restart() {
217
- note "restart opencode to load the updated panel: quit it completely"
214
+ note "V2 reloads watched plugins automatically; if the panel stays unchanged, restart opencode: quit it completely"
218
215
  note " (all windows and background processes), then reopen."
219
216
  if command -v pgrep >/dev/null 2>&1; then
220
217
  local pids
@@ -238,7 +235,7 @@ if [ "$UNINSTALL" = 1 ]; then
238
235
  if [ -d "$PANEL_DIR" ]; then
239
236
  rm -rf "$PANEL_DIR" && note "removed $PANEL_DIR"
240
237
  fi
241
- remove_if_marker "$CONFIG_DIR/plugins/sesh-panel.tsx" "@jsxImportSource @opentui/solid" && note "removed $CONFIG_DIR/plugins/sesh-panel.tsx"
238
+ remove_if_marker "$CONFIG_DIR/plugins/sesh-panel.tsx" 'id: "sesh-panel"' && note "removed $CONFIG_DIR/plugins/sesh-panel.tsx"
242
239
  remove_opencode_entry && note "unregistered the panel from $OPENCODE_JSON"
243
240
  remove_tui_entry && note "unregistered the panel from $TUI_JSON"
244
241
  note "uninstalled (extraction cache and plugin dependencies left intact)"
@@ -309,6 +306,7 @@ remove_if_marker "$COMMAND_DST" "rich interactive picker" && note "removed obsol
309
306
  [ "$INSTALL_TOOL" = 1 ] && install_file "$SOURCE_DIR/plugins/sesh-list.ts" "$TOOL_DST"
310
307
  # Clean up obsolete V1 tool if present
311
308
  remove_if_marker "$CONFIG_DIR/tools/sesh-list.ts" "list opencode sessions for the agent" && note "removed obsolete V1 tool"
309
+ remove_if_marker "$CONFIG_DIR/plugins/sesh-panel.tsx" 'id: "sesh-panel"' && note "removed obsolete V1 panel"
312
310
 
313
311
  if [ "$INSTALL_TUI" = 1 ]; then
314
312
  install_tui_panel
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "1.0.3",
3
+ "version": "1.0.4",
4
4
  "description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -66,8 +66,9 @@
66
66
  "devDependencies": {
67
67
  "@commitlint/cli": "^21.2.2",
68
68
  "@commitlint/config-conventional": "^21.2.2",
69
- "@opencode/client": "2.0.18",
70
- "@opencode/plugin": "2.0.18",
69
+ "@opencode/client": "2.0.25",
70
+ "@opencode/plugin": "2.0.25",
71
+ "@opencode/theme": "2.0.25",
71
72
  "@opentui/core": "^0.5.11",
72
73
  "@opentui/keymap": "^0.5.11",
73
74
  "@opentui/solid": "^0.5.11",
@@ -26,7 +26,7 @@ const input = {
26
26
  export default Plugin.define({
27
27
  id: "sesh-list",
28
28
  async setup(ctx) {
29
- ctx.tool.transform((editor) => {
29
+ await ctx.tool.transform((editor) => {
30
30
  editor.add({
31
31
  name: "sesh_list",
32
32
  description: "List opencode sessions across all project directories, newest first",
@@ -3,7 +3,7 @@ import { Plugin } from "@opencode/plugin/tui"
3
3
  import type { Project, SessionInfo as Session } from "@opencode/client"
4
4
  import type { Accessor } from "solid-js"
5
5
 
6
- type TuiPluginApi = any
6
+ type TuiPluginApi = ReturnType<typeof createApi>
7
7
  type TuiPlugin = (api: TuiPluginApi) => Promise<void>
8
8
  import { createEffect, createMemo, createRoot, createSignal, For, getOwner, on, onCleanup, onMount, runWithOwner, Show } from "solid-js"
9
9
  import { mkdir, readFile, rename, rmdir, unlink, writeFile } from "node:fs/promises"
@@ -237,6 +237,8 @@ function searchBindings(append: (input: string) => void, exit: () => void) {
237
237
 
238
238
  const rootMouseHandlers = new Set<() => void>()
239
239
  let rootMouseInstalled = false
240
+ let removeRootMouse: (() => void) | undefined
241
+ const ROOT_MOUSE = Symbol.for("opencode-sesh.root-mouse")
240
242
 
241
243
  function ensureRootMouse(renderer: TuiPluginApi["renderer"]) {
242
244
  if (rootMouseInstalled || !renderer?.root) return
@@ -244,12 +246,26 @@ function ensureRootMouse(renderer: TuiPluginApi["renderer"]) {
244
246
  const root = renderer.root as unknown as {
245
247
  onMouseDown?: (event: unknown) => void
246
248
  _mouseListeners?: { down?: (event: unknown) => void }
249
+ [ROOT_MOUSE]?: Set<() => void>
247
250
  }
248
- const previous = root._mouseListeners?.down
249
- root.onMouseDown = (event) => {
250
- previous?.(event)
251
+ if (!root[ROOT_MOUSE]) {
252
+ const handlers = new Set<() => void>()
253
+ root[ROOT_MOUSE] = handlers
254
+ const previous = root._mouseListeners?.down
255
+ root.onMouseDown = (event) => {
256
+ previous?.(event)
257
+ for (const handler of [...handlers]) handler()
258
+ }
259
+ }
260
+ const dispatch = () => {
251
261
  for (const handler of [...rootMouseHandlers]) handler()
252
262
  }
263
+ root[ROOT_MOUSE].add(dispatch)
264
+ removeRootMouse = () => {
265
+ root[ROOT_MOUSE]?.delete(dispatch)
266
+ rootMouseHandlers.clear()
267
+ rootMouseInstalled = false
268
+ }
253
269
  }
254
270
 
255
271
  type Entry = { id: string; title: string; dir: string; group: string; updated: number }
@@ -422,17 +438,14 @@ const SESSION_MAX = 5000
422
438
 
423
439
  async function fetchEntries(api: TuiPluginApi): Promise<EntryResult> {
424
440
  const home = process.env.HOME ?? ""
425
- const projectResult = await api.client.project.list({})
441
+ const projectResult = await api.client.project.list()
426
442
  const projects = ((projectResult?.data ?? projectResult) as Project[]) ?? []
427
443
  const projectName = new Map<string, string>(
428
444
  projects.map((p) => [p.id, p.name?.trim() || shortDir(p.canonical, home)]),
429
445
  )
430
446
  const sessions: Session[] = []
431
- let cursor: number | undefined
447
+ let cursor: string | undefined
432
448
  let truncated = false
433
- // Page through the global list instead of one fixed window, so sessions in
434
- // older directories stay searchable. `cursor` is the previous page's oldest
435
- // `time.updated`; a repeated or missing cursor stops the walk.
436
449
  for (;;) {
437
450
  const result = await api.client.experimental.session.list({
438
451
  limit: SESSION_PAGE_LIMIT,
@@ -443,13 +456,13 @@ async function fetchEntries(api: TuiPluginApi): Promise<EntryResult> {
443
456
  const page = ((result?.data ?? result) as Session[]) ?? []
444
457
  if (!Array.isArray(page) || page.length === 0) break
445
458
  sessions.push(...page)
446
- if (page.length < SESSION_PAGE_LIMIT) break
459
+ if (!result.cursor?.next) break
447
460
  if (sessions.length >= SESSION_MAX) {
448
461
  truncated = true
449
462
  break
450
463
  }
451
- const next = page[page.length - 1]?.time?.updated
452
- if (typeof next !== "number" || next === cursor) {
464
+ const next = result.cursor.next
465
+ if (next === cursor) {
453
466
  truncated = true
454
467
  break
455
468
  }
@@ -773,7 +786,7 @@ function Highlighted(props: {
773
786
 
774
787
  async function fetchTranscriptText(api: TuiPluginApi, sessionID: string): Promise<string> {
775
788
  try {
776
- const result = await api.client.session.messages({ sessionID })
789
+ const result = await api.client.session.messages({ sessionID, preview: true })
777
790
  const messages = result.data ?? []
778
791
  const blocks = messages
779
792
  .map((message: any) => {
@@ -815,6 +828,7 @@ function createTranscriptPreview(api: TuiPluginApi) {
815
828
  close()
816
829
  return
817
830
  }
831
+ if (previewID()) close()
818
832
  setPreviewID(entry.id)
819
833
  setPreviewText("Loading…")
820
834
  void fetchTranscriptText(api, entry.id).then((text) => {
@@ -857,6 +871,7 @@ function createTranscriptPreview(api: TuiPluginApi) {
857
871
  api.ui.dialog.setSize("large")
858
872
  disposePreviewKeys?.()
859
873
  disposePreviewKeys = api.keymap.registerLayer({
874
+ mode: "modal",
860
875
  bindings: [
861
876
  {
862
877
  key: "escape",
@@ -2044,10 +2059,10 @@ const tui: TuiPlugin = async (api) => {
2044
2059
  },
2045
2060
  })
2046
2061
 
2047
- const openPicker = async () => {
2062
+ const openPicker = async (command = false) => {
2048
2063
  refreshPins()
2049
- if (api.mode.current() !== BASE_MODE) return
2050
- if (api.renderer.currentFocusedEditor === null) return
2064
+ if (!command && api.mode.current() !== BASE_MODE) return
2065
+ if (!command && api.renderer.currentFocusedEditor === null) return
2051
2066
 
2052
2067
  let entriesResult: EntryResult
2053
2068
  try {
@@ -2357,6 +2372,7 @@ const tui: TuiPlugin = async (api) => {
2357
2372
  confirmTimer = setTimeout(cancelDelete, 5000)
2358
2373
  disposeConfirm?.()
2359
2374
  disposeConfirm = api.keymap.registerLayer({
2375
+ mode: "modal",
2360
2376
  priority: 20,
2361
2377
  bindings: [
2362
2378
  {
@@ -2415,9 +2431,7 @@ const tui: TuiPlugin = async (api) => {
2415
2431
  }
2416
2432
 
2417
2433
  const disposeNav = api.keymap.registerLayer({
2418
- // Outrank the built-in dialog layer so Escape reaches this picker before
2419
- // the dialog host closes the whole thing.
2420
- mode: "global",
2434
+ mode: "modal",
2421
2435
  priority: 1,
2422
2436
  bindings: [
2423
2437
  { key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
@@ -2490,6 +2504,7 @@ const tui: TuiPlugin = async (api) => {
2490
2504
  })
2491
2505
 
2492
2506
  const cleanup = () => {
2507
+ if (!alive) return
2493
2508
  alive = false
2494
2509
  if (confirmTimer) clearTimeout(confirmTimer)
2495
2510
  pickerState = { query: query(), scope: scope(), waitingOnly: waitingOnly(), pinnedOnly: pinnedOnly() }
@@ -2793,9 +2808,9 @@ const tui: TuiPlugin = async (api) => {
2793
2808
 
2794
2809
  // Read-only digest dialog: spend per project, last day beside lifetime.
2795
2810
  // Same message-level sums as `sesh costs`; the dialog just renders them.
2796
- const openCosts = async () => {
2797
- if (api.mode.current() !== BASE_MODE) return
2798
- if (api.renderer.currentFocusedEditor === null) return
2811
+ const openCosts = async (command = false) => {
2812
+ if (!command && api.mode.current() !== BASE_MODE) return
2813
+ if (!command && api.renderer.currentFocusedEditor === null) return
2799
2814
  type CostRow = { directory: string; window: number; lifetime: number; sessions: number }
2800
2815
  let rows: CostRow[] = []
2801
2816
  let failed = false
@@ -2853,6 +2868,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2853
2868
  const money = (n: number) => (n === 0 ? "—" : `$${n.toFixed(2)}`)
2854
2869
  const home = process.env.HOME ?? ""
2855
2870
  const disposeKeys = api.keymap.registerLayer({
2871
+ mode: "modal",
2856
2872
  bindings: [
2857
2873
  { key: "escape", desc: "Close", preventDefault: true, cmd: () => api.ui.dialog.clear() },
2858
2874
  ],
@@ -2906,9 +2922,9 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2906
2922
  // Triage dialog: the waiting set as an openable list. Enter opens the
2907
2923
  // highlighted session; the sidebar section stays the always-visible view.
2908
2924
  let lastNeedsID: string | undefined
2909
- const openNeeds = async () => {
2910
- if (api.mode.current() !== BASE_MODE) return
2911
- if (api.renderer.currentFocusedEditor === null) return
2925
+ const openNeeds = async (command = false) => {
2926
+ if (!command && api.mode.current() !== BASE_MODE) return
2927
+ if (!command && api.renderer.currentFocusedEditor === null) return
2912
2928
  let items: { entry: Entry; reason: string; since?: number }[] = []
2913
2929
  try {
2914
2930
  const { entries } = await fetchEntries(api)
@@ -2953,6 +2969,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2953
2969
  api.route.navigate("session", { sessionID: target.entry.id })
2954
2970
  }
2955
2971
  const disposeNav = api.keymap.registerLayer({
2972
+ mode: "modal",
2956
2973
  bindings: [
2957
2974
  { key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
2958
2975
  { key: "down", desc: "Next session", preventDefault: true, cmd: () => moveCursor(1) },
@@ -3054,7 +3071,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
3054
3071
  category: "Sessions",
3055
3072
  slash: { name: "sesh" },
3056
3073
  onSelect: () => {
3057
- void openPicker()
3074
+ void openPicker(true)
3058
3075
  },
3059
3076
  },
3060
3077
  {
@@ -3064,7 +3081,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
3064
3081
  category: "Sessions",
3065
3082
  slash: { name: "sesh-costs" },
3066
3083
  onSelect: () => {
3067
- void openCosts()
3084
+ void openCosts(true)
3068
3085
  },
3069
3086
  },
3070
3087
  {
@@ -3074,7 +3091,7 @@ ORDER BY lifetime_cost DESC`).all() ?? []) as {
3074
3091
  category: "Sessions",
3075
3092
  slash: { name: "sesh-needs" },
3076
3093
  onSelect: () => {
3077
- void openNeeds()
3094
+ void openNeeds(true)
3078
3095
  },
3079
3096
  },
3080
3097
  ])
@@ -3120,7 +3137,7 @@ function adaptTheme(theme: Plugin.Context["theme"]) {
3120
3137
  }
3121
3138
  }
3122
3139
 
3123
- function createApi(ctx: Plugin.Context): TuiPluginApi {
3140
+ function createApi(ctx: Plugin.Context) {
3124
3141
  const [dialogOpen, setDialogOpen] = createSignal(false)
3125
3142
  let sourceTheme: Plugin.Context["theme"] | undefined
3126
3143
  let currentTheme: ReturnType<typeof adaptTheme>
@@ -3135,9 +3152,9 @@ function createApi(ctx: Plugin.Context): TuiPluginApi {
3135
3152
  },
3136
3153
  },
3137
3154
  ui: {
3138
- toast(opts: { message: string; variant?: "info" | "warning" | "error" | "success" }) {
3155
+ toast(opts: { title?: string; message: string; variant?: "info" | "warning" | "error" | "success" }) {
3139
3156
  ctx.ui.toast.show({
3140
- title: "Session",
3157
+ title: opts.title ?? "Sessions",
3141
3158
  message: opts.message,
3142
3159
  variant: opts.variant ?? "info",
3143
3160
  })
@@ -3147,12 +3164,18 @@ function createApi(ctx: Plugin.Context): TuiPluginApi {
3147
3164
  ctx.ui.dialog.clear()
3148
3165
  setDialogOpen(false)
3149
3166
  },
3150
- replace(render: () => any) {
3167
+ replace(render: () => any, onClose?: () => void) {
3168
+ let closed = false
3169
+ ctx.ui.dialog.show(render, () => {
3170
+ if (closed) return
3171
+ closed = true
3172
+ setDialogOpen(false)
3173
+ onClose?.()
3174
+ })
3151
3175
  setDialogOpen(true)
3152
- ctx.ui.dialog.show(render, () => setDialogOpen(false))
3153
3176
  },
3154
- setSize(size: string) {
3155
- ctx.ui.dialog.set({ size: size as any })
3177
+ setSize(size: Parameters<Plugin.Context["ui"]["dialog"]["set"]>[0]["size"]) {
3178
+ ctx.ui.dialog.set({ size })
3156
3179
  },
3157
3180
  // V2's Dialog has no reactive open/isOpen accessor; track it locally
3158
3181
  // from the replace/clear calls above and the host's onClose callback.
@@ -3224,8 +3247,7 @@ function createApi(ctx: Plugin.Context): TuiPluginApi {
3224
3247
  ctx.keymap.layer(() => ({
3225
3248
  mode: layer.mode ?? "base",
3226
3249
  priority: layer.priority ?? 20,
3227
- commands: (layer.bindings ?? []).map((b, i) => ({
3228
- id: `sesh-cmd-${Math.random().toString(36).slice(2)}-${i}`,
3250
+ commands: (layer.bindings ?? []).map((b) => ({
3229
3251
  title: b.desc,
3230
3252
  bind: b.key,
3231
3253
  run: () => {
@@ -3234,7 +3256,7 @@ function createApi(ctx: Plugin.Context): TuiPluginApi {
3234
3256
  })),
3235
3257
  }))
3236
3258
  return dispose
3237
- }))
3259
+ }))!
3238
3260
  },
3239
3261
  },
3240
3262
  slots: {
@@ -3291,20 +3313,28 @@ function createApi(ctx: Plugin.Context): TuiPluginApi {
3291
3313
  await ctx.client.session.remove({ sessionID: req.sessionID })
3292
3314
  return { data: undefined }
3293
3315
  },
3294
- async fork(req: { path: { id: string } }) {
3295
- const res: any = await ctx.client.session.fork({ sessionID: req.path.id })
3296
- return { data: res?.data ?? res }
3316
+ async fork(req: { sessionID: string; directory?: string }) {
3317
+ const res = await ctx.client.session.fork({ sessionID: req.sessionID })
3318
+ return { data: res }
3297
3319
  },
3298
- async messages(req: { path?: { id?: string }; sessionID?: string }) {
3299
- const sessionID = req.sessionID ?? req.path?.id ?? ""
3300
- const res = await ctx.client.message.list({ sessionID, order: "asc", limit: 100 })
3301
- const messages = (res.data ?? []).map((m: any) => {
3320
+ async messages(req: { sessionID: string; preview?: boolean }) {
3321
+ let cursor: string | undefined
3322
+ const all = []
3323
+ do {
3324
+ const res = await ctx.client.message.list({ sessionID: req.sessionID, order: req.preview ? "desc" : "asc", limit: 100, cursor })
3325
+ all.push(...res.data)
3326
+ const next = res.cursor.next
3327
+ if (req.preview || !next || next === cursor) break
3328
+ cursor = next
3329
+ } while (true)
3330
+ if (req.preview) all.reverse()
3331
+ const messages = all.map((m) => {
3302
3332
  let text = ""
3303
3333
  if (m.type === "user") text = m.text ?? ""
3304
3334
  else if (m.type === "assistant") {
3305
3335
  text = (m.content ?? [])
3306
- .filter((c: any) => c.type === "text")
3307
- .map((c: any) => c.text)
3336
+ .filter((c) => c.type === "text")
3337
+ .map((c) => c.text)
3308
3338
  .join("\n")
3309
3339
  }
3310
3340
  return {
@@ -3318,30 +3348,17 @@ function createApi(ctx: Plugin.Context): TuiPluginApi {
3318
3348
  },
3319
3349
  experimental: {
3320
3350
  session: {
3321
- async list(input: any) {
3322
- // V1 paged the global list with a numeric `time.updated` cursor and a
3323
- // descending order. V2 returns an opaque cursor per location, so
3324
- // fetch once and page in memory to keep the caller's loop intact.
3325
- const limit = Number(input?.limit) > 0 ? Number(input.limit) : 200
3326
- const cursor = typeof input?.cursor === "number" ? input.cursor : undefined
3327
- const res: any = await ctx.client.session.list({ limit: 5000, order: "desc" })
3328
- const all: any[] = Array.isArray(res) ? res : (res?.data ?? [])
3329
- const normalized = all
3330
- .map((s: any) => ({
3351
+ async list(input: { limit: number; cursor?: string; roots?: boolean; directory?: string }) {
3352
+ const res = await ctx.client.session.list({ parentID: null, order: "desc", limit: input.limit, cursor: input.cursor })
3353
+ return { cursor: res.cursor, data: res.data.map((s) => ({
3331
3354
  ...s,
3332
- directory: s.directory ?? s.location?.directory ?? "",
3333
- }))
3334
- .sort((a: any, b: any) => (b.time?.updated ?? 0) - (a.time?.updated ?? 0))
3335
- const page =
3336
- cursor === undefined
3337
- ? normalized.slice(0, limit)
3338
- : normalized.filter((s: any) => (s.time?.updated ?? 0) < cursor).slice(0, limit)
3339
- return { data: page }
3355
+ directory: s.location?.directory ?? "",
3356
+ })) }
3340
3357
  },
3341
3358
  },
3342
3359
  },
3343
3360
  },
3344
- } as any
3361
+ }
3345
3362
  }
3346
3363
 
3347
3364
  export default Plugin.define({
@@ -3349,7 +3366,7 @@ export default Plugin.define({
3349
3366
  async setup(ctx) {
3350
3367
  const api = createApi(ctx)
3351
3368
  let initialized = false
3352
- return ctx.ui.slot({
3369
+ const disposeSlot = ctx.ui.slot({
3353
3370
  append: "app",
3354
3371
  render: () => {
3355
3372
  if (!initialized) {
@@ -3360,5 +3377,10 @@ export default Plugin.define({
3360
3377
  return <box />
3361
3378
  },
3362
3379
  })
3380
+ return () => {
3381
+ if (api.ui.dialog.open) api.ui.dialog.clear()
3382
+ disposeSlot()
3383
+ removeRootMouse?.()
3384
+ }
3363
3385
  },
3364
3386
  })