opencode-sesh 0.4.0 → 0.6.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
@@ -147,6 +147,17 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
147
147
  Sessions waiting on the user (unanswered questions, stuck runs) pin a
148
148
  **Needs input** group above the directories, with the count in the heading —
149
149
  same `NEEDS_INPUT_SQL` heuristic as `bin/sesh-waiting.sh`.
150
+ - **Presence / click-to-focus (cmux-only).** Each sidebar pane heartbeats
151
+ `{workspace, surface, updated}` to the session file
152
+ `${XDG_DATA_HOME:-~/.local/share}/sesh/presence/<sessionId>.json` every poll,
153
+ but only when `CMUX_WORKSPACE_ID` is set; without it nothing is written and a
154
+ click always navigates locally. Opening a session reads that file and, when a
155
+ *fresh* (≤45 s), *foreign* workspace claims it, runs
156
+ `cmux workspace select <id>` instead of `api.route.navigate`. One file per
157
+ session means concurrent panes never share a file; writes are tmp+rename and
158
+ stale files are swept on each poll. A crashed pane falls through to a local
159
+ open after the TTL. The dispatch lives in the sidebar only — the picker and
160
+ home list always open locally.
150
161
  - **Picker:** a custom dialog (not `DialogSelect`) grouped by
151
162
  project/directory, recency-ordered, resume via
152
163
  `api.route.navigate("session", …)`. `ctrl+o` and the command palette open it;
@@ -179,7 +190,9 @@ Resume happens in place (`exec` after `cd` to the session's cwd).
179
190
  `slash: { name: "sesh" }` on the `api.command.register` entry — that is the
180
191
  only way to open the dialog. A markdown command under `commands/` cannot drive
181
192
  the picker (it just prompts the model), so never ship one; the installer
182
- removes any leftover `commands/sesh.md`.
193
+ removes any leftover `commands/sesh.md`. The command API never passes slash
194
+ arguments, so subcommands are separate registrations (`/sesh-costs`,
195
+ `/sesh-needs`) — all read-only. Destructive actions stay CLI-only.
183
196
  - Do not bind `<leader>l` (native `session_list`) or shadow the native
184
197
  `/sessions` command.
185
198
  - **The sidebar never binds arrow keys globally.** Keyboard navigation is
package/CHANGELOG.md CHANGED
@@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-09-24
11
+
12
+ ### Added
13
+
14
+ - Clicking (or Enter on) a sidebar session now switches to the live cmux
15
+ workspace already showing it, instead of opening a duplicate in the current
16
+ pane. Panes advertise their open session (cmux-only, one heartbeat file per
17
+ session, ~45 s TTL); outside cmux or with no live claimant, the click opens
18
+ the session locally as before.
19
+ ## [0.5.0] - 2026-09-24
20
+
21
+ ### Added
22
+
23
+ - `/sesh-costs` opens the per-project cost digest inside the TUI (24h beside
24
+ lifetime, same message-level sums as `sesh costs`).
25
+ - `/sesh-needs` opens the sessions waiting on you as an openable list (Enter
26
+ opens, Esc closes). Both new slashes are read-only; prune and retitle stay
27
+ CLI-only.
28
+
10
29
  ## [0.4.0] - 2026-09-24
11
30
 
12
31
  ### Added
@@ -258,7 +277,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
258
277
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
259
278
  CI.
260
279
 
261
- [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.4.0...HEAD
280
+ [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.6.0...HEAD
281
+ [0.6.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.5.0...v0.6.0
282
+ [0.5.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.4.0...v0.5.0
262
283
  [0.4.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.3.0...v0.4.0
263
284
  [0.3.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.2.1...v0.3.0
264
285
  [0.2.1]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.2.0...v0.2.1
package/README.md CHANGED
@@ -165,6 +165,8 @@ non-interactive `sesh-delete.sh` refuses unless given `--yes`.
165
165
  | Key | Action |
166
166
  | --- | --- |
167
167
  | `/sesh` | Open the full picker |
168
+ | `/sesh-costs` | Per-project cost digest (24h beside lifetime) |
169
+ | `/sesh-needs` | Sessions waiting on you (Enter opens) |
168
170
  | `ctrl+o` | Open the full picker (also in the command palette) |
169
171
  | Type | Search titles, directories and transcript text |
170
172
  | `↑`/`↓`, `PgUp`/`PgDn`, `Home`/`End` | Move the selection |
@@ -186,7 +188,10 @@ Sessions waiting on you (unanswered agent questions, runs stuck mid-tool) pin
186
188
  themselves to a **Needs input** group above the directories, with the count in
187
189
  the heading; `sesh --needs-input` lists the same set in the terminal.
188
190
  Hovering a row while keyboard navigation is active selects it for the next
189
- shortcut. The full picker is one keystroke away.
191
+ shortcut. Clicking (or Enter on) a session opens it here — unless a live cmux
192
+ workspace is already showing that session, in which case the sidebar focuses
193
+ that workspace instead of opening a duplicate. The full picker is one
194
+ keystroke away.
190
195
 
191
196
  The full picker pages through your whole global session list (no fixed window)
192
197
  and indexes transcript text for every session in the background, showing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-sesh",
3
- "version": "0.4.0",
3
+ "version": "0.6.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",
@@ -2,8 +2,10 @@
2
2
  import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
3
3
  import type { Project, Session } from "@opencode-ai/sdk/v2"
4
4
  import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
5
- import { mkdir, readFile, rename, rmdir, unlink, writeFile } from "node:fs/promises"
5
+ import { mkdir, readFile, readdir, rename, rmdir, unlink, writeFile } from "node:fs/promises"
6
6
  import { dirname, join } from "node:path"
7
+ import { execFile } from "node:child_process"
8
+ import { promisify } from "node:util"
7
9
  import { getTreeSitterClient, RGBA, SyntaxStyle, TextAttributes } from "@opentui/core"
8
10
 
9
11
  type ThemeColors = TuiPluginApi["theme"]["current"]
@@ -388,6 +390,51 @@ async function queryWaitingIds(db: any): Promise<Map<string, string>> {
388
390
  return out
389
391
  }
390
392
 
393
+ // Presence registry: which cmux workspace is showing which session, so opening
394
+ // a session that is already visible elsewhere focuses that workspace instead
395
+ // of opening a duplicate here. Each panel heartbeats its own session file
396
+ // (one file per session: concurrent panes never share a file, and renames are
397
+ // atomic, so no locking is needed); files older than PRESENCE_TTL_MS are
398
+ // treated as gone. Outside cmux nothing is written and nothing is read.
399
+ const PRESENCE_TTL_MS = 45000
400
+ function presenceDir(): string {
401
+ const home = process.env.HOME ?? ""
402
+ const dataHome = process.env.XDG_DATA_HOME ?? `${home}/.local/share`
403
+ return join(dataHome, "sesh", "presence")
404
+ }
405
+ function parsePresence(raw: string, now: number): string | undefined {
406
+ let parsed: { workspace?: unknown; updated?: unknown }
407
+ try {
408
+ parsed = JSON.parse(raw)
409
+ } catch {
410
+ return undefined
411
+ }
412
+ if (typeof parsed?.workspace !== "string" || !parsed.workspace) return undefined
413
+ if (typeof parsed?.updated !== "number" || now - parsed.updated > PRESENCE_TTL_MS) return undefined
414
+ return parsed.workspace
415
+ }
416
+ async function sweepPresence(): Promise<void> {
417
+ try {
418
+ const dir = presenceDir()
419
+ const now = Date.now()
420
+ const names = await readdir(dir)
421
+ await Promise.all(
422
+ names
423
+ .filter((name) => name.endsWith(".json"))
424
+ .map(async (name) => {
425
+ try {
426
+ const raw = await readFile(join(dir, name), "utf8")
427
+ if (!parsePresence(raw, now)) await unlink(join(dir, name))
428
+ } catch {
429
+ // ignore races with other panes
430
+ }
431
+ }),
432
+ )
433
+ } catch {
434
+ // missing directory or concurrent cleanup; presence is best-effort
435
+ }
436
+ }
437
+
391
438
  function addTranscript(index: Map<string, string>, sid: string, data: string): void {
392
439
  try {
393
440
  const part = JSON.parse(data) as { type?: unknown; text?: unknown }
@@ -696,6 +743,30 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
696
743
  } catch {
697
744
  // leave the previous waiting set in place rather than flashing
698
745
  }
746
+ // Heartbeat this pane's session so another pane can focus it instead of
747
+ // opening a duplicate. cmux-only: without CMUX_WORKSPACE_ID nothing is
748
+ // written and opening a session always navigates locally.
749
+ try {
750
+ const workspace = process.env.CMUX_WORKSPACE_ID
751
+ const id = currentID()
752
+ if (workspace && id && SESSION_ID_PATTERN.test(id)) {
753
+ const dir = presenceDir()
754
+ await mkdir(dir, { recursive: true })
755
+ const tmp = join(dir, `${id}.tmp`)
756
+ await writeFile(
757
+ tmp,
758
+ JSON.stringify({
759
+ workspace,
760
+ surface: process.env.CMUX_SURFACE_ID ?? null,
761
+ updated: Date.now(),
762
+ }),
763
+ )
764
+ await rename(tmp, join(dir, `${id}.json`))
765
+ }
766
+ } catch {
767
+ // presence is best-effort; a failed heartbeat only disables focusing
768
+ }
769
+ if (process.env.CMUX_WORKSPACE_ID) void sweepPresence()
699
770
  }
700
771
  void load()
701
772
  const clock = setInterval(() => void load(), POLL_MS)
@@ -774,6 +845,31 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
774
845
  setSectionCollapsed((value) => !value)
775
846
  }
776
847
 
848
+ // Open a session. If a live cmux workspace is already showing it, focus that
849
+ // workspace instead of opening a duplicate here. The heartbeat self-entry is
850
+ // ignored (it is this pane); a stale file (crashed pane) falls through to a
851
+ // local navigate. Only the sidebar goes through here.
852
+ const openSession = async (entry: Entry) => {
853
+ const selfWorkspace = process.env.CMUX_WORKSPACE_ID
854
+ if (selfWorkspace) {
855
+ try {
856
+ const raw = await readFile(join(presenceDir(), `${entry.id}.json`), "utf8")
857
+ const workspace = parsePresence(raw, Date.now())
858
+ if (workspace && workspace !== selfWorkspace) {
859
+ try {
860
+ await promisify(execFile)("cmux", ["workspace", "select", workspace])
861
+ return
862
+ } catch {
863
+ // cmux unavailable; fall through to a local open
864
+ }
865
+ }
866
+ } catch {
867
+ // no presence file for this session
868
+ }
869
+ }
870
+ props.api.route.navigate("session", { sessionID: entry.id })
871
+ }
872
+
777
873
  // Flat order of the rows a cursor can land on, so keyboard navigation and
778
874
  // mouse hover resolve to the same row.
779
875
  const itemRows = createMemo(() => tree().flatMap((row) => (row.kind === "item" ? [row.entry] : [])))
@@ -1007,7 +1103,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1007
1103
  preventDefault: true,
1008
1104
  cmd: () => {
1009
1105
  const entry = itemRows()[cursor()]
1010
- if (entry) props.api.route.navigate("session", { sessionID: entry.id })
1106
+ if (entry) void openSession(entry)
1011
1107
  },
1012
1108
  },
1013
1109
  {
@@ -1138,7 +1234,7 @@ function SidebarSessions(props: { api: TuiPluginApi } & PinProps) {
1138
1234
  }
1139
1235
  }}
1140
1236
  onMouseOut={() => setHovered(undefined)}
1141
- onMouseDown={() => props.api.route.navigate("session", { sessionID: row.entry.id })}
1237
+ onMouseDown={() => void openSession(row.entry)}
1142
1238
  >
1143
1239
  <text flexShrink={0}>
1144
1240
  <span style={{ fg: theme().textMuted }}>{row.last ? "└" : "├"}</span>
@@ -1906,6 +2002,249 @@ const tui: TuiPlugin = async (api) => {
1906
2002
  api.ui.dialog.setSize("xlarge")
1907
2003
  }
1908
2004
 
2005
+ // Read-only digest dialog: spend per project, last day beside lifetime.
2006
+ // Same message-level sums as `sesh costs`; the dialog just renders them.
2007
+ const openCosts = async () => {
2008
+ if (api.mode.current() !== BASE_MODE) return
2009
+ if (api.renderer.currentFocusedEditor === null) return
2010
+ type CostRow = { directory: string; window: number; lifetime: number; sessions: number }
2011
+ let rows: CostRow[] = []
2012
+ let failed = false
2013
+ try {
2014
+ const db = await openTranscriptDb()
2015
+ if (!db) failed = true
2016
+ else {
2017
+ try {
2018
+ const cutoff = Date.now() - 86400000
2019
+ const raw = (db.query(`
2020
+ SELECT s.directory AS directory,
2021
+ ROUND(SUM(CASE WHEN m.time_created >= ${cutoff} THEN COALESCE(json_extract(m.data, '$.cost'), 0) ELSE 0 END), 4) AS window_cost,
2022
+ ROUND(SUM(COALESCE(json_extract(m.data, '$.cost'), 0)), 4) AS lifetime_cost,
2023
+ COUNT(DISTINCT s.id) AS sessions
2024
+ FROM message m
2025
+ JOIN session s ON s.id = m.session_id
2026
+ WHERE json_extract(m.data, '$.role') = 'assistant'
2027
+ AND json_extract(m.data, '$.cost') IS NOT NULL
2028
+ GROUP BY s.directory
2029
+ ORDER BY lifetime_cost DESC`).all() ?? []) as {
2030
+ directory: unknown
2031
+ window_cost: unknown
2032
+ lifetime_cost: unknown
2033
+ sessions: unknown
2034
+ }[]
2035
+ rows = raw
2036
+ .filter(
2037
+ (
2038
+ row,
2039
+ ): row is {
2040
+ directory: string
2041
+ window_cost: number
2042
+ lifetime_cost: number
2043
+ sessions: number
2044
+ } =>
2045
+ typeof row?.directory === "string" &&
2046
+ typeof row?.window_cost === "number" &&
2047
+ typeof row?.lifetime_cost === "number" &&
2048
+ typeof row?.sessions === "number" &&
2049
+ row.lifetime_cost > 0,
2050
+ )
2051
+ .map((row) => ({
2052
+ directory: row.directory,
2053
+ window: row.window_cost,
2054
+ lifetime: row.lifetime_cost,
2055
+ sessions: row.sessions,
2056
+ }))
2057
+ } finally {
2058
+ db.close()
2059
+ }
2060
+ }
2061
+ } catch {
2062
+ failed = true
2063
+ }
2064
+ const money = (n: number) => (n === 0 ? "—" : `$${n.toFixed(2)}`)
2065
+ const home = process.env.HOME ?? ""
2066
+ const disposeKeys = api.keymap.registerLayer({
2067
+ bindings: [
2068
+ { key: "escape", desc: "Close", preventDefault: true, cmd: () => api.ui.dialog.clear() },
2069
+ ],
2070
+ })
2071
+ api.ui.dialog.replace(
2072
+ () => (
2073
+ <box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1}>
2074
+ <text attributes={TextAttributes.BOLD}>
2075
+ Cost digest{" "}
2076
+ <span style={{ fg: api.theme.current.textMuted }}>(24h · lifetime)</span>
2077
+ </text>
2078
+ <Show
2079
+ when={!failed && rows.length > 0}
2080
+ fallback={
2081
+ <text style={{ fg: api.theme.current.textMuted }}>
2082
+ {failed ? "Cost data unavailable." : "No assistant cost recorded."}
2083
+ </text>
2084
+ }
2085
+ >
2086
+ <For each={rows}>
2087
+ {(row) => (
2088
+ <box flexDirection="row" gap={2}>
2089
+ <text flexShrink={0} style={{ fg: api.theme.current.success }}>
2090
+ {money(row.window)}
2091
+ </text>
2092
+ <text flexShrink={0} style={{ fg: api.theme.current.textMuted }}>
2093
+ {money(row.lifetime)}
2094
+ </text>
2095
+ <text flexShrink={0} style={{ fg: api.theme.current.textMuted }}>
2096
+ ({row.sessions})
2097
+ </text>
2098
+ <text flexGrow={1} flexShrink={1} wrapMode="none">
2099
+ {prettyDir(row.directory, home)}
2100
+ </text>
2101
+ </box>
2102
+ )}
2103
+ </For>
2104
+ </Show>
2105
+ <box flexShrink={0}>
2106
+ <text style={{ fg: api.theme.current.textMuted }}>esc close</text>
2107
+ </box>
2108
+ </box>
2109
+ ),
2110
+ () => {
2111
+ disposeKeys()
2112
+ },
2113
+ )
2114
+ api.ui.dialog.setSize("large")
2115
+ }
2116
+
2117
+ // Triage dialog: the waiting set as an openable list. Enter opens the
2118
+ // highlighted session; the sidebar section stays the always-visible view.
2119
+ const openNeeds = async () => {
2120
+ if (api.mode.current() !== BASE_MODE) return
2121
+ if (api.renderer.currentFocusedEditor === null) return
2122
+ let items: { entry: Entry; reason: string }[] = []
2123
+ try {
2124
+ const { entries } = await fetchEntries(api)
2125
+ const db = await openTranscriptDb()
2126
+ let waiting = new Map<string, string>()
2127
+ if (db) {
2128
+ try {
2129
+ waiting = await queryWaitingIds(db)
2130
+ } finally {
2131
+ db.close()
2132
+ }
2133
+ }
2134
+ items = entries
2135
+ .filter((entry) => waiting.has(entry.id))
2136
+ .map((entry) => ({
2137
+ entry,
2138
+ reason: waiting.get(entry.id) === "question" ? "awaiting answer" : "run stuck",
2139
+ }))
2140
+ } catch {
2141
+ api.ui.toast({ message: "Could not load sessions", variant: "error" })
2142
+ return
2143
+ }
2144
+ if (items.length === 0) {
2145
+ api.ui.toast({ message: "No sessions are waiting on you.", variant: "info" })
2146
+ return
2147
+ }
2148
+ const [cursor, setCursor] = createSignal(0)
2149
+ const moveCursor = (delta: number) =>
2150
+ setCursor((value) => Math.max(0, Math.min(items.length - 1, value + delta)))
2151
+ const choose = () => {
2152
+ const target = items[cursor()]
2153
+ if (!target) return
2154
+ disposeNav()
2155
+ api.ui.dialog.clear()
2156
+ api.route.navigate("session", { sessionID: target.entry.id })
2157
+ }
2158
+ const disposeNav = api.keymap.registerLayer({
2159
+ bindings: [
2160
+ { key: "up", desc: "Previous session", preventDefault: true, cmd: () => moveCursor(-1) },
2161
+ { key: "down", desc: "Next session", preventDefault: true, cmd: () => moveCursor(1) },
2162
+ { key: "enter", desc: "Open session", preventDefault: true, cmd: () => choose() },
2163
+ {
2164
+ key: "escape",
2165
+ desc: "Close",
2166
+ preventDefault: true,
2167
+ cmd: () => api.ui.dialog.clear(),
2168
+ },
2169
+ ],
2170
+ })
2171
+ const home = process.env.HOME ?? ""
2172
+ api.ui.dialog.replace(
2173
+ () => (
2174
+ <box flexDirection="column" paddingLeft={4} paddingRight={4} paddingTop={1} gap={1}>
2175
+ <text attributes={TextAttributes.BOLD}>
2176
+ Needs input{" "}
2177
+ <span style={{ fg: api.theme.current.textMuted }}>({items.length})</span>
2178
+ </text>
2179
+ <box flexDirection="column">
2180
+ <For each={items}>
2181
+ {(item, index) => (
2182
+ <box
2183
+ flexDirection="row"
2184
+ gap={2}
2185
+ paddingLeft={1}
2186
+ paddingRight={1}
2187
+ backgroundColor={
2188
+ index() === cursor()
2189
+ ? api.theme.current.primary
2190
+ : RGBA.fromInts(0, 0, 0, 0)
2191
+ }
2192
+ onMouseDown={() => {
2193
+ setCursor(index())
2194
+ choose()
2195
+ }}
2196
+ >
2197
+ <text
2198
+ flexGrow={1}
2199
+ flexShrink={1}
2200
+ wrapMode="none"
2201
+ style={{
2202
+ fg:
2203
+ index() === cursor()
2204
+ ? api.theme.current.selectedListItemText
2205
+ : api.theme.current.text,
2206
+ }}
2207
+ >
2208
+ {truncate(item.entry.title, 48)}
2209
+ </text>
2210
+ <text
2211
+ flexShrink={0}
2212
+ style={{
2213
+ fg:
2214
+ index() === cursor()
2215
+ ? api.theme.current.selectedListItemText
2216
+ : api.theme.current.warning,
2217
+ }}
2218
+ >
2219
+ {item.reason}
2220
+ </text>
2221
+ <text
2222
+ flexShrink={0}
2223
+ style={{
2224
+ fg:
2225
+ index() === cursor()
2226
+ ? api.theme.current.selectedListItemText
2227
+ : api.theme.current.textMuted,
2228
+ }}
2229
+ >
2230
+ {prettyDir(item.entry.dir, home)} · {ago(item.entry.updated)}
2231
+ </text>
2232
+ </box>
2233
+ )}
2234
+ </For>
2235
+ </box>
2236
+ <box flexShrink={0}>
2237
+ <text style={{ fg: api.theme.current.textMuted }}>↑↓ navigate · enter open · esc close</text>
2238
+ </box>
2239
+ </box>
2240
+ ),
2241
+ () => {
2242
+ disposeNav()
2243
+ },
2244
+ )
2245
+ api.ui.dialog.setSize("large")
2246
+ }
2247
+
1909
2248
  api.command?.register(() => [
1910
2249
  {
1911
2250
  title: "Pick session (all directories)",
@@ -1917,6 +2256,26 @@ const tui: TuiPlugin = async (api) => {
1917
2256
  void openPicker()
1918
2257
  },
1919
2258
  },
2259
+ {
2260
+ title: "Cost digest (per project)",
2261
+ value: "sesh.costs",
2262
+ description: "Spend per project, last day and lifetime",
2263
+ category: "Sessions",
2264
+ slash: { name: "sesh-costs" },
2265
+ onSelect: () => {
2266
+ void openCosts()
2267
+ },
2268
+ },
2269
+ {
2270
+ title: "Sessions waiting on you",
2271
+ value: "sesh.needs",
2272
+ description: "Unanswered questions and stuck runs",
2273
+ category: "Sessions",
2274
+ slash: { name: "sesh-needs" },
2275
+ onSelect: () => {
2276
+ void openNeeds()
2277
+ },
2278
+ },
1920
2279
  ])
1921
2280
 
1922
2281
  api.keymap.registerLayer({