opencode-sesh 0.1.7 → 0.2.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 +69 -16
- package/CHANGELOG.md +39 -1
- package/README.md +39 -21
- package/bin/sesh-delete.sh +38 -4
- package/bin/sesh-list.sh +57 -9
- package/bin/sesh-shortcuts.sh +3 -2
- package/bin/sesh.sh +47 -16
- package/package.json +6 -4
- package/tui/sesh-panel.tsx +394 -58
package/AGENTS.md
CHANGED
|
@@ -23,10 +23,12 @@ slot section plus an xlarge modal picker; the terminal UI is fullscreen fzf.
|
|
|
23
23
|
## Start here
|
|
24
24
|
|
|
25
25
|
```bash
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
26
|
+
bun install # dev deps (TypeScript, opencode/OpenTUI types)
|
|
27
|
+
bun run test # fixture-DB regression suite — must stay green
|
|
28
|
+
bun run test:changelog # release-tooling regressions (scripts/changelog.sh)
|
|
29
|
+
bun run test:picker # PTY picker suite (needs fzf >= 0.73)
|
|
30
|
+
bun run typecheck # tsc over tui/ and opencode/
|
|
31
|
+
bun run lint:sh # bash -n on every script
|
|
30
32
|
HOME=/tmp/fakehome bash install.sh # installer smoke test (never touches ~/.config)
|
|
31
33
|
```
|
|
32
34
|
|
|
@@ -36,6 +38,9 @@ HOME=/tmp/fakehome bash install.sh # installer smoke test (never touches ~/.co
|
|
|
36
38
|
Never hardcode install paths.
|
|
37
39
|
- `tui/sesh-panel.tsx` — SolidJS panel, `/** @jsxImportSource @opentui/solid */`.
|
|
38
40
|
- `opencode/tools/sesh-list.ts` — the agent-facing `sesh-list` tool.
|
|
41
|
+
- `scripts/changelog.sh` — release tooling: drafts `[Unreleased]` from
|
|
42
|
+
conventional commits, promotes it to a dated version, prints release notes and
|
|
43
|
+
checks the compare links. `tests/changelog.sh` covers it in a throwaway repo.
|
|
39
44
|
- `themes/`, `tests/`, `install.sh`, `README.md`.
|
|
40
45
|
|
|
41
46
|
The **filename becomes the tool name** in opencode, so `sesh-list.ts` is the
|
|
@@ -60,7 +65,13 @@ the schema below. Keystrokes never touch the database:
|
|
|
60
65
|
the whole DB and pruning is always safe.
|
|
61
66
|
- render (cheap, pure `jq`): reads the snapshot only. Filters on `searchText`
|
|
62
67
|
(title + `fulltextLower`, both lowercased at extraction), groups by `cwd`,
|
|
63
|
-
sorts groups/items by recency, renders the TSV.
|
|
68
|
+
sorts groups/items by recency, renders the TSV. Notice rows (headers, a
|
|
69
|
+
vanished selection, an empty store, a query matching nothing) carry an empty
|
|
70
|
+
`sessionId` and a unique `trackingId`. `--header` prints the one-line
|
|
71
|
+
scope/count/status that the picker uses as fzf's header, and a non-actionable
|
|
72
|
+
selection leaves a one-shot `STATE_DIR/action-notice` the next render shows
|
|
73
|
+
and consumes. Snapshot records carry `archived` as 0/1 (the DB column is an
|
|
74
|
+
epoch, not a boolean).
|
|
64
75
|
|
|
65
76
|
**Output contract** (TSV, 6 fields):
|
|
66
77
|
`agentId sessionId liveState display cwd trackingId`. `display` is the only
|
|
@@ -69,8 +80,11 @@ empty `sessionId` and are a selection no-op — keep that guard.
|
|
|
69
80
|
|
|
70
81
|
One worker (`sesh-refresh-worker.sh`, per picker, TERMed on exit) serializes
|
|
71
82
|
scans; fzf's `start`/`every(3)`/`change` bindings render only. Selection requests
|
|
72
|
-
a fresh poll (`request`/`wait`, ≤12 s) before dispatch
|
|
73
|
-
|
|
83
|
+
a fresh poll (`request`/`wait`, ≤12 s) before dispatch — skipped for `--print`,
|
|
84
|
+
which is a pure snapshot lookup — and prints a progress line to stderr while it
|
|
85
|
+
waits, because fzf has already exited and the terminal is idle. opencode has no
|
|
86
|
+
live-agent API, so `liveState` is always `unknown` and rows render gray unless
|
|
87
|
+
they are archived (opt-in, tagged `· archived`);
|
|
74
88
|
dispatch is always `opencode --session <id>` (`--fork` with Ctrl-F or --fork).
|
|
75
89
|
Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
76
90
|
|
|
@@ -88,7 +102,12 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
|
88
102
|
raw string.
|
|
89
103
|
- fzf: stock only, `>= 0.73` (`transform:`, `every():`). `{q}` in binds is
|
|
90
104
|
shell-quoted by fzf — don't add quoting. Ctrl-G is bind-only (stays open);
|
|
91
|
-
Ctrl-F is `--expect` (accepts with fork).
|
|
105
|
+
Ctrl-F is `--expect` (accepts with fork). A `transform-header` inside a chained
|
|
106
|
+
action must come **before** `reload`/`reload-sync`: placed after, fzf silently
|
|
107
|
+
publishes an empty list (verified on 0.74.3).
|
|
108
|
+
- Destructive actions confirm. `sesh-delete.sh` prompts on a TTY and refuses
|
|
109
|
+
without `--yes` when stdin is not one; the TUI arms on the first Ctrl-X and
|
|
110
|
+
commits on the second (or `y`).
|
|
92
111
|
- The installer prefers the local opencode version for `@opencode-ai/*` and
|
|
93
112
|
merges (never clobbers) the config `package.json`; opencode runs `bun install`
|
|
94
113
|
at startup.
|
|
@@ -96,23 +115,37 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
|
96
115
|
(`feat`, `fix`, `docs`, `ci`, …). See
|
|
97
116
|
[CONTRIBUTING.md](CONTRIBUTING.md#commit-messages) for the types and the
|
|
98
117
|
version mapping.
|
|
118
|
+
- `CHANGELOG.md` follows Keep a Changelog: notable changes are written under
|
|
119
|
+
`[Unreleased]` as the work lands, and the Release workflow promotes that
|
|
120
|
+
section (dated, relinked) into the published version, using it verbatim as the
|
|
121
|
+
GitHub release body. `scripts/changelog.sh draft` can seed entries from
|
|
122
|
+
conventional commits, but hand-written prose is the expected form — never let
|
|
123
|
+
a draft overwrite curated entries.
|
|
99
124
|
|
|
100
125
|
## TUI panel (`tui/sesh-panel.tsx`)
|
|
101
126
|
|
|
102
127
|
- **Sidebar:** `api.slots.register` on `sidebar_content` (append mode — native
|
|
103
|
-
sidebar content stays). Renders the
|
|
104
|
-
current one highlighted, polled every 15 s
|
|
105
|
-
|
|
128
|
+
sidebar content stays). Renders the newest `SIDEBAR_LIMIT` unarchived sessions
|
|
129
|
+
with the current one highlighted, polled every 15 s; space previews a row and
|
|
130
|
+
ctrl+x deletes one (armed, then confirmed). Anything bulkier belongs to the
|
|
131
|
+
picker.
|
|
106
132
|
- **Picker:** a custom dialog (not `DialogSelect`) grouped by
|
|
107
133
|
project/directory, recency-ordered, resume via
|
|
108
134
|
`api.route.navigate("session", …)`. `ctrl+o` and the command palette open it;
|
|
109
|
-
Ctrl-X
|
|
110
|
-
|
|
135
|
+
Ctrl-X arms and then confirms a delete through `api.client.session.delete` and
|
|
136
|
+
removes the row only after the server confirms; Ctrl-F forks through
|
|
137
|
+
`api.client.session.fork`; Ctrl-G scopes the list to the selected session's
|
|
138
|
+
project.
|
|
111
139
|
- **Picker search:** pages the global session list (`SESSION_PAGE_LIMIT`, capped
|
|
112
140
|
at `SESSION_MAX` and reported when truncated) instead of a fixed 500-row
|
|
113
141
|
window, then indexes transcript text for **every** session in batches
|
|
114
142
|
(`TRANSCRIPT_BATCH`) with bounded remote concurrency (`REMOTE_CONCURRENCY`).
|
|
115
143
|
The header shows indexing progress/coverage. `tests/tui.mjs` locks this.
|
|
144
|
+
- **Durable install:** opencode imports plugins once at startup and never hot
|
|
145
|
+
reloads, so panel changes need a full quit/reopen. `install.sh --sync-panel`
|
|
146
|
+
(run by the `postinstall`) refreshes an already-installed panel and is a
|
|
147
|
+
no-op when absent, and `sesh --check` reports when the installed copy differs
|
|
148
|
+
from the bundled one.
|
|
116
149
|
|
|
117
150
|
### Hard-won rules — do not regress
|
|
118
151
|
|
|
@@ -131,12 +164,32 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
|
131
164
|
removes any leftover `commands/sesh.md`.
|
|
132
165
|
- Do not bind `<leader>l` (native `session_list`) or shadow the native
|
|
133
166
|
`/sessions` command.
|
|
167
|
+
- **The sidebar never binds arrow keys globally.** Keyboard navigation is
|
|
168
|
+
opt-in: clicking the *Sessions* heading (or its search box) activates a
|
|
169
|
+
priority-20 layer, because a global `↑`/`↓` would hijack the main prompt.
|
|
170
|
+
Cursor state is derived from `itemRows()`, so hover and cursor resolve to the
|
|
171
|
+
same row, and the section is capped at `SIDEBAR_LIMIT` (`… N more · ctrl+o for
|
|
172
|
+
all` is the escape hatch) — it must stay a glanceable recent list.
|
|
173
|
+
- **Delete in the TUI is two-step.** The first `ctrl+x` arms `pendingDelete`,
|
|
174
|
+
the second (or `y`, or Enter in the picker footer) commits, and `n`/Esc/moving
|
|
175
|
+
cancels. The `y`/`n` layer is registered *only while armed*, so it can never
|
|
176
|
+
shadow the search box's typing.
|
|
177
|
+
- **Picker `ctrl+g` is project scope**, not close (parity with the fzf picker's
|
|
178
|
+
`Ctrl-G`); Esc is the only close key. `ctrl+f` forks through
|
|
179
|
+
`api.client.session.fork` and navigates to the returned session.
|
|
180
|
+
- Row titles render through `<Highlighted>`, and JSX must stay out of the
|
|
181
|
+
`function shortDir` … `const TRANSCRIPT_PREVIEW_TURNS` window that
|
|
182
|
+
`tests/tui.mjs` extracts and evaluates as the data layer.
|
|
134
183
|
|
|
135
184
|
## Verifying
|
|
136
185
|
|
|
137
|
-
- `
|
|
138
|
-
- `
|
|
139
|
-
|
|
186
|
+
- `bun run test` — fixture-DB regression suite, hermetic via `SESH_*` overrides.
|
|
187
|
+
- `bun run test:changelog` — release-tooling regressions; builds its own git
|
|
188
|
+
history in a temp dir (included in `bun run test`).
|
|
189
|
+
- `bun run test:picker` — PTY suite driving the real fzf picker (needs fzf
|
|
190
|
+
`>= 0.73` and Python 3).
|
|
191
|
+
- `bun run typecheck` — `tsc` over `tui/` and `opencode/`.
|
|
192
|
+
- `bun run lint:sh` — `bash -n` on every script.
|
|
140
193
|
- `HOME=/tmp/fakehome bash install.sh` — installer smoke test.
|
|
141
194
|
- Keep `tui/sesh-panel.tsx` and any installed copy byte-identical when testing
|
|
142
195
|
the panel locally.
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.0] - 2026-09-21
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- The picker header always names the effective scope, the session count and
|
|
15
|
+
whether the index is fresh or stale (`sesh-list.sh --header`), so a `Ctrl-G`
|
|
16
|
+
scope toggle or a degraded snapshot is no longer invisible.
|
|
17
|
+
- `sesh --print --json` emits `{"sessionId","cwd","fork"}` for scripting.
|
|
18
|
+
- Archived sessions are tagged `· archived` when included with `--archived`,
|
|
19
|
+
replacing the agent-state colours that could never fire (opencode exposes no
|
|
20
|
+
live agent state, so every row rendered grey either way).
|
|
21
|
+
- The TUI picker takes `Ctrl-X` (delete), `Ctrl-F` (fork) and `Ctrl-G` (scope to
|
|
22
|
+
the selected session's project), so both UIs now agree on the same keys.
|
|
23
|
+
- The TUI sidebar is capped at the 15 most recent sessions with
|
|
24
|
+
`… N more · ctrl+o for all`, and clicking the **Sessions** heading drives it
|
|
25
|
+
from the keyboard (`↑`/`↓`, Enter, Space, `Ctrl-X`, Esc).
|
|
26
|
+
- Empty stores, queries that match nothing, vanished selections and an
|
|
27
|
+
unavailable session list now say so instead of rendering a blank screen.
|
|
28
|
+
- Session titles highlight the part of the row the search matched, and the
|
|
29
|
+
sidebar search accepts uppercase and punctuation.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- Deleting a session asks first. The picker prompts `[y/N]` before dispatching,
|
|
34
|
+
the TUI arms on the first `Ctrl-X` and commits on a second `Ctrl-X` (or `y`;
|
|
35
|
+
`n`, Esc or moving cancels), and `sesh-delete.sh` refuses a non-terminal stdin
|
|
36
|
+
unless given `--yes` — a piped newline can no longer authorize an irreversible
|
|
37
|
+
delete.
|
|
38
|
+
- Resuming no longer stalls silently while the picker waits for a fresh scan: it
|
|
39
|
+
prints a progress line first, and `--print` lookups skip that poll entirely.
|
|
40
|
+
- Selecting a directory header or a notice row explains itself with a one-shot
|
|
41
|
+
notice row instead of repainting the same list as if the key had been ignored.
|
|
42
|
+
- Reconciled README, AGENTS.md and SECURITY.md with current behavior: the
|
|
43
|
+
terminal UI is described as the full set of scripts (including the refresh
|
|
44
|
+
worker and shortcut help), the extraction cache key documents the latest part
|
|
45
|
+
timestamp, the PTY picker suite (`npm run test:picker`) is listed, and the
|
|
46
|
+
npm distribution and supported-version policy are stated.
|
|
10
47
|
## [0.1.7] - 2026-09-13
|
|
11
48
|
|
|
12
49
|
### Fixed
|
|
@@ -141,7 +178,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
141
178
|
- Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
|
|
142
179
|
CI.
|
|
143
180
|
|
|
144
|
-
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.
|
|
181
|
+
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.2.0...HEAD
|
|
182
|
+
[0.2.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.7...v0.2.0
|
|
145
183
|
[0.1.7]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.6...v0.1.7
|
|
146
184
|
[0.1.6]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.5...v0.1.6
|
|
147
185
|
[0.1.5]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.4...v0.1.5
|
package/README.md
CHANGED
|
@@ -58,7 +58,8 @@ cd opencode-sesh
|
|
|
58
58
|
bash install.sh
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
-
`sesh install` (or `bash install.sh`) links `sesh` into
|
|
61
|
+
`sesh install` (or `bash install.sh`) links `sesh` into `$XDG_BIN_HOME`
|
|
62
|
+
(default `~/.local/bin`), copies
|
|
62
63
|
the `sesh-list` tool and the TUI panel into
|
|
63
64
|
`${XDG_CONFIG_HOME:-~/.config}/opencode`, registers the panel in `tui.json`, and
|
|
64
65
|
declares the plugin dependencies (opencode installs them on next start). The
|
|
@@ -113,20 +114,28 @@ bash install.sh --uninstall # from source
|
|
|
113
114
|
| Ctrl-G | Toggle current-directory scope / all sessions |
|
|
114
115
|
| Space (empty query) | Toggle the transcript preview |
|
|
115
116
|
| `?` (empty query) | Toggle shortcut help |
|
|
116
|
-
| Ctrl-X | Delete the selected session |
|
|
117
|
+
| Ctrl-X | Delete the selected session (asks to confirm) |
|
|
117
118
|
| Escape | Exit |
|
|
118
119
|
|
|
120
|
+
The header line always shows the effective scope, the session count and whether
|
|
121
|
+
the index is fresh or stale, so a `Ctrl-G` toggle is never silent. A query that
|
|
122
|
+
matches nothing says so instead of showing an empty screen.
|
|
123
|
+
|
|
119
124
|
Flags: `--cwd` (current directory only), `--limit N` (default: all),
|
|
120
125
|
`--archived`, `--print` (print `id<TAB>cwd` instead of resuming), `--fork`,
|
|
121
|
-
`--query TEXT`, `--check`.
|
|
126
|
+
`--json` (with `--print`, emit JSON), `--query TEXT`, `--check`.
|
|
122
127
|
|
|
123
128
|
Because `--print` just emits the id and directory, `sesh` doubles as a scriptable
|
|
124
129
|
session lookup:
|
|
125
130
|
|
|
126
131
|
```bash
|
|
127
132
|
read -r id cwd < <(sesh --print --query "auth")
|
|
133
|
+
sesh --print --json --query "auth" | jq -r .cwd
|
|
128
134
|
```
|
|
129
135
|
|
|
136
|
+
Deleting is irreversible, so the picker asks before dispatching it, and a
|
|
137
|
+
non-interactive `sesh-delete.sh` refuses unless given `--yes`.
|
|
138
|
+
|
|
130
139
|
### TUI panel
|
|
131
140
|
|
|
132
141
|
| Key | Action |
|
|
@@ -137,10 +146,16 @@ read -r id cwd < <(sesh --print --query "auth")
|
|
|
137
146
|
| `↑`/`↓`, `PgUp`/`PgDn`, `Home`/`End` | Move the selection |
|
|
138
147
|
| Space | Toggle the transcript preview |
|
|
139
148
|
| Enter | Open the selected session |
|
|
149
|
+
| Ctrl-X | Delete the selected session (asks to confirm) |
|
|
150
|
+
| Ctrl-F | Fork the selected session |
|
|
151
|
+
| Ctrl-G | Toggle scope: the selected session's project, or every project |
|
|
140
152
|
| Esc | Close |
|
|
141
153
|
|
|
142
154
|
Click the sidebar's `search…` box to filter recent sessions in place; click
|
|
143
|
-
elsewhere or press Esc to leave search.
|
|
155
|
+
elsewhere or press Esc to leave search. Click the **Sessions** heading to drive
|
|
156
|
+
the list from the keyboard instead of the mouse (`↑`/`↓` to move, Enter to open,
|
|
157
|
+
Space to preview, Ctrl-X to delete, Esc to leave). The sidebar shows the 15 most
|
|
158
|
+
recent sessions — the full picker is one keystroke away.
|
|
144
159
|
|
|
145
160
|
The full picker pages through your whole global session list (no fixed window)
|
|
146
161
|
and indexes transcript text for every session in the background, showing
|
|
@@ -162,19 +177,21 @@ All variables are optional.
|
|
|
162
177
|
|
|
163
178
|
## How it works
|
|
164
179
|
|
|
165
|
-
`bin/sesh-list.sh` is the only
|
|
166
|
-
(SQLite: `session` / `message` / `part`). Each refresh extracts session
|
|
167
|
-
and text-only parts into a persistent per-session cache keyed on
|
|
168
|
-
|
|
169
|
-
re-renders that snapshot with `jq`
|
|
170
|
-
database scans. Reasoning and tool
|
|
171
|
-
session ids are validated before
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
(`sesh-
|
|
176
|
-
|
|
177
|
-
|
|
180
|
+
`bin/sesh-list.sh` is the only `bin/` script that reads the opencode session
|
|
181
|
+
store (SQLite: `session` / `message` / `part`). Each refresh extracts session
|
|
182
|
+
metadata and text-only parts into a persistent per-session cache keyed on
|
|
183
|
+
`time_updated`, part count and the latest part timestamp, then atomically
|
|
184
|
+
publishes a `snapshot.jsonl`. Every keystroke re-renders that snapshot with `jq`
|
|
185
|
+
alone, so typing never starts competing database scans. Reasoning and tool
|
|
186
|
+
payloads are never indexed or previewed, and session ids are validated before
|
|
187
|
+
any SQL interpolation.
|
|
188
|
+
|
|
189
|
+
Flat scripts back the terminal UI — the picker (`sesh.sh`), the refresh engine
|
|
190
|
+
(`sesh-list.sh`, driven by a per-picker `sesh-refresh-worker.sh` that serializes
|
|
191
|
+
scans), preview renderer (`sesh-preview.sh`), deleter (`sesh-delete.sh`) and
|
|
192
|
+
shortcut help (`sesh-shortcuts.sh`). The TUI panel (`tui/sesh-panel.tsx`) is a
|
|
193
|
+
SolidJS OpenTUI plugin that talks to the opencode SDK over the same store; the
|
|
194
|
+
`sesh-list` agent tool reads it through `opencode db`.
|
|
178
195
|
|
|
179
196
|
## FAQ
|
|
180
197
|
|
|
@@ -202,10 +219,11 @@ guide; this project follows the [Code of Conduct](CODE_OF_CONDUCT.md). Run the
|
|
|
202
219
|
checks before opening a PR:
|
|
203
220
|
|
|
204
221
|
```bash
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
222
|
+
bun install
|
|
223
|
+
bun run test # fixture-database regression suite
|
|
224
|
+
bun run test:picker # PTY picker suite (needs fzf >= 0.73)
|
|
225
|
+
bun run typecheck # tsc over tui/ and opencode/
|
|
226
|
+
bun run lint:sh # bash -n on every script
|
|
209
227
|
```
|
|
210
228
|
|
|
211
229
|
`HOME=/tmp/fakehome bash install.sh` smoke-tests the installer without touching
|
package/bin/sesh-delete.sh
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# Usage: sesh-delete.sh STATE_DIR SESSION_ID
|
|
2
|
+
# Usage: sesh-delete.sh [--yes] STATE_DIR SESSION_ID
|
|
3
3
|
# Ctrl-X deletes through the opencode CLI so its own safeguards apply.
|
|
4
|
-
#
|
|
4
|
+
# Deletion is irreversible, so this confirms first: an interactive run asks for
|
|
5
|
+
# a y, and a non-interactive run must pass --yes rather than inherit whatever
|
|
6
|
+
# stdin happens to be. Rows with no session id (headers, notices) stay silent
|
|
7
|
+
# no-ops.
|
|
5
8
|
set -euo pipefail
|
|
6
9
|
|
|
7
10
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
assume_yes=0
|
|
12
|
+
positional=()
|
|
13
|
+
while [ "$#" -gt 0 ]; do
|
|
14
|
+
case "$1" in
|
|
15
|
+
--yes|-y) assume_yes=1 ;;
|
|
16
|
+
--help) echo "usage: ${0##*/} [--yes] STATE_DIR SESSION_ID"; exit 0 ;;
|
|
17
|
+
*) positional+=("$1") ;;
|
|
18
|
+
esac
|
|
19
|
+
shift
|
|
20
|
+
done
|
|
21
|
+
state_dir=${positional[0]:-}
|
|
22
|
+
session_id=${positional[1]:-}
|
|
10
23
|
snapshot="$state_dir/snapshot.jsonl"
|
|
11
24
|
lock="$state_dir/refresh.lock"
|
|
12
25
|
OPENCODE_BIN=${SESH_OPENCODE:-opencode}
|
|
@@ -40,6 +53,27 @@ acquire_lock() {
|
|
|
40
53
|
[[ "$session_id" =~ ^ses_[A-Za-z0-9]+$ ]] || { echo "Invalid session identity."; pause; exit 0; }
|
|
41
54
|
[ -n "$state_dir" ] && [ -f "$snapshot" ] || { echo "No session snapshot available."; pause; exit 0; }
|
|
42
55
|
|
|
56
|
+
# Confirm before taking the lock: a human can sit on the prompt for as long as
|
|
57
|
+
# they like without blocking the snapshot publisher. The row is re-checked
|
|
58
|
+
# against the snapshot below, so a session that vanishes meanwhile still loses.
|
|
59
|
+
if [ "$assume_yes" = 0 ]; then
|
|
60
|
+
if [ -t 0 ]; then
|
|
61
|
+
title=$("$JQ_BIN" -r --arg sid "$session_id" 'select(.sessionId == $sid) | .title // ""' "$snapshot" 2>/dev/null || true)
|
|
62
|
+
[ -n "$title" ] || title='untitled'
|
|
63
|
+
printf '%s' "Delete \"$title\" ($session_id)? [y/N] "
|
|
64
|
+
reply=''
|
|
65
|
+
IFS= read -r -n 1 reply || reply=''
|
|
66
|
+
printf '\n'
|
|
67
|
+
case "$reply" in
|
|
68
|
+
y|Y) ;;
|
|
69
|
+
*) echo "Cancelled; nothing was deleted."; pause; exit 0 ;;
|
|
70
|
+
esac
|
|
71
|
+
else
|
|
72
|
+
echo "Refusing to delete $session_id: pass --yes when stdin is not a terminal." >&2
|
|
73
|
+
exit 2
|
|
74
|
+
fi
|
|
75
|
+
fi
|
|
76
|
+
|
|
43
77
|
# Serialize this action with the sole snapshot publisher. A short collision
|
|
44
78
|
# is safer than deleting against a moving snapshot.
|
|
45
79
|
if ! acquire_lock; then
|
package/bin/sesh-list.sh
CHANGED
|
@@ -12,6 +12,7 @@ LIMIT=0
|
|
|
12
12
|
LIMIT_SET=0
|
|
13
13
|
SCOPE_SET=0
|
|
14
14
|
REFRESH=0
|
|
15
|
+
HEADER=0
|
|
15
16
|
WAIT_LOCK=0
|
|
16
17
|
TOGGLE_SCOPE=0
|
|
17
18
|
INCLUDE_ARCHIVED=0
|
|
@@ -20,10 +21,11 @@ STATE_DIR=''
|
|
|
20
21
|
QUERY=''
|
|
21
22
|
SELECTED_ID=''
|
|
22
23
|
SCOPE_CWD=''
|
|
23
|
-
usage() { echo "usage: ${0##*/} [--refresh] [--wait-lock SECONDS] --state-dir DIR [--cwd] [--limit N] [--archived] [--toggle-scope] [--query TEXT]" >&2; }
|
|
24
|
+
usage() { echo "usage: ${0##*/} [--refresh] [--header] [--wait-lock SECONDS] --state-dir DIR [--cwd] [--limit N] [--archived] [--toggle-scope] [--query TEXT]" >&2; }
|
|
24
25
|
while [ "$#" -gt 0 ]; do
|
|
25
26
|
case "$1" in
|
|
26
27
|
--refresh) REFRESH=1 ;;
|
|
28
|
+
--header) HEADER=1 ;;
|
|
27
29
|
--wait-lock) WAIT_LOCK=${2:-}; shift ;;
|
|
28
30
|
--state-dir) STATE_DIR=${2:-}; shift ;;
|
|
29
31
|
--cwd) SCOPE_CWD=$PWD; SCOPE_SET=1 ;;
|
|
@@ -113,14 +115,47 @@ atomic_text() {
|
|
|
113
115
|
}
|
|
114
116
|
set_status() { atomic_text "$STATUS" "$1"; }
|
|
115
117
|
|
|
118
|
+
# One-line fzf --header: the effective scope, how many sessions the snapshot
|
|
119
|
+
# holds and whether it is fresh. Reads published state only, so it stays cheap
|
|
120
|
+
# enough to run on fzf's 3s poll without touching the database.
|
|
121
|
+
header() {
|
|
122
|
+
local state='' count=0 scope='all dirs' line=''
|
|
123
|
+
[ -f "$STATUS" ] && state=$(cat "$STATUS" 2>/dev/null || true)
|
|
124
|
+
if [ -f "$SNAPSHOT" ]; then
|
|
125
|
+
count=$(wc -l < "$SNAPSHOT" 2>/dev/null || printf '0')
|
|
126
|
+
count=${count//[!0-9]/}
|
|
127
|
+
[ -n "$count" ] || count=0
|
|
128
|
+
fi
|
|
129
|
+
if [ -n "$SCOPE_CWD" ]; then
|
|
130
|
+
case "$SCOPE_CWD" in
|
|
131
|
+
"$HOME") scope='~' ;;
|
|
132
|
+
"$HOME"/*) scope="~${SCOPE_CWD#"$HOME"}" ;;
|
|
133
|
+
*) scope=$SCOPE_CWD ;;
|
|
134
|
+
esac
|
|
135
|
+
fi
|
|
136
|
+
line="${CYAN}${scope}${RESET} ${GRAY}·${RESET} ${GRAY}${count} sessions${RESET}"
|
|
137
|
+
[ "$INCLUDE_ARCHIVED" = 1 ] && line="${line} ${GRAY}·${RESET} ${YELLOW}archived shown${RESET}"
|
|
138
|
+
[ -n "$state" ] && [ "$state" != fresh ] && line="${line} ${GRAY}·${RESET} ${YELLOW}${state}${RESET}"
|
|
139
|
+
printf '%s' "$line"
|
|
140
|
+
}
|
|
141
|
+
|
|
116
142
|
render() {
|
|
117
|
-
local state=''
|
|
143
|
+
local state='' notice=''
|
|
118
144
|
[ -f "$STATUS" ] && state=$(cat "$STATUS" 2>/dev/null || true)
|
|
145
|
+
# A non-actionable selection (a directory header, a stale notice) leaves a
|
|
146
|
+
# one-shot message here so the reopened picker explains itself instead of
|
|
147
|
+
# repainting the same list as if the key had been ignored.
|
|
148
|
+
if [ -f "$STATE_DIR/action-notice" ]; then
|
|
149
|
+
notice=$(cat "$STATE_DIR/action-notice" 2>/dev/null || true)
|
|
150
|
+
rm -f "$STATE_DIR/action-notice" 2>/dev/null || true
|
|
151
|
+
fi
|
|
119
152
|
if [ ! -f "$SNAPSHOT" ]; then
|
|
153
|
+
[ -n "$notice" ] && printf '\t\tunknown\t%s%s%s\t\tnotice:action\n' "$YELLOW" "$notice" "$RESET"
|
|
120
154
|
[ -n "$state" ] && [ "$state" != fresh ] && printf '\t\tunknown\t%s%s%s\t\tnotice:live-state\n' "$YELLOW" "$state" "$RESET"
|
|
121
155
|
return 0
|
|
122
156
|
fi
|
|
123
157
|
{
|
|
158
|
+
[ -n "$notice" ] && printf '\t\tunknown\t%s%s%s\t\tnotice:action\n' "$YELLOW" "$notice" "$RESET"
|
|
124
159
|
[ -n "$state" ] && [ "$state" != fresh ] && printf '\t\tunknown\t%s%s%s\t\tnotice:live-state\n' "$YELLOW" "$state" "$RESET"
|
|
125
160
|
"$JQ_BIN" -sr --arg green "$GREEN" --arg cyan "$CYAN" --arg gray "$GRAY" --arg magenta "$MAGENTA" --arg red "$RED" --arg bold "$BOLD" --arg yellow "$YELLOW" --arg reset "$RESET" --arg query "$QUERY" --arg home "$HOME" --arg selected "$SELECTED_ID" '
|
|
126
161
|
def ago($epoch):
|
|
@@ -128,23 +163,34 @@ render() {
|
|
|
128
163
|
| if $s < 60 then "now" elif $s < 3600 then "\($s / 60 | floor)m" elif $s < 86400 then "\($s / 3600 | floor)h" else "\($s / 86400 | floor)d" end;
|
|
129
164
|
def homepath:
|
|
130
165
|
. as $p | if $p == "" then "?" elif $p == $home then "~" elif ($home != "" and startswith($home + "/")) then "~" + .[($home|length):] else . end;
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
|
|
166
|
+
# Non-actionable rows: an empty field 2 makes Enter/Ctrl-F/Ctrl-X no-ops,
|
|
167
|
+
# and the unique id keeps fzf from falling back to a neighbouring session.
|
|
168
|
+
def notice_row($msg; $id): (["", "", "unknown", ($yellow + $msg + $reset), "", $id] | @tsv);
|
|
169
|
+
# Preserve a vanished selection so the picker keeps its identity instead
|
|
170
|
+
# of jumping to a different session; keep it until the user moves away.
|
|
134
171
|
(if ($selected | test("^ses_[A-Za-z0-9]+$")) and (any(.sessionId == $selected) | not)
|
|
135
|
-
then (
|
|
172
|
+
then notice_row("Selected session is no longer available; choose another session"; $selected)
|
|
136
173
|
else empty end),
|
|
174
|
+
# An empty result set must say so: with the info line hidden and an empty
|
|
175
|
+
# preview, a filtered-to-nothing list is otherwise an indistinguishable
|
|
176
|
+
# blank screen.
|
|
137
177
|
(($query | ascii_downcase) as $q
|
|
138
|
-
|
|
|
178
|
+
| [.[] | select($q == "" or (.searchText | contains($q)))] as $matched
|
|
179
|
+
| (if ($matched | length) == 0
|
|
180
|
+
then notice_row(if $q == "" then "No sessions in this store" else "No sessions match \"" + $query + "\"" end; "notice:no-match")
|
|
181
|
+
else empty end),
|
|
182
|
+
($matched
|
|
139
183
|
| group_by(.cwd)
|
|
140
184
|
| map({cwd: .[0].cwd, updated: (map(.updatedEpoch) | max), items: (sort_by(.updatedEpoch) | reverse)})
|
|
141
185
|
| sort_by(.updated) | reverse
|
|
142
186
|
| .[] as $g
|
|
143
187
|
| (["", "", "unknown", ($magenta + ($g.cwd | homepath | gsub("[\u0000-\u001f\u007f-\u009f]";" ")) + $reset), $g.cwd, ("hdr:" + $g.cwd)] | @tsv),
|
|
144
188
|
($g.items | to_entries[] | .key as $i | .value as $s
|
|
145
|
-
|
|
189
|
+
# Archived rows are opt-in and need to look like it; nothing else about
|
|
190
|
+
# a row is knowable here (live agent state is not exposed by opencode).
|
|
191
|
+
| (if $s.archived == 1 then $yellow else $gray end) as $c
|
|
146
192
|
| ($gray + (if $i == (($g.items|length)-1) then "└" else "├" end) + $reset) as $branch
|
|
147
|
-
| [$s.agentId, $s.sessionId, $s.liveState, ($branch + " " + $c + ($s.title | gsub("[\u0000-\u001f\u007f-\u009f]";" ")) + $reset + " " + $gray + ago($s.updatedEpoch) + $reset), $s.cwd, $s.sessionId] | @tsv))
|
|
193
|
+
| [$s.agentId, $s.sessionId, $s.liveState, ($branch + " " + $c + ($s.title | gsub("[\u0000-\u001f\u007f-\u009f]";" ")) + (if $s.archived == 1 then " · archived" else "" end) + $reset + " " + $gray + ago($s.updatedEpoch) + $reset), $s.cwd, $s.sessionId] | @tsv)))
|
|
148
194
|
' "$SNAPSHOT"
|
|
149
195
|
} || printf '\t\tunknown\t%sSnapshot rendering failed; retry refresh%s\t\tnotice:render-error\n' "$YELLOW" "$RESET"
|
|
150
196
|
}
|
|
@@ -336,6 +382,7 @@ refresh() {
|
|
|
336
382
|
| . as $m | ($histById[$m.id] // {}) as $h
|
|
337
383
|
| {sessionId: $m.id, malformedRecords: 0, agentId: "", agentState: "",
|
|
338
384
|
liveState: "unknown", transcriptPath: "", source: "opencode",
|
|
385
|
+
archived: (if (($m.archived // 0) | tonumber) == 0 then 0 else 1 end),
|
|
339
386
|
cwd: (if ($h.cwd // "") != "" then $h.cwd else $m.directory end),
|
|
340
387
|
updatedEpoch: ($h.updatedEpoch // (($m.time_updated / 1000 | floor))),
|
|
341
388
|
title: (if ($h.title // "") != "" then $h.title else ($m.title // "(untitled)") end)}
|
|
@@ -356,5 +403,6 @@ refresh() {
|
|
|
356
403
|
fi
|
|
357
404
|
}
|
|
358
405
|
|
|
406
|
+
if [ "$HEADER" = 1 ]; then header; exit 0; fi
|
|
359
407
|
if [ "$REFRESH" = 1 ] || [ "$TOGGLE_SCOPE" = 1 ]; then refresh; fi
|
|
360
408
|
render
|
package/bin/sesh-shortcuts.sh
CHANGED
|
@@ -9,9 +9,10 @@ sesh shortcuts
|
|
|
9
9
|
Ctrl-G Toggle current-directory scope / all sessions
|
|
10
10
|
Space Toggle transcript preview
|
|
11
11
|
? Toggle this help
|
|
12
|
-
Ctrl-X Delete the selected session
|
|
12
|
+
Ctrl-X Delete the selected session (asks to confirm)
|
|
13
13
|
Escape Exit the picker
|
|
14
14
|
|
|
15
15
|
Flags: --cwd (current directory only), --limit N (default: all),
|
|
16
|
-
--archived (include archived), --print (print id instead of resuming)
|
|
16
|
+
--archived (include archived), --print (print id instead of resuming),
|
|
17
|
+
--json (with --print, emit JSON)
|
|
17
18
|
HELP
|
package/bin/sesh.sh
CHANGED
|
@@ -59,6 +59,7 @@ picker_limit=0
|
|
|
59
59
|
picker_archived=0
|
|
60
60
|
picker_print=0
|
|
61
61
|
picker_fork=0
|
|
62
|
+
picker_json=0
|
|
62
63
|
picker_query=''
|
|
63
64
|
while [ "$#" -gt 0 ]; do
|
|
64
65
|
case "$1" in
|
|
@@ -67,12 +68,14 @@ while [ "$#" -gt 0 ]; do
|
|
|
67
68
|
--archived) picker_archived=1 ;;
|
|
68
69
|
--print) picker_print=1 ;;
|
|
69
70
|
--fork) picker_fork=1 ;;
|
|
71
|
+
--json) picker_json=1 ;;
|
|
70
72
|
--query) picker_query=${2:-}; shift ;;
|
|
71
73
|
*) echo "unknown option: $1" >&2; exit 2 ;;
|
|
72
74
|
esac
|
|
73
75
|
shift
|
|
74
76
|
done
|
|
75
77
|
case "$picker_limit" in ''|*[!0-9]*) echo "--limit must be a non-negative integer" >&2; exit 2;; esac
|
|
78
|
+
[ "$picker_json" = 0 ] || [ "$picker_print" = 1 ] || { echo '--json requires --print' >&2; exit 2; }
|
|
76
79
|
|
|
77
80
|
shell_quote() {
|
|
78
81
|
# Quotes fixed command arguments embedded in fzf's shell action strings.
|
|
@@ -106,6 +109,12 @@ trap cleanup EXIT
|
|
|
106
109
|
trap 'exit 130' HUP INT TERM
|
|
107
110
|
|
|
108
111
|
cache_cmd="$q_worker render --state-dir $q_state --query {q} --selected-id {6}"
|
|
112
|
+
# fzf's --header is the only place a persistent scope/status line can live
|
|
113
|
+
# without becoming a selectable row, so it is refreshed by transform-header on
|
|
114
|
+
# start, on the 3s poll and after either action that can change it. Order
|
|
115
|
+
# matters: a transform-* action placed AFTER reload/reload-sync in the chain
|
|
116
|
+
# leaves the list empty, so the header is always transformed first.
|
|
117
|
+
header_cmd="$q_list --header --state-dir $q_state"
|
|
109
118
|
preview_cmd="if [ -f $q_state/help ]; then $q_shortcuts; else $q_preview $q_state {2}; fi"
|
|
110
119
|
help_bind="?:transform:[ -z {q} ] && ( [ -f $q_state/help ] && rm -f $q_state/help && echo hide-preview || ( : > $q_state/help && echo show-preview+refresh-preview ) ) || echo 'put(?)'"
|
|
111
120
|
toggle_cmd="$q_list --toggle-scope --state-dir $q_state >/dev/null"
|
|
@@ -113,16 +122,18 @@ delete_cmd="$q_delete $q_state {2}"
|
|
|
113
122
|
|
|
114
123
|
query="$picker_query"
|
|
115
124
|
while :; do
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
--
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
125
|
+
header_line=$("$SCRIPT_DIR/sesh-list.sh" --header --state-dir "$state_dir" 2>/dev/null || true)
|
|
126
|
+
selection=''
|
|
127
|
+
set +e
|
|
128
|
+
selection=$(FZF_DEFAULT_COMMAND='printf ""' FZF_DEFAULT_OPTS='' FZF_DEFAULT_OPTS_FILE='' "$SESH_FZF" --print-query --expect=ctrl-f --query="$query" --ansi --delimiter=$'\t' --with-nth=4 --track --id-nth=6 --disabled --layout=reverse --info=hidden --prompt='❯ ' \
|
|
129
|
+
--preview="$preview_cmd" --preview-window='right,50%,wrap,hidden' \
|
|
130
|
+
--header-first --header="$header_line" \
|
|
131
|
+
--bind='space:transform:[ -z {q} ] && echo toggle-preview || echo "put( )"' \
|
|
132
|
+
--bind="$help_bind" \
|
|
133
|
+
--bind="ctrl-x:execute($delete_cmd)+transform-header($header_cmd)+reload($cache_cmd)" \
|
|
134
|
+
--bind="ctrl-g:execute($toggle_cmd)+transform-header($header_cmd)+reload($cache_cmd)" \
|
|
135
|
+
--bind="change:reload:$cache_cmd" \
|
|
136
|
+
--bind="start,every(3):transform-header($header_cmd)+reload-sync:$cache_cmd")
|
|
126
137
|
status=$?
|
|
127
138
|
set -e
|
|
128
139
|
case "$status" in 0) ;; 1|130) exit 0;; *) echo "sesh: picker failed ($status)" >&2; exit "$status";; esac
|
|
@@ -132,13 +143,30 @@ selection=${selection#*$'\n'}
|
|
|
132
143
|
key=${selection%%$'\n'*}
|
|
133
144
|
selection=${selection#*$'\n'}
|
|
134
145
|
session_id=$(printf '%s\n' "$selection" | cut -f2)
|
|
135
|
-
|
|
146
|
+
tracking_id=$(printf '%s\n' "$selection" | cut -f6)
|
|
147
|
+
if [ -z "$session_id" ]; then
|
|
148
|
+
# Enter/Ctrl-F on a directory header or a notice row. Leave a one-shot
|
|
149
|
+
# message for the reopened picker so the keypress visibly did something,
|
|
150
|
+
# and keep the query so the user has not lost their place.
|
|
151
|
+
case "$tracking_id" in
|
|
152
|
+
hdr:*) printf '%s' 'That row is a directory header, not a session.' > "$state_dir/action-notice" ;;
|
|
153
|
+
notice:*) printf '%s' 'That row is a notice, not a session; pick a session row.' > "$state_dir/action-notice" ;;
|
|
154
|
+
esac
|
|
155
|
+
continue
|
|
156
|
+
fi
|
|
136
157
|
# Dispatch queues a new worker scan and waits for that exact serialized poll.
|
|
137
158
|
# It cannot accept a previously fresh status while the worker owns its lock.
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
159
|
+
# A --print lookup reads the published snapshot, so it skips the wait entirely
|
|
160
|
+
# and stays usable from scripts without paying a rescan.
|
|
161
|
+
if [ "$picker_print" = 0 ]; then
|
|
162
|
+
printf 'sesh: refreshing session state…' >&2
|
|
163
|
+
poll_target=$("$SCRIPT_DIR/sesh-refresh-worker.sh" request --state-dir "$state_dir")
|
|
164
|
+
if ! "$SCRIPT_DIR/sesh-refresh-worker.sh" wait --state-dir "$state_dir" --target "$poll_target" --timeout 12; then
|
|
165
|
+
printf '\r\033[K' >&2
|
|
166
|
+
echo 'sesh: session state unavailable; selection cancelled, retry after refresh' >&2
|
|
167
|
+
continue
|
|
168
|
+
fi
|
|
169
|
+
printf '\r\033[K' >&2
|
|
142
170
|
fi
|
|
143
171
|
record=$("$SESH_JQ" -c --arg sid "$session_id" 'select(.sessionId==$sid)' "$state_dir/snapshot.jsonl")
|
|
144
172
|
[ -n "$record" ] || continue
|
|
@@ -146,7 +174,10 @@ cwd=$(printf '%s' "$record" | "$SESH_JQ" -r '.cwd')
|
|
|
146
174
|
fork=0
|
|
147
175
|
if [ "$picker_fork" = 1 ] || [ "$key" = ctrl-f ]; then fork=1; fi
|
|
148
176
|
if [ "$picker_print" = 1 ]; then
|
|
149
|
-
if [ "$
|
|
177
|
+
if [ "$picker_json" = 1 ]; then
|
|
178
|
+
"$SESH_JQ" -cn --arg sessionId "$session_id" --arg cwd "$cwd" --argjson fork "$fork" \
|
|
179
|
+
'{sessionId: $sessionId, cwd: $cwd, fork: ($fork == 1)}'
|
|
180
|
+
elif [ "$fork" = 1 ]; then printf '%s\t%s\tfork\n' "$session_id" "$cwd"; else printf '%s\t%s\n' "$session_id" "$cwd"; fi
|
|
150
181
|
exit 0
|
|
151
182
|
fi
|
|
152
183
|
if [ ! -d "$cwd" ]; then
|