opencode-sesh 0.2.1 → 0.4.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 +13 -1
- package/CHANGELOG.md +36 -1
- package/README.md +31 -1
- package/ROADMAP.md +61 -0
- package/bin/sesh +12 -0
- package/bin/sesh-costs.sh +103 -0
- package/bin/sesh-list.sh +11 -3
- package/bin/sesh-prune.sh +175 -0
- package/bin/sesh-retitle.sh +141 -0
- package/bin/sesh-waiting.sh +127 -0
- package/bin/sesh.sh +11 -0
- package/contrib/cmux/README.md +59 -0
- package/contrib/cmux/cmux.json.snippet +38 -0
- package/install.sh +4 -0
- package/package.json +4 -2
- package/tui/sesh-panel.tsx +106 -2
package/AGENTS.md
CHANGED
|
@@ -35,7 +35,16 @@ HOME=/tmp/fakehome bash install.sh # installer smoke test (never touches ~/.co
|
|
|
35
35
|
## Layout
|
|
36
36
|
|
|
37
37
|
- `bin/` — flat; no subdirs. Scripts resolve siblings from their own location.
|
|
38
|
-
Never hardcode install paths.
|
|
38
|
+
Never hardcode install paths. `sesh-waiting.sh` owns the needs-input query
|
|
39
|
+
(shared heuristic with the TUI sidebar copy, marked `NEEDS_INPUT_SQL` in
|
|
40
|
+
both); `sesh-prune.sh` archives stale sessions; `sesh-costs.sh` sums
|
|
41
|
+
assistant-message cost per directory; `sesh-retitle.sh` renames placeholder
|
|
42
|
+
titles. Reads go through `sqlite3 -readonly` (or `opencode db`); the direct
|
|
43
|
+
`UPDATE`s in `sesh-prune.sh` (archive) and `sesh-retitle.sh` (title) are the
|
|
44
|
+
only sanctioned writes — neither has a CLI or API endpoint, and hard deletes
|
|
45
|
+
stay on `opencode session delete`.
|
|
46
|
+
- `contrib/` — caller-side integrations (currently cmux). Nothing here may add
|
|
47
|
+
window management to sesh itself.
|
|
39
48
|
- `tui/sesh-panel.tsx` — SolidJS panel, `/** @jsxImportSource @opentui/solid */`.
|
|
40
49
|
- `opencode/tools/sesh-list.ts` — the agent-facing `sesh-list` tool.
|
|
41
50
|
- `scripts/changelog.sh` — release tooling: drafts `[Unreleased]` from
|
|
@@ -135,6 +144,9 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
|
|
|
135
144
|
a row and ctrl+x deletes one (armed, then confirmed). The section lists every
|
|
136
145
|
session without a row cap and flex-grows to fill the sidebar, with overflow
|
|
137
146
|
scrolling inside; the picker remains the place for transcript search.
|
|
147
|
+
Sessions waiting on the user (unanswered questions, stuck runs) pin a
|
|
148
|
+
**Needs input** group above the directories, with the count in the heading —
|
|
149
|
+
same `NEEDS_INPUT_SQL` heuristic as `bin/sesh-waiting.sh`.
|
|
138
150
|
- **Picker:** a custom dialog (not `DialogSelect`) grouped by
|
|
139
151
|
project/directory, recency-ordered, resume via
|
|
140
152
|
`api.route.navigate("session", …)`. `ctrl+o` and the command palette open it;
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-09-24
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `sesh costs [--days N] [--json]` sums assistant-message cost per project, with
|
|
15
|
+
a recent window beside the lifetime total, so a day's work reads as a
|
|
16
|
+
per-project record.
|
|
17
|
+
- `sesh retitle [--dry-run] [--yes]` replaces auto-generated placeholder titles
|
|
18
|
+
(`New session - …`) with the start of the session's first user message. Only
|
|
19
|
+
placeholder titles are touched.
|
|
20
|
+
- `contrib/cmux/` documents driving sesh from cmux: command-palette entries,
|
|
21
|
+
shortcuts, resuming a session in its own workspace, and a global "resume from
|
|
22
|
+
anywhere" hotkey. The glue is caller-side; sesh stays terminal-agnostic.
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- Search now weights results: a title match outranks a transcript-only match and
|
|
27
|
+
the current project rises while a query is active. Pins still win outright,
|
|
28
|
+
and the idle list keeps pure pin+recency ordering.
|
|
29
|
+
## [0.3.0] - 2026-09-24
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- Needs-input triage: sessions with unanswered agent questions or runs stuck
|
|
34
|
+
mid-tool pin a **Needs input** group above the sidebar directories (count in
|
|
35
|
+
the heading), and `sesh --needs-input [--json]` lists the same set in the
|
|
36
|
+
terminal. Detection reads the opencode store directly, so it works for every
|
|
37
|
+
session, not just ones owned by the current server.
|
|
38
|
+
- `sesh prune [--older-than 30d] [--dry-run] [--yes] [--delete]` archives stale
|
|
39
|
+
sessions so the list stays triageable. Pinned sessions, sessions waiting on
|
|
40
|
+
you, fork children and already-archived sessions are never touched;
|
|
41
|
+
archiving is reversible, `--delete` hard-deletes through the opencode CLI,
|
|
42
|
+
and non-TTY runs need `--yes`.
|
|
10
43
|
## [0.2.1] - 2026-09-24
|
|
11
44
|
|
|
12
45
|
### Changed
|
|
@@ -225,7 +258,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
225
258
|
- Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
|
|
226
259
|
CI.
|
|
227
260
|
|
|
228
|
-
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.
|
|
261
|
+
[Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.4.0...HEAD
|
|
262
|
+
[0.4.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.3.0...v0.4.0
|
|
263
|
+
[0.3.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.2.1...v0.3.0
|
|
229
264
|
[0.2.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.2.0...v0.2.1
|
|
230
265
|
[0.2.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.7...v0.2.0
|
|
231
266
|
[0.1.7]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.6...v0.1.7
|
package/README.md
CHANGED
|
@@ -127,7 +127,27 @@ matches nothing says so instead of showing an empty screen.
|
|
|
127
127
|
|
|
128
128
|
Flags: `--cwd` (current directory only), `--limit N` (default: all),
|
|
129
129
|
`--archived`, `--print` (print `id<TAB>cwd` instead of resuming), `--fork`,
|
|
130
|
-
`--json` (with `--print`, emit JSON), `--query TEXT`, `--check
|
|
130
|
+
`--json` (with `--print`, emit JSON), `--query TEXT`, `--check`,
|
|
131
|
+
`--needs-input` (list sessions waiting on you instead of opening the picker).
|
|
132
|
+
|
|
133
|
+
`sesh prune [--older-than 30d] [--dry-run] [--yes] [--delete]` archives stale
|
|
134
|
+
sessions (not updated within the threshold) so the list stays triageable.
|
|
135
|
+
Pinned sessions, sessions waiting on you, fork children and already-archived
|
|
136
|
+
sessions are never touched; archiving is reversible, while `--delete`
|
|
137
|
+
hard-deletes through `opencode session delete`. Without `--dry-run`, a TTY run
|
|
138
|
+
confirms first and a non-TTY run needs `--yes`.
|
|
139
|
+
|
|
140
|
+
`sesh costs [--days N] [--json]` sums assistant-message cost per project, with a
|
|
141
|
+
recent window beside the lifetime total, so a day's work reads as a
|
|
142
|
+
per-project record.
|
|
143
|
+
|
|
144
|
+
`sesh retitle [--dry-run] [--yes]` replaces auto-generated placeholder titles
|
|
145
|
+
(`New session - …`) with the start of the session's first user message. Only
|
|
146
|
+
placeholder titles are touched, and the same dry-run/`--yes` gating applies.
|
|
147
|
+
|
|
148
|
+
When searching, title matches outrank transcript-only matches and the current
|
|
149
|
+
project rises, so the list answers "where was that thing I worked on" before it
|
|
150
|
+
answers "what is newest". Pins still win outright.
|
|
131
151
|
|
|
132
152
|
Because `--print` just emits the id and directory, `sesh` doubles as a scriptable
|
|
133
153
|
session lookup:
|
|
@@ -162,6 +182,9 @@ the list from the keyboard instead of the mouse (`↑`/`↓` to move, Enter to o
|
|
|
162
182
|
Ctrl-P to preview, Option-S/Option-D to pin, Ctrl-X to delete, Esc to leave). Pinned
|
|
163
183
|
sessions appear ahead of the rest; the section lists every session and scrolls
|
|
164
184
|
to fill the sidebar, so click **▾ hide** to collapse it when you need the room.
|
|
185
|
+
Sessions waiting on you (unanswered agent questions, runs stuck mid-tool) pin
|
|
186
|
+
themselves to a **Needs input** group above the directories, with the count in
|
|
187
|
+
the heading; `sesh --needs-input` lists the same set in the terminal.
|
|
165
188
|
Hovering a row while keyboard navigation is active selects it for the next
|
|
166
189
|
shortcut. The full picker is one keystroke away.
|
|
167
190
|
|
|
@@ -184,6 +207,13 @@ All variables are optional.
|
|
|
184
207
|
| `SESH_CACHE_DIR` | `~/.cache/sesh` | persistent search-index cache |
|
|
185
208
|
| `SESH_PINS_FILE` | `${XDG_DATA_HOME:-~/.local/share}/sesh/pins.json` | shared persistent session/directory pins |
|
|
186
209
|
|
|
210
|
+
## Integrations
|
|
211
|
+
|
|
212
|
+
sesh is terminal-agnostic on purpose: it has no window-management code. The
|
|
213
|
+
[`contrib/cmux/`](contrib/cmux/README.md) directory shows how to drive it from
|
|
214
|
+
cmux — command-palette entries, shortcuts, resuming a session in its own
|
|
215
|
+
workspace, and a global "resume from anywhere" hotkey — all caller-side.
|
|
216
|
+
|
|
187
217
|
## How it works
|
|
188
218
|
|
|
189
219
|
`bin/sesh-list.sh` is the only `bin/` script that reads the opencode session
|
package/ROADMAP.md
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
Productivity bets for sesh, roughly ordered by payoff. Status is tracked here
|
|
4
|
+
as items land.
|
|
5
|
+
|
|
6
|
+
## 1. Needs-input triage (done)
|
|
7
|
+
|
|
8
|
+
**Problem:** with 100+ sessions, the bottleneck is not finding a session but
|
|
9
|
+
knowing which ones are *waiting on you* (unanswered agent questions, stale
|
|
10
|
+
runs awaiting approval) versus done versus dead. Today that means polling rows
|
|
11
|
+
by hand.
|
|
12
|
+
|
|
13
|
+
**Approach:** server-independent detection straight from the opencode SQLite
|
|
14
|
+
store, so it works for every session, not just ones owned by the current TUI
|
|
15
|
+
server (whose in-memory permission/question state is invisible cross-project):
|
|
16
|
+
|
|
17
|
+
- a `question` tool part that is not `completed` with no later user message in
|
|
18
|
+
the same session → awaiting an answer;
|
|
19
|
+
- a `tool` part stuck in `running` older than ~10 minutes → awaiting approval
|
|
20
|
+
or orphaned by a dead server.
|
|
21
|
+
|
|
22
|
+
**Surfaces:** a "Needs input" section pinned above the directory groups in the
|
|
23
|
+
TUI sidebar, plus a terminal listing (`sesh --needs-input`).
|
|
24
|
+
|
|
25
|
+
## 2. Stale-session pruning (done)
|
|
26
|
+
|
|
27
|
+
**Problem:** the list grows without bound (127+ rows), which slows scanning
|
|
28
|
+
and bloats the transcript search index with archaeology.
|
|
29
|
+
|
|
30
|
+
**Approach:** `sesh prune --older-than 30d` archives (never deletes) sessions
|
|
31
|
+
older than the threshold: never pinned ones, never ones flagged as needing
|
|
32
|
+
input. Archive is reversible; `--delete` stays the explicit hard-delete path
|
|
33
|
+
through the existing two-step confirm. Interactive runs confirm with a
|
|
34
|
+
candidate summary; non-TTY runs require `--yes`; `--dry-run` lists only.
|
|
35
|
+
|
|
36
|
+
## 3. sesh ↔ cmux bridge (done)
|
|
37
|
+
|
|
38
|
+
**Approach:** kept caller-side. sesh has no window-management code (a hard
|
|
39
|
+
repo rule), so [`contrib/cmux/`](contrib/cmux/README.md) ships the cmux
|
|
40
|
+
actions, shortcuts and a `--print --json` recipe that resumes a session in its
|
|
41
|
+
own workspace. cmux calls sesh; sesh never calls cmux.
|
|
42
|
+
|
|
43
|
+
## 4. Per-project cost digest (done)
|
|
44
|
+
|
|
45
|
+
`sesh costs [--days N] [--json]` sums assistant-message cost per directory with
|
|
46
|
+
a recent window beside the lifetime total, so a day's work reads as a
|
|
47
|
+
per-project record. Message-level summing (not `session.cost`) keeps a
|
|
48
|
+
half-finished day from dragging in a session's earlier history.
|
|
49
|
+
|
|
50
|
+
## 5. Resume from anywhere (done)
|
|
51
|
+
|
|
52
|
+
Covered by the same contrib recipes: `sesh --print --json` resolves a session
|
|
53
|
+
non-interactively, so a cmux action, skhd binding or launcher can resume a
|
|
54
|
+
specific session — or open the picker — from any context.
|
|
55
|
+
|
|
56
|
+
## Smaller items (done)
|
|
57
|
+
|
|
58
|
+
- Search weighting: title matches outrank transcript-only matches and the
|
|
59
|
+
current project rises while a query is active; pins still win outright.
|
|
60
|
+
- `sesh retitle [--dry-run] [--yes]` replaces placeholder titles
|
|
61
|
+
(`New session - …`) with the start of the first user message.
|
package/bin/sesh
CHANGED
|
@@ -22,5 +22,17 @@ case ${1:-} in
|
|
|
22
22
|
shift
|
|
23
23
|
exec bash "$SCRIPT_DIR/../install.sh" --uninstall "$@"
|
|
24
24
|
;;
|
|
25
|
+
prune)
|
|
26
|
+
shift
|
|
27
|
+
exec bash "$SCRIPT_DIR/sesh-prune.sh" "$@"
|
|
28
|
+
;;
|
|
29
|
+
costs)
|
|
30
|
+
shift
|
|
31
|
+
exec bash "$SCRIPT_DIR/sesh-costs.sh" "$@"
|
|
32
|
+
;;
|
|
33
|
+
retitle)
|
|
34
|
+
shift
|
|
35
|
+
exec bash "$SCRIPT_DIR/sesh-retitle.sh" "$@"
|
|
36
|
+
;;
|
|
25
37
|
esac
|
|
26
38
|
exec "$SCRIPT_DIR/sesh.sh" "$@"
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Per-directory cost digest: what each project spent in a recent window and
|
|
3
|
+
# over its lifetime, so a day's work reads as a per-project record.
|
|
4
|
+
#
|
|
5
|
+
# Cost is summed from assistant-message `cost` (not `session.cost`) so a
|
|
6
|
+
# partially-completed day does not drag a session's whole history into the
|
|
7
|
+
# window — the same reason the TUI statusline differs from `session.cost`.
|
|
8
|
+
#
|
|
9
|
+
# Usage: sesh-costs.sh [--days N] [--json]
|
|
10
|
+
# --days N window in days (default 1); 0 means lifetime only
|
|
11
|
+
# --json machine-readable rows
|
|
12
|
+
set -euo pipefail
|
|
13
|
+
|
|
14
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
15
|
+
OPENCODE_BIN=${SESH_OPENCODE:-opencode}
|
|
16
|
+
days=1
|
|
17
|
+
emit_json=0
|
|
18
|
+
while [ "$#" -gt 0 ]; do
|
|
19
|
+
case "$1" in
|
|
20
|
+
--days) days=${2:-}; shift ;;
|
|
21
|
+
--days=*) days=${1#--days=} ;;
|
|
22
|
+
--json) emit_json=1 ;;
|
|
23
|
+
--help) echo "usage: ${0##*/} [--days N] [--json]"; exit 0 ;;
|
|
24
|
+
*) echo "unknown option: $1" >&2; exit 2 ;;
|
|
25
|
+
esac
|
|
26
|
+
shift
|
|
27
|
+
done
|
|
28
|
+
case "$days" in ''|*[!0-9]*) echo "--days must be a non-negative integer" >&2; exit 2;; esac
|
|
29
|
+
|
|
30
|
+
resolve_db() {
|
|
31
|
+
if [ -n "${SESH_DB:-}" ]; then printf '%s\n' "$SESH_DB"; return 0; fi
|
|
32
|
+
local from_cli=''
|
|
33
|
+
if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
|
|
34
|
+
from_cli=$("$OPENCODE_BIN" db path 2>/dev/null || true)
|
|
35
|
+
fi
|
|
36
|
+
if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
|
|
37
|
+
if [ -f "$HOME/.local/share/opencode/opencode.db" ]; then printf '%s\n' "$HOME/.local/share/opencode/opencode.db"; return 0; fi
|
|
38
|
+
return 1
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
SQLITE_BIN=''
|
|
42
|
+
if [ -n "${SESH_SQLITE:-}" ]; then
|
|
43
|
+
SQLITE_BIN=$SESH_SQLITE
|
|
44
|
+
elif command -v sqlite3 >/dev/null 2>&1; then
|
|
45
|
+
SQLITE_BIN=sqlite3
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
DB_PATH=$(resolve_db) || { echo 'sesh: session database unavailable' >&2; exit 1; }
|
|
49
|
+
[ -f "$DB_PATH" ] || { echo 'sesh: session database unavailable' >&2; exit 1; }
|
|
50
|
+
|
|
51
|
+
NOW_MS=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || echo "$(( $(date +%s) * 1000 ))")
|
|
52
|
+
CUTOFF=$((NOW_MS - days * 86400000))
|
|
53
|
+
|
|
54
|
+
# One pass over assistant messages; the window filter is a cheap CASE so the
|
|
55
|
+
# lifetime total and the window total come back together.
|
|
56
|
+
ROWS_QUERY="
|
|
57
|
+
SELECT s.directory AS directory,
|
|
58
|
+
ROUND(SUM(CASE WHEN m.time_created >= $CUTOFF THEN COALESCE(json_extract(m.data, '\$.cost'), 0) ELSE 0 END), 4) AS window_cost,
|
|
59
|
+
ROUND(SUM(COALESCE(json_extract(m.data, '\$.cost'), 0)), 4) AS lifetime_cost,
|
|
60
|
+
COUNT(DISTINCT s.id) AS sessions,
|
|
61
|
+
SUM(CASE WHEN m.time_created >= $CUTOFF THEN COALESCE(json_extract(m.data, '\$.tokens.input'), 0) + COALESCE(json_extract(m.data, '\$.tokens.output'), 0) ELSE 0 END) AS window_tokens
|
|
62
|
+
FROM message m
|
|
63
|
+
JOIN session s ON s.id = m.session_id
|
|
64
|
+
WHERE json_extract(m.data, '\$.role') = 'assistant'
|
|
65
|
+
AND json_extract(m.data, '\$.cost') IS NOT NULL
|
|
66
|
+
GROUP BY s.directory
|
|
67
|
+
ORDER BY lifetime_cost DESC;"
|
|
68
|
+
|
|
69
|
+
if [ -n "$SQLITE_BIN" ]; then
|
|
70
|
+
ROWS=$("$SQLITE_BIN" -json -readonly "$DB_PATH" "$ROWS_QUERY")
|
|
71
|
+
else
|
|
72
|
+
command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'sesh: neither sqlite3 nor opencode is available' >&2; exit 1; }
|
|
73
|
+
ROWS=$("$OPENCODE_BIN" db "$ROWS_QUERY" --format json)
|
|
74
|
+
fi
|
|
75
|
+
[ -n "$ROWS" ] || ROWS='[]'
|
|
76
|
+
|
|
77
|
+
if [ "$emit_json" = 1 ]; then
|
|
78
|
+
printf '%s\n' "$ROWS"
|
|
79
|
+
exit 0
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
JQ_BIN=${SESH_JQ:-$(command -v jq || true)}
|
|
83
|
+
[ -n "$JQ_BIN" ] || { echo 'sesh: jq is required for the table view (or use --json)' >&2; exit 1; }
|
|
84
|
+
|
|
85
|
+
HOME_DIR=${HOME:-}
|
|
86
|
+
printf '%s' "$ROWS" | "$JQ_BIN" -r --arg home "$HOME_DIR" --argjson days "$days" '
|
|
87
|
+
def pretty: if . == null or . == "" then "other"
|
|
88
|
+
elif ($home != "" and startswith($home + "/")) then "~" + .[$home | length:]
|
|
89
|
+
else . end;
|
|
90
|
+
def money:
|
|
91
|
+
if . == 0 then "—"
|
|
92
|
+
else ((.*100 | round | tostring) as $c
|
|
93
|
+
| "$" + (if ($c | length) == 1 then "0.0" + $c
|
|
94
|
+
elif ($c | length) == 2 then "0." + $c
|
|
95
|
+
else $c[0:-2] + "." + $c[-2:] end))
|
|
96
|
+
end;
|
|
97
|
+
(map(select(.lifetime_cost > 0)) | sort_by(-.lifetime_cost)) as $rows
|
|
98
|
+
| if ($rows | length) == 0 then "No assistant cost recorded."
|
|
99
|
+
else
|
|
100
|
+
(["WINDOW" + (if $days == 0 then "" else "(\($days)d)" end), "LIFETIME", "SESS", "DIRECTORY"] | @tsv),
|
|
101
|
+
($rows[] | [((.window_cost) | money), ((.lifetime_cost) | money), (.sessions | tostring), (.directory | pretty)] | @tsv)
|
|
102
|
+
end
|
|
103
|
+
' | column -t -s $'\t'
|
package/bin/sesh-list.sh
CHANGED
|
@@ -157,7 +157,7 @@ render() {
|
|
|
157
157
|
{
|
|
158
158
|
[ -n "$notice" ] && printf '\t\tunknown\t%s%s%s\t\tnotice:action\n' "$YELLOW" "$notice" "$RESET"
|
|
159
159
|
[ -n "$state" ] && [ "$state" != fresh ] && printf '\t\tunknown\t%s%s%s\t\tnotice:live-state\n' "$YELLOW" "$state" "$RESET"
|
|
160
|
-
"$JQ_BIN" -sr --argjson pins "$("${BASH_SOURCE[0]%/*}/sesh-pins.sh" read)" --arg green "$GREEN" --arg cyan "$CYAN" --arg gray "$GRAY" --arg magenta "$MAGENTA" --arg red "$RED" --arg bold "$BOLD" --arg yellow "$YELLOW" --arg reset "$RESET" --arg query "$QUERY" --arg home "$HOME" --arg selected "$SELECTED_ID" '
|
|
160
|
+
"$JQ_BIN" -sr --argjson pins "$("${BASH_SOURCE[0]%/*}/sesh-pins.sh" read)" --arg green "$GREEN" --arg cyan "$CYAN" --arg gray "$GRAY" --arg magenta "$MAGENTA" --arg red "$RED" --arg bold "$BOLD" --arg yellow "$YELLOW" --arg reset "$RESET" --arg query "$QUERY" --arg home "$HOME" --arg cwd "$PWD" --arg selected "$SELECTED_ID" '
|
|
161
161
|
def ago($epoch):
|
|
162
162
|
((now - ($epoch | tonumber)) | floor) as $s
|
|
163
163
|
| if $s < 60 then "now" elif $s < 3600 then "\($s / 60 | floor)m" elif $s < 86400 then "\($s / 3600 | floor)h" else "\($s / 86400 | floor)d" end;
|
|
@@ -181,11 +181,19 @@ render() {
|
|
|
181
181
|
else empty end),
|
|
182
182
|
($matched
|
|
183
183
|
| group_by(.cwd)
|
|
184
|
+
# Search relevance, not just recency: a title hit outranks a
|
|
185
|
+
# transcript-only hit, and the current project rises while a query is
|
|
186
|
+
# active. Both are query-gated so the idle list keeps pure pin+recency
|
|
187
|
+
# ordering (the scope toggle, not this, is what narrows the list).
|
|
184
188
|
| map({cwd: .[0].cwd, updated: (map(.updatedEpoch) | max),
|
|
185
189
|
pinDirectory: (.[0].cwd as $dir | $pins.directories | index($dir) != null),
|
|
186
190
|
pinSession: (any(.[]; .sessionId as $id | $pins.sessions | index($id) != null)),
|
|
187
|
-
|
|
188
|
-
|
|
191
|
+
current: (($q != "") and (.[0].cwd == $cwd)),
|
|
192
|
+
titleHit: (($q != "") and any(.[]; (.title | ascii_downcase | contains($q)))),
|
|
193
|
+
items: (sort_by([(.sessionId as $id | $pins.sessions | index($id) != null),
|
|
194
|
+
(if $q != "" then (.title | ascii_downcase | contains($q)) else false end),
|
|
195
|
+
.updatedEpoch]) | reverse)})
|
|
196
|
+
| sort_by([.pinDirectory, .pinSession, .current, .titleHit, .updated]) | reverse
|
|
189
197
|
| .[] as $g
|
|
190
198
|
| (["", "", "unknown", ($magenta + (if $g.pinDirectory then "★ " else "" end) + ($g.cwd | homepath | gsub("[\u0000-\u001f\u007f-\u009f]";" ")) + $reset), $g.cwd, ("hdr:" + $g.cwd)] | @tsv),
|
|
191
199
|
($g.items | to_entries[] | .key as $i | .value as $s
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Archive (or, with --delete, hard-delete) stale sessions: not updated within
|
|
3
|
+
# the threshold, never pinned, never waiting on the user, never already
|
|
4
|
+
# archived, never a fork child.
|
|
5
|
+
#
|
|
6
|
+
# Archive is the default because it is reversible; hard deletes go through
|
|
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]
|
|
13
|
+
# --older-than Nd|Nh|Nw (bare N means days; default 30d)
|
|
14
|
+
# --dry-run lists candidates and changes nothing
|
|
15
|
+
# without --dry-run, a TTY run confirms first; a non-TTY run needs --yes
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
19
|
+
OPENCODE_BIN=${SESH_OPENCODE:-opencode}
|
|
20
|
+
|
|
21
|
+
older_than=30d
|
|
22
|
+
dry_run=0
|
|
23
|
+
assume_yes=0
|
|
24
|
+
hard_delete=0
|
|
25
|
+
while [ "$#" -gt 0 ]; do
|
|
26
|
+
case "$1" in
|
|
27
|
+
--older-than) older_than=${2:-}; shift ;;
|
|
28
|
+
--older-than=*) older_than=${1#--older-than=} ;;
|
|
29
|
+
--dry-run) dry_run=1 ;;
|
|
30
|
+
--yes|-y) assume_yes=1 ;;
|
|
31
|
+
--delete) hard_delete=1 ;;
|
|
32
|
+
--help) echo "usage: ${0##*/} [--older-than 30d] [--dry-run] [--yes] [--delete]"; exit 0 ;;
|
|
33
|
+
*) echo "unknown option: $1" >&2; exit 2 ;;
|
|
34
|
+
esac
|
|
35
|
+
shift
|
|
36
|
+
done
|
|
37
|
+
|
|
38
|
+
if [[ "$older_than" =~ ^([0-9]+)([dhw])?$ ]]; then
|
|
39
|
+
amount=$((10#${BASH_REMATCH[1]}))
|
|
40
|
+
unit=${BASH_REMATCH[2]:-d}
|
|
41
|
+
[ "$amount" -gt 0 ] || { echo "--older-than must be positive" >&2; exit 2; }
|
|
42
|
+
else
|
|
43
|
+
echo "--older-than takes Nd, Nh or Nw (bare N means days)" >&2
|
|
44
|
+
exit 2
|
|
45
|
+
fi
|
|
46
|
+
case "$unit" in
|
|
47
|
+
h) threshold_ms=$((amount * 3600000)) ;;
|
|
48
|
+
d) threshold_ms=$((amount * 86400000)) ;;
|
|
49
|
+
w) threshold_ms=$((amount * 604800000)) ;;
|
|
50
|
+
esac
|
|
51
|
+
NOW_MS=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || echo "$(( $(date +%s) * 1000 ))")
|
|
52
|
+
CUTOFF=$((NOW_MS - threshold_ms))
|
|
53
|
+
|
|
54
|
+
resolve_db() {
|
|
55
|
+
if [ -n "${SESH_DB:-}" ]; then printf '%s\n' "$SESH_DB"; return 0; fi
|
|
56
|
+
local from_cli=''
|
|
57
|
+
if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
|
|
58
|
+
from_cli=$("$OPENCODE_BIN" db path 2>/dev/null || true)
|
|
59
|
+
fi
|
|
60
|
+
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
|
+
return 1
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
SQLITE_BIN=''
|
|
66
|
+
if [ -n "${SESH_SQLITE:-}" ]; then
|
|
67
|
+
SQLITE_BIN=$SESH_SQLITE
|
|
68
|
+
elif command -v sqlite3 >/dev/null 2>&1; then
|
|
69
|
+
SQLITE_BIN=sqlite3
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
DB_PATH=$(resolve_db) || { echo 'sesh: session database unavailable' >&2; exit 1; }
|
|
73
|
+
[ -f "$DB_PATH" ] || { echo 'sesh: session database unavailable' >&2; exit 1; }
|
|
74
|
+
|
|
75
|
+
if [ -n "$SQLITE_BIN" ]; then
|
|
76
|
+
query() { "$SQLITE_BIN" -json -readonly "$DB_PATH" "$1"; }
|
|
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
|
|
83
|
+
|
|
84
|
+
JQ_BIN=${SESH_JQ:-$(command -v jq || true)}
|
|
85
|
+
[ -n "$JQ_BIN" ] || { echo 'sesh: jq is required' >&2; exit 1; }
|
|
86
|
+
|
|
87
|
+
CANDIDATES=$(query "SELECT id, directory, title, time_updated AS updated FROM session WHERE time_updated < $CUTOFF AND COALESCE(time_archived, 0) = 0 AND parent_id IS NULL ORDER BY time_updated ASC;")
|
|
88
|
+
# sqlite3 -json prints nothing (not []) when no rows match.
|
|
89
|
+
[ -n "$CANDIDATES" ] || CANDIDATES='[]'
|
|
90
|
+
PINS=$("$SCRIPT_DIR/sesh-pins.sh" read 2>/dev/null || printf '{"sessions":[],"directories":[]}')
|
|
91
|
+
WAITING_IDS=$(bash "$SCRIPT_DIR/sesh-waiting.sh" --json 2>/dev/null | "$JQ_BIN" -r '.[].id' || true)
|
|
92
|
+
[ -n "$WAITING_IDS" ] || WAITING_IDS='__none__'
|
|
93
|
+
|
|
94
|
+
ELIGIBLE=$(printf '%s' "$CANDIDATES" | "$JQ_BIN" -c --slurpfile _pins <(printf '%s' "$PINS") --arg waiting "$WAITING_IDS" '
|
|
95
|
+
($waiting | split("\n") | map(select(. != "" and . != "__none__"))) as $waiting_ids
|
|
96
|
+
| [ .[]
|
|
97
|
+
| .id as $id
|
|
98
|
+
| .directory as $dir
|
|
99
|
+
| select($id | test("^ses_[A-Za-z0-9]+$"))
|
|
100
|
+
| select(([$_pins[0].sessions[]] | index($id) | not))
|
|
101
|
+
| select(([$_pins[0].directories[]] | index($dir) | not))
|
|
102
|
+
| select(($waiting_ids | index($id) | not))
|
|
103
|
+
]
|
|
104
|
+
')
|
|
105
|
+
COUNT=$(printf '%s' "$ELIGIBLE" | "$JQ_BIN" -r 'length')
|
|
106
|
+
VERB=Archive
|
|
107
|
+
[ "$hard_delete" = 0 ] || VERB=Delete
|
|
108
|
+
|
|
109
|
+
if [ "$COUNT" = 0 ]; then
|
|
110
|
+
echo "Nothing to prune older than $older_than."
|
|
111
|
+
exit 0
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
printf '%s' "$ELIGIBLE" | "$JQ_BIN" -r --argjson now "$NOW_MS" '
|
|
115
|
+
def ago(ms): ((($now - ms) / 1000) | floor) as $s
|
|
116
|
+
| if $s < 3600 then "\($s / 60 | floor)m"
|
|
117
|
+
elif $s < 86400 then "\($s / 3600 | floor)h"
|
|
118
|
+
else "\($s / 86400 | floor)d" end;
|
|
119
|
+
.[] | "\(.id)\t\(.title // "untitled")\t\(ago(.updated)) ago"
|
|
120
|
+
' | while IFS=$'\t' read -r id title age; do
|
|
121
|
+
printf '%s %s \033[90m%s\033[0m\n' "$id" "$title" "$age"
|
|
122
|
+
done
|
|
123
|
+
|
|
124
|
+
if [ "$dry_run" = 1 ]; then
|
|
125
|
+
printf '%s %s session(s) older than %s (--dry-run: nothing changed).\n' "$VERB" "$COUNT" "$older_than" >&2
|
|
126
|
+
exit 0
|
|
127
|
+
fi
|
|
128
|
+
|
|
129
|
+
if [ "$assume_yes" = 0 ]; then
|
|
130
|
+
if [ -t 0 ]; then
|
|
131
|
+
printf '%s' "$VERB $COUNT session(s) older than $older_than? [y/N] "
|
|
132
|
+
reply=''
|
|
133
|
+
IFS= read -r -n 1 reply || reply=''
|
|
134
|
+
printf '\n'
|
|
135
|
+
case "$reply" in
|
|
136
|
+
y|Y) ;;
|
|
137
|
+
*) echo 'Cancelled; nothing was changed.'; exit 0 ;;
|
|
138
|
+
esac
|
|
139
|
+
else
|
|
140
|
+
echo "Refusing to prune $COUNT session(s): pass --yes when stdin is not a terminal." >&2
|
|
141
|
+
exit 2
|
|
142
|
+
fi
|
|
143
|
+
fi
|
|
144
|
+
|
|
145
|
+
if [ "$hard_delete" = 0 ]; then
|
|
146
|
+
IDS=$(printf '%s' "$ELIGIBLE" | "$JQ_BIN" -r '.[].id')
|
|
147
|
+
IN_LIST=''
|
|
148
|
+
for id in $IDS; do
|
|
149
|
+
case "$id" in
|
|
150
|
+
ses_[A-Za-z0-9]*) IN_LIST="$IN_LIST'$id'," ;;
|
|
151
|
+
*) echo "Refusing: unexpected session id shape." >&2; exit 1 ;;
|
|
152
|
+
esac
|
|
153
|
+
done
|
|
154
|
+
IN_LIST=${IN_LIST%,}
|
|
155
|
+
write_db "UPDATE session SET time_archived = $NOW_MS WHERE id IN ($IN_LIST);"
|
|
156
|
+
echo "Archived $COUNT session(s) older than $older_than."
|
|
157
|
+
else
|
|
158
|
+
command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'sesh: opencode executable is unavailable for deletes.' >&2; exit 1; }
|
|
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
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Give auto-generated session titles a readable first line.
|
|
3
|
+
#
|
|
4
|
+
# opencode titles most sessions itself; a few keep their placeholder
|
|
5
|
+
# ("New session - <ISO>") because they ended before a title was derived. This
|
|
6
|
+
# replaces just those placeholders with the start of the session's first user
|
|
7
|
+
# message, so the row scans like the rest of the list.
|
|
8
|
+
#
|
|
9
|
+
# It only ever touches titles matching the placeholder pattern, and the value
|
|
10
|
+
# it overwrites carries no information (a timestamp), so nothing recoverable is
|
|
11
|
+
# lost. The write is a single direct UPDATE through the same sqlite3-or-
|
|
12
|
+
# `opencode db` path as `sesh prune` — opencode exposes no title endpoint in
|
|
13
|
+
# the CLI, so a direct write is the only route.
|
|
14
|
+
#
|
|
15
|
+
# Usage: sesh-retitle.sh [--dry-run] [--yes] [--max-words N]
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
19
|
+
OPENCODE_BIN=${SESH_OPENCODE:-opencode}
|
|
20
|
+
dry_run=0
|
|
21
|
+
assume_yes=0
|
|
22
|
+
max_words=8
|
|
23
|
+
while [ "$#" -gt 0 ]; do
|
|
24
|
+
case "$1" in
|
|
25
|
+
--dry-run) dry_run=1 ;;
|
|
26
|
+
--yes|-y) assume_yes=1 ;;
|
|
27
|
+
--max-words) max_words=${2:-}; shift ;;
|
|
28
|
+
--max-words=*) max_words=${1#--max-words=} ;;
|
|
29
|
+
--help) echo "usage: ${0##*/} [--dry-run] [--yes] [--max-words N]"; exit 0 ;;
|
|
30
|
+
*) echo "unknown option: $1" >&2; exit 2 ;;
|
|
31
|
+
esac
|
|
32
|
+
shift
|
|
33
|
+
done
|
|
34
|
+
case "$max_words" in ''|*[!0-9]*) echo "--max-words must be a positive integer" >&2; exit 2;; esac
|
|
35
|
+
[ "$max_words" -gt 0 ] || { echo "--max-words must be positive" >&2; exit 2; }
|
|
36
|
+
|
|
37
|
+
resolve_db() {
|
|
38
|
+
if [ -n "${SESH_DB:-}" ]; then printf '%s\n' "$SESH_DB"; return 0; fi
|
|
39
|
+
local from_cli=''
|
|
40
|
+
if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
|
|
41
|
+
from_cli=$("$OPENCODE_BIN" db path 2>/dev/null || true)
|
|
42
|
+
fi
|
|
43
|
+
if [ -n "$from_cli" ] && [ -f "$from_cli" ]; then printf '%s\n' "$from_cli"; return 0; fi
|
|
44
|
+
if [ -f "$HOME/.local/share/opencode/opencode.db" ]; then printf '%s\n' "$HOME/.local/share/opencode/opencode.db"; return 0; fi
|
|
45
|
+
return 1
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
SQLITE_BIN=''
|
|
49
|
+
if [ -n "${SESH_SQLITE:-}" ]; then
|
|
50
|
+
SQLITE_BIN=$SESH_SQLITE
|
|
51
|
+
elif command -v sqlite3 >/dev/null 2>&1; then
|
|
52
|
+
SQLITE_BIN=sqlite3
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
DB_PATH=$(resolve_db) || { echo 'sesh: session database unavailable' >&2; exit 1; }
|
|
56
|
+
[ -f "$DB_PATH" ] || { echo 'sesh: session database unavailable' >&2; exit 1; }
|
|
57
|
+
|
|
58
|
+
if [ -n "$SQLITE_BIN" ]; then
|
|
59
|
+
query() { "$SQLITE_BIN" -json -readonly "$DB_PATH" "$1"; }
|
|
60
|
+
write_db() { "$SQLITE_BIN" "$DB_PATH" "$1"; }
|
|
61
|
+
else
|
|
62
|
+
command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'sesh: neither sqlite3 nor opencode is available' >&2; exit 1; }
|
|
63
|
+
query() { "$OPENCODE_BIN" db "$1" --format json; }
|
|
64
|
+
write_db() { "$OPENCODE_BIN" db "$1" --format json > /dev/null; }
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
JQ_BIN=${SESH_JQ:-$(command -v jq || true)}
|
|
68
|
+
[ -n "$JQ_BIN" ] || { echo 'sesh: jq is required' >&2; exit 1; }
|
|
69
|
+
|
|
70
|
+
# The first user text part per placeholder-titled session, cleaned into a
|
|
71
|
+
# candidate: image chips and markdown noise dropped, whitespace collapsed,
|
|
72
|
+
# first `max_words` words kept.
|
|
73
|
+
CANDIDATES=$(query "
|
|
74
|
+
SELECT s.id AS id, s.directory AS directory, s.title AS old_title,
|
|
75
|
+
(SELECT p.data FROM part p
|
|
76
|
+
JOIN message m ON m.id = p.message_id
|
|
77
|
+
WHERE p.session_id = s.id
|
|
78
|
+
AND json_extract(p.data, '\$.type') = 'text'
|
|
79
|
+
AND json_extract(m.data, '\$.role') = 'user'
|
|
80
|
+
ORDER BY p.time_created ASC LIMIT 1) AS first_part
|
|
81
|
+
FROM session s
|
|
82
|
+
WHERE s.title LIKE 'New session - %'
|
|
83
|
+
OR TRIM(COALESCE(s.title, '')) = ''
|
|
84
|
+
ORDER BY s.time_updated DESC;")
|
|
85
|
+
[ -n "$CANDIDATES" ] || CANDIDATES='[]'
|
|
86
|
+
|
|
87
|
+
PLAN=$(printf '%s' "$CANDIDATES" | "$JQ_BIN" -c --argjson words "$max_words" '
|
|
88
|
+
def derive:
|
|
89
|
+
(.first_part // "")
|
|
90
|
+
| (try (fromjson | .text) catch "")
|
|
91
|
+
| gsub("\\[Image #[0-9]+\\]"; " ")
|
|
92
|
+
| gsub("[#*_`>]"; " ")
|
|
93
|
+
| gsub("\\s+"; " ")
|
|
94
|
+
| sub("^ +"; "") | sub(" +$"; "")
|
|
95
|
+
| (split(" ") | map(select(. != "")))
|
|
96
|
+
| (if length == 0 then "" else .[0:$words] | join(" ") end)
|
|
97
|
+
| if length > 60 then .[0:57] + "..." else . end;
|
|
98
|
+
[ .[] | .new_title = derive | select(.new_title != "") ]')
|
|
99
|
+
COUNT=$(printf '%s' "$PLAN" | "$JQ_BIN" -r 'length')
|
|
100
|
+
|
|
101
|
+
if [ "$COUNT" = 0 ]; then
|
|
102
|
+
echo "No placeholder-titled sessions with a usable first message."
|
|
103
|
+
exit 0
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
printf '%s' "$PLAN" | "$JQ_BIN" -r '
|
|
107
|
+
.[] | "\(.id)\t\(.new_title)\t\(.old_title)"' | while IFS=$'\t' read -r id new old; do
|
|
108
|
+
printf '%s\n \033[90m%s\033[0m\n → %s\n' "$id" "$old" "$new"
|
|
109
|
+
done
|
|
110
|
+
|
|
111
|
+
if [ "$dry_run" = 1 ]; then
|
|
112
|
+
printf 'Retitle %s session(s) (--dry-run: nothing changed).\n' "$COUNT" >&2
|
|
113
|
+
exit 0
|
|
114
|
+
fi
|
|
115
|
+
|
|
116
|
+
if [ "$assume_yes" = 0 ]; then
|
|
117
|
+
if [ -t 0 ]; then
|
|
118
|
+
printf 'Retitle %s session(s)? [y/N] ' "$COUNT"
|
|
119
|
+
reply=''
|
|
120
|
+
IFS= read -r -n 1 reply || reply=''
|
|
121
|
+
printf '\n'
|
|
122
|
+
case "$reply" in
|
|
123
|
+
y|Y) ;;
|
|
124
|
+
*) echo 'Cancelled; nothing was changed.'; exit 0 ;;
|
|
125
|
+
esac
|
|
126
|
+
else
|
|
127
|
+
echo "Refusing to retitle $COUNT session(s): pass --yes when stdin is not a terminal." >&2
|
|
128
|
+
exit 2
|
|
129
|
+
fi
|
|
130
|
+
fi
|
|
131
|
+
|
|
132
|
+
# Escape single quotes for the SQL string literal; ids are validated by shape.
|
|
133
|
+
sql_escape() { printf '%s' "$1" | sed "s/'/''/g"; }
|
|
134
|
+
printf '%s' "$PLAN" | "$JQ_BIN" -r '.[] | [.id, .new_title] | @tsv' | while IFS=$'\t' read -r id new; do
|
|
135
|
+
case "$id" in
|
|
136
|
+
ses_[A-Za-z0-9]*) ;;
|
|
137
|
+
*) echo "Refusing: unexpected session id shape." >&2; exit 1 ;;
|
|
138
|
+
esac
|
|
139
|
+
write_db "UPDATE session SET title = '$(sql_escape "$new")' WHERE id = '$id' AND (title LIKE 'New session - %' OR TRIM(COALESCE(title, '')) = '');"
|
|
140
|
+
done
|
|
141
|
+
echo "Retitled $COUNT session(s)."
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# List sessions waiting on the user: unanswered agent questions and runs
|
|
3
|
+
# stuck with a tool still marked running. Server-independent: it reads the
|
|
4
|
+
# opencode SQLite store directly, so it sees every session — not just ones
|
|
5
|
+
# owned by a running TUI server, whose in-memory permission/question state
|
|
6
|
+
# is invisible cross-project.
|
|
7
|
+
#
|
|
8
|
+
# A session waits when it is not archived, is not a fork child, and either
|
|
9
|
+
# - has a `question` tool part that never completed with no later user
|
|
10
|
+
# message in the same session (awaiting an answer, errored included), or
|
|
11
|
+
# - has a `tool` part still marked `running` older than STUCK_AFTER_MS
|
|
12
|
+
# (awaiting approval, or orphaned by a dead server).
|
|
13
|
+
#
|
|
14
|
+
# Usage: sesh-waiting.sh [--json]
|
|
15
|
+
# default prints a human table; --json prints [{id,reason,title,directory,updated}]
|
|
16
|
+
# Keep NEEDS_INPUT_SQL in sync with the TUI sidebar copy in tui/sesh-panel.tsx.
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
|
|
19
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
20
|
+
OPENCODE_BIN=${SESH_OPENCODE:-opencode}
|
|
21
|
+
JSON=0
|
|
22
|
+
while [ "$#" -gt 0 ]; do
|
|
23
|
+
case "$1" in
|
|
24
|
+
--json) JSON=1 ;;
|
|
25
|
+
--help) echo "usage: ${0##*/} [--json]"; exit 0 ;;
|
|
26
|
+
*) echo "unknown option: $1" >&2; exit 2 ;;
|
|
27
|
+
esac
|
|
28
|
+
shift
|
|
29
|
+
done
|
|
30
|
+
|
|
31
|
+
resolve_db() {
|
|
32
|
+
if [ -n "${SESH_DB:-}" ]; then printf '%s\n' "$SESH_DB"; return 0; fi
|
|
33
|
+
local from_cli=''
|
|
34
|
+
if command -v "$OPENCODE_BIN" >/dev/null 2>&1; then
|
|
35
|
+
from_cli=$("$OPENCODE_BIN" db path 2>/dev/null || true)
|
|
36
|
+
fi
|
|
37
|
+
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
|
+
return 1
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
SQLITE_BIN=''
|
|
43
|
+
if [ -n "${SESH_SQLITE:-}" ]; then
|
|
44
|
+
SQLITE_BIN=$SESH_SQLITE
|
|
45
|
+
elif command -v sqlite3 >/dev/null 2>&1; then
|
|
46
|
+
SQLITE_BIN=sqlite3
|
|
47
|
+
fi
|
|
48
|
+
|
|
49
|
+
DB_PATH=$(resolve_db) || { echo 'sesh: session database unavailable' >&2; exit 1; }
|
|
50
|
+
[ -f "$DB_PATH" ] || { echo 'sesh: session database unavailable' >&2; exit 1; }
|
|
51
|
+
|
|
52
|
+
NOW_MS=$(python3 -c 'import time; print(int(time.time() * 1000))' 2>/dev/null || date +%s000)
|
|
53
|
+
STUCK_BEFORE=$((NOW_MS - 600000))
|
|
54
|
+
|
|
55
|
+
# NEEDS_INPUT_SQL (shared heuristic — keep in sync with tui/sesh-panel.tsx).
|
|
56
|
+
read -r -d '' QUERY <<SQL || true
|
|
57
|
+
SELECT s.id AS id, s.directory AS directory, s.title AS title, s.time_updated AS updated,
|
|
58
|
+
CASE WHEN EXISTS (
|
|
59
|
+
SELECT 1 FROM part p
|
|
60
|
+
WHERE p.session_id = s.id
|
|
61
|
+
AND json_extract(p.data, '\$.type') = 'tool'
|
|
62
|
+
AND json_extract(p.data, '\$.tool') = 'question'
|
|
63
|
+
AND COALESCE(json_extract(p.data, '\$.state.status'), 'pending') != 'completed'
|
|
64
|
+
AND NOT EXISTS (
|
|
65
|
+
SELECT 1 FROM message m
|
|
66
|
+
WHERE m.session_id = s.id
|
|
67
|
+
AND json_extract(m.data, '\$.role') = 'user'
|
|
68
|
+
AND m.time_created > p.time_created)
|
|
69
|
+
) THEN 'question' ELSE 'stuck' END AS reason
|
|
70
|
+
FROM session s
|
|
71
|
+
WHERE COALESCE(s.time_archived, 0) = 0
|
|
72
|
+
AND s.parent_id IS NULL
|
|
73
|
+
AND (EXISTS (
|
|
74
|
+
SELECT 1 FROM part p
|
|
75
|
+
WHERE p.session_id = s.id
|
|
76
|
+
AND json_extract(p.data, '\$.type') = 'tool'
|
|
77
|
+
AND json_extract(p.data, '\$.tool') = 'question'
|
|
78
|
+
AND COALESCE(json_extract(p.data, '\$.state.status'), 'pending') != 'completed'
|
|
79
|
+
AND NOT EXISTS (
|
|
80
|
+
SELECT 1 FROM message m
|
|
81
|
+
WHERE m.session_id = s.id
|
|
82
|
+
AND json_extract(m.data, '\$.role') = 'user'
|
|
83
|
+
AND m.time_created > p.time_created)
|
|
84
|
+
) OR EXISTS (
|
|
85
|
+
SELECT 1 FROM part p2
|
|
86
|
+
WHERE p2.session_id = s.id
|
|
87
|
+
AND json_extract(p2.data, '\$.type') = 'tool'
|
|
88
|
+
AND json_extract(p2.data, '\$.state.status') = 'running'
|
|
89
|
+
AND p2.time_created < $STUCK_BEFORE))
|
|
90
|
+
ORDER BY s.time_updated DESC;
|
|
91
|
+
SQL
|
|
92
|
+
|
|
93
|
+
if [ -n "$SQLITE_BIN" ]; then
|
|
94
|
+
# Read-only: sesh never writes the store on this path (archive writes go
|
|
95
|
+
# through `opencode db`, hard deletes through `opencode session delete`).
|
|
96
|
+
ROWS=$("$SQLITE_BIN" -json -readonly "$DB_PATH" "$QUERY")
|
|
97
|
+
else
|
|
98
|
+
command -v "$OPENCODE_BIN" >/dev/null 2>&1 || { echo 'sesh: neither sqlite3 nor opencode is available' >&2; exit 1; }
|
|
99
|
+
ROWS=$("$OPENCODE_BIN" db "$QUERY" --format json)
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
# sqlite3 -json prints nothing (not []) when no rows match.
|
|
103
|
+
[ -n "$ROWS" ] || ROWS='[]'
|
|
104
|
+
|
|
105
|
+
if [ "$JSON" = 1 ]; then
|
|
106
|
+
printf '%s\n' "$ROWS"
|
|
107
|
+
exit 0
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
JQ_BIN=${SESH_JQ:-$(command -v jq || true)}
|
|
111
|
+
[ -n "$JQ_BIN" ] || { echo 'sesh: jq is required for the table view (or use --json)' >&2; exit 1; }
|
|
112
|
+
COUNT=$(printf '%s' "$ROWS" | "$JQ_BIN" -r 'length')
|
|
113
|
+
if [ "$COUNT" = 0 ]; then
|
|
114
|
+
echo 'No sessions are waiting on you.'
|
|
115
|
+
exit 0
|
|
116
|
+
fi
|
|
117
|
+
printf '%s' "$ROWS" | "$JQ_BIN" -r --argjson now "$NOW_MS" '
|
|
118
|
+
def ago(ms): ((($now - ms) / 1000) | floor) as $s
|
|
119
|
+
| if $s < 60 then "now"
|
|
120
|
+
elif $s < 3600 then "\($s / 60 | floor)m"
|
|
121
|
+
elif $s < 86400 then "\($s / 3600 | floor)h"
|
|
122
|
+
else "\($s / 86400 | floor)d" end;
|
|
123
|
+
def reason(t): if t == "question" then "awaiting answer" else "run stuck" end;
|
|
124
|
+
.[] | "\(.id)\t\(reason(.reason))\t\(.title // "untitled")\t\(.directory)\t\(ago(.updated)) ago"
|
|
125
|
+
' | while IFS=$'\t' read -r id reason title dir age; do
|
|
126
|
+
printf '%s \033[33m%s\033[0m %s \033[90m%s · %s\033[0m\n' "$id" "$reason" "$title" "$dir" "$age"
|
|
127
|
+
done
|
package/bin/sesh.sh
CHANGED
|
@@ -54,6 +54,17 @@ if [ "${1:-}" = --check ]; then
|
|
|
54
54
|
exit 0
|
|
55
55
|
fi
|
|
56
56
|
|
|
57
|
+
# Triage listing, not the picker: sessions with unanswered agent questions
|
|
58
|
+
# or runs stuck mid-tool. Never starts fzf or the refresh worker.
|
|
59
|
+
if [ "${1:-}" = --needs-input ]; then
|
|
60
|
+
shift
|
|
61
|
+
case "${1:-}" in
|
|
62
|
+
'') exec bash "$SCRIPT_DIR/sesh-waiting.sh" ;;
|
|
63
|
+
--json) exec bash "$SCRIPT_DIR/sesh-waiting.sh" --json ;;
|
|
64
|
+
*) echo "usage: sesh --needs-input [--json]" >&2; exit 2 ;;
|
|
65
|
+
esac
|
|
66
|
+
fi
|
|
67
|
+
|
|
57
68
|
scope_value=''
|
|
58
69
|
picker_limit=0
|
|
59
70
|
picker_archived=0
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# sesh ↔ cmux
|
|
2
|
+
|
|
3
|
+
Optional glue for running sesh inside [cmux](https://cmux.dev). Nothing here is
|
|
4
|
+
bundled into the picker: sesh has no window-management code, and these recipes
|
|
5
|
+
keep it that way. cmux calls sesh; sesh never calls cmux.
|
|
6
|
+
|
|
7
|
+
## Command palette + shortcuts
|
|
8
|
+
|
|
9
|
+
Merge [`cmux.json.snippet`](cmux.json.snippet) into `~/.config/cmux/cmux.json`
|
|
10
|
+
(or `.cmux/cmux.json` in a project), then `cmux reload-config` (Cmd+Shift+,).
|
|
11
|
+
You get:
|
|
12
|
+
|
|
13
|
+
| Action | Shortcut | What it does |
|
|
14
|
+
| --- | --- | --- |
|
|
15
|
+
| `sesh.picker` | Cmd+Shift+S | Open the picker in a new tab |
|
|
16
|
+
| `sesh.needs` | Cmd+Shift+I | List sessions waiting on you |
|
|
17
|
+
| `sesh.costs` | — | Per-project spend digest |
|
|
18
|
+
|
|
19
|
+
Paste only the actions you want; the `ui.newWorkspace` block is optional.
|
|
20
|
+
|
|
21
|
+
## Resume a session in its own cmux workspace
|
|
22
|
+
|
|
23
|
+
The picker resumes in place (it `cd`s and `exec`s opencode). To instead open a
|
|
24
|
+
session in a **new cmux workspace** at its directory, shell out to cmux after
|
|
25
|
+
resolving the session with sesh's scriptable lookup:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
sesh --print --json --query "auth refactor" \
|
|
29
|
+
| jq -r '[.sessionId, .cwd] | @tsv' \
|
|
30
|
+
| while IFS=$'\t' read -r id cwd; do
|
|
31
|
+
cmux new-workspace --cwd "$cwd" --command "opencode --session $id"
|
|
32
|
+
done
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Wrap that in a cmux `command` action (as above) or a shell function; it is
|
|
36
|
+
caller-side on purpose, so sesh stays terminal-agnostic.
|
|
37
|
+
|
|
38
|
+
## Needs-input triage from cmux
|
|
39
|
+
|
|
40
|
+
`sesh --needs-input` prints (or `--json` emits) the sessions with unanswered
|
|
41
|
+
agent questions or runs stuck mid-tool. Bind it to a shortcut and it becomes
|
|
42
|
+
the "what is blocked on me right now" view without opening the picker.
|
|
43
|
+
|
|
44
|
+
## Global hotkey (resume from anywhere)
|
|
45
|
+
|
|
46
|
+
sesh is a normal CLI, so any system-wide launcher works — a cmux `shortcut`, an
|
|
47
|
+
`skhd` binding, a Raycast/Hammerspoon script:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
# skhd example: Cmd+Shift+S opens the picker in a new cmux workspace
|
|
51
|
+
cmd + shift - s : cmux new-workspace --command "sesh"
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Because `--print --json` resolves a session non-interactively, the same hook can
|
|
55
|
+
resume a specific session without any UI:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
cmd + alt - r : cmux new-workspace --command "opencode --session $(sesh --print --json --limit 1 | jq -r .sessionId)"
|
|
59
|
+
```
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"actions": {
|
|
3
|
+
"sesh.picker": {
|
|
4
|
+
"type": "command",
|
|
5
|
+
"title": "sesh — session picker",
|
|
6
|
+
"subtitle": "Fuzzy-find and resume any opencode session",
|
|
7
|
+
"command": "sesh",
|
|
8
|
+
"target": "newTabInCurrentPane",
|
|
9
|
+
"palette": true,
|
|
10
|
+
"shortcut": "cmd+shift+s"
|
|
11
|
+
},
|
|
12
|
+
"sesh.needs": {
|
|
13
|
+
"type": "command",
|
|
14
|
+
"title": "sesh — needs input",
|
|
15
|
+
"subtitle": "Sessions waiting on you (questions, stuck runs)",
|
|
16
|
+
"command": "sesh --needs-input",
|
|
17
|
+
"target": "newTabInCurrentPane",
|
|
18
|
+
"palette": true,
|
|
19
|
+
"shortcut": "cmd+shift+i"
|
|
20
|
+
},
|
|
21
|
+
"sesh.costs": {
|
|
22
|
+
"type": "command",
|
|
23
|
+
"title": "sesh — cost digest",
|
|
24
|
+
"subtitle": "Spend per project, today and lifetime",
|
|
25
|
+
"command": "sesh costs",
|
|
26
|
+
"target": "newTabInCurrentPane",
|
|
27
|
+
"palette": true
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"ui": {
|
|
31
|
+
"newWorkspace": {
|
|
32
|
+
"contextMenu": [
|
|
33
|
+
{ "action": "sesh.picker", "title": "Resume a session" },
|
|
34
|
+
{ "type": "separator" }
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
package/install.sh
CHANGED
|
@@ -235,8 +235,12 @@ for path in \
|
|
|
235
235
|
bin/sesh.sh \
|
|
236
236
|
bin/sesh-list.sh \
|
|
237
237
|
bin/sesh-preview.sh \
|
|
238
|
+
bin/sesh-costs.sh \
|
|
238
239
|
bin/sesh-delete.sh \
|
|
240
|
+
bin/sesh-prune.sh \
|
|
239
241
|
bin/sesh-refresh-worker.sh \
|
|
242
|
+
bin/sesh-retitle.sh \
|
|
243
|
+
bin/sesh-waiting.sh \
|
|
240
244
|
bin/sesh-shortcuts.sh \
|
|
241
245
|
opencode/tools/sesh-list.ts \
|
|
242
246
|
tui/sesh-panel.tsx \
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-sesh",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Browse, search, preview and resume opencode sessions from the terminal and the TUI.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -26,12 +26,14 @@
|
|
|
26
26
|
},
|
|
27
27
|
"files": [
|
|
28
28
|
"bin/",
|
|
29
|
+
"contrib/",
|
|
29
30
|
"opencode/",
|
|
30
31
|
"themes/",
|
|
31
32
|
"tui/",
|
|
32
33
|
"install.sh",
|
|
33
34
|
"AGENTS.md",
|
|
34
|
-
"CHANGELOG.md"
|
|
35
|
+
"CHANGELOG.md",
|
|
36
|
+
"ROADMAP.md"
|
|
35
37
|
],
|
|
36
38
|
"publishConfig": {
|
|
37
39
|
"access": "public"
|
package/tui/sesh-panel.tsx
CHANGED
|
@@ -326,6 +326,68 @@ async function openTranscriptDb(): Promise<any | undefined> {
|
|
|
326
326
|
}
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
+
// NEEDS_INPUT_SQL: sessions waiting on the user (shared heuristic — keep in
|
|
330
|
+
// sync with bin/sesh-waiting.sh). A session waits when it is not archived,
|
|
331
|
+
// is not a fork child, and either has an unanswered `question` tool part or
|
|
332
|
+
// a `tool` part still marked running older than STUCK_AFTER_MS (awaiting
|
|
333
|
+
// approval, or orphaned by a dead server). Server-independent on purpose:
|
|
334
|
+
// the TUI sync layer only sees permission/question state for sessions owned
|
|
335
|
+
// by the current server, which is invisible cross-project.
|
|
336
|
+
const STUCK_AFTER_MS = 600000
|
|
337
|
+
const NEEDS_INPUT_SQL = (stuckBefore: number) => `
|
|
338
|
+
SELECT s.id AS id,
|
|
339
|
+
CASE WHEN EXISTS (
|
|
340
|
+
SELECT 1 FROM part p
|
|
341
|
+
WHERE p.session_id = s.id
|
|
342
|
+
AND json_extract(p.data, '$.type') = 'tool'
|
|
343
|
+
AND json_extract(p.data, '$.tool') = 'question'
|
|
344
|
+
AND COALESCE(json_extract(p.data, '$.state.status'), 'pending') != 'completed'
|
|
345
|
+
AND NOT EXISTS (
|
|
346
|
+
SELECT 1 FROM message m
|
|
347
|
+
WHERE m.session_id = s.id
|
|
348
|
+
AND json_extract(m.data, '$.role') = 'user'
|
|
349
|
+
AND m.time_created > p.time_created)
|
|
350
|
+
) THEN 'question' ELSE 'stuck' END AS reason
|
|
351
|
+
FROM session s
|
|
352
|
+
WHERE COALESCE(s.time_archived, 0) = 0
|
|
353
|
+
AND s.parent_id IS NULL
|
|
354
|
+
AND (EXISTS (
|
|
355
|
+
SELECT 1 FROM part p
|
|
356
|
+
WHERE p.session_id = s.id
|
|
357
|
+
AND json_extract(p.data, '$.type') = 'tool'
|
|
358
|
+
AND json_extract(p.data, '$.tool') = 'question'
|
|
359
|
+
AND COALESCE(json_extract(p.data, '$.state.status'), 'pending') != 'completed'
|
|
360
|
+
AND NOT EXISTS (
|
|
361
|
+
SELECT 1 FROM message m
|
|
362
|
+
WHERE m.session_id = s.id
|
|
363
|
+
AND json_extract(m.data, '$.role') = 'user'
|
|
364
|
+
AND m.time_created > p.time_created)
|
|
365
|
+
) OR EXISTS (
|
|
366
|
+
SELECT 1 FROM part p2
|
|
367
|
+
WHERE p2.session_id = s.id
|
|
368
|
+
AND json_extract(p2.data, '$.type') = 'tool'
|
|
369
|
+
AND json_extract(p2.data, '$.state.status') = 'running'
|
|
370
|
+
AND p2.time_created < ${stuckBefore}))
|
|
371
|
+
ORDER BY s.time_updated DESC`
|
|
372
|
+
|
|
373
|
+
async function queryWaitingIds(db: any): Promise<Map<string, string>> {
|
|
374
|
+
const rows = (db.query(NEEDS_INPUT_SQL(Date.now() - STUCK_AFTER_MS)).all() ?? []) as {
|
|
375
|
+
id: unknown
|
|
376
|
+
reason: unknown
|
|
377
|
+
}[]
|
|
378
|
+
const out = new Map<string, string>()
|
|
379
|
+
for (const row of rows) {
|
|
380
|
+
if (
|
|
381
|
+
typeof row?.id === "string" &&
|
|
382
|
+
SESSION_ID_PATTERN.test(row.id) &&
|
|
383
|
+
(row.reason === "question" || row.reason === "stuck")
|
|
384
|
+
) {
|
|
385
|
+
out.set(row.id, row.reason)
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
return out
|
|
389
|
+
}
|
|
390
|
+
|
|
329
391
|
function addTranscript(index: Map<string, string>, sid: string, data: string): void {
|
|
330
392
|
try {
|
|
331
393
|
const part = JSON.parse(data) as { type?: unknown; text?: unknown }
|
|
@@ -597,6 +659,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
597
659
|
const [navActive, setNavActive] = createSignal(false)
|
|
598
660
|
const [cursor, setCursor] = createSignal(0)
|
|
599
661
|
const [pendingDelete, setPendingDelete] = createSignal<string>()
|
|
662
|
+
const [waiting, setWaiting] = createSignal<Map<string, string>>(new Map())
|
|
600
663
|
let confirmTimer: ReturnType<typeof setTimeout> | undefined
|
|
601
664
|
const currentID = createMemo(() => {
|
|
602
665
|
const route = props.api.route.current
|
|
@@ -620,6 +683,19 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
620
683
|
// say so when there is nothing at all to show
|
|
621
684
|
if (alive && entries().length === 0) setLoadFailed(true)
|
|
622
685
|
}
|
|
686
|
+
try {
|
|
687
|
+
const db = await openTranscriptDb()
|
|
688
|
+
if (db) {
|
|
689
|
+
try {
|
|
690
|
+
const ids = await queryWaitingIds(db)
|
|
691
|
+
if (alive) setWaiting(ids)
|
|
692
|
+
} finally {
|
|
693
|
+
db.close()
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
} catch {
|
|
697
|
+
// leave the previous waiting set in place rather than flashing
|
|
698
|
+
}
|
|
623
699
|
}
|
|
624
700
|
void load()
|
|
625
701
|
const clock = setInterval(() => void load(), POLL_MS)
|
|
@@ -641,9 +717,27 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
641
717
|
const shownEntries = createMemo(() => filteredEntries())
|
|
642
718
|
const remaining = createMemo(() => Math.max(0, filteredEntries().length - shownEntries().length))
|
|
643
719
|
|
|
720
|
+
const NEEDS_INPUT_DIR = "__needs_input__"
|
|
721
|
+
const waitingEntries = createMemo(() => {
|
|
722
|
+
const ids = waiting()
|
|
723
|
+
return shownEntries().filter((entry) => ids.has(entry.id))
|
|
724
|
+
})
|
|
725
|
+
const waitingReason = (id: string) => (waiting().get(id) === "question" ? "awaiting answer" : "run stuck")
|
|
726
|
+
|
|
644
727
|
const tree = createMemo<TreeRow[]>(() => {
|
|
728
|
+
const rows: TreeRow[] = []
|
|
729
|
+
const pending = waitingEntries()
|
|
730
|
+
if (pending.length > 0) {
|
|
731
|
+
rows.push({ kind: "group", dir: NEEDS_INPUT_DIR, label: "Needs input", count: pending.length })
|
|
732
|
+
if (!collapsed()[NEEDS_INPUT_DIR]) {
|
|
733
|
+
for (let i = 0; i < pending.length; i++) {
|
|
734
|
+
rows.push({ kind: "item", entry: pending[i], last: i === pending.length - 1 })
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
645
738
|
const groups = new Map<string, Entry[]>()
|
|
646
739
|
for (const entry of shownEntries()) {
|
|
740
|
+
if (waiting().has(entry.id)) continue
|
|
647
741
|
const list = groups.get(entry.dir) ?? []
|
|
648
742
|
list.push(entry)
|
|
649
743
|
groups.set(entry.dir, list)
|
|
@@ -656,7 +750,6 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
656
750
|
pins.directories.includes(group.dir) ? 2 : group.list.some((entry) => pins.sessions.includes(entry.id)) ? 1 : 0
|
|
657
751
|
return rank(b) - rank(a) || b.updated - a.updated
|
|
658
752
|
})
|
|
659
|
-
const rows: TreeRow[] = []
|
|
660
753
|
for (const group of ordered) {
|
|
661
754
|
rows.push({ kind: "group", dir: group.dir, label: prettyDir(group.dir, home), count: group.list.length })
|
|
662
755
|
if (collapsed()[group.dir]) continue
|
|
@@ -961,6 +1054,9 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
961
1054
|
{query().trim() ? ` (${filteredEntries().length}/${entries().length})` : ` (${entries().length})`}
|
|
962
1055
|
</span>
|
|
963
1056
|
<span style={{ fg: theme().accent }}>{navActive() ? " · nav" : ""}</span>
|
|
1057
|
+
<Show when={waitingEntries().length > 0}>
|
|
1058
|
+
<span style={{ fg: theme().warning }}> · {waitingEntries().length} need input</span>
|
|
1059
|
+
</Show>
|
|
964
1060
|
</text>
|
|
965
1061
|
<text flexShrink={0} style={{ fg: theme().textMuted }} onMouseDown={toggleSection}>
|
|
966
1062
|
{sectionCollapsed() ? "▸ show" : "▾ hide"}
|
|
@@ -1065,7 +1161,15 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
|
|
|
1065
1161
|
matchColor={theme().warning}
|
|
1066
1162
|
/>
|
|
1067
1163
|
<text flexShrink={0} style={{ fg: isPendingDelete(row.entry.id) ? theme().error : theme().textMuted }}>
|
|
1068
|
-
{isPendingDelete(row.entry.id) ?
|
|
1164
|
+
{isPendingDelete(row.entry.id) ? (
|
|
1165
|
+
"ctrl+x again"
|
|
1166
|
+
) : waiting().has(row.entry.id) ? (
|
|
1167
|
+
<span>
|
|
1168
|
+
{ago(row.entry.updated)} · <span style={{ fg: theme().warning }}>{waitingReason(row.entry.id)}</span>
|
|
1169
|
+
</span>
|
|
1170
|
+
) : (
|
|
1171
|
+
ago(row.entry.updated)
|
|
1172
|
+
)}
|
|
1069
1173
|
</text>
|
|
1070
1174
|
</box>
|
|
1071
1175
|
)
|