opencode-sesh 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -35,7 +35,12 @@ 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. Reads go through
41
+ `sqlite3 -readonly` (or `opencode db`); the single archive `UPDATE` in
42
+ `sesh-prune.sh` is the only sanctioned direct write (no archive endpoint
43
+ exists — deletes stay on `opencode session delete`).
39
44
  - `tui/sesh-panel.tsx` — SolidJS panel, `/** @jsxImportSource @opentui/solid */`.
40
45
  - `opencode/tools/sesh-list.ts` — the agent-facing `sesh-list` tool.
41
46
  - `scripts/changelog.sh` — release tooling: drafts `[Unreleased]` from
@@ -135,6 +140,9 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
135
140
  a row and ctrl+x deletes one (armed, then confirmed). The section lists every
136
141
  session without a row cap and flex-grows to fill the sidebar, with overflow
137
142
  scrolling inside; the picker remains the place for transcript search.
143
+ Sessions waiting on the user (unanswered questions, stuck runs) pin a
144
+ **Needs input** group above the directories, with the count in the heading —
145
+ same `NEEDS_INPUT_SQL` heuristic as `bin/sesh-waiting.sh`.
138
146
  - **Picker:** a custom dialog (not `DialogSelect`) grouped by
139
147
  project/directory, recency-ordered, resume via
140
148
  `api.route.navigate("session", …)`. `ctrl+o` and the command palette open it;
package/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-09-24
11
+
12
+ ### Added
13
+
14
+ - Needs-input triage: sessions with unanswered agent questions or runs stuck
15
+ mid-tool pin a **Needs input** group above the sidebar directories (count in
16
+ the heading), and `sesh --needs-input [--json]` lists the same set in the
17
+ terminal. Detection reads the opencode store directly, so it works for every
18
+ session, not just ones owned by the current server.
19
+ - `sesh prune [--older-than 30d] [--dry-run] [--yes] [--delete]` archives stale
20
+ sessions so the list stays triageable. Pinned sessions, sessions waiting on
21
+ you, fork children and already-archived sessions are never touched;
22
+ archiving is reversible, `--delete` hard-deletes through the opencode CLI,
23
+ and non-TTY runs need `--yes`.
10
24
  ## [0.2.1] - 2026-09-24
11
25
 
12
26
  ### Changed
@@ -225,7 +239,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
225
239
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
226
240
  CI.
227
241
 
228
- [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.2.1...HEAD
242
+ [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.3.0...HEAD
243
+ [0.3.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.2.1...v0.3.0
229
244
  [0.2.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.2.0...v0.2.1
230
245
  [0.2.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.7...v0.2.0
231
246
  [0.1.7]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.1.6...v0.1.7
package/README.md CHANGED
@@ -127,7 +127,15 @@ 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`.
131
139
 
132
140
  Because `--print` just emits the id and directory, `sesh` doubles as a scriptable
133
141
  session lookup:
@@ -162,6 +170,9 @@ the list from the keyboard instead of the mouse (`↑`/`↓` to move, Enter to o
162
170
  Ctrl-P to preview, Option-S/Option-D to pin, Ctrl-X to delete, Esc to leave). Pinned
163
171
  sessions appear ahead of the rest; the section lists every session and scrolls
164
172
  to fill the sidebar, so click **▾ hide** to collapse it when you need the room.
173
+ Sessions waiting on you (unanswered agent questions, runs stuck mid-tool) pin
174
+ themselves to a **Needs input** group above the directories, with the count in
175
+ the heading; `sesh --needs-input` lists the same set in the terminal.
165
176
  Hovering a row while keyboard navigation is active selects it for the next
166
177
  shortcut. The full picker is one keystroke away.
167
178
 
package/bin/sesh CHANGED
@@ -22,5 +22,9 @@ 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
+ ;;
25
29
  esac
26
30
  exec "$SCRIPT_DIR/sesh.sh" "$@"
@@ -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,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
package/install.sh CHANGED
@@ -236,7 +236,9 @@ for path in \
236
236
  bin/sesh-list.sh \
237
237
  bin/sesh-preview.sh \
238
238
  bin/sesh-delete.sh \
239
+ bin/sesh-prune.sh \
239
240
  bin/sesh-refresh-worker.sh \
241
+ bin/sesh-waiting.sh \
240
242
  bin/sesh-shortcuts.sh \
241
243
  opencode/tools/sesh-list.ts \
242
244
  tui/sesh-panel.tsx \
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "0.2.1",
3
+ "version": "0.3.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",
@@ -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) ? "ctrl+x again" : ago(row.entry.updated)}
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
  )