opencode-sesh 1.0.8 → 2.0.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 +10 -10
- package/CHANGELOG.md +18 -0
- package/README.md +12 -14
- package/bin/sesh +0 -4
- package/bin/sesh-costs.sh +26 -16
- package/bin/sesh-list.sh +9 -20
- package/bin/sesh-preview.sh +6 -13
- package/bin/sesh-prune.sh +24 -56
- package/bin/sesh-waiting.sh +20 -18
- package/bin/sesh.sh +13 -4
- package/install.sh +5 -6
- package/package.json +4 -5
- package/plugins/sesh-list.ts +14 -4
- package/tui-plugins/sesh-panel/package.json +1 -2
- package/tui-plugins/sesh-panel/tui.tsx +59 -26
- package/ROADMAP.md +0 -60
- package/bin/sesh-retitle.sh +0 -139
package/AGENTS.md
CHANGED
|
@@ -39,12 +39,10 @@ HOME=/tmp/fakehome bash install.sh # installer smoke test (never touches ~/.co
|
|
|
39
39
|
- `bin/` — flat; no subdirs. Scripts resolve siblings from their own location.
|
|
40
40
|
Never hardcode install paths. `sesh-waiting.sh` owns the needs-input query
|
|
41
41
|
(shared heuristic with the TUI sidebar copy, marked `NEEDS_INPUT_SQL` in
|
|
42
|
-
both); `sesh-prune.sh`
|
|
43
|
-
assistant-message cost per directory
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
only sanctioned writes — neither has a CLI or API endpoint, and hard deletes
|
|
47
|
-
stay on `opencode session delete`.
|
|
42
|
+
both); `sesh-prune.sh` deletes stale sessions; `sesh-costs.sh` sums
|
|
43
|
+
assistant-message cost per directory. Reads go through `sqlite3 -readonly`
|
|
44
|
+
(required; `opencode db` no longer exists). Sesh never writes the database
|
|
45
|
+
directly; deletes go through `opencode session delete`.
|
|
48
46
|
- `contrib/` — caller-side integrations (currently cmux). Nothing here may add
|
|
49
47
|
window management to sesh itself.
|
|
50
48
|
- `tui-plugins/sesh-panel/tui.tsx` — SolidJS panel, `/** @jsxImportSource @opentui/solid */`.
|
|
@@ -105,19 +103,21 @@ which is a pure snapshot lookup — and prints a progress line to stderr while i
|
|
|
105
103
|
waits, because fzf has already exited and the terminal is idle. opencode has no
|
|
106
104
|
live-agent API, so `liveState` is always `unknown` and rows render gray unless
|
|
107
105
|
they are archived (opt-in, tagged `· archived`);
|
|
108
|
-
dispatch is always `opencode --session <id
|
|
106
|
+
dispatch is always `opencode --session <id>`; Ctrl-F or `--fork` first creates the
|
|
107
|
+
fork with `opencode api session.fork` (the CLI has no fork flag) and resumes the new id.
|
|
109
108
|
Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
110
109
|
|
|
111
110
|
## Conventions
|
|
112
111
|
|
|
113
112
|
- Default scope is **all** sessions, every directory. `--cwd`/Ctrl-G narrows.
|
|
114
|
-
Archived sessions and child sessions (
|
|
113
|
+
Archived sessions and child sessions (spawned subagents) are
|
|
115
114
|
hidden unless `--archived`; metadata and cache pruning still see them.
|
|
116
115
|
- **Text parts only** in search/preview; reasoning and tool payloads are
|
|
117
116
|
excluded (asserted in tests).
|
|
118
117
|
- Validate session ids (`^ses_[A-Za-z0-9]+$`) before SQL interpolation — never
|
|
119
118
|
interpolate unvalidated input. Open SQLite read-only (`sqlite3 -readonly`).
|
|
120
|
-
-
|
|
119
|
+
- Resolve the database as `SESH_DB`, then `$XDG_DATA_HOME/opencode/opencode.db`, then the
|
|
120
|
+
`db` line of `opencode debug paths`. Never parse
|
|
121
121
|
`sqlite3 -json` in `-R` mode; it pretty-prints — parse as JSON.
|
|
122
122
|
- `jq -R` output stays JSON-quoted: use `-Rr` when a shell variable needs the
|
|
123
123
|
raw string.
|
|
@@ -129,7 +129,7 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
|
129
129
|
- Destructive actions confirm. `sesh-delete.sh` prompts on a TTY and refuses
|
|
130
130
|
without `--yes` when stdin is not one; the TUI arms on the first Option-D and
|
|
131
131
|
commits on the second (or `y`).
|
|
132
|
-
- The installer prefers the local opencode version for `@opencode
|
|
132
|
+
- The installer prefers the local opencode version for `@opencode/*` and
|
|
133
133
|
merges (never clobbers) the config `package.json`; opencode runs `bun install`
|
|
134
134
|
at startup.
|
|
135
135
|
- Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/)
|
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,22 @@ 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
|
+
## [2.0.0] - 2026-10-10
|
|
9
|
+
|
|
10
|
+
### Breaking changes
|
|
11
|
+
|
|
12
|
+
- Finish V2 session compatibility and prune semantics _(breaking)_
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- **tui:** Align sidebar search with Sessions heading
|
|
17
|
+
|
|
18
|
+
## [1.1.0] - 2026-10-09
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Align database access, fork and writes with the OpenCode V2 CLI
|
|
23
|
+
|
|
8
24
|
## [1.0.8] - 2026-10-08
|
|
9
25
|
|
|
10
26
|
### Fixed
|
|
@@ -466,6 +482,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
466
482
|
- Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
|
|
467
483
|
CI.
|
|
468
484
|
|
|
485
|
+
[2.0.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.1.0...v2.0.0
|
|
486
|
+
[1.1.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.8...v1.1.0
|
|
469
487
|
[1.0.8]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.7...v1.0.8
|
|
470
488
|
[1.0.7]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.6...v1.0.7
|
|
471
489
|
[1.0.6]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.5...v1.0.6
|
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ grouped by directory, full-text searchable, and previewable from anywhere.
|
|
|
19
19
|
|
|
20
20
|
```text
|
|
21
21
|
Sessions (8) ▾ hide
|
|
22
|
-
|
|
22
|
+
search…
|
|
23
23
|
▾ …/Projects/portal (5)
|
|
24
24
|
├○ Retry the OAuth token refr… 2h
|
|
25
25
|
├○ Login rate limiting notes 3d
|
|
@@ -100,8 +100,7 @@ option+o # inside opencode — picker
|
|
|
100
100
|
|
|
101
101
|
- [opencode](https://opencode.ai), used at least once
|
|
102
102
|
- `bash`, `jq`, and [`fzf`](https://github.com/junegunn/fzf) `>= 0.73`
|
|
103
|
-
- `sqlite3`
|
|
104
|
-
milliseconds and falls back to the slower `opencode db` CLI without it
|
|
103
|
+
- `sqlite3` — the picker queries the session DB directly
|
|
105
104
|
- [`glow`](https://github.com/charmbracelet/glow) optional — styles the
|
|
106
105
|
transcript preview as Markdown; without it the preview shows the plain
|
|
107
106
|
Markdown, unchanged otherwise
|
|
@@ -138,20 +137,19 @@ Flags: `--cwd` (current directory only), `--limit N` (default: all),
|
|
|
138
137
|
`--json` (with `--print`, emit JSON), `--query TEXT`, `--check`,
|
|
139
138
|
`--needs-input` (list sessions waiting on you instead of opening the picker).
|
|
140
139
|
|
|
141
|
-
`sesh prune [--older-than 30d] [--dry-run] [--yes]
|
|
142
|
-
sessions (not updated within the threshold)
|
|
143
|
-
Pinned sessions, sessions waiting on you,
|
|
144
|
-
sessions are never
|
|
145
|
-
hard-deletes through `opencode session delete`. Without `--dry-run`, a TTY run
|
|
140
|
+
`sesh prune [--older-than 30d] [--dry-run] [--yes]` deletes stale
|
|
141
|
+
sessions (not updated within the threshold) through `opencode session delete`, including their children.
|
|
142
|
+
Pinned sessions, sessions waiting on you, subagent child sessions and already-archived
|
|
143
|
+
sessions are never selected. Without `--dry-run`, a TTY run
|
|
146
144
|
confirms first and a non-TTY run needs `--yes`.
|
|
147
145
|
|
|
148
146
|
`sesh costs [--days N] [--json]` sums assistant-message cost per project, with a
|
|
149
147
|
recent window beside the lifetime total, so a day's work reads as a
|
|
150
|
-
per-project record.
|
|
148
|
+
per-project record. Legacy V1 history is included when available; sessions
|
|
149
|
+
already present in V2 are counted only once.
|
|
151
150
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
placeholder titles are touched, and the same dry-run/`--yes` gating applies.
|
|
151
|
+
Session browsing uses the V2 store. Sessions remaining only in the legacy V1
|
|
152
|
+
tables are not listed or resumable through sesh.
|
|
155
153
|
|
|
156
154
|
When searching, title matches outrank transcript-only matches and the current
|
|
157
155
|
project rises, so the list answers "where was that thing I worked on" before it
|
|
@@ -230,8 +228,8 @@ All variables are optional.
|
|
|
230
228
|
|
|
231
229
|
| Variable | Default | Effect |
|
|
232
230
|
| --- | --- | --- |
|
|
233
|
-
| `SESH_DB` | `opencode
|
|
234
|
-
| `SESH_SQLITE` | `sqlite3` | query tool
|
|
231
|
+
| `SESH_DB` | `${XDG_DATA_HOME:-~/.local/share}/opencode/opencode.db`, falling back to `opencode debug paths` | opencode SQLite database |
|
|
232
|
+
| `SESH_SQLITE` | `sqlite3` | query tool |
|
|
235
233
|
| `SESH_JQ` / `SESH_FZF` | `PATH` | explicit executable overrides |
|
|
236
234
|
| `SESH_GLOW` | first `glow` on `PATH` | optional Markdown preview renderer |
|
|
237
235
|
| `SESH_OPENCODE` | `opencode` | opencode executable (resume / delete) |
|
package/bin/sesh
CHANGED
package/bin/sesh-costs.sh
CHANGED
|
@@ -29,12 +29,12 @@ case "$days" in ''|*[!0-9]*) echo "--days must be a non-negative integer" >&2; e
|
|
|
29
29
|
|
|
30
30
|
resolve_db() {
|
|
31
31
|
if [ -n "${SESH_DB:-}" ]; then printf '%s\n' "$SESH_DB"; return 0; fi
|
|
32
|
-
local from_cli=''
|
|
32
|
+
local default="${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db" from_cli=''
|
|
33
|
+
if [ -f "$default" ]; then printf '%s\n' "$default"; return 0; fi
|
|
33
34
|
if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
|
|
34
|
-
from_cli=$("$OPENCODE_BIN"
|
|
35
|
+
from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { sub(/^db[ \t]+/, ""); print; exit }' || true)
|
|
35
36
|
fi
|
|
36
37
|
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
38
|
return 1
|
|
39
39
|
}
|
|
40
40
|
|
|
@@ -51,27 +51,37 @@ DB_PATH=$(resolve_db) || { echo 'sesh: session database unavailable' >&2; exit 1
|
|
|
51
51
|
NOW_MS=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || echo "$(( $(date +%s) * 1000 ))")
|
|
52
52
|
CUTOFF=$((NOW_MS - days * 86400000))
|
|
53
53
|
|
|
54
|
+
[ -n "$SQLITE_BIN" ] || { echo 'sesh: sqlite3 is required' >&2; exit 1; }
|
|
55
|
+
LEGACY_QUERY=''
|
|
56
|
+
if [ "$("$SQLITE_BIN" -readonly "$DB_PATH" "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name IN ('session', 'message');")" = 2 ]; then
|
|
57
|
+
LEGACY_QUERY="UNION ALL
|
|
58
|
+
SELECT s.directory, s.id AS session_id, m.time_created, m.data
|
|
59
|
+
FROM message m JOIN session s ON s.id = m.session_id
|
|
60
|
+
WHERE json_extract(m.data, '\$.role') = 'assistant'
|
|
61
|
+
AND NOT EXISTS (SELECT 1 FROM session_v2 v WHERE v.id = s.id)"
|
|
62
|
+
fi
|
|
63
|
+
|
|
54
64
|
# One pass over assistant messages; the window filter is a cheap CASE so the
|
|
55
65
|
# lifetime total and the window total come back together.
|
|
56
66
|
ROWS_QUERY="
|
|
57
|
-
|
|
67
|
+
WITH costs AS (
|
|
68
|
+
SELECT s.directory, s.id AS session_id, m.time_created, m.data
|
|
69
|
+
FROM session_message m JOIN session_v2 s ON s.id = m.session_id
|
|
70
|
+
WHERE m.type = 'assistant'
|
|
71
|
+
$LEGACY_QUERY
|
|
72
|
+
)
|
|
73
|
+
SELECT m.directory AS directory,
|
|
58
74
|
ROUND(SUM(CASE WHEN m.time_created >= $CUTOFF THEN COALESCE(json_extract(m.data, '\$.cost'), 0) ELSE 0 END), 4) AS window_cost,
|
|
59
75
|
ROUND(SUM(COALESCE(json_extract(m.data, '\$.cost'), 0)), 4) AS lifetime_cost,
|
|
60
|
-
COUNT(DISTINCT
|
|
76
|
+
COUNT(DISTINCT m.session_id) AS sessions,
|
|
61
77
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
AND json_extract(m.data, '\$.cost') IS NOT NULL
|
|
66
|
-
GROUP BY s.directory
|
|
78
|
+
FROM costs m
|
|
79
|
+
WHERE json_extract(m.data, '\$.cost') IS NOT NULL
|
|
80
|
+
GROUP BY m.directory
|
|
67
81
|
ORDER BY lifetime_cost DESC;"
|
|
68
82
|
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
83
|
+
[ -n "$SQLITE_BIN" ] || { echo 'sesh: sqlite3 is required' >&2; exit 1; }
|
|
84
|
+
ROWS=$("$SQLITE_BIN" -json -readonly "$DB_PATH" "$ROWS_QUERY")
|
|
75
85
|
[ -n "$ROWS" ] || ROWS='[]'
|
|
76
86
|
|
|
77
87
|
if [ "$emit_json" = 1 ]; then
|
package/bin/sesh-list.sh
CHANGED
|
@@ -76,12 +76,12 @@ GREEN=$'\033[32m'; CYAN=$'\033[36m'; GRAY=$'\033[90m'; MAGENTA=$'\033[35m'; RED=
|
|
|
76
76
|
|
|
77
77
|
resolve_db() {
|
|
78
78
|
if [ -n "${SESH_DB:-}" ]; then printf '%s\n' "$SESH_DB"; return 0; fi
|
|
79
|
-
local from_cli=''
|
|
79
|
+
local default="${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db" from_cli=''
|
|
80
|
+
if [ -f "$default" ]; then printf '%s\n' "$default"; return 0; fi
|
|
80
81
|
if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
|
|
81
|
-
from_cli=$("$OPENCODE_BIN"
|
|
82
|
+
from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { sub(/^db[ \t]+/, ""); print; exit }' || true)
|
|
82
83
|
fi
|
|
83
84
|
if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
|
|
84
|
-
if [ -f "$HOME/.local/share/opencode/opencode.db" ]; then printf '%s\n' "$HOME/.local/share/opencode/opencode.db"; return 0; fi
|
|
85
85
|
return 1
|
|
86
86
|
}
|
|
87
87
|
|
|
@@ -92,18 +92,7 @@ elif command -v sqlite3 >/dev/null 2>&1; then
|
|
|
92
92
|
SQLITE_BIN=sqlite3
|
|
93
93
|
fi
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
# in milliseconds, while `opencode db` pays a full CLI startup per call and
|
|
97
|
-
# would make every preview keystroke laggy.
|
|
98
|
-
db_json() {
|
|
99
|
-
if [ -n "$SQLITE_BIN" ]; then
|
|
100
|
-
# Read-only: sesh never writes the store directly (deletes go through
|
|
101
|
-
# `opencode session delete`).
|
|
102
|
-
"$SQLITE_BIN" -json -readonly "$DB_PATH" "$1"
|
|
103
|
-
else
|
|
104
|
-
"$OPENCODE_BIN" db "$1" --format json
|
|
105
|
-
fi
|
|
106
|
-
}
|
|
95
|
+
db_json() { "$SQLITE_BIN" -json -readonly "$DB_PATH" "$1"; }
|
|
107
96
|
|
|
108
97
|
valid_session_id() { [[ "$1" =~ ^ses_[A-Za-z0-9]+$ ]]; }
|
|
109
98
|
|
|
@@ -258,8 +247,8 @@ refresh() {
|
|
|
258
247
|
if [ -f "$SNAPSHOT" ]; then set_status 'stale: opencode database is unavailable; showing last good snapshot'; else set_status 'unavailable: opencode database is unavailable'; fi
|
|
259
248
|
return 0
|
|
260
249
|
}
|
|
261
|
-
if [ -z "$SQLITE_BIN" ]
|
|
262
|
-
if [ -f "$SNAPSHOT" ]; then set_status 'stale:
|
|
250
|
+
if [ -z "$SQLITE_BIN" ]; then
|
|
251
|
+
if [ -f "$SNAPSHOT" ]; then set_status 'stale: sqlite3 is not available; showing last good snapshot'; else set_status 'unavailable: sqlite3 is not available'; fi
|
|
263
252
|
return 0
|
|
264
253
|
fi
|
|
265
254
|
local scratch sessions parts cache_records cache_state historical needs_extract out
|
|
@@ -290,7 +279,7 @@ refresh() {
|
|
|
290
279
|
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
280
|
"$JQ_BIN" -e 'type == "array"' "$parts" >/dev/null 2>&1 || printf '[]\n' > "$parts"
|
|
292
281
|
|
|
293
|
-
# Same bulk cache validation as the
|
|
282
|
+
# Same bulk cache validation as the full list: every cache arrives as its
|
|
294
283
|
# own file argument (ARG_MAX), records match on session id plus the two
|
|
295
284
|
# change signals, and only vanished sessions are pruned — metadata always
|
|
296
285
|
# covers the whole database, so pruning is safe under any scope or limit.
|
|
@@ -389,8 +378,8 @@ refresh() {
|
|
|
389
378
|
| ($hist | map({key: .sessionId, value: .}) | from_entries) as $histById
|
|
390
379
|
| [$meta[]
|
|
391
380
|
| select((.id | type) == "string" and (.id | test("^ses_[A-Za-z0-9]+$")))
|
|
392
|
-
# Child sessions (
|
|
393
|
-
#
|
|
381
|
+
# Child sessions (spawned subagents) surface only with --archived;
|
|
382
|
+
# metadata still sees them, so cache pruning stays safe.
|
|
394
383
|
| select($archived == 1 or (.archived == 0 and .parent == ""))
|
|
395
384
|
| select($scope == "" or .directory == $scope)
|
|
396
385
|
| . as $m | ($histById[$m.id] // {}) as $h
|
package/bin/sesh-preview.sh
CHANGED
|
@@ -14,13 +14,10 @@ JQ_BIN=${SESH_JQ:-jq}
|
|
|
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
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
DB_PATH=$("$OPENCODE_BIN" db path 2>/dev/null || true)
|
|
22
|
-
fi
|
|
23
|
-
[ -n "$DB_PATH" ] || DB_PATH="$HOME/.local/share/opencode/opencode.db"
|
|
17
|
+
OPENCODE_BIN=${SESH_OPENCODE:-opencode}
|
|
18
|
+
DB_PATH=${SESH_DB:-"${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db"}
|
|
19
|
+
if [ -z "${SESH_DB:-}" ] && [ ! -f "$DB_PATH" ] && command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
|
|
20
|
+
DB_PATH=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { sub(/^db[ \t]+/, ""); print; exit }' || true)
|
|
24
21
|
fi
|
|
25
22
|
[ -f "$DB_PATH" ] || { echo "(opencode database is unavailable)"; exit 0; }
|
|
26
23
|
|
|
@@ -28,12 +25,8 @@ SQLITE_BIN=${SESH_SQLITE:-}
|
|
|
28
25
|
if [ -z "$SQLITE_BIN" ] && command -v sqlite3 >/dev/null 2>&1; then SQLITE_BIN=sqlite3; fi
|
|
29
26
|
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
27
|
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;"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
else
|
|
34
|
-
OPENCODE_BIN=${SESH_OPENCODE:-opencode}
|
|
35
|
-
rows=$("$OPENCODE_BIN" db "$SQL" --format json 2>/dev/null) || rows=''
|
|
36
|
-
fi
|
|
28
|
+
[ -n "$SQLITE_BIN" ] || { echo "(sqlite3 is required for previews)"; exit 0; }
|
|
29
|
+
rows=$("$SQLITE_BIN" -json -readonly "$DB_PATH" "$SQL" 2>/dev/null) || rows=''
|
|
37
30
|
[ -n "$rows" ] || { echo "(no displayable transcript messages)"; exit 0; }
|
|
38
31
|
|
|
39
32
|
# Text parts only, newest first, with role headers. The 120-block cap is applied
|
package/bin/sesh-prune.sh
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
#
|
|
2
|
+
# Delete stale sessions: not updated within
|
|
3
3
|
# the threshold, never pinned, never waiting on the user, never already
|
|
4
|
-
# archived, never a
|
|
4
|
+
# archived, never a child (subagent) session.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
# `opencode session delete` so its own safeguards apply. The archive itself
|
|
8
|
-
# is a single atomic UPDATE through the same sqlite3-or-`opencode db` read
|
|
9
|
-
# path the list commands use — there is no archive endpoint in the opencode
|
|
10
|
-
# API or CLI, so a direct write is the only route (deletes stay on the CLI).
|
|
11
|
-
#
|
|
12
|
-
# Usage: sesh-prune.sh [--older-than 30d] [--dry-run] [--yes] [--delete]
|
|
6
|
+
# Usage: sesh-prune.sh [--older-than 30d] [--dry-run] [--yes]
|
|
13
7
|
# --older-than Nd|Nh|Nw (bare N means days; default 30d)
|
|
14
8
|
# --dry-run lists candidates and changes nothing
|
|
15
9
|
# without --dry-run, a TTY run confirms first; a non-TTY run needs --yes
|
|
@@ -21,15 +15,13 @@ OPENCODE_BIN=${SESH_OPENCODE:-opencode}
|
|
|
21
15
|
older_than=30d
|
|
22
16
|
dry_run=0
|
|
23
17
|
assume_yes=0
|
|
24
|
-
hard_delete=0
|
|
25
18
|
while [ "$#" -gt 0 ]; do
|
|
26
19
|
case "$1" in
|
|
27
20
|
--older-than) older_than=${2:-}; shift ;;
|
|
28
21
|
--older-than=*) older_than=${1#--older-than=} ;;
|
|
29
22
|
--dry-run) dry_run=1 ;;
|
|
30
23
|
--yes|-y) assume_yes=1 ;;
|
|
31
|
-
--
|
|
32
|
-
--help) echo "usage: ${0##*/} [--older-than 30d] [--dry-run] [--yes] [--delete]"; exit 0 ;;
|
|
24
|
+
--help) echo "usage: ${0##*/} [--older-than 30d] [--dry-run] [--yes]"; exit 0 ;;
|
|
33
25
|
*) echo "unknown option: $1" >&2; exit 2 ;;
|
|
34
26
|
esac
|
|
35
27
|
shift
|
|
@@ -53,12 +45,12 @@ CUTOFF=$((NOW_MS - threshold_ms))
|
|
|
53
45
|
|
|
54
46
|
resolve_db() {
|
|
55
47
|
if [ -n "${SESH_DB:-}" ]; then printf '%s\n' "$SESH_DB"; return 0; fi
|
|
56
|
-
local from_cli=''
|
|
48
|
+
local default="${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db" from_cli=''
|
|
49
|
+
if [ -f "$default" ]; then printf '%s\n' "$default"; return 0; fi
|
|
57
50
|
if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
|
|
58
|
-
from_cli=$("$OPENCODE_BIN"
|
|
51
|
+
from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { sub(/^db[ \t]+/, ""); print; exit }' || true)
|
|
59
52
|
fi
|
|
60
53
|
if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
|
|
61
|
-
if [ -f "$HOME/.local/share/opencode/opencode.db" ]; then printf '%s\n' "$HOME/.local/share/opencode/opencode.db"; return 0; fi
|
|
62
54
|
return 1
|
|
63
55
|
}
|
|
64
56
|
|
|
@@ -72,14 +64,8 @@ fi
|
|
|
72
64
|
DB_PATH=$(resolve_db) || { echo 'sesh: session database unavailable' >&2; exit 1; }
|
|
73
65
|
[ -f "$DB_PATH" ] || { echo 'sesh: session database unavailable' >&2; exit 1; }
|
|
74
66
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
write_db() { "$SQLITE_BIN" "$DB_PATH" "$1"; }
|
|
78
|
-
else
|
|
79
|
-
command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'sesh: neither sqlite3 nor opencode is available' >&2; exit 1; }
|
|
80
|
-
query() { "$OPENCODE_BIN" db "$1" --format json; }
|
|
81
|
-
write_db() { "$OPENCODE_BIN" db "$1" --format json > /dev/null; }
|
|
82
|
-
fi
|
|
67
|
+
[ -n "$SQLITE_BIN" ] || { echo 'sesh: sqlite3 is required' >&2; exit 1; }
|
|
68
|
+
query() { "$SQLITE_BIN" -json -readonly "$DB_PATH" "$1"; }
|
|
83
69
|
|
|
84
70
|
JQ_BIN=${SESH_JQ:-$(command -v jq || true)}
|
|
85
71
|
[ -n "$JQ_BIN" ] || { echo 'sesh: jq is required' >&2; exit 1; }
|
|
@@ -103,8 +89,7 @@ ELIGIBLE=$(printf '%s' "$CANDIDATES" | "$JQ_BIN" -c --slurpfile _pins <(printf '
|
|
|
103
89
|
]
|
|
104
90
|
')
|
|
105
91
|
COUNT=$(printf '%s' "$ELIGIBLE" | "$JQ_BIN" -r 'length')
|
|
106
|
-
VERB=
|
|
107
|
-
[ "$hard_delete" = 0 ] || VERB=Delete
|
|
92
|
+
VERB=Delete
|
|
108
93
|
|
|
109
94
|
if [ "$COUNT" = 0 ]; then
|
|
110
95
|
echo "Nothing to prune older than $older_than."
|
|
@@ -142,34 +127,17 @@ if [ "$assume_yes" = 0 ]; then
|
|
|
142
127
|
fi
|
|
143
128
|
fi
|
|
144
129
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
failed=0
|
|
160
|
-
done_count=0
|
|
161
|
-
for id in $(printf '%s' "$ELIGIBLE" | "$JQ_BIN" -r '.[].id'); do
|
|
162
|
-
case "$id" in
|
|
163
|
-
ses_[A-Za-z0-9]*) ;;
|
|
164
|
-
*) echo "Refusing: unexpected session id shape." >&2; exit 1 ;;
|
|
165
|
-
esac
|
|
166
|
-
if "$OPENCODE_BIN" session delete "$id" >/dev/null 2>&1; then
|
|
167
|
-
done_count=$((done_count + 1))
|
|
168
|
-
else
|
|
169
|
-
echo "Could not delete $id." >&2
|
|
170
|
-
failed=$((failed + 1))
|
|
171
|
-
fi
|
|
172
|
-
done
|
|
173
|
-
echo "Deleted $done_count session(s) older than $older_than."
|
|
174
|
-
[ "$failed" = 0 ] || exit 1
|
|
175
|
-
fi
|
|
130
|
+
command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'sesh: opencode executable is unavailable for deletes.' >&2; exit 1; }
|
|
131
|
+
failed=0
|
|
132
|
+
done_count=0
|
|
133
|
+
for id in $(printf '%s' "$ELIGIBLE" | "$JQ_BIN" -r '.[].id'); do
|
|
134
|
+
[[ "$id" =~ ^ses_[A-Za-z0-9]+$ ]] || { echo "Refusing: unexpected session id shape." >&2; exit 1; }
|
|
135
|
+
if "$OPENCODE_BIN" session delete "$id" >/dev/null 2>&1; then
|
|
136
|
+
done_count=$((done_count + 1))
|
|
137
|
+
else
|
|
138
|
+
echo "Could not delete $id." >&2
|
|
139
|
+
failed=$((failed + 1))
|
|
140
|
+
fi
|
|
141
|
+
done
|
|
142
|
+
echo "Deleted $done_count session(s) older than $older_than."
|
|
143
|
+
[ "$failed" = 0 ] || exit 1
|
package/bin/sesh-waiting.sh
CHANGED
|
@@ -5,11 +5,16 @@
|
|
|
5
5
|
# owned by a running TUI server, whose in-memory permission/question state
|
|
6
6
|
# is invisible cross-project.
|
|
7
7
|
#
|
|
8
|
-
# A session waits when it is not archived, is not a
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
8
|
+
# A session waits when it is not archived, is not a child (subagent) session,
|
|
9
|
+
# and either
|
|
10
|
+
# - has a `question` tool part still pending or running with no later user
|
|
11
|
+
# message in the same session (awaiting an answer; a dismissed question
|
|
12
|
+
# that errored is not waiting), or
|
|
13
|
+
# - has a `tool` part still marked `running` older than STUCK_AFTER_MS with
|
|
14
|
+
# no message activity in the session or its direct children since then
|
|
15
|
+
# (a long-running subagent/shell tool with recent activity is not stuck;
|
|
16
|
+
# V2 resumes interrupted runs server-side, so `running` with no activity
|
|
17
|
+
# means orphaned or awaiting approval).
|
|
13
18
|
#
|
|
14
19
|
# Usage: sesh-waiting.sh [--json]
|
|
15
20
|
# default prints a human table; --json prints [{id,reason,title,directory,updated}]
|
|
@@ -30,12 +35,12 @@ done
|
|
|
30
35
|
|
|
31
36
|
resolve_db() {
|
|
32
37
|
if [ -n "${SESH_DB:-}" ]; then printf '%s\n' "$SESH_DB"; return 0; fi
|
|
33
|
-
local from_cli=''
|
|
38
|
+
local default="${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db" from_cli=''
|
|
39
|
+
if [ -f "$default" ]; then printf '%s\n' "$default"; return 0; fi
|
|
34
40
|
if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
|
|
35
|
-
from_cli=$("$OPENCODE_BIN"
|
|
41
|
+
from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { sub(/^db[ \t]+/, ""); print; exit }' || true)
|
|
36
42
|
fi
|
|
37
43
|
if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
|
|
38
|
-
if [ -f "$HOME/.local/share/opencode/opencode.db" ]; then printf '%s\n' "$HOME/.local/share/opencode/opencode.db"; return 0; fi
|
|
39
44
|
return 1
|
|
40
45
|
}
|
|
41
46
|
|
|
@@ -82,20 +87,17 @@ WHERE COALESCE(s.time_archived, 0) = 0
|
|
|
82
87
|
AND NOT EXISTS (
|
|
83
88
|
SELECT 1 FROM session_message u
|
|
84
89
|
WHERE u.session_id = live.session_id AND u.type = 'user' AND u.seq > live.seq)
|
|
85
|
-
) OR EXISTS (
|
|
90
|
+
) OR (EXISTS (
|
|
86
91
|
SELECT 1 FROM live
|
|
87
|
-
WHERE live.session_id = s.id AND live.status = 'running' AND live.created < $STUCK_BEFORE)
|
|
92
|
+
WHERE live.session_id = s.id AND live.status = 'running' AND live.created < $STUCK_BEFORE)
|
|
93
|
+
AND NOT EXISTS (
|
|
94
|
+
SELECT 1 FROM session_message r JOIN session_v2 f ON f.id = r.session_id
|
|
95
|
+
WHERE (f.id = s.id OR f.parent_id = s.id) AND r.time_updated >= $STUCK_BEFORE)))
|
|
88
96
|
ORDER BY s.time_updated DESC;
|
|
89
97
|
SQL
|
|
90
98
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
# through `opencode db`, hard deletes through `opencode session delete`).
|
|
94
|
-
ROWS=$("$SQLITE_BIN" -json -readonly "$DB_PATH" "$QUERY")
|
|
95
|
-
else
|
|
96
|
-
command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'sesh: neither sqlite3 nor opencode is available' >&2; exit 1; }
|
|
97
|
-
ROWS=$("$OPENCODE_BIN" db "$QUERY" --format json)
|
|
98
|
-
fi
|
|
99
|
+
[ -n "$SQLITE_BIN" ] || { echo 'sesh: sqlite3 is required' >&2; exit 1; }
|
|
100
|
+
ROWS=$("$SQLITE_BIN" -json -readonly "$DB_PATH" "$QUERY")
|
|
99
101
|
|
|
100
102
|
# sqlite3 -json prints nothing (not []) when no rows match.
|
|
101
103
|
[ -n "$ROWS" ] || ROWS='[]'
|
package/bin/sesh.sh
CHANGED
|
@@ -37,7 +37,7 @@ fi
|
|
|
37
37
|
|
|
38
38
|
if [ "${1:-}" = --check ]; then
|
|
39
39
|
"$SESH_JQ" -en '"2026-01-01T00:00:00Z" | fromdateiso8601 | type == "number"' >/dev/null || { echo 'jq capability check failed' >&2; exit 1; }
|
|
40
|
-
command -v
|
|
40
|
+
command -v "${SESH_SQLITE:-sqlite3}" >/dev/null 2>&1 || { echo 'sqlite3 is not available' >&2; exit 1; }
|
|
41
41
|
# The TUI panel is a copied file that opencode imports once at startup. Report
|
|
42
42
|
# when the installed copy has fallen behind this package so upgrades are not
|
|
43
43
|
# silently stale.
|
|
@@ -203,9 +203,18 @@ fi
|
|
|
203
203
|
command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'sesh: opencode executable unavailable' >&2; exit 1; }
|
|
204
204
|
# Resume in place: this terminal becomes the session. No tabs, no panes.
|
|
205
205
|
if [ "$fork" = 1 ]; then
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
206
|
+
fork_err="$state_dir/fork-err"
|
|
207
|
+
forked=$(cd "$cwd" && "$OPENCODE_BIN" api session.fork --param "sessionID=$session_id" -d '{}' 2>"$fork_err" | "$SESH_JQ" -r '.data.id // empty' 2>/dev/null || true)
|
|
208
|
+
if [[ "$forked" =~ ^ses_[A-Za-z0-9]+$ ]]; then
|
|
209
|
+
session_id=$forked
|
|
210
|
+
rm -f "$fork_err"
|
|
211
|
+
else
|
|
212
|
+
echo 'sesh: could not fork the session; returning to the picker.' >&2
|
|
213
|
+
[ -s "$fork_err" ] && sed 's/^/sesh: /' "$fork_err" >&2
|
|
214
|
+
rm -f "$fork_err"
|
|
215
|
+
continue
|
|
216
|
+
fi
|
|
209
217
|
fi
|
|
218
|
+
( cd "$cwd" && exec "$OPENCODE_BIN" --session "$session_id" )
|
|
210
219
|
echo 'sesh: opencode exited; returning to the picker.' >&2
|
|
211
220
|
done
|
package/install.sh
CHANGED
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
# Install sesh for the current user.
|
|
3
3
|
# Terminal- and OS-agnostic: macOS and Linux, any terminal. No administrator
|
|
4
4
|
# privileges, downloads, or shell-rc edits. Runtime requires jq, fzf >= 0.73
|
|
5
|
-
# and sqlite3
|
|
6
|
-
# packages opencode installs for local plugins (handled
|
|
5
|
+
# and sqlite3; resuming and deleting need the opencode CLI. The in-TUI panel
|
|
6
|
+
# additionally needs the packages opencode installs for local plugins (handled
|
|
7
|
+
# below).
|
|
7
8
|
set -euo pipefail
|
|
8
9
|
|
|
9
10
|
fail() { printf 'sesh install: %s\n' "$*" >&2; exit 1; }
|
|
@@ -23,7 +24,7 @@ SYNC_PANEL=0
|
|
|
23
24
|
|
|
24
25
|
# Fallback plugin version for when the opencode CLI is unavailable; the
|
|
25
26
|
# installer prefers the local opencode version so the panel matches its SDK.
|
|
26
|
-
PLUGIN_VERSION_FALLBACK="2.0.
|
|
27
|
+
PLUGIN_VERSION_FALLBACK="2.0.25"
|
|
27
28
|
# Peer line the panel was developed and typechecked against. A checkout's
|
|
28
29
|
# package.json overrides these, so dependency bumps cannot drift the installed
|
|
29
30
|
# config out of sync (solid-js moves with OpenTUI via @opentui/keymap's peer).
|
|
@@ -251,7 +252,6 @@ for path in \
|
|
|
251
252
|
bin/sesh-delete.sh \
|
|
252
253
|
bin/sesh-prune.sh \
|
|
253
254
|
bin/sesh-refresh-worker.sh \
|
|
254
|
-
bin/sesh-retitle.sh \
|
|
255
255
|
bin/sesh-waiting.sh \
|
|
256
256
|
bin/sesh-shortcuts.sh \
|
|
257
257
|
plugins/sesh-list.ts \
|
|
@@ -284,8 +284,7 @@ if [[ "$fzf_version" =~ ^([0-9]+)\.([0-9]+)(\.([0-9]+))? ]]; then
|
|
|
284
284
|
else
|
|
285
285
|
fail "cannot parse fzf version from: $fzf_version."
|
|
286
286
|
fi
|
|
287
|
-
command -v sqlite3 >/dev/null 2>&1 ||
|
|
288
|
-
|| fail "either sqlite3 or the opencode CLI is required."
|
|
287
|
+
command -v sqlite3 >/dev/null 2>&1 || fail "sqlite3 is required."
|
|
289
288
|
command -v opencode >/dev/null 2>&1 || note "opencode CLI not found: listing and previews work via sqlite3, but resume and delete need opencode."
|
|
290
289
|
|
|
291
290
|
umask 077
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-sesh",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -38,8 +38,7 @@
|
|
|
38
38
|
"AGENTS.md",
|
|
39
39
|
"CHANGELOG.md",
|
|
40
40
|
"LICENSE",
|
|
41
|
-
"README.md"
|
|
42
|
-
"ROADMAP.md"
|
|
41
|
+
"README.md"
|
|
43
42
|
],
|
|
44
43
|
"publishConfig": {
|
|
45
44
|
"access": "public"
|
|
@@ -62,8 +61,8 @@
|
|
|
62
61
|
},
|
|
63
62
|
"peerDependencies": {
|
|
64
63
|
"@opencode/plugin": ">=2.0.0",
|
|
65
|
-
"@opentui/core": "
|
|
66
|
-
"@opentui/solid": "
|
|
64
|
+
"@opentui/core": "^0.5.8",
|
|
65
|
+
"@opentui/solid": "^0.5.8",
|
|
67
66
|
"solid-js": ">=1.9.0"
|
|
68
67
|
},
|
|
69
68
|
"devDependencies": {
|
package/plugins/sesh-list.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
1
|
import { Plugin } from "@opencode/plugin"
|
|
2
|
+
import { spawnSync } from "node:child_process"
|
|
3
|
+
import { existsSync } from "node:fs"
|
|
2
4
|
import { homedir } from "node:os"
|
|
3
5
|
import { join } from "node:path"
|
|
4
6
|
|
|
5
|
-
function
|
|
6
|
-
|
|
7
|
+
function dbPath(): string {
|
|
8
|
+
if (process.env.SESH_DB) return process.env.SESH_DB
|
|
9
|
+
const xdgPath = join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "opencode", "opencode.db")
|
|
10
|
+
if (existsSync(xdgPath)) return xdgPath
|
|
11
|
+
try {
|
|
12
|
+
const result = spawnSync(process.env.SESH_OPENCODE ?? "opencode", ["debug", "paths"], { encoding: "utf8" })
|
|
13
|
+
const match = result.stdout?.match(/^db\s+(.+)$/m)
|
|
14
|
+
if (match) return match[1].trimEnd()
|
|
15
|
+
} catch {}
|
|
16
|
+
return xdgPath
|
|
7
17
|
}
|
|
8
18
|
|
|
9
19
|
const input = {
|
|
@@ -44,12 +54,12 @@ export default Plugin.define({
|
|
|
44
54
|
throw new Error("sesh_list requires the Bun runtime (bun:sqlite)")
|
|
45
55
|
}
|
|
46
56
|
|
|
47
|
-
const db = new sqlite.Database(
|
|
57
|
+
const db = new sqlite.Database(dbPath(), { readonly: true })
|
|
48
58
|
try {
|
|
49
59
|
const scoped = typeof args.directory === "string" && args.directory.length > 0
|
|
50
60
|
const query = `SELECT id, title, directory, time_updated AS updated, time_created AS created
|
|
51
61
|
FROM session_v2
|
|
52
|
-
WHERE parent_id IS NULL AND time_archived
|
|
62
|
+
WHERE parent_id IS NULL AND COALESCE(time_archived, 0) = 0${scoped ? " AND directory = ?" : ""}
|
|
53
63
|
ORDER BY time_updated DESC, id DESC
|
|
54
64
|
LIMIT ${limit}`
|
|
55
65
|
const rows = scoped ? db.query(query).all(args.directory) : db.query(query).all()
|
|
@@ -7,8 +7,9 @@ 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"
|
|
10
|
+
import { existsSync } from "node:fs"
|
|
10
11
|
import { dirname, join } from "node:path"
|
|
11
|
-
import { spawn } from "node:child_process"
|
|
12
|
+
import { spawn, spawnSync } from "node:child_process"
|
|
12
13
|
import { getTreeSitterClient, RGBA, SyntaxStyle, TextAttributes } from "@opentui/core"
|
|
13
14
|
import { Portal } from "@opentui/solid"
|
|
14
15
|
|
|
@@ -502,26 +503,63 @@ type IndexProgress = { indexed: number; total: number; complete: boolean }
|
|
|
502
503
|
|
|
503
504
|
const nextTick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
|
|
504
505
|
|
|
506
|
+
function resolveDbPath(): string {
|
|
507
|
+
if (process.env.SESH_DB) return process.env.SESH_DB
|
|
508
|
+
const home = process.env.HOME ?? ""
|
|
509
|
+
const dataHome = process.env.XDG_DATA_HOME ?? `${home}/.local/share`
|
|
510
|
+
const xdgPath = `${dataHome}/opencode/opencode.db`
|
|
511
|
+
if (existsSync(xdgPath)) return xdgPath
|
|
512
|
+
try {
|
|
513
|
+
const result = spawnSync(process.env.SESH_OPENCODE ?? "opencode", ["debug", "paths"], { encoding: "utf8" })
|
|
514
|
+
const match = result.stdout?.match(/^db\s+(.+)$/m)
|
|
515
|
+
if (match) return match[1].trimEnd()
|
|
516
|
+
} catch {}
|
|
517
|
+
return xdgPath
|
|
518
|
+
}
|
|
519
|
+
|
|
505
520
|
async function openTranscriptDb(): Promise<any | undefined> {
|
|
506
521
|
try {
|
|
507
|
-
const home = process.env.HOME ?? ""
|
|
508
|
-
const dataHome = process.env.XDG_DATA_HOME ?? `${home}/.local/share`
|
|
509
522
|
// @ts-ignore - bun:sqlite ships with the Bun runtime (verified); no type package installed
|
|
510
523
|
const sqlite: any = await import("bun:sqlite")
|
|
511
524
|
if (typeof sqlite?.Database !== "function") return undefined
|
|
512
|
-
return new sqlite.Database(
|
|
525
|
+
return new sqlite.Database(resolveDbPath(), { readonly: true })
|
|
513
526
|
} catch {
|
|
514
527
|
return undefined
|
|
515
528
|
}
|
|
516
529
|
}
|
|
517
530
|
|
|
531
|
+
function costRowsQuery(cutoff: number, includeLegacy: boolean): string {
|
|
532
|
+
const legacy = includeLegacy ? `UNION ALL
|
|
533
|
+
SELECT s.directory, s.id AS session_id, m.time_created, m.data
|
|
534
|
+
FROM message m JOIN session s ON s.id = m.session_id
|
|
535
|
+
WHERE json_extract(m.data, '$.role') = 'assistant'
|
|
536
|
+
AND NOT EXISTS (SELECT 1 FROM session_v2 v WHERE v.id = s.id)` : ""
|
|
537
|
+
return `WITH costs AS (
|
|
538
|
+
SELECT s.directory, s.id AS session_id, m.time_created, m.data
|
|
539
|
+
FROM session_message m JOIN session_v2 s ON s.id = m.session_id
|
|
540
|
+
WHERE m.type = 'assistant'
|
|
541
|
+
${legacy}
|
|
542
|
+
)
|
|
543
|
+
SELECT m.directory AS directory,
|
|
544
|
+
ROUND(SUM(CASE WHEN m.time_created >= ${cutoff} THEN COALESCE(json_extract(m.data, '$.cost'), 0) ELSE 0 END), 4) AS window_cost,
|
|
545
|
+
ROUND(SUM(COALESCE(json_extract(m.data, '$.cost'), 0)), 4) AS lifetime_cost,
|
|
546
|
+
COUNT(DISTINCT m.session_id) AS sessions
|
|
547
|
+
FROM costs m
|
|
548
|
+
WHERE json_extract(m.data, '$.cost') IS NOT NULL
|
|
549
|
+
GROUP BY m.directory
|
|
550
|
+
ORDER BY lifetime_cost DESC`
|
|
551
|
+
}
|
|
552
|
+
|
|
518
553
|
// NEEDS_INPUT_SQL: sessions waiting on the user (shared heuristic — keep in
|
|
519
554
|
// sync with bin/sesh-waiting.sh). A session waits when it is not archived,
|
|
520
|
-
// is not a
|
|
521
|
-
// a `tool` part still marked running older than STUCK_AFTER_MS
|
|
522
|
-
//
|
|
523
|
-
//
|
|
524
|
-
//
|
|
555
|
+
// is not a child (subagent) session, and either has an unanswered `question`
|
|
556
|
+
// tool part or a `tool` part still marked running older than STUCK_AFTER_MS
|
|
557
|
+
// with no message activity in the session or its direct children since then (a
|
|
558
|
+
// long-running subagent/shell tool with recent activity is not stuck; V2
|
|
559
|
+
// resumes interrupted runs server-side, so `running` with no activity means
|
|
560
|
+
// orphaned or awaiting approval). Server-independent on purpose: the TUI
|
|
561
|
+
// sync layer only sees permission/question state for sessions owned by the
|
|
562
|
+
// current server, which is invisible cross-project.
|
|
525
563
|
const STUCK_AFTER_MS = 600000
|
|
526
564
|
const LIVE_TOOLS_CTE = (sessionFilter: string) => `
|
|
527
565
|
WITH live AS (
|
|
@@ -539,6 +577,9 @@ const QUESTION_OPEN = `live.name = 'question'
|
|
|
539
577
|
AND NOT EXISTS (
|
|
540
578
|
SELECT 1 FROM session_message u
|
|
541
579
|
WHERE u.session_id = live.session_id AND u.type = 'user' AND u.seq > live.seq)`
|
|
580
|
+
const NO_RECENT_ACTIVITY = (sessionIdExpr: string, stuckBefore: number) => `NOT EXISTS (
|
|
581
|
+
SELECT 1 FROM session_message r JOIN session_v2 f ON f.id = r.session_id
|
|
582
|
+
WHERE (f.id = ${sessionIdExpr} OR f.parent_id = ${sessionIdExpr}) AND r.time_updated >= ${stuckBefore})`
|
|
542
583
|
const NEEDS_INPUT_SQL = (stuckBefore: number) => `${LIVE_TOOLS_CTE("")}
|
|
543
584
|
SELECT s.id AS id,
|
|
544
585
|
CASE WHEN EXISTS (SELECT 1 FROM live WHERE live.session_id = s.id AND ${QUESTION_OPEN})
|
|
@@ -547,9 +588,10 @@ FROM session_v2 s
|
|
|
547
588
|
WHERE COALESCE(s.time_archived, 0) = 0
|
|
548
589
|
AND s.parent_id IS NULL
|
|
549
590
|
AND (EXISTS (SELECT 1 FROM live WHERE live.session_id = s.id AND ${QUESTION_OPEN})
|
|
550
|
-
OR EXISTS (
|
|
591
|
+
OR (EXISTS (
|
|
551
592
|
SELECT 1 FROM live
|
|
552
|
-
WHERE live.session_id = s.id AND live.status = 'running' AND live.created < ${stuckBefore})
|
|
593
|
+
WHERE live.session_id = s.id AND live.status = 'running' AND live.created < ${stuckBefore})
|
|
594
|
+
AND ${NO_RECENT_ACTIVITY("s.id", stuckBefore)}))
|
|
553
595
|
ORDER BY s.time_updated DESC`
|
|
554
596
|
|
|
555
597
|
async function queryWaitingIds(db: any): Promise<Map<string, string>> {
|
|
@@ -679,7 +721,7 @@ async function buildSearchIndexRemote(
|
|
|
679
721
|
onEach: () => void,
|
|
680
722
|
): Promise<void> {
|
|
681
723
|
const home = process.env.HOME ?? ""
|
|
682
|
-
const cacheDir = process.env.SESH_CACHE_DIR ?? `${home}/.cache/sesh`
|
|
724
|
+
const cacheDir = process.env.SESH_CACHE_DIR ?? `${process.env.XDG_CACHE_HOME ?? `${home}/.cache`}/sesh`
|
|
683
725
|
let cursor = 0
|
|
684
726
|
// Bounded concurrency: a fixed worker pool instead of one request per session.
|
|
685
727
|
const worker = async () => {
|
|
@@ -1558,7 +1600,6 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1558
1600
|
<box
|
|
1559
1601
|
flexDirection="row"
|
|
1560
1602
|
gap={1}
|
|
1561
|
-
paddingLeft={1}
|
|
1562
1603
|
paddingRight={1}
|
|
1563
1604
|
backgroundColor={searching() ? theme().backgroundElement : RGBA.fromInts(0, 0, 0, 0)}
|
|
1564
1605
|
onMouseDown={(event: { stopPropagation: () => void }) => {
|
|
@@ -1576,7 +1617,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1576
1617
|
wrapMode="none"
|
|
1577
1618
|
style={{ fg: query() ? theme().text : theme().textMuted }}
|
|
1578
1619
|
>
|
|
1579
|
-
{query() || "search…"}
|
|
1620
|
+
{query() || (searching() ? "" : "search…")}
|
|
1580
1621
|
{searching() ? "▏" : ""}
|
|
1581
1622
|
</text>
|
|
1582
1623
|
<Show when={query()}>
|
|
@@ -2001,7 +2042,7 @@ function HomeSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
2001
2042
|
wrapMode="none"
|
|
2002
2043
|
style={{ fg: query() ? theme().text : theme().textMuted }}
|
|
2003
2044
|
>
|
|
2004
|
-
{query() || "search…"}
|
|
2045
|
+
{query() || (searching() ? "" : "search…")}
|
|
2005
2046
|
{searching() ? "▏" : ""}
|
|
2006
2047
|
</text>
|
|
2007
2048
|
</box>
|
|
@@ -2574,6 +2615,7 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2574
2615
|
focusedBackgroundColor={api.theme.current.backgroundPanel}
|
|
2575
2616
|
focusedTextColor={api.theme.current.text}
|
|
2576
2617
|
cursorColor={api.theme.current.primary}
|
|
2618
|
+
cursorStyle={{ style: "line", blinking: false }}
|
|
2577
2619
|
onInput={(value) => {
|
|
2578
2620
|
if (pendingDelete()) return
|
|
2579
2621
|
setQuery(value)
|
|
@@ -2851,17 +2893,8 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2851
2893
|
else {
|
|
2852
2894
|
try {
|
|
2853
2895
|
const cutoff = Date.now() - 86400000
|
|
2854
|
-
const
|
|
2855
|
-
|
|
2856
|
-
ROUND(SUM(CASE WHEN m.time_created >= ${cutoff} THEN COALESCE(json_extract(m.data, '$.cost'), 0) ELSE 0 END), 4) AS window_cost,
|
|
2857
|
-
ROUND(SUM(COALESCE(json_extract(m.data, '$.cost'), 0)), 4) AS lifetime_cost,
|
|
2858
|
-
COUNT(DISTINCT s.id) AS sessions
|
|
2859
|
-
FROM session_message m
|
|
2860
|
-
JOIN session_v2 s ON s.id = m.session_id
|
|
2861
|
-
WHERE m.type = 'assistant'
|
|
2862
|
-
AND json_extract(m.data, '$.cost') IS NOT NULL
|
|
2863
|
-
GROUP BY s.directory
|
|
2864
|
-
ORDER BY lifetime_cost DESC`).all() ?? []) as {
|
|
2896
|
+
const legacyTables = db.query("SELECT count(*) AS count FROM sqlite_master WHERE type = 'table' AND name IN ('session', 'message')").get()
|
|
2897
|
+
const raw = (db.query(costRowsQuery(cutoff, legacyTables?.count === 2)).all() ?? []) as {
|
|
2865
2898
|
directory: unknown
|
|
2866
2899
|
window_cost: unknown
|
|
2867
2900
|
lifetime_cost: unknown
|
package/ROADMAP.md
DELETED
|
@@ -1,60 +0,0 @@
|
|
|
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:** [`contrib/cmux/`](contrib/cmux/README.md) ships the cmux
|
|
39
|
-
actions, shortcuts and a `--print --json` recipe that resumes a session in its
|
|
40
|
-
own workspace; the TUI's right-click action opens one directly.
|
|
41
|
-
|
|
42
|
-
## 4. Per-project cost digest (done)
|
|
43
|
-
|
|
44
|
-
`sesh costs [--days N] [--json]` sums assistant-message cost per directory with
|
|
45
|
-
a recent window beside the lifetime total, so a day's work reads as a
|
|
46
|
-
per-project record. Message-level summing (not `session.cost`) keeps a
|
|
47
|
-
half-finished day from dragging in a session's earlier history.
|
|
48
|
-
|
|
49
|
-
## 5. Resume from anywhere (done)
|
|
50
|
-
|
|
51
|
-
Covered by the same contrib recipes: `sesh --print --json` resolves a session
|
|
52
|
-
non-interactively, so a cmux action, skhd binding or launcher can resume a
|
|
53
|
-
specific session — or open the picker — from any context.
|
|
54
|
-
|
|
55
|
-
## Smaller items (done)
|
|
56
|
-
|
|
57
|
-
- Search weighting: title matches outrank transcript-only matches and the
|
|
58
|
-
current project rises while a query is active; pins still win outright.
|
|
59
|
-
- `sesh retitle [--dry-run] [--yes]` replaces placeholder titles
|
|
60
|
-
(`New session - …`) with the start of the first user message.
|
package/bin/sesh-retitle.sh
DELETED
|
@@ -1,139 +0,0 @@
|
|
|
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 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
|
|
80
|
-
WHERE s.title LIKE 'New session - %'
|
|
81
|
-
OR TRIM(COALESCE(s.title, '')) = ''
|
|
82
|
-
ORDER BY s.time_updated DESC;")
|
|
83
|
-
[ -n "$CANDIDATES" ] || CANDIDATES='[]'
|
|
84
|
-
|
|
85
|
-
PLAN=$(printf '%s' "$CANDIDATES" | "$JQ_BIN" -c --argjson words "$max_words" '
|
|
86
|
-
def derive:
|
|
87
|
-
(.first_part // "")
|
|
88
|
-
| (try (fromjson | .text) catch "")
|
|
89
|
-
| gsub("\\[Image #[0-9]+\\]"; " ")
|
|
90
|
-
| gsub("[#*_`>]"; " ")
|
|
91
|
-
| gsub("\\s+"; " ")
|
|
92
|
-
| sub("^ +"; "") | sub(" +$"; "")
|
|
93
|
-
| (split(" ") | map(select(. != "")))
|
|
94
|
-
| (if length == 0 then "" else .[0:$words] | join(" ") end)
|
|
95
|
-
| if length > 60 then .[0:57] + "..." else . end;
|
|
96
|
-
[ .[] | .new_title = derive | select(.new_title != "") ]')
|
|
97
|
-
COUNT=$(printf '%s' "$PLAN" | "$JQ_BIN" -r 'length')
|
|
98
|
-
|
|
99
|
-
if [ "$COUNT" = 0 ]; then
|
|
100
|
-
echo "No placeholder-titled sessions with a usable first message."
|
|
101
|
-
exit 0
|
|
102
|
-
fi
|
|
103
|
-
|
|
104
|
-
printf '%s' "$PLAN" | "$JQ_BIN" -r '
|
|
105
|
-
.[] | "\(.id)\t\(.new_title)\t\(.old_title)"' | while IFS=$'\t' read -r id new old; do
|
|
106
|
-
printf '%s\n \033[90m%s\033[0m\n → %s\n' "$id" "$old" "$new"
|
|
107
|
-
done
|
|
108
|
-
|
|
109
|
-
if [ "$dry_run" = 1 ]; then
|
|
110
|
-
printf 'Retitle %s session(s) (--dry-run: nothing changed).\n' "$COUNT" >&2
|
|
111
|
-
exit 0
|
|
112
|
-
fi
|
|
113
|
-
|
|
114
|
-
if [ "$assume_yes" = 0 ]; then
|
|
115
|
-
if [ -t 0 ]; then
|
|
116
|
-
printf 'Retitle %s session(s)? [y/N] ' "$COUNT"
|
|
117
|
-
reply=''
|
|
118
|
-
IFS= read -r -n 1 reply || reply=''
|
|
119
|
-
printf '\n'
|
|
120
|
-
case "$reply" in
|
|
121
|
-
y|Y) ;;
|
|
122
|
-
*) echo 'Cancelled; nothing was changed.'; exit 0 ;;
|
|
123
|
-
esac
|
|
124
|
-
else
|
|
125
|
-
echo "Refusing to retitle $COUNT session(s): pass --yes when stdin is not a terminal." >&2
|
|
126
|
-
exit 2
|
|
127
|
-
fi
|
|
128
|
-
fi
|
|
129
|
-
|
|
130
|
-
# Escape single quotes for the SQL string literal; ids are validated by shape.
|
|
131
|
-
sql_escape() { printf '%s' "$1" | sed "s/'/''/g"; }
|
|
132
|
-
printf '%s' "$PLAN" | "$JQ_BIN" -r '.[] | [.id, .new_title] | @tsv' | while IFS=$'\t' read -r id new; do
|
|
133
|
-
case "$id" in
|
|
134
|
-
ses_[A-Za-z0-9]*) ;;
|
|
135
|
-
*) echo "Refusing: unexpected session id shape." >&2; exit 1 ;;
|
|
136
|
-
esac
|
|
137
|
-
write_db "UPDATE session_v2 SET title = '$(sql_escape "$new")' WHERE id = '$id' AND (title LIKE 'New session - %' OR TRIM(COALESCE(title, '')) = '');"
|
|
138
|
-
done
|
|
139
|
-
echo "Retitled $COUNT session(s)."
|