opencode-sesh 0.5.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;
package/CHANGELOG.md CHANGED
@@ -7,6 +7,15 @@ 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.
10
19
  ## [0.5.0] - 2026-09-24
11
20
 
12
21
  ### Added
@@ -16,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
16
25
  - `/sesh-needs` opens the sessions waiting on you as an openable list (Enter
17
26
  opens, Esc closes). Both new slashes are read-only; prune and retitle stay
18
27
  CLI-only.
28
+
19
29
  ## [0.4.0] - 2026-09-24
20
30
 
21
31
  ### Added
@@ -267,7 +277,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
267
277
  - Fixture-database regression suite, TypeScript typechecking, and ShellCheck in
268
278
  CI.
269
279
 
270
- [Unreleased]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.5.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
271
282
  [0.5.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.4.0...v0.5.0
272
283
  [0.4.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.3.0...v0.4.0
273
284
  [0.3.0]: https://github.com/ozandogrultan/opencode-sesh/compare/v0.2.1...v0.3.0
package/README.md CHANGED
@@ -188,7 +188,10 @@ Sessions waiting on you (unanswered agent questions, runs stuck mid-tool) pin
188
188
  themselves to a **Needs input** group above the directories, with the count in
189
189
  the heading; `sesh --needs-input` lists the same set in the terminal.
190
190
  Hovering a row while keyboard navigation is active selects it for the next
191
- 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.
192
195
 
193
196
  The full picker pages through your whole global session list (no fixed window)
194
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.5.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>