opencode-sesh 1.1.0 → 2.0.1
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 +15 -12
- package/CHANGELOG.md +18 -0
- package/README.md +69 -30
- package/bin/sesh-costs.sh +22 -8
- package/bin/sesh-list.sh +4 -4
- package/bin/sesh-preview.sh +1 -1
- package/bin/sesh-prune.sh +19 -41
- package/bin/sesh-shortcuts.sh +7 -2
- package/bin/sesh-waiting.sh +13 -6
- package/bin/sesh.sh +5 -1
- package/contrib/cmux/README.md +6 -4
- package/install.sh +3 -3
- package/package.json +3 -2
- package/plugins/sesh-list.ts +11 -1
- package/tui-plugins/sesh-panel/tui.tsx +72 -36
package/AGENTS.md
CHANGED
|
@@ -11,10 +11,10 @@ territory: read this first, then the specific file you need.
|
|
|
11
11
|
any terminal. Lists **all** sessions across **all** project directories,
|
|
12
12
|
full-text searchable, with transcript preview and resume/fork/delete.
|
|
13
13
|
2. **`tui-plugins/sesh-panel/tui.tsx`** — a SolidJS/OpenTUI plugin for the opencode TUI: a
|
|
14
|
-
|
|
14
|
+
sessions section in the sidebar plus an `option+o` / `/sesh` picker.
|
|
15
15
|
3. **`plugins/sesh-list.ts`** — the `sesh_list` custom tool, so the agent
|
|
16
|
-
can list sessions across every directory by querying the global
|
|
17
|
-
|
|
16
|
+
can list sessions across every directory by querying the global opencode
|
|
17
|
+
SQLite store (the native `opencode session list` is project-scoped).
|
|
18
18
|
|
|
19
19
|
No iTerm2, no AppleScript, no panes, no window management — **never add any.**
|
|
20
20
|
The one exception is the TUI's right-click action, which shells out to
|
|
@@ -39,11 +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`
|
|
42
|
+
both); `sesh-prune.sh` deletes stale sessions; `sesh-costs.sh` sums
|
|
43
43
|
assistant-message cost per directory. Reads go through `sqlite3 -readonly`
|
|
44
|
-
(required; `opencode db` no longer exists)
|
|
45
|
-
|
|
46
|
-
no archive endpoint — and hard deletes stay on `opencode session delete`.
|
|
44
|
+
(required; `opencode db` no longer exists). Sesh never writes the database
|
|
45
|
+
directly; deletes go through `opencode session delete`.
|
|
47
46
|
- `contrib/` — caller-side integrations (currently cmux). Nothing here may add
|
|
48
47
|
window management to sesh itself.
|
|
49
48
|
- `tui-plugins/sesh-panel/tui.tsx` — SolidJS panel, `/** @jsxImportSource @opentui/solid */`.
|
|
@@ -58,7 +57,7 @@ The tool name in opencode is `sesh_list`. Do not rename it without updating the
|
|
|
58
57
|
## Data flow (fzf picker)
|
|
59
58
|
|
|
60
59
|
`bin/sesh-list.sh` owns the fzf picker's session store access (opencode SQLite
|
|
61
|
-
DB: `session_v2` / `session_message` tables); the `
|
|
60
|
+
DB: `session_v2` / `session_message` tables); the `sesh_list` agent tool queries
|
|
62
61
|
the same store read-only through `bun:sqlite`. V2 discovers the tool from the
|
|
63
62
|
global config directory's `plugins/`. Keep its SQL column names in sync with
|
|
64
63
|
the schema below. Keystrokes never touch the database:
|
|
@@ -76,7 +75,8 @@ the schema below. Keystrokes never touch the database:
|
|
|
76
75
|
the whole DB and pruning is always safe.
|
|
77
76
|
- render (cheap, pure `jq`): reads the snapshot only. Filters on `searchText`
|
|
78
77
|
(title + `fulltextLower`, both lowercased at extraction), groups by `cwd`,
|
|
79
|
-
sorts groups/items by recency,
|
|
78
|
+
sorts groups/items by pins and recency, with current-project and title-match
|
|
79
|
+
priority while searching, and renders the TSV. Notice rows (headers, a
|
|
80
80
|
vanished selection, an empty store, a query matching nothing) carry an empty
|
|
81
81
|
`sessionId` and a unique `trackingId`. `--header` prints the one-line
|
|
82
82
|
scope/count/status that the picker uses as fzf's header, and a non-actionable
|
|
@@ -90,7 +90,9 @@ the schema below. Keystrokes never touch the database:
|
|
|
90
90
|
The sidebar and home lists use Option-S / Option-R instead: opencode binds
|
|
91
91
|
`ctrl+d` itself (composer delete-char, session delete), so ctrl pins would
|
|
92
92
|
shadow the composer. Pinned directories sort before groups
|
|
93
|
-
containing pinned sessions
|
|
93
|
+
containing pinned sessions. The terminal picker puts pinned sessions first
|
|
94
|
+
inside each group; the TUI picker and sidebar keep sessions recency-ordered
|
|
95
|
+
within each directory. The home list puts pinned sessions first.
|
|
94
96
|
|
|
95
97
|
**Output contract** (TSV, 6 fields):
|
|
96
98
|
`agentId sessionId liveState display cwd trackingId`. `display` is the only
|
|
@@ -106,7 +108,8 @@ live-agent API, so `liveState` is always `unknown` and rows render gray unless
|
|
|
106
108
|
they are archived (opt-in, tagged `· archived`);
|
|
107
109
|
dispatch is always `opencode --session <id>`; Ctrl-F or `--fork` first creates the
|
|
108
110
|
fork with `opencode api session.fork` (the CLI has no fork flag) and resumes the new id.
|
|
109
|
-
Resume
|
|
111
|
+
Resume runs opencode in a subshell at the session's cwd in the same terminal;
|
|
112
|
+
when opencode exits, the picker reopens with the query preserved.
|
|
110
113
|
|
|
111
114
|
## Conventions
|
|
112
115
|
|
|
@@ -130,7 +133,7 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
|
130
133
|
- Destructive actions confirm. `sesh-delete.sh` prompts on a TTY and refuses
|
|
131
134
|
without `--yes` when stdin is not one; the TUI arms on the first Option-D and
|
|
132
135
|
commits on the second (or `y`).
|
|
133
|
-
- The installer prefers the local opencode version for `@opencode
|
|
136
|
+
- The installer prefers the local opencode version for `@opencode/*` and
|
|
134
137
|
merges (never clobbers) the config `package.json`; opencode runs `bun install`
|
|
135
138
|
at startup.
|
|
136
139
|
- 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.1] - 2026-10-10
|
|
9
|
+
|
|
10
|
+
### Fixed
|
|
11
|
+
|
|
12
|
+
- **packaging:** Add root export for opencode plugin and broaden opentui peer range
|
|
13
|
+
|
|
14
|
+
## [2.0.0] - 2026-10-10
|
|
15
|
+
|
|
16
|
+
### Breaking changes
|
|
17
|
+
|
|
18
|
+
- Finish V2 session compatibility and prune semantics _(breaking)_
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- **tui:** Align sidebar search with Sessions heading
|
|
23
|
+
|
|
8
24
|
## [1.1.0] - 2026-10-09
|
|
9
25
|
|
|
10
26
|
### Fixed
|
|
@@ -472,6 +488,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
472
488
|
- Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
|
|
473
489
|
CI.
|
|
474
490
|
|
|
491
|
+
[2.0.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v2.0.0...v2.0.1
|
|
492
|
+
[2.0.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.1.0...v2.0.0
|
|
475
493
|
[1.1.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.8...v1.1.0
|
|
476
494
|
[1.0.8]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.7...v1.0.8
|
|
477
495
|
[1.0.7]: https://github.com/ozandogrultan/opencode-sesh/compare/v1.0.6...v1.0.7
|
package/README.md
CHANGED
|
@@ -12,14 +12,13 @@ The TUI plugin and agent tool require OpenCode V2 and are not compatible with V1
|
|
|
12
12
|
`/sessions`) are scoped to the current project. `sesh` shows all of them —
|
|
13
13
|
grouped by directory, full-text searchable, and previewable from anywhere.
|
|
14
14
|
|
|
15
|
-
[](https://github.com/ozandogrultan/opencode-sesh/actions/workflows/ci.yml)
|
|
16
15
|
[](LICENSE)
|
|
17
16
|
[](#contributing)
|
|
18
17
|
[](https://github.com/ozandogrultan/opencode-sesh/stargazers)
|
|
19
18
|
|
|
20
19
|
```text
|
|
21
20
|
Sessions (8) ▾ hide
|
|
22
|
-
|
|
21
|
+
search…
|
|
23
22
|
▾ …/Projects/portal (5)
|
|
24
23
|
├○ Retry the OAuth token refr… 2h
|
|
25
24
|
├○ Login rate limiting notes 3d
|
|
@@ -35,26 +34,49 @@ Sessions (8) ▾ hide
|
|
|
35
34
|
## Why sesh
|
|
36
35
|
|
|
37
36
|
- **Every session, everywhere.** Sessions are grouped by project directory and
|
|
38
|
-
|
|
37
|
+
shown across *all* your projects, not just the current one. Archived and
|
|
38
|
+
subagent child sessions are hidden by default.
|
|
39
39
|
- **Pin what matters.** Ctrl-S pins a session and Ctrl-D pins its directory in
|
|
40
|
-
either picker
|
|
41
|
-
persist across restarts
|
|
40
|
+
either picker; the TUI sidebar and home list use Option-S and Option-R.
|
|
41
|
+
Pins are shared and persist across restarts. Pinned directories rise first;
|
|
42
|
+
pinned sessions sort first in the terminal picker and home list. The TUI picker
|
|
43
|
+
and sidebar keep sessions within each directory recency-ordered.
|
|
42
44
|
- **Full-text search.** Type to match against session titles *and* the text of
|
|
43
45
|
the conversation itself. Reasoning and tool output are excluded, so the index
|
|
44
46
|
stays clean.
|
|
45
|
-
- **Transcript preview.**
|
|
46
|
-
Markdown (with `glow`
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
- **Transcript preview.** Ctrl-P in the terminal picker or Option-P in the TUI
|
|
48
|
+
shows the most recent messages first, rendered as Markdown (with `glow` in
|
|
49
|
+
the terminal if you have it).
|
|
50
|
+
- **Resume in place.** Enter runs `opencode --session <id>` in the session's
|
|
51
|
+
own directory. Ctrl-F forks instead. The session opens in the same terminal,
|
|
52
|
+
and exiting opencode returns to the picker — no tabs, panes, or window management.
|
|
53
|
+
- **Native TUI, too.** A sessions section in the opencode sidebar plus an
|
|
51
54
|
`option+o` picker, both themed by your opencode theme.
|
|
52
55
|
- **Fast.** The database is scanned once per refresh and cached; keystrokes only
|
|
53
56
|
re-render the last snapshot, so typing never triggers a query storm.
|
|
54
57
|
|
|
55
58
|
## Install
|
|
56
59
|
|
|
57
|
-
|
|
60
|
+
### OpenCode plugin
|
|
61
|
+
|
|
62
|
+
Add the package to `plugins` in your OpenCode V2 `opencode.json` or
|
|
63
|
+
`opencode.jsonc`:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"$schema": "https://opencode.ai/config.json",
|
|
68
|
+
"plugins": ["opencode-sesh"]
|
|
69
|
+
}
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
OpenCode installs the package and loads its server tool and TUI panel. Merge
|
|
73
|
+
this entry with your existing plugins. Remove the copied
|
|
74
|
+
`./tui-plugins/sesh-panel` registration and `plugins/sesh-list.ts` tool before
|
|
75
|
+
switching to avoid loading them twice.
|
|
76
|
+
|
|
77
|
+
### Terminal picker
|
|
78
|
+
|
|
79
|
+
Install the CLI with npm:
|
|
58
80
|
|
|
59
81
|
```bash
|
|
60
82
|
npm install -g opencode-sesh
|
|
@@ -71,7 +93,7 @@ bash install.sh
|
|
|
71
93
|
|
|
72
94
|
`sesh install` (or `bash install.sh`) links `sesh` into `$XDG_BIN_HOME`
|
|
73
95
|
(default `~/.local/bin`), copies
|
|
74
|
-
the `
|
|
96
|
+
the `sesh_list` tool and the TUI panel into
|
|
75
97
|
`${XDG_CONFIG_HOME:-~/.config}/opencode`, registers the panel in `opencode.json`, and
|
|
76
98
|
declares the plugin dependencies (opencode installs them on next start). The
|
|
77
99
|
panel registers the `/sesh` slash command. It never edits your shell rc, and any
|
|
@@ -137,23 +159,26 @@ Flags: `--cwd` (current directory only), `--limit N` (default: all),
|
|
|
137
159
|
`--json` (with `--print`, emit JSON), `--query TEXT`, `--check`,
|
|
138
160
|
`--needs-input` (list sessions waiting on you instead of opening the picker).
|
|
139
161
|
|
|
140
|
-
`sesh prune [--older-than 30d] [--dry-run] [--yes]
|
|
141
|
-
sessions (not updated within the threshold)
|
|
142
|
-
Pinned sessions, sessions waiting on you, subagent child sessions and already-archived
|
|
143
|
-
sessions are never
|
|
144
|
-
hard-deletes through `opencode session delete`. Without `--dry-run`, a TTY run
|
|
162
|
+
`sesh prune [--older-than 30d] [--dry-run] [--yes]` deletes stale
|
|
163
|
+
sessions (not updated within the threshold) through `opencode session delete`, including their children.
|
|
164
|
+
Pinned sessions and directories, sessions waiting on you, subagent child sessions and already-archived
|
|
165
|
+
sessions are never selected. Without `--dry-run`, a TTY run
|
|
145
166
|
confirms first and a non-TTY run needs `--yes`.
|
|
146
167
|
|
|
147
168
|
`sesh costs [--days N] [--json]` sums assistant-message cost per project, with a
|
|
148
169
|
recent window beside the lifetime total, so a day's work reads as a
|
|
149
|
-
per-project record.
|
|
170
|
+
per-project record. Legacy V1 history is included when available; sessions
|
|
171
|
+
already present in V2 are counted only once.
|
|
172
|
+
|
|
173
|
+
Session browsing uses the V2 store. Sessions remaining only in the legacy V1
|
|
174
|
+
tables are not listed or resumable through sesh.
|
|
150
175
|
|
|
151
176
|
When searching, title matches outrank transcript-only matches and the current
|
|
152
177
|
project rises, so the list answers "where was that thing I worked on" before it
|
|
153
178
|
answers "what is newest". Pins still win outright.
|
|
154
179
|
|
|
155
|
-
|
|
156
|
-
|
|
180
|
+
`--print` still opens the interactive picker, but emits the selected id and
|
|
181
|
+
directory instead of resuming. `--query` supplies the initial search text:
|
|
157
182
|
|
|
158
183
|
```bash
|
|
159
184
|
read -r id cwd < <(sesh --print --query "auth")
|
|
@@ -163,7 +188,7 @@ sesh --print --json --query "auth" | jq -r .cwd
|
|
|
163
188
|
Deleting is irreversible, so the picker asks before dispatching it, and a
|
|
164
189
|
non-interactive `sesh-delete.sh` refuses unless given `--yes`.
|
|
165
190
|
|
|
166
|
-
### TUI
|
|
191
|
+
### TUI picker
|
|
167
192
|
|
|
168
193
|
| Key | Action |
|
|
169
194
|
| --- | --- |
|
|
@@ -175,13 +200,16 @@ non-interactive `sesh-delete.sh` refuses unless given `--yes`.
|
|
|
175
200
|
| `↑`/`↓`, `PgUp`/`PgDn`, `Home`/`End` | Move the selection |
|
|
176
201
|
| Option-P | Toggle the transcript preview |
|
|
177
202
|
| Enter | Open the selected session |
|
|
178
|
-
| Option-D |
|
|
203
|
+
| Option-D | Arm deletion; press Option-D again, `y`, or Enter to confirm |
|
|
179
204
|
| Ctrl-F | Fork the selected session |
|
|
180
205
|
| Ctrl-G | Toggle scope: the selected session's project, or every project |
|
|
181
206
|
| Option-W / Option-S | Show only sessions needing input / pinned sessions |
|
|
182
207
|
| Ctrl-S / Ctrl-D | Pin or unpin the selected session / directory |
|
|
183
208
|
| Esc | Close the preview first, then the picker |
|
|
184
209
|
|
|
210
|
+
While deletion is armed, `n` or Esc cancels; it also cancels after five seconds.
|
|
211
|
+
Option-O opens the picker globally; the other keys above apply inside it.
|
|
212
|
+
|
|
185
213
|
The picker groups sessions under collapsible directory headings (click ▾/▸).
|
|
186
214
|
Mouse-wheel scrolling moves through the tree without changing the selection
|
|
187
215
|
under a stationary pointer. Option-P opens a full-size transcript preview over
|
|
@@ -193,10 +221,14 @@ action, or Esc to dismiss the menu.
|
|
|
193
221
|
Right-click a session in the home list or picker to open it in a new cmux
|
|
194
222
|
workspace at its directory; outside cmux it shows a toast instead.
|
|
195
223
|
|
|
196
|
-
|
|
224
|
+
### TUI sidebar and home list
|
|
225
|
+
|
|
226
|
+
Click the sidebar's `search…` box to filter sessions in place; click
|
|
197
227
|
elsewhere or press Esc to leave search. Click the **Sessions** heading to drive
|
|
198
228
|
the list from the keyboard instead of the mouse (`↑`/`↓` to move, Enter to open,
|
|
199
|
-
Option-P to preview, Option-S
|
|
229
|
+
Option-P to preview, Option-S to pin a session, Option-R to pin its directory,
|
|
230
|
+
Option-D to arm deletion, Esc to leave). Press Option-D again or `y` to confirm
|
|
231
|
+
an armed deletion; `n` or Esc cancels. Pinned
|
|
200
232
|
directories rise first, while sessions within each directory are ordered by
|
|
201
233
|
last update, newest first. The section lists every session and scrolls to fill
|
|
202
234
|
the sidebar, so click **▾ hide** to collapse it when you need the room.
|
|
@@ -209,6 +241,12 @@ Hovering a row while keyboard navigation is active selects it for the next
|
|
|
209
241
|
shortcut. Clicking (or Enter on) a session opens it here. The full picker is one
|
|
210
242
|
keystroke away.
|
|
211
243
|
|
|
244
|
+
The home list also supports search and the Option-P, Option-S, Option-R, and
|
|
245
|
+
Option-D actions on the hovered session. These Option pin shortcuts do not
|
|
246
|
+
replace the picker's Ctrl-S/Ctrl-D bindings; Option-S in the picker filters pins.
|
|
247
|
+
|
|
248
|
+
### Transcript search and needs-input triage
|
|
249
|
+
|
|
212
250
|
The full picker pages through your whole global session list (no fixed window)
|
|
213
251
|
and indexes transcript text for every session in the background, showing
|
|
214
252
|
indexing progress under the search box. It only stops at a safety cap of 5,000
|
|
@@ -225,12 +263,12 @@ All variables are optional.
|
|
|
225
263
|
|
|
226
264
|
| Variable | Default | Effect |
|
|
227
265
|
| --- | --- | --- |
|
|
228
|
-
| `SESH_DB` | `opencode debug paths` | opencode SQLite database |
|
|
266
|
+
| `SESH_DB` | `${XDG_DATA_HOME:-~/.local/share}/opencode/opencode.db`, falling back to `opencode debug paths` | opencode SQLite database |
|
|
229
267
|
| `SESH_SQLITE` | `sqlite3` | query tool |
|
|
230
268
|
| `SESH_JQ` / `SESH_FZF` | `PATH` | explicit executable overrides |
|
|
231
269
|
| `SESH_GLOW` | first `glow` on `PATH` | optional Markdown preview renderer |
|
|
232
270
|
| `SESH_OPENCODE` | `opencode` | opencode executable (resume / delete) |
|
|
233
|
-
| `SESH_CACHE_DIR` |
|
|
271
|
+
| `SESH_CACHE_DIR` | `${XDG_CACHE_HOME:-~/.cache}/sesh` | persistent search-index cache |
|
|
234
272
|
| `SESH_PINS_FILE` | `${XDG_DATA_HOME:-~/.local/share}/sesh/pins.json` | shared persistent session/directory pins |
|
|
235
273
|
|
|
236
274
|
## Integrations
|
|
@@ -256,7 +294,7 @@ Flat scripts back the terminal UI — the picker (`sesh.sh`), the refresh engine
|
|
|
256
294
|
scans), preview renderer (`sesh-preview.sh`), deleter (`sesh-delete.sh`) and
|
|
257
295
|
shortcut help (`sesh-shortcuts.sh`). The TUI panel (`tui-plugins/sesh-panel/tui.tsx`) is a
|
|
258
296
|
SolidJS OpenTUI plugin that talks to the opencode SDK over the same store; the
|
|
259
|
-
`
|
|
297
|
+
`sesh_list` agent tool reads it read-only through `bun:sqlite`. V2 discovers
|
|
260
298
|
the installed tool automatically in the global config directory's `plugins/`.
|
|
261
299
|
|
|
262
300
|
## FAQ
|
|
@@ -264,12 +302,13 @@ the installed tool automatically in the global config directory's `plugins/`.
|
|
|
264
302
|
**Does it replace opencode's native session list?** No. `<leader>l` and the
|
|
265
303
|
native `/sessions` command are untouched; `sesh` is additive.
|
|
266
304
|
|
|
267
|
-
**Is there an agent-facing list?** Yes — the `
|
|
305
|
+
**Is there an agent-facing list?** Yes — the `sesh_list` tool lets the model list
|
|
268
306
|
every session across all directories (the native `opencode session list` only
|
|
269
307
|
covers the current project) and offer to resume one.
|
|
270
308
|
|
|
271
309
|
**Where is my data?** It reads the opencode database read-only and caches
|
|
272
|
-
extracted text under
|
|
310
|
+
extracted text under `${XDG_CACHE_HOME:-~/.cache}/sesh` (or `SESH_CACHE_DIR`).
|
|
311
|
+
Nothing is uploaded anywhere.
|
|
273
312
|
|
|
274
313
|
**Does it work on Windows?** It targets macOS and Linux. WSL should work; native
|
|
275
314
|
Windows is untested.
|
package/bin/sesh-costs.sh
CHANGED
|
@@ -32,7 +32,7 @@ resolve_db() {
|
|
|
32
32
|
local default="${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db" from_cli=''
|
|
33
33
|
if [ -f "$default" ]; then printf '%s\n' "$default"; return 0; fi
|
|
34
34
|
if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
|
|
35
|
-
from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" {
|
|
35
|
+
from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { sub(/^db[ \t]+/, ""); print; exit }' || true)
|
|
36
36
|
fi
|
|
37
37
|
if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
|
|
38
38
|
return 1
|
|
@@ -51,19 +51,33 @@ 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
83
|
[ -n "$SQLITE_BIN" ] || { echo 'sesh: sqlite3 is required' >&2; exit 1; }
|
package/bin/sesh-list.sh
CHANGED
|
@@ -79,7 +79,7 @@ resolve_db() {
|
|
|
79
79
|
local default="${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db" from_cli=''
|
|
80
80
|
if [ -f "$default" ]; then printf '%s\n' "$default"; return 0; fi
|
|
81
81
|
if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
|
|
82
|
-
from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" {
|
|
82
|
+
from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { sub(/^db[ \t]+/, ""); print; exit }' || true)
|
|
83
83
|
fi
|
|
84
84
|
if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
|
|
85
85
|
return 1
|
|
@@ -279,7 +279,7 @@ refresh() {
|
|
|
279
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"
|
|
280
280
|
"$JQ_BIN" -e 'type == "array"' "$parts" >/dev/null 2>&1 || printf '[]\n' > "$parts"
|
|
281
281
|
|
|
282
|
-
# Same bulk cache validation as the
|
|
282
|
+
# Same bulk cache validation as the full list: every cache arrives as its
|
|
283
283
|
# own file argument (ARG_MAX), records match on session id plus the two
|
|
284
284
|
# change signals, and only vanished sessions are pruned — metadata always
|
|
285
285
|
# covers the whole database, so pruning is safe under any scope or limit.
|
|
@@ -378,8 +378,8 @@ refresh() {
|
|
|
378
378
|
| ($hist | map({key: .sessionId, value: .}) | from_entries) as $histById
|
|
379
379
|
| [$meta[]
|
|
380
380
|
| select((.id | type) == "string" and (.id | test("^ses_[A-Za-z0-9]+$")))
|
|
381
|
-
# Child sessions (
|
|
382
|
-
#
|
|
381
|
+
# Child sessions (spawned subagents) surface only with --archived;
|
|
382
|
+
# metadata still sees them, so cache pruning stays safe.
|
|
383
383
|
| select($archived == 1 or (.archived == 0 and .parent == ""))
|
|
384
384
|
| select($scope == "" or .directory == $scope)
|
|
385
385
|
| . as $m | ($histById[$m.id] // {}) as $h
|
package/bin/sesh-preview.sh
CHANGED
|
@@ -17,7 +17,7 @@ JQ_BIN=${SESH_JQ:-jq}
|
|
|
17
17
|
OPENCODE_BIN=${SESH_OPENCODE:-opencode}
|
|
18
18
|
DB_PATH=${SESH_DB:-"${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db"}
|
|
19
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" {
|
|
20
|
+
DB_PATH=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { sub(/^db[ \t]+/, ""); print; exit }' || true)
|
|
21
21
|
fi
|
|
22
22
|
[ -f "$DB_PATH" ] || { echo "(opencode database is unavailable)"; exit 0; }
|
|
23
23
|
|
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
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 sqlite3 — the opencode server API has no
|
|
9
|
-
# archive endpoint, so a direct write is the only route (deletes stay on the
|
|
10
|
-
# 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
|
|
@@ -56,7 +48,7 @@ resolve_db() {
|
|
|
56
48
|
local default="${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db" from_cli=''
|
|
57
49
|
if [ -f "$default" ]; then printf '%s\n' "$default"; return 0; fi
|
|
58
50
|
if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
|
|
59
|
-
from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" {
|
|
51
|
+
from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { sub(/^db[ \t]+/, ""); print; exit }' || true)
|
|
60
52
|
fi
|
|
61
53
|
if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
|
|
62
54
|
return 1
|
|
@@ -74,7 +66,6 @@ DB_PATH=$(resolve_db) || { echo 'sesh: session database unavailable' >&2; exit 1
|
|
|
74
66
|
|
|
75
67
|
[ -n "$SQLITE_BIN" ] || { echo 'sesh: sqlite3 is required' >&2; exit 1; }
|
|
76
68
|
query() { "$SQLITE_BIN" -json -readonly "$DB_PATH" "$1"; }
|
|
77
|
-
write_db() { "$SQLITE_BIN" "$DB_PATH" "$1"; }
|
|
78
69
|
|
|
79
70
|
JQ_BIN=${SESH_JQ:-$(command -v jq || true)}
|
|
80
71
|
[ -n "$JQ_BIN" ] || { echo 'sesh: jq is required' >&2; exit 1; }
|
|
@@ -98,8 +89,7 @@ ELIGIBLE=$(printf '%s' "$CANDIDATES" | "$JQ_BIN" -c --slurpfile _pins <(printf '
|
|
|
98
89
|
]
|
|
99
90
|
')
|
|
100
91
|
COUNT=$(printf '%s' "$ELIGIBLE" | "$JQ_BIN" -r 'length')
|
|
101
|
-
VERB=
|
|
102
|
-
[ "$hard_delete" = 0 ] || VERB=Delete
|
|
92
|
+
VERB=Delete
|
|
103
93
|
|
|
104
94
|
if [ "$COUNT" = 0 ]; then
|
|
105
95
|
echo "Nothing to prune older than $older_than."
|
|
@@ -137,29 +127,17 @@ if [ "$assume_yes" = 0 ]; then
|
|
|
137
127
|
fi
|
|
138
128
|
fi
|
|
139
129
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
for id in $(printf '%s' "$ELIGIBLE" | "$JQ_BIN" -r '.[].id'); do
|
|
155
|
-
[[ "$id" =~ ^ses_[A-Za-z0-9]+$ ]] || { echo "Refusing: unexpected session id shape." >&2; exit 1; }
|
|
156
|
-
if "$OPENCODE_BIN" session delete "$id" >/dev/null 2>&1; then
|
|
157
|
-
done_count=$((done_count + 1))
|
|
158
|
-
else
|
|
159
|
-
echo "Could not delete $id." >&2
|
|
160
|
-
failed=$((failed + 1))
|
|
161
|
-
fi
|
|
162
|
-
done
|
|
163
|
-
echo "Deleted $done_count session(s) older than $older_than."
|
|
164
|
-
[ "$failed" = 0 ] || exit 1
|
|
165
|
-
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-shortcuts.sh
CHANGED
|
@@ -15,6 +15,11 @@ sesh shortcuts
|
|
|
15
15
|
Escape Exit the picker
|
|
16
16
|
|
|
17
17
|
Flags: --cwd (current directory only), --limit N (default: all),
|
|
18
|
-
--archived (include archived
|
|
19
|
-
--
|
|
18
|
+
--archived (include archived and child sessions),
|
|
19
|
+
--print (print selected id<TAB>cwd instead of resuming),
|
|
20
|
+
--json (with --print, emit JSON), --fork (resume as a fork),
|
|
21
|
+
--query TEXT (initial search), --check (check dependencies),
|
|
22
|
+
--needs-input [--json] (list sessions waiting on you)
|
|
23
|
+
|
|
24
|
+
--print still opens the picker; select a session with Enter or Ctrl-F.
|
|
20
25
|
HELP
|
package/bin/sesh-waiting.sh
CHANGED
|
@@ -5,12 +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
|
|
8
|
+
# A session waits when it is not archived, is not a child (subagent) session,
|
|
9
|
+
# and either
|
|
9
10
|
# - has a `question` tool part still pending or running with no later user
|
|
10
11
|
# message in the same session (awaiting an answer; a dismissed question
|
|
11
12
|
# that errored is not waiting), or
|
|
12
|
-
# - has a `tool` part still marked `running` older than STUCK_AFTER_MS
|
|
13
|
-
#
|
|
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).
|
|
14
18
|
#
|
|
15
19
|
# Usage: sesh-waiting.sh [--json]
|
|
16
20
|
# default prints a human table; --json prints [{id,reason,title,directory,updated}]
|
|
@@ -34,7 +38,7 @@ resolve_db() {
|
|
|
34
38
|
local default="${XDG_DATA_HOME:-$HOME/.local/share}/opencode/opencode.db" from_cli=''
|
|
35
39
|
if [ -f "$default" ]; then printf '%s\n' "$default"; return 0; fi
|
|
36
40
|
if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
|
|
37
|
-
from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" {
|
|
41
|
+
from_cli=$("$OPENCODE_BIN" debug paths 2>/dev/null | awk '$1 == "db" { sub(/^db[ \t]+/, ""); print; exit }' || true)
|
|
38
42
|
fi
|
|
39
43
|
if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
|
|
40
44
|
return 1
|
|
@@ -83,9 +87,12 @@ WHERE COALESCE(s.time_archived, 0) = 0
|
|
|
83
87
|
AND NOT EXISTS (
|
|
84
88
|
SELECT 1 FROM session_message u
|
|
85
89
|
WHERE u.session_id = live.session_id AND u.type = 'user' AND u.seq > live.seq)
|
|
86
|
-
) OR EXISTS (
|
|
90
|
+
) OR (EXISTS (
|
|
87
91
|
SELECT 1 FROM live
|
|
88
|
-
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)))
|
|
89
96
|
ORDER BY s.time_updated DESC;
|
|
90
97
|
SQL
|
|
91
98
|
|
package/bin/sesh.sh
CHANGED
|
@@ -203,11 +203,15 @@ 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
|
-
|
|
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)
|
|
207
208
|
if [[ "$forked" =~ ^ses_[A-Za-z0-9]+$ ]]; then
|
|
208
209
|
session_id=$forked
|
|
210
|
+
rm -f "$fork_err"
|
|
209
211
|
else
|
|
210
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"
|
|
211
215
|
continue
|
|
212
216
|
fi
|
|
213
217
|
fi
|
package/contrib/cmux/README.md
CHANGED
|
@@ -20,7 +20,8 @@ Paste only the actions you want; the `ui.newWorkspace` block is optional.
|
|
|
20
20
|
|
|
21
21
|
## Resume a session in its own cmux workspace
|
|
22
22
|
|
|
23
|
-
The picker
|
|
23
|
+
The picker runs opencode in the selected session's directory and returns to the
|
|
24
|
+
picker when opencode exits. To instead open a
|
|
24
25
|
session in a **new cmux workspace** at its directory, shell out to cmux after
|
|
25
26
|
resolving the session with sesh's scriptable lookup:
|
|
26
27
|
|
|
@@ -57,9 +58,10 @@ sesh is a normal CLI, so any system-wide launcher works — a cmux `shortcut`, a
|
|
|
57
58
|
cmd + shift - s : cmux new-workspace --command "sesh"
|
|
58
59
|
```
|
|
59
60
|
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
`--print --json` still opens the interactive picker; `--query` initializes its
|
|
62
|
+
search and `--limit` caps the list rather than automatically accepting a result.
|
|
63
|
+
To resume a known session without the picker, use its id and directory directly:
|
|
62
64
|
|
|
63
65
|
```bash
|
|
64
|
-
cmd + alt - r : cmux new-workspace --command "opencode --session
|
|
66
|
+
cmd + alt - r : cmux new-workspace --cwd /path/to/project --command "opencode --session ses_EXAMPLE"
|
|
65
67
|
```
|
package/install.sh
CHANGED
|
@@ -24,7 +24,7 @@ SYNC_PANEL=0
|
|
|
24
24
|
|
|
25
25
|
# Fallback plugin version for when the opencode CLI is unavailable; the
|
|
26
26
|
# installer prefers the local opencode version so the panel matches its SDK.
|
|
27
|
-
PLUGIN_VERSION_FALLBACK="2.0.
|
|
27
|
+
PLUGIN_VERSION_FALLBACK="2.0.25"
|
|
28
28
|
# Peer line the panel was developed and typechecked against. A checkout's
|
|
29
29
|
# package.json overrides these, so dependency bumps cannot drift the installed
|
|
30
30
|
# config out of sync (solid-js moves with OpenTUI via @opentui/keymap's peer).
|
|
@@ -42,7 +42,7 @@ usage() {
|
|
|
42
42
|
Usage: bash install.sh [--bin-dir DIR] [--no-tool] [--no-tui] [--uninstall]
|
|
43
43
|
|
|
44
44
|
--bin-dir DIR Directory for the sesh launcher (default: ~/.local/bin).
|
|
45
|
-
--no-tool Skip installing the
|
|
45
|
+
--no-tool Skip installing the sesh_list custom tool.
|
|
46
46
|
--no-tui Skip installing the in-TUI sessions panel plugin.
|
|
47
47
|
--uninstall Remove everything this script installed.
|
|
48
48
|
--sync-panel Refresh an already-installed TUI panel only (used by postinstall).
|
|
@@ -314,7 +314,7 @@ fi
|
|
|
314
314
|
"$LAUNCHER" --check || fail "post-install check failed."
|
|
315
315
|
note "done"
|
|
316
316
|
note " picker: run 'sesh' in any terminal, or '/sesh' inside opencode"
|
|
317
|
-
note "
|
|
317
|
+
note " TUI: Option-O opens the full picker"
|
|
318
318
|
note_restart
|
|
319
319
|
case ":$PATH:" in
|
|
320
320
|
*":$BIN_DIR:"*) ;;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-sesh",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"cli"
|
|
23
23
|
],
|
|
24
24
|
"exports": {
|
|
25
|
+
".": "./plugins/sesh-list.ts",
|
|
25
26
|
"./tui": "./tui-plugins/sesh-panel/tui.tsx",
|
|
26
27
|
"./tool": "./plugins/sesh-list.ts"
|
|
27
28
|
},
|
|
@@ -74,7 +75,7 @@
|
|
|
74
75
|
"@opentui/core": "^0.5.11",
|
|
75
76
|
"@opentui/keymap": "^0.5.11",
|
|
76
77
|
"@opentui/solid": "^0.5.11",
|
|
77
|
-
"@types/node": "26.
|
|
78
|
+
"@types/node": "26.6.4",
|
|
78
79
|
"husky": "^9.1.7",
|
|
79
80
|
"solid-js": "1.9.15",
|
|
80
81
|
"typescript": "7.0.2"
|
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
7
|
function dbPath(): string {
|
|
6
|
-
|
|
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 = {
|
|
@@ -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
|
|
|
@@ -16,8 +17,8 @@ type ThemeColors = TuiPluginApi["theme"]["current"]
|
|
|
16
17
|
|
|
17
18
|
let markdownStyleCache: { theme: ThemeColors; style: SyntaxStyle } | undefined
|
|
18
19
|
|
|
19
|
-
function buildMarkdownStyle(theme: ThemeColors): SyntaxStyle {
|
|
20
|
-
return SyntaxStyle.fromTheme([
|
|
20
|
+
function buildMarkdownStyle(theme: ThemeColors, owner?: unknown): SyntaxStyle {
|
|
21
|
+
return (SyntaxStyle as any).fromTheme([
|
|
21
22
|
{ scope: ["default"], style: { foreground: theme.text } },
|
|
22
23
|
{ scope: ["comment", "comment.documentation"], style: { foreground: theme.syntaxComment, italic: true } },
|
|
23
24
|
{ scope: ["string", "symbol", "character"], style: { foreground: theme.syntaxString } },
|
|
@@ -80,17 +81,17 @@ function buildMarkdownStyle(theme: ThemeColors): SyntaxStyle {
|
|
|
80
81
|
},
|
|
81
82
|
{ scope: ["markup.link.label", "label"], style: { foreground: theme.markdownLinkText, underline: true } },
|
|
82
83
|
{ scope: ["conceal"], style: { foreground: theme.textMuted } },
|
|
83
|
-
])
|
|
84
|
+
], owner)
|
|
84
85
|
}
|
|
85
86
|
|
|
86
|
-
function getMarkdownStyle(theme: ThemeColors): SyntaxStyle | undefined {
|
|
87
|
+
function getMarkdownStyle(theme: ThemeColors, owner?: unknown): SyntaxStyle | undefined {
|
|
87
88
|
if (markdownStyleCache?.theme === theme) return markdownStyleCache?.style
|
|
88
89
|
try {
|
|
89
|
-
markdownStyleCache = { theme, style: buildMarkdownStyle(theme) }
|
|
90
|
+
markdownStyleCache = { theme, style: buildMarkdownStyle(theme, owner) }
|
|
90
91
|
return markdownStyleCache?.style
|
|
91
92
|
} catch {
|
|
92
93
|
try {
|
|
93
|
-
return SyntaxStyle.create()
|
|
94
|
+
return (SyntaxStyle as any).create(owner)
|
|
94
95
|
} catch {
|
|
95
96
|
return undefined
|
|
96
97
|
}
|
|
@@ -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 () => {
|
|
@@ -865,10 +907,10 @@ function createTranscriptPreview(api: TuiPluginApi) {
|
|
|
865
907
|
scrollbarOptions={{ visible: false }}
|
|
866
908
|
maxHeight={Math.max(6, Math.floor(api.renderer.height / 2))}
|
|
867
909
|
>
|
|
868
|
-
{getMarkdownStyle(api.theme.current) ? (
|
|
910
|
+
{getMarkdownStyle(api.theme.current, (api.renderer as any)?.nativeSession) ? (
|
|
869
911
|
<markdown
|
|
870
912
|
content={previewText()}
|
|
871
|
-
syntaxStyle={getMarkdownStyle(api.theme.current)!}
|
|
913
|
+
syntaxStyle={getMarkdownStyle(api.theme.current, (api.renderer as any)?.nativeSession)!}
|
|
872
914
|
treeSitterClient={getTreeSitterClient()}
|
|
873
915
|
/>
|
|
874
916
|
) : (
|
|
@@ -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)
|
|
@@ -2812,10 +2854,10 @@ const tui: TuiPlugin = async (api) => {
|
|
|
2812
2854
|
<Show when={previewID()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Select a session to preview</text>}>
|
|
2813
2855
|
<Show when={previewText()} fallback={<text style={{ fg: api.theme.current.textMuted }}>Loading…</text>}>
|
|
2814
2856
|
{(text: Accessor<string>) =>
|
|
2815
|
-
getMarkdownStyle(api.theme.current) ? (
|
|
2857
|
+
getMarkdownStyle(api.theme.current, (api.renderer as any)?.nativeSession) ? (
|
|
2816
2858
|
<markdown
|
|
2817
2859
|
content={text()}
|
|
2818
|
-
syntaxStyle={getMarkdownStyle(api.theme.current)!}
|
|
2860
|
+
syntaxStyle={getMarkdownStyle(api.theme.current, (api.renderer as any)?.nativeSession)!}
|
|
2819
2861
|
treeSitterClient={getTreeSitterClient()}
|
|
2820
2862
|
/>
|
|
2821
2863
|
) : (
|
|
@@ -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
|
|
@@ -3261,6 +3294,9 @@ function createApi(ctx: Plugin.Context) {
|
|
|
3261
3294
|
get currentFocusedEditor() {
|
|
3262
3295
|
return ctx.renderer.currentFocusedEditor
|
|
3263
3296
|
},
|
|
3297
|
+
get nativeSession() {
|
|
3298
|
+
return (ctx.renderer as any)?.nativeSession
|
|
3299
|
+
},
|
|
3264
3300
|
on(event: string, handler: (...args: any[]) => void) {
|
|
3265
3301
|
ctx.renderer.on(event, handler)
|
|
3266
3302
|
},
|