opencode-sesh 0.3.0 → 0.5.0

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
@@ -37,10 +37,14 @@ HOME=/tmp/fakehome bash install.sh # installer smoke test (never touches ~/.co
37
37
  - `bin/` — flat; no subdirs. Scripts resolve siblings from their own location.
38
38
  Never hardcode install paths. `sesh-waiting.sh` owns the needs-input query
39
39
  (shared heuristic with the TUI sidebar copy, marked `NEEDS_INPUT_SQL` in
40
- both); `sesh-prune.sh` archives stale sessions. Reads go through
41
- `sqlite3 -readonly` (or `opencode db`); the single archive `UPDATE` in
42
- `sesh-prune.sh` is the only sanctioned direct write (no archive endpoint
43
- exists — deletes stay on `opencode session delete`).
40
+ both); `sesh-prune.sh` archives stale sessions; `sesh-costs.sh` sums
41
+ assistant-message cost per directory; `sesh-retitle.sh` renames placeholder
42
+ titles. Reads go through `sqlite3 -readonly` (or `opencode db`); the direct
43
+ `UPDATE`s in `sesh-prune.sh` (archive) and `sesh-retitle.sh` (title) are the
44
+ only sanctioned writes — neither has a CLI or API endpoint, and hard deletes
45
+ stay on `opencode session delete`.
46
+ - `contrib/` — caller-side integrations (currently cmux). Nothing here may add
47
+ window management to sesh itself.
44
48
  - `tui/sesh-panel.tsx` — SolidJS panel, `/** @jsxImportSource @opentui/solid */`.
45
49
  - `opencode/tools/sesh-list.ts` — the agent-facing `sesh-list` tool.
46
50
  - `scripts/changelog.sh` — release tooling: drafts `[Unreleased]` from
@@ -175,7 +179,9 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
175
179
  `slash: { name: "sesh" }` on the `api.command.register` entry — that is the
176
180
  only way to open the dialog. A markdown command under `commands/` cannot drive
177
181
  the picker (it just prompts the model), so never ship one; the installer
178
- removes any leftover `commands/sesh.md`.
182
+ removes any leftover `commands/sesh.md`. The command API never passes slash
183
+ arguments, so subcommands are separate registrations (`/sesh-costs`,
184
+ `/sesh-needs`) — all read-only. Destructive actions stay CLI-only.
179
185
  - Do not bind `<leader>l` (native `session_list`) or shadow the native
180
186
  `/sessions` command.
181
187
  - **The sidebar never binds arrow keys globally.** Keyboard navigation is
package/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-09-24
11
+
12
+ ### Added
13
+
14
+ - `/sesh-costs` opens the per-project cost digest inside the TUI (24h beside
15
+ lifetime, same message-level sums as `sesh costs`).
16
+ - `/sesh-needs` opens the sessions waiting on you as an openable list (Enter
17
+ opens, Esc closes). Both new slashes are read-only; prune and retitle stay
18
+ CLI-only.
19
+ ## [0.4.0] - 2026-09-24
20
+
21
+ ### Added
22
+
23
+ - `sesh costs [--days N] [--json]` sums assistant-message cost per project, with
24
+ a recent window beside the lifetime total, so a day's work reads as a
25
+ per-project record.
26
+ - `sesh retitle [--dry-run] [--yes]` replaces auto-generated placeholder titles
27
+ (`New session - …`) with the start of the session's first user message. Only
28
+ placeholder titles are touched.
29
+ - `contrib/cmux/` documents driving sesh from cmux: command-palette entries,
30
+ shortcuts, resuming a session in its own workspace, and a global "resume from
31
+ anywhere" hotkey. The glue is caller-side; sesh stays terminal-agnostic.
32
+
33
+ ### Changed
34
+
35
+ - Search now weights results: a title match outranks a transcript-only match and
36
+ the current project rises while a query is active. Pins still win outright,
37
+ and the idle list keeps pure pin+recency ordering.
10
38
  ## [0.3.0] - 2026-09-24
11
39
 
12
40
  ### Added
@@ -239,7 +267,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
239
267
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
240
268
  CI.
241
269
 
242
- [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.3.0...HEAD
270
+ [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.5.0...HEAD
271
+ [0.5.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.4.0...v0.5.0
272
+ [0.4.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.3.0...v0.4.0
243
273
  [0.3.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.2.1...v0.3.0
244
274
  [0.2.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.2.0...v0.2.1
245
275
  [0.2.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.7...v0.2.0
package/README.md CHANGED
@@ -137,6 +137,18 @@ sessions are never touched; archiving is reversible, while `--delete`
137
137
  hard-deletes through `opencode session delete`. Without `--dry-run`, a TTY run
138
138
  confirms first and a non-TTY run needs `--yes`.
139
139
 
140
+ `sesh costs [--days N] [--json]` sums assistant-message cost per project, with a
141
+ recent window beside the lifetime total, so a day's work reads as a
142
+ per-project record.
143
+
144
+ `sesh retitle [--dry-run] [--yes]` replaces auto-generated placeholder titles
145
+ (`New session - …`) with the start of the session's first user message. Only
146
+ placeholder titles are touched, and the same dry-run/`--yes` gating applies.
147
+
148
+ When searching, title matches outrank transcript-only matches and the current
149
+ project rises, so the list answers "where was that thing I worked on" before it
150
+ answers "what is newest". Pins still win outright.
151
+
140
152
  Because `--print` just emits the id and directory, `sesh` doubles as a scriptable
141
153
  session lookup:
142
154
 
@@ -153,6 +165,8 @@ non-interactive `sesh-delete.sh` refuses unless given `--yes`.
153
165
  | Key | Action |
154
166
  | --- | --- |
155
167
  | `/sesh` | Open the full picker |
168
+ | `/sesh-costs` | Per-project cost digest (24h beside lifetime) |
169
+ | `/sesh-needs` | Sessions waiting on you (Enter opens) |
156
170
  | `ctrl+o` | Open the full picker (also in the command palette) |
157
171
  | Type | Search titles, directories and transcript text |
158
172
  | `↑`/`↓`, `PgUp`/`PgDn`, `Home`/`End` | Move the selection |
@@ -195,6 +209,13 @@ All variables are optional.
195
209
  | `SESH_CACHE_DIR` | `~/.cache/sesh` | persistent search-index cache |
196
210
  | `SESH_PINS_FILE` | `${XDG_DATA_HOME:-~/.local/share}/sesh/pins.json` | shared persistent session/directory pins |
197
211
 
212
+ ## Integrations
213
+
214
+ sesh is terminal-agnostic on purpose: it has no window-management code. The
215
+ [`contrib/cmux/`](contrib/cmux/README.md) directory shows how to drive it from
216
+ cmux — command-palette entries, shortcuts, resuming a session in its own
217
+ workspace, and a global "resume from anywhere" hotkey — all caller-side.
218
+
198
219
  ## How it works
199
220
 
200
221
  `bin/sesh-list.sh` is the only `bin/` script that reads the opencode session
package/ROADMAP.md ADDED
@@ -0,0 +1,61 @@
1
+ # Roadmap
2
+
3
+ Productivity bets for sesh, roughly ordered by payoff. Status is tracked here
4
+ as items land.
5
+
6
+ ## 1. Needs-input triage (done)
7
+
8
+ **Problem:** with 100+ sessions, the bottleneck is not finding a session but
9
+ knowing which ones are *waiting on you* (unanswered agent questions, stale
10
+ runs awaiting approval) versus done versus dead. Today that means polling rows
11
+ by hand.
12
+
13
+ **Approach:** server-independent detection straight from the opencode SQLite
14
+ store, so it works for every session, not just ones owned by the current TUI
15
+ server (whose in-memory permission/question state is invisible cross-project):
16
+
17
+ - a `question` tool part that is not `completed` with no later user message in
18
+ the same session → awaiting an answer;
19
+ - a `tool` part stuck in `running` older than ~10 minutes → awaiting approval
20
+ or orphaned by a dead server.
21
+
22
+ **Surfaces:** a "Needs input" section pinned above the directory groups in the
23
+ TUI sidebar, plus a terminal listing (`sesh --needs-input`).
24
+
25
+ ## 2. Stale-session pruning (done)
26
+
27
+ **Problem:** the list grows without bound (127+ rows), which slows scanning
28
+ and bloats the transcript search index with archaeology.
29
+
30
+ **Approach:** `sesh prune --older-than 30d` archives (never deletes) sessions
31
+ older than the threshold: never pinned ones, never ones flagged as needing
32
+ input. Archive is reversible; `--delete` stays the explicit hard-delete path
33
+ through the existing two-step confirm. Interactive runs confirm with a
34
+ candidate summary; non-TTY runs require `--yes`; `--dry-run` lists only.
35
+
36
+ ## 3. sesh ↔ cmux bridge (done)
37
+
38
+ **Approach:** kept caller-side. sesh has no window-management code (a hard
39
+ repo rule), so [`contrib/cmux/`](contrib/cmux/README.md) ships the cmux
40
+ actions, shortcuts and a `--print --json` recipe that resumes a session in its
41
+ own workspace. cmux calls sesh; sesh never calls cmux.
42
+
43
+ ## 4. Per-project cost digest (done)
44
+
45
+ `sesh costs [--days N] [--json]` sums assistant-message cost per directory with
46
+ a recent window beside the lifetime total, so a day's work reads as a
47
+ per-project record. Message-level summing (not `session.cost`) keeps a
48
+ half-finished day from dragging in a session's earlier history.
49
+
50
+ ## 5. Resume from anywhere (done)
51
+
52
+ Covered by the same contrib recipes: `sesh --print --json` resolves a session
53
+ non-interactively, so a cmux action, skhd binding or launcher can resume a
54
+ specific session — or open the picker — from any context.
55
+
56
+ ## Smaller items (done)
57
+
58
+ - Search weighting: title matches outrank transcript-only matches and the
59
+ current project rises while a query is active; pins still win outright.
60
+ - `sesh retitle [--dry-run] [--yes]` replaces placeholder titles
61
+ (`New session - …`) with the start of the first user message.
package/bin/sesh CHANGED
@@ -26,5 +26,13 @@ case ${1:-} in
26
26
  shift
27
27
  exec bash "$SCRIPT_DIR/sesh-prune.sh" "$@"
28
28
  ;;
29
+ costs)
30
+ shift
31
+ exec bash "$SCRIPT_DIR/sesh-costs.sh" "$@"
32
+ ;;
33
+ retitle)
34
+ shift
35
+ exec bash "$SCRIPT_DIR/sesh-retitle.sh" "$@"
36
+ ;;
29
37
  esac
30
38
  exec "$SCRIPT_DIR/sesh.sh" "$@"
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env bash
2
+ # Per-directory cost digest: what each project spent in a recent window and
3
+ # over its lifetime, so a day's work reads as a per-project record.
4
+ #
5
+ # Cost is summed from assistant-message `cost` (not `session.cost`) so a
6
+ # partially-completed day does not drag a session's whole history into the
7
+ # window — the same reason the TUI statusline differs from `session.cost`.
8
+ #
9
+ # Usage: sesh-costs.sh [--days N] [--json]
10
+ # --days N window in days (default 1); 0 means lifetime only
11
+ # --json machine-readable rows
12
+ set -euo pipefail
13
+
14
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
+ OPENCODE_BIN=${SESH_OPENCODE:-opencode}
16
+ days=1
17
+ emit_json=0
18
+ while [ "$#" -gt 0 ]; do
19
+ case "$1" in
20
+ --days) days=${2:-}; shift ;;
21
+ --days=*) days=${1#--days=} ;;
22
+ --json) emit_json=1 ;;
23
+ --help) echo "usage: ${0##*/} [--days N] [--json]"; exit 0 ;;
24
+ *) echo "unknown option: $1" >&2; exit 2 ;;
25
+ esac
26
+ shift
27
+ done
28
+ case "$days" in ''|*[!0-9]*) echo "--days must be a non-negative integer" >&2; exit 2;; esac
29
+
30
+ resolve_db() {
31
+ if [ -n "${SESH_DB:-}" ]; then printf '%s\n' "$SESH_DB"; return 0; fi
32
+ local from_cli=''
33
+ if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
34
+ from_cli=$("$OPENCODE_BIN" db path 2>/dev/null || true)
35
+ fi
36
+ if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
37
+ if [ -f "$HOME/.local/share/opencode/opencode.db" ]; then printf '%s\n' "$HOME/.local/share/opencode/opencode.db"; return 0; fi
38
+ return 1
39
+ }
40
+
41
+ SQLITE_BIN=''
42
+ if [ -n "${SESH_SQLITE:-}" ]; then
43
+ SQLITE_BIN=$SESH_SQLITE
44
+ elif command -v sqlite3 >/dev/null 2>&1; then
45
+ SQLITE_BIN=sqlite3
46
+ fi
47
+
48
+ DB_PATH=$(resolve_db) || { echo 'sesh: session database unavailable' >&2; exit 1; }
49
+ [ -f "$DB_PATH" ] || { echo 'sesh: session database unavailable' >&2; exit 1; }
50
+
51
+ NOW_MS=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || echo "$(( $(date +%s) * 1000 ))")
52
+ CUTOFF=$((NOW_MS - days * 86400000))
53
+
54
+ # One pass over assistant messages; the window filter is a cheap CASE so the
55
+ # lifetime total and the window total come back together.
56
+ ROWS_QUERY="
57
+ SELECT s.directory AS directory,
58
+ ROUND(SUM(CASE WHEN m.time_created >= $CUTOFF THEN COALESCE(json_extract(m.data, '\$.cost'), 0) ELSE 0 END), 4) AS window_cost,
59
+ ROUND(SUM(COALESCE(json_extract(m.data, '\$.cost'), 0)), 4) AS lifetime_cost,
60
+ COUNT(DISTINCT s.id) AS sessions,
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'
65
+ AND json_extract(m.data, '\$.cost') IS NOT NULL
66
+ GROUP BY s.directory
67
+ ORDER BY lifetime_cost DESC;"
68
+
69
+ if [ -n "$SQLITE_BIN" ]; then
70
+ ROWS=$("$SQLITE_BIN" -json -readonly "$DB_PATH" "$ROWS_QUERY")
71
+ else
72
+ command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'sesh: neither sqlite3 nor opencode is available' >&2; exit 1; }
73
+ ROWS=$("$OPENCODE_BIN" db "$ROWS_QUERY" --format json)
74
+ fi
75
+ [ -n "$ROWS" ] || ROWS='[]'
76
+
77
+ if [ "$emit_json" = 1 ]; then
78
+ printf '%s\n' "$ROWS"
79
+ exit 0
80
+ fi
81
+
82
+ JQ_BIN=${SESH_JQ:-$(command -v jq || true)}
83
+ [ -n "$JQ_BIN" ] || { echo 'sesh: jq is required for the table view (or use --json)' >&2; exit 1; }
84
+
85
+ HOME_DIR=${HOME:-}
86
+ printf '%s' "$ROWS" | "$JQ_BIN" -r --arg home "$HOME_DIR" --argjson days "$days" '
87
+ def pretty: if . == null or . == "" then "other"
88
+ elif ($home != "" and startswith($home + "/")) then "~" + .[$home | length:]
89
+ else . end;
90
+ def money:
91
+ if . == 0 then "—"
92
+ else ((.*100 | round | tostring) as $c
93
+ | "$" + (if ($c | length) == 1 then "0.0" + $c
94
+ elif ($c | length) == 2 then "0." + $c
95
+ else $c[0:-2] + "." + $c[-2:] end))
96
+ end;
97
+ (map(select(.lifetime_cost > 0)) | sort_by(-.lifetime_cost)) as $rows
98
+ | if ($rows | length) == 0 then "No assistant cost recorded."
99
+ else
100
+ (["WINDOW" + (if $days == 0 then "" else "(\($days)d)" end), "LIFETIME", "SESS", "DIRECTORY"] | @tsv),
101
+ ($rows[] | [((.window_cost) | money), ((.lifetime_cost) | money), (.sessions | tostring), (.directory | pretty)] | @tsv)
102
+ end
103
+ ' | column -t -s $'\t'
package/bin/sesh-list.sh CHANGED
@@ -157,7 +157,7 @@ render() {
157
157
  {
158
158
  [ -n "$notice" ] && printf '\t\tunknown\t%s%s%s\t\tnotice:action\n' "$YELLOW" "$notice" "$RESET"
159
159
  [ -n "$state" ] && [ "$state" != fresh ] && printf '\t\tunknown\t%s%s%s\t\tnotice:live-state\n' "$YELLOW" "$state" "$RESET"
160
- "$JQ_BIN" -sr --argjson pins "$("${BASH_SOURCE[0]%/*}/sesh-pins.sh" read)" --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" '
160
+ "$JQ_BIN" -sr --argjson pins "$("${BASH_SOURCE[0]%/*}/sesh-pins.sh" read)" --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 cwd "$PWD" --arg selected "$SELECTED_ID" '
161
161
  def ago($epoch):
162
162
  ((now - ($epoch | tonumber)) | floor) as $s
163
163
  | 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;
@@ -181,11 +181,19 @@ render() {
181
181
  else empty end),
182
182
  ($matched
183
183
  | group_by(.cwd)
184
+ # Search relevance, not just recency: a title hit outranks a
185
+ # transcript-only hit, and the current project rises while a query is
186
+ # active. Both are query-gated so the idle list keeps pure pin+recency
187
+ # ordering (the scope toggle, not this, is what narrows the list).
184
188
  | map({cwd: .[0].cwd, updated: (map(.updatedEpoch) | max),
185
189
  pinDirectory: (.[0].cwd as $dir | $pins.directories | index($dir) != null),
186
190
  pinSession: (any(.[]; .sessionId as $id | $pins.sessions | index($id) != null)),
187
- items: (sort_by([(.sessionId as $id | $pins.sessions | index($id) != null), .updatedEpoch]) | reverse)})
188
- | sort_by([.pinDirectory, .pinSession, .updated]) | reverse
191
+ current: (($q != "") and (.[0].cwd == $cwd)),
192
+ titleHit: (($q != "") and any(.[]; (.title | ascii_downcase | contains($q)))),
193
+ items: (sort_by([(.sessionId as $id | $pins.sessions | index($id) != null),
194
+ (if $q != "" then (.title | ascii_downcase | contains($q)) else false end),
195
+ .updatedEpoch]) | reverse)})
196
+ | sort_by([.pinDirectory, .pinSession, .current, .titleHit, .updated]) | reverse
189
197
  | .[] as $g
190
198
  | (["", "", "unknown", ($magenta + (if $g.pinDirectory then "★ " else "" end) + ($g.cwd | homepath | gsub("[\u0000-\u001f\u007f-\u009f]";" ")) + $reset), $g.cwd, ("hdr:" + $g.cwd)] | @tsv),
191
199
  ($g.items | to_entries[] | .key as $i | .value as $s
@@ -0,0 +1,141 @@
1
+ #!/usr/bin/env bash
2
+ # Give auto-generated session titles a readable first line.
3
+ #
4
+ # opencode titles most sessions itself; a few keep their placeholder
5
+ # ("New session - <ISO>") because they ended before a title was derived. This
6
+ # replaces just those placeholders with the start of the session's first user
7
+ # message, so the row scans like the rest of the list.
8
+ #
9
+ # It only ever touches titles matching the placeholder pattern, and the value
10
+ # it overwrites carries no information (a timestamp), so nothing recoverable is
11
+ # lost. The write is a single direct UPDATE through the same sqlite3-or-
12
+ # `opencode db` path as `sesh prune` — opencode exposes no title endpoint in
13
+ # the CLI, so a direct write is the only route.
14
+ #
15
+ # Usage: sesh-retitle.sh [--dry-run] [--yes] [--max-words N]
16
+ set -euo pipefail
17
+
18
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
19
+ OPENCODE_BIN=${SESH_OPENCODE:-opencode}
20
+ dry_run=0
21
+ assume_yes=0
22
+ max_words=8
23
+ while [ "$#" -gt 0 ]; do
24
+ case "$1" in
25
+ --dry-run) dry_run=1 ;;
26
+ --yes|-y) assume_yes=1 ;;
27
+ --max-words) max_words=${2:-}; shift ;;
28
+ --max-words=*) max_words=${1#--max-words=} ;;
29
+ --help) echo "usage: ${0##*/} [--dry-run] [--yes] [--max-words N]"; exit 0 ;;
30
+ *) echo "unknown option: $1" >&2; exit 2 ;;
31
+ esac
32
+ shift
33
+ done
34
+ case "$max_words" in ''|*[!0-9]*) echo "--max-words must be a positive integer" >&2; exit 2;; esac
35
+ [ "$max_words" -gt 0 ] || { echo "--max-words must be positive" >&2; exit 2; }
36
+
37
+ resolve_db() {
38
+ if [ -n "${SESH_DB:-}" ]; then printf '%s\n' "$SESH_DB"; return 0; fi
39
+ local from_cli=''
40
+ if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
41
+ from_cli=$("$OPENCODE_BIN" db path 2>/dev/null || true)
42
+ fi
43
+ if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
44
+ if [ -f "$HOME/.local/share/opencode/opencode.db" ]; then printf '%s\n' "$HOME/.local/share/opencode/opencode.db"; return 0; fi
45
+ return 1
46
+ }
47
+
48
+ SQLITE_BIN=''
49
+ if [ -n "${SESH_SQLITE:-}" ]; then
50
+ SQLITE_BIN=$SESH_SQLITE
51
+ elif command -v sqlite3 >/dev/null 2>&1; then
52
+ SQLITE_BIN=sqlite3
53
+ fi
54
+
55
+ DB_PATH=$(resolve_db) || { echo 'sesh: session database unavailable' >&2; exit 1; }
56
+ [ -f "$DB_PATH" ] || { echo 'sesh: session database unavailable' >&2; exit 1; }
57
+
58
+ if [ -n "$SQLITE_BIN" ]; then
59
+ query() { "$SQLITE_BIN" -json -readonly "$DB_PATH" "$1"; }
60
+ write_db() { "$SQLITE_BIN" "$DB_PATH" "$1"; }
61
+ else
62
+ command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'sesh: neither sqlite3 nor opencode is available' >&2; exit 1; }
63
+ query() { "$OPENCODE_BIN" db "$1" --format json; }
64
+ write_db() { "$OPENCODE_BIN" db "$1" --format json > /dev/null; }
65
+ fi
66
+
67
+ JQ_BIN=${SESH_JQ:-$(command -v jq || true)}
68
+ [ -n "$JQ_BIN" ] || { echo 'sesh: jq is required' >&2; exit 1; }
69
+
70
+ # The first user text part per placeholder-titled session, cleaned into a
71
+ # candidate: image chips and markdown noise dropped, whitespace collapsed,
72
+ # first `max_words` words kept.
73
+ CANDIDATES=$(query "
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
82
+ WHERE s.title LIKE 'New session - %'
83
+ OR TRIM(COALESCE(s.title, '')) = ''
84
+ ORDER BY s.time_updated DESC;")
85
+ [ -n "$CANDIDATES" ] || CANDIDATES='[]'
86
+
87
+ PLAN=$(printf '%s' "$CANDIDATES" | "$JQ_BIN" -c --argjson words "$max_words" '
88
+ def derive:
89
+ (.first_part // "")
90
+ | (try (fromjson | .text) catch "")
91
+ | gsub("\\[Image #[0-9]+\\]"; " ")
92
+ | gsub("[#*_`>]"; " ")
93
+ | gsub("\\s+"; " ")
94
+ | sub("^ +"; "") | sub(" +$"; "")
95
+ | (split(" ") | map(select(. != "")))
96
+ | (if length == 0 then "" else .[0:$words] | join(" ") end)
97
+ | if length > 60 then .[0:57] + "..." else . end;
98
+ [ .[] | .new_title = derive | select(.new_title != "") ]')
99
+ COUNT=$(printf '%s' "$PLAN" | "$JQ_BIN" -r 'length')
100
+
101
+ if [ "$COUNT" = 0 ]; then
102
+ echo "No placeholder-titled sessions with a usable first message."
103
+ exit 0
104
+ fi
105
+
106
+ printf '%s' "$PLAN" | "$JQ_BIN" -r '
107
+ .[] | "\(.id)\t\(.new_title)\t\(.old_title)"' | while IFS=$'\t' read -r id new old; do
108
+ printf '%s\n \033[90m%s\033[0m\n → %s\n' "$id" "$old" "$new"
109
+ done
110
+
111
+ if [ "$dry_run" = 1 ]; then
112
+ printf 'Retitle %s session(s) (--dry-run: nothing changed).\n' "$COUNT" >&2
113
+ exit 0
114
+ fi
115
+
116
+ if [ "$assume_yes" = 0 ]; then
117
+ if [ -t 0 ]; then
118
+ printf 'Retitle %s session(s)? [y/N] ' "$COUNT"
119
+ reply=''
120
+ IFS= read -r -n 1 reply || reply=''
121
+ printf '\n'
122
+ case "$reply" in
123
+ y|Y) ;;
124
+ *) echo 'Cancelled; nothing was changed.'; exit 0 ;;
125
+ esac
126
+ else
127
+ echo "Refusing to retitle $COUNT session(s): pass --yes when stdin is not a terminal." >&2
128
+ exit 2
129
+ fi
130
+ fi
131
+
132
+ # Escape single quotes for the SQL string literal; ids are validated by shape.
133
+ sql_escape() { printf '%s' "$1" | sed "s/'/''/g"; }
134
+ printf '%s' "$PLAN" | "$JQ_BIN" -r '.[] | [.id, .new_title] | @tsv' | while IFS=$'\t' read -r id new; do
135
+ case "$id" in
136
+ ses_[A-Za-z0-9]*) ;;
137
+ *) echo "Refusing: unexpected session id shape." >&2; exit 1 ;;
138
+ esac
139
+ write_db "UPDATE session SET title = '$(sql_escape "$new")' WHERE id = '$id' AND (title LIKE 'New session - %' OR TRIM(COALESCE(title, '')) = '');"
140
+ done
141
+ echo "Retitled $COUNT session(s)."
@@ -0,0 +1,59 @@
1
+ # sesh ↔ cmux
2
+
3
+ Optional glue for running sesh inside [cmux](https://cmux.dev). Nothing here is
4
+ bundled into the picker: sesh has no window-management code, and these recipes
5
+ keep it that way. cmux calls sesh; sesh never calls cmux.
6
+
7
+ ## Command palette + shortcuts
8
+
9
+ Merge [`cmux.json.snippet`](cmux.json.snippet) into `~/.config/cmux/cmux.json`
10
+ (or `.cmux/cmux.json` in a project), then `cmux reload-config` (Cmd+Shift+,).
11
+ You get:
12
+
13
+ | Action | Shortcut | What it does |
14
+ | --- | --- | --- |
15
+ | `sesh.picker` | Cmd+Shift+S | Open the picker in a new tab |
16
+ | `sesh.needs` | Cmd+Shift+I | List sessions waiting on you |
17
+ | `sesh.costs` | — | Per-project spend digest |
18
+
19
+ Paste only the actions you want; the `ui.newWorkspace` block is optional.
20
+
21
+ ## Resume a session in its own cmux workspace
22
+
23
+ The picker resumes in place (it `cd`s and `exec`s opencode). To instead open a
24
+ session in a **new cmux workspace** at its directory, shell out to cmux after
25
+ resolving the session with sesh's scriptable lookup:
26
+
27
+ ```bash
28
+ sesh --print --json --query "auth refactor" \
29
+ | jq -r '[.sessionId, .cwd] | @tsv' \
30
+ | while IFS=$'\t' read -r id cwd; do
31
+ cmux new-workspace --cwd "$cwd" --command "opencode --session $id"
32
+ done
33
+ ```
34
+
35
+ Wrap that in a cmux `command` action (as above) or a shell function; it is
36
+ caller-side on purpose, so sesh stays terminal-agnostic.
37
+
38
+ ## Needs-input triage from cmux
39
+
40
+ `sesh --needs-input` prints (or `--json` emits) the sessions with unanswered
41
+ agent questions or runs stuck mid-tool. Bind it to a shortcut and it becomes
42
+ the "what is blocked on me right now" view without opening the picker.
43
+
44
+ ## Global hotkey (resume from anywhere)
45
+
46
+ sesh is a normal CLI, so any system-wide launcher works — a cmux `shortcut`, an
47
+ `skhd` binding, a Raycast/Hammerspoon script:
48
+
49
+ ```bash
50
+ # skhd example: Cmd+Shift+S opens the picker in a new cmux workspace
51
+ cmd + shift - s : cmux new-workspace --command "sesh"
52
+ ```
53
+
54
+ Because `--print --json` resolves a session non-interactively, the same hook can
55
+ resume a specific session without any UI:
56
+
57
+ ```bash
58
+ cmd + alt - r : cmux new-workspace --command "opencode --session $(sesh --print --json --limit 1 | jq -r .sessionId)"
59
+ ```
@@ -0,0 +1,38 @@
1
+ {
2
+ "actions": {
3
+ "sesh.picker": {
4
+ "type": "command",
5
+ "title": "sesh — session picker",
6
+ "subtitle": "Fuzzy-find and resume any opencode session",
7
+ "command": "sesh",
8
+ "target": "newTabInCurrentPane",
9
+ "palette": true,
10
+ "shortcut": "cmd+shift+s"
11
+ },
12
+ "sesh.needs": {
13
+ "type": "command",
14
+ "title": "sesh — needs input",
15
+ "subtitle": "Sessions waiting on you (questions, stuck runs)",
16
+ "command": "sesh --needs-input",
17
+ "target": "newTabInCurrentPane",
18
+ "palette": true,
19
+ "shortcut": "cmd+shift+i"
20
+ },
21
+ "sesh.costs": {
22
+ "type": "command",
23
+ "title": "sesh — cost digest",
24
+ "subtitle": "Spend per project, today and lifetime",
25
+ "command": "sesh costs",
26
+ "target": "newTabInCurrentPane",
27
+ "palette": true
28
+ }
29
+ },
30
+ "ui": {
31
+ "newWorkspace": {
32
+ "contextMenu": [
33
+ { "action": "sesh.picker", "title": "Resume a session" },
34
+ { "type": "separator" }
35
+ ]
36
+ }
37
+ }
38
+ }
package/install.sh CHANGED
@@ -235,9 +235,11 @@ for path in \
235
235
  bin/sesh.sh \
236
236
  bin/sesh-list.sh \
237
237
  bin/sesh-preview.sh \
238
+ bin/sesh-costs.sh \
238
239
  bin/sesh-delete.sh \
239
240
  bin/sesh-prune.sh \
240
241
  bin/sesh-refresh-worker.sh \
242
+ bin/sesh-retitle.sh \
241
243
  bin/sesh-waiting.sh \
242
244
  bin/sesh-shortcuts.sh \
243
245
  opencode/tools/sesh-list.ts \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
6
6
  "license": "MIT",
@@ -26,12 +26,14 @@
26
26
  },
27
27
  "files": [
28
28
  "bin/",
29
+ "contrib/",
29
30
  "opencode/",
30
31
  "themes/",
31
32
  "tui/",
32
33
  "install.sh",
33
34
  "AGENTS.md",
34
- "CHANGELOG.md"
35
+ "CHANGELOG.md",
36
+ "ROADMAP.md"
35
37
  ],
36
38
  "publishConfig": {
37
39
  "access": "public"
@@ -1906,6 +1906,249 @@ const tui: TuiPlugin = async (api) => {
1906
1906
  api.ui.dialog.setSize("xlarge")
1907
1907
  }
1908
1908
 
1909
+ // Read-only digest dialog: spend per project, last day beside lifetime.
1910
+ // Same message-level sums as `sesh costs`; the dialog just renders them.
1911
+ const openCosts = async () => {
1912
+ if (api.mode.current() !== BASE_MODE) return
1913
+ if (api.renderer.currentFocusedEditor === null) return
1914
+ type CostRow = { directory: string; window: number; lifetime: number; sessions: number }
1915
+ let rows: CostRow[] = []
1916
+ let failed = false
1917
+ try {
1918
+ const db = await openTranscriptDb()
1919
+ if (!db) failed = true
1920
+ else {
1921
+ try {
1922
+ const cutoff = Date.now() - 86400000
1923
+ const raw = (db.query(`
1924
+ SELECT s.directory AS directory,
1925
+ ROUND(SUM(CASE WHEN m.time_created >= ${cutoff} THEN COALESCE(json_extract(m.data, '$.cost'), 0) ELSE 0 END), 4) AS window_cost,
1926
+ ROUND(SUM(COALESCE(json_extract(m.data, '$.cost'), 0)), 4) AS lifetime_cost,
1927
+ COUNT(DISTINCT s.id) AS sessions
1928
+ FROM message m
1929
+ JOIN session s ON s.id = m.session_id
1930
+ WHERE json_extract(m.data, '$.role') = 'assistant'
1931
+ AND json_extract(m.data, '$.cost') IS NOT NULL
1932
+ GROUP BY s.directory
1933
+ ORDER BY lifetime_cost DESC`).all() ?? []) as {
1934
+ directory: unknown
1935
+ window_cost: unknown
1936
+ lifetime_cost: unknown
1937
+ sessions: unknown
1938
+ }[]
1939
+ rows = raw
1940
+ .filter(
1941
+ (
1942
+ row,
1943
+ ): row is {
1944
+ directory: string
1945
+ window_cost: number
1946
+ lifetime_cost: number
1947
+ sessions: number
1948
+ } =>
1949
+ typeof row?.directory === "string" &&
1950
+ typeof row?.window_cost === "number" &&
1951
+ typeof row?.lifetime_cost === "number" &&
1952
+ typeof row?.sessions === "number" &&
1953
+ row.lifetime_cost > 0,
1954
+ )
1955
+ .map((row) => ({
1956
+ directory: row.directory,
1957
+ window: row.window_cost,
1958
+ lifetime: row.lifetime_cost,
1959
+ sessions: row.sessions,
1960
+ }))
1961
+ } finally {
1962
+ db.close()
1963
+ }
1964
+ }
1965
+ } catch {
1966
+ failed = true
1967
+ }
1968
+ const money = (n: number) => (n === 0 ? "—" : `$${n.toFixed(2)}`)
1969
+ const home = process.env.HOME ?? ""
1970
+ const disposeKeys = api.keymap.registerLayer({
1971
+ bindings: [
1972
+ { key: "escape", desc: "Close", preventDefault: true, cmd: () => api.ui.dialog.clear() },
1973
+ ],
1974
+ })
1975
+ api.ui.dialog.replace(
1976
+ () => (
1977
+ <box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1}>
1978
+ <text attributes={TextAttributes.BOLD}>
1979
+ Cost digest{" "}
1980
+ <span style={{ fg: api.theme.current.textMuted }}>(24h · lifetime)</span>
1981
+ </text>
1982
+ <Show
1983
+ when={!failed && rows.length > 0}
1984
+ fallback={
1985
+ <text style={{ fg: api.theme.current.textMuted }}>
1986
+ {failed ? "Cost data unavailable." : "No assistant cost recorded."}
1987
+ </text>
1988
+ }
1989
+ >
1990
+ <For each={rows}>
1991
+ {(row) => (
1992
+ <box flexDirection="row" gap={2}>
1993
+ <text flexShrink={0} style={{ fg: api.theme.current.success }}>
1994
+ {money(row.window)}
1995
+ </text>
1996
+ <text flexShrink={0} style={{ fg: api.theme.current.textMuted }}>
1997
+ {money(row.lifetime)}
1998
+ </text>
1999
+ <text flexShrink={0} style={{ fg: api.theme.current.textMuted }}>
2000
+ ({row.sessions})
2001
+ </text>
2002
+ <text flexGrow={1} flexShrink={1} wrapMode="none">
2003
+ {prettyDir(row.directory, home)}
2004
+ </text>
2005
+ </box>
2006
+ )}
2007
+ </For>
2008
+ </Show>
2009
+ <box flexShrink={0}>
2010
+ <text style={{ fg: api.theme.current.textMuted }}>esc close</text>
2011
+ </box>
2012
+ </box>
2013
+ ),
2014
+ () => {
2015
+ disposeKeys()
2016
+ },
2017
+ )
2018
+ api.ui.dialog.setSize("large")
2019
+ }
2020
+
2021
+ // Triage dialog: the waiting set as an openable list. Enter opens the
2022
+ // highlighted session; the sidebar section stays the always-visible view.
2023
+ const openNeeds = async () => {
2024
+ if (api.mode.current() !== BASE_MODE) return
2025
+ if (api.renderer.currentFocusedEditor === null) return
2026
+ let items: { entry: Entry; reason: string }[] = []
2027
+ try {
2028
+ const { entries } = await fetchEntries(api)
2029
+ const db = await openTranscriptDb()
2030
+ let waiting = new Map<string, string>()
2031
+ if (db) {
2032
+ try {
2033
+ waiting = await queryWaitingIds(db)
2034
+ } finally {
2035
+ db.close()
2036
+ }
2037
+ }
2038
+ items = entries
2039
+ .filter((entry) => waiting.has(entry.id))
2040
+ .map((entry) => ({
2041
+ entry,
2042
+ reason: waiting.get(entry.id) === "question" ? "awaiting answer" : "run stuck",
2043
+ }))
2044
+ } catch {
2045
+ api.ui.toast({ message: "Could not load sessions", variant: "error" })
2046
+ return
2047
+ }
2048
+ if (items.length === 0) {
2049
+ api.ui.toast({ message: "No sessions are waiting on you.", variant: "info" })
2050
+ return
2051
+ }
2052
+ const [cursor, setCursor] = createSignal(0)
2053
+ const moveCursor = (delta: number) =>
2054
+ setCursor((value) => Math.max(0, Math.min(items.length - 1, value + delta)))
2055
+ const choose = () => {
2056
+ const target = items[cursor()]
2057
+ if (!target) return
2058
+ disposeNav()
2059
+ api.ui.dialog.clear()
2060
+ api.route.navigate("session", { sessionID: target.entry.id })
2061
+ }
2062
+ const disposeNav = api.keymap.registerLayer({
2063
+ bindings: [
2064
+ { key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
2065
+ { key: "down", desc: "Next session", preventDefault: true, cmd: () => moveCursor(1) },
2066
+ { key: "enter", desc: "Open session", preventDefault: true, cmd: () => choose() },
2067
+ {
2068
+ key: "escape",
2069
+ desc: "Close",
2070
+ preventDefault: true,
2071
+ cmd: () => api.ui.dialog.clear(),
2072
+ },
2073
+ ],
2074
+ })
2075
+ const home = process.env.HOME ?? ""
2076
+ api.ui.dialog.replace(
2077
+ () => (
2078
+ <box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1}>
2079
+ <text attributes={TextAttributes.BOLD}>
2080
+ Needs input{" "}
2081
+ <span style={{ fg: api.theme.current.textMuted }}>({items.length})</span>
2082
+ </text>
2083
+ <box flexDirection="column">
2084
+ <For each={items}>
2085
+ {(item, index) => (
2086
+ <box
2087
+ flexDirection="row"
2088
+ gap={2}
2089
+ paddingLeft={1}
2090
+ paddingRight={1}
2091
+ backgroundColor={
2092
+ index() === cursor()
2093
+ ? api.theme.current.primary
2094
+ : RGBA.fromInts(0, 0, 0, 0)
2095
+ }
2096
+ onMouseDown={() => {
2097
+ setCursor(index())
2098
+ choose()
2099
+ }}
2100
+ >
2101
+ <text
2102
+ flexGrow={1}
2103
+ flexShrink={1}
2104
+ wrapMode="none"
2105
+ style={{
2106
+ fg:
2107
+ index() === cursor()
2108
+ ? api.theme.current.selectedListItemText
2109
+ : api.theme.current.text,
2110
+ }}
2111
+ >
2112
+ {truncate(item.entry.title, 48)}
2113
+ </text>
2114
+ <text
2115
+ flexShrink={0}
2116
+ style={{
2117
+ fg:
2118
+ index() === cursor()
2119
+ ? api.theme.current.selectedListItemText
2120
+ : api.theme.current.warning,
2121
+ }}
2122
+ >
2123
+ {item.reason}
2124
+ </text>
2125
+ <text
2126
+ flexShrink={0}
2127
+ style={{
2128
+ fg:
2129
+ index() === cursor()
2130
+ ? api.theme.current.selectedListItemText
2131
+ : api.theme.current.textMuted,
2132
+ }}
2133
+ >
2134
+ {prettyDir(item.entry.dir, home)} · {ago(item.entry.updated)}
2135
+ </text>
2136
+ </box>
2137
+ )}
2138
+ </For>
2139
+ </box>
2140
+ <box flexShrink={0}>
2141
+ <text style={{ fg: api.theme.current.textMuted }}>↑↓ navigate · enter open · esc close</text>
2142
+ </box>
2143
+ </box>
2144
+ ),
2145
+ () => {
2146
+ disposeNav()
2147
+ },
2148
+ )
2149
+ api.ui.dialog.setSize("large")
2150
+ }
2151
+
1909
2152
  api.command?.register(() => [
1910
2153
  {
1911
2154
  title: "Pick session (all directories)",
@@ -1917,6 +2160,26 @@ const tui: TuiPlugin = async (api) => {
1917
2160
  void openPicker()
1918
2161
  },
1919
2162
  },
2163
+ {
2164
+ title: "Cost digest (per project)",
2165
+ value: "sesh.costs",
2166
+ description: "Spend per project, last day and lifetime",
2167
+ category: "Sessions",
2168
+ slash: { name: "sesh-costs" },
2169
+ onSelect: () => {
2170
+ void openCosts()
2171
+ },
2172
+ },
2173
+ {
2174
+ title: "Sessions waiting on you",
2175
+ value: "sesh.needs",
2176
+ description: "Unanswered questions and stuck runs",
2177
+ category: "Sessions",
2178
+ slash: { name: "sesh-needs" },
2179
+ onSelect: () => {
2180
+ void openNeeds()
2181
+ },
2182
+ },
1920
2183
  ])
1921
2184
 
1922
2185
  api.keymap.registerLayer({